How to Build Referral-Aware Split Payment Flows in Django — Opportunihub
Course Remote

How to Build Referral-Aware Split Payment Flows in Django

Chidozie Managwu · Remote

At a glance

Type
Course
Organisation
Chidozie Managwu
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
24 Aug 2026

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>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Chidozie Managwu’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Build Referral-Aware Split Payment Flows in Django?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Chidozie Managwu’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Build Referral-Aware Split Payment Flows in Django free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.