Referral System

Account-centric referral program: each account owns one active short code, and both sides earn credits when the configured trigger fires. Paid rewards are bound to the exact positive local payment that qualified them, so an unrelated refund or dispute cannot claw them 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 positive finalized payment — credit pack, license, or paid invoiceHybrid billing
on_first_subscriptionFirst positive subscription invoice with a persisted Stripe subscription idSubscription-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. Paid checkout authorizes the requested billing Account, then applies
   the referral cookie before returning the Stripe URL. B2B targets the
   verified workspace; B2C targets the verified personal Account.
            │
            ▼
4. Onboarding awaits POST /api/referral/attribute as a non-blocking fallback.
   It resolves an owned workspace in B2B or personal Account in B2C. The
   trusted cookie flow may reconcile a payment that committed first; manual
   code application cannot reuse a historical payment.
            │
            ▼
5. referrals row inserted (status='pending' for paid triggers or 'rewarded'
   immediately when trigger = on_signup).
            │
            ▼
6. A paid webhook passes the exact finalized payments.id to the qualification
   RPC. Zero, fully refunded, missing, or foreign-Account payments are rejected.
   Full refunds / lost disputes reverse only the referral linked to that
   payment via qualifying_payment_id.

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. qualifying_payment_id references the exact payments row used by a paid reward and has a partial unique index.
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). Historical-payment reconciliation is an explicit flag used only by trusted cookie attribution.
qualify_referralValidates the exact qualifying payment, then calls add_credits on both sides with source='referral' + metadata { referral_id, role, trigger, qualifying_payment_id }
qualify_referral_for_accountRace-safe paid-webhook entry point. Serializes on the referred Account and returns the referrer Account id for targeted cache invalidation.
reverse_referral_for_paymentFinds and reverses only the referral linked to the supplied local payment id.
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, qualifyingPaymentId) from core/billing/mutations.ts only after a positive local payment row is finalized. Refund and lost-dispute handling call reverseReferralForPayment(paymentId, reason), so the clawback is tied to the payment that originally qualified the referral. 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', paymentId)
checkout.session.completed (license)maybeQualifyOnPurchase(..., 'license', paymentId)
invoice.paid (positive paid invoice)maybeQualifyOnPurchase(..., 'invoice', paymentId)
charge.refunded (full refund)reverseReferralForPayment(paymentId, ...)
charge.dispute.closed (status = lost)reverseReferralForPayment(paymentId, ...)

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; never reconciles an earlier payment
POST /api/referral/attributeauthenticated + CSRF + strict rate limitAwaited cookie fallback from onboarding; B2B workspace / B2C personal Account resolution
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))
  • Paid rewards require a positive persisted payment belonging to the referred Account; zero/free, missing, foreign-Account, and fully refunded payments are rejected
  • Attribution and webhook qualification serialize on the referred Account, closing the payment-before-attribution race
  • Each paid reward stores qualifying_payment_id; refund and dispute clawbacks use that exact payment
  • Historical-payment reconciliation is available only to trusted cookie attribution, never manual code application
  • 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 referral, role, trigger, and qualifying-payment metadata 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)