Inkress ADRsarchitecture decisions
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 via Constants.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/2 writes the new status with a conditional UPDATE whose WHERE whitelists 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 at order_paid with 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_refunded at 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_refunded at two integers) — consolidating it into one shared definition, and the transition-whitelist into one place, are open cleanups. (order_stale is 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 WHERE clauses.
  • A status_version column or row lock for concurrency: rejected — the conditional UPDATE'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_on timestamp 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.
← 0012 Payment links and hosted checkout are server-owned intents with a server-authoritative amount 0014 Background jobs and outbound merchant webhooks run on Oban, migrating off Verk →