Inkress ADRsarchitecture decisions
Overview / Discounts
ADR 0003

Discount redemption limits are derived from orders, not a counter or ledger

AcceptedDiscounts

Context

Discount codes need caps: a global usage limit ("first 100 orders"), a per-customer limit ("once per customer"), and the guarantee that an abandoned or refunded order does not permanently burn a redemption. An early version kept a usage_count column incremented at order-create, which had three problems:

  • It never released. A code was consumed the moment an order was created, even if the customer never paid or was later refunded — so a limited code could be exhausted by carts that produced no revenue.
  • It only counted globally. A per-customer cap had no home in a single counter.
  • A running counter can drift from what actually happened, with no way to reconcile.

The key realization: a redemption is already recorded. Every order line carries the discount id, and the order carries the customer and its status. The orders are the ledger.

Decision

Do not keep a redemption counter, and do not add a redemption ledger table. Derive live redemptions by counting the orders a code is recorded on, filtered by order status.

  • Counting is by exclusion: an order counts unless it has reached a terminal status that releases the slot (errored, cancelled, returned, refunded, stale). A pending checkout therefore holds its slot as a reservation, paid and post-payment orders count, and a newly added status counts by default — a status can never silently free a redeemed slot.
  • The global cap counts all of a code's non-released orders; the per-customer cap counts the same for one customer (resolved by account, looked up by email). A code's id is unique to one merchant, so counting by it is inherently tenant-safe.
  • Enforcement runs inside the order-create transaction under a row lock on the code, so two concurrent redemptions of the same limited code are serialised and cannot exceed the cap; a lost race rolls the order back rather than honouring a spent code. Unlimited codes skip the lock.
  • A slow payment (invoice, bank transfer) keeps its slot because a pending order stays counted until it either pays or is swept to a terminal status — there is no fixed reservation clock to expire it early.

Consequences

  • Positive: refunds and cancellations release the slot automatically; the count reflects reality with no reconciliation; no new table.
  • Neutral / to revisit: the standalone usage_count column becomes redundant — kept in place for now, removed in a later release per the deploy constraint below.
  • Neutral / to revisit: correctness relies on the default (READ COMMITTED) isolation plus the row lock; raising the global isolation level would require revisiting this.
  • Cost: for a limited code the lock is held for the duration of the order-create transaction, which includes payment-provider setup — acceptable for scarce/promotional codes, and a candidate for a later refactor if a very high-volume limited code appears.
  • Deploy constraint: commerce-api runs migrations at image-build time, so the database is migrated before the new code is live. Every migration must be expand/contract — add columns now, drop superseded ones in a later release — or the still-running previous release breaks against the new schema.

Alternatives considered

  • A usage_count counter incremented at order-create (with a conditional guard): rejected — it consumes a redemption on unpaid/abandoned orders and never releases on refund, and models only the global cap.
  • A dedicated redemptions ledger table (customer × code): rejected — the order already links the code to the customer, so a parallel table would only drift; counting the source of truth is simpler and cannot disagree with the orders.
← 0002 Discounts are server-authoritative and modeled as a fee group 0004 The public discount-quote endpoint is rate-limited, fail-open →