Site

The boilerplate contains provider-neutral billing contracts for Stripe, Lemon Squeezy, and Paddle. One provider is selected once by the initialization script and persisted in server-owned PAYMENTS_PROVIDER; the browser and checkout payload can never choose or override it. Existing installations must not switch providers in place because historical subscriptions, refunds, disputes, secrets, and reconciliation remain attached to their original provider. Use a new or reset disposable installation for a different provider.

Authenticated purchases use the Account selected by the deployment's business model: a workspace for B2B and a personal Account for B2C. This applies equally to subscriptions, credit packs and licenses. The server verifies membership authority and the persisted Account type before starting payment. If the B2B workspace is missing, checkout resumes workspace onboarding; a failed workspace lookup never falls back to personal billing.

Current launch boundary

Stripe, Lemon Squeezy, and Paddle implement provider-neutral member checkout and customer portal flows. The provider is selected once during initialization and never by the customer. Lemon Squeezy and Paddle are accepted through sandbox/test evidence only; Paddle uses a local Paddle.js Default Payment Link and keeps guest checkout closed. Selecting a provider is not evidence that its credentials, catalogue, webhook destination, and sandbox operator smokes are complete.

The commercial source of truth is config/pricing.ts. Each paid offer binds internal plan, pack, or license identifiers to the external catalogue ID for every provider, currency, interval, and environment. pnpm run check:payment-catalog validates the selected provider locally, including the implementation surfaces required by the active billing model. pnpm run test:staging adds bounded provider-specific catalogue, mode, webhook-configuration, portal, and signed-delivery proofs. Lemon Squeezy and Paddle probes refuse live traffic. Lemon's paid-customer portal and Paddle's paid-customer portal plus dashboard-only Default Payment Link pass only after their exact server-only sandbox operator acknowledgements.

The checkout identifies the legal seller before payment without offering a provider selector. With Stripe, the configured application company is the seller and Stripe is the payment processor. Lemon Squeezy and Paddle are presented as Merchant of Record and handle payment, taxes, official receipts or invoices, and refund requests. Transactional emails repeat the provider and seller role; application purchase confirmations are explicitly not presented as the provider-issued tax document.

For customer-support evidence and the platform-admin exception workflow, use the Payment Support & Reconciliation runbook.

A provider redirect is never payment proof. Member checkout return URLs contain only a random 256-bit local bearer. GET /api/billing/checkout/status/exchange/[locale] atomically invalidates it, mints a different short HttpOnly cookie bearer, and redirects with 303 to a clean localized URL; GET /api/billing/checkout/status then hashes the cookie bearer and exposes only the durable provider-neutral attempt status. The page polls that endpoint at a bounded cadence and shows success, confetti, workspace reads, and dashboard routing only after a signed webhook has moved the attempt to completed. It never retrieves a provider session from the browser and never grants an entitlement.

Selected-provider setup

Run pnpm run init first, then configure only the selected provider. Every paid internal offer needs the matching test/sandbox catalogue object and a binding in config/pricing.ts. Use the exact query-free webhook route and keep signing/API secrets server-only.

ProviderExternal catalogue IDWebhook routeAdditional readiness boundary
Stripeprice_... Price ID/api/billing/webhooks/stripeDedicated active Billing Portal configuration with provider-side plan updates disabled; Dahlia API version pin.
Lemon SqueezyNumeric Variant ID/api/billing/webhooks/lemon-squeezyStore/catalogue/webhook sandbox proof and an exact paid-customer portal acknowledgement. Hosted checkout needs no browser secret.
Paddlepri_... Price ID/api/billing/webhooks/paddleApproved Paddle.js Default Payment Link and paid-customer portal acknowledgements. Guest checkout remains closed.

Lemon Squeezy and Paddle integration acceptance is sandbox-only. Run check:payment-catalog, the relevant deterministic local profile, partial signed smokes when required, and the complete test:staging gate. A partial smoke, a provider redirect, or an error page cannot make readiness green.

Stripe setup (when Stripe is selected)

Follow these steps to connect your Stripe account and configure payment processing:

1. Create Stripe Account

  1. Sign up at dashboard.stripe.com
  2. Complete your business profile
  3. Get your API keys from Developers → API Keys

2. Environment Variables

