# Payments: one entitlement read model, three provider modules

The repository's architectural decision, rationale and consequences.

# Payments: one entitlement read model, three provider modules

The template already verified RevenueCat purchases on the server (`billing_event`, `billing_refresh`, `billing_entitlement`, pull reconciliation). The web app needed Stripe and Polar checkout, and the mobile app never registered the RevenueCat SDK. Each provider must be optional, everything off by default, and the app must boot and pass its tests with no payment keys.

## Decision [#decision]

**One read model.** `billing_entitlement` is the only thing that answers "does this User have Paid Access". It gained a `provider` column (existing rows are `revenuecat`) and its key became `(user_id, provider, entitlement_id)`. Each provider replaces only its own rows: RevenueCat reconciliation deletes `provider = 'revenuecat'`; a web event rebuilds that provider's rows for that User. `billing.status` projects all rows of enabled providers in the current `BILLING_ENVIRONMENT`, so a Stripe purchase unlocks the mobile app and a store purchase shows on the web Billing page. RevenueCat rows also expire when their verification is older than `BILLING_MAX_VERIFIED_AGE_SECONDS`; web rows are push-updated and get a 24-hour grace after the period end so a late renewal webhook does not cut access.

**Per User, not per Organization.** CONTEXT.md defines an Entitlement as belonging to a User, and Paid Access as not granting Organization membership. ADR-0003 makes Organizations the tenant for shared data, not for billing. Organization billing (seats, one payer per Organization) is a follow-up that would add a reference column, not a second model.

**Webhooks are notifications, verified before parsing.** `/api/billing/stripe` and `/api/billing/polar` are Worker routes. They bound and read the raw body, verify with Web Crypto (`crypto.subtle.verify`, constant time), then dedupe on `billing_event (provider, id)` inside the same transaction that applies the change. A replay is a no-op; a failed write rolls back the event row so the provider retries. Stripe signs `${t}.${body}` with the whole `whsec_…` string; Polar follows Standard Webhooks and both key derivations it has used are accepted. Stripe does not order events, so the handler re-reads the subscription from the API (`Stripe-Version` pinned) and fences on that read time; Polar payloads carry `modified_at`, which fences directly. Every applied change queues a `billing.subscription_changed` audit event targeting the User.

**No Better Auth payment plugins.** `@better-auth/stripe` 1.7.7 and `@polar-sh/better-auth` 2.1.0 are compatible with better-auth 1.7.7 and contain no Node-only code. We still do not use them: each adds its own subscription state (a `subscription` table and `user.stripeCustomerId`, or Polar's customer sync) that would compete with `billing_entitlement`; registering them conditionally changes the inferred auth type that `createAuth` exports; and they bring the `stripe` and `@polar-sh/sdk` packages into the Worker for five REST calls. The provider modules are fetch-based (`packages/api/src/billing/{stripe,polar}.ts`) and run through the same bounded, redirect-free request helper as the RevenueCat client.

**Configuration.** `BILLING_PROVIDERS` lists the enabled providers (`stripe,polar,revenuecat`); it replaces `BILLING_ENABLED`, which only meant RevenueCat. Each listed provider is validated as a whole and errors never echo values. Stripe keys must match the environment (`sk_test_`/`rk_test_` for `SANDBOX`, `*_live_` for `PRODUCTION`); Polar uses the sandbox API in `SANDBOX`. The plan catalog (`packages/api/src/lib/billing-plans.ts`) is a leaf module shared with the web client; provider price/product ids come from env (`STRIPE_PRICE_IDS`, `POLAR_PRODUCT_IDS`) because they differ per mode. `BILLING_CHECKOUT_PROVIDER` picks the web checkout when both web providers are on (for example while migrating), otherwise Stripe wins over Polar.

**Visibility is a flag, capability is env.** `billing.web` shows the Billing navigation entry and `billing.mobile` shows the subscription screen. A flag cannot enable a provider the server has not configured, and a configured provider stays invisible until its flag is on. The mobile app registers `react-native-purchases` through a lazy loader, so the SDK only initializes when billing is used and an older dev client degrades to "store unavailable".

## Consequences [#consequences]

- Checkout and portal URLs are built on the server from `BETTER_AUTH_URL` (or the request origin); the client never supplies a redirect target. A second checkout for an active entitlement is refused; plan changes go through the provider portal.
- Web subscriptions are only as fresh as their webhooks plus the grace window. There is no scheduled pull for Stripe/Polar yet; `billing.sync` only reconciles RevenueCat.
- `scripts/remove-server-billing.mjs` removes all three providers, the Billing page and its navigation together.
- Removing a provider from `BILLING_PROVIDERS` hides its entitlements immediately but keeps its rows, so turning it back on restores access without data loss.

