When a product has a single checkout, payment logic is usually simple: charge the user, mark the order as paid, and move on.
But once the business model includes a deposit now, a balance later, and referral or coupon attribution in between, the problem changes completely.
At that point, you're not just collecting money. You're managing a payment workflow.
In this tutorial, I’ll show you how to build a referral-aware split payment flow in Django that:
tracks Step 2 deposit and balance separately
supports coupon and partner linkage
prevents duplicate payment processing
uses database transactions safely
keeps referral payouts consistent
unlocks deliverables only when the workflow is complete
The main idea is simple: treat payment as a state transition, not just a webhook event.
Table of Contents
Prerequisites
Before following along, you should already be comfortable with:
Django models, views, and querysets
database transactions in Django
basic webhook concepts
Python class-based or function-based view patterns
how payment providers like Stripe or Paystack send event callbacks
You don't need to be an expert in payments, but you should understand how Django talks to the database and how to store state safely.
Project Structure
Here's a simple structure for the parts we need:
payments/
├── models.py
├── services.py
├── views.py
├── urls.py
└── webhooks.py
This separation matters.
models.pystores the business stateservices.pycontains the finalization logicviews.pyhandles user-facing payment actionswebhooks.pyreceives gateway callbacksurls.pyconnects endpoints
Keeping payment logic out of views makes the system easier to test and much harder to break.
Designing the Data Model
The most important decision is to model the payment stages clearly.
Instead of storing one vague “paid” flag, define the stages your business actually uses. For example:
from django.db import models
from django.conf import settings
class Journey(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
deposit_paid = models.BooleanField(default=False)
balance_paid = models.BooleanField(default=False)
deliverables_released = models.BooleanField(default=False)
referral_code = models.CharField(max_length=50, blank=True, default="")
partner_name = models.CharField(max_length=120, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Payment(models.Model):
STAGE_DEPOSIT = "deposit"
STAGE_BALANCE = "balance"
STAGE_CHOICES = [
(STAGE_DEPOSIT, "Deposit"),
(STAGE_BALANCE, "Balance"),
]
STATUS_PENDING = "pending"
STATUS_SUCCEEDED = "succeeded"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_SUCCEEDED, "Succeeded"),
(STATUS_FAILED, "Failed"),
]
journey = models.ForeignKey(Journey, on_delete=models.CASCADE, related_name="payments")
stage = models.CharField(max_length=20, choices=STAGE_CHOICES)
gateway_reference = models.CharField(max_length=120, unique=True)
amount = models.DecimalField(max_digits=10, decimal_places=2)
discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
net_amount = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
raw_payload = models.JSONField(null=True, blank=True)
finalized_at = models.DateTimeField(null=True, blank=True)
class ReferralPayout(models.Model):
payment = models.OneToOneField(Payment, on_delete=models.CASCADE, related_name="referral_payout")
partner_name = models.CharField(max_length=120)
amount = models.DecimalField(max_digits=10, decimal_places=2)
is_paid = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
This model design gives you a clean separation:
Journeyrepresents the customer’s overall progressPaymentrepresents each financial eventReferralPayoutrepresents what the partner earns from that payment
That separation is what keeps the logic manageable.
How Split Payments Work
Split payments usually follow a simple pattern:
the customer pays a deposit
the system records that deposit
a later payment clears the balance
the full workflow becomes complete
deliverables unlock only after the right stage
The important part is that each payment stage should be explicit.
If you treat the deposit and balance as two different milestones, then:
discounts can apply to one stage and not the other
referral attribution can be recorded per stage
payouts can happen only when the stage is truly completed
admin users can see the exact status of the workflow
That's much safer than trying to infer meaning from the amount alone.
Finalizing Payments Safely
The finalization logic should live in a service function, not directly inside the webhook view.
Here's a simple example:
from django.db import transaction
from django.utils import timezone
def finalize_payment(*, payment):
with transaction.atomic():
locked_payment = Payment.objects.select_for_update().select_related("journey").get(pk=payment.pk)
if locked_payment.status == Payment.STATUS_SUCCEEDED:
return locked_payment
locked_payment.status = Payment.STATUS_SUCCEEDED
locked_payment.finalized_at = timezone.now()
locked_payment.save(update_fields=["status", "finalized_at"])
journey = locked_payment.journey
if locked_payment.stage == Payment.STAGE_DEPOSIT:
journey.deposit_paid = True
elif locked_payment.stage == Payment.STAGE_BALANCE:
journey.balance_paid = True
if journey.deposit_paid and journey.balance_paid:
journey.deliverables_released = True
journey.save(update_fields=["deposit_paid", "balance_paid", "deliverables_released"])
if journey.referral_code and not hasattr(locked_payment, "referral_payout"):
ReferralPayout.objects.create(
payment=locked_payment,
partner_name=journey.partner_name,
amount=locked_payment.net_amount * 0.10,
)
return locked_payment
There are three important ideas here.
First, transaction.atomic() makes sure the update happens as one unit.
Second, select_for_update() locks the row so two processes don't finalize the same payment at the same time.
Third, the function checks whether the payment was already processed before doing any work.
That gives you a safe and repeatable finalization path.
Handling Webhooks Idempotently
Payment gateways can send the same webhook more than once.
That means your webhook handler must be idempotent, which simply means it can safely run multiple times without creating duplicate records or breaking state.
Here's a clean pattern:
import json
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def payment_webhook(request):
payload = json.loads(request.body.decode("utf-8"))
event_type = payload.get("event")
data = payload.get("data", {})
reference = data.get("reference")
if not reference:
return JsonResponse({"error": "Missing reference"}, status=400)
if event_type != "charge.success":
return HttpResponse(status=200)
payment = Payment.objects.filter(gateway_reference=reference).first()
if not payment:
return JsonResponse({"error": "Payment not found"}, status=404)
finalize_payment(payment=payment)
return HttpResponse(status=200)
This view stays intentionally small.
It doesn't try to decide business rules. It only reads the webhook, finds the payment, and passes it to the service layer.
That makes it much easier to test and debug.
Applying Coupons and Referral Attribution
Coupons and partner codes become tricky when the payment is split across stages.
For example, a coupon might apply only to the deposit. Or it might apply to the balance only. Or it might affect both.
The best solution is to store that rule explicitly.
Here's a simple model for stage-aware coupon logic:
class DiscountCode(models.Model):
APPLIES_DEPOSIT = "deposit"
APPLIES_BALANCE = "balance"
APPLIES_BOTH = "both"
APPLIES_CHOICES = [
(APPLIES_DEPOSIT, "Deposit only"),
(APPLIES_BALANCE, "Balance only"),
(APPLIES_BOTH, "Both stages"),
]
code = models.CharField(max_length=50, unique=True)
partner_name = models.CharField(max_length=120, blank=True, default="")
applies_to = models.CharField(max_length=20, choices=APPLIES_CHOICES, default=APPLIES_BOTH)
percent_off = models.PositiveSmallIntegerField(default=0)
is_active = models.BooleanField(default=True)
Now your payment flow can check whether the coupon is valid for the current stage before applying it.
A helper function might look like this:
def calculate_discount(amount, coupon, stage):
if not coupon or not coupon.is_active:
return 0
if coupon.applies_to == DiscountCode.APPLIES_DEPOSIT and stage != Payment.STAGE_DEPOSIT:
return 0
if coupon.applies_to == DiscountCode.APPLIES_BALANCE and stage != Payment.STAGE_BALANCE:
return 0
return amount * (coupon.percent_off / 100)
This keeps referral and coupon logic predictable.
Why the Referral Payout Should Be Explicit
A lot of systems accidentally mix these ideas:
payment received
coupon applied
referral credited
referral paid out
Those aren't the same thing.
A referral code can be attached at checkout, but the actual payout should be created only when the business rules say it's safe.
For example, you might decide:
the partner gets credited when the deposit is paid
the payout is created only after the balance clears
the payout amount is based on the final net payment
That way, you don't pay out early if the customer never completes the full flow.
Unlocking Deliverables at the Right Time
One of the biggest mistakes in split payment systems is unlocking everything after the first payment.
That creates operational problems and trust issues.
A better rule is:
deposit confirms intent
balance confirms completion
deliverables unlock only after the balance is received
You can keep that logic very simple in the Journey model:
def update_delivery_state(journey):
journey.deliverables_released = journey.deposit_paid and journey.balance_paid
journey.save(update_fields=["deliverables_released"])
The logic is readable, testable, and easy for an admin to understand.
Common Mistakes
Here are the mistakes that usually cause trouble in split payment systems:
1. Using one payment flag for everything
A single paid=True field isn't enough when the business has multiple payment stages.
2. Letting the webhook write directly to many tables
That makes the flow hard to test and easy to break. Use a service layer instead.
3. Forgetting idempotency
If the gateway retries a webhook, you shouldn't create duplicate payouts or double-update the journey.
4. Applying coupons without checking the stage
A code that's valid for the deposit may not be valid for the balance.
5. Releasing deliverables too early
Payment received doesn't always mean the workflow is complete.
Conclusion
Referral-aware split payment systems aren't hard because of the payment gateway. They're hard because the business rules are multi-step.
If you want the system to stay reliable, you should:
model each payment stage explicitly
store coupon and referral logic separately
finalize payments inside
transaction.atomic()lock rows with
select_for_update()make webhook handling idempotent
unlock deliverables only when the full workflow is complete
That approach keeps your Django app honest, traceable, and much easier to maintain as the product grows.