# Reward redemption lifecycle

> **Status: In progress (demo only).** The shop exists only on the unmerged branch `feat/player-shop` (commit `09c682f`). It runs as an in-browser stand-in for a future API, `ShopServer`, behind a typed transport. Nothing on this page runs in `services/api` at `53fd444`.

The demo files are not on this branch, so they are cited as paths, not links. Read them with `git show feat/player-shop:<path>`. All paths are under `demos/apps/portals/src/shop/`:

| File (branch feat/player-shop) | What it holds |
|---|---|
| `server/shopServer.ts` | The stand-in server: catalogue, redemption, reconciliation, access, ledger |
| `server/rules.ts` | Eligibility, pricing, the state machine and error codes. Pure functions, written to move into the API unchanged. |
| `server/fulfillment.ts` | The fulfilment provider interface and simulated adapters |
| `server/vault.ts` | AES-GCM secret vault and code masking |
| `server/network.ts` | Generated back-office activity for the staff store screens |
| `model.ts`, `transport.ts`, `services.ts` | Shared types, the HTTP contract, and the UI service layer |
| `server/shopServer.test.ts` | Tests for the rules below |

`services.ts` uses `ShopServer` unless `VITE_SHOP_API=http`, in which case it uses `HttpShopTransport` against an API that does not exist yet.

## Flow: points → catalogue → redemption

1. **Balance.** The balance is the sum of the player's ledger entries. It is never stored (`balanceOf` in `server/shopServer.ts`). Ledger entry types are `EARN`, `REDEEM`, `BONUS`, `EXPIRE`, `ADJUST` and `REVERSE`, signed so that debits are negative (`model.ts`).
2. **Catalogue.** For the session's player and market, each reward gets a server-computed `state`, a market price and, if short, a `shortfall` (`toShop`). Vendor and pricing internals are stripped from the player view (`ShopReward` in `model.ts`, test "strips vendor and pricing internals").
3. **Price.** A market override wins over the default points cost (`priceFor` in `server/rules.ts`). The code comment says points never derive from fiat.
4. **Confirm.** The UI makes one idempotency key per confirmation and calls `createRedemption` (`RedemptionService` in `services.ts`).
5. **Fulfil.** The server debits points, hands the order to the reward's fulfilment provider and settles asynchronously.
6. **Access.** Once fulfilled, the player reveals the code or claim link through a separate, rate-limited access call.

The player is always taken from the session. No transport method accepts a player ID (`transport.ts` header comment).

## Reward eligibility states

`rewardState` in `server/rules.ts` returns the first matching state, in this order. The order is deliberate: a reward that is not on sale says so before the player's balance is mentioned.

| Order | State | Condition | Refusal on redeem |
|---|---|---|---|
| 1 | `EXPIRED` | `validTo` is in the past | `409 reward_unavailable` |
| 2 | `COMING_SOON` | Reward inactive, or `validFrom` in the future | `409 reward_unavailable` |
| 3 | `UNAVAILABLE_MARKET` | Player's market not in `markets`, or in `excludedMarkets` | `409 market_unavailable` |
| 4 | `OUT_OF_STOCK` | `LIMITED` stock with none remaining | `409 out_of_stock` |
| 5 | `INSUFFICIENT_POINTS` | Balance below the market price | `409 insufficient_points` |
| 6 | `LIMITED_STOCK` | `LIMITED` stock at or below `lowStockThreshold` (default 5) | None: redeemable |
| 7 | `AVAILABLE` | Otherwise | None: redeemable |

A refusal never touches points (test "checks balance, market, stock and window server-side without touching points").

## Redemption state machine

The allowed transitions are the `NEXT` table in `server/rules.ts`. Any other transition throws `invalid_redemption_transition:<from>-><to>`. Terminal states are `POINTS_REVERSED`, `USED`, `EXPIRED` and `CANCELLED`.

```mermaid
stateDiagram-v2
    [*] --> RESERVED
    RESERVED --> POINTS_DEBITED
    RESERVED --> CANCELLED
    POINTS_DEBITED --> FULFILLMENT_PENDING
    POINTS_DEBITED --> FULFILLMENT_FAILED
    FULFILLMENT_PENDING --> FULFILLED
    FULFILLMENT_PENDING --> FULFILLMENT_FAILED
    FULFILLED --> USED
    FULFILLED --> EXPIRED
    FULFILLMENT_FAILED --> POINTS_REVERSED
    POINTS_REVERSED --> [*]
    USED --> [*]
    EXPIRED --> [*]
    CANCELLED --> [*]
```

