DEV Community

Cover image for Adding Payment to Django app
Paul
Paul

Posted on

Adding Payment to Django app

So, you've decided to build your eCommerce or SaaS platform. Congratulation! But now comes the big question: how will you collect payments from your customers?

Having a solid and secure payment system is a must for any online business. This is where adding a payment gateway to your Django app comes in.

Many payment gateways, such as Stripe, Paypal, have made this easier to collect payments. All you need to do is integrate their API into your application, and they handle the rest—securely collecting payments, ensuring compliance, and more.

In this post, we'll walk you through how to set up a payment system in your Django application using Stripe, making sure your transactions are smooth and secure for your customers, and straightforward for you.

First make sure to create a stripe account, we'll only need a test account for this tutorial

NOTE
Use only stripe test account for testing purposes. Don't send money to youself in production as this violates stripe policy

Overview of Stripe and Django

  1. First we make a call to the stripe API, and redirect the customer to a Stripe secure form, to collect Payment.
  2. If the charge was successful, the stripe form will redirect to a success page in our website, otherwise a failed page.
  3. We listen to webhook events to confirm the transaction.

Installing stripe to Django

We'll focus on building a small SaaS Payment subscription, even if you are building something else, the below steps will remain the same

You can check the implementation in the Django Saas Boilerplate

Install dependency

pip install stripe
Enter fullscreen mode Exit fullscreen mode

Optain stripe test secret key from dashboard and click on developers

Stripe key

Now under webhook click on test in local environment, you'll find the stripe webhook key as well
Webhook key

now add it to settings.py

INSTALLED_APPS = [
]
.
.
.
STRIPE_API_KEY = "sk_test_"
STRIPE_WEBHOOK_KEY = "whsec_"
.
.
.
Enter fullscreen mode Exit fullscreen mode

Let's create an app and call it transaction, where everything related to the payments is added

python manage.py startapp transaction
Enter fullscreen mode Exit fullscreen mode

Add a model called Plan (subscription plan) inside transaction/models.py

from decimal import Decimal

class Plan(models.Model):
    name = models.CharField()
    description = models.CharField(max_length=150) # small description of the plan

    price = models.DecimalField(max_digits=9, decimal_places=2, default="0.0")

    datetime = models.DateTimeField(auto_now=True) # created datetime
    def get_total_cents(self):
        # converts  dollar to cents.
        integer = int(self.price)
        decimal = int((self.price % 1)*100)

        return (integer * 100) + decimal
        return dollar_to_cents(self.price)

Enter fullscreen mode Exit fullscreen mode

Now lets add a Transaction model that records all the transaction initiated, status and more.

class SUBSCRIPTION_STATUS(models.IntegerChoices):

    INACTIVE  = (0, 'inactive')
    ACTIVE  = (1, 'active')
    CANCELLED = (2, 'cancelled')

class PAYMENT_STATUS(models.IntegerChoices):

    UNPAID = (0, 'unpaid')
    PAID = (1, 'paid')

class Transaction(BasePayment):

    user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) # foreign key to user model
    plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.SET_NULL) # foreign key to subscription plan

    total = models.DecimalField(max_digits=9, decimal_places=2, default="0.0")
    status = models.PositiveSmallIntegerField(choices=PAYMENT_STATUS.choices, default=PAYMENT_STATUS.UNPAID)

    created = models.DateTimeField(auto_now_add=True) 
    modified = models.DateTimeField(auto_now=True)

    transaction_id = models.CharField(max_length=255, blank=True)
    subscription_id = models.CharField(max_length=255, null=True, blank=True) # creating stripe subscription
    customer_id = models.CharField(max_length=255, null=True, blank=True) # for creating stripe subscription

    subscription_status = models.PositiveSmallIntegerField(choices=SUBSCRIPTION_STATUS.choices, default=SUBSCRIPTION_STATUS.INACTIVE)
Enter fullscreen mode Exit fullscreen mode

Now go to view and lets start by listing out plan/product

def pricing(request):

    plans = Plan.objects.all()

    return render(request, "payment/pricing.html", {
        'plans': plans
    })

def payment_success(request):

    return render(request, "payment/success.html")


def payment_failed(request):

    return render(request, "payment/failure.html")
Enter fullscreen mode Exit fullscreen mode

Now the payment/pricing.html

{% extends "base.html" %}

{% block title %}Pricing{% endblock title %}
{% block description %}Pricing for the SAAS{% endblock description %}

{% block content %}
<div>
    <h1>Plans and pricing</h1>
    <div>
        This is a sample pricing, the purchase won't be made.
    </div>
</div>

<section >
    {% for plan in plans %}
        <form action="{% url "create-payment" %}" method="POST">
            {% csrf_token %}
            <div>
                <h2 >{{plan.name}}</h2>
                <h3 >$ {{plan.price|stringformat:'d'}}</h3>

                <input type="hidden" name="plan" value="{{plan.id}}">
                <button type="submit"">
                    Get started
                </button>
            </div>
        </form>
    {% endfor %}
</section>

{% endblock content %}
Enter fullscreen mode Exit fullscreen mode

Now add a success page and a failure page, so if the transaction is successful it can be redirected to success page.

success.html

{% extends "base.html" %}
{% load static %}

{% block content %}
<div class="">

    <div class="">

        <i class="bi bi-check-circle tw-text-9xl tw-text-green-600"></i>
        <div class="">Success</div>
    </div>

