Scout — Full Product Context → feature documentation

Rewards

A shared primitive — 'this scout has earned something they can redeem in the store' — plus its first consumer, the rebuilt referral ladder.

Summary

A shared primitive — "this scout has earned something they can redeem in the store" — plus its first consumer, the rebuilt referral ladder. A UserReward ledger (backend/prisma/schema.prisma, migration backend/prisma/migrations/20260901180000_user_rewards/migration.sql) records every reward ever earned; RewardsService.grant() (backend/src/rewards/rewards.service.ts:84) reserves a ledger row, mints a single-use Shopify discount code scoped to that reward's kind, and raises a poll-on-open Notification. Four kinds exist today. The three a scout can EARN are $8 or $30 off with a 90-day expiry and no order minimum (minSubtotalCents: 0). A reward that requires a purchase is not a reward, so each is redeemable on its own; redeeming one alone produces a $0 order, and with free shipping standard that order is pure cost. Accepted knowingly — the per-kind cap bounds the exposure, not a minimum.

A minimum could not have bought what it appeared to buy in any case. Shopify counts only the discount's own scoped items toward a minimum purchase amount ("If the discount applies to a specific product or collection, then only these items contribute to the minimum purchase amount" — Shopify Help Center, Amount off discounts). So a product-scoped reward's minimum is either below the item's price, changing nothing, or above it, requiring the scout to buy two of the rewarded item. An earlier revision set the $30 Snapback's minimum to $38 intending "free hat if you also buy a patch"; the patch contributes nothing to that test, so it was buy-one-get-one. Widening the scope is not a fix either — scope also governs what the discount may be spent on, so letting patches satisfy the hat's minimum would let $30 come off a cart of patches. "Buy X, get Y free" needs discountCodeBxgy, a different primitive this feature does not use.

The fourth kind, snapback_bogo, is never earned — it is granted only as free_snapback's fallbackKind once that cap is exhausted, and it is the one kind that carries a minimum. That is not an inconsistency but the same rule read forwards: because Shopify counts only the discount's own scoped items, a $60 minimum on a discount scoped to the $30 hat means exactly "two hats in the cart". $30 off $60 with appliesOnEachItem: false is one hat paid for and one free. It is the single shape a scoped minimum expresses correctly, and it is the same mechanic the old $38 minimum produced by accident on a reward that was supposed to be free.

Why the cap has a fallback rather than a waitlist

Hitting a cap used to set the row waitlisted — a status nothing promotes and nothing expires, so the scout kept a drawer badge for a reward they could never redeem, and (before the CAP_CONSUMING fix) each one permanently burned a slot. A fallbackKind replaces that dead end with something real: the row BECOMES the fallback kind and mints it, so the same milestone pays out a different reward because the free ones ran out. Rewriting the row rather than adding a second one keeps @@unique([userId, sourceRef]) meaningful — one rung, one reward, whichever it turned out to be.

The waitlist branch still exists for a capped kind with no sensible consolation (fallbackKind: null), and is tested by temporarily clearing the snapback's fallback, since no shipping kind reaches it. A test also asserts that every capped kind HAS a fallback, so adding a capped kind cannot reintroduce the dead end by omission. The only current trigger is the referral ladder (backend/src/referral/referral-ladder.ts), repriced 2026-09-03 to three rungs split by what a prize costs: 3 friends → permanent cloud album access (free to give), 5 → the Scout patch, 10 → the Compass Snapback (the first 30 scouts; after that, two-for-one). Nothing pays below three qualified friends and no merchandise below five, and no rung names free_earned_patch any more — it is admin-grant-only now. The ladder is editable at runtime from Admin → Referral Ladder (see referral-and-invite.md), so these thresholds can move without a build. Shipped 2026-09-01, directly on develop, no feature flag.

Status

Fully shipped, not flagged. No feature-flag check gates any code path under backend/src/rewards/ or its mobile/store surfaces. GET /api/rewards/unclaimed-count and the four admin routes are unconditionally live.