Add your Stripe keys to .env.local. You need three values: STRIPE_SECRET_KEY (server-side API key from Developers → API Keys), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (client-side key for Checkout), and STRIPE_WEBHOOK_SECRET (signing secret generated when you create your webhook endpoint). Use test mode keys during development and switch to live keys for production.

3. Create Products in Stripe

Create your subscription plans, credit packs, and license products in Stripe Dashboard. Product names are for Stripe dashboard clarity; the app uses the copied Stripe Price IDs.

Product Type Stripe Settings Example
Subscription Plan Recurring price, monthly/yearly interval Pro Plan - €29/month
Credit Pack One-time price 2,000,000 credits (≈ 2M tokens) — €18 (1 credit = 1 LLM token)
Include license products when using license or hybrid billing

If billingModel is 'one_time' or 'hybrid', also create one-time Stripe prices for the entries in pricingConfig.products such as pro-lifetime, pro-yearly, business-lifetime, and business-yearly. Paste those Price IDs into the matching Stripe entries in providerCatalogIds.

4. Configure Webhook

Set up a webhook endpoint in Stripe Dashboard → Developers → Webhooks:

Endpoint URL: https://yourdomain.com/api/billing/webhooks/stripe
API version: 2026-07-29.dahlia

Events to listen for:
# Checkout Events
├── checkout.session.completed              # Process purchases (subscriptions, credit packs, licenses)
├── checkout.session.async_payment_succeeded # Async payment completed (SEPA, bank transfer)
├── checkout.session.async_payment_failed   # Async payment failed - revoke credits
├── checkout.session.expired                # Abandoned checkout tracking
# Subscription Events
├── customer.subscription.created           # New subscription created
├── customer.subscription.updated           # Plan changes, status updates
├── customer.subscription.deleted           # Subscription canceled
├── customer.subscription.paused            # Subscription paused
├── customer.subscription.resumed           # Subscription resumed after pause
├── customer.subscription.trial_will_end    # Trial ending notification (3 days before)
# Invoice Events
├── invoice.paid                            # Successful payment / monthly credit refill
├── invoice.payment_failed                  # Failed payment (triggers grace period)
├── invoice.payment_action_required         # SCA/3D Secure authentication required
# Charge & Dispute Events
├── charge.refunded                         # Refund processed (deducts credits/revokes licenses)
├── charge.dispute.created                  # Chargeback created (CRITICAL - immediate action)
├── charge.dispute.closed                   # Dispute resolved (won/lost)
# Customer Events
├── customer.created                        # Link Stripe customer to account
├── customer.updated                        # Sync customer data changes
├── customer.deleted                        # Clean up Stripe references
# Payment Method Events
├── payment_method.attached                 # New payment method added
└── payment_method.detached                 # Payment method removed (churn signal)
Safe API-version upgrade: Create the Dahlia endpoint with the same exact query-free /api/billing/webhooks/stripe URL and keep the old endpoint enabled while you validate deliveries. Put the new endpoint secret in STRIPE_WEBHOOK_SECRET and the old endpoint secret in STRIPE_WEBHOOK_SECRET_PREVIOUS. After successful Dahlia deliveries, disable the old endpoint and remove STRIPE_WEBHOOK_SECRET_PREVIOUS. The staging readiness probe rejects query strings and fragments and requires an explicit Dahlia endpoint pin.
Local Development: Use Stripe CLI to forward webhooks locally: stripe listen --forward-to localhost:3777/api/billing/webhooks/stripe. The command prints a whsec_… signing secret — copy it into STRIPE_WEBHOOK_SECRET (it differs from the Dashboard webhook secret used in production).

Testing with Stripe test cards

In test mode, use these card numbers with any future expiry, any CVC, and any postal code:

ScenarioCard number
Successful payment4242 4242 4242 4242
Requires 3D Secure / SCA4000 0027 6000 3184
Generic decline4000 0000 0000 0002
Insufficient funds4000 0000 0000 9995

Drive webhook handlers directly without going through Checkout:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger charge.refunded

5. Configure provider catalogue IDs

The shared providerCatalogIds object is keyed by offer[:interval]:currency. Each entry contains independent dev/prod bindings for Stripe, Lemon Squeezy, and Paddle. Missing values intentionally make that commercial combination unavailable.

