Referral System

Account-centric referral program: each account owns one active short code, and both sides earn credits when the configured trigger fires. Refunds and lost chargebacks automatically claw the credits back. The system is feature-gated via the REFERRAL_ENABLED environment variable (server-only, no NEXT_PUBLIC_ prefix) and surfaces as 404 when disabled — invisible until you turn it on.

Feature Gate

Set REFERRAL_ENABLED=true in your environment to enable the full user dashboard, signup banner, tracking redirect, and admin console. The flag is read once inside config/referral.ts and exposed as referralConfig.enabled / isReferralEnabled(). Components and routes must import the helper — never read process.env directly.

Configuration (config/referral.ts)

Field Purpose
enabledEnv-driven (REFERRAL_ENABLED=true)
triggeron_signup | on_first_purchase | on_first_subscription
rewards.referrer.creditsCredits granted to the inviter when the trigger fires
rewards.referred.creditsCredits granted to the invitee when the trigger fires
code.length / code.alphabetCode format (unambiguous-character alphabet, no 0/O, 1/I)
code.patternRegex used to validate cookies and query params
limits.maxApplicationsPerIpRolling 24-hour rate limit per hashed IP
cookie.name / cookie.maxAgeDayshttpOnly attribution cookie (30 days default)

Trigger Semantics

Trigger Fires On Best For
on_signupImmediately after attributionB2C growth, freemium
on_first_purchaseAny paid event — credit pack, license, or first paid invoiceHybrid billing
on_first_subscriptionFirst paid subscription invoice onlySubscription-only SaaS

Attribution Flow

1. Visitor hits /?ref=ABCD1234  or  /[locale]/refer/ABCD1234
            │
            ▼
2. Middleware / route — validates regex, sets httpOnly bsk_ref cookie
   (Secure, SameSite=Lax, 30-day window) — no DB call in hot path
            │
            ▼
3. User signs up via magic link → onboarding completes
            │
            ▼
4. Onboarding form fires POST /api/referral/attribute (fire-and-forget).
   Server reads bsk_ref cookie, resolves user's personal account,
   calls apply_referral_code RPC, clears cookie.
            │
            ▼
5. referrals row inserted (status='pending' by default, or 'qualified'
   immediately if trigger = on_signup).
            │
            ▼
6. When configured trigger fires (Stripe webhook), qualify_referral RPC
   calls add_credits for both accounts. Refunds / lost disputes call
   reverse_referral, which uses decrement_credits per side.

Database Schema

Two tables, both row-level-security protected with SELECT scoped by membership on the referrer account. Writes go exclusively through SECURITY DEFINER RPCs (no INSERT/UPDATE/DELETE policies exposed to clients).

Object Purpose
referral_codesOne active code per account (partial unique indexes on code WHERE active and account_id WHERE active)
referralsThe attribution graph: CHECK (referrer_account_id ≠ referred_account_id) + UNIQUE(referred_account_id) — one referral per account, lifetime
referral_status enumpending, qualified, rewarded, reversed, rejected
ip_hash / user_agent_hashSalted SHA-256 (REFERRAL_SALT), never raw PII — used for fraud heuristics
credit_source enumExtended with 'referral' value

SECURITY DEFINER RPCs

Every RPC runs REVOKE EXECUTE FROM authenticated, anon; GRANT TO service_role; with an auth.role() defense-in-depth check inside the body. Credit movements go through add_credits / decrement_credits only — the referral RPCs never touch credits_balance directly.

Function Purpose
generate_referral_codeIdempotent — returns active code or generates a new one with collision-retry
apply_referral_codeAttribution + all fraud guards (self-referral, same-owner, one-per-lifetime, IP rate-limit)
qualify_referralCalls add_credits both sides with source='referral' + metadata { referral_id, role, trigger }
reverse_referralPer-side guarded decrement_credits — an insufficient balance on one side doesn't abort the other (details written to metadata)
referral_admin_statsSingle-query aggregate for the admin overview (no N+1)

Stripe Webhook Integration

