spec: referrals status: active last-updated: 2026-05-22 owners: [@amalkrsihna] code-refs: - ftl-backend/internal/referral/bonus.go - ftl-backend/internal/referral/service.go - ftl-backend/internal/user/service.go - ftl-backend/internal/handler/routes.go - ftl-backend/migrations/000029_referral_invites_and_referrals.up.sql - ftl-frontend/src/stores/referral.ts - ftl-frontend/src/pages/Register.tsx - ftl-frontend/src/pages/InviteFriends.tsx - ftl-frontend/src/pages/ComingSoon.tsx - ftl-frontend/src/pages/Profile.tsx - ftl-frontend/src/components/WelcomeCelebration.tsx - ftl-frontend/src/App.tsx decisions: [0008, 0009]
Referrals¶
TL;DR for an intern¶
A user shares a link like https://ftl.jetafutures.com?ref=ABCD1234&invite=<uuid>. When a
friend signs up with that code and places their first trade, the inviting user (the
referrer) earns 500 coins, plus a bonus at every 5th successful invite. The friend
(the referee) gets nothing extra at signup — only the standard 10,000-coin starting
wallet. This is v2 economics; v1 paid both at signup and was abused.
Glossary¶
- Referrer — the user who shared their code. Gets paid.
- Referee — the user who used someone else's code at signup. Does NOT get paid.
- Referral code — 8-char hex assigned to every user (
generateReferralCodeinuser/service.go). Stable for the life of the account. - Invite ID — optional UUID generated when a referrer creates a named friend invite
via
POST /api/referrals/invites. Lets the referrer label the link ("invite for Asha") and lets analytics attribute conversions to the specific link. - Trade-triggered payout — the referrer's reward lands when the referee places their
first successful trade, not at signup. See
referral.Service.PayoutOnFirstTrade.
Reward economics¶
referee_signup_bonus = 0 # v2 — was 500 in v1
referrer_base_bonus = 500 # per successful referral
referrer_milestone_bonus = 100 * (n / 5) # only on multiples of 5
if n > 0 and n % 5 == 0
# Total credit at payout (referee's first trade)
referrer_total = referrer_base_bonus + referrer_milestone_bonus(n)
Where n is the referrer's referral_count AFTER the new signup is recorded. So:
- 1st invite → 500
- 5th invite → 500 + 100 = 600
- 10th invite → 500 + 200 = 700
- 25th invite → 500 + 500 = 1,000
Two parameters: ?ref= and ?invite=¶
| Param | Required | Carries | Captured by |
|---|---|---|---|
?ref=<code> |
Yes (otherwise no referral) | The referrer's 8-char code | App.tsx → useReferralStore.set(code) |
?invite=<uuid> |
No (optional analytics) | The specific named-invite row ID | App.tsx → useReferralStore.setInviteId(id) |
Both are persisted to sessionStorage under key ftl-referral so they survive the Google
OAuth redirect round-trip. The Zustand store is cleared after a successful
authGoogleComplete so a sign-out + new-account flow in the same browser doesn't credit
the wrong referrer.
Sharing the link: the referral.Service.CreateInvite handler emits a URL of the form
{frontendURL}?ref={code}&invite={inviteId} — both params populated. A user who shares the
plain ?ref= link (e.g. by typing their code into a tweet) still gets credited; they just
lose per-link attribution.
Lifecycle¶
1. Referrer T1 visits /invite → calls POST /api/referrals/invites
- Backend creates a row in referral_invites with the friend's name + a UUID.
- Backend returns shareUrl = https://ftl.jetafutures.com?ref=T1_CODE&invite=UUID.
2. Friend F1 opens the shareUrl.
- App.tsx useEffect captures ?ref=T1_CODE and ?invite=UUID into the store.
- F1 clicks "Sign in with Google".
- After OAuth + OTP, /register seeds the referral code field from the store.
3. F1 submits the profile form.
- Frontend calls POST /api/auth/google/complete with { referralCode, inviteId, ... }.
- Backend: user.RegisterFromGoogle creates F1's user row + 10,000-coin wallet, then
calls referral.Service.ClaimInviteOnRegister(tx, F1.id, "T1_CODE", &UUID).
- ClaimInviteOnRegister:
a. Resolves T1_CODE → T1.id.
b. Increments T1.referral_count → N.
c. Computes base + milestone(N) = pending payout.
d. Inserts a referrals row: (T1.id, F1.id, N, base, milestone, NULL credited_at).
e. If UUID was passed, marks that referral_invites row as claimed_by = F1.id.
f. NO wallet credit yet.
4. F1 places first successful trade (any instrument, any direction).
- The /api/trade handler calls referral.Service.PayoutOnFirstTrade(F1.id).
- PayoutOnFirstTrade atomically:
a. Finds the referrals row for F1 with reward_credited_at IS NULL.
b. Credits T1's wallet by base + milestone.
c. Sets reward_credited_at = NOW() (idempotency).
d. Pushes a notification to T1 ("Your friend traded! +500 coins").
- F1 never sees a referral-bonus banner (correctly — referee gets nothing extra).
Frontend copy contract (Finding 3)¶
The v1 economics promised "+500 to both wallets at signup". The audit (2026-05-22) found three frontend surfaces still promising the referee a bonus they never received. The corrected copy is binding — any future change here MUST flow through this spec.
| Surface | Correct copy | What it must NOT say |
|---|---|---|
WelcomeCelebration.tsx celebration screen |
(no referral-bonus banner — drop entirely) | "+500 wallet points referral bonus applied" |
ComingSoon.tsx share text |
"Join me on FTL — the football trading game. Use my code X when you sign up. |
"Use my code X for 500 bonus points" |
InviteFriends.tsx share text |
Same template as above | Same fix |
InviteFriends.tsx invite-CTA paragraph |
"Share your code — you earn 500 coins when a friend places their first trade." | "Share your code and both get 500 bonus points!" |
Register.tsx referral input placeholder |
"Have a referral code?" | "Enter referral code and earn 500 wallet points" |
Profile.tsx stats card |
"Coins earned (Referrals)" | "Bonus Points Earned" with a "both get" implication |
The referralMismatched warning on the celebration screen STAYS — it's a real signal that
the user typed a code that didn't resolve (typo'd, deleted referrer, self-referral).
Edge cases¶
- Self-referral —
referral.Service.ClaimInviteOnRegisterrejects whenreferrerID == refereeID(the user signed in with their own code). Silently dropped; the user is registered without a referral. No error surfaced. - Unknown referral code — same as self-referral. Backend silently drops. The frontend
surfaces the
referralMismatchedwarning on the celebration screen so the user knows their typo'd code didn't credit anyone. - Bad inviteId without matching ref — backend ignores
inviteIdif the resolved referrer doesn't own that invite row. Audit trail stays clean. - Multiple registrations from one browser (sign-out / new account) — the Zustand
store's
clear()runs after a successful registration. A second signup in the same browser session starts with an empty referral state. - Referrer deletes account between F1's signup and F1's first trade — the referrals
row stays (FK is
ON DELETE SET NULLonreferrer_id); payout silently no-ops because the wallet UPDATE matches zero rows. No error surfaced.
Code references¶
ftl-backend/internal/referral/bonus.go:19-46—BaseBonus,MilestoneBonus,ReferrerCredit— the only place v2 economics is encoded.ftl-backend/internal/referral/service.go—CreateInvite,ClaimInviteOnRegister,PayoutOnFirstTrade,ListReferrals.ftl-backend/internal/user/service.go:316-365—RegisterFromGooglecallsClaimInviteOnRegisterin the same transaction as the user INSERT.ftl-backend/internal/handler/routes.go:217—POST /api/referral/validate(protected; for the logged-in user's own state lookup).ftl-backend/internal/handler/routes.go:184—GET /api/public/referral/validate(new in ADR-0008; debounced from the registration form before login).ftl-backend/internal/handler/routes.go:486-498—authGoogleCompleteRequestacceptsreferralCodeandinviteId.ftl-frontend/src/stores/referral.ts— Zustand store, sessionStorage-backed, withset(code),setInviteId(id),clear().ftl-frontend/src/App.tsx:95-110—?ref=+?invite=URL-param capture.ftl-frontend/src/pages/Register.tsx— seeds local state from the store, submits both params inauthGoogleComplete, clears the store post-success.ftl-frontend/src/components/WelcomeCelebration.tsx— celebration screen. NoreferralBonusprop under v2.ftl-frontend/src/pages/InviteFriends.tsx— referrer's panel: share link, code, milestone progress, friends-invited counter.ftl-frontend/src/components/MilestoneCard.tsx— progress bar to next multiple-of-5.
Related decisions¶
- ADR-0008 — public referral validate endpoint (debounced from the registration form before login).
- ADR-0009 — v2 attribution
codified;
?invite=wired end-to-end; misleading "+500 bonus" copy retired.
How to verify¶
# Backend integration tests for the full referral lifecycle
cd ftl-backend
go test ./internal/user/ -run RegisterFromGoogle_ValidReferral\|MilestoneRefund\|SelfReferralRejected
go test ./internal/referral/...
# Public referral validate (debounced FE endpoint)
curl -s "$API_BASE/api/public/referral/validate?code=ABCD1234" | jq
# Frontend search-and-destroy — no "+500 bonus" promises left
cd ftl-frontend
rg -n "500 bonus|500 wallet|\+500" src/
# Expect zero matches.
# End-to-end attribution test (Playwright)
cd ftl-frontend
pnpm exec playwright test tests/referral-flow.spec.ts
# Manual UI repro
# 1. As user A, visit /invite → copy share link → confirm URL has both ?ref= and ?invite=.
# 2. Open the link as user B in a fresh browser → sign in via Google.
# 3. Confirm B's registration form has A's code prefilled.
# 4. Complete signup → confirm WelcomeCelebration shows NO "+500 bonus" banner.
# 5. As user B, place a trade → confirm a notification reaches A and A's wallet went up
# by 500 (or 600 on the 5th, etc.).