// config/pricing.ts
const providerCatalogIds = {
  'pro:monthly:EUR': {
    stripe: { dev: 'price_test_pro_monthly_eur', prod: 'price_live_pro_monthly_eur' },
    lemonSqueezy: { dev: 'numeric_variant_id', prod: '' },
    paddle: { dev: 'pri_sandbox_price_id', prod: '' },
  },
  'pack-2000:EUR': {
    stripe: { dev: 'price_test_pack_2000_eur', prod: 'price_live_pack_2000_eur' },
    lemonSqueezy: { dev: 'numeric_variant_id', prod: '' },
    paddle: { dev: 'pri_sandbox_price_id', prod: '' },
  },
  'pro-lifetime:EUR': {
    stripe: { dev: 'price_test_license_eur', prod: 'price_live_license_eur' },
    lemonSqueezy: { dev: 'numeric_variant_id', prod: '' },
    paddle: { dev: 'pri_sandbox_price_id', prod: '' },
  },
}
  1. Open the selected provider's test or sandbox dashboard.
  2. Create every recurring plan, one-time credit pack, and license you intend to sell in that environment.
  3. Copy the provider's exact Price or Variant ID into the matching provider and dev slot. Never use a Product ID where a Price/Variant is required.
  4. Run pnpm run check:payment-catalog, complete the selected provider's test checkout, and confirm signed webhook convergence before checking credits.
  5. Production bindings belong in prod; Lemon/Paddle integration acceptance remains sandbox-only and does not require a live test purchase.
Multi-Currency Setup

For each sold plan, credit pack, or license, create the selected provider's catalogue object for every supported currency and interval. The localeCurrencyMap determines which currency the server resolves for the user's locale.

Common catalogue configuration mistakes
  • Do not paste prod_... product IDs; Checkout needs price_... IDs.
  • Do not paste test Price IDs into prod slots or live Price IDs into dev slots.
  • A paid offer with an empty selected-provider binding cannot create a checkout. Free plans intentionally use amount 0 and no external binding.
  • If PayPal or another method does not appear in Checkout, activate it in Stripe Dashboard and check pricingConfig.checkoutPaymentMethods.

Subscriptions

Subscriptions are managed by the single configured provider with automatic synchronization to the normalized application database.

Subscription Flow

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User clicks    │     │ Provider-hosted │     │  Webhook fires  │
│  "Subscribe"    │────▶│  checkout page  │────▶│  on completion  │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                                                         │
                                                         ▼
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User gets      │     │  Credits added  │     │  Subscription   │
│  access         │◀────│  to account     │◀────│  saved to DB    │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Creating a Checkout Session

POST /api/billing/checkout accepts only internal purchase facts (Account, purchase kind, offer, interval, currency, and locale). The server resolves PAYMENTS_PROVIDER, the exact external catalogue binding, immutable quote, customer binding, timeout, and provider options before creating the hosted checkout. The response contains only { url }; no provider or external Price/Variant ID can be selected by the browser. Subscription state and paid entitlements converge only from signed, journaled provider events.

Promotion Codes

Stripe promotion/coupon codes can be enabled or disabled at checkout via the allowPromotionCodes flag in config/pricing.ts. When set to true, Stripe displays a "Add promotion code" input field on the checkout page for both subscription and one-time payment (license/credit pack) sessions. Promotion codes must be created in the Stripe Dashboard. Set to false to hide the field.

Webhook handlers

Each provider has an exact query-free route under app/api/billing/webhooks/. The route reads a bounded raw body, verifies the selected provider's current/previous signing secret before parsing, journals the stable provider event, and enqueues normalized processing. Shared effects own payments, subscriptions, licenses, credits, adjustments, holds, and reconciliation; synchronous checkout responses never apply them. Stripe additionally pins Dahlia invoice relationships through invoice.parent.subscription_details.subscription and Invoice Payments.

Plan Configuration (Multi-Currency)

Plans are defined in config/pricing.ts with multi-currency and translation support. Each plan specifies an ID, translation keys for the name and description, included credits, feature list, limits, optional trial settings, and provider catalogue bindings for each currency, billing interval, and environment. The resolvePricingConfig() function translates plan copy and derives pricing-table values from config, including credits.included, limits.projects, trialDays, and defaultTrialCredits.

Free Plan Subscription

