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.
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.
| Provider | External catalogue ID | Webhook route | Additional readiness boundary |
|---|---|---|---|
| Stripe | price_... Price ID | /api/billing/webhooks/stripe | Dedicated active Billing Portal configuration with provider-side plan updates disabled; Dahlia API version pin. |
| Lemon Squeezy | Numeric Variant ID | /api/billing/webhooks/lemon-squeezy | Store/catalogue/webhook sandbox proof and an exact paid-customer portal acknowledgement. Hosted checkout needs no browser secret. |
| Paddle | pri_... Price ID | /api/billing/webhooks/paddle | Approved 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
- Sign up at dashboard.stripe.com
- Complete your business profile
- 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) |
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)
/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.
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:
| Scenario | Card number |
|---|---|
| Successful payment | 4242 4242 4242 4242 |
| Requires 3D Secure / SCA | 4000 0027 6000 3184 |
| Generic decline | 4000 0000 0000 0002 |
| Insufficient funds | 4000 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.refunded5. 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: '' },
},
}- Open the selected provider's test or sandbox dashboard.
- Create every recurring plan, one-time credit pack, and license you intend to sell in that environment.
- Copy the provider's exact Price or Variant ID into the matching provider and
devslot. Never use a Product ID where a Price/Variant is required. - Run
pnpm run check:payment-catalog, complete the selected provider's test checkout, and confirm signed webhook convergence before checking credits. - Production bindings belong in
prod; Lemon/Paddle integration acceptance remains sandbox-only and does not require a live test purchase.
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.
- Do not paste
prod_...product IDs; Checkout needsprice_...IDs. - Do not paste test Price IDs into
prodslots or live Price IDs intodevslots. - A paid offer with an empty selected-provider binding cannot create a checkout. Free plans intentionally use amount
0and 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_checkoutis 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 viapendingCheckoutCookieSchemainlib/checkout/pending-checkout-cookie.ts.- Tampering is inert —
/api/billing/checkoutaccepts only internal offer facts and resolves the configured provider plus external catalogue binding on the server;/api/billing/subscribe-freevalidatesplanIdexists in config ANDprice.amount === 0. The cookie only hints which selection to resume — it never selects a provider or grants entitlements. /checkoutis intentionally not inproxy.ts:protectedRoutesso 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, orcompleted. - 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.
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.
- Durable intent:
begin_subscription_control_command()recordsend_trialbefore any provider call and excludes concurrent lifecycle or plan-change commands. - 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. - Signed convergence: the route waits for the bounded provider mutation and returns
200 processingwhen the provider acknowledges it. Only an ambiguous result that needs durable reconciliation returns202 pending. Webhooks own the local subscription state, normalized paid proof, and credit top-up. Read-only reconciliation additionally requires an immutablesubscription_cycleinvoice carrying the exact command ID, a strictlypaidpayment, 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.
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 |
|---|---|
subscription | Recurring payments through the configured provider |
one_time | One-time product purchases only (lifetime, yearly, monthly, custom) |
hybrid | Both subscriptions and licenses available to users |
License Types
| Type | Description | Expires |
|---|---|---|
lifetime | Perpetual access, never expires | Never |
yearly | 365-day access from purchase | 1 year |
monthly | 30-day access from purchase | 30 days |
custom | Custom duration defined per product | Configurable |
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 licenses | GET /api/admin/licenses | Get all licenses with account info |
| Extend license | PATCH /api/admin/licenses | { action: 'extend', days: 30 } |
| Revoke license | PATCH /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.