Affiliation Program
Account-centric partner program for marketers and content creators. Distinct from referrals: cash commissions paid via Stripe Connect (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. Phases 1 and 2 ship the application workflow, tracking, conversion ledger, and admin console; Stripe Connect onboarding + monthly payouts land in Phase 3.
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, distinct from REFERRAL_SALT and LOGS_SALT) is used to hash visitor IPs before storage.
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, 60-day default — 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 / currency | Monthly batches (1st of month), single-currency MVP (EUR) |
terms.currentVersion | ToS version string. Bumping forces re-acceptance via requireReAcceptOnVersionBump |
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. Middleware / route — regex-validates code, sets httpOnly bsk_aff
cookie (Secure, SameSite=Lax). Tracking redirect fires
record_affiliate_click RPC (fire-and-forget).
│
▼
3. User signs up → onboarding completes
│
▼
4. Onboarding form fires POST /api/affiliates/attribute. Server reads
bsk_aff cookie, calls attribute_affiliate RPC, clears cookie.
Self-referral, same-owner, one-per-lifetime, IP rate-limit guards.
│
▼
5. affiliate_attributions row inserted (status='active', expires_at
= now + per-tier cookieWindowDays). Persistent record — survives
cookie expiry.
│
▼
6. When billing event fires (Stripe webhook), maybeRecordAffiliateConversion
resolves attribution + tier policy + commission math, calls
record_affiliate_conversion RPC. Idempotent on stripe_event_id.
│
▼
7. Daily cron (approve-mature-affiliate-conversions) flips pending
conversions to approved after the per-affiliate 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; partial UNIQUE on code WHERE active for code rotation |
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 snapshot enforces cookie window at conversion time |
affiliate_conversions | Commission ledger: UNIQUE(stripe_event_id) idempotency gate; commission_pct snapshotted at write so config edits never mutate history; partial composite index (source_type, source_id) WHERE status <> 'reversed' for refund hot path |
affiliate_conversion_status enum | Four values, all live in the schema today: pending, approved, reversed, paid. Phase 2 wires conversions through pending → approved (via the daily hold-period job) and pending|approved → reversed (refund / dispute). The paid state exists but is only set by Phase 3 payout batches — until Stripe Connect ships, no conversion ever transitions to paid. |
affiliate_payouts | Monthly batched payouts; UNIQUE(stripe_transfer_id) idempotency for Phase 3 Stripe Connect |
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: self-referral, same-owner, one-per-lifetime, 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 job: flips pending → approved once per-affiliate hold period elapses; GET DIAGNOSTICS row_count |
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 (1st invoice) | maybeRecordAffiliateConversion(..., 'first_invoice') with multiplier for one_time tiers |
invoice.paid (2nd+) | maybeRecordAffiliateConversion(..., 'recurring_invoice') until tier recurringMonthsCap |
charge.refunded | reverseAffiliateConversionsBySource(...) for credit_pack + license |
charge.dispute.closed (lost) | reverseAffiliateConversionsBySource(...) for credit_pack + license (subscription chargeback walk deferred — see callout below) |
applyDisputeClosed currently auto-reverses only credit_pack and license conversion sources. Subscription chargebacks are not walked back automatically — recovering them requires either:
- An explicit
charge → invoice → subscriptionwalk (Phase 2.x follow-up, tracked inaffiliation-plan.md), or - An admin manually reversing the affected conversions via
POST /api/admin/affiliates/conversions/[id]/reverse.
If you run a subscription-heavy business with non-trivial chargeback exposure, monitor disputes and clawback manually until Phase 2.x ships. See .claude/rules/affiliates.md §Known Gaps for the full list (including the recompute-affiliate-totals reconciliation job and recent-auth gate on conversion reversal).
User Dashboard
The user-facing dashboard at /private-dashboard/affiliates branches on application state (no application → public landing redirect; pending → "review in progress" card; rejected → reason + re-apply CTA; active affiliate → status banner + KPI grid + recent-conversions table). Per-status conversion KPIs come from the affiliate_conversion_stats_by_status RPC (single round-trip, no JS aggregation).
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 (suggested 0 4 * * *) | Calls approve_mature_affiliate_conversions RPC; honors per-affiliate hold-period override; job-config hold_period_days override clamped to [1, 365] |
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-based attribution called from onboarding; non-blocking errors |
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 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; 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- 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