Upgrading

Move an existing SyntaxKit installation or a customized fork to V1.1, the release that adds the billing provider boundary and Polar.

V1.1 is an additive release. A Stripe installation upgrades by deploying the new code and applying one migration; nothing about its behavior, data, or environment has to change. This page is the checklist for that, plus the merge notes for buyers who have customized packages/payments, the billing API, or the dashboard. The full record of the release is in CHANGELOG.md at the repository root; the design is ADR 0006 (docs/adr/0006-billing-provider-boundary.md).

What Changed In V1.1

  • One active billing provider. BILLING_PROVIDER=stripe|polar selects the provider a deployment bills through. Unset means Stripe, so existing installations keep working without any configuration change. Only the active provider's env vars are required.
  • Polar ships as the second provider. Hosted checkout, customer portal, cancel/resume, reconciliation, support compare/resync, and the price-drift guard all work with Polar. Polar is merchant of record, so the kit does not send receipts or trial-ending reminders for it, and manual webhook replay from the support console is Stripe-only. See Billing: Choosing A Billing Provider.
  • A narrow adapter boundary. packages/payments/src/provider/ holds the contract, capabilities, and selection; packages/payments/src/stripe/ and packages/payments/src/polar/ implement it; packages/payments/src/ledger/ holds the shared webhook dedup ledger, the canonical recency-guarded subscription write, and canonical-row selection. @syntaxkit/payments/server exports provider-neutral facades; packages/core and packages/api no longer import Stripe modules directly.
  • Provider-neutral domain and wire types. SubscriptionData gains provider and renames stripeSubscriptionId to providerSubscriptionId and stripePriceId to providerPriceId. BillingIssueCode renames stripe_price_not_configured to provider_price_not_configured and adds billing_provider_mismatch. The oRPC DTO exposes provider and providerSubscriptionId.
  • Catalog declares both providers. Each price may name stripePriceEnv and/or polarProductEnv; the shipped catalog names both. pnpm billing:check-prices and pnpm billing:reconcile dispatch on the active provider.
  • Switching providers never migrates subscriptions. A live row from another provider resolves to configuration_error / billing_provider_mismatch; pnpm setup:doctor reports the count before boot.

The Migration

packages/database/prisma/migrations/20260908140000_billing_provider_boundary/migration.sql is additive only:

CREATE TYPE "BillingProvider" AS ENUM ('stripe', 'polar');
ALTER TABLE "subscription" ADD COLUMN "provider" "BillingProvider" NOT NULL DEFAULT 'stripe';
ALTER TABLE "stripe_webhook_event" ADD COLUMN "provider" "BillingProvider" NOT NULL DEFAULT 'stripe';
ALTER TABLE "organization" ADD COLUMN "polarCustomerId" TEXT;
CREATE UNIQUE INDEX "organization_polarCustomerId_key" ON "organization"("polarCustomerId");
CREATE INDEX "subscription_provider_status_idx" ON "subscription"("provider", "status");

Every existing row is tagged provider = 'stripe' by the column default, so historical Stripe data stays interpretable without a backfill. The stripe*-named columns (stripeSubscriptionId, stripePriceId, stripeCurrentPeriodStart/End, stripeLastEventAt) and the stripe_webhook_event table keep their names and now hold the row's provider's identifiers, qualified by provider (ADR 0006 §3). Nothing is renamed, dropped, or rewritten, so on PostgreSQL the migration is a metadata change plus two index builds and does not lock out writers for long.

Organization.polarCustomerId is injected into the Better Auth schema by scripts/generate-auth-schema.ts, which is why packages/database/prisma/auth.generated.prisma changed in this release. Never hand-edit that file; if your fork changed the Organization model, re-run pnpm auth:generate after merging.

Deployment Ordering

Deploy the code and apply the migration together

Run pnpm db:migrate:deploy as part of the deploy, the same way every other migration ships. Old code running against the migrated schema is safe (it ignores the new columns and their defaults are correct); new code against the old schema is not, because the Prisma client selects provider. Do not run the new build before the migration has applied.

Leave BILLING_PROVIDER unset

An upgraded Stripe installation should not set BILLING_PROVIDER at all. The default is stripe, the doctor label reads Billing (Stripe), and every existing subscription is a stripe row. Set the variable only when you deliberately bill a new deployment through Polar.

Run the doctor once after deploy

pnpm setup:doctor now prints Billing provider consistency: OK (every live subscription belongs to Stripe) when the database is reachable. Anything else means a row was written by another provider, which cannot happen on a straight upgrade.

Rollback Limitations

  • Rolling back the code before the migration ran is fine: the previous release neither reads nor writes the new columns.
  • Rolling back the code after the migration ran is also fine for a Stripe installation. The new columns are nullable or defaulted, and the old code ignores them. Leave the migration in place; do not try to revert it as part of a code rollback.
  • Do not drop the new columns while Polar rows exist. Once any deployment has billed through Polar, subscription.provider = 'polar' rows and organization.polarCustomerId values are the only record of which provider owns those customers. Dropping the columns would make those rows look like Stripe rows and every read path would try to open them with the Stripe adapter.
  • Switching BILLING_PROVIDER is not a rollback. Changing the value does not move subscriptions; it only changes which adapter the deployment talks to. See Billing: Switching never migrates anything.

Merging Into A Customized Fork

If you customized billing, expect conflicts in the areas below. Run git diff --stat between your fork and the release tag to see the exact list for your tree; this section explains what moved and why so the conflicts are quick to resolve.

Files that moved

