> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Redemptions — Parent/Child, Idempotent, Rollback

> After this ships, a point of sale can redeem a member's points over a flaky connection and be certain that a retry never charges them twice.

## 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)*

| Table                       | Key invariants                                                                                                                                                                                                                                                 |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.redemptions`       | self-referencing `parent_id` (null = parent); `program_id`, `contact_id`, `reward_id`; `cost_points` snapshotted at redemption; `state_code` FK; `idempotency_key` unique per tenant; `money_component` BIGINT + `currency_code`, both nullable; never deleted |
| `loyalty.redemption_states` | parametric; `is_system` seeds `requested`, `validated`, `applied`, `rolled_back`, `failed`; not tenant-extensible                                                                                                                                              |

## API *(normative)*

| Endpoint                                     | Class   | Permission                     | Budget       | Idempotent   |
| -------------------------------------------- | ------- | ------------------------------ | ------------ | ------------ |
| `POST /v1/loyalty/redemptions`               | Runtime | `loyalty.redemptions.create`   | p95 \<300 ms | **required** |
| `POST /v1/loyalty/redemptions/{id}/rollback` | Runtime | `loyalty.redemptions.rollback` | p95 \<300 ms | required     |
| `GET /v1/loyalty/members/me`                 | Runtime | member token                   | p95 \<150 ms | —            |

## 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`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
