Skip to main content

Context

This is the endpoint a cashier’s terminal calls with a customer standing at the counter. It is the most operationally hostile path in the module: unreliable networks, retried requests, cancelled sales, and a human waiting. Three properties are non-negotiable. Idempotency, because a POS will retry and a double redemption is a customer-service incident. Atomicity across several rewards, because a basket can redeem more than one thing and half a redemption is worse than none. And rollback, because sales get voided and the points must come back with a decision about the lots they consumed. DEC-H3 confirmed mixed points + money as a real requirement — it is a contract-winning feature. F1 does not build it, but leaves the seam: a nullable money_component and a domain event shaped so that adding payment later is an addition rather than surgery.

Scope (normative)

  • loyalty.redemptions, parent and child rows from the first migration.
  • Idempotency-Key handling with a 24-hour stored result.
  • Validation against balance, reward availability, stock and per-contact limits.
  • Ledger redeem transactions consuming lots FIFO, through FS-LOY-0002’s service.
  • Rollback with two explicit modes: revert (return the points) and keep (do not).
  • money_component, nullable, unused in F1.
  • The member-facing members/me read: balance, tier, active coupons.

Non-scope (normative)

  • Stacking policy across multiple discounts — FS-LOY-0008. A redemption here applies what it is told to; deciding what may be combined is a policy layer above.
  • Coupon redemption — FS-LOY-0007. Coupons and points are different instruments.
  • Any payment execution. The seam is reserved, not wired.

Behaviour (normative)

  1. Idempotency-Key is required. The same key replays the stored result for 24 hours, byte for byte, without re-executing anything. A different payload under the same key is a typed conflict error, never a silent overwrite.
  2. A redemption is one transaction: validation, ledger redeem rows, lot consumption, stock decrement, projection update, outbox event and audit row commit together.
  3. Multi-reward requests are parent/child. The parent is all-or-nothing: if any child fails, nothing is applied.
  4. Validation happens before any write. Insufficient balance, an inactive or out-of-stock reward, or an exhausted per-contact limit fails cleanly with a typed error and zero side effects.
  5. Rollback is at parent level with an explicit mode. revert writes compensating ledger transactions that restore the points; consumed lots are restored to their original lots whenever those lots have not expired, and to a new lot inheriting the earliest expiry when they have. keep leaves the points spent and records the decision. There is no default — the caller states the mode.
  6. A redemption is never deleted. Rollback is a state transition plus compensating transactions.
  7. money_component is nullable and MUST be null in F1. A non-null value is rejected until the payment seam is implemented.
  8. Budget: p95 <300 ms for the synchronous decision (DEC-D7).

Data (normative)

API (normative)

Events (normative)

loyalty.points.redeemed on a successful parent, carrying the redemption id, rewards, total cost and correlation_id. A rollback emits it again with a reverted marker rather than inventing a second event name — a consumer that understands redemption already understands its reversal.

Acceptance criteria (normative)

  1. The same Idempotency-Key sent 100 times concurrently produces exactly one redemption and one set of ledger rows.
  2. A different payload under an already-used key returns a typed conflict, and the original redemption is unchanged.
  3. A three-reward parent where the third exceeds stock applies nothing: no ledger rows, no stock decrement, no partial parent.
  4. revert restores the exact point total; the sum over the member’s ledger returns to its pre-redemption value. Restored points landing in a new lot inherit the earliest expiry of the lots consumed.
  5. keep leaves the balance unchanged and records the mode in the redemption row.
  6. p95 <300 ms under the k6 profile with a 10 000-contact dataset.
  7. Negative: a request with a non-null money_component is rejected in F1 with a typed error, not silently ignored.

Execution

Synchronous command archetype. Ships as its own slice after TS-004: the endpoint in backend/api, the domain service beside the ledger service, no worker involved — this path is synchronous by design because the cashier is waiting.

Open questions

None outstanding. Every question this spec carried was answered in the consolidated register (../../../design/open-questions-v1.md, v1.1) and folded into the normative sections above.

Changelog

Delivery record

Not implemented yet.