Playbook
Payment State Machine Design: Checkout vs. Payment Lifecycles (Payment State Machine Design)
Payment.Succeeded does not mean Checkout.Completed. If you don't separate checkout and payment lifecycles, two truths collide in production.
Distributed Payment Engine
Part 2 of 22
A series on distributed payment architecture — the gap between capture and complete.
Two timelines, one screen
The customer sees a single “order status” on screen, but behind it run at least two independent state machines: the checkout lifecycle and the payment lifecycle. Trying to manage both through a single status field is the state-machine version of the “one truth” illusion we introduced in part one.
Checkout: Init → Processing → FinalizePending → Completed
↘ Failed / Expired
Payment: Init → Processing → Captured → Completed
↘ Failed / Expired
This part explains why these two machines need separate designs, and why the gap between them is a design decision, not a bug.
Concepts, defined where they first appear
📦 Checkout Lifecycle
The stages an order goes through from the customer's perspective: started, processing, awaiting finalization, completed.
📦 Payment Lifecycle
The stages a money movement goes through from the PSP's perspective: started, processing, captured, completed.
📦 Terminal State
An irreversible state where a branch of the machine ends (Completed, Failed, Expired).
📦 Transition Guard
A precondition checked before allowing a move from one state to another.
📦 State Drift
When two related state machines fall out of sync at a point where they were supposed to align.
Captured is the PSP saying “I took the money.” Completed is you saying “I finished the order.” These are not the same event — between them sits a waiting state called FinalizePending, and it can last minutes, not seconds.
Why a single status field isn't enough
Many systems have one status column on the orders table, and both “payment status” and “order status” get squeezed into it. This is the classic symptom of cramming two responsibilities into one field: when the payment webhook arrives and sets status to Completed, the row now claims the order is “done” even though stock hasn't been reserved or the invoice hasn't been issued.
Orders
id | status
1 | Completed ← webhook arrived, but the finalization saga hasn't run yet
Separating the two machines
The correct model defines checkout and payment as separate state machines with a one-directional trigger relationship: the payment moving to Captured triggers the checkout moving to FinalizePending; but checkout reaching Completed depends on its own saga finishing.
Payment.Captured --(triggers)--> Checkout.FinalizePending
|
once stock, ledger, notification, cart cleanup all finish
↓
Checkout.Completed
With this separation, “payment succeeded but order is still processing” stops being a bug and becomes an expected, displayable intermediate state. Telling the customer “Your payment was received, your order is being prepared” now accurately reflects the system's real state.
Who triggers Failed and Expired
Both machines have their own Failed and Expired branches, and they can be triggered independently. On the payment side, Expired typically means the PSP didn't respond within a given window (say, a 3D Secure confirmation that never completed). On the checkout side, Expired means the finalization saga didn't finish within its allotted window — even if the payment was already Captured.
Payment.Captured + checkout finalization didn't finish within 30 minutes
↓
Checkout.Expired (but Payment.Captured is still valid — whether to refund or retry the saga is an operational decision)
This scenario shows the real benefit of separating the machines: you can start an independent recovery process on the checkout side without corrupting the Payment.Captured state. With a single status field, you couldn't represent both truths at once.
Guards: protecting transitions
Every transition needs a guard. For example, the Checkout.Processing → Checkout.FinalizePending transition should only happen after confirming the linked payment record is actually in the Captured state. A state machine without guards can slide into invalid states when webhooks arrive out of order — a problem we detail in part six of this series.
Guard: transition to Checkout.FinalizePending
→ Does a linked Payment record exist?
→ Is Payment.Status == Captured?
→ Does Payment.Amount match the checkout snapshot?
If all true, allow the transition; otherwise reject it and park the event in a pending queue.
The mappings that get confused most often
❌ Payment.Succeeded = Checkout.Completed
✓ Payment.Succeeded triggers Checkout.FinalizePending; Completed is a separate decision
❌ A single status field can carry both payment and order state
✓ Two independent lifecycles need two independent fields (or tables)
❌ A Failed state always means “the money went back”
✓ Checkout.Failed doesn't invalidate Payment.Captured; it needs its own compensation flow
❌ A transition without a guard is just “an extra check”
✓ Guards are the only defense against out-of-order or duplicate events
A checklist for auditing your state machine
- Is payment status stored in the same column as order status in your orders table? Split them.
- Which guards does the code that triggers the
Captured → Completedtransition actually check? - What happens if the checkout saga doesn't finish within 30 minutes after payment is
Captured? Is there an alert? - Can you clearly list which event drives each transition into
FailedandExpired? - If two webhooks for the same payment arrive out of order, does your guard catch it, or does it allow an invalid transition?
If you can't answer these five questions confidently, you've probably merged two state machines into one field.
What to take away from this part
- Checkout and payment are two related but independent state machines; they can't be squeezed into one
statusfield. - Payment.Succeeded doesn't guarantee Checkout.Completed; there's a measurable, displayable
FinalizePendinggap between them. - Every transition needs a guard; without one, out-of-order or duplicate events push the machine into invalid states.
FailedandExpiredcan be triggered independently on either machine; one doesn't automatically invalidate the other.
The day you write payment status and order status into the same column, you turn two separate truths into one lie.
FAQ
Frequently asked questions
What is Checkout Lifecycle?
The stages an order goes through from the customer's perspective: started, processing, awaiting finalization, completed.
What is Payment Lifecycle?
The stages a money movement goes through from the PSP's perspective: started, processing, captured, completed.
Is it true that "Payment.Succeeded = Checkout.Completed"?
Payment.Succeeded triggers Checkout.FinalizePending; Completed is a separate decision
What does this part lock in?
This part explains why these two machines need separate designs, and why the gap between them is a design decision, not a bug. Checkout and payment are two related but independent state machines; they can't be squeezed into one `status` field. The customer sees a single “order status” on screen, but behind it run at least two independent state machines: the checkout lifecycle and the payment lifecycle. Trying to manage both through a single `status` field is the state-machine version of the “one truth” illusion we introduced in part one.
Engineering Principles Learned
- Checkout and payment lifecycles are related but independent; they cannot share one field.
- Payment.Succeeded is a trigger, not an outcome; Checkout.Completed depends on its own saga finishing.
- A transition without a guard leaves the door open to invalid states the moment events arrive out of order or duplicated.
Continue reading
Continue reading
Next in series
Why Payment Capture Is Easy but Finalization Is Hard
The PSP taking the money is one step. Finishing the order is a saga that needs stock, finance, notifications, and cleanup to all succeed.
Next in series
Why Payment Systems Are Distributed Systems
A payment is never one service's job: basket, stock, provider gateway, and ledger all have to agree. Here's why the synchronous chain breaks down.
Same series
Immutable Payment Snapshot Design: Freezing the Cart at Intent Time
Re-reading the live basket during payment leaves amount and currency undecided. Without a snapshot frozen at intent time, finalization can't be trusted.