Paul
Posted on July 1, 2024
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
- First we make a call to the stripe API, and redirect the customer to a Stripe secure form, to collect Payment.
- If the charge was successful, the stripe form will redirect to a success page in our website, otherwise a failed page.
- 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
Optain stripe test secret key from dashboard and click on developers
Now under webhook click on test in local environment, you'll find the stripe webhook key as well
now add it to settings.py
INSTALLED_APPS = [
]
.
.
.
STRIPE_API_KEY = "sk_test_"
STRIPE_WEBHOOK_KEY = "whsec_"
.
.
.
Let's create an app and call it transaction, where everything related to the payments is added
python manage.py startapp transaction
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)
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)
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")
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 %}
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 %}
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 %}
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
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)
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'),
]
Testing stripe webhook locally
To test stripe webhook locally, you'll need to install stripe cli
Once installed, login via
stripe login
Now forward the events to localhost
stripe listen --forward-to localhost:8000/stripe/webhooks/
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.
Posted on July 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.