</div>
{% endblock content %}
Enter fullscreen mode Exit fullscreen mode

Similarly failure.html page

{% extends "base.html" %}
{% load static %}

{% block content %}
<div class="">

    <div class="">
        <i class="bi bi-x-circle tw-text-9xl tw-text-red-600"></i>
        <div class="tw-text-3xl">Payment failed</div>
    </div>

</div>
{% endblock content %}
Enter fullscreen mode Exit fullscreen mode

Now lets start creating checkout view. So go back to views.py and add the following view.

from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods

stripe.api_key = settings.STRIPE_API_KEY


@login_required
@require_http_methods(['POST'])
def create_payment(request):

    plan = request.POST.get("plan")

    try:
        plan = Plan.objects.get(id=int(plan))

    except (Plan.DoesNotExist, ValueError):
        return render(request, "404.html", status=404)

    amount = plan.price 

    payment = Payment.objects.create(
        total=amount,
        billing_email=request.user.email,
        user=request.user,
        plan=plan
    )

    pay_data = {
                'price_data' :{
                        'product_data': {
                            'name': f'{plan.name}',
                            'description': plan.description or '',
                            },
                        'unit_amount': plan.get_total_cents(), # get the currency in the smallest unit
                        'currency': 'usd', # set this to your currency
                        'recurring': {'interval': 'month'} # refer: https ://docs.stripe.com/api/checkout/sessions/create?lang=cli#create_checkout_session-line_items-price_data-recurring
                    },
                'quantity' : 1
            }


    checkout_session = stripe.checkout.Session.create(
            line_items=[
                pay_data
            ],
            mode='subscription',
            success_url=request.build_absolute_uri(payment.get_success_url()),
            cancel_url=request.build_absolute_uri(payment.get_failure_url()),
            customer=None,
            client_reference_id=request.user.id,
            customer_email=request.user.email,
            metadata={
                'customer': request.user.id,
                'payment_id': payment.id
            }
        )

    payment.transaction_id = checkout_session.id
    payment.save()

    return redirect(checkout_session.url) # redirect the user to stripe secure form
Enter fullscreen mode Exit fullscreen mode

Now a customer might take time to fill in their details and submit the form to stripe, once its submitted stripe sends events via webhook

A webhook is a simple view, that accepts post request and returns a 200 ok status.

so lets add webhook to views.py

from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

@require_POST
@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META['HTTP_STRIPE_SIGNATURE']
    event = None

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except ValueError as e:
        # Invalid payload
        return JsonResponse({'error': str(e)}, status=400)
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
        return JsonResponse({'error': str(e)}, status=400)

    # print("Event: ", event)

    data = event['data']['object']

    # Handle the even
    if event['type'] == 'checkout.session.completed':
        subscription = Transaction.objects.get(transaction_id=data['id'])
        subscription.status = PAYMENT_STATUS.PAID
        subscription.subscription_status = SUBSCRIPTION_STATUS.ACTIVE
        subscription.subscription_id = data['subscription']
        subscription.customer_id = data['customer']
        subscription.save()

    if event['type'] == 'checkout.session.expired':
        subscription = Transaction.objects.get(transaction_id=data['id'])
        subscription.status = PAYMENT_STATUS.UNPAID
        subscription.save()

    elif event['type'] == 'customer.subscription.deleted':
        # Subscription deleted
        subscription = Transaction.objects.get(stripe_subscription_id=event['data']['object']['subscription'])
        subscription.subscription_status = SUBSCRIPTION_STATUS.CANCELLED
        subscription.save()

    elif event['type'] == "charge.failed":
        pass

    elif event['type'] == 'invoice.payment_succeeded':
        # Payment succeeded
        pass

    elif event['type'] == 'invoice.payment_failed':
        # Payment succeeded
        pass

    elif event['type'] == 'customer.subscription.trial_will_end':
        # print('Subscription trial will end')
        pass

    elif event['type'] == 'customer.subscription.created':
        # print('Subscription created %s', event.id)
        pass

    elif event['type'] == 'customer.subscription.updated':
        # print('Subscription created %s', event.id)
        pass

    return JsonResponse({'status': 'success'}, status=200)

Enter fullscreen mode Exit fullscreen mode

You can read more about webhook events in stripe's page: Stripe events

Now add your paths to your urls.py

from django.urls import path

from .views import (create_payment, pricing, stripe_webhook,
                        payment_failed, payment_success)

urlpatterns = [
    path('pricing/', pricing, name='pricing'),
    path('create-payment/', create_payment, name='create-payment'),


    path('payment/failed/', payment_failed, name='payment-failed'),
    path('payment/success/', payment_success, name='payment-success'),
    path('stripe/webhook/', stripe_webhook, name='webhook'),
]
Enter fullscreen mode Exit fullscreen mode

Testing stripe webhook locally

To test stripe webhook locally, you'll need to install stripe cli
Once installed, login via

stripe login
Enter fullscreen mode Exit fullscreen mode

Now forward the events to localhost

stripe listen --forward-to localhost:8000/stripe/webhooks/
Enter fullscreen mode Exit fullscreen mode

That's it now you can listen to webhook events locally.

You can checkout the source code at: https://github.com/PaulleDemon/Django-SAAS-Boilerplate

If you have question, drop a comment. Found it helpful? share this article.

Top comments (0)