Billing
Configure one billing provider and enforce entitlements on the server.
Billing
The application supports Polar, Stripe, and Dodo Payments behind one provider-neutral application
boundary. A deployment selects exactly one adapter with BILLING_PROVIDER; providers are not
combined, and only the selected provider can change local entitlements.
This is billing for the SaaS built from the foundation. It is separate from the seller's
source-product purchase and fulfillment design in docs/commercial/fulfillment.md.
Modes and provider selection
| Mode | Configuration | Expected behavior |
|---|---|---|
| Local zero-key | selected provider credentials/products all unset | app trial works; plans are empty; checkout/portal are unavailable; no provider IO |
| Automated test | normalized fake provider plus disposable Postgres | ordering, replay, rollback, and entitlement run without external calls |
| Sandbox/test | complete selected-provider test configuration | hosted checkout, portal, and signed lifecycle events without a real charge |
| Production | complete selected-provider live configuration | real lifecycle; release smoke and incident ownership are required |
Choose one:
BILLING_PROVIDER=polar # or stripe or dodoConfiguration is atomic for the selected provider. Leave every selected-provider credential and product/price ID unset for zero-key development. Once any is set, provide its API credential, webhook secret, and at least one unique plan mapping:
| Provider | Credentials | Plan identifiers | Environment |
|---|---|---|---|
| Polar | POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_ORGANIZATION_ID | POLAR_*_PRODUCT_ID | POLAR_ENVIRONMENT=sandbox|production |
| Stripe | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | STRIPE_*_PRICE_ID | determined by the Stripe key |
| Dodo Payments | DODO_PAYMENTS_API_KEY, DODO_PAYMENTS_WEBHOOK_SECRET | DODO_PAYMENTS_*_PRODUCT_ID | DODO_PAYMENTS_ENVIRONMENT=test_mode|live_mode |
Each provider has monthly and yearly display-price variables matching its identifier prefix. Displayed price is application configuration, not provider authority. Compare identifier, interval, currency, and charged amount during the release smoke.
Inactive-provider variables are ignored. This permits deliberate provider switching without allowing
two providers to mutate the same entitlement. Switching an application that already has customers
requires a migration and reconciliation plan; changing BILLING_PROVIDER alone is not a
live-customer migration.
Boundaries
packages/billing
provider.ts provider-neutral methods
types.ts normalized customers/subscriptions/payments/events
providers/polar.ts Polar SDK and normalization
providers/stripe.ts Stripe SDK and normalization
providers/dodo.ts Dodo SDK and normalization
apps/server/src/domains/billing
routes.ts session endpoints and selected-provider webhook route
service.ts plan mapping, event transaction, notifications, app trial
repository.ts provider-neutral customers/subscriptions/event ledger
entitlements.ts sole paid-access policyRaw SDK payloads stop inside their provider adapter. Hono routes, repositories, web code, and public contracts never accept a provider SDK object.
Checkout and portal
Authenticated users can list configured plans, create a hosted checkout for a configured plan, open the selected provider's portal after a provider customer exists, and fetch server-derived subscription/trial/entitlement state.
Checkout derives the user and email from Better Auth. It sends user_id and plan_type as
provider-side metadata. The return URL is UX/recovery information only and never grants access.
Stripe receives its own checkout-session placeholder; Dodo receives a plain return URL; Polar
receives its native checkout placeholder. Polar also receives the buyer address resolved by the
selected TRUSTED_PROXY_PROFILE so its hosted checkout can localize currency and calculate tax from
the buyer rather than from the API server. Keep the application origin inaccessible around that
trusted ingress.
Portal creation uses the provider customer ID stored from a verified webhook. Stripe and Dodo require that provider-side ID; Polar uses the application external customer ID. Portal-session creation remains rate-limited.
Trials and entitlements
The application has a 14-day local product trial starting at users.created_at. This is separate
from any provider-side trial.
Paid access is decided only in entitlements.ts:
| Local status | Paid access | New checkout blocked | Recovery behavior |
|---|---|---|---|
active | yes | yes | portal available |
trialing | yes | yes | provider trial |
canceled, period end still in future | yes | yes | access until exact period boundary |
past_due | no | yes | update payment method; do not double-buy |
unpaid | no | no | provider state is inactive |
paused, incomplete, incomplete_expired | no | no | no paid entitlement |
| absent or expired cancellation | no | no | app trial may still grant product access |
Provider statuses are normalized fail-closed. For example, Dodo on_hold maps to past_due and
failed maps to unpaid; Stripe and Polar lifecycle states map to the same application policy. A
scheduled cancellation remains entitled only before its provider period end.
Webhook contract
Register the endpoint matching the selected provider:
https://<api-origin>/webhook/<polar|stripe|dodo>The route returns 404 when the path provider is not the selected provider. It returns 503 when the selected webhook integration is intentionally unset and 403 for a missing or invalid signature.
Each adapter verifies the raw body before normalization:
- Polar uses Standard Webhooks headers and its SDK verifier.
- Stripe requires
Stripe-Signatureand constructs the event with Stripe's verifier. - Dodo requires its Standard Webhooks ID/timestamp/signature headers and official verifier.
After verification, the application:
- normalizes the provider event;
- claims
(provider, event_id)and mutates subscription/customer/access state in one transaction; - orders state using provider event time in
provider_modified_at; - commits before best-effort email/in-app notification.
Duplicate committed events are no-ops. A failed mutation rolls back the claim so the provider can retry. An older event may be claimed without overwriting newer state. Unknown products, contradictory plan metadata, and missing user correlation fail the transaction instead of defaulting to a paid plan.
Subscribe only to lifecycle events handled by the selected adapter:
- Polar: subscription created/active/updated/canceled/uncanceled/revoked/past-due and order paid.
- Stripe: customer subscription created/updated/deleted and invoice payment succeeded/failed.
- Dodo: payment succeeded/failed and subscription active/renewed/plan-changed/updated, update-payment-method, cancelled/expired/on-hold/failed/paused.
Provider-valid unrelated events are acknowledged and recorded as unhandled. Treat a newly required event as an adapter/version change with a regression fixture.
Notifications, deletion, and failure semantics
Billing state commits before lifecycle notification delivery. A notification failure must not make the provider retry an already-committed financial mutation. The current implementation is not backed by an outbox, so an operator resend must not replay billing state.
Deleting local application data does not cancel a remote subscription. Account deletion returns
409 CONFLICT while billing can renew or paid time remains. The customer cancels through Billing
and waits for the period boundary before local account deletion.
Never manually toggle users.is_premium, delete event claims, or grant access from a checkout
redirect. Use Diagnose a failed webhook.
Provider verification
Before production, run the same lifecycle in the selected provider's sandbox/test mode:
- create every enabled recurring product/price;
- configure the complete selected provider;
- register the exact webhook route and events;
- run checkout with a fresh verified application user and a deliverable email address accepted by the provider sandbox;
- confirm customer correlation, subscription, event claim, entitlement, and portal;
- replay the same event and deliver an older lifecycle event;
- fail and recover a payment;
- schedule cancellation and verify the exact access boundary;
- reject an invalid signature;
- verify account deletion remains blocked until billing is resolved.
The optional Add a billing plan, Add a billing-gated feature, and Diagnose a failed webhook playbooks provide maintained implementation and recovery sequences.
Reconciliation and alerting
Run a bounded dry-run against the selected provider:
vp run --filter @app/server billing:reconcile -- --limit=100Apply remote subscription state only after reviewing that report:
vp run --filter @app/server billing:reconcile -- --apply --limit=100The compiled deployment artifact supports the same operator command:
./server billing:reconcile --apply --limit=100The command exits non-zero for unresolved drift, provider errors, or a locally stored subscription that no longer exists remotely. Configure Railway or Dokploy scheduled-job failure alerts around that exit status. Missing remote subscriptions are never silently deleted or revoked; investigate them in the provider dashboard first. This explicit bounded command is the v1 recovery mechanism, not a generic worker or queue.
Known limits
- Only one provider can be active per deployment.
- Plan currency and amount are not reconciled into the application plan response.
- There is no generic coupon, tax display, usage, seat, or refund UI.
- Reconciliation is bounded and operator-triggered; each deployment must schedule it and route a non-zero exit to its platform alerting channel.
- Adapter unit/integration tests do not replace a real Polar sandbox, Stripe test-mode, or Dodo test-mode checkout-to-webhook proof.