> ## 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.

# Rewards Catalog

> After this ships, a tenant can define what points actually buy, and a member can see it.

## Context

Points with nothing to spend them on are a liability with no marketing value. The catalog is what
turns the ledger into a program.

The design decision that matters is DEC-B4: `reward_types` **is** tenant-extensible, and it is one
of only two catalogs in the whole platform that are. The reason is the rule stated in the data
standard — a catalog is tenant-extensible when its values are the tenant's own business taxonomy,
and never when they govern a state machine. "Free shipping", "a free coffee", "10% off the next
order" are a gym's or a bakery's vocabulary, not ours. What each type *means operationally* is
still the tenant's business, which is why fulfilment is deliberately out of scope.

## Scope *(normative)*

* `loyalty.rewards`: catalog per program, with `cost_points` against a specific point currency.
* `loyalty.reward_types`: parametric and **tenant-extensible**, with `is_system` seeds.
* Availability windows, stock limits and per-contact limits.
* Tier-based cost overrides, reserved but unused until FS-LOY-0010.
* Management CRUD and a member-facing read of the available catalog.

## Non-scope *(normative)*

* Redeeming a reward — FS-LOY-0006. This feature defines what exists; spending is a separate act
  with entirely different failure modes.
* **Fulfilment.** We record that a reward was redeemed; shipping the coffee is the tenant's
  business process. Softcrum is not a fulfilment system and this is not a phase-one gap.
* Money-priced rewards. `cost_points` is points only; mixed points + money is the redemption seam
  (DEC-H3), not a catalog concern.

## Behaviour *(normative)*

1. A reward belongs to exactly one program and is priced in exactly one point currency. Pricing a
   reward in status points is FORBIDDEN — status points qualify, they do not buy.
2. `cost_points` is INTEGER, never a monetary amount, and never in a column shared with one
   (DEC-B5).
3. A reward with stock decrements it on redemption, inside the redemption transaction. Stock is
   never advisory.
4. A reward is **never hard-deleted**: it is deactivated. Historic redemptions must keep resolving
   to what the member actually received.
5. Changing `cost_points` does not alter past redemptions — the redemption records the cost that
   applied at the time.
6. Tenant-created `reward_types` rows have `is_system = false` and are scoped to that tenant. A
   tenant can never modify or deactivate an `is_system` row.
7. The member-facing read returns only rewards that are active, in window, in stock, and within
   the member's per-contact limit.

## Data *(normative)*

| Table                  | Key invariants                                                                                                                                                                                                                                                            |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.rewards`      | `program_id`; `type_code` FK; `cost_points` INTEGER > 0; `point_currency_id` must be of kind `redeemable`; `available_from`/`available_until` nullable; `stock_total`/`stock_remaining` nullable = unlimited; `per_contact_limit` nullable; `is_active`; soft-delete only |
| `loyalty.reward_types` | parametric; **tenant-extensible** (DEC-B4); `is_system` seeds `discount`, `free_item`, `free_shipping`, `experience`, `donation`, `external_voucher`                                                                                                                      |

## API *(normative)*

| Endpoint                             | Class      | Permission                               | Budget       |
| ------------------------------------ | ---------- | ---------------------------------------- | ------------ |
| `GET/POST/PATCH /v1/loyalty/rewards` | Management | `loyalty.rewards.{read\|create\|update}` | p95 \<1 s    |
| `GET /v1/loyalty/members/me/rewards` | Runtime    | member token                             | p95 \<150 ms |

## Events *(normative)*

None. A catalog change is an administrative act covered by the audit log; no consumer reacts to it.

## Acceptance criteria *(normative)*

1. A reward priced in a `status` currency is rejected at creation with a typed error.
2. A tenant-created reward type is visible only to that tenant — proven by a cross-tenant read.
3. A tenant attempting to deactivate an `is_system` reward type is rejected.
4. Deactivating a reward keeps every historic redemption resolvable to its name and cost.
5. Changing `cost_points` from 2000 to 2500 leaves a redemption made at 2000 reporting 2000.
6. `members/me/rewards` p95 \<150 ms over a 500-reward catalog and excludes out-of-stock,
   out-of-window and limit-exhausted rewards.
7. **Negative:** no endpoint allows a hard delete of a reward.

## Execution

Single slice, synchronous command. Schema in TS-001, endpoints in the module's first API slice.

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