Repository navigation
Billing for Paddle and Polar: pick per project, switch later without touching existing subscribers - #1
Merged
Conversation
…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
marked this pull request as ready for review
October 14, 2026 10:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andgetEntitlement(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: Paddlenpm run paddle:catalog -- --write+npm run paddle:webhook -- <url> --write; Polar: create the products in the dashboard (npm run polar:cataloglists them), paste the ids,npm run polar:webhook -- <url> --write.npm run billing:checksays which provider takes new checkouts and what is missing. Guide:BILLING.md(replacesPADDLE.md).The structure
src/lib/billing/: provider-neutral (rows with aprovider, the Supabase store, idempotent delivery, entitlements, the adapter interface) + one folder per provider (paddle/,polar/: config,fetchAPI client, signature check, payload -> row mapping, sync, adapter). A third provider is one more folder.BILLING_PROVIDERsays; 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.409 already_subscribedto a new plan checkout.BILLING_PROVIDERempty, checkouts are refused rather than guessed.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; eventssubscription.*andorder.*(a refund is one growing adjustment per order), Standard Webhooks signature verified by hand (no@polar-sh/sdk).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.sqlreplaces20261014120000_paddle_billing.sqlfrom the first version of this PR (never merged, never released): primary key(provider, id)on every table,customer_idinstead ofpaddle_customer_id, nullable UNIQUEpaddle_customer_id+polar_linked_atonbilling_customers,event_occurred_aton refunds. If you already applied the old file to a database, rundb 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).authschema 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.BILLING_PROVIDER=polarwith 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 goespending_approval-> approved after 205 s with bothadjustmentwebhooks processed andrefunded= the payment.sandbox.polar.sh); real objects (subscriptions, orders with a 409 + 41 partial refund) delivered as webhooks signed with the referencestandardwebhookslibrary (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 answersno_customer.Not verified here (honest list)
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 with4242 4242 4242 4242, expect "You are all set" on/billing/success.webhooks:write: deliveries were signed locally),npm run polar:webhookandnpm run polar:checkagainst Polar (the product comparison is unit-tested with the field shapes of a real product), refunds created at Polar.supabase start): see above, the SQL ran on PGlite and the requests through a stand-in.Unit testsworkflow 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.mdsays what).🤖 Generated with Claude Code