Plans with amount 0 and no external catalogue binding bypass hosted checkout entirely. When a user selects a free plan, the /api/billing/subscribe-free endpoint delegates to core/billing/free-plan.ts, validates the selected plan against config/pricing.ts, resolves the correct personal/workspace account, creates or updates the free subscription row, and grants included credits through the add_credits RPC. This avoids unnecessary provider calls for zero-cost plans while keeping the same account-centric billing rules as paid plans.

Configured-provider customer portal

POST /api/billing/portal resolves the provider and test/live environment persisted on the current subscription, verifies that they match the installation, retrieves the Account's provider-customer binding, and returns only the configured provider's hosted portal URL. Available self-service actions depend on the selected provider and its dashboard configuration; the browser cannot choose a provider, customer ID, subscription ID, or return URL.

Who may open it: billing-manager roles only — the workspace owner alone in b2b mode, owner or admin in b2c / hybrid. The portal can cancel a subscription and change the payment method, so membership in the account is not sufficient. This is enforced in both entry points: the /api/billing/portal route and the createPortalSession Server Action behind the billing card on /my-account.

For Lemon Squeezy sandbox readiness, leave LEMON_SQUEEZY_CUSTOMER_PORTAL_ACK empty until that button opens the hosted portal for a paid test customer. Only after a successful smoke set it to sandbox-lemon-squeezy-customer-portal-opened-for-paid-test-customer. A checkout success or a redirect that ends on a provider error page is not sufficient evidence.

Checkout-before-onboarding workflow

The pricing-to-dashboard funnel runs payment before profile collection — same flow whether the visitor is signing up or already authenticated, B2B or B2C, paid or free.

/pricing → click plan
   │  └─ bsk_pending_checkout cookie set (httpOnly, Secure, SameSite=Lax, 30min, Zod-validated)
   ▼
/login (skipped if already authed)
   │  └─ DB trigger creates personal account
   │  └─ B2B: ensureWorkspaceForUser auto-creates workspace
   ▼
/checkout
   │  └─ Server Component reads the cookie
   │  └─ Client posts to /api/billing/checkout (or /api/billing/subscribe-free for free plans)
   ▼
Hosted checkout of the configured provider
   ▼
/api/billing/checkout/status/exchange/[locale] → clean /checkout/success
   │  └─ URL bearer atomically rotated into a short HttpOnly cookie bearer
   │  ├─ pending/processing/provider_unknown → bounded local polling, no entitlement
   │  └─ completed → profile/workspace decision → onboarding or dashboard

Internal free plan or completed guest claim
   └─ /private-dashboard → shared proxy/private-layout onboarding and access gates

Key invariants:

  • bsk_pending_checkout is httpOnly + Secure + SameSite=Lax + 30min TTL. Its payload (purchase type, internal plan/product ID, interval, currency, locale, and free marker) is Zod-validated server-side via pendingCheckoutCookieSchema in lib/checkout/pending-checkout-cookie.ts.
  • Tampering is inert — /api/billing/checkout accepts only internal offer facts and resolves the configured provider plus external catalogue binding on the server; /api/billing/subscribe-free validates planId exists in config AND price.amount === 0. The cookie only hints which selection to resume — it never selects a provider or grants entitlements.
  • /checkout is intentionally not in proxy.ts:protectedRoutes so the middleware onboarding gate does not fire for it. Un-onboarded users can complete payment.
  • Free plans and completed guest claims have no provider checkout bearer. They enter the protected dashboard path directly, where the shared proxy and layout enforce onboarding, workspace creation, and durable access instead of fabricating a checkout-success proof.
  • The provider return URL never contains a provider checkout/session/transaction ID. Its opaque local bearer is atomically invalidated and replaced by a different short HttpOnly cookie bearer before the browser reaches the clean success URL; that cookie resolves only missing, invalid, unknown, pending, processing, provider_unknown, failed, expired, or completed.
  • The browser never calls Stripe, Lemon Squeezy, or Paddle to validate payment. Ten local status requests maximum are issued per automatic polling run, two seconds apart, with a five-second timeout per request and cancellation on unmount or terminal state.
Paid checkout onboarding gate — do not duplicate (anti-pattern A13)

