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. Every link and image Scout shares carries it (since 2026-10-13), and a friend who enters it on Invite a friend › "Have a code?" (pre-filled on Android when they installed from an invite link) is attributed to the referrer. When the friend becomes a real account and collects at least one patch, they count toward the referrer — never the friend. "Patch on us": when ten friends have counted, the referrer gets a free patch — free_earned_patch, a single-use store code for $8 off any patch they have earned, and with the store's standing free US shipping the order comes to $0. It is paid once: friends after the tenth still count and still list, and nothing more is promised. That is the whole default ladder — one rung (see "What changed 2026-10-19"). The code is minted by RewardsService.grant() (see rewards.md) and shows on the Rewards screen. The invite screen is a tracker: ten slots, one per friend, filling as friends count.

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; repriced again 2026-09-22 to a 1/3/5/10 store-promo ladder; repriced 2026-10-19 to the current single "10 friends → patch on us" rung, and given a runtime admin editor in the 2026-09-03 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-10-25 — referrals no longer grant album access at all

Cloud-album access is granted separately from referrals now. The ladder's album grant is removed end to end:

What changed 2026-10-19 — one goal: ten friends, a patch on us

Alan, 2026-10-19: "if you get ten people to join with your user code, you get a free patch… just say patch on us." Two changes, one release:

What changed 2026-10-13 — every share carries the code; Android pre-fills it

Boards ce3991e9 ("Every share carries the sharer's invite code") and 2261d84d ("Invite code survives the install"), as Alan scoped them on 2026-10-13:

What changed 2026-09-22 — store promos, and the Scout patch drops off the ladder

The 2026-09-03 ladder (3 → album, 5 → Scout patch, 10 → Snapback) paid real merchandise it had to give away for free — a growing liability as the programme scales. The fix changes what the lower rungs pay, not just their price: 1, 3 and 5 friends now pay store discounts (pct_10_off, pct_20_off, patch_bogo — see rewards.md) rather than free goods. A discount pays nothing unless someone buys, so a one-friend rung — which would have been a farmable coupon under the old free-merchandise rule ("no merchandise below the price of farming a throwaway account," the 2026-09-03 rule) — is safe now: referral-ladder.ts's header comment states this explicitly. The 10-friend rung was amended the same day (owner decision, 2026-09-22) to pay only permanent cloud-album access — no reward at all — rather than the Snapback the plan originally specified.

The consequences worth knowing:

What changed 2026-09-03 — the first repricing

The ladder used to open at one qualified friend (a free earned patch) and pay the Scout patch at three. Both rungs were removed at the time. 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 free merchandise for a single friend was a coupon with an extra step, not a referral programme, and it paid out to farming rather than to word of mouth. (The 2026-09-22 repricing above reopens the low rungs, but only because they now pay discounts rather than free goods — see that section for why a discount doesn't reintroduce the exploit.)

The fix split the ladder by what a prize actually costs, at the time: cloud album access has no marginal cost, so it moved down to three friends; everything physical moved up, to five (Scout patch) and ten (Snapback). That specific arrangement is now superseded by the 2026-09-22 repricing above — kept here as history, not as the current shape.

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

The 2026-10-13 invite attribution (codes on every share, the Android pre-fill, "Copy code & get the app", ab93bfd3) is live: it shipped in v1.1.108, and by 2026-10-21 every new iOS and Android install on the admin dashboard was on 1.1.110. The backend carrying it is deployed (prod runs d2f24f03, which contains ab93bfd3). Nothing about it is flagged.

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 issuance — ReferralService.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 — every share carries the code (see "What changed 2026-10-13"): the invite screen's Share sends inviteUrlFor(code) = ${apiBaseUrl}/i/${code}?src=invite; the celebration, map cards, trip finale and profile build theirs with the same function, and their images print the code. Opening /i/:code does not open the app or hand it the code by deep link (universal links do not cover /i/ yet); it shows the code, copies it on "Copy code & get the app", and on Android puts it in Play's install referrer. The app never reads the clipboard by itself.

  3. Entry — the friend opens Invite a friend › "Have a code?" and types or pastes the code, or, on Android after installing from an invite link, finds it already filled in and taps to confirm. POST /api/referral/claim is called with source: 'settings'. (ReferralCaptureScreen would send 'onboarding', but it is switched off.)

  4. Claim validation — ReferralService.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. 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 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 applies at rung 5 (buy one patch, get one free) and rung 10 (permanent album access), and any bulk import that stamps qualifiedAt skips every rung it jumps 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.

    Each reached rung pays only its store reward; referrals no longer write AlbumAccess (removed 2026-10-25, see "What changed" above):

    • reward: <RewardKind> (the one 10-friend rung today — free_earned_patch) — 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. None of the three currently-shipped reward kinds carries a cap, so the waitlisted/ fallbackKind machinery described in rewards.md (the snapback's "buy one, get one once the free ones run out" behaviour) does not fire on the default ladder today — it remains real mechanics for a capped kind, reachable again the moment an admin ladder names one. Referral.rewardedAt is stamped once grant() 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/):

Since 2026-10-13 (invite attribution):

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

Mobile:

Open questions