Payments & Money APIs · Charging Safely
Payments as State Machines
Explicit states and transitions for orders and payments.
Model payments (and orders) as explicit state machines with allowed transitions, persisted state and history. A PSP payment object (e.g. PaymentIntent) has its own states; your order has business states. Map PSP events to order transitions idempotently, reject invalid transitions (e.g. refunded → paid), and record each transition with the triggering event ID. This makes retries, out-of-order webhooks and support investigations manageable.
Order payment states
| Order state | Entered when | Next states |
|---|---|---|
| pending_payment | Order created | authorized, paid, payment_failed, cancelled |
| authorized | Auth succeeded (manual capture) | paid (captured), cancelled (voided), expired |
| paid | Capture/charge succeeded | fulfilled, refunded, partially_refunded, disputed |
| payment_failed | Decline / failure | pending_payment (retry), cancelled |
| refunded / partially_refunded | Refund succeeded | disputed (rare) |
| disputed | Chargeback opened | paid (won), refunded/lost |
TRANSITIONS = {
"pending_payment": {"authorized", "paid", "payment_failed", "cancelled"},
"authorized": {"paid", "cancelled", "expired"},
"paid": {"fulfilled", "refunded", "partially_refunded", "disputed"},
"partially_refunded": {"refunded", "disputed"},
"payment_failed": {"pending_payment", "cancelled"},
}
async def transition(db, order_id: str, to: str, *, event_id: str) -> bool:
async with db.transaction():
order = await db.fetchrow("SELECT state FROM orders WHERE id=$1 FOR UPDATE", order_id)
if await db.fetchval("SELECT 1 FROM order_events WHERE event_id=$1", event_id):
return False # already applied (idempotent)
if to not in TRANSITIONS.get(order["state"], set()):
await log_invalid_transition(order_id, order["state"], to, event_id)
return False
await db.execute("UPDATE orders SET state=$2 WHERE id=$1", order_id, to)
await db.execute("INSERT INTO order_events(order_id, event_id, from_state, to_state) VALUES ($1,$2,$3,$4)",
order_id, event_id, order["state"], to)
return True
enum OrderState {
PENDING_PAYMENT, AUTHORIZED, PAID, PAYMENT_FAILED, REFUNDED, DISPUTED, CANCELLED;
private static final Map<OrderState, Set<OrderState>> NEXT = Map.of(
PENDING_PAYMENT, EnumSet.of(AUTHORIZED, PAID, PAYMENT_FAILED, CANCELLED),
AUTHORIZED, EnumSet.of(PAID, CANCELLED),
PAID, EnumSet.of(REFUNDED, DISPUTED));
boolean canMoveTo(OrderState to) { return NEXT.getOrDefault(this, Set.of()).contains(to); }
}
Common pitfalls
- Boolean flags (
is_paid,is_refunded) instead of states. - Updating state without locking (races between webhook and API).
- No history of transitions.
Key points
- Explicit states and allowed transitions
- Idempotent, locked transitions
- Persist transition history with event IDs
Further reading: Stripe: PaymentIntent lifecycle
Practice questions
Why model payments as a state machine instead of boolean flags?
Answer
Flags allow impossible combinations and hide history; explicit states with allowed transitions prevent invalid updates and make out-of-order events safe to handle.
How do you make applying a webhook-driven transition idempotent?
Answer
Record the PSP event ID with the transition in the same transaction and skip events already applied.
Open in the app to mark lessons complete, take notes and track your progress.