For a paid provider checkout, the place that decides "onboarding or dashboard?" is app/[locale]/(auth)/checkout/success/page.tsx, and it runs only after durable status completed. Free plans and completed guest claims instead enter /private-dashboard, where the existing proxy/private-layout gates apply. Do not duplicate either decision in a checkout Client Component or another Route Handler.

End trial early

Trialing subscriptions can request paid conversion via POST /api/billing/end-trial. Its strict body is only { accountId }; provider, environment, external subscription, catalogue identity, timing, and idempotency key are resolved on the server. The route requires CSRF, a strict rate limit, billing step-up, and the billing-manager role.

  1. Durable intent: begin_subscription_control_command() records end_trial before any provider call and excludes concurrent lifecycle or plan-change commands.
  2. One-shot provider mutation: the configured ready adapter ends the trial once. Stripe uses trial_end: 'now' with the persisted idempotency key and copies the opaque command ID into subscription metadata. An ambiguous response is never retried as another write.
  3. Signed convergence: the route waits for the bounded provider mutation and returns 200 processing when the provider acknowledges it. Only an ambiguous result that needs durable reconciliation returns 202 pending. Webhooks own the local subscription state, normalized paid proof, and credit top-up. Read-only reconciliation additionally requires an immutable subscription_cycle invoice carrying the exact command ID, a strictly paid payment, and a period exactly equal to the signed subscription period that began no later than the recorded completion of the one-shot provider mutation; prorations, concurrent invoices, later renewals, stale periods and risked payments cannot complete the command.

Refund and risk decisions remain provider-side. The application exposes no refund-creation route: signed refund, dispute, chargeback and reversal events are normalized locally, converge credit clawbacks and payment state idempotently, and place a provider-owned subscription billing hold for a full refund, active dispute or reversed payment. A later signed positive state may clear only that provider-owned hold; commercial or operator holds are preserved.

Terminal mismatches are visible to platform admins at /admin-dashboard/billing/reconciliation. This surface exposes a bounded, redacted projection rather than raw provider payloads and supports only the audited sequence open → acknowledged → resolved behind billing step-up authentication. It may help an operator correlate an Account and external resource identifier, but it cannot create a refund, decide a dispute, or switch the installation provider.

Member subscription, credit-pack, and licence confirmations are persisted in the pending_emails outbox only after their paid proof and entitlement or credit ledger have converged. Stripe, Lemon Squeezy, and Paddle use provider/environment-qualified semantic keys, so webhook replay recovers a missing enqueue without duplicating the message. Guest claim delivery remains a separate atomic outbox.

Provider response is not payment proof

An active provider snapshot or an unsigned local synchronization cannot complete the command or grant paid credits. SQL requires the subscription cursor to match a completed signed lifecycle journal event received after the intent, and the normalized payment to match its own completed signed paid-event journal row.

Paddle early conversion also requires the signed amount and currency to match the frozen commercial quote, accounting for tax-inclusive or tax-exclusive prices and discounts. A mismatch places a commercial hold before payment persistence; SQL independently rejects the first credit grant and command completion. A refunded amount cannot complete the conversion.

The paid webhook uses the same provider-period semantic key as renewals, so replay cannot double-grant. Refunds, disputes, and payment failures continue through their signed provider effects and normalized entitlement/clawback rules. Lemon Squeezy and Paddle stay unavailable for early conversion until their payment origins and sandbox flows are attested.

License System (One-Time Payments)

As an alternative to subscriptions, you can sell licenses for one-time payment access. Configure via billingModel in config/app.ts.

Billing Models

Model Description
subscriptionRecurring payments through the configured provider
one_timeOne-time product purchases only (lifetime, yearly, monthly, custom)
hybridBoth subscriptions and licenses available to users

License Types

Type Description Expires
lifetimePerpetual access, never expiresNever
yearly365-day access from purchase1 year
monthly30-day access from purchase30 days
customCustom duration defined per productConfigurable

License Configuration

License products are declared in config/pricing.ts under pricingConfig.products (the source of truth — never in the database). Each product sets a licenseType (lifetime | yearly | monthly | custom), per-currency pricesByCurrency with provider catalogue bindings, the one-time credits.oneTime granted on purchase, and optional limits / feature keys. Non-lifetime types use validityDays to compute expiration.

