Skip to content

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 (generateReferralCode in user/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.tsxuseReferralStore.set(code)
?invite=<uuid> No (optional analytics) The specific named-invite row ID App.tsxuseReferralStore.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-referralreferral.Service.ClaimInviteOnRegister rejects when referrerID == 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 referralMismatched warning on the celebration screen so the user knows their typo'd code didn't credit anyone.
  • Bad inviteId without matching ref — backend ignores inviteId if 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 NULL on referrer_id); payout silently no-ops because the wallet UPDATE matches zero rows. No error surfaced.

Code references

  • ftl-backend/internal/referral/bonus.go:19-46BaseBonus, MilestoneBonus, ReferrerCredit — the only place v2 economics is encoded.
  • ftl-backend/internal/referral/service.goCreateInvite, ClaimInviteOnRegister, PayoutOnFirstTrade, ListReferrals.
  • ftl-backend/internal/user/service.go:316-365RegisterFromGoogle calls ClaimInviteOnRegister in the same transaction as the user INSERT.
  • ftl-backend/internal/handler/routes.go:217POST /api/referral/validate (protected; for the logged-in user's own state lookup).
  • ftl-backend/internal/handler/routes.go:184GET /api/public/referral/validate (new in ADR-0008; debounced from the registration form before login).
  • ftl-backend/internal/handler/routes.go:486-498authGoogleCompleteRequest accepts referralCode and inviteId.
  • ftl-frontend/src/stores/referral.ts — Zustand store, sessionStorage-backed, with set(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 in authGoogleComplete, clears the store post-success.
  • ftl-frontend/src/components/WelcomeCelebration.tsx — celebration screen. No referralBonus prop 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.
  • 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.).