Minting works end to end in production as of 2026-09-02. It did not earlier that day — the Shopify app lacked write_discounts/read_discounts and every grant parked as pending — and the fix was two steps, both needed: release an app version carrying the scopes (scout-sync-6), and re-approve the install on mjhd81-vs, because releasing a version does not re-grant on an existing install. Verified afterwards by re-minting a parked row: pendingissued with a live gid://shopify/DiscountCodeNode/… and a 90-day expiry.

User-facing surfaces

How it works

  1. Trigger. Today the only caller of grant() is ReferralQualificationService (backend/src/referral/referral-qualification.service.ts), invoked from the same sync-time qualification pass referral-and-invite.md documents. It grants every ladder rung the referrer's qualified-friend count has reached, not only the highest one — see referral-and-invite.md for why. region:<slug> is reserved in sourceRef's shape for a future region-completion consumer that does not exist yet.

  2. Grant, in order (rewards.service.ts): refuse guests and missing profiles → generate the discount code → prisma.userReward.create() with status: 'pending' and that code already written to the row (the row is reserved before any network call; a P2002 on the (userId, sourceRef) unique constraint means this reward already exists and the call is a no-op) → a non-transactional cap check for capped kinds → mint a single-use Shopify code via RewardsShopifyService.mint() → mark issued with the node id, issuedAt, expiresAt → best-effort notify. A Shopify failure between reserve and mint leaves the row pending (visible in admin, re-mintable via remintPending()) rather than losing the reward or double-minting.

    A stuck row heals itself on the next grant for the same reward. The (userId, sourceRef) unique constraint means a later grant for the same rung raises P2002; instead of reporting duplicate blind, grant() reads the row (finishOrDuplicate) and, if it is still pending with no shopifyDiscountNodeId, delegates to remintPending(). That node-id check — not the status — is what keeps this apart from a double-mint, because the dangerous case is precisely a row that still reads pending after a crash. Anything else (issued, waitlisted, redeemed, expired, revoked, or pending-with-a-node-id) still returns duplicate cheaply. Retries are rate-limited per row by an in-process cooldown (HEAL_COOLDOWN_MS, 15 minutes) so a permanently-failing cause — such as the missing all-patches collection — cannot become a retry storm; a refused attempt returns mint_failed, which leaves Referral.rewardedAt honestly null.

    Reserving the code before the mint is what makes a crash recoverable. Without it, a process death between a successful mint and the "issued" write is indistinguishable from a mint that never ran, and an admin re-mint would put a second live single-use code against one reward — two codes, only the later one revocable, and markRedeemed unable to ever match the first. Persisting it early leaks nothing: toRewardDTO withholds discountCode for every status but issued.

  3. Minting (rewards-shopify.service.ts): resolves the reward's scope handle (a product or collection handle) to a Shopify GID via productByIdentifier/collectionByIdentifier (rewards-shopify.service.ts:143-167), then calls discountCodeBasicCreate with usageLimit: 1, appliesOncePerCustomer: true, customerSelection: { all: true } (forced, not chosen — Shopify has no way to bind a code to a Scout account), combinesWith: { productDiscounts: true, orderDiscounts: false, shippingDiscounts: true } (rewards-shopify.service.ts:83-123).

  4. Cap enforcement (rewards.service.ts, CAP_CONSUMING): only free_snapback carries a cap (30 — reward-kinds.ts). The count query includes pending, issued and redeemed rows and deliberately excludes waitlisted — a waitlist entry is a place in a queue, not an allocated hat. (It used to be counted, which meant every waitlist entry burned a slot permanently: nothing promotes a waitlisted row and the sweep never touches one, so the effective cap ratcheted toward zero while stock sat unsold.) A grant that would push the count past the cap is marked waitlisted instead of minted, and still raises a notification — the calling code explains why: "the scout earned this the moment the milestone was crossed, so a waitlisted outcome still counts". pending is counted: it passed the cap check and is waiting only on a mint, so its slot is genuinely spoken for.

  5. Notify. UserNotificationsService.create() writes a reward_earned Notification row (rewards.service.ts:255-270); failure here is swallowed and logged, never fails the grant.

  6. Redemption. The existing orders/paid webhook handler (backend/src/shopify-webhooks/handlers/orders-paid.handler.ts:113-124) iterates the order's discount_codes[] and calls RewardsService.markRedeemed(code, orderId) (rewards.service.ts:406-420), which matches an issued row by code and marks it redeemed. This runs last and is wrapped in try/catch per code — a failure here must never fail the webhook (a throw would delete the WebhookDelivery row and make Shopify retry an already-processed order).

  7. Expiry sweep. A daily 4AM cron (rewards-expiry.service.ts, chosen to avoid the 3AM test-account cleanup) marks issued rows past expiresAt as expired. That is all it does. This is bookkeeping only — Shopify's own endsAt already stops the code working at checkout.

    It does not touch pending rows. It used to write them off after 7 days to free their capped slot, and that made an earned reward unrecoverable: remintPending refuses a non-pending row, grant() is blocked by the (userId, sourceRef) unique constraint, and the referral that earned it is already stamped qualifiedAt so it is never re-evaluated. Past day 7 the reward was gone through every code path in the repo. A pending row cannot run away with a cap (the cap is checked before the mint, so a kind can never hold more pending rows than its cap), it heals itself on the next grant for that same reward (see #2), and an admin revoke is the way to release one that never will — a decision a person makes rather than one a nightly job makes on their behalf.

    It also does not serve the waitlist. Nothing anywhere promotes a waitlisted row to issued.

  8. Revoke. Admin-only (rewards.service.ts:298-312): deactivates the Shopify discount first (a throw here is not caught, so a failed kill surfaces to the admin as an error rather than a silent success), then marks the row revoked. Order matters — marking the row first and having Shopify reject the deactivation would leave the ledger saying "revoked" while the code still works, with revokedAt now short-circuiting any retry.

  9. Apply to cart. /app/rewards writes the same non-httpOnly scout_discount cookie ad traffic has used for months (store/lib/store/rewards.ts:78-83, applyRewardToCart), then routes back to the store home. createCheckoutSession (pre-existing, store/lib/shopify/cart-actions.ts) already reads this cookie and passes it as discountCodes at checkout. There is no server-side cart on this stack.

Data model

UserReward (user_rewards table, backend/prisma/migrations/20260901180000_user_rewards/migration.sql):

Field Notes
id UUID PK
userId not FK'd to profiles (matches the referral models' pattern)
kind free_earned_patch | free_scout_patch | free_snapback
status pending | issued | waitlisted | redeemed | expired | revoked
sourceRef referral:milestone:<n>; admin:<adminId>:<timestamp> for manual grants; region:<slug> reserved, unused
discountCode reserved on the pending row before the mint is attempted (null only on a waitlisted row, which never got one); the value the orders/paid handler matches on. 48 bits of entropy after a per-kind prefix
shopifyDiscountNodeId admin-only handle, used by revoke()/deactivate(); never returned to the app
minSubtotalCents copied from the kind config at grant time
issuedAt, expiresAt, redeemedAt, revokedAt, orderId lifecycle timestamps