products: [
  {
    id: 'pro-lifetime',
    nameKey: 'pricing.products.proLifetime.name',
    descriptionKey: 'pricing.products.proLifetime.description',
    licenseType: 'lifetime',
    validityDays: null,
    pricesByCurrency: {
      EUR: { price: 299, providers: getProviderCatalogBindings('pro-lifetime', 'EUR') },
      USD: { price: 329, providers: getProviderCatalogBindings('pro-lifetime', 'USD') },
    },
    credits: { oneTime: 2500000 },
    limits: { projects: 10 },
    featureKeys: [{ nameKey: 'pricing.features.apiAccess', included: true }],
  },
  { id: 'pro-yearly', licenseType: 'yearly', validityDays: 365, /* ... */ },
]

License Checkout Flow

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User clicks    │     │ Configured host │     │  Webhook fires  │
│  "Buy License"  │────▶│  checkout page  │────▶│  on completion  │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                        (mode: 'payment')                │
                                                         ▼
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User gets      │     │  Credits added  │     │  License record │
│  access         │◀────│  to account     │◀────│  created in DB  │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Creating License Checkout

POST /api/billing/license-checkout accepts only the Account, internal product, currency, locale, and the two required legal acknowledgements. The server resolves the configured provider, immutable licence quote and external catalogue binding, then sends a signed bounded billing context through the provider adapter. Only the signed, journaled payment event creates the normalized payments proof and licence, then grants one-time credits via grant_license_credits. Provider/environment identities, checkout attempt, quote digest and payment proof keys gate replay.

Access Control

Use the unified checkAccountAccess() function to check both subscriptions and licenses in one call. In hybrid billing mode an active subscription wins over a license:

import { checkAccountAccess } from '@/lib/billing/access'

const access = await checkAccountAccess(accountId)
// {
//   hasAccess: boolean,
//   source: 'subscription' | 'license',
//   plan?: PlanConfig,
//   license?: LicenseWithProduct,
//   expires_at?: string,
//   daysRemaining?: number,
// }

License Expiration Job

The check-license-expiration job handler batch-marks expired licenses (markExpiredLicenses()) and sends warning emails at 7, 3, and 1 days before expiry. It is seeded by pnpm run init when billingModel is one_time or hybrid; register it with a daily cron (e.g. 0 2 * * *) from /admin-dashboard/jobs if you enable licensing after init.

Admin License Management

Admins can view, extend, and revoke licenses from /admin-dashboard/licenses:

Action Endpoint Description
List licensesGET /api/admin/licensesGet all licenses with account info
Extend licensePATCH /api/admin/licenses{ action: 'extend', days: 30 }
Revoke licensePATCH /api/admin/licenses{ action: 'revoke' }

Credits System

Credits are the internal currency for AI usage with a deliberately simple model — 1 credit = 1 LLM token. Balances live on accounts.credits_balance with a full audit trail in credit_transactions; every change goes through the atomic add_credits / decrement_credits RPCs (never direct UPDATE). A pre-flight check rejects requests below aiConfig.minCreditsRequired before the LLM call, and the DB CHECK (credits_balance >= 0) constraint is the last-resort safety net for concurrent races.

For the full architecture (sources/usage diagram, atomic RPC contract, credit-pack purchase flow, transaction history), see Credits System.

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 require a positive persisted payment and retain its exact qualifying_payment_id; full refunds and lost chargebacks reverse only the reward tied to that payment via decrement_credits. The system is feature-gated via REFERRAL_ENABLED (server-only) and surfaces as 404 when disabled. Configuration lives in config/referral.ts; all writes go through SECURITY DEFINER RPCs.

For the full configuration table, attribution flow, RPC contracts, webhook hooks, API surface, and security invariants, see Referral System.

Affiliation Program

Account-centric partner program for marketers and content creators. Distinct from referrals — cash commissions settled manually per billing currency (not in-app credits), application + admin approval required, and tier-based commission models (recurring monthly or one-time upfront). Feature-gated via AFFILIATES_ENABLED (server-only) with AFFILIATES_SALT for IP/UA hashing; surfaces as 404 when disabled. Configuration lives in config/affiliates.ts; all writes go through SECURITY DEFINER RPCs and affiliate failures never break billing.

For the tiers/commission table, attribution flow, database schema, RPC catalog, webhook hooks, admin console, jobs, API surface, and security invariants, see Affiliation Program.