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.

Distributed payment engine architecture diagram

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

  1. Is payment status stored in the same column as order status in your orders table? Split them.
  2. Which guards does the code that triggers the Captured → Completed transition actually check?
  3. What happens if the checkout saga doesn't finish within 30 minutes after payment is Captured? Is there an alert?
  4. Can you clearly list which event drives each transition into Failed and Expired?
  5. 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

  1. Checkout and payment are two related but independent state machines; they can't be squeezed into one status field.
  2. Payment.Succeeded doesn't guarantee Checkout.Completed; there's a measurable, displayable FinalizePending gap between them.
  3. Every transition needs a guard; without one, out-of-order or duplicate events push the machine into invalid states.
  4. Failed and Expired can 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

Next in series

Same series

Paylaş