adr: 0009 title: Referral attribution — codify v2 economics + wire per-invite tracking status: accepted implementation-status: implemented date: 2026-05-22 deciders: [@amalkrsihna] affects-specs: [referrals] affects-code: - ftl-backend/internal/referral/bonus.go # comment is misleading; tighten (~L4-21) - ftl-backend/internal/user/service_test.go # codified in test (~L177-222) - ftl-backend/internal/handler/routes.go # already accepts inviteId (~L494-498) - ftl-frontend/src/App.tsx # add ?invite= capture (~L95-100) - ftl-frontend/src/pages/Register.tsx # include inviteId in payload (~L442-451) - ftl-frontend/src/components/WelcomeCelebration.tsx # remove fake referee toast (~L289) - ftl-frontend/src/pages/ComingSoon.tsx # fix share-link copy (~L36) - ftl-frontend/src/pages/InviteFriends.tsx # fix "both get 500" copy (~L67) supersedes: null superseded-by: null
ADR-0009: Referral attribution — v2 economics + per-invite tracking¶
Pending implementation. Backend referral economics are already correct (v2 model is live in
bonus.go). The pending work is three frontend copy strings that contradict the backend model, one removed fake toast, and a missing URL-param capture that drops per-invite analytics for every user. Bundled here because all five touch the same domain.
Context¶
The backend has two implementations of referral payouts: an older v1 (both parties earned at signup) and the current v2 (only the referrer earns, only after the referee's first trade). v2 is what runs in production and what the tests assert:
ftl-backend/internal/referral/bonus.go:4-16— package comment documents v2: "At signup the referral is RECORDED but no wallet credit happens. The referee gets nothing." Only the referrer is credited (BaseBonus500 + milestone bonuses), and only after the referee firesPayoutOnFirstTrade.ftl-backend/internal/user/service_test.go:205-222—TestRegisterFromGoogle_ValidReferralasserts: "v2: referee gets nothing at signup" withif got := readBalance(t, pool, referee.ID); got != 10000— referee balance stays at the 10,000 opening grant, with no referral bonus added.
The frontend tells the opposite story in three places (verbatim quotes from current code):
| File | Line | Today's copy (verbatim) |
|---|---|---|
ftl-frontend/src/components/WelcomeCelebration.tsx |
289 | ✨ +500 wallet points referral bonus applied |
ftl-frontend/src/pages/ComingSoon.tsx |
36 | Join me on FTL! Use my code ${code} for 500 bonus points. ${shareUrl} |
ftl-frontend/src/pages/InviteFriends.tsx |
67 | Share your code and both get 500 bonus points! |
The WelcomeCelebration modal renders the "bonus applied" toast to the referee at
signup — but the backend never credits that wallet. The user sees a celebratory message and
then has 10,000 coins (the standard opening grant), exactly as if they had no referral
code. Subtle but corrosive: users figure it out by checking their balance, then mistrust
every other in-app message.
Separately, the backend supports per-invite attribution (each share link can be a unique invite ID, allowing the operator to see which channel/campaign converted) but the frontend never participates:
ftl-backend/internal/handler/routes.go:494-498— theauthGoogleCompleteRequeststruct accepts bothreferralCode(string) andinviteId(string, JSON tag"inviteId"). The handler at routes.go:536-537 passes both toRegisterFromGoogle.ftl-frontend/src/App.tsx:95-100— the URL-param effect reads onlyparams.get('ref')and stores it viauseReferralStore.getState().set(ref). There is noparams.get('invite')anywhere.ftl-frontend/src/pages/Register.tsx:442-451— theapi.authGoogleCompletecall sends{ registrationTicket, firstName, lastName, username, phone, age, districtId, referralCode }. NoinviteIdfield.
Per-invite analytics is silently dropped for every user. The backend has the schema and endpoints; the frontend just doesn't fill them in.
Decision¶
We will codify the v2 economics in this ADR so the model is canonical, fix the three
misleading frontend strings to match it, remove the fake referral-bonus toast for the
referee, and wire ?invite=<id> end-to-end alongside the existing ?ref=<code>.
Concrete changes the paired code PR will encode:
Codify v2 economics (documentation only — backend logic unchanged)¶
The model going forward:
- A user registering with a valid referral code/invite ID has their
referrer_user_idset in theuserstable at signup time. No wallet credit happens at this moment. The referee receives the standard 10,000-coin opening grant only. - When the referee places their first successful trade,
PayoutOnFirstTradefires. The referrer is credited withBaseBonus = 500coins. Subsequent referrals may unlockMilestoneBonusbrackets, also paid to the referrer only. - The referee never receives a referral-specific wallet credit. Not at signup, not at first trade, not later.
Tighten the misleading bonus.go:19-20 comment as part of the paired PR:
// Before (misleading — contradicts the model):
// BaseBonus is applied per successful referral to both the referrer and the new user.
// After:
// BaseBonus is paid to the referrer when the referee places their first trade.
// The referee receives no referral-specific credit — only the standard opening grant.
const BaseBonus = 500.0
Frontend copy fixes (verbatim replacements)¶
The paired frontend PR substitutes these three strings exactly:
WelcomeCelebration.tsx:289
props.referralBonus === true
— there's no referral bonus to celebrate for the referee, so the line should always render
the welcome version. Drop the referralBonus prop entirely; remove its usage at the call
site in Register.tsx.
ComingSoon.tsx:36 (the share-link share-sheet copy)
- Join me on FTL! Use my code ${code} for 500 bonus points. ${shareUrl}
+ Join me on FTL — the football trading game. ${shareUrl}
InviteFriends.tsx:67 (the in-app invite screen headline)
- Share your code and both get 500 bonus points!
+ Share your code — you earn 500 coins when a friend places their first trade.
Wire ?invite=<id> end-to-end¶
App.tsx:95-100— extend the URL-param effect:This requires extending// Before: const ref = params.get('ref'); if (ref) useReferralStore.getState().set(ref); // After: const ref = params.get('ref'); const invite = params.get('invite'); if (ref) useReferralStore.getState().setReferralCode(ref); if (invite) useReferralStore.getState().setInviteId(invite);useReferralStorewith aninviteId: string | nullfield + setter. Keep the existingset(code)method assetReferralCode(code)for symmetry.Register.tsx:442-451— extend the submit payload:- Backend: no change. The route already accepts both fields and passes them to the user service.
- Share-link generators (where the app constructs invite URLs — typically
InviteFriends.tsx): emit?invite=<id>&ref=<code>going forward. Backward-compat: old?ref=<code>links keep working unchanged because the new code captures both independently.
Consequences¶
Positive¶
- Frontend copy matches backend reality. Users see consistent messaging; trust in the app's other claims is preserved.
- Removes a known-misleading toast that's been in production undisputed because the bug is silent (no error, just a wallet that doesn't reflect what the UI promised).
- Per-invite analytics unlocks without any backend work — the operator can correlate signup → first-trade conversion per share link / campaign, which informs marketing spend.
- Backward-compatible. Old
?ref=share links keep working.
Negative¶
- Existing share links in the wild claiming "500 bonus points" are now technically false advertising the moment the copy fix ships, but the new user lands on a corrected page so the inconsistency is contained to the share-sheet text the referrer originally pasted. Mitigation: post-merge, announce in the in-app inbox / notification system that "the referral rewards have been clarified — the referrer earns 500 coins on a friend's first trade." Be honest rather than retroactively change the model.
WelcomeCelebrationcomponent contract changes. ThereferralBonusprop is removed; any other call sites need updating. (Confirmed: there's only one —Register.tsx.)
Neutral / new obligations¶
- Backend
bonus.go:19-20comment is misleading independently of the v2 model — the comment was written for v1. Tighten it as part of the paired PR. Treat the comment as build-once, not as a separate ADR. - No backend logic changes. The v2 economics are already correct in code; this ADR is primarily documentation + frontend cleanup.
Alternatives considered¶
Alternative A: Change backend to actually credit the referee 500 at signup¶
Bring back v1 — both parties earn at signup.
Rejected. v2 was chosen intentionally to prevent the referee-then-vanish abuse pattern (create account → claim bonus → never trade → repeat with throwaway accounts). The "referee earns after first trade" gate is the explicit anti-abuse measure. Reverting it would re-introduce the exploit and undo the v1→v2 work already shipped.
Alternative B: Drop the invite-ID feature entirely¶
Mark the inviteId field on the registration struct as deprecated; never wire the frontend.
Rejected. The operator wants per-link click-through analytics for marketing campaigns (e.g. "which influencer's link converted best"). The backend support is already in place; the only missing piece is the 2-line URL-param capture and 1-line payload addition. Net work is trivial.
Alternative C: Migrate to ?invite= only, deprecate ?ref=¶
New share links emit ?invite=<id> and the referral code is looked up server-side from the
invite ID. Old ?ref=<code> links continue working server-side but no new ones are minted.
Rejected for now. Cleaner long-term, but requires updating every share-link generator
in the frontend (typically InviteFriends.tsx plus the share buttons on ComingSoon.tsx)
PLUS auditing any external marketing material the operator has already deployed. Not worth
the migration cost. Backward-compatible capture of both params achieves the same analytics
benefit with zero ecosystem churn.
Alternative D: Keep the misleading frontend strings; just remove the toast¶
Patch only the most egregious case (the WelcomeCelebration toast), accept that the
share-sheet and invite-page copy still falsely promise "500 to both".
Rejected. The same lie in three places is the same lie. Fix all three.