Scout — Full Product Context → feature documentation

Referrals and invites

Every signed-in, non-guest account gets a 6-character invite code on first request.

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 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.

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:

It is edited from Admin → Referral Ladder, not by shipping a build — see "The ladder is editable at runtime" above.

User-facing surfaces

How it works

  1. Code issuanceReferralService.getOrCreateCode (backend/src/referral/referral.service.ts:47) is called lazily from GET /api/referral/me. It never reissues once a profile.referralCode exists. 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.

  2. Sharing — the referrer copies the code or taps Share, which shares a message containing inviteUrlFor(code) = ${apiBaseUrl}/i/${code} (falls back to the generic /get link if no code is loaded yet). There is no clipboard-based or deep-link-based auto-attribution: opening /i/:code in 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.

  3. 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/claim is called with source: 'onboarding' | 'settings'.

  4. Claim validationReferralService.claim (referral.service.ts:87) runs, in order: format check (normalizeReferralCode), self-lookup of the referee profile, already-referred check (Referral.refereeId is unique — one claim per account, ever), the claim window (claimWindowDays from the referee's profile.createdAt), code-owner lookup, self-referral check, then the reciprocal-referral check (see below). On success, one Referral row is inserted (referrerId, refereeId, code, source). A unique-constraint race on refereeId is caught and reported as already_referred rather than a 500.

  5. QualificationReferralQualificationService.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 create user_patches rows. It is best-effort: a failure is caught and logged, never fails the sync. It finds the referee's one unqualified Referral row, requires the referee to be non-anonymous AND to have collected at least one patch (UserPatch count ≥ 1), then computes the referrer's qualified-friend count (existing qualified rows + this one) and looks up the applicable milestone. Referral.qualifiedAt is stamped either way (keeps the count honest even if the grant can't be written, e.g. an admin-revoked AlbumAccess row); Referral.rewardedAt only if a grant was actually written.

  6. 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: qualifiedCount is read outside any transaction and qualifiedAt is stamped afterwards, so three referees syncing at once all compute qualifiedCount = 1, all fire rung 1 (two dedupe on the unique index), all stamp qualifiedAt — 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 stamps qualifiedAt skipped every rung it jumped over, permanently.

    Re-granting an already-paid rung is free: the sourceRef is keyed by the rung, so @@unique([userId, sourceRef]) collapses it to a duplicate no-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 calls grant() for the old rung again; grant() then reads the existing row rather than reporting duplicate blind, and re-attempts the mint if it is still pending with 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 no reward) — AlbumAccess is upserted via grantPermanent, clearing expiresAt entirely (backend/src/referral/referral-rewards.rules.ts). An admin's revokedAt always outranks an automated grant — a revoked row gets no write, ever, and is never un-revoked. AlbumAccess.source is set to 'referral' only on create; an existing 'admin_grant' row is never overwritten. There is no per-friend extension any moreextendAccess/perFriendDays stacking was removed in the 2026-09-01 rebuild; album access is now purely a milestone, not a running balance. Keyed on grant === '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) — calls RewardsService.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 out snapback_bogo (buy one, get one on hats) instead, so the outcome is an ordinary granted. A waitlisted outcome 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.rewardedAt is stamped once grant()/the AlbumAccess write actually succeeds — not merely because a milestone existed — so a second evaluateReferee pass (e.g. a retried sync) never double-grants. It is stamped for granted and waitlisted outcomes alike, and left null for refused_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):

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:

Under AdminGuard (referral-ladder-admin.controller.ts):

Unauthenticated, no guard:

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Backend (backend/src/referral/):

Also see rewards.md's own Tests section for RewardsService, RewardsShopifyService and the admin/webhook coverage the merchandise rungs depend on.

Mobile:

Open questions