Overview / Orders
ADR 0013
Order status is a flat integer enum with idempotent compare-and-set transitions
AcceptedOrders
Context
An order moves through a lifecycle — pending, paid, fulfilled, cancelled, refunded — and the events that move it arrive concurrently and more than once: a payment callback can be delivered twice, a stale-sweep and a late "paid" can race, and a refund can land after fulfilment. The status model has to make duplicate and out-of-order transitions safe without a heavyweight state machine, and it has to serve as the ledger that discount redemption counting reads (ADR 0003).
Decision
Model order status as a flat integer enum and enforce transitions with a single guarded SQL compare-and-set, not a state-machine library, version column, or distributed lock.
- The enum. Status is an integer from
Constants.Status(a compile-time config list read viaConstants.status()):order_pending 1,order_error 2,order_paid 3,order_confirmed 4,order_cancelled 5,order_prepared 6,order_shipped 7,order_delivered 8,order_completed 9,order_returned 10,order_refunded 11,order_verifying 12,order_stale 13,order_archived 14,order_partial 32. - Idempotent transition.
Service.Order.Processor.update_order_status/2writes the new status with a conditionalUPDATEwhoseWHEREwhitelists the source statuses allowed to change (plus a null status). The row count is the decision:{1, _}is a genuine transition (and, on payment, spins up subscription billing);{0, nil}is an idempotent no-op that returns the current status rather than erroring. So a repeated "mark paid" on an already-paid or already-terminal order is silently absorbed — late/duplicate webhooks are safe with no version column and no lock. - Terminal statuses define "released." Discount redemption counts orders by exclusion of the statuses that free a slot (ADR 0003): error, cancelled, returned, refunded, stale. A pending order therefore holds its slot; a newly added status counts by default (a status can never silently free a redemption).
- Out-of-band paid orders skip the transition. An offline order (recorded out-of-band: cash, bank transfer, other via a
tender_type) is written directly atorder_paidwith no provider, no ledger transaction, and no platform fee — it does not go through the pending→paid transition because it was never pending.
Consequences
- Positive: concurrency and at-least-once delivery are handled by the database, not application locks; the same guard is reused wherever status changes.
- Positive: the orders table is the redemption ledger — no separate counter can drift from it (ADR 0003).
- Neutral / to revisit: the enum is not a single source of truth — it is duplicated in each service's compile-time config, and the copies have drifted (the worker's copy even defines
order_refundedat two different integers). The shared database's integers are authoritative; the drift is a latent hazard when a new status is added on one side only. - Neutral / to revisit: the source-status guard is copy-pasted across several call sites with slightly different whitelists, so "which transitions are legal" is defined per call site rather than in one table.
- Follow-ups: the enum is duplicated and drifted across services (the worker's copy even defines
order_refundedat two integers) — consolidating it into one shared definition, and the transition-whitelist into one place, are open cleanups. (order_staleis reached: a scheduled hourly sweeper in commerce-worker moves aged unpaid orders to it — so ADR 0003's "swept to a terminal status" holds.)
Alternatives considered
- A state-machine library with explicit transition tables: rejected for now — the guarded compare-and-set gives the one property that matters (idempotent, race-safe transitions) with far less machinery; the cost is that legal transitions live in scattered
WHEREclauses. - A
status_versioncolumn or row lock for concurrency: rejected — the conditionalUPDATE's row count already makes a duplicate transition a no-op; a version column or lock adds coordination for no extra safety here. - A separate order-status history/ledger table as the source of truth: rejected — the order row plus its
status_ontimestamp is sufficient for the flows that read it, and the orders already serve as the discount ledger; a parallel table would only be one more thing to keep in sync.