How the demo server actually moves through it (`server/shopServer.ts`):

- `createRedemption` creates the record in `RESERVED`, holds one unit of `LIMITED` stock, appends a `REDEEM` ledger entry for `-price`, then moves to `POINTS_DEBITED` and `FULFILLMENT_PENDING` in the same synchronous step. The response is therefore normally `FULFILLMENT_PENDING`.
- `FULFILLED → USED` happens when the player marks the reward used (`markUsed`).
- `FULFILLED → EXPIRED` happens lazily when the record is read after its `expiresAt`. The demo only sets `expiresAt` when the reward's expiry policy text contains "12 months".
- `RESERVED → CANCELLED` and `POINTS_DEBITED → FULFILLMENT_FAILED` are allowed by the table, but no demo code path uses them.

## Idempotency

| Aspect | Behaviour (`createRedemption`) |
|---|---|
| Key | Client-generated, one per confirmation (`crypto.randomUUID()` in `services.ts`). A retry of the same confirmation reuses it. Required, at most 128 characters, or `400 invalid_request`. |
| Scope | Per player: the stored key is `<playerId>:<idempotencyKey>`. |
| Replay | Same key, same reward: returns the original `{ redemptionId, status }` with the current status. No second debit, even for concurrent calls (test "is idempotent"). |
| Conflict | Same key, different reward: `409 idempotency_conflict`. |
| Upstream | The redemption ID is passed to the provider as its idempotency key, so a resend cannot create a second order (`FulfillmentRequest.redemptionId` in `server/fulfillment.ts`). |

The replay check runs before the eligibility checks. Everything up to the debit is synchronous, so two calls with one key cannot both pass the checks (code comment on `createRedemption`).

## Fulfilment adapters

Every reward source sits behind `RewardFulfillmentProvider` (`getCatalog`, `quote`, `fulfill`, `getStatus`) in `server/fulfillment.ts`. All the adapters on the branch are **simulations with no network calls**. They exist to exercise the outcomes the engine must handle:

| Adapter kind | Simulated behaviour |
|---|---|
| Gift-card aggregator | Returns a code immediately |
| Second aggregator | Catalogue can lag reality: refuses some product/market pairs definitively (`FAILED`, `MARKET_UNAVAILABLE`) |
| Operator reward | Credits the operator account by the operator-scoped alias. The first call returns `UNKNOWN` (timeout) after the order was accepted. |
| Game provider entitlement | Returns `PENDING`; activation appears on the second status check |
| Claim link | Returns a single-use claim URL; delivery details are collected by the partner, not PlayerPal |
| Manual | Stays `PENDING` until staff complete it from the back office |

Fulfilment requests carry no email, name or phone. Partner rewards receive that partner's scoped alias, never the global PlayerPal ID (module comment in `server/fulfillment.ts`). Which real vendors would be used is not decided in the repo.

## Failure handling

| Provider outcome | What the server does |
|---|---|
| `FULFILLED` | Seal any secret in the vault, record a masked code, move to `FULFILLED` |
| `FAILED` (definitive) | Move to `FULFILLMENT_FAILED`, append a `REVERSE` ledger entry for `+price`, release held stock, set `pointsReturned`, move to `POINTS_REVERSED` |
| `PENDING` or `UNKNOWN` | Stay in `FULFILLMENT_PENDING` and schedule a status check (`reconcile`). Never reversed. |
| Status `NOT_FOUND` | The provider never received the order: resend with the same key, up to `maxAttempts` sends (default 3) |

If a pending or unknown outcome is still unresolved after `maxPolls` status checks (default 6, every `pollMs` = 2.5 s by default), or sends are exhausted, the record is flagged `needsReview`. It stays `FULFILLMENT_PENDING` with points still debited, and the staff redemption list shows "Outcome unknown: needs review". Late or duplicate answers are ignored once the record has left `FULFILLMENT_PENDING` (`settle`).

Evidence: tests "reverses points and releases stock on a definitive failure", "holds an uncertain outcome instead of reversing…" and "resends with the same key only when the provider never received the order…" in `server/shopServer.test.ts`.

## Secure reward access

> **Status: In progress (demo only)** — evidence: `server/vault.ts`, `access` in `server/shopServer.ts` (branch feat/player-shop)

