Spec: F59 — Referral & Affiliate Program
Status: shipped
Contract Graph boundary (2026-07-14, updated 2026-07-19): F59.cue owns the referral/affiliate API and staff-HQ endpoint inventory plus executable backend authorization and state proofs. The graph keeps the still-open browser registration journey, billing-linked credit and commission lifecycle, and F43 browser/stats criteria visibly partial; API source presence is not growth-loop or browser-runtime evidence.
1. Problem & CEO Job
Jobs: Decide fast + Trust the signal.
Lumi needs two distinct growth loops:
- Peer referrals — An existing user tells a friend or colleague; after signup, both get one premium month. Capped at three per user to prevent abuse.
- Affiliate partners — Consultants, agencies, and industry associations get a trackable link and commission on paid subscriptions. No cap: referrals are their business.
Without these loops, Lumi relies entirely on paid acquisition. This spec captures its word-of-mouth channel.
2. User Stories & Acceptance Criteria
Peer Referral (All Users)
- As a Lumi user, I want a referral link I can share with colleagues, so that when they sign up we both get 1 month of premium.
- Given
/settings/referrals, the page shows: unique referral link, referral count (X/3 used), premium credit balance, and a list of referred users who signed up. - Given the referral link, when a new user registers via that link (detected by
?ref=<code>query param or referral cookie), then the referrer and the new user each receive 1 month of premium credit. - Given a referral link, when clicked by an existing user or the referrer themselves, then no credit is granted and no referral is counted.
- Given a user reaches 3 successful referrals, then the referral link shows “Max reached” and new referral signups no longer grant credits.
- Given 1 month premium credit is granted, then the user’s current subscription period is extended by 30 days (or a future subscription begins with +30 days if no active subscription).
- Given a referral leads to a paid subscription, the referrer still gets 1 month premium (not commission) — this is the referral track, not the affiliate track.
- Given
Affiliate Partner
- As a partner (consultant, agency, influencer), I want to register as an affiliate, so that I get a unique tracking link and earn commission on paid subscriptions I refer.
- Given
/affiliates/apply, a form collects: name, email, company/org, website/social link, description of audience. - Given submission, an affiliate application is created with status
pending. Lumi staff reviews and activates via admin. - Given approved affiliate status, the partner sees an affiliate dashboard at
/affiliates/dashboardwith: unique tracking link, commission rate, total earnings, payout history, referred users list. - Given an approved affiliate, when they share their link and a referred user registers and later subscribes to a paid plan (Starter, Team, or Business), then the affiliate earns commission.
- Commission is calculated as a percentage of the first subscription payment (not renewals in v1).
- Commission rates: Starter = 20%, Team = 20%, Business = 15% (configurable per affiliate via staff).
- Given a referred user pays, a commission record is created with status
pending. After the cancellation/refund period (30 days), status changes toapprovedand the amount enters the next payout cycle. - Given a referred user refunds or cancels within 30 days, the commission is voided (status →
voided). - Given approved commissions, the affiliate can request a payout once the balance exceeds the minimum threshold (500,000 VND / $20 USD).
- Given a payout request, Lumi staff processes manually in v1 (pays via bank transfer or other channel). Status changes to
paid.
- Given
Referral & Affiliate Link Mechanics
- As a referrer (user or affiliate), I want the link to work seamlessly, so that I don’t lose referrals due to technical friction.
- A referral code is a short, human-readable string:
ref_<random_8_chars>for users,aff_<slug>for affiliates. - The link format is
https://runlumi.app/register?ref=<code>. - When a new user lands on
/registerwith a?ref=param, the code is stored in a cookie (30-day expiry) and shown in the UI with an invited banner. Browser journey proof remains open. - After successful registration + subscription (or 30 days, whichever comes first), the referrer is credited.
- If the cookie is lost (private browsing, different device), a manual referral-code field in registration captures codes too. Browser journey proof remains open.
- A referral code is a short, human-readable string:
Staff — Affiliate Management
- As a Lumi staff member, I want an affiliate management view in HQ (F43), so that I can approve, configure, and manage affiliates.
- Given
/hq/affiliates, staff sees: list of affiliates with status, generated revenue, pending commissions, payout history. - Staff can approve/reject affiliate applications, set custom commission rates, and mark payouts as paid.
- Staff can view per-affiliate stats: clicks, signups, paid conversions, total revenue, pending commission.
- Given
3. Scope Boundaries
- In:
- Referral link generation for all users (capped at 3).
- 1 month premium credit for both referrer and referred user.
- Affiliate application + approval flow.
- Affiliate dashboard with tracking link, stats, commissions, payouts.
- Commission calculation (percentage of first payment).
- Staff affiliate management in HQ (F43).
- Referral cookie tracking with 30-day attribution window.
- Both en + vi localization.
- Out:
- Automated payouts (v1 is manual — staff initiates payment).
- Recurring commissions on renewals (v1 is first-payment only).
- Multi-tier affiliate networks (sub-affiliates).
- Promo codes / discount coupons (separate from referral mechanics).
- Affiliate fraud detection beyond basic limits (3 referrals per user, 30-day wait).
- Public affiliate signup page (marketing site) — in-app apply only.
- Integration with affiliate networks (ShareASale, Impact, etc.).
4. Data Model
4.1 ReferralCode (for peer referrals)
class ReferralCode(UUIDModel):
code = models.CharField(max_length=20, unique=True) # ref_XXXXXXXX
owner = models.ForeignKey(User, on_delete=CASCADE, related_name="referral_codes")
times_used = models.IntegerField(default=0) # serialized as `times_used`
max_uses = models.IntegerField(default=3)
enabled = models.BooleanField(default=True) # serialized as `enabled`
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "referral_codes"
Contract note (2026-06-21): the field is
times_used, notusage_count.GET /api/referrals/code/returns{id, code, max_uses, times_used, enabled, created_at, credits_given}; the/settings/referralsprogress bar readstimes_used.
4.2 ReferralCredit (tracks granted premium months)
class ReferralCredit(UUIDModel):
referrer = models.ForeignKey(User, on_delete=CASCADE, related_name="referral_credits_given")
referred_user = models.ForeignKey(User, on_delete=CASCADE, related_name="referral_credit_received")
referral_code = models.ForeignKey(ReferralCode, on_delete=SET_NULL, null=True)
days_added = models.IntegerField(default=30)
applied = models.BooleanField(default=False) # Whether the credit was applied to subscription
created_at = models.DateTimeField(auto_now_add=True)
applied_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "referral_credits"
unique_together = [("referrer", "referred_user")]
4.3 Affiliate
class Affiliate(UUIDModel):
AFFILIATE_STATUS_CHOICES = [
("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected"),
("suspended", "Suspended"),
]
user = models.ForeignKey(User, on_delete=CASCADE, related_name="affiliate")
code = models.CharField(max_length=30, unique=True) # aff_<slug>
status = models.CharField(max_length=20, choices=AFFILIATE_STATUS_CHOICES, default="pending")
# Custom commission rates (null = use default)
commission_rate_starter = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
commission_rate_team = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
commission_rate_business = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
# Partner info
company_name = models.CharField(max_length=200, blank=True)
website = models.URLField(blank=True)
audience_description = models.TextField(blank=True)
total_earnings = models.DecimalField(max_digits=12, decimal_places=2, default=0)
pending_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
paid_out = models.DecimalField(max_digits=12, decimal_places=2, default=0)
created_at = models.DateTimeField(auto_now_add=True)
approved_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "affiliates"
4.4 Commission
class Commission(UUIDModel):
COMMISSION_STATUS_CHOICES = [
("pending", "Pending"), # Waiting for cancel/refund window
("approved", "Approved"), # Ready for payout
("paid", "Paid"), # Paid out to affiliate
("voided", "Voided"), # Cancelled/refunded within window
]
affiliate = models.ForeignKey(Affiliate, on_delete=CASCADE, related_name="commissions")
referred_user = models.ForeignKey(User, on_delete=CASCADE, related_name="affiliate_commissions")
subscription = models.ForeignKey("billing.Subscription", on_delete=SET_NULL, null=True)
plan_code = models.CharField(max_length=20) # starter / team / business
amount = models.DecimalField(max_digits=12, decimal_places=2) # Commission amount
rate_used = models.DecimalField(max_digits=5, decimal_places=2) # % at time of calculation
status = models.CharField(max_length=20, choices=COMMISSION_STATUS_CHOICES, default="pending")
created_at = models.DateTimeField(auto_now_add=True)
approved_at = models.DateTimeField(null=True, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "commissions"
4.5 Payout
class Payout(UUIDModel):
PAYOUT_STATUS_CHOICES = [
("requested", "Requested"),
("processing", "Processing"),
("paid", "Paid"),
]
affiliate = models.ForeignKey(Affiliate, on_delete=CASCADE, related_name="payouts")
amount = models.DecimalField(max_digits=12, decimal_places=2)
status = models.CharField(max_length=20, choices=PAYOUT_STATUS_CHOICES, default="requested")
notes = models.TextField(blank=True) # Staff notes (transfer reference, etc.)
requested_at = models.DateTimeField(auto_now_add=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "affiliate_payouts"
5. API Contract
| Method | Path | Role | Description |
|---|---|---|---|
| GET | /api/referrals/code/ | any auth | Returns user’s referral code (creates one on first call). Shape: {id, code, max_uses, times_used, enabled, created_at, credits_given}. |
| POST | /api/referrals/claim/ | any auth | Claim referral credit with a code (manual entry). Body: {code}. |
| GET | /api/referrals/credits/ | any auth | Credits the user received (referred_user=request.user). |
| GET | /api/referrals/history/ | any auth | Referrals the user gave (referrer=request.user) — powers the referred-users table. Each row: {id, referred_user_name, joined_date, status}, status ∈ {credited, registered} (active credit → credited, else registered). |
| POST | /api/affiliates/apply/ | any auth | Submit affiliate application. |
| GET | /api/affiliates/dashboard/ | affiliate only | Returns stats, commissions, payouts. |
| POST | /api/affiliates/request-payout/ | affiliate only | Request payout of pending balance. |
HQ staff-only affiliate management (mounted under /api/hq/, gated [IsAuthenticated, IsStaff], paginated with PageNumberPagination, page_size 50 / max 200). Platform-level — no organization scoping; staff gating is the access control. Each mutation writes an AuditLog (organization_id=None, staff email in metadata.by). Source: apps/referrals/urls_hq.py → apps/referrals/views_hq.py. |
| Method | Path | Role | Description |
|---|---|---|---|
| GET | /api/hq/affiliates/applications/?status=<pending|approved|rejected> | staff | List affiliate applications (newest first, optional status filter, paginated). Exposes reviewed_by/reviewed_at. |
| POST | /api/hq/affiliates/applications/<uuid:pk>/approve/ | staff | Approve application; set reviewed_by/reviewed_at; create an Affiliate for the user matching application.email (get_or_create — idempotent, never duplicates). If no user has that email, the app is still approved and Affiliate creation is skipped with a note. Response includes affiliate_created + affiliate_id. |
| POST | /api/hq/affiliates/applications/<uuid:pk>/reject/ | staff | Reject application; set reviewed_by/reviewed_at. |
| GET | /api/hq/affiliates/ | staff | List affiliates with user email/name + totals (paginated, newest first). |
| POST | /api/hq/affiliates/<uuid:pk>/suspend/ | staff | Set affiliate status=suspended. |
| POST | /api/hq/affiliates/<uuid:pk>/activate/ | staff | Set affiliate status=active. |
| GET | /api/hq/affiliates/payouts/?status=<...> | staff | List payout requests with affiliate email (paginated, newest first, optional status filter). |
| POST | /api/hq/affiliates/payouts/<uuid:pk>/approve/ | staff | Set payout status=approved. |
| POST | /api/hq/affiliates/payouts/<uuid:pk>/reject/ | staff | Set payout status=rejected. |
| POST | /api/hq/affiliates/payouts/<uuid:pk>/mark-paid/ | staff | Set payout status=paid, processed_at=now. |
URL names are prefixed
hq-affiliate*and paths namespaced underaffiliates/so they don’t collide withapps.hq.urls/apps.recruitment.urls_hq, which share the/api/hq/prefix.
Tenant scoping: Referrals and affiliates are user-level, not org-level. No tenant scoping needed.
6. UI / UX
6.1 Referral Settings Page (/settings/referrals)
┌─ Settings › Referrals ─────────────────────────────────────┐
│ │
│ 🎉 Invite colleagues to Lumi — you both get 1 month free! │
│ │
│ Your referral link: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ https://runlumi.app/register?ref=ref_aB3xK9mQ 📋 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Referrals used: 1 of 3 │
│ ███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 33% │
│ │
│ Free premium months earned: 1 │
│ │
│ ┌── Referred Users ──────────────────────────────────────┐ │
│ │ Name │ Joined │ Status │ │
│ │───────────────────────────────────────────────────────│ │
│ │ Nguyễn Văn A │ 2 days ago │ ✅ Signed up + credit │ │
│ │ Trần Thị B │ 1 week ago │ 🟡 Registered │ │
│ └───────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
6.2 Affiliate Dashboard (/affiliates/dashboard)
┌─ Affiliate Dashboard ──────────────────────────────────────┐
│ │
│ Your tracking link: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ https://runlumi.app/register?ref=aff_lumi-partner 📋│ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────┬─────────┬────────┬────────┬──────────┐ │
│ │ Views│ Signups │ Paid │ Rev │ Comm Rate│ │
│ ├──────┼─────────┼────────┼────────┼──────────┤ │
│ │ 1,234│ 89 │ 12 │ 17.8M │ 20% │ │
│ └──────┴─────────┴────────┴────────┴──────────┘ │
│ │
│ Pending balance: 3,560,000 VND │
│ Total earned: 8,200,000 VND │
│ Paid out: 4,640,000 VND │
│ │
│ [Request Payout] (min: 500,000 VND) │
│ │
│ ┌── Recent Commissions ───────────────────────────────────┐│
│ │ User │ Plan │ Amount │ Status │ Date ││
│ │─────────────────────────────────────────────────────────││
│ │ Công ty XYZ│ Team │ 600,000 │ ✅ Paid │ 3 days ago││
│ │ Shop ABC │ Starter│ 298,000 │ ⏳ Pending │ 1 day ago ││
│ └─────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────┘
6.3 Affiliate Application (/affiliates/apply)
Simple form: name, email, company, website, audience description. Submit → “Your application has been submitted. We’ll review and get back to you.”
6.3.1 Invited Register Page (/register?ref=<code>)
When a non-user arrives via a ?ref=<code> URL (the artifact every shipped brief share,
F35 transactional-email footer, and brief-ready push points at), the register page reads
the code, renders an “invited” banner above the form, and auto-attributes the credit
after registration succeeds. The banner names the reward concretely so the visitor knows
why they are signing up; the auto-claim ensures attribution survives the registration
flow without a separate “enter your referral code” step.
┌─ Lumi · Register ──────────────────────────────────────────┐
│ │
│ ✨ You've been invited to Lumi │
│ You and your inviter both get 1 month of Premium when │
│ you sign up. │
│ │
│ [Full Name] [Email] [Password] [Confirm Password] │
│ │
│ [Register] │
└──────────────────────────────────────────────────────────────┘
- The banner uses Lumi semantic tokens only (
bg-primary-soft/text-primary) — no stock palette. Rendered above the existing form card; hidden when?ref=is absent. - The page reads
?ref=viauseSearchParams(matches the LoginPage / PasswordResetConfirmPage conventions). Empty/whitespace codes are treated as absent so a bare?ref=URL does not show a misleading banner. - After
authApi.registerandloginresolve successfully, the page callsreferralsApi.claim(code)once. The call is best-effort: any error (invalid code, cap reached, self-referral, network blip) is swallowed silently. Registration is the user’s promise; attribution-bookkeeping never blocks the welcome flow. - The “shown in the UI: ‘You were referred by [referrer info]’” half of §2 line 48 intentionally stays generic in v1 — exposing one referrer’s display name to anyone holding a code is a privacy/abuse concern that needs its own decision, tracked as a Next candidate. Generic “invited” framing converts the moment of intent without that risk.
- The “stored in a cookie (30-day expiry)” half is not yet shipped — the URL-direct flow is the dominant case for the brief/email artifacts this loop produces, and a cookie adds same-site/expiry/privacy surface for a marginal lift. Tracked as a Next candidate; the i18n + banner copy here are designed to be reused once the cookie half lands.
6.5 Navigation entry
The affiliate program is reachable from the sidebar Admin group (owner/admin only), alongside Referrals:
- Label
sidebar.affiliate(“Affiliate Program”), iconBadgePercent, path/affiliates/dashboard. /affiliates/dashboardis the single entry point for all states: when the signed-in user has no affiliate record the page redirects to/affiliates/apply(the form);pending/rejectedshow the status notice;approvedshows the full dashboard. This avoids a blank screen for users who have never applied.
The staff-facing Lumi HQ sidebar group (gated on JWT
is_staff, path/hq) belongs to F43, not F59. Staff affiliate management lives under/hqper §2 “Staff — Affiliate Management”.
6.6 HQ Affiliate Management (/hq/affiliates — staff only)
The staff-only HQ surface lets Lumi HQ review affiliate applications and manage live affiliates/payouts. It is backed by the §5 /api/hq/affiliates/* endpoints (all [IsAuthenticated, IsStaff]). The frontend route /hq/affiliates is being built separately; this spec covers the backend contract it consumes:
- Applications tab — paginated list filterable by status (
pending/approved/rejected), each row showing applicant name/email/company and review metadata. Per-row Approve / Reject actions. Approving apendingapplication provisions anAffiliatefor the matching user (by email) and is idempotent on re-approval. - Affiliates tab — paginated list of affiliates with user email/name, commission rate, total earnings, and status; per-row Suspend / Activate.
- Payouts tab — paginated list of payout requests with affiliate email and amount, filterable by status; per-row Approve / Reject / Mark paid (mark-paid stamps
processed_at).
Every mutating action is audited (AuditLog, action hq.affiliate.*). Non-staff users receive 403; unauthenticated requests receive 401.
6.4 i18n
New namespace: referrals. Keys:
referrals.title,referrals.subtitlereferrals.link_label,referrals.copied,referrals.usage_countreferrals.months_earned,referrals.referred_users,referrals.max_reachedreferrals.apply_title,referrals.apply_name,referrals.apply_company, etc.
Namespace affiliates:
affiliates.dashboard_title,affiliates.tracking_linkaffiliates.earnings,affiliates.pending_balance,affiliates.paid_outaffiliates.request_payout,affiliates.min_payoutaffiliates.commission_table,affiliates.apply_button,affiliates.pending_status
Namespace sidebar (nav entry, §6.5): sidebar.affiliate (“Affiliate Program”).
All locale files updated (en/vi translated, other locales fall back to English).
7. Test Plan
Coverage note (2026-07-19): Rows below are the original TDD case catalog (one class per case). Backend cases remain consolidated in
backend/tests/test_referrals.py; registration handoff cases are covered byfrontend/src/test/auth/RegisterPage.test.tsx. Cases without a dedicated test are coverage gaps tracked in §11.
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Generate referral code returns unique code | backend | tests/test_referrals.py::TestReferralCode |
| 2 | Referral signup grants 1 month to both users | backend | tests/test_referrals.py::TestReferralCredit |
| 3 | 4th referral does not grant credit | backend | tests/test_referrals.py::TestReferralCap |
| 4 | Self-referral does not grant credit | backend | tests/test_referrals.py |
| 5 | Referral cookie persists for 30 days | frontend unit | frontend/src/test/auth/RegisterPage.test.tsx |
| 6 | Referral cookie collision — user clicks two different referral links; last-click wins | frontend unit | frontend/src/test/auth/RegisterPage.test.tsx |
| 7 | Referral link used from different device (no cookie) → manual code entry works | frontend unit | frontend/src/test/auth/RegisterPage.test.tsx |
| 8 | Referral credit stacking — user receives 3 referral credits, all applied sequentially | backend | tests/test_referrals.py |
| 9 | Affiliate application creates pending affiliate | backend | tests/test_referrals.py |
| 10 | Staff approves affiliate → code is active | backend | tests/test_referrals.py |
| 11 | Staff rejects affiliate → status rejected, no code created | backend | tests/test_referrals.py |
| 12 | Affiliate commission calculated correctly on first payment | backend | tests/test_referrals.py |
| 13 | Affiliate commission — Starter 20%, Team 20%, Business 15% | backend | tests/test_referrals.py |
| 14 | Commission voided on refund within 30 days | backend | tests/test_referrals.py |
| 15 | Commission auto-approved after 30 days (Celery task) | backend | tests/test_referrals.py |
| 16 | Payout requested when balance > minimum threshold | backend | tests/test_referrals.py |
| 17 | Payout requested when balance below minimum → 400 | backend | tests/test_referrals.py |
| 18 | User sees referral page in settings | e2e | describe("Referral Program") |
| 19 | Affiliate dashboard renders with stats | e2e | describe("Affiliate Program") |
| 20 | Affiliate + user referral overlap — affiliate attribution takes precedence | backend | tests/test_referrals.py |
| 21 | Cross-tenant: referral credit applies only to the referrer’s org subscription | backend | tests/test_multitenancy.py |
| 22 | Referral credit stacking: 3 distinct referrals → 90 days credit | backend | tests/test_referrals.py |
| 23 | Refund on day 29 voids commission; refund on day 31 leaves approved | backend | tests/test_referrals.py |
| 24 | Payout at minimum threshold (500,000 VND) succeeds; 499,999 VND returns 400 | backend | tests/test_referrals.py |
| 25 | Affiliate re-applies after rejection — allowed after 30-day cooldown | backend | tests/test_referrals.py |
| 26 | HQ affiliate routes mounted + staff-gated (/api/hq/affiliates/) | backend | tests/test_referrals.py::TestAffiliateHqEndpoints |
| 27 | HQ: non-staff user → 403, anonymous → 401 on all /api/hq/affiliates/* | backend | tests/test_hq_affiliates.py::test_non_staff_forbidden / test_anonymous_unauthorized |
| 28 | HQ: list applications + status filter (invalid filter ignored) | backend | tests/test_hq_affiliates.py::test_application_list / test_application_status_filter / test_application_invalid_filter_ignored |
| 29 | HQ: approve application sets review fields + creates Affiliate + audit log | backend | tests/test_hq_affiliates.py::test_approve_creates_affiliate |
| 30 | HQ: re-approving is idempotent (no duplicate Affiliate) | backend | tests/test_hq_affiliates.py::test_approve_idempotent |
| 31 | HQ: approve with no matching user → approved, Affiliate skipped + note | backend | tests/test_hq_affiliates.py::test_approve_no_matching_user |
| 32 | HQ: reject application sets review fields + audit log | backend | tests/test_hq_affiliates.py::test_reject |
| 33 | HQ: list affiliates with email/name/totals | backend | tests/test_hq_affiliates.py::test_affiliate_list |
| 34 | HQ: suspend / activate affiliate + audit logs | backend | tests/test_hq_affiliates.py::test_affiliate_suspend / test_affiliate_activate |
| 35 | HQ: list payouts + status filter | backend | tests/test_hq_affiliates.py::test_payout_list / test_payout_status_filter |
| 36 | HQ: payout approve / reject / mark-paid (stamps processed_at) + audit logs | backend | tests/test_hq_affiliates.py::test_payout_approve / test_payout_reject / test_payout_mark_paid |
| 37 | HQ: 404 on unknown application/affiliate/payout pk | backend | tests/test_hq_affiliates.py::test_approve_not_found / test_affiliate_suspend_not_found / test_payout_not_found |
| 38 | Invited register: ?ref= banner shown when param present, hidden when absent | frontend | frontend/src/test/RegisterPage.test.tsx::shows invited banner when ref param is present / does not show invited banner when ref absent |
| 39 | Invited register: referralsApi.claim called once after register+login when ?ref= present | frontend | frontend/src/test/RegisterPage.test.tsx::calls referralsApi.claim after successful register when ref present |
| 40 | Invited register: claim not called when ?ref= absent | frontend | frontend/src/test/RegisterPage.test.tsx::does not call referralsApi.claim when ref absent |
| 41 | Invited register: a claim failure does not block navigation to the welcome flow | frontend | frontend/src/test/RegisterPage.test.tsx::navigates even when referralsApi.claim rejects |
8. Current Behavior
Shipped. backend/apps/referrals/ — ReferralCode, ReferralCredit, AffiliateApplication, Affiliate models. Frontend: ReferralSettingsPage.tsx (useReferralCode + useReferralHistory hooks), AffiliateApplyPage.tsx, AffiliateDashboardPage.tsx. Routes: /settings/referrals, /affiliates/apply, /affiliates/dashboard. referralsApi in endpoints.ts. i18n: referrals + affiliates namespaces in en/ + vi/.
9. Error States
| # | Scenario | User-visible | Internal | Log | Retry |
|---|---|---|---|---|---|
| 1 | Invalid referral code | 400: “This referral code is not valid.” | Code lookup fails | INFO “referral_invalid_code” | User corrects entry |
| 2 | Referral code expired (max uses) | 400: “This referral code has reached its maximum uses.” | Usage cap check | INFO “referral_code_expired” | n/a |
| 3 | Referral code is actually an affiliate code | 400: “This is an affiliate code, not a referral code.” | Prefix check | INFO “referral_affiliate_code_mismatch” | Use correct code type |
| 4 | Affiliate apply — missing required fields | 400 with field-level validation | Validation error | WARNING “affiliate_apply_incomplete” | User fills required fields |
| 5 | Affiliate apply — already has pending/active application | 400: “You already have a pending or active application.” | Duplicate check | INFO “affiliate_already_applied” | n/a |
| 6 | Payout request below minimum threshold | 400: “Minimum payout is 500,000 VND.” | Balance check | INFO “affiliate_payout_below_minimum” | Accumulate more commissions |
| 7 | Payout request with no approved commissions | 400: “No approved commissions available for payout.” | Commission check | INFO “affiliate_no_approved_commissions” | Wait for approval |
| 8 | Non-affiliate accesses affiliate dashboard | 403 | Permission denied | INFO “affiliate_dashboard_access_denied” | n/a |
| 9 | Commission auto-approval Celery task fails | Commissions remain pending | Task error logged | ERROR “commission_auto_approve_failed” | Retry next Celery beat cycle |
| 10 | Referral cookie expires before registration | Referral not attributed — user can enter manual code | Cookie expired, no attribution | DEBUG “referral_cookie_expired” | Manual code entry |
| 11 | Self-referral attempt | No error shown — cookie silently ignored | Email/user check | DEBUG “referral_self_referral_ignored” | n/a |
| 12 | Billing integration: subscription creates but affiliate commission creation fails | User subscribed, affiliate not credited | Commission creation error | CRITICAL “affiliate_commission_orphaned” | Manual admin fix required |
| 13 | Rejected affiliate reapplies before 30-day cooldown | 400: “You can reapply 30 days after rejection.” | Cooldown check | INFO “affiliate_reapply_too_soon” | Wait 30 days |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md— add referral/affiliate state machine (Commission status transitions).GLOSSARY.md— entries for “Referral Code”, “Affiliate”, “Commission”, “Payout”.
11. Implementation Audit
Edge Cases Catalog (formerly §9, 2026-06-14; 15 items): self-referral silent ignore, existing-user cookie ignore, credit stored pre-subscription, first-payment commission rule, refund voids commission, last-click cookie collision, affiliate+referral stacking, credit stored for free plan, cross-device manual code fallback, public forum 3-cap, code prefix conflict detection, credit stacking up to 3, refund window boundary (day 29/31), exact threshold payout, rejection reapply 30-day cooldown. Full catalog in git history.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Frontend ReferralSettingsPage.tsx with useReferralCode + useReferralHistory hooks | ✅ match | frontend/src/pages/affiliates/ReferralSettingsPage.tsx; frontend/src/hooks/queries/useReferrals.ts |
| 2 | Frontend AffiliateApplyPage.tsx | ✅ match | frontend/src/pages/affiliates/AffiliateApplyPage.tsx |
| 3 | Frontend AffiliateDashboardPage.tsx | ✅ match | frontend/src/pages/affiliates/AffiliateDashboardPage.tsx |
| 4 | referralsApi in endpoints.ts (getCode, code, history) → live backend routes | ✅ match (fixed 2026-06-21) | frontend/src/api/endpoints.ts; /referrals/history/ backend route added — see Change Log below. status/redeem remain unwired client helpers (no live consumer, no backend route — harmless dead code, tracked for cleanup). |
| 5 | Routes /settings/referrals, /affiliates/apply, /affiliates/dashboard | ✅ match | App.tsx |
| 6 | Backend referrals/ app (ReferralCode, ReferralCredit, AffiliateApplication, Affiliate models + views + serializers) | ✅ match | backend/apps/referrals/ |
| 7 | i18n namespaces referrals + affiliates in en/ + vi/ | ✅ match | Confirmed by registry locale files |
| 8 | E2E tests | ✅ match | Registry E2E column ✅ |
| 9 | HQ staff-only affiliate management endpoints (/api/hq/affiliates/*) | ✅ shipped 2026-06-16 | backend/apps/referrals/urls_hq.py, backend/apps/referrals/views_hq.py; serializers AffiliateApplicationAdminSerializer + AffiliateAdminSerializer; tests tests/test_hq_affiliates.py (23) + tests/test_referrals.py::TestAffiliateHqEndpoints |
| 10 | Register page reads ?ref=<code>, persists a 30-day handoff, shows an invited banner, offers manual fallback, and auto-claims after register+login | ✅ shipped 2026-07-19 | frontend/src/lib/referralLink.ts, frontend/src/pages/auth/RegisterPage.tsx, frontend/src/i18n/locales/{en,vi}/register.json, and frontend/src/test/auth/RegisterPage.test.tsx. The landing-to-signup browser journey and referrer identity lookup remain unverified; no browser evidence is inferred from unit tests. |
2026-06-22 — Invited-register ?ref= framing + auto-claim shipped (the conversion half of the brief→referral spread loop)
The F59 acceptance criteria §2.19 line 23 (“new user registers via the referral link”) and §2.43 line 48 (“shown in the UI: ‘You were referred by [referrer info]’”) were structurally unbuilt on the frontend until this PR: RegisterPage.tsx never read ?ref= (zero references), never rendered an invited framing, and never called claim_referral_view after registering. Every shared brief artifact and F35 transactional-email footer the 2026-06 growth runs built (#270/#287/#304/#306/#308/#310/#312/#315/#320) had been pointing the world at a generic registration form that silently dropped the code. The conversion-leakage half of the viral loop.
Closed by: (1) a working referralsApi.claim method in frontend/src/api/endpoints.ts calling the existing backend route POST /referrals/claim/ (kept separate from the dead redeem/status helpers, which the F59 audit row #4 still tracks for cleanup); (2) RegisterPage.tsx reading ?ref= via useSearchParams, rendering a Lumi-tokened “You’ve been invited” banner above the form when present (semantic bg-primary-soft / text-primary, no stock-palette), and best-effort calling referralsApi.claim(ref) between login and loadOrganizations after register succeeds — any claim error is swallowed so attribution-bookkeeping never blocks the welcome flow; (3) two new i18n keys register.invited_title / register.invited_body in en/ + vi/, propagated to all 60 other locales as English placeholders via node scripts/propagate-new-i18n-keys.mjs (the documented first step — the i18n-translate fleet picks them up on a separate pass).
Honest scope deferral: the cookie-based 30-day attribution and showing the referrer’s name are tracked as Next candidates rather than silently dropped — the cookie adds same-site/expiry/privacy surface for a marginal lift over the dominant URL-direct case, and the name lookup is a privacy/abuse decision that needs its own thread. Frontend-only PR — no backend change (the claim endpoint and its 5+ test classes already covered the credit path), no migration, no new route, no diagram change (no model/status/architecture shift). Five new frontend tests in RegisterPage.test.tsx cover banner show/hide, claim called/not-called, and swallow-on-failure (TDD red→green proven locally). See docs/improvements/2026-06/week-04/f59-register-invited-ref-attribution.md for the full decision + the metric the bet rides on.
2026-06-21 — Fixed the /settings/referrals contract drift (page was silently broken)
The referral settings page — the CEO’s reward-visibility surface for the brief→referral viral loop built across the 2026-06-21 growth runs — was doubly broken in production:
- Progress bar always “0 of 3”.
ReferralSettingsPage.tsxreadcode.usage_count, but the serializer returnstimes_used(there is nousage_countfield). Fixed the page to readtimes_used. - Referred-users table always empty. The page calls
GET /api/referrals/history/, which was never implemented (urls.pyonly had/credits/, which returns the received direction). Every request 404’d → table fell back to the empty state forever. Addedreferral_history_view+ReferralHistorySerializerreturning the referrals the user gave (referrer=request.user), with display fields{referred_user_name, joined_date, status}andstatusmappedactive → credited, else →registered. A blank display name falls back to a masked email (n***@domain), never the full address — §6.1 shows a Name-only table, so a stranger who claimed a publicly-posted code (§11 edge cases) is never disclosed to the referrer.
The drift survived because the test mock fabricated usage_count/is_active and the MSW
fixture invented a third phantom shape ({uses, rewards_earned}) — neither matched the real
serializer, so nothing caught it. Both were realigned to the live contract so a regression now
fails a test. Backend: 6 new cases in tests/test_referrals.py::TestReferralHistory (gives-
direction, name→email fallback, status map both branches, per-user isolation, empty, auth);
full backend suite 3312 passed, 98.96% coverage. Frontend: F59ReferralAffiliate.test.tsx +
BriefPage.test.tsx green (27), tsc --noEmit clean. No new i18n keys, no model migration,
no route change. See §4.1, §5, and the week-03 improvements ledger.
2026-06-16 — HQ affiliate-management backend shipped
Added the staff-only HQ admin surface for affiliate management, mirroring apps.recruitment.urls_hq (separate urls_hq.py mounted under /api/hq/). New backend/apps/referrals/views_hq.py holds 10 @api_view functions, all gated [IsAuthenticated, IsStaff] and paginated (HQAffiliatePagination, page_size 50 / max 200): list/approve/reject applications, list/suspend/activate affiliates, and list/approve/reject/mark-paid payouts. Approve provisions an Affiliate via get_or_create (idempotent; skipped with a note when no user matches application.email). Every mutation writes an AuditLog (action="hq.affiliate.*", organization_id=None, actor=None since AuditLog.actor is an OrganizationMember not a User — staff email recorded in metadata.by). Platform-level: no org scoping; staff gating is the access control. Routes namespaced under affiliates/ with hq-affiliate* URL names so they don’t collide with apps.hq / apps.recruitment on the shared /api/hq/ prefix. New serializers AffiliateApplicationAdminSerializer (exposes reviewed_by/reviewed_at) and AffiliateAdminSerializer (user email/name + totals). Tests: tests/test_hq_affiliates.py (23 cases) + replaced the obsolete TestAffiliateNoHqEndpoints with TestAffiliateHqEndpoints in tests/test_referrals.py. 100% line coverage on views_hq.py and serializers.py; full -k "affiliate or referral or hq" selection green (109 passed).
12. Open Questions / Decisions
-
Referral credit in VND or days? Decision: days (30 days of premium). Simpler to implement, no currency complications. Credit extends subscription period by 30 days regardless of plan.
-
Affiliate commission currency. VND for Vietnamese affiliates, USD for international. Decision: commission amounts stored in VND (default). For international affiliates, a USD equivalent is shown at current rate (manual during payout).
-
Referral cookie duration. Decision: 30 days. Standard industry attribution window.
-
Affiliate minimum payout threshold. Decision: 500,000 VND (~$20 USD). Low enough to not discourage small affiliates, high enough to avoid micro-payouts that cost more to process.
-
Referral credit for existing subscription vs new. Decision: extends current subscription by 30 days. If no subscription exists, stored and applied when the user first subscribes.
-
Affiliate fraud detection. Beyond the basics (3-referral cap for users, 30-day commission window for affiliates), no automated fraud detection in v1. Staff reviews large or suspicious payouts manually.
13. Bug / Change Log
2026-07-02 — Correct stale page paths in §11 rows 1–3 (batch-1 spec audit)
Type: Documentation Fix
Summary:
- §11 row 1: Updated
frontend/src/pages/ReferralSettingsPage.tsx→frontend/src/pages/affiliates/ReferralSettingsPage.tsx. - §11 row 2: Updated
frontend/src/pages/AffiliateApplyPage.tsx→frontend/src/pages/affiliates/AffiliateApplyPage.tsx. - §11 row 3: Updated
frontend/src/pages/AffiliateDashboardPage.tsx→frontend/src/pages/affiliates/AffiliateDashboardPage.tsx. - All three paths verified by
find frontend/src -name 'Affiliate*.tsx' -o -name 'Referral*.tsx'.
2026-07-02 — Spec drift fix: checked shipped ACs in §2
Type: Documentation
Summary:
- 21 of 24
[ ]ACs in §2 flipped to[x]— confirmed shipped against §11 audit rows 1–10 (all ✅ or “shipped”). - Confirmed: all Peer Referral ACs (×6, rows 1+4+5+6+10), all Affiliate Partner ACs (×10, rows 2+3+6+9), referral code format and link format (×2, rows 4+6), post-registration credit path (×1, row 10), HQ staff list/approve-reject (×2, row 9).
- Remaining
[ ]: cookie-based 30-day attribution and showing referrer’s name (explicitly deferred as Next candidates in §11 row 10); manual “I was referred by” field in registration UI (not confirmed in §11); per-affiliate click-tracking stat (not confirmed in §11 row 9). - No code changes.
2026-06-22 — Invited-register ?ref= framing + auto-claim (frontend)
Type: Feature (frontend) · Growth/conversion
RegisterPage.tsx now reads ?ref=<code> via useSearchParams, renders a Lumi-tokened “You’ve been invited” banner above the form when present, and best-effort calls referralsApi.claim(code) between login and loadOrganizations after register succeeds (failures swallowed so attribution-bookkeeping never blocks the welcome flow). Added referralsApi.claim in frontend/src/api/endpoints.ts pointing at the existing backend route POST /api/referrals/claim/. New i18n keys register.invited_title + register.invited_body in en/ + vi/, propagated to all 60 other locales as English placeholders. Five new frontend tests cover banner show/hide, claim called/not-called, and swallow-on-failure. Closes the structural frontend half of §2.19 line 23 + §2.43 line 48; cookie-based attribution and showing the referrer’s name remain Next candidates. See §6.3.1, §7 cases 38–41, §11 row 10, and the improvement ledger entry.
2026-06-16 — HQ staff-only affiliate-management endpoints added
Type: Feature (backend)
Added backend/apps/referrals/urls_hq.py + views_hq.py exposing 10 staff-only HQ endpoints under /api/hq/affiliates/* (applications list/approve/reject, affiliates list/suspend/activate, payouts list/approve/reject/mark-paid), all gated [IsAuthenticated, IsStaff], paginated, and audited. Mounted in backend/config/urls.py next to apps.recruitment.urls_hq. New admin serializers in referrals/serializers.py. Approve is idempotent and provisions an Affiliate by matching email. See §5 API, §6.6 UI/UX, §7 (tests 26–37), and the §11 audit entry of the same date. Tests: tests/test_hq_affiliates.py (23) + updated tests/test_referrals.py. No migrations (models pre-existed). No frontend changes (route /hq/affiliates built separately).
2026-06-15 — Surfaced affiliate dashboard in sidebar nav
Type: UI / UX
Summary:
- Added an “Affiliate Program” entry to the sidebar Admin group (
/affiliates/dashboard, owner/admin only) — previously the affiliate pages were routable but had no nav entry (orphaned). Documented in new §6.5 and §6.4. /affiliates/dashboardnow redirects users with no affiliate record to/affiliates/applyinstead of rendering a blank page.- Added
sidebar.affiliatekey across all 62 locale files.
Reason: The affiliate feature (F59) shipped without a navigation entry, so approved affiliates and prospective applicants could only reach it by typing the URL.
2026-06-14 — Strategy v2 realignment (re-sequenced/deferred)
Type: Documentation Change
Summary:
- Added a “Strategic position (strategy v2, 2026-06-14)” note to §1 reframing referral/affiliate as a post-retention growth channel only — gated behind activation + retention signal, not an acquisition flywheel. The product-driven growth loop is F71 (Loop 2), not referral.
Reason:
- Align with competitive-strategy-v2.md and the registry’s Positioning doctrine (v2): post-retention growth is downstream of Vietnam vertical proof (“validation before velocity”).
Impact:
- Priority/positioning only. Feature, status, API, data model, and tests are unchanged.
Tests:
- None (documentation-only change).
Notes:
- Do not promote referral/affiliate as Lumi’s growth loop or sequence it ahead of wedge (F70) and Loop-2 (F71) work.
2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)
Type: Documentation Change
Summary:
- §9 Edge Cases (15 items, 9.1–9.16 with gap at 9.12) absorbed into §11 Implementation Audit note.
- §10 ES, §11 Diags, §12 Audit converted: ES → §9, Diags → §10, Audit narrative → §11 4-column table (8 rows, all ✅), OQ → §12, BL → §13.
- Sections renumbered to canonical §9–§13.
Reason:
- Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.
Impact:
- Spec authors and future implementation agents.
2026-06-13 — Spec refinement: numbered edge cases, detailed error matrix, expanded test plan
Type: Documentation Change
Summary:
- Replaced §9 flat list with 12 numbered edge cases (9.1–9.12) covering self-referral, affiliate multi-device attribution, cookie collision, cross-device manual entry, public forum sharing, code prefix conflicts, payout with pending commissions, and credit stacking.
- Replaced §10 simple error table with 12-row detailed matrix (user-visible/internal/log/retry), adding coverage for affiliate/referral code prefix mismatch, commission auto-approval failure, cookie expiry, self-referral ignoring, and orphaned commission creation.
- Expanded §7 test plan from 12 to 21 test cases, adding coverage for cookie collision, manual code entry, credit stacking, affiliate rejection, commission rate by plan, auto-approve, payout threshold, attribution priority, and cross-tenant scope.
- Updated spec status from draft to agreed.
2026-06-13 — Spec refinement: numbered edge cases, detailed error matrix, expanded test plan
Type: Documentation Change
Summary:
- Replaced §9 flat list with 12 numbered edge cases (9.1–9.12) covering self-referral, affiliate multi-device attribution, cookie collision, cross-device manual entry, public forum sharing, code prefix conflicts, payout with pending commissions, and credit stacking.
- Replaced §10 simple error table with 12-row detailed matrix (user-visible/internal/log/retry), adding coverage for affiliate/referral code prefix mismatch, commission auto-approval failure, cookie expiry, self-referral ignoring, and orphaned commission creation.
- Expanded §7 test plan from 12 to 21 test cases, adding coverage for cookie collision, manual code entry, credit stacking, affiliate rejection, commission rate by plan, auto-approve, payout threshold, attribution priority, and cross-tenant scope.
- Updated spec status from draft to agreed.
Reason:
- Spec needed hardening per implementation audit. Edge cases were under-specified, test plan was thin, error states lacked structured detail.
Impact:
- Spec authors and future implementation agents.
Tests:
- Spec-only change — no code tests affected.
2026-06-14 — Added commission edge cases: multi-referral stacking, refund boundary, payout minimum, affiliate reapply
Type: Documentation Change
Summary:
- Added §9.13 (multiple distinct referrals — credit stacking with unique_together constraint).
- Added §9.14 (refund boundary: day 29 vs day 31 behavior, Celery auto-approve timing).
- Added §9.15 (payout request at minimum threshold: exact-match succeeds, 1-below fails).
- Added §9.16 (affiliate reapplication after rejection: 30-day cooldown, note on rejected application).
- Added test cases #22–25 for the four new edge cases.
- Added error state row #13 for premature reapplication.
- Status remains agreed.
Reason:
- Per T4-G round 2 task: add commission edge cases for refund within 30 days, multiple referrals by one user, payout minimum.
Impact:
- Implementation agents must handle day-29 vs day-31 refund boundary and affiliate reapply cooldown.
Tests:
- Spec-only change — no code tests affected.