Summary
A shared primitive — "this scout has earned something they can redeem in the
store" — plus its main consumer, the 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
Notification (found on the next poll, and pushed to the phone under the
Messages switch when push is enabled).
Nine kinds are defined today (backend/src/rewards/reward-kinds.ts), and
since the 2026-10-19 repricing the referral ladder pays out exactly one of
them: free_earned_patch at 10 qualified friends — "patch on us", $8 off
any patch in all-patches (which the store only sells to a scout who has
earned it), no minimum, so redeemed alone it is a $0 order with the store's
standing free US shipping. It is paid once per referrer. No rung grants
cloud-album access any more (see referral-and-invite.md).
The other six kinds — pct_10_off, pct_20_off, patch_bogo (the
2026-09-22 rungs, dropped 2026-10-19), free_scout_patch, free_snapback,
snapback_bogo — stay fully defined so any code already issued keeps
rendering and expiring normally, and remain grantable by hand from Admin →
Store → Rewards, but no ladder rung names any of them (see "What this
feature does NOT do").
The eighth, patch_voucher, is not a referral reward at all. It is
reserved for a monthly voucher granted by a subscription that is not live
yet — one per billing month, granted by VoucherService, never by the
ladder (the internal subscription doc has the detail). Its economics are
free_earned_patch's exactly — both spread one shared constant,
ONE_FREE_PATCH (reward-kinds.ts:128-137): $8 off any patch in
all-patches, no minimum, no cap. Only the copy ("Patch voucher") and the
expiry differ: 365 days rather than 90 (reward-kinds.ts:154-160). Its
codes are prefixed SCOUT-VOUCHER-. Nothing can be bought that grants it,
so nobody receives this kind today.
The ninth, zoo_snapback_giveaway, is also outside the ladder. It is the
prize in the Saint Louis Zoo hat giveaway, granted by hand to each winner from
Admin → Store → Rewards: $20 off (the hat's sale price) the
saint-louis-zoo-snapback product, no minimum, no cap, single use, code prefix
SCOUT-ZOOHAT-. It expires in 7 days, the window the giveaway's rules give
a winner. It is flat rather than a percentage so a second hat in the cart is
still paid for, and it does not survive a price rise: above $20 a winner would
owe the difference, so raise it with the price.
A reward's discount is a RewardValue: either a flat dollar amount
({ kind: 'amount'; usd }, minted as Shopify's customerGets.value. discountAmount, unchanged from before) or, new as of the 2026-09-22
repricing, a percentage ({ kind: 'percent'; fraction } — 0.1 for 10%,
0.2 for 20% — minted as customerGets.value.percentage,
rewards-shopify.service.ts's buildDiscountInput). Not every reward is
redeemable on its own:
- The percentage kinds (
pct_10_off,pct_20_off) carry no order minimum (minSubtotalCents: 0) because they need no gate: a percentage of nothing is nothing, so they are worth something only alongside a purchase of patches or patch hats. That is their nature, not a minimum. - The two buy-one-get-one kinds (
snapback_bogo,patch_bogo) are the only kinds with a minimum, and the minimum is the offer's shape — "two of the scoped item in the cart" — see below. - The older flat-amount kinds (
free_earned_patch,free_scout_patch,free_snapback) carry no minimum, so one redeemed alone produces a $0 order, which with free shipping standard is pure cost. That is the point offree_earned_patchon the 10-friend rung (2026-10-19): the patch really is on us. It has no cap (cap: null); ten qualified friends per code is the bound. The Scout patch and the Snapback are on no rung.
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.
snapback_bogo is never earned — it is granted only as free_snapback's
fallbackKind once that cap is exhausted, and it is one of the two kinds
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.
patch_bogo — the 5-friend rung of the 2026-09-22 ladder, on no rung
since 2026-10-19 — uses the identical shape one size down: $8 off a $16 (1,600-cent)
minimum, scoped to the all-patches collection. Shopify again counts only
the scoped items, so while single patches are $8, $16 of patches in the cart
is "two patches, one of them free," not "buy a patch and get $8 off something
else." Unlike snapback_bogo it was directly earnable (2026-09-22 to
2026-10-19), not merely a cap fallback.
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. Only free_snapback (cap 30) is a
capped kind today — the one currently-earnable kind (free_earned_patch)
carries no cap (cap: null, reward-kinds.ts), so the fallback machinery
exists but nothing on the shipped ladder exercises it.
The trigger is the referral ladder (backend/src/referral/referral-ladder.ts),
repriced 2026-10-19 to one rung: 10 qualified friends → free_earned_patch.
It replaced the 2026-09-22 store-promo ladder (1 → pct_10_off, 3 →
pct_20_off, 5 → patch_bogo, 10 → permanent cloud album, no store reward),
which had itself replaced the 2026-09-03 merchandise ladder. A free good at the
top rung is safe where it was not at one friend: ten real-looking accounts that
each collect a patch is far above the farming price referral-ladder.ts's
header comment guards against. The ladder is editable at runtime from Admin →
Referral Ladder (see referral-and-invite.md), so the
threshold and reward kind can move without a build. Shipped 2026-09-01
(merchandise ladder), repriced 2026-09-22 (store promos) and 2026-10-19 (patch
on us), 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 → COMMUNITY & SHOP → Rewards row (
mobile/src/components/navigation/drawerSections.ts:269), 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. The main 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. The other callers are an admin's manual grant (backend/src/admin/api/admin-api.controller.ts:140) and, forpatch_voucheronly,VoucherService(a subscription that is not live yet; the internal subscription doc has the detail), whosesourceRefissubscription:<plan>:<yyyy-mm>— one per billing month, so the(userId, sourceRef)uniqueness below is what stops a month paying twice. -
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: false, orderDiscounts: false, shippingDiscounts: true }(rewards-shopify.service.ts:buildDiscountInput) — one reward code per order, full stop; another reward code or an ad code cannot stack with it. Free shipping stays on because it is an automatic shipping discount, not a product/order one, and turning it off would quietly start charging delivery on a reward order. This used to combine with product discounts (productDiscounts: true) because the completed-set bundle price was itself an automatic product discount at the time; since 2026-09-02 a completed set's price is the set product's own Shopify price (seemobile-store.md), so nothing needs to combine with it any more and the flag was turned off. -
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:142,applyRewardToCart) — stashing an ad-link code it displaces so the cart can write it back later (seemobile-store.md, "The discount cookie follows the choice") — pins the reward for the session, 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.Buy now applies a voucher too (2026-11-06):
?buynow=1has no cart, soBuyNowButtonwrites the chosen reward's code to the same cookie at the tap (patch-shop.md, "Buy now"). The hat giveaway message links there.
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 | snapback_bogo | pct_10_off | pct_20_off | patch_bogo | patch_voucher | zoo_snapback_giveaway |
status |
pending | issued | waitlisted | redeemed | expired | revoked |
sourceRef |
referral:milestone:<n>; admin:<adminId>:<timestamp> for manual grants; subscription:<plan>:<yyyy-mm> for a patch_voucher (no such row exists yet); 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,value(RewardValue— amount or percent),scope, for all nine kinds. The one place to change a threshold, a percentage, or which product/collection 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(amount vs. percentcustomerGets.value, thecombinesWithshape),generateRewardCode(per-kind prefix:SCOUT-PATCH-,SCOUT-EMBLEM-,SCOUT-SNAPBACK-,SCOUT-SNAPBACK2-,SCOUT-10OFF-,SCOUT-20OFF-,SCOUT-PATCHBOGO-,SCOUT-VOUCHER-,SCOUT-ZOOHAT-, 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/scripts/create-patches-and-patch-hats-collection.ts— creates thepatches-and-patch-hatsShopify collection (--apply; dry-run by default), the scope ofpct_10_off/pct_20_off. Disjunctive rule: product typeIron-on PatchOR tagpatch hat(patch-hat product types are inconsistent across the hat-creation scripts; the tag is not). Internal, unpublished, not a Scout collection — see theall-patchesrunbook below for what "Shopify collection" does and does not mean here.backend/src/scripts/lib/ensure-automated-collection.ts— the lookup/create/idempotency logic shared by both collection scripts (create-all-patches-collection.tswas refactored onto it in the same change that added the patches-and-patch-hats script, so the two collections cannot drift in how they're created).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:269— 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:60) — 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
- The
patches-and-patch-hatsShopify collection exists in production (created 2026-09-22 withcreate-patches-and-patch-hats-collection.ts --apply: collection690847547552, 2,611 products; rule "product type Iron-on Patch OR tag patch hat").pct_10_offandpct_20_offresolve their scope against it. If it is ever deleted, those grants mint the wayfree_earned_patchonce did against a missingall-patchescollection:resolveScope()throws, the row stayspending, and it self-heals on the next grant for that rung (How it works #2). - 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.) This bullet predates the 2026-09-03 and 2026-09-22 repricings —free_earned_patchno longer sits on any ladder rung (see "What this feature does NOT do"), so "a 1-friend referral grant" below describes a configuration that no longer ships; today's 1-friend rung mintspct_10_offinstead, scoped topatches-and-patch-hats, notall-patches. Kept because the mechanism it describes — a missing collection leaving the rowpendingand self-healing — is real and current for any kind an admin grants by hand,free_earned_patchincluded. 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 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 same reward re-attempts the mint automatically (How it works #2). 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 30 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 one currently-earnable kind,free_earned_patch, carries no cap; this and the next bullet describefree_snapback, which nothing on the shipped ladder can earn.) - 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 30 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 theNotificationrow (poll-on-open, and mirrored by push; see below). useRewards()(the full mobile reward-list hook) has no screen caller. OnlyuseRewardsUnclaimedCount()is used, for the drawer badge (CustomDrawer.tsx:60). 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, and a reward code never stacks
with a product or order discount either.
combinesWith: { productDiscounts: false, orderDiscounts: false, shippingDiscounts: true }on every minted code (rewards-shopify.service.ts) — Shopify code discounts also never combine with each other regardless of this flag./app/rewardsstates the one-code rule in its own copy ("Only one reward code can be applied per order",store/app/app/rewards/page.tsx). Free shipping is the one thing that still applies alongside a reward, because it is an automatic shipping discount, not a product/order one. This used to combine with product discounts, back when a completed-set bundle price was itself an automatic product discount; since 2026-09-02 the set price is the set product's own Shopify price, so there is nothing left to combine with andproductDiscountswas turned off.
What this feature does NOT do
-
patch_voucheris not a referral reward and is not available to anyone yet. It is reserved for a subscription that is not live (the internal subscription doc has the detail); nothing can be bought that grants it, so it is granted to nobody. It is still aRewardKind, so Admin → Referral Ladder's reward dropdown (backend/src/referral/referral-ladder-admin.controller.ts:55) and Admin → Store → Rewards' manual grant both offer it — nothing stops an admin putting it on a rung, and nobody should. -
Push for a reward is only the inbox mirror. A
reward_earnedNotificationrow is written; when push is enabled (EXPO_PUSH_ENABLED=true) and the member has "Messages from Scout" push on, it also pushes, titled "Scout" (backend/src/user-notifications/push-message.ts:20). Otherwise it is discovered on the app's next poll. No email, no SMS; see push-notifications. Until 2026-11-06 the push never went out and the inbox row was dead:resolveTargetsdid not recognise therewardtarget and marked every such row deleted. A row now goes dead only when its reward is revoked or gone, because the Rewards page no longer lists it. -
zoo_snapback_giveawayis granted by hand and by nothing else. No rung names it and no job grants it. Being aRewardKind, it is offered by the same two admin dropdowns aspatch_voucher, and it is no more meant for a rung. It says nothing to a winner beyond the reward notification: the reply and in-app message the giveaway rules promise are sent separately. -
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. -
Codes never combine — not with each other, and not with a product or order discount.
combinesWith: { productDiscounts: false, orderDiscounts: false, shippingDiscounts: true }on every mint; Shopify code discounts also never combine with each other regardless. One code per order, free shipping still applies, and the UI says so. -
A reward never combines with the automatic quantity breaks either (Route 66 tees and National Park hats: 2 for $35, 3 for $50). Checkout keeps whichever saves more; when the break wins, the cart leaves the reward code out and the reward stays unspent for a later order (
mobile-store.md). -
No reward for the invited friend. Only the referrer is ever granted a reward or the permanent album access — the friend who enters a code gets the app and their own code to share, nothing else. See referral-and-invite.md.
-
A percentage reward does not discount everything in the cart, and never discounts a completed-set bundle or apparel.
pct_10_off/pct_20_offare scoped to thepatches-and-patch-hatscollection (single patches and patch hats only) — a set-bundle product, a tee, a Snapback, or any other apparel line is untouched by either percentage, and the scope-check (lineQualifies,store/lib/store/reward-savings.ts) mirrors this collection's rule exactly so the cart's own estimate can't promise a saving Shopify won't actually apply. -
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. -
Only
free_earned_patchis earnable, and only at ten friends. Since the 2026-10-19 repricing (a hard cutover, like the three before it) no default rung payspct_10_off,pct_20_off,patch_bogo,free_scout_patch,free_snapbackorsnapback_bogo. Codes already issued keep working; all six stay grantable by hand from Admin → Store → Rewards. The Scout patch (the compass patch specifically) cannot be earned by any user action. -
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;patch_vouchercarriesfree_earned_patch's economics with a 365-day expiry (expiryFor('patch_voucher', 2027-03-01)= 2028-02-29, across the leap day).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 30-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')) — exercises the general grant mechanism (which is unchanged by the 2026-09-22 repricing) against an injectedLEGACY_LADDERfixture — the 2026-09-03 three-rung shape (3 → album, 5 →free_scout_patch, 10 →free_snapback) — rather than the currentDEFAULT_LADDER, so these assertions read in terms of that older ladder's 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; 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 a second 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. The same file'sdescribe('the patch-on-us default ladder (2026-10-19)', …)block callsmakeServicewith noladderoverride, sogetAll()returns{}andparseLadderfalls back to the real, liveDEFAULT_LADDER— this runsevaluateReferee()end to end against the shipped ladder: nothing is paid on the first friend or at nine; at ten,rewards.grantis called exactly once, for the referrer, withfree_earned_patch/referral:milestone:10, and noAlbumAccessrow is written. Between this block andreferral-ladder.spec.ts(data-level:DEFAULT_LADDER.milestonesitself), the shipped ladder is pinned both as data and as behavior.
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.