Billing hooks call maybeQualifyOnPurchase(accountId, eventType) from core/billing/mutations.ts after every credit-granting hook. Refunds reverse referrals from the refund path, and lost-dispute handling in core/billing/risk-events.ts calls reverseReferralForAccount(accountId, reason). All referral hooks are try/catch-wrapped so transient referral failures never break billing.

Webhook Event Referral Hook
checkout.session.completed (credit pack)maybeQualifyOnPurchase(..., 'credit_pack')
checkout.session.completed (license)maybeQualifyOnPurchase(..., 'license')
invoice.paid (first paid invoice)maybeQualifyOnPurchase(..., 'invoice')
charge.refundedreverseReferralForAccount(...)
charge.dispute.closed (status = lost)reverseReferralForAccount(...)

User Dashboard

The user dashboard at /private-dashboard/referrals is a Server Component that fetches the code, stats, and referrals list in parallel via Promise.all. Only the share card is 'use client' (clipboard + Web Share API). A signup banner reads the bsk_ref cookie server-side and tells the invitee how many credits they'll receive.

Admin Console

The admin panel lives under /admin-dashboard/referrals with four pages: overview (KPI grid + top-10 referrers from the aggregate RPC, cached 60s), filterable list with cursor pagination, detail page with the full timeline plus linked credit_transactions and admin_logs audit trail, and a codes management table with a deactivate action. Every admin mutation (reverse / reject / deactivate) writes an admin_logs row with { actor_user_id, action, target_id, details: { reason, before } }.

API Endpoints

Endpoint Security Purpose
GET /api/referral/codeauthenticatedLazy-create active code
GET /api/referral/statsauthenticatedDashboard counters (cached 5 min)
GET /api/referral/listauthenticatedCursor-paginated referrals
POST /api/referral/applyauthenticated + CSRF + strict rate limitManual "I have a code" flow
POST /api/referral/attributeauthenticated + CSRF + strict rate limitCookie-based, called from onboarding
GET /[locale]/refer/[code]public + relaxed rate limitTracking redirect — sets cookie, 302 to home
GET /api/admin/referrals/statsadminAggregate KPIs + top referrers
GET /api/admin/referralsadminFilterable, cursor-paginated list
GET /api/admin/referrals/[id]adminDetail + audit trail
POST /api/admin/referrals/[id]/reverseadmin + CSRFClawback with reason
POST /api/admin/referrals/[id]/rejectadmin + CSRFPending-only transition
GET /api/admin/referral-codesadminCodes with usage counts
POST /api/admin/referral-codes/[id]/deactivateadmin + CSRFDeactivate code

Environment Variables

Variable Purpose
REFERRAL_ENABLEDServer-only feature gate (no NEXT_PUBLIC_ prefix). Set to 'true' to enable. Default false. Read only via isReferralEnabled() / referralConfig.enabled in config/referral.ts — never process.env.REFERRAL_ENABLED elsewhere.
REFERRAL_SALTServer-only random salt used to hash visitor IP + User-Agent before storage. Minimum 16 characters; 64-hex is recommended. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Read only via getReferralSalt() exported from config/referral.ts — never raw process.env.REFERRAL_SALT outside that file. See .env.example for the canonical declaration.

Security Invariants

  • Self-referral blocked at the DB level (CHECK constraint) and at the RPC level (same-owner check on accounts.owner_user_id)
  • One referral per account for life (UNIQUE(referred_account_id))
  • One active code per account (partial unique index on account_id WHERE active)
  • IP + User-Agent stored as SHA-256 hashes salted with REFERRAL_SALT — never raw PII
  • Credits move only through add_credits / decrement_credits RPCs; every mutation lands a credit_transactions row with metadata { referral_id, role } for audit
  • Refunds and lost disputes automatically claw back granted credits
  • Every admin mutation writes an admin_logs entry
  • Feature gate returns 404 (not 403) so the surface is invisible when disabled
  • CSRF enforced on every state-changing endpoint via apiSecurity.* (Double Submit Cookie)