Skip to content

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 (BaseBonus 500 + milestone bonuses), and only after the referee fires PayoutOnFirstTrade.
  • ftl-backend/internal/user/service_test.go:205-222TestRegisterFromGoogle_ValidReferral asserts: "v2: referee gets nothing at signup" with if 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 — the authGoogleCompleteRequest struct accepts both referralCode (string) and inviteId (string, JSON tag "inviteId"). The handler at routes.go:536-537 passes both to RegisterFromGoogle.
  • ftl-frontend/src/App.tsx:95-100 — the URL-param effect reads only params.get('ref') and stores it via useReferralStore.getState().set(ref). There is no params.get('invite') anywhere.
  • ftl-frontend/src/pages/Register.tsx:442-451 — the api.authGoogleComplete call sends { registrationTicket, firstName, lastName, username, phone, age, districtId, referralCode }. No inviteId field.

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_id set in the users table 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, PayoutOnFirstTrade fires. The referrer is credited with BaseBonus = 500 coins. Subsequent referrals may unlock MilestoneBonus brackets, 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

- ✨ +500 wallet points referral bonus applied
+ ✨ You're signed up! 10,000 coins ready to trade.
Also remove the conditional rendering that gates this line on 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}
Drop the "500 bonus points" promise entirely — it never lands. The pitch is the game itself, not a bonus that doesn't exist.

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.
This one is the referrer-facing screen, so the promise IS real (for the referrer). Just correct who gets paid and when.

Wire ?invite=<id> end-to-end

  • App.tsx:95-100 — extend the URL-param effect:
    // 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);
    
    This requires extending useReferralStore with an inviteId: string | null field + setter. Keep the existing set(code) method as setReferralCode(code) for symmetry.
  • Register.tsx:442-451 — extend the submit payload:
    // Before:
    await api.authGoogleComplete({ ..., referralCode: referralCode.trim() });
    
    // After:
    await api.authGoogleComplete({
      ...,
      referralCode: referralCode.trim(),
      inviteId: useReferralStore.getState().inviteId ?? undefined,
    });
    
  • 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.
  • WelcomeCelebration component contract changes. The referralBonus prop is removed; any other call sites need updating. (Confirmed: there's only one — Register.tsx.)

Neutral / new obligations

  • Backend bonus.go:19-20 comment 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.