@@unique([userId, sourceRef]) is the whole idempotency guard — a qualification pass that fires twice for the same milestone cannot mint twice. Indexes: (userId, status) for the unclaimed-badge count, (userId, createdAt DESC) for the list query, and (discountCode)unique — for the webhook lookup.

(discountCode) was a plain index and became unique in backend/prisma/migrations/20260902100000_user_rewards_discount_code_unique/migration.sql (hand-written, like every migration here). markRedeemed resolves a redemption with findFirst({ discountCode, status: 'issued' }), which would silently pick one row if two ever shared a code — marking the wrong scout's reward redeemed against someone else's order. Postgres allows any number of NULLs under a unique index, so waitlisted rows are unaffected.

No schema change was needed for the notification: Notification.type is a plain String column (not an enum).

API surface

App-facing, JwtAuthGuard (backend/src/rewards/rewards.controller.ts):

Admin-only, AdminGuard (backend/src/admin/api/admin-api.controller.ts:75-159):

Key files

Configuration and flags

Runbook: the all-patches Shopify collection — DONE 2026-09-02

Created as gid://shopify/Collection/689898815648, handle all-patches, holding 2,246 products. free_earned_patch can now mint; all four reward scopes were verified to resolve to real GIDs against production. Reproducible via backend/src/scripts/create-all-patches-collection.ts (--apply), which is idempotent — it reports an existing collection and changes nothing.

