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 |
|---|---|
enabled | Env-driven (REFERRAL_ENABLED=true) |
trigger | on_signup | on_first_purchase | on_first_subscription |
rewards.referrer.credits | Credits granted to the inviter when the trigger fires |
rewards.referred.credits | Credits granted to the invitee when the trigger fires |
code.length / code.alphabet | Code format (unambiguous-character alphabet, no 0/O, 1/I) |
code.pattern | Regex used to validate cookies and query params |
limits.maxApplicationsPerIp | Rolling 24-hour rate limit per hashed IP |
cookie.name / cookie.maxAgeDays | httpOnly attribution cookie (30 days default) |
Trigger Semantics
| Trigger | Fires On | Best For |
|---|---|---|
on_signup | Immediately after attribution | B2C growth, freemium |
on_first_purchase | Any positive finalized payment — credit pack, license, or paid invoice | Hybrid billing |
on_first_subscription | First positive subscription invoice with a persisted Stripe subscription id | Subscription-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_codes | One active code per account (partial unique indexes on code WHERE active and account_id WHERE active) |
referrals | The 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 enum | pending, qualified, rewarded, reversed, rejected |
ip_hash / user_agent_hash | Salted SHA-256 (REFERRAL_SALT), never raw PII — used for fraud heuristics |
credit_source enum | Extended 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_code | Idempotent — returns active code or generates a new one with collision-retry |
apply_referral_code | Attribution + 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_referral | Validates 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_account | Race-safe paid-webhook entry point. Serializes on the referred Account and returns the referrer Account id for targeted cache invalidation. |
reverse_referral_for_payment | Finds and reverses only the referral linked to the supplied local payment id. |
reverse_referral | Per-side guarded decrement_credits — an insufficient balance on one side doesn't abort the other (details written to metadata) |
referral_admin_stats | Single-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/code | authenticated | Lazy-create active code |
GET /api/referral/stats | authenticated | Dashboard counters (cached 5 min) |
GET /api/referral/list | authenticated | Cursor-paginated referrals |
POST /api/referral/apply | authenticated + CSRF + strict rate limit | Manual "I have a code" flow; never reconciles an earlier payment |
POST /api/referral/attribute | authenticated + CSRF + strict rate limit | Awaited cookie fallback from onboarding; B2B workspace / B2C personal Account resolution |
GET /[locale]/refer/[code] | public + relaxed rate limit | Tracking redirect — sets cookie, 302 to home |
GET /api/admin/referrals/stats | admin | Aggregate KPIs + top referrers |
GET /api/admin/referrals | admin | Filterable, cursor-paginated list |
GET /api/admin/referrals/[id] | admin | Detail + audit trail |
POST /api/admin/referrals/[id]/reverse | admin + CSRF | Clawback with reason |
POST /api/admin/referrals/[id]/reject | admin + CSRF | Pending-only transition |
GET /api/admin/referral-codes | admin | Codes with usage counts |
POST /api/admin/referral-codes/[id]/deactivate | admin + CSRF | Deactivate code |
Environment Variables
| Variable | Purpose |
|---|---|
REFERRAL_ENABLED | Server-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_SALT | Server-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 (
CHECKconstraint) and at the RPC level (same-owner check onaccounts.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_creditsRPCs; every mutation lands acredit_transactionsrow 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_logsentry - 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)