Skip to content

Billing for Paddle and Polar: pick per project, switch later without touching existing subscribers - #1

Merged
nullarch merged 5 commits into
mainfrom
paddle-billing
Oct 14, 2026
Merged

nullarch merged 5 commits into
mainfrom
paddle-billing

Conversation

@nullarch

@nullarch nullarch commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

What

Billing that works with Paddle or Polar (both merchants of record), chosen per project with BILLING_PROVIDER=paddle|polar, and changeable later without touching anybody who already pays. Subscriptions and one-time purchases, signed webhooks for both providers, the customer portal, Supabase tables with Row Level Security, and getEntitlement(supabase, userId) / hasPurchased(...) that do not care which provider bills the user. It stays off until configured (no buy button, 503 billing_not_configured).

This replaces the first version of this PR (Paddle only). The Firebase twin is Streamize-llc/next-firebase-template#1, same structure.

A clone's work: edit src/config/billing.ts (one file, the same for both providers), fill .env.local, apply the migration (npm run db:reset / db:push), then per provider: Paddle npm run paddle:catalog -- --write + npm run paddle:webhook -- <url> --write; Polar: create the products in the dashboard (npm run polar:catalog lists them), paste the ids, npm run polar:webhook -- <url> --write. npm run billing:check says which provider takes new checkouts and what is missing. Guide: BILLING.md (replaces PADDLE.md).

The structure

  • src/lib/billing/: provider-neutral (rows with a provider, the Supabase store, idempotent delivery, entitlements, the adapter interface) + one folder per provider (paddle/, polar/: config, fetch API client, signature check, payload -> row mapping, sync, adapter). A third provider is one more folder.
  • A switch only moves NEW checkouts. Both webhooks stay live whatever BILLING_PROVIDER says; a subscriber of the old provider keeps being updated, keeps their plan, and "Manage billing" opens that provider's portal; the old provider needs only its server side afterwards.
  • Never billed twice: a running, past-due or paused subscription at either provider answers 409 already_subscribed to a new plan checkout.
  • With both providers configured and BILLING_PROVIDER empty, checkouts are refused rather than guessed.
  • Paddle: customers are found by e-mail and shared across apps, so the server links the customer (billing_customers.paddle_customer_id, UNIQUE) and a checkout needs a confirmed e-mail. Polar: the customer IS the user (external_customer_id = user id, set by the server); no confirmed e-mail needed; events subscription.* and order.* (a refund is one growing adjustment per order), Standard Webhooks signature verified by hand (no @polar-sh/sdk).
  • Every webhook: signature on the raw body, idempotent per provider, an older subscription or refund state never overwrites a newer one (one conditional UPDATE, instants), a failure answers 500 so the provider retries. A payment of a user who no longer exists, or an external id that is not a UUID (another app on a shared Polar organization), is ignored instead of a 500 that the provider would retry for days.

Migration

supabase/migrations/20261014120000_billing.sql replaces 20261014120000_paddle_billing.sql from the first version of this PR (never merged, never released): primary key (provider, id) on every table, customer_id instead of paddle_customer_id, nullable UNIQUE paddle_customer_id + polar_linked_at on billing_customers, event_occurred_at on refunds. If you already applied the old file to a database, run db reset.

Verified

  • typecheck, lint, build, 250 unit tests (the stores and the route handlers of both providers, including a switch under existing subscribers, over an in-memory fake of supabase-js).
  • The migration on a real Postgres engine (PGlite) with a stub of Supabase's auth schema and roles: a user reads only their own rows whichever provider they come from, no browser role can write (10 statements x 2 roles refused), the webhook log is closed, the constraints hold (provider check, composite keys and foreign keys, ONE Paddle customer per user, many users without one), the guarded updates behave, deleting a user cascades.
  • Real Paddle sandbox, end to end, through PostgREST-shaped requests into that Postgres (Docker was unavailable, so a throwaway PostgREST/GoTrue stand-in sat in front of PGlite; it is not part of the repo): catalog + webhook scripts, a purchase through the real pricing page and Paddle's overlay, three real signed webhooks (all 200). Then BILLING_PROVIDER=polar with that Paddle subscriber: second subscription refused (409), "Manage billing" opens Paddle's portal, a cancellation made at Paddle still arrives and shows as "ends on", and a full refund goes pending_approval -> approved after 205 s with both adjustment webhooks processed and refunded = the payment.
  • Real Polar sandbox: a real checkout created from the pricing page for a user with an unconfirmed e-mail (lands on sandbox.polar.sh); real objects (subscriptions, orders with a 409 + 41 partial refund) delivered as webhooks signed with the reference standardwebhooks library (the external id rewritten to a test user's UUID) and applied through Row Level Security and the real foreign keys; a redelivery, a payment of a user who does not exist, a non-UUID external id and a payload without one are all answered 200 and ignored; the portal for an unknown Polar customer answers no_customer.
  • The real Polar API found a defect the tests could not: an unknown external customer is a 422 "Customer does not exist.", not a 404 (the portal answered 502). Fixed, and pinned by tests that use the real answer.

Not verified here (honest list)

  • A payment made through Polar's hosted checkout page. The card is typed on sandbox.polar.sh, a third-party host; I did not automate that. One minute by hand: BILLING_PROVIDER=polar, sign in, press a plan on /pricing, pay with 4242 4242 4242 4242, expect "You are all set" on /billing/success.
  • Real Polar -> template webhook delivery (the sandbox token had no webhooks:write: deliveries were signed locally), npm run polar:webhook and npm run polar:check against Polar (the product comparison is unit-tested with the field shapes of a real product), refunds created at Polar.
  • A real local Supabase (supabase start): see above, the SQL ran on PGlite and the requests through a stand-in.
  • Anything live (production accounts, domain approval), renewals, trials, 3-D Secure.
  • The Unit tests workflow on GitHub.

Not in this PR

The template has no login page and no legal pages (a merchant of record's review needs Terms, Refund Policy and Privacy Policy on the live site: BILLING.md says what).

🤖 Generated with Claude Code

nullarch and others added 5 commits October 14, 2026 15:44
…er portal, Supabase tables with RLS

A clone configures src/config/billing.ts and runs three npm scripts (paddle:check, paddle:catalog,
paddle:webhook); the guide is PADDLE.md. A payment is credited through the Paddle customer the
server linked to the signed-in, e-mail-confirmed user, only for prices in .env; the webhook checks
the signature on the raw body, is idempotent and order-safe; no browser role can write billing rows.
100 unit tests (npm test, Node 22.18+) plus a sandbox end-to-end run recorded in the PR.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…_PROVIDER picks the provider of new checkouts, both webhooks stay live, getEntitlement ignores the provider

Same layout as the Firebase twin: provider-neutral src/lib/billing (types, store over PostgREST, delivery, entitlements, adapter interface) with one folder per provider. Migration: primary key (provider, id) on every table, nullable UNIQUE paddle_customer_id, polar_linked_at, guarded refund updates. A payment of a deleted user or a non-UUID external id is ignored instead of retried for days. PADDLE.md becomes BILLING.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
….', not a 404 (found against the real sandbox); tests for the Polar API client

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t names both webhooks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@nullarch nullarch changed the title Paddle billing: subscriptions and one-time purchases, webhook, portal, Supabase tables with RLS (verified end to end in the Paddle sandbox) Billing for Paddle and Polar: pick per project, switch later without touching existing subscribers Oct 14, 2026
@nullarch
nullarch marked this pull request as ready for review October 14, 2026 10:59
@nullarch
nullarch merged commit dea99fa into main Oct 14, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant