shipped

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.

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/dashboard with: 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 to approved and 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.
  • 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 /register with 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.

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.

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, not usage_count. GET /api/referrals/code/ returns {id, code, max_uses, times_used, enabled, created_at, credits_given}; the /settings/referrals progress bar reads times_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

MethodPathRoleDescription
GET/api/referrals/code/any authReturns 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 authClaim referral credit with a code (manual entry). Body: {code}.
GET/api/referrals/credits/any authCredits the user received (referred_user=request.user).
GET/api/referrals/history/any authReferrals 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 authSubmit affiliate application.
GET/api/affiliates/dashboard/affiliate onlyReturns stats, commissions, payouts.
POST/api/affiliates/request-payout/affiliate onlyRequest 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.
MethodPathRoleDescription
GET/api/hq/affiliates/applications/?status=<pending|approved|rejected>staffList affiliate applications (newest first, optional status filter, paginated). Exposes reviewed_by/reviewed_at.
POST/api/hq/affiliates/applications/<uuid:pk>/approve/staffApprove 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/staffReject application; set reviewed_by/reviewed_at.
GET/api/hq/affiliates/staffList affiliates with user email/name + totals (paginated, newest first).
POST/api/hq/affiliates/<uuid:pk>/suspend/staffSet affiliate status=suspended.
POST/api/hq/affiliates/<uuid:pk>/activate/staffSet affiliate status=active.
GET/api/hq/affiliates/payouts/?status=<...>staffList payout requests with affiliate email (paginated, newest first, optional status filter).
POST/api/hq/affiliates/payouts/<uuid:pk>/approve/staffSet payout status=approved.
POST/api/hq/affiliates/payouts/<uuid:pk>/reject/staffSet payout status=rejected.
POST/api/hq/affiliates/payouts/<uuid:pk>/mark-paid/staffSet payout status=paid, processed_at=now.

URL names are prefixed hq-affiliate* and paths namespaced under affiliates/ so they don’t collide with apps.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= via useSearchParams (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.register and login resolve successfully, the page calls referralsApi.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”), icon BadgePercent, path /affiliates/dashboard.
  • /affiliates/dashboard is 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/rejected show the status notice; approved shows 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 /hq per §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 a pending application provisions an Affiliate for 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.subtitle
  • referrals.link_label, referrals.copied, referrals.usage_count
  • referrals.months_earned, referrals.referred_users, referrals.max_reached
  • referrals.apply_title, referrals.apply_name, referrals.apply_company, etc.

Namespace affiliates:

  • affiliates.dashboard_title, affiliates.tracking_link
  • affiliates.earnings, affiliates.pending_balance, affiliates.paid_out
  • affiliates.request_payout, affiliates.min_payout
  • affiliates.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 by frontend/src/test/auth/RegisterPage.test.tsx. Cases without a dedicated test are coverage gaps tracked in §11.

