About this course
<p>When a product has a single checkout, payment logic is usually simple: charge the user, mark the order as paid, and move on.</p>
<p>But once the business model includes a deposit now, a balance later, and referral or coupon attribution in between, the problem changes completely.</p>
<p>At that point, you're not just collecting money. You're managing a payment workflow.</p>
<p>In this tutorial, I’ll show you how to build a referral-aware split payment flow in Django that:</p>
<ul>
<li><p>tracks Step 2 deposit and balance separately</p>
</li>
<li><p>supports coupon and partner linkage</p>
</li>
<li><p>prevents duplicate payment processing</p>
</li>
<li><p>uses database transactions safely</p>
</li>
<li><p>keeps referral payouts consistent</p>
</li>
<li><p>unlocks deliverables only when the workflow is complete</p>
</li>
</ul>
<p>The main idea is simple: treat payment as a state transition, not just a webhook event.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-designing-the-data-model">Designing the Data Model</a></p>
</li>
<li><p><a href="#heading-how-split-payments-work">How Split Payments Work</a></p>
</li>
<li><p><a href="#heading-finalizing-payments-safely">Finalizing Payments Safely</a></p>
</li>
<li><p><a href="#heading-handling-webhooks-idempotently">Handling Webhooks Idempotently</a></p>
</li>
<li><p><a href="#heading-applying-coupons-and-referral-attribution">Applying Coupons and Referral Attribution</a></p>
</li>
<li><p><a href="#heading-why-the-referral-payout-should-be-explicit">Why the Referral Payout Should Be Explicit</a></p>
</li>
<li><p><a href="#heading-unlocking-deliverables-at-the-right-time">Unlocking Deliverables at the Right Time</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should already be comfortable with:</p>
<ul>
<li><p>Django models, views, and querysets</p>
</li>
<li><p>database transactions in Django</p>
</li>
<li><p>basic webhook concepts</p>
</li>
<li><p>Python class-based or function-based view patterns</p>
</li>
<li><p>how payment providers like Stripe or Paystack send event callbacks</p>
</li>
</ul>
<p>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.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's a simple structure for the parts we need:</p>
<pre><code class="language-text">payments/
├── models.py
├── services.py
├── views.py
├── urls.py
└── webhooks.py
</code></pre>
<p>This separation matters.</p>
<ul>
<li><p><code>models.py</code> stores the business state</p>
</li>
<li><p><code>services.py</code> contains the finalization logic</p>
</li>
<li><p><code>views.py</code> handles user-facing payment actions</p>
</li>
<li><p><code>webhooks.py</code> receives gateway callbacks</p>
</li>
<li><p><code>urls.py</code> connects endpoints</p>
</li>
</ul>
<p>Keeping payment logic out of views makes the system easier to test and much harder to break.</p>
<h2 id="heading-designing-the-data-model">Designing the Data Model</h2>
<p>The most important decision is to model the payment stages clearly.</p>
<p>Instead of storing one vague “paid” flag, define the stages your business actually uses. For example:</p>
<pre><code class="language-python">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)
</code></pre>
<p>This model design gives you a clean separation:</p>
<ul>
<li><p><code>Journey</code> represents the customer’s overall progress</p>
</li>
<li><p><code>Payment</code> represents each financial event</p>
</li>
<li><p><code>ReferralPayout</code> represents what the partner earns from that payment</p>
</li>
</ul>
<p>That separation is what keeps the logic manageable.</p>
<h2 id="heading-how-split-payments-work">How Split Payments Work</h2>
<p>Split payments usually follow a simple pattern:</p>
<ol>
<li><p>the customer pays a deposit</p>
</li>
<li><p>the system records that deposit</p>
</li>
<li><p>a later payment clears the balance</p>
</li>
<li><p>the full workflow becomes complete</p>
</li>
<li><p>deliverables unlock only after the right stage</p>
</li>
</ol>
<p>The important part is that each payment stage should be explicit.</p>
<p>If you treat the deposit and balance as two different milestones, then:</p>
<ul>
<li><p>discounts can apply to one stage and not the other</p>
</li>
<li><p>referral attribution can be recorded per stage</p>
</li>
<li><p>payouts can happen only when the stage is truly completed</p>
</li>
<li><p>admin users can see the exact status of the workflow</p>
</li>
</ul>
<p>That's much safer than trying to infer meaning from the amount alone.</p>
<h2 id="heading-finalizing-payments-safely">Finalizing Payments Safely</h2>
<p>The finalization logic should live in a service function, not directly inside the webhook view.</p>
<p>Here's a simple example:</p>
<pre><code class="language-python">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
</code></pre>
<p>There are three important ideas here.</p>
<p>First, <code>transaction.atomic()</code> makes sure the update happens as one unit.</p>
<p>Second, <code>select_for_update()</code> locks the row so two processes don't finalize the same payment at the same time.</p>
<p>Third, the function checks whether the payment was already processed before doing any work.</p>
<p>That gives you a safe and repeatable finalization path.</p>
<h2 id="heading-handling-webhooks-idempotently">Handling Webhooks Idempotently</h2>
<p>Payment gateways can send the same webhook more than once.</p>
<p>That means your webhook handler must be idempotent, which simply means it can safely run multiple times without creating duplicate records or breaking state.</p>
<p>Here's a clean pattern:</p>
<pre><code class="language-python">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)
</code></pre>
<p>This view stays intentionally small.</p>
<p>It doesn't try to decide business rules. It only reads the webhook, finds the payment, and passes it to the service layer.</p>
<p>That makes it much easier to test and debug.</p>
<h2 id="heading-applying-coupons-and-referral-attribution">Applying Coupons and Referral Attribution</h2>
<p>Coupons and partner codes become tricky when the payment is split across stages.</p>
<p>For example, a coupon might apply only to the deposit. Or it might apply to the balance only. Or it might affect both.</p>
<p>The best solution is to store that rule explicitly.</p>
<p>Here's a simple model for stage-aware coupon logic:</p>
<pre><code class="language-python">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)
</code></pre>
<p>Now your payment flow can check whether the coupon is valid for the current stage before applying it.</p>
<p>A helper function might look like this:</p>
<pre><code class="language-python">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)
</code></pre>
<p>This keeps referral and coupon logic predictable.</p>
<h2 id="heading-why-the-referral-payout-should-be-explicit">Why the Referral Payout Should Be Explicit</h2>
<p>A lot of systems accidentally mix these ideas:</p>
<ul>
<li><p>payment received</p>
</li>
<li><p>coupon applied</p>
</li>
<li><p>referral credited</p>
</li>
<li><p>referral paid out</p>
</li>
</ul>
<p>Those aren't the same thing.</p>
<p>A referral code can be attached at checkout, but the actual payout should be created only when the business rules say it's safe.</p>
<p>For example, you might decide:</p>
<ul>
<li><p>the partner gets credited when the deposit is paid</p>
</li>
<li><p>the payout is created only after the balance clears</p>
</li>
<li><p>the payout amount is based on the final net payment</p>
</li>
</ul>
<p>That way, you don't pay out early if the customer never completes the full flow.</p>
<h2 id="heading-unlocking-deliverables-at-the-right-time">Unlocking Deliverables at the Right Time</h2>
<p>One of the biggest mistakes in split payment systems is unlocking everything after the first payment.</p>
<p>That creates operational problems and trust issues.</p>
<p>A better rule is:</p>
<ul>
<li><p>deposit confirms intent</p>
</li>
<li><p>balance confirms completion</p>
</li>
<li><p>deliverables unlock only after the balance is received</p>
</li>
</ul>
<p>You can keep that logic very simple in the <code>Journey</code> model:</p>
<pre><code class="language-python">def update_delivery_state(journey):
journey.deliverables_released = journey.deposit_paid and journey.balance_paid
journey.save(update_fields=["deliverables_released"])
</code></pre>
<p>The logic is readable, testable, and easy for an admin to understand.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>Here are the mistakes that usually cause trouble in split payment systems:</p>
<h4 id="heading-1-using-one-payment-flag-for-everything">1. Using one payment flag for everything</h4>
<p>A single <code>paid=True</code> field isn't enough when the business has multiple payment stages.</p>
<h4 id="heading-2-letting-the-webhook-write-directly-to-many-tables">2. Letting the webhook write directly to many tables</h4>
<p>That makes the flow hard to test and easy to break. Use a service layer instead.</p>
<h4 id="heading-3-forgetting-idempotency">3. Forgetting idempotency</h4>
<p>If the gateway retries a webhook, you shouldn't create duplicate payouts or double-update the journey.</p>
<h4 id="heading-4-applying-coupons-without-checking-the-stage">4. Applying coupons without checking the stage</h4>
<p>A code that's valid for the deposit may not be valid for the balance.</p>
<h4 id="heading-5-releasing-deliverables-too-early">5. Releasing deliverables too early</h4>
<p>Payment received doesn't always mean the workflow is complete.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Referral-aware split payment systems aren't hard because of the payment gateway. They're hard because the business rules are multi-step.</p>
<p>If you want the system to stay reliable, you should:</p>
<ul>
<li><p>model each payment stage explicitly</p>
</li>
<li><p>store coupon and referral logic separately</p>
</li>
<li><p>finalize payments inside <code>transaction.atomic()</code></p>
</li>
<li><p>lock rows with <code>select_for_update()</code></p>
</li>
<li><p>make webhook handling idempotent</p>
</li>
<li><p>unlock deliverables only when the full workflow is complete</p>
</li>
</ul>
<p>That approach keeps your Django app honest, traceable, and much easier to maintain as the product grows.</p>