- Codes and claim links are encrypted with AES-GCM (256-bit key, random 12-byte IV) before storage. The redemption ID is the additional authenticated data, so a ciphertext cannot be moved onto another redemption.
- The key is generated in the browser per session. It is exportable only so the demo survives a reload, and the demo persists the vault in `sessionStorage`. **This key handling is demo-only.**
- **Proposed:** in the API the key would come from a KMS (comment in `server/vault.ts`). No KMS integration exists in the repo.
- Lists and status responses carry only a masked hint (all but the last group replaced, `maskCode`).
- `access` is the only path to plaintext. It requires the player's own redemption (another player's looks like `404 redemption_not_found`), status `FULFILLED` or `USED` (else `409 not_accessible`), and at most 10 reveals per player per rolling minute (else `429 rate_limited`). Each reveal is audited without the secret, and the response carries an `expiresAt` 10 minutes ahead.

## HTTP contract

> **Status: Proposed** — evidence: `HttpShopTransport` in `transport.ts` (branch feat/player-shop). None of these routes exist in `services/api`.

Session cookie only, snake_case on the wire, no player ID in any URL or body.

| Method and path | Purpose |
|---|---|
| `GET /v1/shop/catalog` | Catalogue page with state, price, balance and market |
| `GET /v1/shop/rewards/:id` | Reward detail with balance after redemption |
| `POST /v1/shop/redemptions` | `{ reward_id, idempotency_key }` → `{ redemption_id, status }` |
| `GET /v1/shop/redemptions`, `GET /v1/shop/redemptions/:id` | The player's redemptions |
| `POST /v1/shop/redemptions/:id/access` | Reveal code or link |
| `POST /v1/shop/redemptions/:id/used` | Mark used |
| `GET /v1/me/points`, `GET /v1/me/points/ledger` | Balance summary and ledger |
| `POST /v1/shop/events` | Product analytics events (IDs only) |
| `/v1/staff/store/*` | Back office: overview, rewards, redemptions, providers, manual completion |

## Spec versus demo

The identity spec covers redemption in [§36.5 "View and redeem points"](../../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md) and lists the endpoints in §37.11.

| Topic | Spec | Demo |
|---|---|---|
| Endpoints | `GET /v1/me/wallet`, `POST /v1/me/redemptions`, `GET /v1/me/redemptions/{redemptionId}` | `/v1/me/points` and `/v1/shop/redemptions*` |
| Reservation | "Atomic reserve + idempotency", then a pending redemption and asynchronous fulfilment, ending fulfilled or failed | Reserve and debit in one synchronous step, then async fulfilment, with the richer state set above |
| Idempotency | Commands require an `Idempotency-Key` header with at least 24-hour retention (§37.1) | Key sent in the request body; no retention period implemented |
| Availability | If PlayerPal is unavailable, redemption is unavailable; this never blocks deposits, withdrawals, wagering or settlement | Consistent: the shop is PlayerPal-only |
| Assurance | `A0_PASSWORD_ONLY` accounts may not redeem (assurance table in the spec) | No assurance check in the shop code |

Which endpoint names and key placement win has not been decided. See [open-questions.md](../open-questions.md).

## Gap to production

> **Status: Proposed** — nothing below is decided

**The points ledger is credit-only.** `points_ledger` has a `ledger_action` column defaulting to `credit` and a required `activity_event_id` ([points-ledger.entity.ts](../../services/api/src/db/entities/points-ledger.entity.ts)). The only writer sets `ledgerAction: 'credit'` ([activity.store.ts](../../services/api/src/db/repositories/activity.store.ts)). There is no debit or reversal path, and a redemption debit has no activity event to reference.

**The redemption table does not match the shop's states.** `loyalty.reward_redemption` allows only `PENDING`, `FULFILLED`, `FAILED` and `CANCELLED` ([CanonicalFacts migration](../../services/api/src/db/migrations/1736160000019-CanonicalFacts.ts)). The shop uses nine states, including `POINTS_REVERSED`, `USED` and `EXPIRED`. How these map, or whether the table changes, is unresolved.

**The overview loyalty KPIs read seed data.** Only [seed-kpi-sim.ts](../../services/api/src/scripts/seed-kpi-sim.ts) writes `loyalty.reward_redemption` and `loyalty.mission_progress` ([analytics/metrics.md](../analytics/metrics.mdx)).

**Also not built:** the `/v1/shop/*` routes, KMS-backed vault keys, real fulfilment adapters, a durable idempotency store, a review queue for `needsReview` redemptions, and the spec's assurance-level gate.
