Skip to main content
In development · demoInternalVerified against 53fd444Markdown

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.tsThe stand-in server: catalogue, redemption, reconciliation, access, ledger
server/rules.tsEligibility, pricing, the state machine and error codes. Pure functions, written to move into the API unchanged.
server/fulfillment.tsThe fulfilment provider interface and simulated adapters
server/vault.tsAES-GCM secret vault and code masking
server/network.tsGenerated back-office activity for the staff store screens
model.ts, transport.ts, services.tsShared types, the HTTP contract, and the UI service layer
server/shopServer.test.tsTests 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.

OrderStateConditionRefusal on redeem
1EXPIREDvalidTo is in the past409 reward_unavailable
2COMING_SOONReward inactive, or validFrom in the future409 reward_unavailable
3UNAVAILABLE_MARKETPlayer's market not in markets, or in excludedMarkets409 market_unavailable
4OUT_OF_STOCKLIMITED stock with none remaining409 out_of_stock
5INSUFFICIENT_POINTSBalance below the market price409 insufficient_points
6LIMITED_STOCKLIMITED stock at or below lowStockThreshold (default 5)None: redeemable
7AVAILABLEOtherwiseNone: 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):

  • 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​

AspectBehaviour (createRedemption)
KeyClient-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.
ScopePer player: the stored key is <playerId>:<idempotencyKey>.
ReplaySame key, same reward: returns the original { redemptionId, status } with the current status. No second debit, even for concurrent calls (test "is idempotent").
ConflictSame key, different reward: 409 idempotency_conflict.
UpstreamThe 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 kindSimulated behaviour
Gift-card aggregatorReturns a code immediately
Second aggregatorCatalogue can lag reality: refuses some product/market pairs definitively (FAILED, MARKET_UNAVAILABLE)
Operator rewardCredits the operator account by the operator-scoped alias. The first call returns UNKNOWN (timeout) after the order was accepted.
Game provider entitlementReturns PENDING; activation appears on the second status check
Claim linkReturns a single-use claim URL; delivery details are collected by the partner, not PlayerPal
ManualStays 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 outcomeWhat the server does
FULFILLEDSeal 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 UNKNOWNStay in FULFILLMENT_PENDING and schedule a status check (reconcile). Never reversed.
Status NOT_FOUNDThe 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 pathPurpose
GET /v1/shop/catalogCatalogue page with state, price, balance and market
GET /v1/shop/rewards/:idReward detail with balance after redemption
POST /v1/shop/redemptions{ reward_id, idempotency_key } → { redemption_id, status }
GET /v1/shop/redemptions, GET /v1/shop/redemptions/:idThe player's redemptions
POST /v1/shop/redemptions/:id/accessReveal code or link
POST /v1/shop/redemptions/:id/usedMark used
GET /v1/me/points, GET /v1/me/points/ledgerBalance summary and ledger
POST /v1/shop/eventsProduct 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.

TopicSpecDemo
EndpointsGET /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 failedReserve and debit in one synchronous step, then async fulfilment, with the richer state set above
IdempotencyCommands require an Idempotency-Key header with at least 24-hour retention (§37.1)Key sent in the request body; no retention period implemented
AvailabilityIf PlayerPal is unavailable, redemption is unavailable; this never blocks deposits, withdrawals, wagering or settlementConsistent: the shop is PlayerPal-only
AssuranceA0_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.