Building Referral-Aware Split Payment Flows in Django: A 2026 Guide

When a product relies on a single checkout, payment logic tends to follow a straightforward path: charge the customer, mark the order as paid, and proceed. However, the game changes significantly when the business model introduces elements like partial deposits, deferred balances, and referral or coupon attribution layered in between.

At this point, you're no longer just collecting moneyβ€”you're orchestrating a full payment workflow. This shift demands a more thoughtful approach to state management and data integrity.

In this tutorial, we'll walk through building a referral-aware split payment flow in Django that:

  • Tracks deposit and balance payments as distinct steps
  • Supports coupon application and partner (referral) linkage
  • Prevents duplicate payment processing through idempotency
  • Leverages database transactions for atomicity and safety
  • Maintains consistency in referral payout calculations
  • Gates deliverable access until the workflow is complete

The core principle is straightforward: treat each payment as a deliberate state transition rather than a reactive webhook event. This mindset shift simplifies debugging and enhances reliability across the board.

Table of Contents

Prerequisites

Before diving in, ensure you have the following in place:

  • Python 3.11+ and Django 5.x (or newer, given the 2026 landscape)
  • A solid grasp of Django models, views, and signals
  • Familiarity with payment gateway concepts (e.g., Stripe, PayPal) and webhook integrations
  • Basic understanding of database transactions and locking mechanisms

With these foundations, you'll be well-equipped to follow along and adapt the concepts to your specific use case.

Project Structure

To keep things modular and maintainable, we'll structure the project as follows:

projectroot/
β”œβ”€β”€ core/
β”‚ β”œβ”€β”€ models.py
β”‚ β”œβ”€β”€ services.py
β”‚ β”œβ”€β”€ webhooks.py
β”‚ └── views.py
β”œβ”€β”€ payments/
β”‚ β”œβ”€β”€ models.py
β”‚ β”œβ”€β”€ services.py
β”‚ └── webhooks.py
β”œβ”€β”€ referrals/
β”‚ β”œβ”€β”€ models.py
β”‚ └── services.py
└── config/
β”œβ”€β”€ settings.py
└── urls.py

This separation ensures clear ownership of logic and reduces the risk of circular imports, a common pitfall when payment and referral domains intertwine.

Designing the Data Model

At the heart of the system are three primary models: Order, Payment, and Referral. The Order model tracks the overall purchase lifecycle, while Payment records individual transactions. The Referral model links customers to their referrers and tracks payout eligibility.

from django.db import models
from django.contrib.auth.models import User

