Decision
loyalty.ledger_transactionsis append-only; types (parametric): earn|redeem|expire|revoke|adjust; states pending→available (earn confirms after the tenant-configured return window). A mutable balance column is FORBIDDEN.- Two currencies per program minimum:
redeemableandstatus(XP). Never mixed (airline model). - Earns create
loyalty.point_lotswithexpires_at; redemptions consume lots FIFO. Expiration modes per program (tier-overridable): rolling N days | end-of-month | end-of-year.points.expiring_soonscan feeds retention campaigns. loyalty.contact_balancesis a projection updated in the SAME transaction as the ledger write (near-real-time, non-negotiable); nightly reconciliation detects drift (data-freshness add-on raises frequency of the reconciliation, never of the balance itself).- Redemptions are parent/child (multi-redeemable requests); rollback at parent level with revert|keep modes;
Idempotency-Keymandatory.money_component(amount+currency, nullable) reserves the mixed points+money seam (DEC-H3) — unused in F1.
Consequences
- Auditable liability, replayable balances, standard industry semantics. − More rows than a naive balance; mitigated by partitioning evaluation and projections.