Start Charging Customers with Django and DjStripe
Tags: python, django, stripe
Published: 2024-04-22 19:36:45

We're going to use the Dj-Stripe package so that we can start collecting payments. This should take about 10 minutes to set up.

This guide will include:

* Setting up products and prices in the Stripe dashboard.
* Building an e-commerce store with a shopping cart feature.
* Using dj-stripe to sync product and price changes from Stripe dashboard to database.

Here's a video of the e-commerce store in action:

{% youtube _RKNsLoTMdE %}

### Previewing the project

Want to see a live version of the app? You can create a copy of this project now and try it out.

[Run the online demo](https://dashboard.circumeo.io/projects/dwmf9lut/fork/)

### Setting up the Django app

Install packages and create the Django application.

```
django-admin startproject ecommerce .
python3 manage.py startapp core
```

Add core and other apps to the INSTALLED_APPS list.

```python
# settings.py
INSTALLED_APPS = [
    ...,
    "mathfilters",
    "djstripe",
    "core",
]
```

### Installing the requirements

* Create a `requirements.txt` file if you don't have one already.

```
Django==5.0.2
psycopg2==2.9.9
tzdata==2023.4
phonenumbers==8.13.35
django-phonenumber-field==7.3.0
django-mathfilters==1.0.0
dj-stripe==2.8.4
```

### Adding the templates

* Create a directory named `templates` within the `core` app.
* Create a file named `base.html` within the `templates` directory.

```html
<!DOCTYPE html>
<html>
  <head> 
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://js.stripe.com/v3/"></script>
  </head>
  <body>
    {% block content %}
    {% endblock %}
  </body>
</html>
```

* Create a file named `shopping.html` for displaying the main e-commerce products page.

```html
{% extends "core/base.html" %}
{% load mathfilters %}
{% block content %}
{% if messages %}
    {% for message in messages %}
        <div class="message max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div class="mt-4 p-4 text-sm text-white rounded-md {% if message.tags == 'success' %}bg-green-500{% elif message.tags == 'error' %}bg-red-500{% elif message.tags == 'warning' %}bg-yellow-500{% else %}bg-blue-500{% endif %}">
                {{ message }}
            </div>
        </div>
    {% endfor %}
{% endif %}
<div class="max-w-7xl mx-auto p-4 sm:p-6 lg:p-8">
  {% include 'core/partials/_cart.html' with cart=cart %}
  <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
    {% for product in products %}
    <div class="bg-white p-6 shadow-lg flex flex-col justify-between space-y-6">
      <div>
        <img src="{{ product.images|first }}" width="300" height="300" class="w-full object-cover rounded-md mb-4">
        <div class="flex flex-col justify-between flex-grow">
          <div>
            <h3 class="text-lg font-semibold mb-2">{{ product.name }}</h3>
            <p class="text-gray-600" style="height: 8em; overflow: scroll;">{{ product.description | default:"No description available." }}</p>
          </div>
          <p class="text-lg font-semibold text-gray-900 mt-auto">${{ product.prices.all.0.unit_amount|div:100 }}</p>
        </div>
      </div>
      <form action="{% url 'add_to_cart' %}" method="post">
        {% csrf_token %}
        <input type="hidden" name="price_id" value="{{ product.prices.all.0.id }}">
        <input type="hidden" name="quantity" value="1">
        <button type="submit" class="w-full bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded transition duration-300">Add to Cart 🛒</button>
      </form>
    </div>
    {% endfor %}
  </div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
    const messages = document.querySelectorAll('.message');
    setTimeout(function() {
        messages.forEach((msg) => msg.style.display = 'none');
    }, 3000);
});
</script>
{% endblock %}
```

* Create a file named `checkout.html` to display a summary of what is in the user cart prior to checking out with Stripe.

```html
{% extends "core/base.html" %}
{% load mathfilters %}

{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
    <div class="mt-10 mb-6">
        <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">Shopping Cart</h2>
    </div>
    <div class="flex flex-col">
        <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
            <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
                <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
                    <table class="min-w-full divide-y divide-gray-200">
                        <thead class="bg-gray-50">
                            <tr>
                                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Product</th>
                                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Price</th>
                                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Quantity</th>
                                <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
                            </tr>
                        </thead>
                        <tbody class="bg-white divide-y divide-gray-200">
                            {% for item in cart.items.all %}
                            <tr>
                                <td class="px-6 py-4 whitespace-nowrap">
                                    <div class="flex items-center">
                                        <div class="flex-shrink-0 h-10 w-10">
                                            <img class="h-10 w-10 rounded-full object-cover" src="{{ item.product.images|first }}" alt="Image of {{ item.product.name }}" width="300" height="300">
                                        </div>
                                        <div class="ml-4">
                                            <div class="text-sm font-medium text-gray-900">{{ item.product.name }}</div>
                                            <div class="text-sm text-gray-500">{{ item.product.description|truncatechars:50 }}</div>
                                        </div>
                                    </div>
                                </td>
                                <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${{ item.unit_price }}</td>
                                <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ item.quantity }}</td>
                                <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${{ item.total_price }}</td>
                            </tr>
                            {% endfor %}
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>
    <div class="flex justify-between items-center mt-6">
        <div class="text-lg font-medium text-gray-900">Total: ${{ total }}</div>
        <form action="{% url 'checkout' %}" method="post">
          {% csrf_token %}
          <button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition duration-300 ease-in-out">
            Checkout
          </button>
        </form>
    </div>
</div>
{% endblock %}
```

* Create a file named `checkout_success.html` to use when a user is redirected back from Stripe after a successful payment.

```html
{% extends "core/base.html" %}
{% block content %}
    <div class="bg-gradient-to-br from-emerald-50 to-sky-100 min-h-screen flex flex-col items-center justify-center px-4 py-16">
        <div class="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
            <div class="flex justify-center mb-6">
                <img alt="A 3D rendering of a cheerful golden trophy cup with confetti bursting around it, set against a vibrant blue background"
                     class="w-24 h-24 rounded-full object-cover object-center"
                     src="https://circumeo.io/static/images/checkout.jpg" />
            </div>
            <h2 class="text-3xl font-bold text-gray-800 text-center mb-4">Victory! Your Order is Complete! 🏆</h2>
            <p class="text-gray-600 text-center mb-8">
                We're thrilled to confirm your order is successful! An email confirmation containing all the juicy details is winging its way to your inbox. Your total was ${{ total }}.
            </p>
            <div class="flex justify-center">
                <a class="inline-flex items-center px-6 py-3 bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 text-white font-medium rounded-md shadow-sm transition duration-150 ease-in-out"
                   href="{% url 'shopping' %}">
                    <span class="mr-2">Continue Shopping</span>
                    <svg class="h-5 w-5"
                         fill="none"
                         stroke="currentColor"
                         viewbox="0 0 24 24"
                         xmlns="http://www.w3.org/2000/svg">
                        <path d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
                        </path>
                    </svg>
                </a>
            </div>
        </div>
    </div>
{% endblock %}
```

* Similarly, we'll need the `checkout_canceled.html` template to display something when a user cancels their checkout on the Stripe page.

```html
{% extends "core/base.html" %}
{% block content %}
    <div class="bg-gradient-to-br from-red-50 to-yellow-100 min-h-screen flex flex-col items-center justify-center px-4 py-16">
        <div class="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
            <div class="flex justify-center mb-6">
                <img alt="A 3D rendering of a shopping cart tipped on its side, with its contents spilling out, set against a light gray background"
                     class="w-24 h-24 rounded-full object-cover object-center"
                     src="https://placehold.co/150" />
            </div>
            <h2 class="text-3xl font-bold text-gray-800 text-center mb-4">Order Cancelled 🛒</h2>
            <p class="text-gray-600 text-center mb-8">
                No worries, your order has been cancelled. You haven't been charged. Feel free to browse around and come back anytime!
            </p>
            <div class="flex justify-center">
                <a class="inline-flex items-center px-6 py-3 bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 text-white font-medium rounded-md shadow-sm transition duration-150 ease-in-out"
                   href="{% url 'shopping' %}">
                    <span class="mr-2">Continue Shopping</span>
                    <svg class="h-5 w-5"
                         fill="none"
                         stroke="currentColor"
                         viewbox="0 0 24 24"
                         xmlns="http://www.w3.org/2000/svg">
                        <path d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
                        </path>
                    </svg>
                </a>
            </div>
        </div>
    </div>
{% endblock %}
```

* Create a `partials` directory within the existing `core/templates` directory structure.
* Create a file named `_cart.html` within `partials` and enter the following.

```html
{% load cart_tags %}
<div class="flex justify-end items-center p-4">
    <a href="{% url 'checkout' %}" class="{% if not cart.items.all %}pointer-events-none{% endif %}">
      <button class="flex items-center bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded transition duration-300 ease-in-out {% if not cart.items.all %}opacity-50 cursor-not-allowed{% endif %}">
        <span class="text-lg">🛒</span>
        <span class="ml-2">Your Cart</span>
        {% if cart and cart.items.all %}
        <span class="ml-2 bg-red-600 text-white font-bold py-1 px-2 rounded-full text-xs">
          {{ cart|items_total }} Items
        </span>
        {% else %}
        <span class="ml-2 bg-red-600 text-white font-bold py-1 px-2 rounded-full text-xs">0 Items</span>
        {% endif %}
      </button>
    </a>
</div>
```

### Adding the views

Copy and paste the following into `views.py` within the `core` directory.

```python
import stripe

from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_POST
from django.contrib import messages
from django.utils import timezone
from django.urls import reverse
from django.conf import settings

from djstripe.enums import APIKeyType
from djstripe.models import APIKey, Price, Product
from core.models import Cart

# Set the Stripe API key from settings
stripe.api_key = settings.STRIPE_TEST_SECRET_KEY

def shopping_view(request):
    """
    Displays a list of products available for purchase and the user's active shopping cart.
    """
    products = Product.objects.all()
    cart = None

    cart_id = request.session.get("cart_id")
    if cart_id:
        cart = Cart.objects.get(id=cart_id, is_active=True)
    return render(request, "core/shopping.html", {"cart": cart, "products": products})

def checkout_view(request):
    """
    Handles the checkout process, creating a Stripe checkout session for payment
    and directing the user to the payment page.
    """
    cart_id = request.session["cart_id"]
    cart = get_object_or_404(Cart, id=cart_id, is_active=True)

    if request.method == "POST":
        success_url = request.build_absolute_uri(reverse("checkout_success"))
        cancel_url = request.build_absolute_uri(reverse("checkout_canceled"))

        checkout_session = stripe.checkout.Session.create(
            success_url=success_url,
            cancel_url=cancel_url,
            mode="payment",
            line_items=[
                {"price": item.price.id, "quantity": item.quantity}
                for item in cart.items.all()
            ],
            payment_method_types=["card"],
        )

        return redirect("checkout_redirect", session_id=checkout_session["id"])

    total = sum(item.total_price() for item in cart.items.all())
    return render(request, "core/checkout.html", {"cart": cart, "total": total})

@require_POST
def add_to_cart_view(request):
    """
    Adds a product to the user's shopping cart from a POST request with product and quantity details.
    """
    price_id = request.POST.get("price_id")
    quantity = int(request.POST.get("quantity", 1))

    price = get_object_or_404(Price, id=price_id)

    cart_id = request.session.get("cart_id")
    if cart_id:
        cart = Cart.objects.get(id=cart_id, is_active=True)
    else:
        cart = Cart.objects.create(created_at=timezone.now())
        request.session["cart_id"] = cart.id

    cart.add_product(price.product, price, quantity)

    messages.success(request, "Item added successfully to shopping cart!")
    return redirect("shopping")

def checkout_redirect_view(request, session_id):
    """
    Redirects the user to the Stripe checkout session for payment, passing the necessary session details.
    """
    public_keys = APIKey.objects.filter(type=APIKeyType.publishable)[:1]
    return render(
        request,
        "core/checkout_redirect.html",
        {
            "stripe_public_key": public_keys.get().secret,
            "checkout_session_id": session_id,
        },
    )

def checkout_success_view(request):
    """
    Displays a success message and order summary after a successful checkout, clearing the cart.
    """
    cart_id = request.session.get("cart_id")
    if cart_id:
        cart = Cart.objects.get(id=cart_id, is_active=True)
        total = sum(item.total_price() for item in cart.items.all())
        cart.delete()
        request.session.pop("cart_id")
    else:
        total = 0
    return render(request, "core/checkout_success.html", {"total": total})

def checkout_canceled_view(request):
    """
    Handles the scenario where a checkout is canceled, clearing the cart and redirecting the user.
    """
    cart_id = request.session.get("cart_id")
    if cart_id:
        cart = Cart.objects.get(id=cart_id, is_active=True)
        cart.delete()
        request.session.pop("cart_id")
    return render(request, "core/checkout_canceled.html")
```

### Updating URLs

Create `urls.py` in the core directory.

```python
from django.urls import include, path
from core.views import (
    shopping_view,
    checkout_redirect_view,
    checkout_view,
    checkout_success_view,
    checkout_canceled_view,
    add_to_cart_view,
)

urlpatterns = [
    path("", shopping_view, name="shopping"),
    path(
        "checkout/redirect/<str:session_id>",
        checkout_redirect_view,
        name="checkout_redirect",
    ),
    path("checkout", checkout_view, name="checkout"),
    path("checkout/success/", checkout_success_view, name="checkout_success"),
    path("checkout/canceled/", checkout_canceled_view, name="checkout_canceled"),
    path("add-to-cart", add_to_cart_view, name="add_to_cart"),
    path("stripe/", include("djstripe.urls", namespace="djstripe")),
]
```

Update the existing `urls.py` within the project `ecommerce` directory.

```python
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("core.urls")),
]
```

### Adding the database models

Overwrite the existing `models.py` with the following:

```python
from decimal import Decimal
from django.db import models

from djstripe.models import Product, Price


class Cart(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_active = models.BooleanField(default=True)

    def add_product(self, product, price, quantity=1):
        cart_item, created = CartItem.objects.get_or_create(
            cart=self,
            product=product,
            price=price,
            defaults={"quantity": quantity, "product": product, "price": price},
        )

        if not created:
            cart_item.quantity += quantity
            cart_item.save()


class CartItem(models.Model):
    cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items")
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    price = models.ForeignKey(Price, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField(default=1)
    
    def unit_price(self):
        return Decimal(self.price.unit_amount) / Decimal(100)
    
    def total_price(self):
        dollar_amount = Decimal(self.price.unit_amount) / Decimal(100)
        return dollar_amount * self.quantity
```

### Setting up Products and Prices

Head to [Stripe](https://stripe.com/) and register if you haven't already.  We can use the Stripe API in Test Mode to build the e-commerce app.  You can add a bank account and get verified later when you're ready to start collecting real payments.

Once you're registered, go to the Product Catalog to start adding products and prices.

{% youtube _f3linPQCh4 %}

We only need a few products to test the code.  

The following snippet is a list of test products that I used when making the e-commerce app.

```python
products = [
            {
                "name": "UltraView HD 4K LED Smart TV - 55 Inches",
                "description": "Experience cinematic quality with our UltraView HD 4K Smart TV. Featuring a 55-inch LED display, HDR10+ support, and smart connectivity to stream your favorite shows and apps directly. With voice control and a sleek, bezel-less design, this TV is the perfect centerpiece for your home entertainment system.",
                "price": "450.99",
                "image_url": "https://circumeo.io/static/images/hdtv.jpg"
            },
            {
                "name": "EchoBeat Wireless Noise Cancelling Headphones",
                "description": "Dive into an ocean of sound with EchoBeat Wireless Headphones. Equipped with advanced noise cancelling technology, long-lasting battery life, and seamless Bluetooth connectivity. Enjoy high-fidelity audio, comfortable ear cups, and a lightweight design for hours of musical bliss.",
                "price": "129.99",
                "image_url": "https://circumeo.io/static/images/headphones.jpg"
            },
            {
                "name": "PhotonBeam Mini Projector - Portable HD",
                "description": "Bring the big screen anywhere with the PhotonBeam Mini Projector. This compact, portable HD projector offers vibrant visuals and incredible clarity. Easy to set up and compatible with multiple devices, it's ideal for home theaters, outdoor movies, or business presentations.",
                "price": "199.99",
                "image_url": "https://circumeo.io/static/images/projector.jpg"
            },
            {
                "name": "VirtualWave Immersive VR Headset",
                "description": "Step into new worlds with the VirtualWave VR Headset. Featuring cutting-edge technology for immersive virtual reality experiences, comfortable fit, and easy integration with your smartphone or PC. Perfect for gaming, educational content, or exploring virtual landscapes.",
                "price": "299.99",
                "image_url": "https://circumeo.io/static/images/vr_headset.jpg"
            },
            {
                "name": "QuantumLeap Smart Fitness Watch",
                "description": "Track your health and fitness goals with the QuantumLeap Smart Fitness Watch. This device offers real-time monitoring of your heart rate, steps, and sleep patterns. Features GPS tracking, water resistance, and a durable touchscreen. Syncs seamlessly with your mobile devices to keep you informed and motivated throughout the day.",
                "price": "249.99",
                "image_url": "https://circumeo.io/static/images/smartwatch.jpg"
            },
            {
                "name": "PulseMax Portable Bluetooth Speaker",
                "description": "Bring your music to life with the PulseMax Portable Bluetooth Speaker. Offers high-quality sound with deep bass and crystal-clear highs. It's waterproof and dustproof, making it perfect for outdoor adventures. Features a long-lasting battery and a compact design for easy portability.",
                "price": "89.99",
                "image_url": "https://circumeo.io/static/images/bluetooth_speaker.jpg"
            },
            {
                "name": "Starlight 360 Home Security Camera",
                "description": "Keep your home secure with the Starlight 360 Home Security Camera. Features 360-degree video surveillance with night vision, motion detection, and remote viewing via a secure app. Weather-resistant and easy to install, providing peace of mind for any homeowner.",
                "price": "159.99",
                "image_url": "https://circumeo.io/static/images/security_camera.jpg"
            },
            {
                "name": "GlideTech Electric Scooter",
                "description": "Zip around town on the GlideTech Electric Scooter. It's eco-friendly, featuring a powerful electric motor that reaches speeds up to 20 mph. Comes with a foldable design, LED headlights, and a digital display dashboard. Ideal for quick commutes and fun rides.",
                "price": "329.99",
                "image_url": "https://circumeo.io/static/images/scooter.jpg"
            }
        ]
```

### Stripe API Keys and Webhook Secret

The dj-stripe package uses the API keys and webhook secret to enable sync between Stripe and our Django database.  This means that we'll make any product or price updates in Stripe, and through web-hooks, Stripe will notify our app of the changes.

This video shows the process of setting up the Django admin dashboard, registering the Stripe API keys, and creating a webhook for dj-stripe to use.

{% youtube JI6KgR6i2SA %}

Once the API keys and web-hook are established, you can trigger a manual sync of data from Stripe into the Django database.

```
python3 manage.py djstripe_sync_models
```

The sync will take a moment.  Once the sync is complete, all products and prices should be copied from Stripe.

<img src="https://circumeo-media.nyc3.cdn.digitaloceanspaces.com/products.png" style="width: 100%"/>

### You're ready to start charging customers!

Customers can now add items to their cart and checkout.

You can also make changes from the Stripe dashboard and see them reflected within the Django app.  The `dj-stripe` package handles the web-hooks from Stripe and updates the database.

### Other ways to implement payments

If you'd like to learn more about Django and Stripe, Tom over at PhotonDesigner has also written a great article on the subject.

[Add Stripe subscriptions to Django in 7 minutes 💵](https://www.photondesigner.com/articles/stripe-subs)