In this tutorial, we'll develop a web app for selling books using Stripe (for payment processing), Vue.js (the client-side app), and Flask (the server-side API).
This is an intermediate-level tutorial. It assumes that you a have basic working knowledge of Vue and Flask. Review the following resources for more info:
Final app:
Main dependencies:
- Vue v3.2.47
- Node v20.0.0
- npm v9.6.4
- Flask v2.2.3
- Python v3.11.3
Contents
Objectives
By the end of this tutorial, you will be able to:
- Work with an existing CRUD app, powered by Vue and Flask
- Create an order checkout Vue component
- Validate credit card information and process payments with Stripe Checkout
Project Setup
Clone the base Flask and Vue project from the flask-vue-crud repo:
$ git clone https://github.com/testdrivenio/flask-vue-crud flask-vue-stripe
$ cd flask-vue-stripe
Create and activate a virtual environment, and then spin up the Flask app:
$ cd server
$ python3.11 -m venv env
$ source env/bin/activate
(env)$
(env)$ pip install -r requirements.txt
(env)$ flask run --port=5001 --debug
The above commands, for creating and activating a virtual environment, may differ depending on your environment and operating system. Feel free to swap out virtualenv and Pip for Poetry or Pipenv. For more, review Modern Python Environments.
Point your browser of choice at http://localhost:5001/ping. You should see:
"pong!"
Then, install the dependencies and run the Vue app in a different terminal window:
$ cd client
$ npm install
$ npm run dev
Navigate to http://localhost:5173. Make sure the basic CRUD functionality works as expected:
Want to learn how to build this project? Check out the Developing a Single Page App with Flask and Vue.js tutorial.
What are we building?
Our goal is to build a web app that allows end users to purchase books.
The client-side Vue app will display the books available for purchase and redirect the end user to the checkout form via Stripe.js and Stripe Checkout. After the payment process is complete, users will be redirected to either a success or failure page also managed by Vue.
The Flask app, meanwhile, uses the Stripe Python Library for interacting with the Stripe API to create a checkout session.
Like the previous tutorial, Developing a Single Page App with Flask and Vue.js, we'll only be dealing with the happy path through the app. Check your understanding by incorporating proper error-handling on your own.
Books CRUD
First, let's add a purchase price to the existing list of books on the server-side and update the appropriate CRUD functions on the client -- GET, POST, and PUT.
GET
Start by adding the price
to each dict in the BOOKS
list in server/app.py:
BOOKS = [
{
'id': uuid.uuid4().hex,
'title': 'On the Road',
'author': 'Jack Kerouac',
'read': True,
'price': '19.99'
},
{
'id': uuid.uuid4().hex,
'title': 'Harry Potter and the Philosopher\'s Stone',
'author': 'J. K. Rowling',
'read': False,
'price': '9.99'
},
{
'id': uuid.uuid4().hex,
'title': 'Green Eggs and Ham',
'author': 'Dr. Seuss',
'read': True,
'price': '3.99'
}
]
Then, update the table in the Books
component, client/src/components/Books.vue, to display the purchase price:
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Read?</th>
<th scope="col">Purchase Price</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(book, index) in books" :key="index">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>
<span v-if="book.read">Yes</span>
<span v-else>No</span>
</td>
<td>${{ book.price }}</td>
<td>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-warning btn-sm"
@click="toggleEditBookModal(book)">
Update
</button>
<button
type="button"
class="btn btn-danger btn-sm"
@click="handleDeleteBook(book)">
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
You should now see:
POST
Add a new form input to addBookModal
, between the author and read form inputs:
<div class="mb-3">
<label for="addBookPrice" class="form-label">Purchase price:</label>
<input
type="number"
step="0.01"
class="form-control"
id="addBookPrice"
v-model="addBookForm.price"
placeholder="Enter price">
</div>
The modal should now look like:
<!-- add new book modal -->
<div
ref="addBookModal"
class="modal fade"
:class="{ show: activeAddBookModal, 'd-block': activeAddBookModal }"
tabindex="-1"
role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add a new book</h5>
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
@click="toggleAddBookModal">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form>
<div class="mb-3">
<label for="addBookTitle" class="form-label">Title:</label>
<input
type="text"
class="form-control"
id="addBookTitle"
v-model="addBookForm.title"
placeholder="Enter title">
</div>
<div class="mb-3">
<label for="addBookAuthor" class="form-label">Author:</label>
<input
type="text"
class="form-control"
id="addBookAuthor"
v-model="addBookForm.author"
placeholder="Enter author">
</div>
<div class="mb-3">
<label for="addBookPrice" class="form-label">Purchase price:</label>
<input
type="number"
step="0.01"
class="form-control"
id="addBookPrice"
v-model="addBookForm.price"
placeholder="Enter price">
</div>
<div class="mb-3 form-check">
<input
type="checkbox"
class="form-check-input"
id="addBookRead"
v-model="addBookForm.read">
<label class="form-check-label" for="addBookRead">Read?</label>
</div>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-primary btn-sm"
@click="handleAddSubmit">
Submit
</button>
<button
type="button"
class="btn btn-danger btn-sm"
@click="handleAddReset">
Reset
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div v-if="activeAddBookModal" class="modal-backdrop fade show"></div>
Then, add price
to the state:
addBookForm: {
title: '',
author: '',
read: [],
price: '',
},
The state is now bound to the form's input value. Think about what this means. When the state is updated, the form input will be updated as well -- and vice versa. Here's an example of this in action with the vue-devtools browser extension:
Add the price
to the payload
in the handleAddSubmit
method like so:
handleAddSubmit() {
this.toggleAddBookModal();
let read = false;
if (this.addBookForm.read[0]) {
read = true;
}
const payload = {
title: this.addBookForm.title,
author: this.addBookForm.author,
read, // property shorthand
price: this.addBookForm.price,
};
this.addBook(payload);
this.initForm();
},
Update initForm
to clear out the value after the end user submits the form or clicks the "reset" button:
initForm() {
this.addBookForm.title = '';
this.addBookForm.author = '';
this.addBookForm.read = [];
this.addBookForm.price = '';
this.editBookForm.id = '';
this.editBookForm.title = '';
this.editBookForm.author = '';
this.editBookForm.read = [];
},
Finally, update the route in server/app.py:
@app.route('/books', methods=['GET', 'POST'])
def all_books():
response_object = {'status': 'success'}
if request.method == 'POST':
post_data = request.get_json()
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read'),
'price': post_data.get('price')
})
response_object['message'] = 'Book added!'
else:
response_object['books'] = BOOKS
return jsonify(response_object)
Test it out!
Don't forget to handle errors on both the client and server!
PUT
Do the same, on your own, for editing a book:
- Add a new form input to the modal
- Update
editBookForm
in the state - Add the
price
to thepayload
in thehandleEditSubmit
method - Update
initForm
- Update the server-side route
Need help? Review the previous section again. You can also grab the final code from the flask-vue-stripe repo.
Purchase Button
Add a "purchase" button to the Books
component, just below the "delete" button:
<td>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-warning btn-sm"
@click="toggleEditBookModal(book)">
Update
</button>
<button
type="button"
class="btn btn-danger btn-sm"
@click="handleDeleteBook(book)">
Delete
</button>
<button
type="button"
class="btn btn-primary btn-sm"
@click="handlePurchaseBook(book)">
Purchase
</button>
</div>
</td>
Next, add handlePurchaseBook
to the component's methods
:
handlePurchaseBook(book) {
console.log(book.id);
},
Test it out:
Stripe Keys
Sign up for a Stripe account, if you don't already have one.
Server
Install the Stripe Python library:
(env)$ pip install stripe==5.4.0
Grab the test mode API keys from the Stripe dashboard:
Set them as environment variables within the terminal window where you're running the server:
(env)$ export STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
(env)$ export STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>
Import the Stripe library into server/app.py and assign the keys to stripe.api_key
so that they will be used automatically when interacting with the API:
import os
import uuid
import stripe
from flask import Flask, jsonify, request
from flask_cors import CORS
...
# configuration
DEBUG = True
# instantiate the app
app = Flask(__name__)
app.config.from_object(__name__)
# configure stripe
stripe_keys = {
'secret_key': os.environ['STRIPE_SECRET_KEY'],
'publishable_key': os.environ['STRIPE_PUBLISHABLE_KEY'],
}
stripe.api_key = stripe_keys['secret_key']
# enable CORS
CORS(app, resources={r'/*': {'origins': '*'}})
...
if __name__ == '__main__':
app.run()
Next, add a new route handler that returns the publishable key:
@app.route('/config')
def get_publishable_key():
stripe_config = {'publicKey': stripe_keys['publishable_key']}
return jsonify(stripe_config)
This will be used on the client side to configure the Stripe.js library.
Client
Turning to the client, add Stripe.js to client/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Next, add a new method to the Books
component called getStripePublishableKey
:
getStripePublishableKey() {
fetch('http://localhost:5001/config')
.then((result) => result.json())
.then((data) => {
// Initialize Stripe.js
this.stripe = Stripe(data.publicKey);
});
},
Call this method in the created
hook:
created() {
this.getBooks();
this.getStripePublishableKey();
},
Now, after the instance is created, a call will be made to http://localhost:5001/config
, which will respond with the Stripe publishable key. We'll then use this key to create a new instance of Stripe.js.
Shipping to production? You'll want to use an environment variable to dynamically set the base server-side URL (which is currently
http://localhost:5001
). Review the docs for more info.
Add stripe
to `the state:
data() {
return {
activeAddBookModal: false,
activeEditBookModal: false,
addBookForm: {
title: '',
author: '',
read: [],
price: '',
},
books: [],
editBookForm: {
id: '',
title: '',
author: '',
read: [],
price: '',
},
message: '',
showMessage: false,
stripe: null,
};
},
Stripe Checkout
Next, we need to generate a new Checkout Session ID on the server-side. After clicking the purchase button, an AJAX request will be sent to the server to generate this ID. The server will send the ID back and the user will be redirected to the checkout.
Server
Add the following route handler:
@app.route('/create-checkout-session', methods=['POST'])
def create_checkout_session():
domain_url = 'http://localhost:5173'
try:
data = json.loads(request.data)
# get book
book_to_purchase = ''
for book in BOOKS:
if book['id'] == data['book_id']:
book_to_purchase = book
# create new checkout session
checkout_session = stripe.checkout.Session.create(
success_url=domain_url +
'/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url=domain_url + '/canceled',
payment_method_types=['card'],
mode='payment',
line_items=[
{
'name': book_to_purchase['title'],
'quantity': 1,
'currency': 'usd',
'amount': round(float(book_to_purchase['price']) * 100),
}
]
)
return jsonify({'sessionId': checkout_session['id']})
except Exception as e:
return jsonify(error=str(e)), 403
Here, we-
- Defined a
domain_url
for redirecting the user back to the client after a purchase is complete - Obtained the book info
- Created the Checkout Session
- Sent the ID back in the response
Take note of the success_url
and cancel_url
. The user will be redirected back to those URLs in the event of a successful payment or cancellation, respectively. We'll set the /success
and /cancelled
routes up shortly on the client.
Also, did you notice that we converted the float to an integer via round(float(book_to_purchase['price']) * 100)
? Stripe only allows integer values for the price. For production code, you'll probably want to store the price as an integer value in the database -- e.g., $3.99 should be stored as 399
.
Add the import to the top:
import json
Client
On the client, update the handlePurchaseBook
method:
handlePurchaseBook(book) {
// Get Checkout Session ID
fetch('http://localhost:5001/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ book_id: book.id }),
})
.then((result) => result.json())
.then((data) => {
console.log(data);
// Redirect to Stripe Checkout
return this.stripe.redirectToCheckout({ sessionId: data.sessionId });
})
.then((res) => {
console.log(res);
});
},
Here, after resolving the result.json()
promise, we called the redirectToCheckout
method with the Checkout Session ID from the resolved promise.
Let's test it out. Navigate to http://localhost:5173. Click one of the purchase buttons. You should be redirected to an instance of Stripe Checkout (a Stripe-hosted page to securely collect payment information) with the basic product information:
You can test the form by using one of the several test card numbers that Stripe provides. Let's use 4242 4242 4242 4242
.
- Email: a valid email
- Card number:
4242 4242 4242 4242
- Expiration: any date in the future
- CVC: any three numbers
- Name: anything
- Postal code: any five numbers
The payment should be processed successfully, but the redirect will fail since we have not set up the /success
route yet.
You should see the purchase back in the Stripe Dashboard:
Redirect Pages
Finally, let's set up routes and components for handling a successful payment or cancellation.
Success
When a payment is successful, we'll redirect the user to an order complete page, thanking them for making a purchase.
Add a new component file called OrderSuccess.vue to "client/src/components":
<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Thanks for purchasing!</h1>
<hr><br>
<router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
</div>
</div>
</div>
</template>
Update the router in client/src/router/index.js:
import { createRouter, createWebHistory } from 'vue-router'
import Books from '../components/Books.vue'
import OrderSuccess from '../components/OrderSuccess.vue'
import Ping from '../components/Ping.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Books',
component: Books,
},
{
path: '/ping',
name: 'ping',
component: Ping
},
{
path: '/success',
name: 'OrderSuccess',
component: OrderSuccess,
},
]
})
export default router
Finally, you could display info about the purchase using the session_id
query param:
http://localhost:5173/success?session_id=cs_test_a1qw4pxWK9mF2SDvbiQXqg5quq4yZYUvjNkqPq1H3wbUclXOue0hES6lWl
You can access it like so:
<script>
export default {
mounted() {
console.log(this.$route.query.session_id);
},
};
</script>
From there, you'll want to set up a route handler on the server-side, to look up the session info via stripe.checkout.Session.retrieve(id)
. Try this out on your own.
Cancellation
For the /canceled
redirect, add a new component called client/src/components/OrderCanceled.vue:
<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Your payment was cancelled.</h1>
<hr><br>
<router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
</div>
</div>
</div>
</template>
Then, update the router:
import { createRouter, createWebHistory } from 'vue-router'
import Books from '../components/Books.vue'
import OrderCanceled from '../components/OrderCanceled.vue'
import OrderSuccess from '../components/OrderSuccess.vue'
import Ping from '../components/Ping.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Books',
component: Books,
},
{
path: '/ping',
name: 'ping',
component: Ping
},
{
path: '/success',
name: 'OrderSuccess',
component: OrderSuccess,
},
{
path: '/canceled',
name: 'OrderCanceled',
component: OrderCanceled,
},
]
})
export default router
Test it out one last time.
Conclusion
That's it! Be sure to review the objectives from the top. You can find the final code in the flask-vue-stripe repo on GitHub.
Looking for more?
- Add client and server-side unit and integration tests.
- Create a shopping cart so customers can purchase more than one book at a time.
- Add Postgres to store the books and the orders.
- Containerize Vue and Flask (and Postgres, if you add it) with Docker to simplify the development workflow.
- Add images to the books and create a more robust product page.
- Capture emails and send email confirmations (review Sending Confirmation Emails with Flask, Redis Queue, and Amazon SES).
- Deploy the client-side static files to AWS S3 and the server-side app to an EC2 instance.
- Going into production? Think about the best way to update the Stripe keys so they are dynamic based on the environment.