Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Payments: one entitlement read model, three provider modules

The repository's architectural decision, rationale and consequences.

Markdown

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

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

  • 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.
Edit on GitHubDocumentation is in English.