Seven Payment Rails, Seven Definitions of Done
There are two statuses next to each other in one of the transaction models I work with:
ReturnTransaction
ReturnedTransaction
The first is a completed transaction that sends money back. The second is the original transaction after the money came back.
One suffix separates two movements in opposite directions.
This is the kind of distinction that disappears when someone says, “Can’t we just give every payment a status?”
We have seven payment rails behind one exposure control plane: money pulled from a linked bank account, money pushed to a bank account, card-balance repayments, card purchases, cheque deposits, instant payouts to debit cards, and instant payouts to suppliers.
They all move money. That is about where the similarities end.
A Shared Status Is Not a Shared Meaning
Consider Processed.
On one credit path, the lifecycle ends at Processed. On a debit path, Processed is followed by Settled. Code that interprets Processed as “done” is correct in one direction and early in the other.
Neither model is necessarily wrong. The word is carrying context that lives outside the word itself: which rail, which direction, which processor, and which stage of settlement.
Cheques add another wrinkle. They can enter a pending-review state. That state does not mean a machine is still working. It means the workflow has stopped for a person. A generic progress indicator might render both kinds of pending state identically even though one should resolve on its own and the other never will.
This is why a universal payment state machine tends to become one of two things:
- So generic that it stops being useful.
- A growing collection of exceptions pretending to be a model.
The temptation is to fix this with more statuses. That usually makes the vocabulary larger without making the semantics clearer.
Normalize the Decision, Not the Rail
The control plane still needs one answer: can this company perform this operation right now?
That question can be shared. The facts needed to answer it cannot.
Some checks genuinely apply everywhere: whether the rail is available, whether the company is allowed to use it, whether a block is active, which policy version made the decision, and whether loosening a control requires a second person.
Consumption does not fit one shape as neatly. Some rails maintain counters. One derives its usage at decision time. Some have both daily and monthly controls; others do not have the same monthly concept at all.
Forcing every rail into one storage model would not simplify the system. It would invent data for some rails and duplicate data for another.
The useful abstraction is therefore smaller than “all payments work the same.” It is closer to this:
Every rail must provide enough rail-specific evidence for the control plane to make a common kind of decision.
The decision contract is shared. The evidence adapter is not.
Terminal Is a Local Opinion
Payment state exists in at least two places: locally and at the processor. Often there are more copies in between—webhook projections, workflow state, cached balances, and accounting records.
A local record can look settled while the destination has not received funds. A payout can be debited on one side but absent on the other. A transaction that appeared complete can later be returned.
These are not exotic violations of the payment model. They are part of the model.
The processor retains facts the application cannot manufacture. The application therefore needs reconciliation, not just event handling. Events tell us that something changed. Reconciliation asks whether our current belief still matches the external system.
That distinction matters when defining “done.” Locally terminal might mean no more application work is scheduled. Processor terminal might mean settlement completed. Customer terminal might mean the money is visible and usable. Risk terminal might not exist until a return window closes.
One boolean cannot represent all four.
The Control Plane Should Preserve Disagreement
There is a subtle failure mode in normalization: throwing away the information that two systems disagree.
Suppose the local state says settled and the processor state says pending. Mapping both into a generic in_progress value makes the interface look tidy, but it destroys the most important fact in the system.
The disagreement is the signal.
The control plane should retain provenance: what the application believes, what the processor reported, when each observation was made, and which one is authoritative for the decision being taken. That makes reconciliation explicit and gives an operator something better than a green badge built from stale data.
It also makes gradual rollout possible. A new policy can run in shadow mode, record what it would have decided, and compare that result with the existing path. Moving from shadow to canary to enforcement is much safer when every decision carries its inputs and policy version with it.
What I Would Standardize
Across seven rails, I would standardize fewer things than I once would have:
- A stable identity for the logical operation, independent of retries.
- The rail and direction of money movement.
- Local state and external state as separate facts.
- The authority behind each observation.
- The policy version and evidence used for a control decision.
- Whether an action is reversible and who approved it.
- The path by which the system will reconcile uncertainty.
I would not standardize the meaning of Processed, the timing of settlement, the existence of a review step, or the way consumption is calculated unless the underlying rails actually agree.
Good platform engineering is not the removal of difference. It is deciding which differences callers should not have to manage and which ones are dangerous to hide.
Seven rails can share a control plane.
They should not be forced to share a definition of done.
Next
What $1 Billion Looks Like in the Logs →At payment scale, rare failures become background conditions and reconciliation becomes part of the product.