Kept below because the reasoning is what stops someone "simplifying" the scope later.

Why a collection at all. Shopify's DiscountItemsInput — the scope on a basic code discount — accepts exactly three shapes:

all: true                              every product in the store
products:    { productsToAdd: [GID] }  an explicit list of product IDs
collections: { add: [GID] }            an explicit list of collection IDs

There is no tag field and no product-type field. (An earlier design of this feature scoped by productTypes; that key does not exist in the API and every grant would have thrown. See rewards-shopify.service.ts:100-125.) So "any patch" has to be expressed as a collection ID — not because a collection is wanted, but because it is the only container the discount API accepts. The alternative, enumerating every patch product GID into each discount, is worse on every axis: a large payload per mint, a Shopify-imposed list cap, and any patch added later silently falls outside every already-issued reward.

This is NOT a Scout collection. The word means two unrelated things here, and conflating them is the easy mistake:

Scout collection Shopify collection
Lives in Scout's collections table Shopify catalog
Example National Parks, Civil War Battlefields all-patches
Drives app shelves, sync, earn-a-set progress nothing in Scout
Users see it yes no

Nothing in store/lib/ reads Shopify collections — there are zero call sites. Scout's shelves are built entirely from Scout's own database. all-patches is therefore invisible plumbing: no app surface, no store shelf, no content publish, no sales-channel publication required. It exists only so a discount code has something to point at, and it must never be added to Scout's collections table.

Steps. In Shopify Admin → Products → Collections → Create collection:

  1. Title anything readable (e.g. "All patches — rewards scope"). What matters is the handle, which must be exactly all-patches to match ALL_PATCHES_COLLECTION (reward-kinds.ts). Shopify derives the handle from the title, so check and correct it in the SEO/URL field.
  2. Collection type: Automated (not Manual). Automated keeps itself current as patches are added; a manual collection would silently go stale and new patches would fall outside the reward.
  3. Condition: Product type is equal to Iron-on Patch. That is the type all 2,166 patch products already carry, so no tagging work is needed. A product-tag rule works equally well if a tag is preferred later — the requirement is only that some rule fills the collection automatically.
  4. Leave it unpublished from every sales channel. resolveScope looks it up through the Admin API by handle (collectionByIdentifier), which does not care about publication, and leaving it unpublished keeps it off the storefront.

Deliberately excluded: scout-patch. It is the only product typed Patch rather than Iron-on Patch, and it is what the 3-friend rung (free_scout_patch) hands out. A rule on Iron-on Patch therefore leaves it out, which is intended — it keeps rungs 1 and 3 genuinely different rewards and preserves the ladder's escalation in KIND, not just value (see the reward-kinds.ts header). Retyping scout-patch to Iron-on Patch would fold it in and let the 1-friend reward buy the branded emblem; do that only as a deliberate product decision, and update this section if so.

Verifying it worked. Grant a reward to a test account and confirm the row reaches issued with a discount_code. No backfill is needed for rewards that failed before the collection existed: they stay pending, and the next qualification for that referrer re-attempts the mint automatically (finishOrDuplicate), as does the admin re-mint button.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Backend (backend/src/rewards/):

Store / mobile:

Maestro (added 2026-09-02, run against a production-simulator build on prod):

Open questions