Context
This is the money path of the module. Every other loyalty feature either writes to this ledger or reads a projection of it, so the invariants set here cannot be revisited cheaply later. Three forces shape it. First, a mutable balance column is a data-integrity trap: once the number and its history can disagree, there is no way to determine which is right. The ledger is append-only and the balance is derived — the same reason accounting has never worked any other way. Second, an earn is not final when it happens: the customer can return the purchase, so points enter aspending and become available once the tenant’s return window closes. Open
Loyalty implements exactly this as “locked points”, which is useful corroboration that the model
is standard rather than clever. Third, expiration has to be fair and explainable: consuming
oldest-first means a member’s points expire in the order they would expect, and it is the only
policy that can be explained in one sentence to a customer.
The erasure constraint is not a detail. Under DEC-J3, a data subject exercising deletion has their
profile and events deleted but their ledger rows anonymized to a tombstone, never destroyed.
That reconciles the right to erasure with accounting integrity, and it means every design choice
here must survive the contact reference being nulled.
Scope (normative)
loyalty.ledger_transactions, append-only, monthly RANGE partitioned candidate.- Parametric catalogs
ledger_transaction_types(earn,redeem,expire,revoke,adjust) andledger_transaction_states(pending,available,consumed,cancelled). loyalty.point_lotswithexpires_at, and FIFO consumption across lots.- The
pending → availabletransition driven by the tenant’s configured return window. - Expiration policy per point currency, overridable per tier:
none,rolling_days,end_of_month,end_of_year. Status currencies may expire or not — the tenant configures it (OQ-LOY-02). - Anonymization path: nulling the contact reference to a tombstone without losing the row.
- The domain service that writes a transaction, its lot effects, the outbox event and the audit row in one transaction.
Non-scope (normative)
- The balance projection — FS-LOY-0003. This FS produces correct history; reading it fast is a separate concern with separate failure modes.
- Redemption orchestration — FS-LOY-0006. Here a
redeemtransaction can be written; deciding whether a redemption is allowed, and parent/child structure, lives there. - What causes an earn — FS-LOY-0004. The rules engine calls this service; it does not live here.
- Expiry notifications — FS-LOY-0011. This FS makes expiration happen; telling anyone about it in advance is a scan plus a campaign.
- Status point qualification windows — FS-LOY-0010. Status points are written here like any other currency; what they qualify you for is a tier concern.
- Mixed points + money. The
money_componentseam is reserved on redemptions (FS-LOY-0006), not on ledger transactions.
Behaviour (normative)
- Append-only, always. A ledger row is never updated except for the
pending → availablestate transition and the erasure tombstone. It is never deleted. A correction is a compensatingadjustorrevoketransaction that references the original. - A mutable balance column is FORBIDDEN anywhere in this module.
- Every transaction carries
program_id,contact_id,point_currency_id,points_amount(INTEGER, signed by convention of its type),type_code,state_code,occurred_at,correlation_id, and a reference to what caused it. - Points are not money (DEC-B5):
points_amountis INTEGER against a point currency, never a monetary amount and never in the same column as one. - An
earncreates the transaction and apoint_lotwithexpires_atcomputed from the program’s policy, or the member’s tier override where one applies. - An
earnenterspendingwhen the tenant has a non-zero return window andavailableotherwise. The transition toavailableis idempotent: running it twice must produce one state change and no second event. - A
redeemconsumes lots oldestexpires_atfirst, ties broken bycreated_at. Partial lot consumption is normal and recorded. Consuming more than the available total fails before any write — never a negative lot, never a negative balance. pendingpoints are not redeemable and not counted as available.- Expiration runs against lots, not against transactions: an expired lot writes an
expiretransaction for its unconsumed remainder. 9b. Expiration is a property of the point currency, not of its kind. A status currency may be configured to expire exactly like a redeemable one, or not to expire at all. We do not decide this for the tenant; we make every combination expressible (OQ-LOY-02). When a status currency does expire, FS-LOY-0010’s grace period andtier.grace_startedevent stop being optional — otherwise a member loses a tier through expiry without ever having stopped buying. - One transaction, always (ADR-017): the ledger write, its lot effects, the outbox event and
the
core.audit_logrow commit together or not at all. - Erasure nulls
contact_idto a tombstone reference. The row, its amounts and its lot linkage survive intact — the liability is unchanged by a person exercising their rights. - Adding a row to
ledger_transaction_typesdoes not add behaviour. The catalog is not tenant-extensible (DEC-B4) and the API rejects unsupported codes withUNSUPPORTED_CODE.
Data (normative)
Partitioning decision:
ledger_transactions is listed in standards/data.md §4 as a candidate
pending real volume rather than a designated partition. This FS creates it unpartitioned and
records the trigger for revisiting: sustained growth past the point where index maintenance shows
up in write latency. Partitioning it later is a migration; doing it prematurely costs planner
efficiency on a table that is also read on the hot path.
Every query against it must still carry (program_id, contact_id) — the FIFO index depends on it.
API (normative)
None. This FS delivers a domain service and its persistence, consumed by FS-LOY-0004 (rules engine) and FS-LOY-0006 (redemptions). Exposing a raw “write a ledger transaction” endpoint would let a caller bypass every business rule that governs when points may be created. Manual adjustments by tenant staff arrive with the console, through an endpoint that carries its own permission and audit semantics — a later FS.Events (normative)
loyalty.points.redeemed is emitted by FS-LOY-0006, which owns the redemption as a whole — this
FS writes the transaction but does not own the business event.
Payloads are thin: ids, points_amount, point_currency_id, and the correlation_id inherited
from the originating event. No PII beyond contact_id.
Acceptance criteria (normative)
- An
earnwith a configured return window creates apendingtransaction and a lot; the transition job moves it toavailableexactly once, and running that job twice produces no second state change and no second event. - FIFO correctness: given three lots expiring on different dates, a redemption of a quantity
spanning two of them consumes the two oldest and leaves the third untouched, with
points_remainingcorrect on the partially consumed lot. - Redeeming more than the available total fails before any write. No lot is modified, no transaction row exists, and the error is typed.
points_remainingcan never be driven below zero or abovepoints_total— proven by a test that expects the check-constraint violation.- Atomicity: an induced failure in the outbox write rolls back the ledger row and the audit row with it. Nothing partial survives.
- Liability from first principles: summing available lots equals summing the ledger by type over a seeded dataset of at least 10 000 transactions including expirations and revocations.
- Erasure: anonymizing a contact nulls the reference to the tombstone while total outstanding points across the program remain unchanged.
- RLS denial test per table.
- Negative: no column named
balanceor equivalent exists on any table in this FS, and no code path updates a points total in place. Enforced by a schema assertion in CI.
Execution
Ships across TS-001 (schema, catalogs, seeds, constraints, indexes) and TS-004 (the domain service, its transaction shape, the outbox and audit wiring). Synchronous command archetype: the service is called inside a command handler, never from a route directly. Thepending → available transition is a scheduled job in backend/scheduler, idempotent via
core.processed_jobs.
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.