class Order(models.Model):
STATUS
CHOICES = [
('pending', 'Pending'),
('depositpaid', 'Deposit Paid'),
('balance
paid', 'Balance Paid'),
('completed', 'Completed'),
]
user = models.ForeignKey(User, ondelete=models.CASCADE)
status = models.CharField(max
length=20, choices=STATUSCHOICES, default='pending')
total
amount = models.DecimalField(maxdigits=10, decimalplaces=2)
depositamount = models.DecimalField(maxdigits=10, decimalplaces=2)
balance
amount = models.DecimalField(maxdigits=10, decimalplaces=2)
couponcode = models.CharField(maxlength=50, blank=True)
createdat = models.DateTimeField(autonowadd=True)
updated
at = models.DateTimeField(autonow=True)

class Payment(models.Model):
TYPES = [
('deposit', 'Deposit'),
('balance', 'Balance'),
]
order = models.ForeignKey(Order, on
delete=models.CASCADE, relatedname='payments')
type = models.CharField(max
length=10, choices=TYPES)
amount = models.DecimalField(maxdigits=10, decimalplaces=2)
gatewayref = models.CharField(maxlength=255, unique=True)
issuccessful = models.BooleanField(default=False)
created
at = models.DateTimeField(autonowadd=True)

class Referral(models.Model):
referrer = models.ForeignKey(User, ondelete=models.CASCADE, relatedname='referralsmade')
referred
user = models.ForeignKey(User, ondelete=models.CASCADE, relatedname='referredby')
order = models.ForeignKey(Order, on
delete=models.CASCADE)
commissionrate = models.DecimalField(maxdigits=5, decimalplaces=2)
is
paid = models.BooleanField(default=False)
createdat = models.DateTimeField(autonowadd=True)

By design, the Payment model enforces a unique gateway reference, which is critical for idempotent processing. Meanwhile, the Referral model stores the commission rate at creation time, protecting against future rate changesβ€”a subtle but important detail for financial accuracy.

How Split Payments Work

Split payments allow customers to pay a deposit upfront and settle the balance later. This is common in high-ticket items like course enrollments or membership programs. In 2026, customers increasingly expect such flexible payment options, so implementing them cleanly is a competitive advantage.

To manage this, we define a service layer that handles the creation and processing of each payment part. The service ensures that the deposit is processed before the balance becomes actionable.

from django.db import transaction
from .models import Order, Payment

def process
deposit(orderid, amount, gatewayref):
with transaction.atomic():
order = Order.objects.selectforupdate().get(id=orderid)
if order.status not in ['pending', 'deposit
paid']:
raise ValueError('Deposit already processed.')
payment = Payment.objects.create(
order=order,
type='deposit',
amount=amount,
gatewayref=gatewayref,
issuccessful=True
)
order.status = 'deposit
paid'
order.save()
return payment

Notice the use of selectforupdate(). In high-traffic scenarios, this row-level lock prevents race conditions where two requests might try to process the deposit simultaneously. This is a best practice that pays off in production.

Finalizing Payments Safely

Once the deposit is settled, the balance payment can be finalized. The process is similar, but with additional checks to ensure the order is in the correct state and the balance hasn't already been settled.

def processbalance(orderid, amount, gatewayref):
with transaction.atomic():
order = Order.objects.select
forupdate().get(id=orderid)
if order.status != 'depositpaid':
raise ValueError('Deposit not paid yet or balance already settled.')
if amount != order.balance
amount:
raise ValueError('Balance amount mismatch.')
payment = Payment.objects.create(
order=order,
type='balance',
amount=amount,
gatewayref=gatewayref,
issuccessful=True
)
order.status = 'completed'
order.save()
# Trigger any post-completion actions here (e.g., provision access)
return payment

By using atomic transactions and locking the order row, we guarantee that both the payment creation and order status update happen as one indivisible unit. This prevents partial updates that could corrupt the system state.

Handling Webhooks Idempotently

Payment gateways typically send webhook notifications for events like payment success. These webhooks can fire multiple times due to retries, so idempotency is non-negotiable. We handle this by checking the unique gateway reference before creating a payment.

def handlewebhook(eventtype, payload):
gateway
ref = payload['id']
if Payment.objects.filter(gatewayref=gatewayref).exists():
return {'status': 'ignored', 'reason': 'duplicate'}
if eventtype == 'paymentsucceeded':
orderid = payload['metadata']['orderid']
if payload['metadata']['type'] == 'deposit':
processdeposit(orderid, payload['amount'], gatewayref)
elif payload['metadata']['type'] == 'balance':
process
balance(orderid, payload['amount'], gatewayref)
return {'status': 'processed'}

This pattern layers on top of the transactional guarantees in the service functions. Even if two webhook calls arrive simultaneously, the unique constraint on gatewayref and the row-level lock ensure only one succeeds. The other will raise an integrity error, which we catch and treat as a duplicate.

Applying Coupons and Referral Attribution

Coupons and referrals are pillars of modern e-commerce, especially in subscription and course-based business models. By 2026, customers expect smart, personalized discounts that apply seamlessly at checkoutβ€”and if a referral is involved, the attribution must be unambiguous.

When a customer applies a coupon, we validate it and adjust the deposit and balance amounts accordingly. The key is to store the final amounts on the order at creation time, so downstream payment processing doesn't need to recalculate discounts.

def createorderwithdiscounts(user, total, couponcode=None, referrerid=None):
with transaction.atomic():
discount = 0
if couponcode:
coupon = Coupon.objects.get(code=coupon
code)
if coupon.isvalid():
discount = total (coupon.percentage / 100)
final
total = total - discount
deposit = finaltotal 0.30 # Example: 30% deposit
balance = final
total - deposit
order = Order.objects.create(
user=user,
totalamount=finaltotal,
depositamount=deposit,
balance
amount=balance,
couponcode=couponcode or ''
)
if referrerid:
Referral.objects.create(
referrer
id=referrerid,
referred
user=user,
order=order,
commissionrate=0.10 # Fixed 10% for clarity
)
return order

By storing the deposit and balance as decimals, we avoid floating-point inaccuracies that plague naive implementations. This aligns with financial best practices and ensures precise calculations.

Why the Referral Payout Should Be Explicit

Referral payouts should never be a byproduct of order totals. Instead, they must be a deliberate, independently tracked entity. Why? Because business rules evolveβ€”maybe a new campaign offers double commissions, or a specific product has a different rate. If payout logic is tangled with payment amounts, every tweak risks breaking core transactions.

In our model, the Referral object stores the commission rate at the time of the sale. This snapshot approach means even if you later change the global rate, past referrals remain unaffected. This clarity simplifies accounting and reduces support tickets.

Unlocking Deliverables at the Right Time

Customers expect immediate value once their payment is complete. For split payments, this raises a question: when should we grant access to the product or service? The answer depends on your business logic.

In many cases, access to early content is provided after the deposit, with full access granted after the balance. We implement this via a signal or explicit method call:

from django.db.models.signals import postsave
from django.dispatch import receiver

@receiver(postsave, sender=Payment)
def unlock
content(sender, instance, **kwargs):
if instance.issuccessful:
if instance.type == 'deposit':
instance.order.user.profile.access
level = 'partial'
elif instance.type == 'balance':
instance.order.user.profile.accesslevel = 'full'
instance.order.user.profile.save()

This signal-based approach decouples payment processing from access management, making the codebase easier to extend. Alternatively, if you prefer explicit service calls over signals, you can invoke an unlockdeliverables function directly after each payment stepβ€”just ensure it's idempotent.

By following this architecture, you build a payment flow that is robust, audit-friendly, and ready for the complexities of modern SaaS and e-commerce in 2026. The emphasis on state transitions, transactional integrity, and explicit attribution will save you countless hours of debugging and client-facing issues down the line.

via FreeCodeCamp

Related