Summary
Every signed-in, non-guest account gets a 6-character invite code on first
request. Sharing it and having a friend enter it during onboarding (or later,
via a "Have a code?" late-entry row) attributes that friend to the referrer.
When the friend becomes a real account and collects at least one patch, the
referrer — never the friend — is granted every rung of a three-step
ladder their qualified-friend count has reached: 3 friends → permanent
cloud-album access, 5 → the Scout patch, 10 → the Compass Snapback
(free for the first 30 scouts; after that the rung pays a two-for-one on hats
— see rewards.md). The split is by what a prize costs: cloud album is a
digital entitlement with no marginal cost, so it sits low; anything that
ships in a box sits at five or above. Nothing at all pays below three
qualified friends, and no merchandise below five. The rungs are
cumulative — arriving at ten pays rungs 3 and 5 too, not only the top
one, and a rung that was jumped over (which concurrent qualifications make
easy) is back-filled on the next pass. The 5- and 10-friend rungs pay in
merchandise (a UserReward/Shopify discount code minted by
RewardsService.grant(), see rewards.md); only the 3-friend
rung touches AlbumAccess.
Shipped 2026-08-25, revised 2026-08-27 to reward the referrer alone and close
a code-trading exploit; rebuilt 2026-09-01 to pay in merchandise instead
of cloud-album time; repriced 2026-09-03 from 1/3/5/10 to 3/5/10, and
given a runtime admin editor in the same change (see "What changed" below).
Nothing about the feature is behind a feature flag — it is fully live in
production for every non-guest account.
What changed 2026-09-03 — the repricing
The ladder used to open at one qualified friend (a free earned patch) and pay the Scout patch at three. Both rungs are gone. The qualification bar is a non-anonymous account plus one collected patch, which a determined person can produce for a throwaway account in a few minutes — so a rung that pays $8 of merchandise for a single friend is a coupon with an extra step, not a referral programme, and it pays out to farming rather than to word of mouth.
The fix is not a uniform raise: it splits the ladder by what a prize actually costs. Cloud-album access has no marginal cost and no inventory, so it moved down to three friends, where it can do its job of pulling a new scout into the habit early. Everything physical moved up: the Scout patch to five and the $30 Compass Snapback to ten, both above what a throwaway account is worth faking.
The consequences worth knowing:
- The
free_earned_patchreward kind is no longer on the ladder at all. It still exists inreward-kinds.tsand is still grantable by hand from Admin → Store → Rewards; no ladder rung names it.REWARD_NOUNSinmobile/src/domain/referralLadder.tsstill maps it, deliberately, so an admin grant or a restored config rung still renders with real copy. - The permanent album grant is now the LOWEST rung, which cost a test its
teeth. The old bug where entitlement was computed as "any rung reached"
rather than "a
grant: 'permanent'rung reached" cannot be distinguished from correct behaviour under a ladder whose bottom rung is the permanent one.referral-qualification.service.spec.tsnow injects anAppConfigladder that puts a reward rung underneath a permanent one, purely so that guard stays tested (makeService'sladderoption). - A rung MAY carry both a
grantand areward— they are independent fields andReferralQualificationServiceevaluates them independently. No shipped rung does today, but an admin ladder can, and the mobile copy layer (milestoneNoun,milestoneMeta) names both payouts when one does. - This is a hard cutover, like the 09-01 rebuild. No migration, no
back-fill, no grandfathering. A scout who had already been paid a 1- or
3-friend merchandise rung keeps that
UserRewardrow — nothing is revoked — but those rungs will never fire again for anyone.
The ladder is editable at runtime — Admin → Referral Ladder
Added 2026-09-03 alongside the repricing, because the repricing itself was a code change and a deploy for what has always been a runtime value.
GET/PUT/DELETE /api/admin/referral/ladder(backend/src/referral/referral-ladder-admin.controller.ts,AdminGuard).GETreturns the effective ladder (post-parseLadder, not the raw row), the codedefaults, anisCustomflag, and every mintable reward kind with its price and cap so the editor can render a dropdown.- The write path is STRICT where the read path is lenient.
validateLadderInput(referral-ladder.ts) is the mirror ofparseLadder:parseLaddersilently drops a rung it cannot understand, which is right when reading config an older build wrote and catastrophic at write time — a typo'd reward slug would make a rung quietly stop paying. So the PUT returns a 400 with a per-rung reason instead, and refuses a duplicate friend count, a rung that pays nothing at all, and an unknown reward kind. - Reset is a
DELETE, never a write of the current defaults. A stored row that happens to equalDEFAULT_LADDERshadows it forever, so the next repricing in code would never reach production.AppConfigService.unset()was added for exactly this. - The admin page is
backend/admin-ui/src/pages/ReferralLadderPage.tsx(sidebar → Referral Ladder). It edits rungs, not raw JSON — dropdowns of real reward kinds make the silent-drop class of mistake unreachable. /referral/ladderis deliberately not inCONTENT_WRITE_PREFIXES(backend/admin-ui/src/lib/content-routes.ts), so it stays editable in the read-only production admin, like feature flags and app config. Pinned by a test — if it were treated as content, "change the rungs on the fly" would only work locally.- Changes reach a scout on their next
GET /api/referral/me; the ladder is cached server-side for 30s (AppConfigService), and no mobile build, release or OTA is involved. Nothing inmobile/hardcodes a friend count or a reward noun.
What changed 2026-09-01 — the merchandise ladder
The ladder used to pay perFriendDays: 30 of cloud-album access per
qualified friend, permanent at 5 friends, with a 10-friend milestone whose
bonus ("mystery gift") was deliberately unresolved copy that nothing branched
on. All three of those are gone. This was a hard cutover: no
migration, no backfill, no grandfathering. It was verified safe before
shipping — no active user had referred anyone at the time. Existing
AlbumAccess rows were not revoked; they simply stop being extended by
new referrals (an admin-granted or already-permanent row is untouched).
perFriendDays and refereeDays are still parsed from AppConfig for
back-compat (so an old config row does not error), but neither has a
production reader any more — setting perFriendDays today does nothing.
Status
Fully shipped, not flagged. No feature-flag check gates any referral code
path in backend/src/referral/ or the mobile screens — confirmed by grepping
the feature-flag registries and finding no referral reference. The only
runtime-tunable piece is the reward ladder itself (AppConfig key
referral_ladder, parsed by parseLadder in
backend/src/referral/referral-ladder.ts:110), which is a values config, not
an on/off flag, and falls back to hardcoded defaults
(DEFAULT_LADDER, referral-ladder.ts:79-98) if unset or malformed:
perFriendDays: 0(inert — parsed and validated forAppConfigback-compat only; nothing reads it in production, see "What changed" above)refereeDays: 0(inert — the referee has never received a grant since the 2026-08-27 revision; this field never had a production reader even before the merchandise ladder)claimWindowDays: 7milestones: [{friends: 3, grant: 'permanent'}, {friends: 5, grant: 'none', reward: 'free_scout_patch'}, {friends: 10, grant: 'none', reward: 'free_snapback'}]— three rungs, priced by what the prize costs
It is edited from Admin → Referral Ladder, not by shipping a build — see "The ladder is editable at runtime" above.
User-facing surfaces
scout://invite(mobile/app/invite.tsx) — the referrer's screen: progress bar/ladder, the 6-character code with Copy, a native Share sheet button, a "Who's joined" list (dated, no names — see Anti-abuse), and a collapsible "Have a code?" late-entry field.- Entry point: drawer → SHARE → "Invite a friend" row,
route: 'invite-beta'(mobile/src/components/navigation/drawerSections.ts:271), always visible/enabled — it shares/get, which always resolves to a real store link. - Also reachable from the cloud-album paywall wall's "Invite a friend" CTA
(
mobile/app/cloud-album.tsx:207) and the locked strip (CloudAlbumLockedStrip.tsx).
- Entry point: drawer → SHARE → "Invite a friend" row,
scout://onboarding-referral(mobile/app/onboarding-referral.tsx→ReferralCaptureScreen) — the terminal onboarding step, "How did you hear about us?" Shown exactly once per real (non-guest) account, gated by the standingreferralAskedflag (mobile/src/components/permissions/permissionRouting.ts:40,mobile/src/providers/OnboardingProvider.tsx:119). Never shown to guests.GET /i/:code— the public, unauthenticated invite landing page (backend/src/referral/invite-page.controller.ts), server-rendered (backend/views/invite/page.hbs). Shows the referrer's first name, the visible code with a Copy button, and both store badges.noindex.GET /get— the UA-sniffing smart app link (backend/src/public-profile/public-profile.controller.ts:29), the link actually baked intoINVITE_LINK/every share payload (mobile/src/config/invite.ts:20) and the drawer row's fallback share.GET /card— the permanent business-card redirect (same controller,:53), unrelated to invite attribution; included in scope only because it shares the smart-link resolver (resolveStoreUrl) with/getand/i/:code.
How it works
-
Code issuance —
ReferralService.getOrCreateCode(backend/src/referral/referral.service.ts:47) is called lazily fromGET /api/referral/me. It never reissues once aprofile.referralCodeexists. Guests (profile.isAnonymous) are refused with a 403 ("Sign in to get an invite code") so the ~150 guest profiles never burn a code that would be orphaned if the guest never converts. -
Sharing — the referrer copies the code or taps Share, which shares a message containing
inviteUrlFor(code)=${apiBaseUrl}/i/${code}(falls back to the generic/getlink if no code is loaded yet). There is no clipboard-based or deep-link-based auto-attribution: opening/i/:codein a browser only displays the code and links to the store — it does not carry the code into the app via a deep link with a query param, and the app does not scan the clipboard on first launch to auto-fill a code. The friend must manually copy or type the 6 characters into the app. -
Entry — the friend selects "A friend invited me" on
ReferralCaptureScreen(or later opens/invite's late-entry row) and types/pastes the code.POST /api/referral/claimis called withsource: 'onboarding' | 'settings'. -
Claim validation —
ReferralService.claim(referral.service.ts:87) runs, in order: format check (normalizeReferralCode), self-lookup of the referee profile, already-referred check (Referral.refereeIdis unique — one claim per account, ever), the claim window (claimWindowDaysfrom the referee'sprofile.createdAt), code-owner lookup, self-referral check, then the reciprocal-referral check (see below). On success, oneReferralrow is inserted (referrerId,refereeId,code,source). A unique-constraint race onrefereeIdis caught and reported asalready_referredrather than a 500. -
Qualification —
ReferralQualificationService.evaluateReferee(backend/src/referral/referral-qualification.service.ts:26) is called from the sync read path,SyncService.getUserData(backend/src/sync/sync.service.ts:334), on every sync — not from any of the three code paths that createuser_patchesrows. It is best-effort: a failure is caught and logged, never fails the sync. It finds the referee's one unqualifiedReferralrow, requires the referee to be non-anonymous AND to have collected at least one patch (UserPatchcount ≥ 1), then computes the referrer's qualified-friend count (existing qualified rows + this one) and looks up the applicable milestone.Referral.qualifiedAtis stamped either way (keeps the count honest even if the grant can't be written, e.g. an admin-revokedAlbumAccessrow);Referral.rewardedAtonly if a grant was actually written. -
Reward grant, for the referrer only. Every rung the referrer's qualified-friend count has reached is evaluated —
ladder.milestones.filter(m => qualifiedCount >= m.friends)— not just the highest one.That is deliberate and load-bearing.
milestoneFor()(which returns only the top rung reached, and is still used by the mobile ladder view) was what this granted, and a jumped rung was then never paid at all. Rungs get jumped easily:qualifiedCountis read outside any transaction andqualifiedAtis stamped afterwards, so three referees syncing at once all computequalifiedCount = 1, all fire rung 1 (two dedupe on the unique index), all stampqualifiedAt— and the referrer ends with three qualified friends having never been paid rung 3, which could then only fire if a fourth friend ever qualified. The same shape applied at rung 5 (the $30 snapback) and rung 10 (permanent album access), and any bulk import that stampsqualifiedAtskipped every rung it jumped over, permanently.Re-granting an already-paid rung is free: the
sourceRefis keyed by the rung, so@@unique([userId, sourceRef])collapses it to aduplicateno-op.Together with
grant()'s self-heal that is also the recovery path for a rung whose Shopify mint failed. The back-fill is what callsgrant()for the old rung again;grant()then reads the existing row rather than reportingduplicateblind, and re-attempts the mint if it is stillpendingwith no Shopify node id (see rewards.md, How it works #2). So the trigger is the referrer's next qualifying friend, not a timer — there is no background retry job, and a referrer who never brings anyone else in still needs an admin to finish a stuck rung.Two independent things can happen across the reached rungs, not one:
grant: 'permanent'(today, only the 3-friend rung, which carries noreward) —AlbumAccessis upserted viagrantPermanent, clearingexpiresAtentirely (backend/src/referral/referral-rewards.rules.ts). An admin'srevokedAtalways outranks an automated grant — a revoked row gets no write, ever, and is never un-revoked.AlbumAccess.sourceis set to'referral'only on create; an existing'admin_grant'row is never overwritten. There is no per-friend extension any more —extendAccess/perFriendDaysstacking was removed in the 2026-09-01 rebuild; album access is now purely a milestone, not a running balance. Keyed ongrant === 'permanent', never on "some rung was reached" — a bug that once used the looser test handed permanent cloud-album access to anyone with a single friend.reward: <RewardKind>(the 5- and 10-friend rungs) — callsRewardsService.grant(referrerId, m.reward, 'referral:milestone:<friends>')once per reached rung that names a reward (referral-qualification.service.ts, see rewards.md for the full grant mechanics: Shopify discount minting, caps, idempotency). Each rung is caught separately, so an outage on one does not cost the scout the rungs either side of it. Hitting the snapback's cap no longer waitlists: the rung pays outsnapback_bogo(buy one, get one on hats) instead, so the outcome is an ordinarygranted. Awaitlistedoutcome remains possible in principle — for a capped kind with no fallback — and still counts as rewarded, since the scout earned it the moment the milestone was crossed.Referral.rewardedAtis stamped oncegrant()/theAlbumAccesswrite actually succeeds — not merely because a milestone existed — so a secondevaluateRefereepass (e.g. a retried sync) never double-grants. It is stamped forgrantedandwaitlistedoutcomes alike, and leftnullforrefused_guest/mint_failed. It is a timestamp, not a counter: one write however many rungs the back-fill just paid.
Data model
Three models, all deliberately not foreign-keyed to profiles (a
deleted account must not cascade away referral history or an access grant,
and the looseness avoids write failures racing account creation):
Referral(backend/prisma/schema.prisma:763) — one row per referee, ever (refereeId String @unique). Fields:referrerId,refereeId(unique — the whole anti-farm primitive),code(literal string entered, audit only),source('onboarding' | 'settings'),enteredAt,qualifiedAt(set once referee is real + has ≥1 patch),rewardedAt(set only if the referrer's grant was actually written — idempotency guard so a secondevaluateRefereepass never double-grants). Indexed on(referrerId, qualifiedAt)for the invite screen's summary query.AlbumAccess(schema.prisma:740) — one row per user (userId String @id), shared with the (out-of-scope) cloud-album feature.sourceis'admin_grant'or'referral'.expiresAt: nullmeans permanent.revokedAtis an admin override that outranks any automated grant. Since the 2026-09-01 rebuild, referral only ever writes this row once, at the 10-friend permanent milestone — there is no per-friend extension any more;expiresAtis cleared entirely (grantPermanent) and never merely stacked forward. Referral never setsrevokedAtand never touchessourceon an existing row.UserReward— the merchandise the 1/3/5-friend rungs actually pay in; not part of this feature's own schema, see rewards.md for its full model.SignupSource(schema.prisma:789) — one row per account (userId String @id, upserted, not appended), the answer to "How did you hear about us?":'friend' | 'social' | 'search' | 'other' | 'skipped'. Analytics only; unrelated to the reward mechanism, but the 'friend' answer is recorded on the same screen and independently of whether the paired code claim actually succeeds.profiles.referral_code— a nullable, uniquely-indexedTEXTcolumn added by the referral migration (not its own model).
API surface
All under JwtAuthGuard (backend/src/referral/referral.controller.ts) —
guests get a token but are still refused inside the service layer where
guest-specific:
GET /api/referral/me— returns{ code, qualifiedCount, pendingCount, ladder, friends: [{qualified, enteredAt}], access, ownReferral, claimWindowOpen }. Issues a code as a side effect if the caller has none (403 if the caller is a guest).POST /api/referral/claim— body{ code, source };sourceis coerced server-side to'settings'only on an exact match, defaulting to'onboarding'otherwise — the client's claimed source is not trusted as-is. Returns{ ok: true }or{ ok: false, reason }(never throws for a rejection — five distinct reasons drive five distinct UI messages).POST /api/referral/signup-source— body{ source }, validated against the fixedSIGNUP_SOURCESlist server-side (400 on anything else).
Under AdminGuard (referral-ladder-admin.controller.ts):
GET /api/admin/referral/ladder—{ ladder, defaults, isCustom, rewardKinds }.ladderis the EFFECTIVE, post-parseLadderconfig, not the rawAppConfigrow.PUT /api/admin/referral/ladder— body is aLadderConfig. Validated byvalidateLadderInput; a 400 carries{ message, errors: string[] }with a reason per bad rung. Stores the sorted, normalized ladder.DELETE /api/admin/referral/ladder— removes theAppConfigrow so the build'sDEFAULT_LADDERis live again. Not the same as saving the defaults.
Unauthenticated, no guard:
GET /i/:code— renders the invite landing page or 404s (malformed code never touches the DB; well-formed-but-unknown code also 404s).GET /get,GET /card— smart links, not referral-specific but shared infrastructure (resolveStoreUrl,resolveCardTarget).
Key files
backend/src/referral/referral-code.ts— code alphabet (31 chars, no0/O/1/I/L), 6-char length, generation and normalization.backend/src/referral/referral.service.ts— code issuance, claim validation, reciprocal-referral guard, signup-source recording,summaryFor(invite screen payload).backend/src/referral/referral-qualification.service.ts— the qualify- and-grant path, called from sync; calls bothgrantPermanent(album, 10 friends) andRewardsService.grant()(merchandise, the 5/10 rungs).backend/src/referral/referral-rewards.rules.ts—grantPermanent(revoke-outranks, permanent-is-final rules), the only function this feature still calls.extendAccessis dead code as of the 2026-09-01 rebuild — still exported and still unit-tested (referral-rewards.rules.spec.ts), but nothing calls it: per-friend stacking was removed when the ladder stopped paying in album days.backend/src/referral/referral-ladder.ts—LadderConfigshape,DEFAULT_LADDER,parseLadder(lenient, fails closed on a malformedmilestonesarray),milestoneFor, andvalidateLadderInput— the STRICT mirror ofparseLadderused only by the admin write path.backend/src/referral/referral-ladder-admin.controller.ts—GET/PUT/DELETE /api/admin/referral/ladder, the runtime rung editor's API.backend/admin-ui/src/pages/ReferralLadderPage.tsx— the rung editor (sidebar → Referral Ladder);backend/admin-ui/src/lib/api.ts'sreferralLadderclient.backend/src/app-config/app-config.service.ts—unset(key)exists for this feature's "Reset to defaults", which must DELETE the row rather than store a copy of the current defaults.backend/src/referral/referral.controller.ts/invite-page.controller.ts— the two app-facing HTTP surfaces.backend/src/referral/referral.module.ts— exportsReferralServiceandReferralQualificationService;SyncModuleimports it to callevaluateReferee. It importsAdminModulebecause the ladder admin controller isAdminGuard-protected — without that the app fails to BOOT, a DI error no unit test catches.backend/src/sync/sync.service.ts:334— the one call site ofevaluateReferee, insidegetUserData, best-effort/non-blocking.backend/src/achievements/feature-context.ts:47— feedsreferral_qualified_count(qualified referrals where this user is the referrer) into the achievement system; theringleaderachievement fires atreferral_qualified_count>=5(mirrors the milestone threshold but is a separate, independent system — no shared code).backend/prisma/migrations/20260825120000_referral/migration.sql— thereferralstable andprofiles.referral_codecolumn.backend/prisma/migrations/20260825130000_signup_source/migration.sql— thesignup_sourcestable.mobile/src/api/referral.ts— typed client (referralApi.me/claim/ recordSignupSource).mobile/src/config/invite.ts—INVITE_LINK(/get),inviteUrlFor(per-user/i/:codelink), share payload/copy building.mobile/src/domain/referralLadder.ts— turns the server summary into the ladder view the screen renders: one rung per milestone (four, today: 1/3/5/ 3/5/10 friends since the 2026-09-03 repricing), plus an optional "every friend" per-friend rung that only appears ifperFriendDays > 0— which it is not, by default, since the 2026-09-01 rebuild, so in practice the screen shows exactly the four milestone rungs.REWARD_NOUNS(referralLadder.ts) is the one place aRewardKindis mapped to display copy for a merchandise rung; an unrecognized kind falls back to "a reward" rather than an empty rung. Pure and unit-tested.milestoneNoun/milestoneMetaname both payouts on a rung that carries agrantand areward(the 10-friend rung); the stale "collapses to THREE rows" header comment was corrected in the same change.mobile/app/invite.tsx— the whole invite screen, in the house view-model shape:InviteScreenViewModel(an explicitstatusofloading | signed-out | error | ready, never a compound of "is the summary null"),InviteScreenViewModelImpl(every hook), the pureInviteScreenLayout, and two exported pure helpers the DEV screen mock reuses rather than re-deriving —buildInviteReady(summary → everything the ready screen draws, viabuildLadderView) andinvitePendingHint. It used to bemobile/src/components/referral/InviteScreen.tsx, a 506-line component the route did nothing but render; that file is deleted and the route has its only consumer.mobile/src/components/referral/ReferralCaptureScreen.tsx— the onboarding capture screen, in the house view-model shape:ReferralCaptureScreenViewModel,ReferralCaptureScreenViewModelImpl(every hook), the pureReferralCaptureScreenLayout, and two exported pure helpers the DEV screen mock reuses rather than re-deriving —claimMessage(rejection reason → copy, with the unknown-reason fallback) andreferralCaptureForm(selection + window + code →showCodeField,continueDisabled). It is also the home ofCLAIM_MESSAGE, which the invite screen's late-entry row imports; that second consumer is why the split stayed in this file instead of being pulled up intoapp/onboarding-referral.tsx, which remains a 12-line route. Alongside it:ReferralFriends.tsx,ReferralLadder.tsx.mobile/src/dev/mocks/invite.tsx— nine screen-mock states for the invite screen (scout://dev-screen-mock/invite), seeded from the REALDEFAULT_LADDER(3/5/10) with synthetic user counts. See screen-mocks.md.mobile/src/dev/mocks/referral-capture.tsx— ten screen-mock states for the onboarding capture screen (scout://dev-screen-mock/referral-capture; the slug comes from the component's filename, not the route's). Every rejection state seeds a realClaimRejectiondiscriminant and lets the screen's ownCLAIM_MESSAGErender it; the codes are synthetic. There is deliberately no success state — a successful claim draws nothing, it callsfinish()and the screen unmounts.mobile/src/domain/store.ts—referralSummary,referralAsked(one-time onboarding gate),refreshReferral()(swallows all errors, setsreferralSummary: nullon any failure — including a genuine outage).mobile/src/components/permissions/permissionRouting.ts:40— routes a real (non-guest) account to/onboarding-referralexactly once.
Configuration and flags
- No feature flag. The whole surface (code issuance, claim, qualification,
both screens,
/i/:code) is unconditionally live. AppConfig.referral_ladder(JSON blob) is the only tunable, read viaAppConfigService.getAll()(notgetNumber, deliberately — a structured value needs field-by-field validation, not a scalar's positive-number fallback). Every field validates independently; an invalid single field falls back to the matchingDEFAULT_LADDERfield rather than voiding the whole config.milestonesfails closed: a non-empty array that loses every entry to validation falls back to the four default milestones (3/5/10 friends), but an explicit[]is honored as "no milestones." A milestone naming arewardthis build does not recognize (isRewardKind()fails) is dropped entirely rather than kept with a dead reward — a config typo is not a milestone that silently pays nothing.ladder.perFriendDaysandladder.refereeDaysstill parse and are still returned inGET /api/referral/me'sladderobject, but neither has a production reader any more as of the 2026-09-01 merchandise-ladder rebuild (refereeDayshas had none since 2026-08-27). Both are kept parseable only so an existingAppConfigrow with either key doesn't error — settingperFriendDaysto a nonzero value today changes nothing a scout sees except a client-side "every friend" ladder rung that reappears in the mobile ladder view (mobile/src/domain/referralLadder.ts) with no server-side grant behind it.
Edge cases and known limits
- Claim window: 7 days by default, measured from the referee's
profile.createdAt, inclusive at the boundary (ageMs > windowrejects, so exactlywindowdays old still succeeds). A repeat claimant whose window has also closed getsalready_referred, notwindow_closed— the more specific reason is checked first. reciprocal_referral's copy is STALE, and still ships.CLAIM_MESSAGE.reciprocal_referral(mobile/src/components/referral/ReferralCaptureScreen.tsx) reads "You invited them. Invite someone new to earn your own month." The repriced ladder paysperFriendDays: 0andrefereeDays: 0— no month is granted to anyone, per friend or otherwise — so this is the one string in the app that still promises the retired reward. Both screens that render a claim rejection show it (onboarding capture, and/invite's late-entry row, which imports the same map). Recorded, not fixed: the copy change is a product decision, not a migration's to make.- A
window_closedrejection IS reachable on the onboarding screen, even though it hides the code field once the window has closed. The screen'sclaimWindowOpendefault is permissive (summary ? summary.claimWindowOpen : true), so an account whoseGET /api/referral/mehas not resolved — or failed outright — is offered the field and learns from the server instead. - Guests:
getOrCreateCoderefuses with 403 forprofile.isAnonymous. A guest who later converts to a real account is not covered by any special handling documented here — see Open questions. - Concurrent double-claim (e.g. a double-tap on onboarding's Continue):
the pre-check for an existing
Referralrow can race two calls; therefereeIdunique constraint on the DB is the actual guarantee, and a P2002 on insert is caught and returned asalready_referredrather than a 500. - Code collision on issuance: retried up to 5 times on a Prisma P2002 before throwing (887M possible codes across the 31-char alphabet × 6 positions, so this is described in-code as "vanishingly rare").
- Revoked
AlbumAccess: an admin'srevokedAtalways wins. A qualifying referral still stampsqualifiedAt(so counts/analytics stay accurate) but writes no grant and logs a warning server-side. - Landing page code display:
/i/:codeshows the code as plain visible text with a client-side clipboard-copy button; ifnavigator.clipboardis blocked (e.g. non-HTTPS context) the copy button silently no-ops — the code is still visible and can be typed by hand. - No maestro/E2E coverage found for either
/inviteor/onboarding-referral(searchedmobile/maestro/tests/*.yamlforinvite/referral— none reference this feature). Coverage is unit/ screen-test only (see Tests below).
What this feature does NOT do
- Does not auto-attribute from a deep link or the clipboard. Opening
scout://inviteor/i/:codenever pre-fills, submits, or silently claims a code on the recipient's behalf. The friend must type or paste it themselves, and only from withinReferralCaptureScreen(onboarding) or the/invitelate-entry row (post-onboarding, within the claim window). - Does not reward the referee anymore. As of 2026-08-27, only the referrer is ever rewarded — at any rung. The friend who enters a code gets no merchandise and no cloud-album time for doing so; they get the app and their own code to share.
- Does not stack cloud-album time per friend any more. The 2026-09-01
rebuild removed
perFriendDaysentirely; a referrer's first three qualified friends now earn merchandise (see rewards.md), not a running album-day balance, and cloud album is a single all-or-nothing grant at the 10-friend rung. - Does not pay anything for one or two qualified friends, and no
merchandise below five. Since the 2026-09-03 repricing the first payout
is cloud album at THREE and the first physical prize is at FIVE. A referral
below that still qualifies (
qualifiedAtis stamped, the count is honest, the invite screen lists the friend) — it simply grants nothing. Do not describe the programme as "refer a friend, get a free patch". - The 10-friend rung no longer carries a "mystery gift." That bonus was
always unresolved copy that nothing branched on; the rebuilt ladder
removed the field the milestone used to carry it in
(
referral-ladder.ts'sLadderMilestonehas nobonusfield any more). - Does not let two people trade codes for mutual reward. The
reciprocal-referral guard (
referral.service.ts:118) refuses a claim whenever the code being claimed belongs to someone the claimant already referred — i.e. A refers B, then A tries to enter B's code from Settings. This is keyed on who specifically referred the code's owner, not merely "was the code's owner referred by anyone" (that looser check would wrongly refuse a legitimate claim on a code whose owner happens to have been referred by a third, unrelated person). - Does not allow more than one claim per account, ever —
refereeIdis unique at the database level; this is unconditional and outlives the 7-day window (a repeat claimant is rejected asalready_referredregardless of how long ago their first claim was). - Does not issue a code to a guest account, and does not retroactively attribute or repair a code that would have been issued had the account been real from the start.
- Does not display the friend's name, initials, or avatar anywhere — the invite screen's "Who's joined" list shows only qualified/pending status and a join date, by explicit design (the referrer did not get their friends' consent to be shown).
- Does not gate cloud album on referrals exclusively —
AlbumAccessalso has an'admin_grant'source, entirely independent of this feature (out of scope here; see the cloud-album feature's own doc).
Tests that cover it
Backend (backend/src/referral/):
referral-code.spec.ts— alphabet, generation, normalization.referral-ladder.spec.ts—parseLadderfallback/fail-closed behavior,milestoneForboundaries pinned adjacently on BOTH sides of all three rungs, dropping a milestone whoserewardthis build does not recognize, and two tests that are the 2026-09-03 repricing written down: nothing pays below three friends, and no merchandise rung sits below five (a mutation that moves either reward rung down goes red).referral-rewards.rules.spec.ts—grantPermanent's revoke-outranks-all, permanent-never-downgrades, exact-nowboundary rules. Still also coversextendAccessdirectly (the function is dead code in production but remains unit-tested — see Key files).referral-qualification.service.spec.ts(not under__tests__/— a pre-existing doc/code path mismatch fixed here) — qualification gating (zero patches, anonymous referee); milestone boundaries on both sides of every rung (2 vs 3, 4 vs 5, 9 vs 10); a dedicateddescribe('reward grants')block asserting the 5-friend rung callsRewardsService.grant()with the milestone-keyedsourceRef, that two friends pay nothing at all and three friends unlock the album but mint no merchandise (the repricing's cost split, at the service level), and thatrewardedAtis stamped for both agrantedand awaitlistedoutcome;describe('back-filling jumped rungs')— arriving at 10 grants rungs 5 and 10 in order with the 3-friend album grant back-filled, a throw on one rung does not cost the others, andrewardedAtis written exactly once across the whole back-fill; a dedicated block asserting noAlbumAccesswrite happens for the referee and that the referee's access row is never even looked up.makeServicetakes aladderoverride, used by exactly two tests: the "permanent, not merely reached" guard and the "a failed mint leavesrewardedAtnull" case both need a ladder whose permanent rung is NOT the lowest, whichDEFAULT_LADDERno longer is.referral-ladder-admin.controller.spec.ts—validateLadderInputrefusing exactly whatparseLaddersilently repairs (unknown reward kind, duplicate friend count, a rung that pays nothing, a zero claim window), that an invalid ladder writes NOTHING, thatGETreturns the effective ladder rather than echoing a bad stored row, and that reset DELETES the row instead of storing a copy of the current defaults.__tests__/referral-claim.spec.ts— the fullclaim()decision tree, including a dedicateddescribe('reciprocal referrals')block asserting the exploit is closed (refuses a code belonging to someone this user already referred) and is NOT over-broad (allows the claim when the code owner was referred by someone ELSE).referral.service.spec.ts—getOrCreateCode,summaryFor(includingclaimWindowOpenboundary),recordSignupSourceupsert behavior.referral.controller.spec.ts— HTTP-level source coercion, pass-through of claim rejections.backend/admin-ui/src/lib/content-routes.test.ts—/referral/ladderwrites are NOT content writes, so the rungs stay editable in the read-only production admin.__tests__/invite-page.spec.ts—/i/:coderendering, 404s, and an SPA fallback assertion that/idoesn't swallow other/i*marketing paths.
Also see rewards.md's own Tests section for RewardsService,
RewardsShopifyService and the admin/webhook coverage the merchandise rungs
depend on.
Mobile:
mobile/screen-tests/onboarding-referral.test.tsx— code field show/hide by selection and window state, all five rejection messages (including the reciprocal one), paste-uppercasing, and the "don't overwrite 'friend' with 'skipped'" signup-source guard. All 11 tests passed unchanged through the view-model refactor ofReferralCaptureScreen.tsx, which is the evidence that split preserved behaviour — as didinvite.test.tsx, which importsCLAIM_MESSAGEfrom it.mobile/screen-tests/invite.test.tsx— guest vs signed-in vs loading/error states, late-entry visibility rules (window open AND no prior claim), the ladder/friends list rendering, share-sheet completion vs dismissal recordingreferral_code_sharedcorrectly (dismissal records nothing). All 24 tests passed unchanged through the view-model refactor, which is the evidence the split preserved behaviour.mobile/screen-tests/screen-mocks.test.tsx— renders all nine invite mock states and asserts per-state content, and enforces that the mock renders the screen's own layout rather than one it declared.mobile/src/domain/__tests__/referralLadder.test.ts,store.refreshReferral.test.ts.
Open questions
- What happens to a pending
Referralrow (or a referee's eligibility to be referred) if a guest converts to a real account mid-flow — could not verify from code alone whether guest-to-real conversion interacts withclaimWindowDays(measured fromprofile.createdAt, which may predate conversion) without a live DB check, which is out of scope for this doc. Flagging rather than guessing. - Whether
extendAccess(referral-rewards.rules.ts) and theperFriendDays/refereeDaysAppConfigfields should be deleted outright now that nothing calls the former and neither has a production reader, or kept as documented dead code in case a future change reintroduces per-friend stacking — a product/cleanup call, not something to guess at here. - Whether product intends to add Maestro E2E coverage for this feature per the "one E2E per feature" repo convention — none exists today for either the referral/invite surfaces or the merchandise rewards surface (see rewards.md's own Open questions).