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 normalized payment whose purchase_kind is subscription | 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 configured-provider 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.The onboarding fallback uses the authenticated canonical application actor for both Account selection and attribution, never an identity or Account supplied in the request. It selects the oldest owned Account with a matching membership and business-model type. Missing identity returns 503 APPLICATION_IDENTITY_UNAVAILABLE before cookie or database access. Responses are private and non-cacheable. Invalid or absent cookies are cleared without a database lookup; an absent Account or unexpected persistence failure keeps the cookie for retry, with the existing non-blocking 200 response. The shared Prisma repository and canonical application-identity constraints are part of every Database/Auth composition.
Database Schema
Two tables, both row-level-security protected and Account-scoped. Routes call a shared Prisma repository; its bounded private actor, worker and admin capabilities validate app.actor_id, membership or administrator authority before reaching narrowly owned SECURITY DEFINER mutations. No table mutation is exposed to browser roles.
| 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 |
Private Prisma capabilities and financial RPCs
The application connects as app_runtime; only private referral entry points are executable through its capability membership. Those entry points pin an empty search_path, validate canonical actor context, and delegate financial mutations to narrowly owned functions. Credit movements still go through add_credits / decrement_credits only — referral code never updates 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) |
Payment-provider webhook integration
Stripe event effects in lib/payments/providers/stripe/event-effects.ts invoke maybeQualifyOnPurchase(accountId, eventType, qualifyingPaymentId), defined in core/referrals/mutations.ts, after a qualifying payment is recorded. Provider-neutral SQL payment and adjustment effects also bind qualification and reversal to the original payment ID. The Stripe notification/cache hook contains referral failures; durable payment and adjustment effects retain their own transaction and retry contracts.
| 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) | Atomic payment adjustment convergence reverses the exact payment-backed referral |
charge.dispute.closed (status = lost) | Atomic dispute convergence reverses the exact payment-backed referral |
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
The code, stats and list APIs check ai:use for the canonical application actor on the requested Account. Manual code application requires membership of the supplied Account, without adding an owner or role restriction, and never accepts a client-supplied attribution actor. After the disabled-feature check, missing identity returns 503 APPLICATION_IDENTITY_UNAVAILABLE before parsing or database access. Responses are private and non-cacheable; database failures return 500 with synthetic diagnostics, distinct from business refusals. Manual application clears its cookie only on success and never reconciles historical payments. Stats and cursor pagination run through the same provider-neutral Prisma capability on Supabase and Neon.
| 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)