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
enabledEnv-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)
defaultTierSlugTier assigned on approval when admin doesn't explicitly pick one ('starter' default)
code.length / code.alphabet / code.pattern10-char unambiguous-character codes (no 0/O, 1/I), regex-validated before any DB call
cookie.name / cookie.maxAgeDaysbsk_aff, 60-day default — independent from bsk_ref (a visitor may carry both)
limits.maxApplicationsPerIpRolling 24h application + attribution rate-limit per hashed IP
limits.maxClicksPerIpPerDayClick-fraud ceiling — silent drop on excess (don't tip off scrapers)
limits.holdPeriodDaysDefault days a 'pending' conversion waits before auto-approving for payout (per-affiliate override available)
payouts.cadence / payDay / currencyMonthly batches (1st of month), single-currency MVP (EUR)
terms.currentVersionToS version string. Bumping forces re-acceptance via requireReAcceptOnVersionBump
application.pitchMin/MaxCharsApplication 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
starterrecurringEvery invoice for 12 monthsDefault for new affiliates
partnerrecurringEvery invoice for 24 monthsHigh-performing affiliates with proven retention
influencerone_timeFirst invoice only, × 6 multiplierYouTubers / 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_tiersFK target for affiliates.tier_slug; runtime always reads from config/affiliates.ts (table seeded for referential integrity only)
affiliate_applicationsApproval workflow: partial UNIQUE on account_id WHERE status='pending' (one pending per account)
affiliatesApproved partners: UNIQUE on account_id (one affiliate per account); per-affiliate overrides for tier defaults
affiliate_linksOne affiliate → N campaign links; partial UNIQUE on code WHERE active for code rotation
affiliate_clicksbigint identity PK; service-role only; per-IP daily ceiling enforced inside the RPC
affiliate_attributionsCookie → DB persistence: UNIQUE(referred_account_id) — one attribution per account, lifetime; expires_at snapshot enforces cookie window at conversion time
affiliate_conversionsCommission 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 enumFour 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_payoutsMonthly 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_applicationIdempotent: rejects re-application while one is pending (partial UNIQUE)
approve_affiliate_applicationCreates paired affiliates row at the supplied tier; idempotent on already-approved
reject_affiliate_applicationRecords sanitized reason; idempotent on already-rejected
create_affiliate_linkGenerates unique code with collision-retry × 10
record_affiliate_clickFire-and-forget tracking; silent drop on inactive link or per-IP daily ceiling exceeded (no 429 — don't tip off scrapers)
attribute_affiliatePersistent attribution: self-referral, same-owner, one-per-lifetime, IP rate-limit guards inside the RPC
record_affiliate_conversionIdempotent on stripe_event_id UNIQUE — retried webhooks return existing id without double-incrementing aggregates
reverse_affiliate_conversionSingle-row reversal: greatest(0, total - X) guards against drift; idempotent
reverse_affiliate_conversions_by_sourceBulk reverse for refund / dispute-lost paths (one subscription with N recurring conversions all clawed back)
approve_mature_affiliate_conversionsDaily job: flips pendingapproved once per-affiliate hold period elapses; GET DIAGNOSTICS row_count
affiliate_conversion_stats_by_statusSingle-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.refundedreverseAffiliateConversionsBySource(...) for credit_pack + license
charge.dispute.closed (lost)reverseAffiliateConversionsBySource(...) for credit_pack + license (subscription chargeback walk deferred — see callout below)
Known gap — subscription chargebacks NOT auto-reversed

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 → subscription walk (Phase 2.x follow-up, tracked in affiliation-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-conversionsDaily (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/applyauthenticated + CSRF + strict rate limitSubmit application (Zod-validated, sanitized pitch + URL)
POST /api/affiliates/attributeauthenticated + CSRF + strict rate limitCookie-based attribution called from onboarding; non-blocking errors
GET /[locale]/affiliate/[code]public + relaxed rate limitTracking redirect — sets cookie, fires click record, 302 to landing path
POST /api/admin/affiliates/applications/[id]/approveadmin + CSRFApprove application at supplied tier (defaults to defaultTierSlug)
POST /api/admin/affiliates/applications/[id]/rejectadmin + CSRFReject application with sanitized reason
POST /api/admin/affiliates/conversions/[id]/reverseadmin + CSRFManual conversion clawback for fraud / out-of-band disputes

Environment Variables

Variable Purpose
AFFILIATES_ENABLEDServer-only feature gate. 'true' to enable. Default false.
AFFILIATES_SALTServer-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 UNIQUE on 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 to null when salt unset (graceful)
  • commission_pct snapshotted 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_logs entry 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