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
- Project Structure
- Designing the Data Model
- How Split Payments Work
- Finalizing Payments Safely
- Handling Webhooks Idempotently
- Applying Coupons and Referral Attribution
- Why the Referral Payout Should Be Explicit
- Unlocking Deliverables at the Right Time
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):
STATUSCHOICES = [
('pending', 'Pending'),
('depositpaid', 'Deposit Paid'),
('balancepaid', 'Balance Paid'),
('completed', 'Completed'),
]
user = models.ForeignKey(User, ondelete=models.CASCADE)
status = models.CharField(maxlength=20, choices=STATUSCHOICES, default='pending')
totalamount = models.DecimalField(maxdigits=10, decimalplaces=2)
depositamount = models.DecimalField(maxdigits=10, decimalplaces=2)
balanceamount = models.DecimalField(maxdigits=10, decimalplaces=2)
couponcode = models.CharField(maxlength=50, blank=True)
createdat = models.DateTimeField(autonowadd=True)
updatedat = models.DateTimeField(autonow=True)
class Payment(models.Model):
TYPES = [
('deposit', 'Deposit'),
('balance', 'Balance'),
]
order = models.ForeignKey(Order, ondelete=models.CASCADE, relatedname='payments')
type = models.CharField(maxlength=10, choices=TYPES)
amount = models.DecimalField(maxdigits=10, decimalplaces=2)
gatewayref = models.CharField(maxlength=255, unique=True)
issuccessful = models.BooleanField(default=False)
createdat = models.DateTimeField(autonowadd=True)
class Referral(models.Model):
referrer = models.ForeignKey(User, ondelete=models.CASCADE, relatedname='referralsmade')
referreduser = models.ForeignKey(User, ondelete=models.CASCADE, relatedname='referredby')
order = models.ForeignKey(Order, ondelete=models.CASCADE)
commissionrate = models.DecimalField(maxdigits=5, decimalplaces=2)
ispaid = 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 processdeposit(orderid, amount, gatewayref):
with transaction.atomic():
order = Order.objects.selectforupdate().get(id=orderid)
if order.status not in ['pending', 'depositpaid']:
raise ValueError('Deposit already processed.')
payment = Payment.objects.create(
order=order,
type='deposit',
amount=amount,
gatewayref=gatewayref,
issuccessful=True
)
order.status = 'depositpaid'
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.selectforupdate().get(id=orderid)
if order.status != 'depositpaid':
raise ValueError('Deposit not paid yet or balance already settled.')
if amount != order.balanceamount:
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):
gatewayref = 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':
processbalance(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=couponcode)
if coupon.isvalid():
discount = total (coupon.percentage / 100)
finaltotal = total - discount
deposit = finaltotal 0.30 # Example: 30% deposit
balance = finaltotal - deposit
order = Order.objects.create(
user=user,
totalamount=finaltotal,
depositamount=deposit,
balanceamount=balance,
couponcode=couponcode or ''
)
if referrerid:
Referral.objects.create(
referrerid=referrerid,
referreduser=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 unlockcontent(sender, instance, **kwargs):
if instance.issuccessful:
if instance.type == 'deposit':
instance.order.user.profile.accesslevel = '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