#CaseTypeWhere
1Generate referral code returns unique codebackendtests/test_referrals.py::TestReferralCode
2Referral signup grants 1 month to both usersbackendtests/test_referrals.py::TestReferralCredit
34th referral does not grant creditbackendtests/test_referrals.py::TestReferralCap
4Self-referral does not grant creditbackendtests/test_referrals.py
5Referral cookie persists for 30 daysfrontend unitfrontend/src/test/auth/RegisterPage.test.tsx
6Referral cookie collision — user clicks two different referral links; last-click winsfrontend unitfrontend/src/test/auth/RegisterPage.test.tsx
7Referral link used from different device (no cookie) → manual code entry worksfrontend unitfrontend/src/test/auth/RegisterPage.test.tsx
8Referral credit stacking — user receives 3 referral credits, all applied sequentiallybackendtests/test_referrals.py
9Affiliate application creates pending affiliatebackendtests/test_referrals.py
10Staff approves affiliate → code is activebackendtests/test_referrals.py
11Staff rejects affiliate → status rejected, no code createdbackendtests/test_referrals.py
12Affiliate commission calculated correctly on first paymentbackendtests/test_referrals.py
13Affiliate commission — Starter 20%, Team 20%, Business 15%backendtests/test_referrals.py
14Commission voided on refund within 30 daysbackendtests/test_referrals.py
15Commission auto-approved after 30 days (Celery task)backendtests/test_referrals.py
16Payout requested when balance > minimum thresholdbackendtests/test_referrals.py
17Payout requested when balance below minimum → 400backendtests/test_referrals.py
18User sees referral page in settingse2edescribe("Referral Program")
19Affiliate dashboard renders with statse2edescribe("Affiliate Program")
20Affiliate + user referral overlap — affiliate attribution takes precedencebackendtests/test_referrals.py
21Cross-tenant: referral credit applies only to the referrer’s org subscriptionbackendtests/test_multitenancy.py
22Referral credit stacking: 3 distinct referrals → 90 days creditbackendtests/test_referrals.py
23Refund on day 29 voids commission; refund on day 31 leaves approvedbackendtests/test_referrals.py
24Payout at minimum threshold (500,000 VND) succeeds; 499,999 VND returns 400backendtests/test_referrals.py
25Affiliate re-applies after rejection — allowed after 30-day cooldownbackendtests/test_referrals.py
26HQ affiliate routes mounted + staff-gated (/api/hq/affiliates/)backendtests/test_referrals.py::TestAffiliateHqEndpoints
27HQ: non-staff user → 403, anonymous → 401 on all /api/hq/affiliates/*backendtests/test_hq_affiliates.py::test_non_staff_forbidden / test_anonymous_unauthorized
28HQ: list applications + status filter (invalid filter ignored)backendtests/test_hq_affiliates.py::test_application_list / test_application_status_filter / test_application_invalid_filter_ignored
29HQ: approve application sets review fields + creates Affiliate + audit logbackendtests/test_hq_affiliates.py::test_approve_creates_affiliate
30HQ: re-approving is idempotent (no duplicate Affiliate)backendtests/test_hq_affiliates.py::test_approve_idempotent
31HQ: approve with no matching user → approved, Affiliate skipped + notebackendtests/test_hq_affiliates.py::test_approve_no_matching_user
32HQ: reject application sets review fields + audit logbackendtests/test_hq_affiliates.py::test_reject
33HQ: list affiliates with email/name/totalsbackendtests/test_hq_affiliates.py::test_affiliate_list
34HQ: suspend / activate affiliate + audit logsbackendtests/test_hq_affiliates.py::test_affiliate_suspend / test_affiliate_activate
35HQ: list payouts + status filterbackendtests/test_hq_affiliates.py::test_payout_list / test_payout_status_filter
36HQ: payout approve / reject / mark-paid (stamps processed_at) + audit logsbackendtests/test_hq_affiliates.py::test_payout_approve / test_payout_reject / test_payout_mark_paid
37HQ: 404 on unknown application/affiliate/payout pkbackendtests/test_hq_affiliates.py::test_approve_not_found / test_affiliate_suspend_not_found / test_payout_not_found
38Invited register: ?ref= banner shown when param present, hidden when absentfrontendfrontend/src/test/RegisterPage.test.tsx::shows invited banner when ref param is present / does not show invited banner when ref absent
39Invited register: referralsApi.claim called once after register+login when ?ref= presentfrontendfrontend/src/test/RegisterPage.test.tsx::calls referralsApi.claim after successful register when ref present
40Invited register: claim not called when ?ref= absentfrontendfrontend/src/test/RegisterPage.test.tsx::does not call referralsApi.claim when ref absent
41Invited register: a claim failure does not block navigation to the welcome flowfrontendfrontend/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

#ScenarioUser-visibleInternalLogRetry
1Invalid referral code400: “This referral code is not valid.”Code lookup failsINFO “referral_invalid_code”User corrects entry
2Referral code expired (max uses)400: “This referral code has reached its maximum uses.”Usage cap checkINFO “referral_code_expired”n/a
3Referral code is actually an affiliate code400: “This is an affiliate code, not a referral code.”Prefix checkINFO “referral_affiliate_code_mismatch”Use correct code type
4Affiliate apply — missing required fields400 with field-level validationValidation errorWARNING “affiliate_apply_incomplete”User fills required fields
5Affiliate apply — already has pending/active application400: “You already have a pending or active application.”Duplicate checkINFO “affiliate_already_applied”n/a
6Payout request below minimum threshold400: “Minimum payout is 500,000 VND.”Balance checkINFO “affiliate_payout_below_minimum”Accumulate more commissions
7Payout request with no approved commissions400: “No approved commissions available for payout.”Commission checkINFO “affiliate_no_approved_commissions”Wait for approval
8Non-affiliate accesses affiliate dashboard403Permission deniedINFO “affiliate_dashboard_access_denied”n/a
9Commission auto-approval Celery task failsCommissions remain pendingTask error loggedERROR “commission_auto_approve_failed”Retry next Celery beat cycle
10Referral cookie expires before registrationReferral not attributed — user can enter manual codeCookie expired, no attributionDEBUG “referral_cookie_expired”Manual code entry
11Self-referral attemptNo error shown — cookie silently ignoredEmail/user checkDEBUG “referral_self_referral_ignored”n/a
12Billing integration: subscription creates but affiliate commission creation failsUser subscribed, affiliate not creditedCommission creation errorCRITICAL “affiliate_commission_orphaned”Manual admin fix required
13Rejected affiliate reapplies before 30-day cooldown400: “You can reapply 30 days after rejection.”Cooldown checkINFO “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.

#ClaimStatusEvidence
1Frontend ReferralSettingsPage.tsx with useReferralCode + useReferralHistory hooks✅ matchfrontend/src/pages/affiliates/ReferralSettingsPage.tsx; frontend/src/hooks/queries/useReferrals.ts
2Frontend AffiliateApplyPage.tsx✅ matchfrontend/src/pages/affiliates/AffiliateApplyPage.tsx
3Frontend AffiliateDashboardPage.tsx✅ matchfrontend/src/pages/affiliates/AffiliateDashboardPage.tsx
4referralsApi 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).
5Routes /settings/referrals, /affiliates/apply, /affiliates/dashboard✅ matchApp.tsx
6Backend referrals/ app (ReferralCode, ReferralCredit, AffiliateApplication, Affiliate models + views + serializers)✅ matchbackend/apps/referrals/
7i18n namespaces referrals + affiliates in en/ + vi/✅ matchConfirmed by registry locale files
8E2E tests✅ matchRegistry E2E column ✅
9HQ staff-only affiliate management endpoints (/api/hq/affiliates/*)✅ shipped 2026-06-16backend/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
10Register 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-19frontend/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:

  1. Progress bar always “0 of 3”. ReferralSettingsPage.tsx read code.usage_count, but the serializer returns times_used (there is no usage_count field). Fixed the page to read times_used.
  2. Referred-users table always empty. The page calls GET /api/referrals/history/, which was never implemented (urls.py only had /credits/, which returns the received direction). Every request 404’d → table fell back to the empty state forever. Added referral_history_view + ReferralHistorySerializer returning the referrals the user gave (referrer=request.user), with display fields {referred_user_name, joined_date, status} and status mapped active → 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

  1. 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.

  2. 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).

  3. Referral cookie duration. Decision: 30 days. Standard industry attribution window.

  4. 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.

  5. 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.

  6. 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/dashboard now redirects users with no affiliate record to /affiliates/apply instead of rendering a blank page.
  • Added sidebar.affiliate key 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.