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: pending → issued
with a live gid://shopify/DiscountCodeNode/… and a 90-day expiry.
User-facing surfaces
- Drawer → MORE → Rewards row (
mobile/src/components/navigation/drawerSections.ts:317-329), brass count badge whenrewardsUnclaimedCount > 0(GET /api/rewards/unclaimed-count,mobile/src/domain/api.ts:378). Always visible for any signed-in or guest session (isVisible: (ctx) => ctx.hasSession) — the row itself is reachable by a guest, even thoughgrant()refuses to ever pay one a reward (see How it works). scout://rewards(mobile/app/rewards.tsx) — a real route file (the deep-link drift test requires one), which immediately<Redirect>s into the store WebView at/app/rewards(storeHref('/rewards'),mobile/app/rewards.tsx:9-12). There is no native rewards-list screen; the list itself is web content served by the storefront project./app/rewards(store/app/app/rewards/page.tsx) — the actual rewards page: Available / Used / Expired sections (store/lib/store/rewards.ts:39-67,partitionRewards), each row anApply to cartbutton for anissuedreward, or "Reserved for you — we'll sort this one out by hand" copy for awaitlistedone (store/components/store/RewardCard.tsx,rewardCardAction()/WAITLISTED_COPYinstore/lib/store/rewards.ts) — accurate copy: the waitlist is fulfilled by hand and names no channel or date, see Edge cases.- Admin → Store → Rewards (
/admin/rewardsin the admin SPA,backend/admin-ui/src/components/layout/Sidebar.tsx:106,backend/admin-ui/src/App.tsx:95) — KPI strip (issued/waitlisted/redeemed/ expired), search/filter list, a manual-grant form, and per-row Revoke/Re-mint actions (backend/admin-ui/src/pages/RewardsPage.tsx).
How it works
-
Trigger. Today the only caller of
grant()isReferralQualificationService(backend/src/referral/referral-qualification.service.ts), invoked from the same sync-time qualification passreferral-and-invite.mddocuments. It grants every ladder rung the referrer's qualified-friend count has reached, not only the highest one — seereferral-and-invite.mdfor why.region:<slug>is reserved insourceRef's shape for a future region-completion consumer that does not exist yet. -
Grant, in order (
rewards.service.ts): refuse guests and missing profiles → generate the discount code →prisma.userReward.create()withstatus: 'pending'and that code already written to the row (the row is reserved before any network call; aP2002on 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 viaRewardsShopifyService.mint()→ markissuedwith the node id,issuedAt,expiresAt→ best-effort notify. A Shopify failure between reserve and mint leaves the rowpending(visible in admin, re-mintable viaremintPending()) 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 raisesP2002; instead of reportingduplicateblind,grant()reads the row (finishOrDuplicate) and, if it is stillpendingwith noshopifyDiscountNodeId, delegates toremintPending(). 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 readspendingafter a crash. Anything else (issued, waitlisted, redeemed, expired, revoked, or pending-with-a-node-id) still returnsduplicatecheaply. Retries are rate-limited per row by an in-process cooldown (HEAL_COOLDOWN_MS, 15 minutes) so a permanently-failing cause — such as the missingall-patchescollection — cannot become a retry storm; a refused attempt returnsmint_failed, which leavesReferral.rewardedAthonestly 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
markRedeemedunable to ever match the first. Persisting it early leaks nothing:toRewardDTOwithholdsdiscountCodefor every status butissued. -
Minting (
rewards-shopify.service.ts): resolves the reward's scope handle (a product or collection handle) to a Shopify GID viaproductByIdentifier/collectionByIdentifier(rewards-shopify.service.ts:143-167), then callsdiscountCodeBasicCreatewithusageLimit: 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). -
Cap enforcement (
rewards.service.ts,CAP_CONSUMING): onlyfree_snapbackcarries a cap (30 —reward-kinds.ts). The count query includespending,issuedandredeemedrows and deliberately excludeswaitlisted— 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 markedwaitlistedinstead of minted, and still raises a notification — the calling code explains why: "the scout earned this the moment the milestone was crossed, so awaitlistedoutcome still counts".pendingis counted: it passed the cap check and is waiting only on a mint, so its slot is genuinely spoken for. -
Notify.
UserNotificationsService.create()writes areward_earnedNotificationrow (rewards.service.ts:255-270); failure here is swallowed and logged, never fails the grant. -
Redemption. The existing
orders/paidwebhook handler (backend/src/shopify-webhooks/handlers/orders-paid.handler.ts:113-124) iterates the order'sdiscount_codes[]and callsRewardsService.markRedeemed(code, orderId)(rewards.service.ts:406-420), which matches anissuedrow by code and marks itredeemed. This runs last and is wrapped in try/catch per code — a failure here must never fail the webhook (a throw would delete theWebhookDeliveryrow and make Shopify retry an already-processed order). -
Expiry sweep. A daily 4AM cron (
rewards-expiry.service.ts, chosen to avoid the 3AM test-account cleanup) marksissuedrows pastexpiresAtasexpired. That is all it does. This is bookkeeping only — Shopify's ownendsAtalready stops the code working at checkout.It does not touch
pendingrows. It used to write them off after 7 days to free their capped slot, and that made an earned reward unrecoverable:remintPendingrefuses a non-pendingrow,grant()is blocked by the(userId, sourceRef)unique constraint, and the referral that earned it is already stampedqualifiedAtso 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 adminrevokeis 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
waitlistedrow toissued. -
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 rowrevoked. Order matters — marking the row first and having Shopify reject the deactivation would leave the ledger saying "revoked" while the code still works, withrevokedAtnow short-circuiting any retry. -
Apply to cart.
/app/rewardswrites the same non-httpOnlyscout_discountcookie 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 asdiscountCodesat 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):
GET /api/rewards— the caller's own rewards, newest first, mapped throughtoRewardDTO(rewards.controller.ts:40-61).discountCodeis withheld for every status exceptissued— includingrevoked, whose row still carries a real, previously-minted code in the DB (revoke()never nulls it) andpending/redeemed, which have nothing usable.GET /api/rewards/unclaimed-count— count ofissued+waitlistedrows, the drawer badge's source.
Admin-only, AdminGuard (backend/src/admin/api/admin-api.controller.ts:75-159):
GET /api/admin/rewards?kind=&status=— up to 500 rows, joined with the owner'shandle.POST /api/admin/rewards/grant— body{ userId, kind }; builds asourceRefviabuildAdminGrantSourceRef(backend/src/admin/api/admin-rewards.helpers.ts) keyed by admin id and timestamp, deliberately not idempotent across time — a support agent re-granting the same kind is a replacement, not a duplicate to collapse.POST /api/admin/rewards/:id/revoke— callsRewardsService.revoke(); lets a Shopify-side failure propagate as a 500.POST /api/admin/rewards/:id/remint— callsRewardsService.remintPending(); refuses anything not currentlypending. It asks Shopify whether a discount already exists for the code the row reserved (codeDiscountNodeByCode,RewardsShopifyService.findByCode()): a hit is adopted (node id recorded, the discount's ownendsAtwritten toexpiresAt) rather than minting a second one; a miss mints under that same code. One reward can therefore only ever produce one Shopify code, however many times the re-mint is retried. On success it raises thereward_earnednotification the failed grant never got to send. Sincegrant()self-heals (How it works #2) this button is the manual override, not the only recovery path — it is the way to force an attempt inside the cooldown, or to finish a reward whose scout will never trigger another grant.
Key files
backend/src/rewards/reward-kinds.ts— the economics registry: labels,minSubtotalCents,expiryDays,cap,amountOffUsd,scope, for all three kinds. The one place to change a threshold or which product a reward points at.backend/src/rewards/rewards.service.ts—grant,revoke,remintPending,markRedeemed,expireStale,listForUser,unclaimedCount.backend/src/rewards/rewards-shopify.service.ts—mint,deactivate,findByCode(the re-mint recovery lookup), scope-handle-to-GID resolution,buildDiscountInput,generateRewardCode(per-kind prefix:SCOUT-PATCH-,SCOUT-EMBLEM-,SCOUT-SNAPBACK-, plus 48 bits of hex).backend/src/rewards/rewards-expiry.service.ts— the 4AM cron sweep.backend/src/rewards/rewards.controller.ts—RewardDTO/toRewardDTO, the two app-facing routes.backend/src/admin/api/admin-api.controller.ts:75-159,admin-rewards.helpers.ts— the four admin routes andsourceRefbuilder.backend/src/shopify-webhooks/handlers/orders-paid.handler.ts:113-124— the redemption hook, appended to the existingorders/paidhandler.backend/src/referral/referral-ladder.ts—DEFAULT_LADDER, the rung ↔ reward-kind mapping; seereferral-and-invite.mdfor the full ladder doc.backend/src/referral/referral-qualification.service.ts— the one caller ofgrant().backend/prisma/migrations/20260901180000_user_rewards/migration.sqland.../20260902100000_user_rewards_discount_code_unique/migration.sql— hand-written SQL (prisma migrateis unusable in this repo).backend/admin-ui/src/pages/RewardsPage.tsx— the admin ledger screen.store/app/app/rewards/page.tsx,store/lib/store/rewards.ts(Reward,partitionRewards,applyRewardToCart,rewardCardAction,WAITLISTED_COPY),store/components/store/RewardCard.tsx— the in-app store surface. The card's per-status decision (waitlisted copy vs. Apply button vs. nothing) lives inrewardCardAction(), not branched in JSX, so it has one tested source of truth.mobile/app/rewards.tsx— thescout://rewardsredirect.mobile/src/components/navigation/drawerSections.ts:317-329— the drawer row and its badge.mobile/src/hooks/useRewards.ts—useRewards()(full list) anduseRewardsUnclaimedCount()(drawer badge). Only the latter has a caller today (CustomDrawer.tsx:151) — see Edge cases.mobile/src/query/queries/rewards.ts— query definitions, 1-minute list / 30-second badge stale times, mirroring notifications.mobile/src/components/notifications/notificationRoute.ts:20,35,72—reward_earnedcopy and its hardcoded/rewardsroute.mobile/src/domain/referralLadder.ts—REWARD_NOUNS, the one place a reward kind is mapped to display copy on the invite screen's ladder.
Configuration and flags
- No feature flag gates any part of this surface.
- The economics (
minSubtotalCents,expiryDays,cap,amountOffUsd,scope) are hardcoded constants inreward-kinds.ts, notAppConfig— changing them is a code change with its own unit test (reward-kinds.spec.ts), not a runtime toggle. - The referral ladder that triggers grants is
AppConfig-tunable (referral_ladder, seereferral-and-invite.md) — a milestone naming a reward kind this build does not recognize is dropped byparseLadderrather than crashing (referral-ladder.ts'sisRewardKindfilter).
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:
- Title anything readable (e.g. "All patches — rewards scope"). What matters
is the handle, which must be exactly
all-patchesto matchALL_PATCHES_COLLECTION(reward-kinds.ts). Shopify derives the handle from the title, so check and correct it in the SEO/URL field. - 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.
- 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. - Leave it unpublished from every sales channel.
resolveScopelooks 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
- A scope change needs the INSTALL re-approved, not just a new app version.
Learned the hard way 2026-09-02.
scout-sync-6went out carryingread_discounts,write_discountsand the very next token exchange was still refused — firstwrite_discountsondiscountCodeBasicCreate, then, as the re-mint got one step further,read_discountsoncodeDiscountNodeByCode. The app version describes what the app MAY request; the install records what it was GRANTED, and only re-approving it (Dev Dashboard → app → Overview → Install app) updates that. Two related traps: the app'sapp_urlishttps://example.com, so approving lands you on a blank "Example Domain" page that looks like a failure and is not; and restarting the backend in the same minute as the release caches a token minted against the OLD scopes — the client-credentials token is held in-process for ~24h, so restart after the approval, not before. Its scope is no longer the blocker — resolved 2026-09-02 — thefree_earned_patchcannot be minted in production.all-patchescollection now exists with 2,246 products. (The token scope above still blocks it, along with every other kind.) The mechanism below still describes what happens if the scope ever fails to resolve again. Its scope is the Shopify collection handleall-patches(reward-kinds.ts,ALL_PATCHES_COLLECTION).resolveScope()throws when the handle fails to resolve to a GID (rewards-shopify.service.ts:143-166) rather than minting a discount that buys nothing — a real, current limitation, not a hypothetical. A 1-friend referral grant today leaves the ledger rowpendingand logs the failure; nothing is lost, but nothing is redeemable until the collection is created in Shopify. That row is recoverable: it stayspendingindefinitely (the sweep no longer writes it off), it is re-mintable from admin the moment the collection exists, and the next grant for that rung re-attempts the mint automatically (How it works #2) — which for the 1-friend rung is the next friend who qualifies, because the ladder back-fills every rung reached. Until the collection exists it simply fails the same way each time, at most once per 15 minutes per row. Fixing this is a one-time ~2-minute setup in Shopify Admin — see "Runbook: creating theall-patchesShopify collection" above.- A
pendingrow holds its capped slot until it heals or a person releases it. The sweep will not expire it (see How it works #7). It is retried automatically on every later grant for that reward, but the trigger is a grant, not a timer — so afree_snapbackwhose scout never qualifies again and whose mint keeps failing occupies one of the 20 slots until an admin revokes it. That is the deliberate trade: losing an earned reward to a nightly job is worse than an admin having to make the write-off call. A kind can never hold more pending rows than its cap, because the cap is checked before the mint. - The global snapback cap is not transactional. Two concurrent grants
for different users can both read the same under-cap count before either
writes, and both mint — over-issuing past 20 by a small amount under true
concurrency (
rewards.service.ts:135-146, explicit comment). Accepted knowingly: grant volume is low and it backs a small physical inventory. - The snapback waitlist has no automated fulfilment, and the scout is not
notified when a cap frees up. Nothing promotes a
waitlistedreward when a slot opens (a revoke, an expiry) — it is worked by hand from admin. A waitlisted row no longer consumes the cap it is waiting on, so a freed slot goes to whoever next crosses the rung rather than to the head of the queue. Serving the queue in order is a manual admin grant. The store copy matches this exactly:rewardCardAction()(store/lib/store/rewards.ts) rendersWAITLISTED_COPY, "Reserved for you — we'll sort this one out by hand," which names no channel and no date. There is a Resend mailer in this repo (backend/src/common/email.ts:32,createResendClient), used for auth/magic-link email — but nothing inbackend/src/rewards/orbackend/src/referral/imports it, so no reward, waitlisted or not, is ever emailed about. The only notification mechanism for any reward is the poll-on-openNotificationrow. useRewards()(the full mobile reward-list hook) has no screen caller. OnlyuseRewardsUnclaimedCount()is used, for the drawer badge (CustomDrawer.tsx:151). The actual reward list a scout browses is/app/rewards, rendered by the store WebView — there is no native list screen, by design (mobile/app/rewards.tsx's own header comment).- A
revokedrow still carries its (now-dead)discountCodein the database —revoke()never nulls it (rewards.service.ts:298-312); the API DTO's status gate is what keeps it out of the app response (rewards.controller.ts:20-29). - Expiry is enforced twice, independently. Shopify's own
endsAton the discount stops the code working at checkout regardless of the nightly sweep; the sweep exists only to keep the admin view honest and to free a capped kind's counter. The store'spartitionRewards()also re-checksexpiresAtagainstnowclient-side (store/lib/store/rewards.ts:23-25), because the daily sweep is periodic and a reward can be genuinely past itsexpiresAtwhilestatusstill readsissued. - A leaked code is bounded, not prevented. Shopify has no
Scout-customer mapping and checkout permits guests, so
customerSelection: { all: true }is forced.usageLimit: 1is the real guard — a leaked code burns exactly once — and theUserRewardrow is what makes it traceable back to whoever leaked it. - Two reward codes never stack in one order. Shopify code discounts do
not combine with each other;
/app/rewardsstates this in its own copy ("Only one reward code can be applied per order",store/app/app/rewards/page.tsx). A reward does combine with an automatic product discount (combinesWith.productDiscounts: true), which is what lets a reward still apply on top of a completed-set bundle price.
What this feature does NOT do
- No push notification when a reward lands. A
reward_earnedNotificationrow is written and discovered on the app's next poll — there is no push infrastructure anywhere in this repo (no APNs/FCM integration, noexpo-server-sdk; seemessages-and-notifications.md). - No region-completion rewards. Named as the ticket's second consumer;
requires server-side collection/region-completion evaluation that does not
exist.
sourceRef'sregion:<slug>shape is reserved but unused. - Two reward codes never stack in one order — Shopify code discounts do not combine with each other. One code per order, and the UI says so.
- The snapback waitlist is fulfilled by hand, and the scout is not
notified when a cap frees up. Nothing promotes a waitlisted reward
automatically — not the nightly sweep, not
remintPending, nothing — and no reward, waitlisted or otherwise, is ever emailed; the storefront's own copy says so plainly ("we'll sort this one out by hand") rather than naming a channel that isn't wired up. There is no queue position either: a freed slot goes to the next scout to cross the rung, not to the longest-waiting one. See Edge cases. - Does not sweep, expire or release a
pendingrow on a timer. A stuck row is retried when someone grants that same reward again, and never on a schedule — there is no background retry job. Writing one off, and releasing the capped slot it holds, is an adminrevoke. - Rewards cannot be gifted or transferred. A reward belongs to the
UserRewardrow'suserId; there is no mechanism to reassign one, and the Shopify code itself has no customer binding to enforce this at checkout — onlyusageLimit: 1and the social expectation that a scout redeems their own reward. - Guests cannot hold rewards.
grant()refuses any caller with noProfilerow orprofile.isAnonymous(rewards.service.ts:90-100). Referral makes this moot in practice (a guest cannot accumulate qualified referrals), but the refusal is unconditional so a future trigger cannot leak one to a guest. free_earned_patchis no longer earnable at all. The 2026-09-03 referral repricing removed the rung that named it; the kind still exists and is still grantable by hand from Admin → Store → Rewards, but nothing a scout does will produce one. (Its Shopify scope was resolved 2026-09-02 — the blocker on minting it, as on every kind, is the app-wide missingwrite_discountsscope; see Edge cases.)- Does not enforce a capped kind's global limit atomically. Two
concurrent grants for different users of the same capped kind
(
free_snapback, cap 30) can both read the same under-cap count before either writes, and both mint — over-issuing past the cap by a small amount under true concurrency (rewards.service.ts:135-146). Accepted knowingly: grant volume is low and it backs a small physical inventory, not a transaction-critical balance. - Does not bind a discount code to a Scout account. Shopify has no
Scout-customer mapping and checkout permits guests, so
customerSelection: { all: true }is forced on every mint — nothing at checkout ties a code to the scout it was issued to.usageLimit: 1is the real guard (a leaked code burns exactly once), and theUserRewardrow is what makes a leaked code traceable back to whoever leaked it — not Shopify's own record of the sale.
Tests that cover it
Backend (backend/src/rewards/):
reward-kinds.spec.ts— fixed economics on every kind, cap only on the snapback, the two patch rewards scoped to different products.rewards.service.spec.ts—grantis idempotent on a repeatsourceRef; refuses guests and profile-less user ids; waitlists exactly at the cap boundary (20th issues, 21st waitlists) without calling Shopify; countsredeemedbut notwaitlistedagainst the cap (a paired opposite proves the cap still bites with a ledger full of waitlisted rows); persists the discount code before Shopify is called and keeps it on a failed mint; stamps a 90-dayexpiresAton both the grant and the re-mint paths; still records the reward when the notification itself throws;revokedeactivates at Shopify first and is a no-op on an already-revoked row;markRedeemedreturnsfalsewithout throwing for an unknown code;expireStaleboundary cases forissuedrows and proof it never expires apendingone however old, with the month-old row still re-mintable after the sweep;remintPendingrefuses anything not exactlypending, adopts an existing Shopify discount rather than minting a second, re-mints under the same reserved code on a miss, and reportsmint_failedwhen the lookup itself throws. Adescribe('RewardsService.grant self-heal')block covers the P2002 branch: a stuckpendingrow is re-attempted and reachesissued; a row already carrying ashopifyDiscountNodeIdis not re-minted (the M1 double-mint guard — dropping the node-id check turns this one red on its own); anissuedrow and awaitlistedrow both still returnduplicatewithout touching Shopify; a second attempt inside the cooldown is refused and one after it elapses is not (a paired opposite); the scout is notified when a stuck reward finally becomes redeemable; and a capped kind recovers from a 20-row outage without an admin while still waitlisting a newcomer, with an adminrevokefreeing a written-off slot.rewards-shopify.service.spec.ts— discount input shape, scope resolution and its throw-on-miss behavior;mint()end to end against a stubbed Admin client (90-dayendsAt, handle resolved to the right GID, throws onuserErrorsand on a missing node id, never reaches the create mutation when the scope misses);findByCode(); and thatgenerateRewardCodedraws at least 40 bits.__tests__/user-rewards-schema.db.spec.ts(db tier) — the uniquediscount_codeindex refuses a duplicate code, repeated NULLs are still allowed (the waitlist depends on it), and(userId, sourceRef)still holds.rewards-expiry.service.spec.ts— the cron wrapper swallows a thrown sweep error rather than crashing the process.rewards.controller.spec.ts— DTO mapping,discountCodewithheld for every status butissued, andlist()driven with real rows so that returning the raw ledger row (leakingshopifyDiscountNodeId,sourceRef,userIdand a revoked row's still-live code) fails.backend/src/admin/api/admin-rewards.spec.ts— the adminsourceRefbuilder mints a unique ref per grant (so a deliberate re-grant is not treated as a duplicate) and never collides with a referralsourceRef; plus the four admin endpoints onAdminApiController—isRewardKindvalidation on both the manual grant and the ledger filter, a missinguserIdrefused, the handle join, and a Shopify deactivation failure propagating out ofrevokeinstead of reporting a silent success.backend/src/shopify-webhooks/handlers/orders-paid.handler.spec.ts(describe('reward redemption')) — a matching code marks the right reward redeemed with the order gid; an unmatched code is a no-op; amarkRedeemedthrow never fails the webhook.backend/src/referral/referral-qualification.service.spec.ts(describe('reward grants'),describe('back-filling jumped rungs')) — the 5-friend grant carries the right milestone-keyedsourceRef; two qualified friends pay nothing at all and three unlock the album but mint no merchandise, which together are the regression test for the 2026-09-03 repricing; every rung the count has reached is granted and no rung above it is, with a paired opposite; a throw on one rung does not cost the rungs either side;rewardedAtis stamped exactly once across a multi-rung back-fill, on agrantedorwaitlistedoutcome and never on a refused/failed one; and, against an injected ladder whose permanent rung is not the lowest, that a merchandise rung grants no album access — pinning that the permanent grant is keyed ongrant === 'permanent'and not merely on a rung being reached.
Store / mobile:
store/lib/store/rewards.test.ts—partitionRewardsbucketing (available/used/expired), including the client-side re-check againstnowindependent ofstatus; a paired pair of tests onrewardCardAction()asserting awaitlistedreward gets the manual- fulfilment copy and no apply action while anissuedreward with a code gets an apply action and not the waitlist message (falsified by hand: swapping the two branches turned both red before the swap was reverted), plus one assertingWAITLISTED_COPYnames no email/notification channel.mobile/screen-tests/rewards.test.tsx— asserts thescout://rewardsredirect target is/store-webview?path=%2Fapp%2Frewards&name=Rewards, pinned againststoreHref('/rewards')directly so a change to that resolver can't silently drag the destination along unnoticed.
Maestro (added 2026-09-02, run against a production-simulator build on prod):
mobile/maestro/tests/rewards.yaml— the drawer row with no badge at zero unclaimed, the tap into the store WebView, and the empty rewards page. The empty-state sentence is the authentication assertion:/app/rewardsredirects an anonymous visitor to/auth/sign-in, so that copy can only render if store-webview's/auth/nativehop handed the app's access and refresh tokens over and the storefront minted a web session from them — the most breakable link in this feature, and covered nowhere else. None of Available/Used/Expired may be on screen, which makes it an assertion aboutpartitionRewardsrather than about a blank page. Then both branches of the leave-the-store confirm, andscout://rewardslanding on the same page. It earns nothing:grant()mints a real single-use Shopify discount code against production and the two $8 patch kinds are uncapped, so a flow that earned a reward on every run would leave live discount codes behind indefinitely. That is why the populated state is a separatemanualflow rather than a leg of this one.mobile/maestro/tests/rewards-granted.yaml— the populated page, and the paired opposite that stops the above's absence assertions being vacuous: badge PRESENT, the Available heading, anApply to cartbutton, and "Nothing here yet" absent. Passing as of 2026-09-02, against a realissuedreward (SCOUT-EMBLEM-84A5F133479A). Taggedmanualand excluded from routine runs: it takesEMAIL/PASSWORDfor an account granted by hand through Admin → Store → Rewards (kindfree_scout_patch), because self-provisioning would mint a real single-use Shopify discount on every run and the two $8 patch kinds are uncapped. It never taps Apply; redemption is covered byorders-paid.handler.spec.ts.
Open questions
Whether Judge.me will send a review request for a reward hat at all.Answered 2026-09-11.in_store: falsedoes not suppress the request email — Judge.me's Review requests dashboard showed a scheduled request for all three real orders, 14 days after fulfillment, on the default Fulfilled trigger. What it does suppress is the product: every row reads "Will send (Store review fallback)", so the buyer is asked to review the shop rather than the hat, and a shop-level review never reaches a product page. A reward hat is treated no differently from a paid one. The product-attached path is the unlisted/review/<handle>form, and its reviews can also earn the verified badge: Judge.me matches the reviewer's email against order history and attaches it once they confirm. A reward ships on a real (if $0) order, so a scout who reviews with their order email qualifies the same as a paying buyer. Both halves are documented incommerce-and-store.md.- Whether a native (non-WebView) rewards list screen is planned, given
useRewards()already exists with no caller — could be a build-ahead-of- need or dead code; not verifiable from the code alone. Whether the— resolved. The exact steps are recorded in the runbook above ("Runbook: creating theall-patchesShopify collection has a creation date/ownerall-patchesShopify collection"). It is a one-time ~2-minute setup in Shopify Admin owned by whoever holds catalog access; the work is done (2026-09-02, 2,246 products).free_earned_patchremains under "does NOT do" for a different reason since 2026-09-03: no referral rung names it any more.Whether product intends to add the Maestro E2E flow the design spec called for (drawer → Rewards badge → rewards list)— resolved 2026-09-02.rewards.yaml(empty state, routine) andrewards-granted.yaml(populated,manual) exist; see Tests that cover it. What is still open is that the granted flow has no self-provisioning path — nothing short of minting a real production discount code can put a reward on a test account, so it stays hand-seeded.