BeforeAfterWhat it holds
packages/payments/src/stripe/webhook/idempotency.tspackages/payments/src/ledger/webhook-events.tsclaimEvent, markEventProcessed, markEventFailed, claimEffect, releaseEffectClaim, withDedupedEffect, best-effort email/analytics helpers. ClaimEventInput now requires provider.
Provider-neutral parts of packages/payments/src/stripe/webhook/lib.tspackages/payments/src/ledger/subscription-writes.ts, packages/payments/src/ledger/notifications.tsupsertProviderSubscriptionRecord (the canonical recency-guarded write), owner-email and formatting helpers. Stripe-specific narrowing stays in stripe/webhook/lib.ts.
Canonical selection in packages/payments/src/stripe/subscription.tspackages/payments/src/ledger/canonical.tsselectCanonicalSubscription, syncCurrentOrganizationSubscription.
getBillingState / getSubscription in packages/payments/src/stripe/subscription.tspackages/payments/src/billing-state.tsThe read path, with self-heal dispatched on the row's provider.
packages/payments/src/stripe/billing-state-cache.tspackages/payments/src/billing-state-cache.tsRequest-scoped cache.
packages/payments/src/stripe/drift.tspackages/payments/src/drift.tsreportBillingDrift; drift kinds renamed to neutral names (provider_price_not_configured, subscription_missing_in_provider, subscription_missing_period, subscription_missing_price_id) and new kinds added (polar_customer_conflict, polar_order_unmatched, billing_provider_mismatch).
processWebhookEvent in packages/payments/src/stripe/webhook.tsprocessStripeWebhookEvent in the same file; called through processBillingWebhook("stripe", …) from @syntaxkit/payments/serverThe Stripe route no longer imports the Stripe module directly.

Old paths are deleted, not shimmed. A fork that imported from them fails pnpm check-types with a clear "cannot find module" error; update the import.

Renames to expect

AreaBeforeAfter
SubscriptionData (packages/payments/src/types.ts)stripeSubscriptionId, stripePriceIdproviderSubscriptionId, providerPriceId, plus new provider
BillingIssueCodestripe_price_not_configuredprovider_price_not_configured; new billing_provider_mismatch
Catalog price declaration (packages/payments/src/catalog/declaration.ts, catalog/types.ts)stripePriceEnv (required)stripePriceEnv and/or polarProductEnv (at least one)
Catalog resolution (packages/payments/src/catalog/resolve.ts)resolveForward returned priceIdReturns providerPriceId for the active provider; getPlanByProviderPriceId(provider, id)
Catalog env helpers (packages/payments/src/catalog/static.ts)Stripe-only listsBILLING_SECRET_ENV_NAMES_BY_PROVIDER, priceEnvNames(provider), requiredBillingEnvNames(provider), missingBillingEnvNames(env), isBillingConfigured(env) read BILLING_PROVIDER from the supplied env
oRPC DTO and zod (packages/api/src/dto/billing.ts, packages/api/src/lib/billing.ts)Stripe field namesprovider, providerSubscriptionId; providerPriceId stays server-only
Support schemas (packages/shared/src/schemas/support.ts)Stripe-named comparison fieldsproviderId, providerCustomerId; reasons provider_not_configured, provider_mismatch
Setup (packages/shared/src/setup/*)STRIPE_* groupBILLING_PROVIDER, POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_SERVER; SetupCapabilities.billing.provider
Web i18n (apps/web/messages/en.json, de.json)Support-console explanation key stripe_price_not_configuredprovider_price_not_configured; new billing_provider_mismatch explanation and provider_not_configured, provider_disabled, provider_unsupported, provider_mismatch reasons
Web env (apps/web/lib/env.server.ts)Stripe-only boot checkProvider-aware boot check
Webhook route (apps/web/app/api/webhooks/stripe/route.ts)Calls the Stripe moduleCalls processBillingWebhook("stripe", …); new sibling apps/web/app/api/webhooks/polar/route.ts

Other files touched by the release

packages/core/src/billing/*, packages/core/src/organizations/lifecycle.ts, packages/core/src/support/* (call the neutral facades), scripts/setup-doctor.ts, scripts/billing/check-prices.ts, scripts/billing/reconcile.ts, scripts/generate-auth-schema.ts, packages/database/prisma/models/billing.prisma, apps/web/.env.example, apps/web/.env.test.example, and the deploy configs that enumerate env vars (turbo.json, render.yaml, docker-compose.yml, apps/Dockerfile, .github/workflows/compose-e2e.yml). The ESLint import boundary for @syntaxkit/payments/server widened from apps/web/app/api/webhooks/stripe/** to apps/web/app/api/webhooks/**.

If your fork added a webhook handler, port it onto upsertProviderSubscriptionRecord and claimEvent({ provider: "stripe", … }); the semantics (recency guard, tie rule, P2002 retry) are unchanged. If your fork added a catalog plan, add polarProductEnv to each price only if you intend to sell it through Polar; Stripe-only declarations remain valid.

Upgrade Checklist

Run from the repository root after merging:

pnpm install
pnpm db:validate          # the Prisma schema, including the BillingProvider enum
pnpm auth:generate        # only if your fork changed the Organization model
pnpm db:generate
pnpm check-types
pnpm test:run
pnpm setup:doctor         # expect "Billing (Stripe): OK" and provider consistency OK
pnpm billing:check-prices # declared prices still match the live provider objects

Then deploy with pnpm db:migrate:deploy in the release step, as usual. If you also run the integration tier, pnpm test:integration covers the shared ledger against a real database.

Switching providers does not transfer subscriptions. A deployment that changes BILLING_PROVIDER after it has paying customers puts every live subscription into billing_provider_mismatch: paid entitlements stop, checkout is blocked, and the portal is denied until the value is switched back or the subscriptions are canceled on the old provider. Choose the provider before launch, or plan a customer-facing cancel-and-resubscribe.

Where To Go Next

On this page