Reward redemption lifecycle
Status: In progress (demo only). The shop exists only on the unmerged branch
feat/player-shop(commit09c682f). It runs as an in-browser stand-in for a future API,ShopServer, behind a typed transport. Nothing on this page runs inservices/apiat53fd444.
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
- Balance. The balance is the sum of the player's ledger entries. It is never stored (
balanceOfinserver/shopServer.ts). Ledger entry types areEARN,REDEEM,BONUS,EXPIRE,ADJUSTandREVERSE, signed so that debits are negative (model.ts). - Catalogue. For the session's player and market, each reward gets a server-computed
state, a market price and, if short, ashortfall(toShop). Vendor and pricing internals are stripped from the player view (ShopRewardinmodel.ts, test "strips vendor and pricing internals"). - Price. A market override wins over the default points cost (
priceForinserver/rules.ts). The code comment says points never derive from fiat. - Confirm. The UI makes one idempotency key per confirmation and calls
createRedemption(RedemptionServiceinservices.ts). - Fulfil. The server debits points, hands the order to the reward's fulfilment provider and settles asynchronously.
- 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.
How the demo server actually moves through it (server/shopServer.ts):
createRedemptioncreates the record inRESERVED, holds one unit ofLIMITEDstock, appends aREDEEMledger entry for-price, then moves toPOINTS_DEBITEDandFULFILLMENT_PENDINGin the same synchronous step. The response is therefore normallyFULFILLMENT_PENDING.FULFILLED → USEDhappens when the player marks the reward used (markUsed).FULFILLED → EXPIREDhappens lazily when the record is read after itsexpiresAt. The demo only setsexpiresAtwhen the reward's expiry policy text contains "12 months".RESERVED → CANCELLEDandPOINTS_DEBITED → FULFILLMENT_FAILEDare 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,accessinserver/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). accessis the only path to plaintext. It requires the player's own redemption (another player's looks like404 redemption_not_found), statusFULFILLEDorUSED(else409 not_accessible), and at most 10 reveals per player per rolling minute (else429 rate_limited). Each reveal is audited without the secret, and the response carries anexpiresAt10 minutes ahead.
HTTP contract
Status: Proposed — evidence:
HttpShopTransportintransport.ts(branch feat/player-shop). None of these routes exist inservices/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" 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.
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). The only writer sets ledgerAction: 'credit' (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). 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 writes loyalty.reward_redemption and loyalty.mission_progress (analytics/metrics.md).
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.