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
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, 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.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 / methodMonthly manual settlement; amounts remain separated by original billing currency
terms.currentVersion / currentDocumentIdBoth must match. Released localized documents are preserved append-only in config/affiliate-terms.ts
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. 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_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; codes are unique for life and the first link is created on approval
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 snapshots the authenticated click timestamp plus the tier window
affiliate_conversionsPer-currency commission ledger: provider key, commission, hold period, and approval eligibility are snapshotted; conflicting replays are rejected
affiliate_terms_acceptancesAppend-only proof of the version, immutable document id, actor, date, and request hashes accepted on approval or re-acceptance
affiliate_conversion_status enumpending, approved, reversed, paid; manual settlement may transition approved rows to paid
affiliate_payoutsManual 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_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: signed-click expiry, self-referral, same-owner, one-per-lifetime and 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 financial gate: reverses conversions backed by terminal payments before promoting eligible pending rows
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.paidrecord_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.refundedReverses 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-conversionsDaily (0 4 * * *)Repairs missed writes/reversals from canonical payments, then approves rows whose snapshotted approval_eligible_at elapsed
purge-affiliate-clicksDaily (suggested 30 4 * * *)Deletes raw click rows, including IP and user-agent hashes, after clickRetentionDays

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 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 gateCurrent or historical append-only localized document whose immutable id is stored in every acceptance proof
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 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 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
  • 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_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