Most payment systems are designed for checkout.
That works fine when a customer pays once, and the transaction is complete. But real products are rarely that simple. Some businesses need a deposit now, a balance later, referral attribution in the middle, and deliverables that should only unlock when the full workflow is complete.
That is where a lot of payment systems start to break.
I’ve found that the problem usually isn’t the payment gateway itself. The real issue is that the backend is built around money moving, instead of around the business logic that gives the payment meaning.
If your system has split payments, partner referrals, coupons, or staged fulfilment, then a single “paid” flag is not enough. You need a model that understands the workflow.
Payments are not just events
One of the biggest mistakes I see is treating every payment callback as if it means the same thing.
It does not.
A deposit might mean the customer has committed. A balance payment might mean the work can proceed or be released. A partner-linked coupon might mean attribution should be recorded. A webhook retry might mean the same event is being delivered again. A refund might mean the workflow needs to roll back.
If you flatten all of that into one generic payment record, the system becomes hard to trust.
That is why I prefer to think about payments as state transitions.
A payment is not just an event that happened. It is a change in the business state.
The real challenge: preserving business truth
The hard part of split-payment systems is not charging money.
The hard part is making sure the application preserves the truth of what happened.
For example:
Did the customer pay the deposit or the balance?
Was a discount meant to apply to only one stage?
Was the referral code attached before or after payment?
Should the partner be credited now, or only after the journey is complete?
Has the same webhook already been processed once?
Is the deliverable ready to unlock yet?
These are business questions, not gateway questions.
The payment provider does not know your workflow. It only knows whether a charge succeeded. Your application has to interpret that charge in the context of the business.
Why most systems drift over time
A split-payment system usually starts out simple.
At first, you only need a deposit and a final balance. Then a coupon gets added. Then a partner wants attribution. Then the product team wants deliverables to remain locked until a milestone is reached. Then support needs a way to review payment history. Then finance needs a payout report. Then someone asks what happens if the webhook is duplicated.
That is where systems begin to drift.
If you don’t model the stages clearly from the start, the codebase begins to accumulate special cases:
deposit logic in one place
balance logic in another
referral logic in a third
unlock logic somewhere else
manual admin fixes in a spreadsheet
Once that happens, it becomes very difficult to reason about the system as a whole.
What a better model looks like
The cleaner approach is to model the journey explicitly.
I like to think in terms of a few separate business objects:
a customer journey or assessment object
a payment object for each stage
a coupon or partner attribution object
a referral payout record
a deliverable release flag
That separation makes the system much easier to understand.
The journey can store the overall progress. The payment record can store exactly which stage was paid. The coupon can describe how it applies. The payout record can show what the partner earned. The release flag can indicate whether the customer should now see the next step.
That is much safer than trying to infer everything from a gateway payload.
Make the stage explicit
If a product supports Step 2 deposit and Step 2 balance, I would never rely on amount alone to figure out what was paid.
I would store the stage directly.
For example:
depositbalance
That way, the application can make stage-aware decisions:
deposit can confirm intent
balance can confirm completion
discount can apply to one stage and not the other
payout can trigger only after the right milestone
deliverables can remain locked until the full process is complete
The moment I make the stage explicit, the rest of the logic becomes easier to write and much easier to test.
Why idempotency matters
Payment gateways often retry webhooks.
That means your system must be safe to process the same event more than once.
If it is not idempotent, you can end up with:
duplicate payment records
duplicate partner payouts
repeated state changes
deliverables unlocking twice
financial reports that no longer match reality
This is one of the most important reasons to separate finalisation logic from the webhook handler.
The webhook should identify the event and hand it off to a service layer. The service layer should check whether that payment has already been processed, lock the row, and finalise the record exactly once.
That way, retries become harmless instead of dangerous.
Why locking records matters
Even if your logic is idempotent, concurrency can still cause problems.
Imagine two workers trying to process the same payment at the same time.
Without locking, they may both see the record as unpaid, and both try to finalise it. That can create duplicate updates or duplicate payout records.
This is why row locking is so useful.
By locking the business record during finalisation, I can make sure only one process is allowed to modify it at a time. That gives me consistency under load and prevents race conditions that are painful to debug later.
Referral attribution should be a business rule, not an afterthought
Referral systems often fail because attribution is treated as a side note.
A partner code gets attached, but nobody is completely sure when it should count. Is it at deposit time? Is it after balance? Does the discount apply to both? Does the payout happen immediately? What if the customer only completes half the flow?
If those rules are not explicit, the referral layer becomes unreliable very quickly.
I think the better approach is to define referral attribution as part of the business state.
That means I can answer questions like:
Was the referral attached to this customer journey?
Which stage did it apply to?
Was the payout created or only credited?
Is the payout eligible to be paid now?
Is this customer eligible for a partner discount on the current stage?
When those rules are clear, the business can trust the numbers.
Coupons should know what they apply to
The same idea applies to coupons.
A coupon should not just mean “discount this order.” It should know whether it applies to:
the deposit
the balance
both stages
That matters because a partner may want a code that only reduces the deposit amount, while the balance remains unchanged. Or maybe the discount should apply across the whole Step 2 workflow. Or maybe the coupon is tied to a specific plan.
If I don’t make that distinction explicit, I can easily create mismatched revenue calculations later.
A good coupon system stores:
the discount type
the amount or percentage
the applicable stage
whether it is active
whether it is tied to a partner
That way, the platform can enforce the business rule instead of guessing.
Deliverables should unlock at the right time
Another common mistake is unlocking deliverables too early.
I’ve seen systems where a deposit is treated as full completion. That creates a poor customer experience and can also create operational risk if the workflow is supposed to continue after the deposit.
A better design is to unlock deliverables only when the business logic says the process is complete.
That may mean:
deposit confirms intent
balance confirms completion
deliverables release only after both are paid
That sounds simple, but it makes a huge difference in how reliable the product feels.
Separate “payment received” from “business complete”
This is the core principle behind a robust split-payment system.
Payment received does not always mean business complete.
Those two ideas need to stay separate.
If I blur them together, I run into problems like:
sending access too early
creating payouts before the journey is finished
marking something complete when only the deposit was paid
making reports that overstate progress
confusing support and finance teams
A mature system treats each step as a distinct state transition.
That is the real architectural shift.
What a mature payment system looks like
If I had to describe the ideal design in one sentence, I’d say this:
It models business truth first, and gateway behavior second.
In practice, that means:
payment stages are explicit
referral attribution is attached to the journey
discounts know which stage they apply to
finalization happens inside a transaction
rows are locked before updates
webhook handlers are idempotent
payout records are separate from payment records
deliverables are released only when the workflow is truly complete
That is the difference between a system that works in a demo and a system that survives production.
Why this matters for product teams
This is not just a technical concern.
It affects operations, support, finance, and customer trust.
When a system is built well:
customers see the right status at the right time
partners get credited correctly
finance can reconcile the numbers
support can explain what happened
admins can repair issues without guessing
engineers can extend the flow without breaking old behavior
That kind of clarity is what makes a payment system actually useful.
Final thoughts
Most split-payment systems fail because they are built like checkout code when they should be built like workflow infrastructure.
The payment gateway is only one part of the picture. The real challenge is representing the business correctly across multiple stages, multiple actors, and multiple incentives.
If I were building this again, I would focus on a few principles:
make payment stages explicit
keep referral logic separate from gateway logic
model coupons by stage
use transactions and locking
write idempotent webhook handlers
unlock value only when the full workflow is complete
That is how I would build a split-payment system that stays correct as the product grows.




