Affiliation Program
Account-centric partner program for marketers and content creators. Distinct from referrals: cash commissions settled manually in each original billing currency (not in-app credits), application + admin approval required, and tier-based commission models (recurring monthly or one-time upfront). The system is feature-gated via the AFFILIATES_ENABLED environment variable (server-only) and surfaces as 404 when disabled. Stripe Connect is intentionally not part of this workflow.
Feature Gate
Set AFFILIATES_ENABLED=true to activate the public landing page, application form, user dashboard, tracking redirect, and admin console. The flag is read once inside config/affiliates.ts and exposed as affiliatesConfig.enabled / isAffiliatesEnabled(). A second env var AFFILIATES_SALT (server-only, exactly 64 hexadecimal characters, distinct from REFERRAL_SALT and LOGS_SALT) authenticates click timestamps and hashes visitor IPs before storage.
Upgrade note: the signed-cookie release intentionally rejects legacy plain-code bsk_aff cookies. Existing visitors must revisit an affiliate URL to receive a signed cookie; already-persisted affiliate_attributions and their qualified recurring subscriptions are unaffected. Announce or stage this one-time reset before deployment if the program is already active.
Configuration (config/affiliates.ts)
| Field | Purpose |
|---|---|
enabled | Env-driven (AFFILIATES_ENABLED=true) |
tiers[] | Commission tiers: starter (20%, 30-day cookie, 12-month recurring), partner (30%, 60-day, 24-month recurring), influencer (30%, 60-day cookie, one-time × 6 multiplier, 90-day hold-period override via holdPeriodDaysOverride in config/affiliates.ts — absorbs early-churn risk on big upfront payouts) |
tier.subscriptionModel | 'recurring' (pay each invoice up to recurringMonthsCap) or 'one_time' (pay once at first invoice × upfrontMultiplier) |
defaultTierSlug | Tier assigned on approval when admin doesn't explicitly pick one ('starter' default) |
code.length / code.alphabet / code.pattern | 10-char unambiguous-character codes (no 0/O, 1/I), regex-validated before any DB call |
cookie.name / cookie.maxAgeDays | bsk_aff, HMAC-authenticated code + click timestamp, with a 60-day browser upper bound. The effective 30/60-day tier window is measured from the signed click — independent from bsk_ref (a visitor may carry both) |
limits.maxApplicationsPerIp | Rolling 24h application + attribution rate-limit per hashed IP |
limits.maxClicksPerIpPerDay | Click-fraud ceiling — silent drop on excess (don't tip off scrapers) |
limits.holdPeriodDays | Default days a 'pending' conversion waits before auto-approving for payout (per-affiliate override available) |
payouts.cadence / payDay / method | Monthly manual settlement; amounts remain separated by original billing currency |
terms.currentVersion / currentDocumentId | Both must match. Released localized documents are preserved append-only in config/affiliate-terms.ts |
application.pitchMin/MaxChars | Application form caps — referenced from Zod schemas (no inline literals) |
Per-Tier Commission Model
The subscriptionModel field decides what happens on subscription invoices. Per-affiliate overrides on the affiliates row (subscription_model_override, upfront_multiplier_override, hold_period_days_override, default_commission_pct, cookie_window_days) win over the tier defaults.
| Tier | Model | Pays On | Best For |
|---|---|---|---|
starter | recurring | Every invoice for 12 months | Default for new affiliates |
partner | recurring | Every invoice for 24 months | High-performing affiliates with proven retention |
influencer | one_time | First invoice only, × 6 multiplier | YouTubers / creators wanting upfront cash; longer 90-day hold absorbs early churn |
Attribution Flow
1. Visitor hits /?aff=ABCD123XYZ or /[locale]/affiliate/ABCD123XYZ
│
▼
2. Proxy / route — Zod-validates the code, signs the click timestamp and sets
the httpOnly bsk_aff cookie (Secure, SameSite=Lax). Tracking redirect fires
record_affiliate_click RPC (fire-and-forget).
│
▼
3. Authenticated user starts checkout for a server-authorized billing Account
│
▼
4. Checkout authenticates and consumes bsk_aff before creating the Stripe
session. Onboarding also calls POST /api/affiliates/attribute as a legacy
reconciliation fallback. Self-referral, same-owner, expired-click,
one-per-lifetime and IP rate-limit guards.
│
▼
5. affiliate_attributions row inserted (status='active', expires_at
= signed click time + per-tier cookieWindowDays). Persistent record —
survives browser-cookie deletion.
│
▼
6. When billing event fires (Stripe webhook), maybeRecordAffiliateConversion
resolves attribution + tier policy + commission math, calls
record_affiliate_conversion RPC. Idempotent on stripe_event_id. A
subscription first qualified inside the window remains eligible for its
configured 12/24 invoice cap; expiry still blocks a new subscription or
new one-shot purchase.
│
▼
7. Daily cron reconciles canonical payment proofs and missed reversals, then
flips eligible pending conversions to approved after the hold period.
Refunds / lost disputes call reverse_affiliate_conversions_by_source.Database Schema
Eight tables, all RLS-enabled with SELECT policies scoped by membership. Writes go exclusively through SECURITY DEFINER RPCs (no INSERT/UPDATE/DELETE policies). affiliate_clicks has zero policies — service-role only (high-volume internal table).
| Object | Purpose |
|---|---|
affiliate_tiers | FK target for affiliates.tier_slug; runtime always reads from config/affiliates.ts (table seeded for referential integrity only) |
affiliate_applications | Approval workflow: partial UNIQUE on account_id WHERE status='pending' (one pending per account) |
affiliates | Approved partners: UNIQUE on account_id (one affiliate per account); per-affiliate overrides for tier defaults |
affiliate_links | One affiliate → N campaign links; codes are unique for life and the first link is created on approval |
affiliate_clicks | bigint identity PK; service-role only; per-IP daily ceiling enforced inside the RPC |
affiliate_attributions | Cookie → DB persistence: UNIQUE(referred_account_id) — one attribution per account, lifetime; expires_at snapshots the authenticated click timestamp plus the tier window |
affiliate_conversions | Per-currency commission ledger: provider key, commission, hold period, and approval eligibility are snapshotted; conflicting replays are rejected |
affiliate_terms_acceptances | Append-only proof of the version, immutable document id, actor, date, and request hashes accepted on approval or re-acceptance |
affiliate_conversion_status enum | pending, approved, reversed, paid; manual settlement may transition approved rows to paid |
affiliate_payouts | Manual per-currency settlement records; legacy Connect fields remain compatibility-only |
SECURITY DEFINER RPCs
Every RPC runs REVOKE EXECUTE FROM authenticated, anon; GRANT TO service_role; with an auth.role() defense-in-depth check. Stable error code strings (AFFILIATE_*) are mapped back to typed AffiliateError in core/affiliates/error-codes.ts and translated via errors.affiliate_* i18n keys.
| Function | Purpose |
|---|---|
submit_affiliate_application | Idempotent: rejects re-application while one is pending (partial UNIQUE) |
approve_affiliate_application | Creates paired affiliates row at the supplied tier; idempotent on already-approved |
reject_affiliate_application | Records sanitized reason; idempotent on already-rejected |
create_affiliate_link | Generates unique code with collision-retry × 10 |
record_affiliate_click | Fire-and-forget tracking; silent drop on inactive link or per-IP daily ceiling exceeded (no 429 — don't tip off scrapers) |
attribute_affiliate | Persistent attribution: signed-click expiry, self-referral, same-owner, one-per-lifetime and IP rate-limit guards inside the RPC |
record_affiliate_conversion | Idempotent on stripe_event_id UNIQUE — retried webhooks return existing id without double-incrementing aggregates |
reverse_affiliate_conversion | Single-row reversal: greatest(0, total - X) guards against drift; idempotent |
reverse_affiliate_conversions_by_source | Bulk reverse for refund / dispute-lost paths (one subscription with N recurring conversions all clawed back) |
approve_mature_affiliate_conversions | Daily financial gate: reverses conversions backed by terminal payments before promoting eligible pending rows |
affiliate_conversion_stats_by_status | Single-query JSONB aggregate (count + cents per status) for the dashboard — replaces JS-side aggregation |
Stripe Webhook Integration
Billing hooks call maybeRecordAffiliateConversion from core/billing/mutations.ts after every credit-granting hook. Refund reversals run from the refund path, and lost-dispute reversals run from core/billing/risk-events.ts through reverseAffiliateConversionsBySource. All affiliate hooks are wrapped in try/catch + logError — affiliate failures NEVER break billing. Cache invalidation (revalidateAffiliateStats) fires on success.
| Webhook Event | Affiliate Hook |
|---|---|
checkout.session.completed (credit pack) | maybeRecordAffiliateConversion(..., 'credit_pack') |
checkout.session.completed (license) | maybeRecordAffiliateConversion(..., 'license') |
invoice.paid | record_subscription_affiliate_invoice derives first/recurring order from canonical positive payment rows. Once the subscription qualified inside the click window, later eligible invoices continue to the configured cap; zero-value, proration, refunded, failed, and disputed invoices are skipped |
charge.refunded | Reverses the exact credit-pack, license, or subscription-invoice conversion |
charge.dispute.closed (lost) | Reverses the exact conversion; disputes without a PaymentIntent resolve it through the Charge before journal ordering |
User Dashboard
The user-facing dashboard at /private-dashboard/affiliates branches on application state and provides link creation/copying, terms re-acceptance, recent conversions, and commission totals separated by currency. Per-status conversion KPIs come from the affiliate_conversion_stats_by_status RPC in one round-trip.
Admin Console
The admin panel at /admin-dashboard/affiliates renders three sections in parallel via Promise.all: pending-applications queue with approve/reject dialogs (tier dropdown + reason textarea), active-affiliates list with tier + status + lifetime totals, and a recent-conversions table with manual reverse action. Every admin mutation writes an admin_logs row with { actor_user_id, action, target_type, target_id, details: { reason, before } }.
Background Jobs
| Job | Cadence | Purpose |
|---|---|---|
approve-mature-affiliate-conversions | Daily (0 4 * * *) | Repairs missed writes/reversals from canonical payments, then approves rows whose snapshotted approval_eligible_at elapsed |
purge-affiliate-clicks | Daily (suggested 30 4 * * *) | Deletes raw click rows, including IP and user-agent hashes, after clickRetentionDays |
API Endpoints
| Endpoint | Security | Purpose |
|---|---|---|
POST /api/affiliates/apply | authenticated + CSRF + strict rate limit | Submit application (Zod-validated, sanitized pitch + URL) |
POST /api/affiliates/attribute | authenticated + CSRF + strict rate limit | Cookie attribution plus idempotent reconciliation of earlier positive payments; onboarding reports typed non-blocking errors while checkout stops on transient persistence failures |
GET /[locale]/affiliate-terms/[version] | public + feature gate | Current or historical append-only localized document whose immutable id is stored in every acceptance proof |
GET /[locale]/affiliate/[code] | public + relaxed rate limit | Tracking redirect — sets cookie, fires click record, 302 to landing path |
POST /api/admin/affiliates/applications/[id]/approve | admin + CSRF | Approve application at supplied tier (defaults to defaultTierSlug) |
POST /api/admin/affiliates/applications/[id]/reject | admin + CSRF | Reject application with sanitized reason |
POST /api/admin/affiliates/conversions/[id]/reverse | admin + CSRF | Manual conversion clawback for fraud / out-of-band disputes |
Environment Variables
| Variable | Purpose |
|---|---|
AFFILIATES_ENABLED | Server-only feature gate. 'true' to enable. Default false. |
AFFILIATES_SALT | Server-only 64-hex random salt for click-timestamp HMAC signatures plus IP/User-Agent hashing. Distinct from REFERRAL_SALT and LOGS_SALT. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Required in production; rotating it invalidates outstanding affiliate cookies. Dev gets a stable fallback. |
Security Invariants
- Self-attribution blocked at the RPC level (account-id check + same-owner check on
accounts.owner_user_id) - One attribution per account for life (
UNIQUE(referred_account_id)) — prevents farming via re-attribution - One affiliate per account (
UNIQUE(affiliates.account_id)); one pending application at a time (partial UNIQUE) stripe_event_id UNIQUEon conversions — replayed Stripe webhooks no-op without double-incrementing affiliate aggregates- IP + User-Agent stored as SHA-256 hashes salted with
AFFILIATES_SALT— never raw PII; hash helper degrades tonullwhen salt unset (graceful) commission_pctsnapshotted on every conversion row — config edits never retroactively change historical commissions- The cookie click timestamp is HMAC-authenticated; unsigned cookies are rejected and cannot extend a tier window
- Attribution expiry blocks a new subscription or one-shot purchase, but does not truncate an already-qualified recurring subscription
- Refunds and lost disputes automatically reverse all matching conversions for credit_pack + license sources via
reverse_affiliate_conversions_by_source - Every admin mutation (approve / reject / reverse) writes an
admin_logsentry with a before-snapshot - Feature gate returns 404 (not 403) so the surface is invisible when disabled
- CSRF + rate limit enforced on every state-changing endpoint via
apiSecurity.* - Affiliate webhook hooks wrapped in try/catch +
logError— affiliate failures NEVER break billing