diff --git a/.env.example b/.env.example index 30f913b..d0c100d 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,43 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key # Database (for Supabase CLI commands) SUPABASE_DB_PASSWORD=your_database_password -# Google Analytics -NEXT_PUBLIC_GAID=your_google_analytics_id +# Supabase service role key. SERVER ONLY: it bypasses Row Level Security. Needed by the payment webhooks and the /api/billing routes. +# Supabase dashboard > Project settings > API > service_role. Never prefix it with NEXT_PUBLIC_. +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key # ───────────────────────────────────────────────────────── -# E2E Testing (see .env.test for local Supabase settings) +# Billing (see BILLING.md). `npm run billing:check` says which provider takes new checkouts and what is still missing. # ───────────────────────────────────────────────────────── -# E2E_BASE_URL=http://localhost:3000 +# Which provider takes NEW checkouts: paddle | polar. Switching it never touches existing subscribers (BILLING.md, "Switching provider"). +# Leave it empty only while exactly one provider is configured. +BILLING_PROVIDER=paddle + +# ── Paddle: `npm run paddle:check` ── +# A short name for THIS app, unique on your Paddle account (lowercase, digits, - and _). Stamped on everything the app creates in Paddle. +PADDLE_APP_TAG=my-app +# sandbox | production. The API key must match: pdl_sdbx_... in the sandbox, pdl_live_... in production (anything else is refused). +NEXT_PUBLIC_PADDLE_ENVIRONMENT=sandbox +# Paddle > Developer tools > Authentication. The API key is SECRET and server only; the client-side token (test_... / live_...) is public. +PADDLE_API_KEY=pdl_sdbx_apikey_... +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_... +# The signing secret of the notification destination: `npm run paddle:webhook -- https:///api/webhooks/paddle --write`. +PADDLE_WEBHOOK_SECRET=pdl_ntfset_... +# One line per plan interval / product of src/config/billing.ts: `npm run paddle:catalog -- --write` creates them in Paddle and writes them here. +# PADDLE_PRICE_PRO_MONTH=pri_... +# PADDLE_PRICE_PRO_YEAR=pri_... +# PADDLE_PRICE_TEAM_MONTH=pri_... +# PADDLE_PRICE_TEAM_YEAR=pri_... + +# ── Polar: `npm run polar:check` ── +# sandbox (default) | production. Polar tokens look the same in both: this decides which server they are sent to. +# POLAR_SERVER=sandbox +# Settings > Developers > organization access token: scopes checkouts:write, customer_sessions:write, subscriptions:read, orders:read +# (+ products:read for polar:check, webhooks:read/write for polar:webhook: add them for the setup, take them off after). SECRET, server only. +# POLAR_ACCESS_TOKEN=polar_oat_... +# The signing secret of the webhook endpoint: `npm run polar:webhook -- https:///api/webhooks/polar --write`. +# POLAR_WEBHOOK_SECRET=... +# One line per plan interval / product of src/config/billing.ts: create the products in the Polar dashboard (`npm run polar:catalog` lists them) and paste the ids. +# POLAR_PRODUCT_PRO_MONTH=... +# POLAR_PRODUCT_PRO_YEAR=... +# POLAR_PRODUCT_TEAM_MONTH=... +# POLAR_PRODUCT_TEAM_YEAR=... diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..f1a1c51 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,35 @@ +name: Unit tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # The unit tests run the TypeScript directly (Node 22.18+ strips the types), so this job does not use the Node 20 of the e2e job. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test diff --git a/BILLING.md b/BILLING.md new file mode 100644 index 0000000..28639b0 --- /dev/null +++ b/BILLING.md @@ -0,0 +1,179 @@ +# Billing: Paddle and Polar + +Subscriptions and one-time purchases through a **merchant of record** (the provider is the seller: it charges the customer, calculates and pays the tax, issues the receipt and invoice, and handles refunds and chargebacks). The template speaks to **two** providers, [Paddle](https://developer.paddle.com) and [Polar](https://docs.polar.sh), through the same routes, the same pages and the same data. Next.js + Supabase, nothing to install: both APIs are called with `fetch`; Paddle.js is loaded from Paddle's CDN only when a Paddle checkout opens. + +**You choose per project, and you can change your mind later.** `BILLING_PROVIDER=paddle|polar` decides where **new** checkouts go. Changing it touches nobody who has already paid (see "Switching provider" below). The app runs without any of this configured: the pricing page says "not configured yet" and shows no buy button, and the API routes answer `503 billing_not_configured`. + +## Which one? + +| | Paddle | Polar | +|---|---|---| +| Customer | found by **e-mail**, shared by every app on your Paddle account | **your user id** (`external_customer_id`), one Polar organization per app | +| Checkout | overlay on your page (Paddle.js) | Polar's own checkout page (redirect) | +| Checkout needs | a **confirmed** e-mail (see "How it stays safe") | nothing special | +| Products | created for you: `npm run paddle:catalog` | created by hand in the dashboard (a checklist: `npm run polar:catalog`), then compared with the catalog: `npm run polar:check` | +| Review | domain approval + business verification before live payments | account review (business, identity, payout) before the first payout; a restricted category (AI generation, ...) may be refused: read its acceptable-use policy first | + +Both are Merchants of Record with their own acceptable-use rules and fees: check that your product is allowed **before** you build on one. If you are unsure which will accept you, set up the sandbox of both (it costs nothing), keep both webhooks registered, and decide with `BILLING_PROVIDER`. + +## What you get + +| | | +|---|---| +| `src/config/billing.ts` | **The one file you edit**: plans (monthly / yearly prices) and one-time products. The pricing page, the checkout, the webhooks and the setup scripts all read it; it is the same whichever provider you use. | +| `/pricing` | Public pricing page with a monthly / yearly switch. A buy button appears only for an item the active provider can sell (its id is in `.env`). | +| `/billing`, `/billing/success` | The signed-in user's plan and payments, a "Manage billing" button into the customer portal **of the provider that bills them**, and the page the provider returns to after paying. | +| `POST /api/billing/checkout` · `portal` · `sync` | Create a checkout at the active provider · open the portal of the user's provider · re-read the user's subscriptions and payments from every provider they have been to (heals a slow or lost webhook). | +| `POST /api/webhooks/paddle` · `/api/webhooks/polar` | The webhooks. **Both stay live whichever provider is active**, so a subscriber of the old provider keeps being updated. Signature checked, idempotent, ignores what is not yours. | +| `supabase/migrations/…_billing.sql` | `billing_customers`, `billing_subscriptions`, `billing_transactions`, `billing_adjustments`, `billing_webhook_events`, with Row Level Security: a user reads only their own rows, **nobody** writes from the browser. Every row says which `provider` it came from; the primary key is (`provider`, `id`). | +| `src/lib/billing/entitlements.ts` | `getEntitlement(supabase, userId)` and `hasPurchased(supabase, userId, key)`: what the rest of your app calls. **They do not care which provider bills the user.** | +| `src/lib/billing/{paddle,polar}/` | One adapter per provider: API client, signature check, payload -> row mapping, sync. `src/lib/billing/adapter.ts` is the interface (a third provider is one more folder). | +| `scripts/` | `npm run billing:check` (which provider takes new checkouts, is each one ready), `paddle:check · catalog · webhook`, `polar:check · catalog · webhook`. | + +## Set up Paddle (sandbox, about 15 minutes) + +1. **Database.** Apply the migration: `npm run db:reset` (local Supabase) or `npm run db:push` (remote). Put `SUPABASE_SERVICE_ROLE_KEY` in `.env.local` (server only; Supabase dashboard > Project settings > API). +2. **Your plans.** Edit `src/config/billing.ts`: names, features, prices. Keys are lowercase letters, digits and underscores (`pro`, `team_plus`): they go into the database and into env var names. +3. **A Paddle sandbox account** at (separate from the live account, with its own keys). Developer tools > Authentication: create an **API key** and a **client-side token**. Add to `.env.local`: + ``` + BILLING_PROVIDER=paddle + PADDLE_APP_TAG=my-app # short, unique per app on your Paddle account + NEXT_PUBLIC_PADDLE_ENVIRONMENT=sandbox + PADDLE_API_KEY=pdl_sdbx_apikey_... + NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_... + ``` +4. **Create the products and prices** in Paddle from your config, and write their ids into `.env.local`: `npm run paddle:catalog -- --write`. Run it again after you change a price or add a plan: what exists is reused, never duplicated. +5. **The webhook.** Paddle must reach your app from the internet. Locally, open a tunnel (`ngrok http 3000`), then `npm run paddle:webhook -- https:///api/webhooks/paddle --write`. It creates the notification destination and writes its signing secret to `.env.local`. Restart `npm run dev`. +6. **Check:** `npm run paddle:check`, aim for "Ready." +7. **Try it.** Sign in (the template has no login page: add yours; billing only needs a Supabase session), open `/pricing`, choose a plan and pay with a [test card](https://developer.paddle.com/concepts/payment-methods/credit-debit-card): `4242 4242 4242 4242` pays; `4000 0000 0000 0002` is declined; `4000 0038 0000 0446` asks for 3-D Secure. Any future expiry, any CVC. You land on `/billing/success`, then `/billing` shows the plan and the payment. + +In the sandbox, refunds are approved by Paddle after a few minutes (up to about ten); until then the refund is `pending_approval` and `billing_transactions.refunded` stays 0. + +## Set up Polar (sandbox, about 15 minutes) + +1. **Database and your plans**: steps 1 and 2 above. +2. **A Polar sandbox organization** at (separate from production). Make **one organization per app**: webhooks belong to an organization, so a shared one would also send your other apps' orders (with buyers' e-mails) here. +3. **Products.** `npm run polar:catalog` prints what to create: for each plan interval one **recurring** product (every 1 month / 1 year) with a **fixed** price, for each one-time product a **one-time** product. Create them in the dashboard, then put each id into `.env.local` (the script prints the variable names, e.g. `POLAR_PRODUCT_PRO_MONTH`). Do **not** create public checkout links for these products: the template creates every checkout itself so that it can tie it to the signed-in user. +4. **An access token** (Settings > Developers) with the scopes `checkouts:write`, `customer_sessions:write`, `subscriptions:read`, `orders:read`; add `products:read` for `polar:check` and `webhooks:read` + `webhooks:write` for `polar:webhook`, and take those off afterwards. Add to `.env.local`: + ``` + BILLING_PROVIDER=polar + POLAR_SERVER=sandbox # production for live + POLAR_ACCESS_TOKEN=polar_oat_... + POLAR_PRODUCT_PRO_MONTH=... # one line per item of src/config/billing.ts + ``` +5. **The webhook.** Open a tunnel, then `npm run polar:webhook -- https:///api/webhooks/polar --write`: it registers the endpoint (format **Raw**, the ten events the handler knows) and writes its signing secret to `.env.local`. Restart `npm run dev`. (Or create the endpoint by hand in the dashboard with the events listed in `scripts/polar-setup.mjs`.) +6. **Check:** `npm run polar:check`. With `products:read` it also compares every product with `src/config/billing.ts` (interval, price, currency): a merchant of record's review compares your site with what is charged, so a mismatch is worth knowing now. +7. **Try it.** Same as Paddle step 7; on Polar's page pay with `4242 4242 4242 4242`. You come back to `/billing/success`. + +## Switching provider + +`BILLING_PROVIDER` is read on every request: change it in your host's environment and redeploy (or restart). What that does, and does not do: + +| | | +|---|---| +| **New checkouts** | go to the provider `BILLING_PROVIDER` names, and only to it. | +| **Existing subscribers** | stay with the provider that bills them. Their subscription keeps renewing there, its webhooks keep updating `billing_subscriptions`, their portal link opens **that** provider's portal, and `getEntitlement` / `hasPurchased` answer the same as before. | +| **Double billing** | cannot happen through the template: a user with a running, past-due or paused subscription (at either provider) gets `409 already_subscribed`. They change plan or cancel in their portal; once it has ended they can subscribe again, now at the new provider. | +| **Both webhooks** | keep running. Keep the old provider's `*_WEBHOOK_SECRET` and API key in `.env` for as long as it has subscribers; without them its renewals and cancellations stop reaching you. | +| **What you can drop** | after the switch the old provider needs only its server side (API key, webhook secret; for Paddle also `PADDLE_APP_TAG`). Its price / product ids and, for Paddle, the browser token are not needed any more. | +| **Moving subscribers across** | is **not** automatic (there is no way to move a card between merchants). The honest options: leave them where they are (they stay until they cancel), or ask them to cancel at the end of the period and subscribe again. | + +A runbook for a switch that must not break anything: + +1. `npm run billing:check` with both providers' variables in the environment: both "ready", and it says which takes new checkouts. +2. Test the new provider end to end in its **sandbox** (steps 4-7 of its set-up) before touching production. +3. Complete the new provider's review (domain / account) and put its **live** keys, ids and webhook in the production environment, with `BILLING_PROVIDER` still on the old one: nothing changes for buyers yet. +4. Buy your smallest plan at the new provider with a real card **and refund it**, from a test account of yours (set `BILLING_PROVIDER` for one deploy, or use a preview deployment). +5. Set `BILLING_PROVIDER` to the new one in production. To go back, set it to the old one again; nothing is lost either way. + +If you set neither provider's variables for one of them, the template simply never uses it. If **both** are configured and `BILLING_PROVIDER` is empty, new checkouts are refused (`503 billing_not_configured`, the reason is in the server log) rather than guessed. + +## Use it in your app + +```ts +import { getEntitlement, hasPurchased } from "@/lib/billing/entitlements"; +import { createClient } from "@/utils/supabase/server"; + +const supabase = await createClient(); // carries the user's session: Row Level Security keeps other users' rows out +const { data: { user } } = await supabase!.auth.getUser(); +if (!user) redirect("/login"); + +const entitlement = await getEntitlement(supabase!, user.id); +if (!entitlement.active) redirect("/pricing"); // active = running subscription (active or in a trial) +if (entitlement.planKey === "team") { /* team-only feature */ } +entitlement.cancelAtPeriodEnd; // the user cancelled: still active until currentPeriodEnd +entitlement.pastDue; // the last payment failed and the provider is retrying: YOU decide whether to keep access +entitlement.provider; // "paddle" | "polar": who bills it (you rarely need this) + +await hasPurchased(supabase!, user.id, "credits_100"); // a one-time product from billingConfig.products, whichever provider took the payment +``` + +What a plan *allows* is your code, keyed on `planKey`. To grant something when a payment arrives (credits, a licence), add it where the webhook records the payment: `applyTransaction` in `src/lib/billing/paddle/webhook.ts` and `applyPolarOrder` in `src/lib/billing/polar/webhook.ts` (it runs once per payment; `billing_transactions` is your ledger). + +The data model, if you read it from the browser: Row Level Security lets a user read only the rows with their own `user_id`; `billing_webhook_events` is not readable from the browser at all. Amounts are the provider's minor units (cents). A Polar order's refund is kept as one adjustment per order (`refund_`) that only grows. + +## How it stays safe + +- **A payment is credited through what the SERVER linked, never through anything the browser sends.** + Paddle: before a checkout opens, the server finds or creates the Paddle customer for the signed-in user's e-mail and links it (`billing_customers.paddle_customer_id`, UNIQUE: a Paddle customer can never belong to two users); a payment from a customer the server never linked is ignored. + Polar: the server creates every checkout with `external_customer_id = `, and a payload is credited to that id only (not to its metadata, not to its e-mail); one without it is ignored. +- **Paddle: a checkout needs a verified e-mail address** (`403 email_not_verified` otherwise). A Paddle customer is found by e-mail and is shared by every app on the account, so linking one to an address nobody confirmed would let a stranger who signs up with it open that customer's portal. Keep e-mail confirmation on in Supabase (Authentication > Providers > Email), or sign users in with OAuth, which confirms the address. Polar does not need this: its customer is the user id. +- **Only your products count.** An event whose price (Paddle) or product (Polar) is not one of the ids in your environment is ignored (it belongs to another app). Paddle sends every destination the events of the whole account; for that reason Paddle also needs your `PADDLE_APP_TAG`. +- **The server creates the checkout.** The browser names *what* to buy (`pro` + `month`, never an id, a price, a customer or a provider); the id, the quantity (1) and the customer are fixed by `/api/billing/checkout`. +- **Signatures are checked on the raw body.** Paddle: `Paddle-Signature`, several `h1` values accepted (secret rotation), five-minute tolerance. Polar: [Standard Webhooks](https://www.standardwebhooks.com) (`webhook-id`, `webhook-timestamp`, `webhook-signature`), both of Polar's key schemes, five-minute tolerance. No secret configured means the webhook refuses, never accepts. +- **Idempotent and order-safe.** A delivery is claimed once per provider (the primary key (`provider`, `event_id`) refuses the second); a failed one is retried by the provider and taken over again with one conditional update; an older subscription state, and an older refund state, never overwrite a newer one (one conditional `UPDATE ... WHERE event_occurred_at <= new`, comparing instants). +- **The browser only follows an `https://` checkout URL** from the server, never something that would run in the page. +- **A key of the wrong environment is refused** (Paddle: a sandbox key with `NEXT_PUBLIC_PADDLE_ENVIRONMENT=production`, or the reverse). Polar defaults to its sandbox unless `POLAR_SERVER=production` exactly. +- **Row Level Security**: users read only their own billing rows; no browser role can write any of them (there is no write policy, and the privileges are revoked as well); the webhook log is closed to the browser. Writes use the service role, on the server only (`src/utils/supabase/admin.ts` imports `server-only`). + +If several apps share one Paddle account (the usual case when you clone the template), give each its own `PADDLE_APP_TAG` and its own notification destination: their events reach each other's webhooks and are ignored. + +## Deleting a user + +Deleting a Supabase user removes their `billing_*` rows (cascade) but **not their subscription**: the provider keeps charging. Cancel it first, from your account-deletion code (Paddle: `POST /subscriptions/{id}/cancel` with `{ "effective_from": "immediately" }`; Polar: `DELETE /v1/subscriptions/{id}`, which needs the `subscriptions:write` scope), or send the user to "Manage billing" before they delete the account. `getEntitlement(supabase, userId)` returns the `provider` and the `subscriptionId`: they tell you which call. The cascade also deletes the payment rows: if you need them for accounting, export them first or change the foreign keys to `on delete set null`. + +## Go live + +**Paddle** + +1. Paddle verifies you (identity and business), then the **domain you sell on must be approved** (Paddle > Checkout > Website approval). Until then the checkout does not open on that domain. What the review reads on the live site (most are approved automatically; a manual review takes about 5-7 business days): a clear description of the product, its key features and **pricing** (`/pricing` is made for this), **Terms of Service, Refund Policy and Privacy Policy** reachable from the navigation or footer with your company's name in the Terms, HTTPS, a live site, nothing that reads as a pre-sale. Check Paddle's [Acceptable Use Policy](https://www.paddle.com/help/start/intro-to-paddle/what-am-i-not-allowed-to-sell-on-paddle) too. +2. Create live credentials (a `pdl_live_` API key, a `live_` client-side token) and keep them in a separate file, not in `.env.local`: + ```bash + # .env.production.local: PADDLE_API_KEY=pdl_live_..., NEXT_PUBLIC_PADDLE_ENVIRONMENT=production, NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=live_..., PADDLE_APP_TAG=my-app + node --no-warnings --env-file=.env.production.local scripts/paddle-setup.mjs catalog --live --yes --write --out=.env.production.local + node --no-warnings --env-file=.env.production.local scripts/paddle-setup.mjs webhook-create https://yourdomain.com/api/webhooks/paddle --live --yes --write --out=.env.production.local + ``` + On the live account every command that changes something needs `--live --yes`, and the webhook destination is created **inactive**; activate it after the deploy: `... webhook-activate --live --yes`. +3. Push the migration to the production project (`npm run db:push`), set the environment on your host (everything in `.env.production.local` plus the Supabase variables and `SUPABASE_SERVICE_ROLE_KEY`), deploy. `https://yourdomain.com/api/webhooks/paddle` must answer `401` to an unsigned request. +4. Buy your smallest plan with a real card and refund it from the Paddle dashboard; check `/billing` and the `billing_*` rows. + +**Polar** + +1. Create the **production** organization at and submit its account review (business details, identity, payout account). Read the [acceptable-use policy](https://polar.sh/docs/merchant-of-record/acceptable-use) first: a restricted category can be refused at onboarding, and the product description must be the true one. Public pages linked from the footer: pricing, Terms, Privacy, Refund & cancellation, contact; prices on the site must equal the Polar products (`npm run polar:check`). +2. In production: create the products (`npm run polar:catalog` with `POLAR_SERVER=production` lists them), a production access token, and the webhook (`node --no-warnings --env-file=.env.production.local scripts/polar-setup.mjs webhook-create https://yourdomain.com/api/webhooks/polar --live --yes --write --out=.env.production.local`). Environment on your host: `POLAR_SERVER=production`, the token, the secret, the `POLAR_PRODUCT_*` ids. +3. `https://yourdomain.com/api/webhooks/polar` must answer `401` to an unsigned request. +4. After the first real sale request a small payout right away: the full review happens before the first payout, so find problems while the balance is small. + +## Troubleshooting + +| You see | Cause | +|---|---| +| `503 billing_not_configured` (checkout, portal, webhook) | `npm run billing:check` says which provider is meant and what is missing. The exact reason is in the server log (`[billing] ...`, `[paddle] ...`, `[polar] ...`). | +| `503 billing_not_configured` although one provider works | Both are configured and `BILLING_PROVIDER` is empty, or it names a provider that is not fully configured. | +| `503 supabase_admin_not_configured` | `SUPABASE_SERVICE_ROLE_KEY` (or the Supabase URL) is not set: the webhooks and the checkout need to write. | +| A webhook answers `401 invalid_signature` | The `*_WEBHOOK_SECRET` is not the secret of the endpoint the provider is calling (each endpoint has its own), or the app was not restarted after you changed it. | +| A provider shows deliveries failing with `500` | Read the server log; providers retry for days. Fix the cause and the deliveries are applied. `billing_webhook_events` keeps the payload and the error. | +| A webhook answers `200` but nothing appears | The event was `ignored`: see its `note` in `billing_webhook_events` (`unknown_customer`, `no_customer`, `not_our_price` / `not_our_product`, `other_app`, `unhandled_event_type`). | +| `/billing/success` waits a minute, then says it is slow | The webhook has not arrived. Is the endpoint active, its URL reachable, the tunnel up? The page asks the server to sync, so a paid plan still appears. | +| `403 email_not_verified` | Paddle only: the user's e-mail address is not confirmed in Supabase. | +| `409 already_subscribed` | One subscription at a time, at either provider: changing plan or cancelling goes through "Manage billing". | +| `409 customer_conflict` | Paddle only: two accounts in your database share one e-mail address, and the Paddle customer is already linked to the other one (the UNIQUE constraint on `paddle_customer_id` said no). | +| "Manage billing" opens the old provider | Correct: that is where this user's subscription lives. | +| The Paddle overlay does not open on your live domain | The domain is not approved yet. | +| A button says "Not available yet." | The active provider has no id for that item in `.env` (`npm run paddle:catalog -- --write`, or paste the Polar product id). | + +## Tests + +- `npm test` runs the unit tests (Node 22.18+ is needed: they run the TypeScript directly): both signatures (Polar's is cross-checked against the reference `standardwebhooks` library when it is installed), the configuration rules, the provider choice, the checkout request, every webhook flow of both providers (subscription lifecycle, payments, refunds, duplicates, out-of-order, failures and redelivery), the sync, the Supabase store and customer links over an in-memory fake, the route handlers of **both providers, including a switch under existing subscribers**, the checkout button's decision, the pricing table, and that the scripts repeat the library's rules faithfully. +- The fake does not run SQL. The migration was applied to a real Postgres engine ([PGlite](https://pglite.dev)) with a stub of Supabase's `auth` schema and roles when this template was built: a user reads only their own rows (whichever provider they come from), no browser role can write, the webhook log is invisible to the browser, the constraints hold (provider check, composite keys, one Paddle customer per user, many users without one) and the guarded updates behave. Repeat it after you change the migration (`npm run db:reset`, then the same checks by hand against your local Supabase). +- `npm run typecheck` and `npm run lint` as usual. diff --git a/CLAUDE.md b/CLAUDE.md index 5c4eae2..27de881 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,22 @@ Required environment variables (create `.env.local`): - `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Supabase anonymous key - `NEXT_PUBLIC_GAID` - Google Analytics ID - `SUPABASE_DB_PASSWORD` - Database password (for Supabase CLI commands) +- `SUPABASE_SERVICE_ROLE_KEY` - Server only, bypasses RLS (payment webhooks and billing routes) +- Billing: `BILLING_PROVIDER` (`paddle` | `polar`); Paddle (`PADDLE_APP_TAG`, `NEXT_PUBLIC_PADDLE_ENVIRONMENT`, `PADDLE_API_KEY`, `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN`, `PADDLE_WEBHOOK_SECRET`, `PADDLE_PRICE_*`); Polar (`POLAR_SERVER`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_PRODUCT_*`) - see `BILLING.md` and `.env.example` + +## Billing (Paddle or Polar) + +Subscriptions and one-time purchases through a merchant of record, **Paddle or Polar**, chosen with `BILLING_PROVIDER` (which only decides where NEW checkouts go: both webhooks stay live and existing subscribers stay with their provider). Full guide: [BILLING.md](./BILLING.md). Run `npm run billing:check` to see what is configured. + +- **`src/config/billing.ts` is the single source** for plans, products and prices (pricing page, checkout, webhooks and the catalog scripts all read it), the same for both providers. Provider ids are NOT in code: `PADDLE_PRICE__` and `POLAR_PRODUCT__` env vars. +- Gate features with `getEntitlement(supabase, userId)` / `hasPurchased(supabase, userId, key)` from `@/lib/billing/entitlements` (a Supabase client that carries the user's session); never call Paddle or Polar from feature code, and never branch on the provider to decide access. +- **Server code** in `src/lib/billing/`: provider-neutral (`types.ts`, `catalog.ts`, `provider.ts` which picks the provider of new checkouts, `adapter.ts` the interface and `adapters.ts` the registry, `delivery.ts` idempotent webhook processing, `store.ts` the Supabase `BillingStore` (service role), `customers.ts`, `checkout.ts`, `entitlements.ts`, `route-helpers.ts`) and one folder per provider (`paddle/`, `polar/`: `config.ts`, `api.ts` REST via `fetch`, `signature.ts`, `webhook.ts` pure payload -> row logic, `sync.ts`, `adapter.ts`). To add a provider, write its folder against `adapter.ts` and map its payloads to the rows of `types.ts`. +- **Routes**: `POST /api/webhooks/paddle`, `/api/webhooks/polar`, `/api/billing/checkout`, `/api/billing/portal`, `/api/billing/sync` (the three `billing` routes are the same for both providers: they dispatch through the adapter). Pages: `/pricing`, `/billing`, `/billing/success`. UI in `src/components/billing/`. +- **Tables** (migration `…_billing.sql`): `billing_customers`, `billing_subscriptions`, `billing_transactions`, `billing_adjustments`, `billing_webhook_events`; primary key (`provider`, `id`). RLS: a user reads only their own rows; **all writes are service role, server side**. Never add a write policy for `authenticated`. +- **Rules that must not be weakened**: a payment is credited through what the server linked (the Paddle customer it linked, or the user id it set as the Polar external customer id), never custom data, metadata or anything from the browser; only ids in `.env` count; the server creates the checkout (the browser only names plan + interval, never a provider or an id); a Paddle checkout needs a confirmed e-mail; a webhook verifies the signature on the raw body and refuses when no secret is set; events are idempotent per provider and an older subscription or refund state never overwrites a newer one; a user with a running subscription at either provider cannot start another (a switch must never bill twice); both webhooks keep working whatever `BILLING_PROVIDER` says. +- **Client modules**: a function exported from a `"use client"` file cannot be called from a server component (it fails at request time, not at build). Helpers used on both sides live in `src/components/billing/format.ts`; `tests/billing-ui.test.ts` enforces it. +- Errors from the billing routes are stable codes (`already_subscribed`, `price_not_configured`, ...), never display text; texts are in `src/components/billing/messages.ts`. +- `npm test` (Node 22.18+) runs the unit tests in `tests/`; `npm run typecheck`; `npm run lint`. ## Key Implementation Details diff --git a/README.md b/README.md index e215bc4..5a3440c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,26 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Billing (Paddle or Polar) + +Subscriptions and one-time purchases are built in, through **Paddle or Polar** (merchants of record): `/pricing`, `/billing`, signed webhooks, tables with Row Level Security, and a `getEntitlement(supabase, userId)` helper for the rest of your app. You pick the provider per project with `BILLING_PROVIDER=paddle|polar`, and you can switch later without touching anybody who has already paid. It stays off until you configure it. + +```bash +# 1. edit your plans in src/config/billing.ts, apply the migration (npm run db:reset), fill .env.local from .env.example +# Paddle: +npm run paddle:catalog -- --write # creates the products and prices in Paddle (sandbox) from that file +npm run paddle:webhook -- https:///api/webhooks/paddle --write +npm run paddle:check # what is configured, what is missing +# Polar: +npm run polar:catalog # lists the products to create in the Polar dashboard; paste their ids into .env.local +npm run polar:webhook -- https:///api/webhooks/polar --write +npm run polar:check # what is configured, what is missing, and whether each product matches the file +# Both: +npm run billing:check # which provider takes new checkouts, and is each one ready +``` + +The full guide (choosing, switching, the go-live checklists, troubleshooting) is in [BILLING.md](./BILLING.md). + ## Learn More To learn more about Next.js, take a look at the following resources: diff --git a/package.json b/package.json index d208955..4c46964 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,15 @@ "build": "next build", "start": "next start", "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "node --no-warnings --experimental-test-module-mocks --import ./tests/support/setup.mjs --test \"tests/**/*.test.ts\"", + "billing:check": "node --no-warnings --env-file-if-exists=.env.local scripts/billing-check.mjs", + "paddle:check": "node --no-warnings --env-file=.env.local scripts/paddle-setup.mjs check", + "paddle:catalog": "node --no-warnings --env-file=.env.local scripts/paddle-setup.mjs catalog", + "paddle:webhook": "node --no-warnings --env-file=.env.local scripts/paddle-setup.mjs webhook-create", + "polar:check": "node --no-warnings --env-file=.env.local scripts/polar-setup.mjs check", + "polar:catalog": "node --no-warnings --env-file-if-exists=.env.local scripts/polar-setup.mjs catalog", + "polar:webhook": "node --no-warnings --env-file=.env.local scripts/polar-setup.mjs webhook-create", "supabase:start": "supabase start", "supabase:stop": "supabase stop", "supabase:status": "supabase status", diff --git a/scripts/billing-check.mjs b/scripts/billing-check.mjs new file mode 100644 index 0000000..7e92578 --- /dev/null +++ b/scripts/billing-check.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Which payment provider takes NEW checkouts, and is each provider ready? No network, no changes. Run through npm (it loads .env.local): +// +// npm run billing:check +// +// For the details of one provider run `npm run paddle:check` or `npm run polar:check`. The rules below are the ones of +// src/lib/billing/provider.ts (tests/billing-check-script.test.ts keeps the two in step). +const env = process.env; +const wanted = (env.BILLING_PROVIDER ?? "").trim().toLowerCase(); + +function paddleProblem() { + const key = (env.PADDLE_API_KEY ?? "").trim(); + if (!key) return "PADDLE_API_KEY is not set"; + const production = env.NEXT_PUBLIC_PADDLE_ENVIRONMENT === "production"; + if (production && !key.startsWith("pdl_live_")) return "NEXT_PUBLIC_PADDLE_ENVIRONMENT is production but PADDLE_API_KEY is not a live key (pdl_live_...)"; + if (!production && !key.startsWith("pdl_sdbx_")) return "NEXT_PUBLIC_PADDLE_ENVIRONMENT is sandbox (the default) but PADDLE_API_KEY is not a sandbox key (pdl_sdbx_...)"; + if (!/^[a-z0-9][a-z0-9_-]{1,40}$/.test((env.PADDLE_APP_TAG ?? "").trim())) return "PADDLE_APP_TAG must be a short unique name for this app (lowercase letters, digits, - and _)"; + return null; +} +const paddleCheckoutProblem = () => paddleProblem() ?? ((env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "").trim() ? null : "NEXT_PUBLIC_PADDLE_CLIENT_TOKEN is not set"); +const polarProblem = () => ((env.POLAR_ACCESS_TOKEN ?? "").trim() ? null : "POLAR_ACCESS_TOKEN is not set"); + +const providers = [ + { name: "paddle", label: "Paddle", checkout: paddleCheckoutProblem(), server: paddleProblem(), webhook: (env.PADDLE_WEBHOOK_SECRET ?? "").trim().length > 0 }, + { name: "polar", label: "Polar", checkout: polarProblem(), server: polarProblem(), webhook: (env.POLAR_WEBHOOK_SECRET ?? "").trim().length > 0 }, +]; + +for (const provider of providers) { + if (provider.checkout === null) console.log(`✔ ${provider.label}: ready${provider.webhook ? "" : " (no webhook secret yet: payments cannot be recorded until `npm run " + provider.name + ":webhook` has run)"}`); + else if (provider.server === null) console.log(`• ${provider.label}: can serve its existing subscribers (portal, sync, webhooks) but cannot take a new checkout: ${provider.checkout}`); + else console.log(`✖ ${provider.label}: not configured: ${provider.checkout}`); +} + +let choice = null; +let reason = null; +if (wanted) { + const provider = providers.find((candidate) => candidate.name === wanted); + if (!provider) reason = `BILLING_PROVIDER must be "paddle" or "polar", not "${env.BILLING_PROVIDER}"`; + else if (provider.checkout) reason = `BILLING_PROVIDER is ${wanted} but ${provider.checkout}`; + else choice = wanted; +} else { + const ready = providers.filter((provider) => provider.checkout === null); + if (ready.length === 1) choice = ready[0].name; + else if (ready.length === 0) reason = "no payment provider is configured (set up Paddle or Polar, see BILLING.md)"; + else reason = "both Paddle and Polar are configured: set BILLING_PROVIDER to the one that should take new checkouts"; +} + +console.log(choice ? `\nNew checkouts: ${choice}${wanted ? "" : " (the only one configured; set BILLING_PROVIDER to be explicit)"}` : `\nNew checkouts: none. ${reason}`); +if (choice) { + const other = providers.find((provider) => provider.name !== choice); + if (other.server === null) console.log(`Existing ${other.label} subscribers keep working: the ${other.label} webhook, portal and sync stay active while its keys are in .env.`); +} +process.exit(choice ? 0 : 1); diff --git a/scripts/paddle-setup.mjs b/scripts/paddle-setup.mjs new file mode 100644 index 0000000..f04e1ef --- /dev/null +++ b/scripts/paddle-setup.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +// One-time Paddle setup, driven by src/config/billing.ts. Run through npm (they load .env.local): +// +// npm run paddle:check what is configured and what is still missing (no changes anywhere) +// npm run paddle:catalog create a product per plan / product and a price per interval in Paddle, print the PADDLE_PRICE_* lines +// npm run paddle:catalog -- --write ...and put them into .env.local +// npm run paddle:webhook -- https:///api/webhooks/paddle [--write] [--inactive] +// create the notification destination (it needs a PUBLIC url: your deployed site, or an ngrok tunnel) +// node --env-file=.env.local scripts/paddle-setup.mjs webhook-activate | webhook-list | webhook-delete +// +// Safe to run again: a product or price that already exists (found by the PADDLE_APP_TAG stamped on it) is reused, never duplicated, and +// nothing is ever deleted except by `webhook-delete`. The sandbox needs nothing special. On the LIVE account (a `pdl_live_` key with +// NEXT_PUBLIC_PADDLE_ENVIRONMENT=production) every command that changes something (`catalog`, `webhook-create`, `webhook-activate`, +// `webhook-delete`) additionally requires `--live --yes`, and `webhook-create` creates the destination INACTIVE (activate it after the deploy +// with `webhook-activate`), so nothing is sent to a site that is not up yet. Keep the live key in its own file, never in .env.local: +// node --no-warnings --env-file=.env.production.local scripts/paddle-setup.mjs catalog --live --yes --write --out=.env.production.local +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((arg) => arg.startsWith("--"))); +const [command, argument] = argv.filter((arg) => !arg.startsWith("--")); + +const KEY = (process.env.PADDLE_API_KEY ?? "").trim(); +const ENVIRONMENT = process.env.NEXT_PUBLIC_PADDLE_ENVIRONMENT === "production" ? "production" : "sandbox"; +const TAG = (process.env.PADDLE_APP_TAG ?? "").trim(); +/** Where `--write` puts what it creates. `--out=` lets a LIVE run write into its own file instead of .env.local. */ +const ENV_FILE = argv.find((arg) => arg.startsWith("--out="))?.slice(6) ?? ".env.local"; + +const EVENTS = [ + "transaction.completed", + "adjustment.created", + "adjustment.updated", + "subscription.created", + "subscription.activated", + "subscription.updated", + "subscription.canceled", + "subscription.past_due", + "subscription.paused", + "subscription.resumed", +]; + +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +const { billingConfig, catalogItems, priceEnvName, toMinorUnits, validateBillingConfig } = await import("../src/config/billing.ts"); + +// ── check: no network, no changes ──────────────────────────────────────────────────────────────────── +function check() { + const problems = validateBillingConfig(); + const rows = []; + const row = (name, ok, hint) => rows.push({ name, ok, hint }); + row("src/config/billing.ts is valid", problems.length === 0, problems.join("; ")); + row("PADDLE_APP_TAG", /^[a-z0-9][a-z0-9_-]{1,40}$/.test(TAG), "a short unique name for THIS app, e.g. my-app (lowercase, digits, - _)"); + const keyOk = ENVIRONMENT === "production" ? KEY.startsWith("pdl_live_") : KEY.startsWith("pdl_sdbx_"); + row(`PADDLE_API_KEY (${ENVIRONMENT})`, keyOk, ENVIRONMENT === "production" ? "a live key (pdl_live_...) is needed when NEXT_PUBLIC_PADDLE_ENVIRONMENT=production" : "a sandbox key (pdl_sdbx_...); Paddle sandbox > Developer tools > Authentication"); + const token = (process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "").trim(); + row("NEXT_PUBLIC_PADDLE_CLIENT_TOKEN", ENVIRONMENT === "production" ? token.startsWith("live_") : token.startsWith("test_"), "a client-side token (test_... in the sandbox, live_... live); Developer tools > Authentication"); + row("PADDLE_WEBHOOK_SECRET", (process.env.PADDLE_WEBHOOK_SECRET ?? "").startsWith("pdl_ntfset_"), "a notification destination's secret key (pdl_ntfset_...): run `npm run paddle:webhook -- --write`, or copy it from the dashboard"); + row("SUPABASE_SERVICE_ROLE_KEY", Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY), "Supabase > Project settings > API > service_role (server only)"); + for (const item of catalogItems()) { + const name = priceEnvName(item); + row(name, Boolean(process.env[name]?.trim()), "run `npm run paddle:catalog -- --write`"); + } + for (const { name, ok, hint } of rows) console.log(`${ok ? "✔" : "✖"} ${name}${ok ? "" : ` <- ${hint}`}`); + const missing = rows.filter((r) => !r.ok).length; + console.log(missing === 0 ? "\nReady." : `\n${missing} thing(s) to do. See BILLING.md.`); + process.exit(missing === 0 ? 0 : 1); +} + +if (command === "check") check(); + +// ── everything below talks to Paddle ───────────────────────────────────────────────────────────────── +if (!command || !["catalog", "webhook-create", "webhook-activate", "webhook-list", "webhook-delete"].includes(command)) { + fail("usage: check | catalog [--write] | webhook-create [--write] [--inactive] | webhook-activate | webhook-list | webhook-delete "); +} +if (!KEY) fail("PADDLE_API_KEY is not set (run through npm, which loads .env.local)."); +const live = KEY.startsWith("pdl_live_"); +if (!live && !KEY.startsWith("pdl_sdbx_")) fail("PADDLE_API_KEY is neither a sandbox (pdl_sdbx_...) nor a live (pdl_live_...) key."); +if (live !== (ENVIRONMENT === "production")) { + fail(`refusing: the key is ${live ? "LIVE" : "sandbox"} but NEXT_PUBLIC_PADDLE_ENVIRONMENT is ${ENVIRONMENT}. They must agree.`); +} +if (live && ["catalog", "webhook-create", "webhook-activate", "webhook-delete"].includes(command) && !(flags.has("--live") && flags.has("--yes"))) { + fail(`refusing: this is the LIVE Paddle account. Add --live --yes to ${command} if you mean it (nothing is charged: it creates or changes products, prices and notification destinations).`); +} +if (!/^[a-z0-9][a-z0-9_-]{1,40}$/.test(TAG) && ["catalog", "webhook-create"].includes(command)) { + fail("PADDLE_APP_TAG must be set to a short unique name for this app (lowercase letters, digits, - and _)."); +} + +const BASE = live ? "https://api.paddle.com" : "https://sandbox-api.paddle.com"; + +async function api(path, method = "GET", body) { + const response = await fetch(`${BASE}${path}`, { + method, + headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "Paddle-Version": "1" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = json.error ? `${json.error.code}: ${json.error.detail}` : response.statusText; + const fields = json.error?.errors ? ` ${JSON.stringify(json.error.errors)}` : ""; + throw new Error(`${method} ${path} -> ${response.status} ${detail}${fields}`); + } + return json; +} + +async function list(path) { + const out = []; + let next = `${path}${path.includes("?") ? "&" : "?"}per_page=200`; + while (next) { + const page = await api(next); + out.push(...page.data); + const url = page.meta?.pagination?.has_more ? page.meta.pagination.next : null; + next = url ? url.replace(BASE, "") : null; + } + return out; +} + +/** Merge KEY=value lines into .env.local, replacing a line of the same name and keeping the rest. */ +function writeEnv(entries) { + for (const [name, value] of Object.entries(entries)) { + if (typeof value !== "string" || value.length === 0 || value === "undefined") fail(`refusing to write ${name}=${String(value)} into ${ENV_FILE}`); + } + const lines = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8").split("\n").filter((line) => line.length > 0) : []; + const names = new Set(Object.keys(entries)); + const kept = lines.filter((line) => !names.has(line.split("=")[0])); + for (const [name, value] of Object.entries(entries)) kept.push(`${name}=${value}`); + writeFileSync(ENV_FILE, `${kept.join("\n")}\n`); +} + +if (command === "catalog") { + const problems = validateBillingConfig(); + if (problems.length > 0) fail(`src/config/billing.ts has problems:\n- ${problems.join("\n- ")}`); + const tax = argv.find((arg) => arg.startsWith("--tax="))?.slice(6) ?? "standard"; + const currency = billingConfig.currency; + + const products = await list("/products?status=active"); + const prices = await list("/prices?status=active"); + const entries = {}; + const productFor = new Map(); + + for (const item of catalogItems()) { + const productKey = `${item.kind}:${item.key}`; + let product = productFor.get(productKey) ?? products.find((p) => p.custom_data?.app === TAG && p.custom_data?.kind === item.kind && p.custom_data?.key === item.key); + if (!product) { + product = (await api("/products", "POST", { name: item.name, description: item.description, tax_category: tax, custom_data: { app: TAG, kind: item.kind, key: item.key } })).data; + console.log(`created product ${item.name} (${product.id})`); + } + productFor.set(productKey, product); + + const amount = toMinorUnits(item.amount); + let price = prices.find( + (p) => + p.product_id === product.id && + p.unit_price.amount === amount && + p.unit_price.currency_code === currency && + (item.interval ? p.billing_cycle?.interval === item.interval && p.billing_cycle?.frequency === 1 : !p.billing_cycle), + ); + if (!price) { + price = ( + await api("/prices", "POST", { + product_id: product.id, + description: item.interval ? `${item.name} (${item.interval === "month" ? "monthly" : "yearly"})` : item.name, + unit_price: { amount, currency_code: currency }, + tax_mode: "account_setting", + quantity: { minimum: 1, maximum: 1 }, + custom_data: { app: TAG, kind: item.kind, key: item.key, interval: item.interval }, + ...(item.interval ? { billing_cycle: { interval: item.interval, frequency: 1 } } : {}), + }) + ).data; + console.log(`created price ${item.name} ${item.interval ?? "one-time"} ${item.amount} ${currency} (${price.id})`); + } + entries[priceEnvName(item)] = price.id; + } + + console.log("\n# Paddle price ids (not secret):"); + for (const [name, value] of Object.entries(entries)) console.log(`${name}=${value}`); + if (flags.has("--write")) { + writeEnv(entries); + console.log(`\nwrote ${Object.keys(entries).length} line(s) to ${ENV_FILE}`); + } else { + console.log(`\nAdd them to ${ENV_FILE} (or run again with --write).`); + } +} else if (command === "webhook-create") { + const url = argument; + if (!url || !/^https:\/\/[^/\s]+\/api\/webhooks\/paddle$/.test(url)) fail("usage: webhook-create https:///api/webhooks/paddle (a public https URL; use an ngrok tunnel for local development)"); + const active = !(flags.has("--inactive") || live); + const created = ( + await api("/notification-settings", "POST", { + description: `${TAG} webhook`, + destination: url, + type: "url", + active, + include_sensitive_fields: false, + subscribed_events: EVENTS, + }) + ).data; + console.log(`created notification destination ${created.id} -> ${url} (${active ? "active" : "INACTIVE: run webhook-activate " + created.id + " after the deploy"})`); + // Paddle names the signing secret `endpoint_secret_key`. Never write anything else into .env.local: a secret of "undefined" verifies nothing. + const secret = created.endpoint_secret_key; + if (typeof secret !== "string" || !secret.startsWith("pdl_ntfset_")) { + fail(`Paddle did not return a signing secret for ${created.id}. Open it in the Paddle dashboard (Developer tools > Notifications) and copy the secret key into PADDLE_WEBHOOK_SECRET.`); + } + if (flags.has("--write")) { + writeEnv({ PADDLE_WEBHOOK_SECRET: secret }); + console.log(`wrote PADDLE_WEBHOOK_SECRET to ${ENV_FILE}`); + } else { + console.log("\nIts signing secret (keep it private) - set it as PADDLE_WEBHOOK_SECRET:"); + console.log(`PADDLE_WEBHOOK_SECRET=${secret}`); + } +} else if (command === "webhook-activate") { + if (!argument) fail("usage: webhook-activate "); + await api(`/notification-settings/${encodeURIComponent(argument)}`, "PATCH", { active: true }); + console.log(`activated ${argument}`); +} else if (command === "webhook-list") { + for (const setting of (await api("/notification-settings")).data) { + console.log(`${setting.id} ${setting.active ? "active " : "inactive"} ${setting.destination} (${setting.subscribed_events?.length ?? 0} events)`); + } +} else if (command === "webhook-delete") { + if (!argument) fail("usage: webhook-delete "); + await api(`/notification-settings/${encodeURIComponent(argument)}`, "DELETE"); + console.log(`deleted ${argument}`); +} diff --git a/scripts/polar-setup.mjs b/scripts/polar-setup.mjs new file mode 100644 index 0000000..5fac9c7 --- /dev/null +++ b/scripts/polar-setup.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// One-time Polar setup, driven by src/config/billing.ts. Run through npm (they load .env.local): +// +// npm run polar:check what is configured and what is still missing; when the token can read products, also whether each +// product matches the catalog (interval, price, currency) +// npm run polar:catalog print the products to create in the Polar dashboard, and the POLAR_PRODUCT_* lines to fill in +// npm run polar:webhook -- https:///api/webhooks/polar [--write] +// register the webhook endpoint (it needs a PUBLIC url: your deployed site, or a tunnel) and, with +// --write, put its signing secret into .env.local +// node --env-file=.env.local scripts/polar-setup.mjs webhook-list | webhook-delete +// +// Why the catalog is a checklist and not an API call: Polar products are made in the dashboard (a couple of minutes for a handful), and the +// check afterwards compares each one with src/config/billing.ts, which is what a merchant of record's review does too. +// The access token needs: checkouts:write, customer_sessions:write, subscriptions:read, orders:read. This script additionally uses +// products:read (check) and webhooks:read + webhooks:write (webhook-*): add those scopes for the setup and take them off again. +// POLAR_SERVER=sandbox (the default) talks to sandbox.polar.sh, a separate account and data set from production. `webhook-create` against +// production needs --live --yes. +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((arg) => arg.startsWith("--"))); +const [command, argument] = argv.filter((arg) => !arg.startsWith("--")); + +const TOKEN = (process.env.POLAR_ACCESS_TOKEN ?? "").trim(); +const SERVER = process.env.POLAR_SERVER === "production" ? "production" : "sandbox"; +const BASE = SERVER === "production" ? "https://api.polar.sh" : "https://sandbox-api.polar.sh"; +/** Where `--write` puts what it creates. `--out=` lets a LIVE run write into its own file instead of .env.local. */ +const ENV_FILE = argv.find((arg) => arg.startsWith("--out="))?.slice(6) ?? ".env.local"; + +// The events src/lib/billing/polar/webhook.ts handles (tests/polar-webhook.test.ts keeps the two lists equal). +const EVENTS = [ + "subscription.created", + "subscription.updated", + "subscription.active", + "subscription.canceled", + "subscription.uncanceled", + "subscription.revoked", + "subscription.past_due", + "order.paid", + "order.updated", + "order.refunded", +]; + +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +const { billingConfig, catalogItems, productEnvName, toMinorUnits, validateBillingConfig } = await import("../src/config/billing.ts"); +const { productProblems } = await import("../src/lib/billing/polar/product-check.ts"); + +async function api(path, method = "GET", body) { + const response = await fetch(`${BASE}${path}`, { + method, + headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + // not JSON + } + return { status: response.status, ok: response.ok, json }; +} + +// ── check ──────────────────────────────────────────────────────────────────────────────────────────── +async function check() { + const problems = validateBillingConfig(); + const rows = []; + const row = (name, ok, hint) => rows.push({ name, ok, hint }); + row("src/config/billing.ts is valid", problems.length === 0, problems.join("; ")); + row("POLAR_ACCESS_TOKEN", TOKEN.length > 0, "an organization access token: Polar > Settings > Developers (sandbox.polar.sh for the sandbox)"); + row("POLAR_WEBHOOK_SECRET", (process.env.POLAR_WEBHOOK_SECRET ?? "").trim().length > 0, "the signing secret of the webhook endpoint: run `npm run polar:webhook -- --write`, or copy it from the dashboard"); + row("POLAR_SERVER", ["sandbox", "production"].includes(process.env.POLAR_SERVER ?? "sandbox"), 'sandbox (the default) or production; anything else counts as sandbox'); + row("SUPABASE_SERVICE_ROLE_KEY", Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY), "Supabase > Project settings > API > service_role (server only)"); + const items = catalogItems(); + for (const item of items) { + const name = productEnvName(item); + row(name, Boolean(process.env[name]?.trim()), "create the product in the Polar dashboard (`npm run polar:catalog` lists them) and paste its id"); + } + + // Online: does each configured product match the catalog? Skipped (not failed) when the token cannot read products. + const notes = []; + if (TOKEN && !flags.has("--offline")) { + for (const item of items) { + const id = process.env[productEnvName(item)]?.trim(); + if (!id) continue; + const answer = await api(`/v1/products/${encodeURIComponent(id)}`); + const label = `${productEnvName(item)} matches the catalog`; + if (answer.status === 403) { + notes.push("the token cannot read products (scope products:read): the products were not compared with the catalog"); + break; + } + if (answer.status === 404 || answer.status === 422) { + row(label, false, `Polar has no product ${id} on the ${SERVER} server (wrong POLAR_SERVER or a product of another organization?)`); + continue; + } + if (!answer.ok) { + row(label, false, `Polar answered ${answer.status} for the product`); + continue; + } + const mismatch = productProblems(answer.json, { interval: item.interval, amountMinor: Number(toMinorUnits(item.amount)), currency: billingConfig.currency }); + row(label, mismatch.length === 0, mismatch.join("; ")); + } + } + + for (const { name, ok, hint } of rows) console.log(`${ok ? "✔" : "✖"} ${name}${ok ? "" : ` <- ${hint}`}`); + for (const note of [...new Set(notes)]) console.log(`• ${note}`); + const provider = (process.env.BILLING_PROVIDER ?? "").trim().toLowerCase(); + if (provider && provider !== "polar") console.log(`• BILLING_PROVIDER is "${provider}": new checkouts do NOT go to Polar. Polar stays active for the subscribers it already has.`); + const missing = rows.filter((r) => !r.ok).length; + console.log(missing === 0 ? "\nReady." : `\n${missing} thing(s) to do. See BILLING.md.`); + process.exit(missing === 0 ? 0 : 1); +} + +if (command === "check") await check(); + +// ── catalog: a checklist, no network ───────────────────────────────────────────────────────────────── +if (command === "catalog") { + const problems = validateBillingConfig(); + if (problems.length > 0) fail(`src/config/billing.ts has problems:\n- ${problems.join("\n- ")}`); + console.log(`Create these products in the Polar dashboard (${SERVER === "production" ? "PRODUCTION" : "sandbox: https://sandbox.polar.sh"}), one each, then paste the ids:\n`); + for (const item of catalogItems()) { + const price = `${item.amount.toFixed(2)} ${billingConfig.currency}`; + console.log(` ${item.name}${item.interval ? ` (${item.interval === "month" ? "monthly" : "yearly"})` : ""}`); + console.log(` type: ${item.interval ? `recurring, every 1 ${item.interval}` : "one-time"} price: fixed ${price}`); + console.log(` description: ${item.description}`); + console.log(` -> ${productEnvName(item)}=\n`); + } + console.log("Then run `npm run polar:check`: it compares every product with src/config/billing.ts."); + process.exit(0); +} + +// ── everything below talks to Polar's webhook API ──────────────────────────────────────────────────── +if (!["webhook-create", "webhook-list", "webhook-delete"].includes(command ?? "")) { + fail("usage: check [--offline] | catalog | webhook-create [--write] [--out=] | webhook-list | webhook-delete "); +} +if (!TOKEN) fail("POLAR_ACCESS_TOKEN is not set (run through npm, which loads .env.local)."); +if (SERVER === "production" && command !== "webhook-list" && !(flags.has("--live") && flags.has("--yes"))) { + fail(`refusing: POLAR_SERVER is production. Add --live --yes to ${command} if you mean it (it registers or removes a webhook endpoint of your LIVE organization).`); +} + +/** Merge KEY=value lines into the env file, replacing a line of the same name and keeping the rest. */ +function writeEnv(entries) { + for (const [name, value] of Object.entries(entries)) { + if (typeof value !== "string" || value.length === 0 || value === "undefined") fail(`refusing to write ${name}=${String(value)} into ${ENV_FILE}`); + } + const lines = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8").split("\n").filter((line) => line.length > 0) : []; + const names = new Set(Object.keys(entries)); + const kept = lines.filter((line) => !names.has(line.split("=")[0])); + for (const [name, value] of Object.entries(entries)) kept.push(`${name}=${value}`); + writeFileSync(ENV_FILE, `${kept.join("\n")}\n`); +} + +if (command === "webhook-create") { + const url = argument; + if (!url || !/^https:\/\/[^/\s]+\/api\/webhooks\/polar$/.test(url)) fail("usage: webhook-create https:///api/webhooks/polar (a public https URL; use a tunnel for local development)"); + // Polar IGNORES a secret we send and generates its own, so `secret` here only has to be long enough to be accepted: the real one is read back. + const created = await api("/v1/webhooks/endpoints", "POST", { url, format: "raw", secret: "x".repeat(32), events: EVENTS }); + if (!created.ok) fail(`create failed: ${created.status} ${JSON.stringify(created.json).slice(0, 400)}`); + console.log(`created webhook endpoint ${created.json.id} -> ${url} (${EVENTS.length} events, format raw)`); + const real = await api(`/v1/webhooks/endpoints/${created.json.id}`); + const secret = real.json?.secret; + if (typeof secret !== "string" || secret.length < 16) { + fail(`Polar did not return a signing secret for ${created.json.id}. Open it in the Polar dashboard (Settings > Webhooks) and copy the secret into POLAR_WEBHOOK_SECRET.`); + } + if (flags.has("--write")) { + writeEnv({ POLAR_WEBHOOK_SECRET: secret }); + console.log(`wrote POLAR_WEBHOOK_SECRET to ${ENV_FILE}`); + } else { + console.log("\nIts signing secret (keep it private) - set it as POLAR_WEBHOOK_SECRET:"); + console.log(`POLAR_WEBHOOK_SECRET=${secret}`); + } +} else if (command === "webhook-list") { + const listed = await api("/v1/webhooks/endpoints?limit=100"); + if (!listed.ok) fail(`list failed: ${listed.status} ${JSON.stringify(listed.json).slice(0, 300)}`); + for (const item of listed.json.items ?? []) console.log(`${item.id} ${item.enabled ? "enabled " : "disabled"} ${item.url} (${item.events?.length ?? 0} events)`); + if (!(listed.json.items ?? []).length) console.log("no endpoints"); +} else if (command === "webhook-delete") { + if (!argument) fail("usage: webhook-delete "); + const deleted = await api(`/v1/webhooks/endpoints/${encodeURIComponent(argument)}`, "DELETE"); + console.log(deleted.status === 204 ? `deleted ${argument}` : `not deleted (${deleted.status})`); +} diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..0d0e08d --- /dev/null +++ b/src/app/api/billing/checkout/route.ts @@ -0,0 +1,66 @@ +import { adapterFor } from "@/lib/billing/adapters"; +import { parseCheckoutRequest, resolveCheckoutItem } from "@/lib/billing/checkout"; +import { entitlementFrom, readSubscriptions } from "@/lib/billing/entitlements"; +import { CustomerConflictError } from "@/lib/billing/paddle/customers"; +import { chooseBillingProvider } from "@/lib/billing/provider"; +import { adapterUser, errorResponse, userOrResponse } from "@/lib/billing/route-helpers"; +import { createAdminClient } from "@/utils/supabase/admin"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +/** + * POST { kind: "plan", key, interval } | { kind: "product", key } + * -> Paddle: { provider: "paddle", transactionId, clientToken, environment } (the browser opens Paddle's overlay on the transaction) + * -> Polar: { provider: "polar", url } (the browser goes to Polar's checkout page) + * + * The browser names WHAT to buy. This route picks the provider that takes new checkouts (`BILLING_PROVIDER`), links the signed-in user to + * that provider's customer and creates the checkout on the provider's side (item, quantity and customer are fixed here). + * Error answers are stable codes (the UI maps them to text): unauthorized, invalid_request, unknown_item, price_not_configured, + * already_subscribed, email_required, email_not_verified, customer_conflict, billing_not_configured, supabase_admin_not_configured, checkout_failed. + */ +export async function POST(request: Request) { + const choice = chooseBillingProvider(); + if ("error" in choice) { + console.error(`[billing] ${choice.reason}`); + return errorResponse(choice.error, 503); + } + const adapter = adapterFor(choice.provider); + + const auth = await userOrResponse(); + if ("response" in auth) return auth.response; + const user = adapterUser(auth.user); + if (adapter.requiresVerifiedEmail) { + if (!user.email) return errorResponse("email_required", 400); + // See ProviderAdapter.requiresVerifiedEmail: Paddle customers are found by e-mail and shared across apps. + if (!user.emailVerified) return errorResponse("email_not_verified", 403); + } + + const parsed = parseCheckoutRequest(await request.json().catch(() => null)); + if (!parsed) return errorResponse("invalid_request", 400); + const resolved = resolveCheckoutItem(parsed, choice.provider); + if ("error" in resolved) return errorResponse(resolved.error, resolved.error === "unknown_item" ? 404 : 503); + + const admin = createAdminClient(); + if (!admin) { + console.error("[billing] SUPABASE_SERVICE_ROLE_KEY (or the Supabase URL) is not set"); + return errorResponse("supabase_admin_not_configured", 503); + } + + try { + // One subscription at a time, whichever provider bills it: changing plan, cancelling and updating the card all go through the provider's + // customer portal. This is also what makes switching BILLING_PROVIDER safe: a subscriber of the old provider cannot start a second one. + if (resolved.item.kind === "plan") { + const current = entitlementFrom(await readSubscriptions(admin, user.id)); + if (current.active || current.pastDue || current.status === "paused") return errorResponse("already_subscribed", 409); + } + + const outcome = await adapter.createCheckout({ db: admin, user, item: resolved.item, ref: resolved.ref, origin: new URL(request.url).origin }); + return Response.json(outcome); + } catch (error) { + if (error instanceof CustomerConflictError) return errorResponse("customer_conflict", 409); + console.error(`[${choice.provider}] checkout failed:`, error instanceof Error ? error.message : error); + return errorResponse("checkout_failed", 502); + } +} diff --git a/src/app/api/billing/portal/route.ts b/src/app/api/billing/portal/route.ts new file mode 100644 index 0000000..218f4ec --- /dev/null +++ b/src/app/api/billing/portal/route.ts @@ -0,0 +1,44 @@ +import { adapterFor } from "@/lib/billing/adapters"; +import { readCustomerLinks } from "@/lib/billing/customers"; +import { providerForUser, readLastPaymentProvider, readSubscriptions } from "@/lib/billing/entitlements"; +import { chooseBillingProvider, providerServerProblem } from "@/lib/billing/provider"; +import { adapterUser, errorResponse, userOrResponse } from "@/lib/billing/route-helpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const RUNNING = ["active", "trialing", "past_due", "paused"]; + +/** + * POST -> { url, cancelUrl }: a short-lived, already-signed-in link to the customer portal of the provider that BILLS this user (Paddle or + * Polar, whatever `BILLING_PROVIDER` says now: after a switch an existing subscriber still manages their subscription where it lives). The + * user updates the card, reads invoices, changes plan or cancels there. Make one per click; never store it. Reads the user's own rows + * through Row Level Security (no service role needed). + */ +export async function POST(request: Request) { + const auth = await userOrResponse(); + if ("response" in auth) return auth.response; + const { supabase } = auth; + const user = adapterUser(auth.user); + + try { + const [subscriptions, lastPaymentProvider, links] = await Promise.all([readSubscriptions(supabase, user.id), readLastPaymentProvider(supabase, user.id), readCustomerLinks(supabase, user.id)]); + const current = chooseBillingProvider(); + const provider = providerForUser({ subscriptions, lastPaymentProvider, links, preferred: "provider" in current ? current.provider : null }); + if (!provider) return errorResponse("no_customer", 404); + + const problem = providerServerProblem(provider); + if (problem) { + console.error(`[billing] this user is billed by ${provider} but ${problem}: their portal cannot open`); + return errorResponse("billing_not_configured", 503); + } + + const ids = subscriptions.filter((row) => row.provider === provider && RUNNING.includes(row.status)).map((row) => row.id); + const session = await adapterFor(provider).createPortal({ db: supabase, user, subscriptionIds: ids, origin: new URL(request.url).origin }); + if (!session) return errorResponse("no_customer", 404); + return Response.json({ provider, url: session.url, cancelUrl: session.cancelUrl }); + } catch (error) { + console.error("[billing] portal failed:", error); + return errorResponse("portal_failed", 502); + } +} diff --git a/src/app/api/billing/sync/route.ts b/src/app/api/billing/sync/route.ts new file mode 100644 index 0000000..b1aa437 --- /dev/null +++ b/src/app/api/billing/sync/route.ts @@ -0,0 +1,52 @@ +import { adapterFor } from "@/lib/billing/adapters"; +import { linkedProviders, readCustomerLinks } from "@/lib/billing/customers"; +import { getEntitlement } from "@/lib/billing/entitlements"; +import { providerServerProblem } from "@/lib/billing/provider"; +import { adapterUser, errorResponse, userOrResponse } from "@/lib/billing/route-helpers"; +import { createAdminClient } from "@/utils/supabase/admin"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +// Best-effort brake (per server instance) so that a page that polls cannot hammer the providers' APIs. +const MIN_INTERVAL_MS = 2000; +const lastRun = new Map(); + +/** + * POST -> { entitlement }. Reads the signed-in user's subscriptions and payments from every provider they have been to (Paddle, Polar) and + * applies them like the webhooks would, then answers what the user now has. The success page calls it until the plan shows up, so a slow or + * lost webhook never leaves a paying user waiting. Safe next to the webhooks (see paddle/sync.ts and polar/sync.ts). + */ +export async function POST() { + const auth = await userOrResponse(); + if ("response" in auth) return auth.response; + const { supabase } = auth; + const user = adapterUser(auth.user); + + const now = Date.now(); + if (now - (lastRun.get(user.id) ?? 0) < MIN_INTERVAL_MS) return errorResponse("too_many_requests", 429); + lastRun.set(user.id, now); + + try { + const providers = linkedProviders(await readCustomerLinks(supabase, user.id)).filter((provider) => { + const problem = providerServerProblem(provider); + if (problem) console.error(`[billing] cannot sync ${provider} for ${user.id}: ${problem}`); + return problem === null; + }); + if (providers.length > 0) { + const admin = createAdminClient(); + if (!admin) return errorResponse("supabase_admin_not_configured", 503); + const results = await Promise.allSettled(providers.map((provider) => adapterFor(provider).sync({ db: admin, user }))); + results.forEach((result, index) => { + if (result.status === "rejected") console.error(`[${providers[index]}] sync failed:`, result.reason); + }); + // Only when every provider we asked failed is it an error: the other one may well have answered. + if (results.every((result) => result.status === "rejected")) return errorResponse("sync_failed", 502); + } + return Response.json({ entitlement: await getEntitlement(supabase, user.id) }); + } catch (error) { + console.error("[billing] sync failed:", error); + return errorResponse("sync_failed", 502); + } +} diff --git a/src/app/api/webhooks/paddle/route.ts b/src/app/api/webhooks/paddle/route.ts new file mode 100644 index 0000000..58a030a --- /dev/null +++ b/src/app/api/webhooks/paddle/route.ts @@ -0,0 +1,48 @@ +import { paddleWebhookContext } from "@/lib/billing/paddle/adapter"; +import { paddleConfigOrResponse } from "@/lib/billing/paddle/route-helpers"; +import { verifyPaddleSignature } from "@/lib/billing/paddle/signature"; +import { parsePaddleEvent, processPaddleDelivery } from "@/lib/billing/paddle/webhook"; +import { errorResponse } from "@/lib/billing/route-helpers"; +import { supabaseBillingStore } from "@/lib/billing/store"; +import { createAdminClient } from "@/utils/supabase/admin"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Paddle webhook. Point a notification destination at `https:///api/webhooks/paddle` (`npm run paddle:webhook`). + * It is processed whichever provider `BILLING_PROVIDER` names: subscribers who bought through Paddle keep being updated after a switch. + * + * Order matters: the signature is checked on the RAW body before anything is parsed, the event is claimed (idempotency) before it is + * applied, and any failure answers 500 so that Paddle delivers it again. The rules for what is believed are in src/lib/billing/delivery.ts + * and src/lib/billing/paddle/webhook.ts. + */ +export async function POST(request: Request) { + const configured = paddleConfigOrResponse(); + if ("response" in configured) return configured.response; + const { config } = configured; + // A missing secret must never mean "accept everything". + if (!config.webhookSecret) return errorResponse("webhook_secret_not_configured", 503); + + const rawBody = await request.text(); + if (!verifyPaddleSignature(config.webhookSecret, rawBody, request.headers.get("paddle-signature"))) { + return errorResponse("invalid_signature", 401); + } + + const event = parsePaddleEvent(rawBody); + if (!event) return errorResponse("invalid_event", 400); + + const admin = createAdminClient(); + if (!admin) { + console.error("[paddle] SUPABASE_SERVICE_ROLE_KEY (or the Supabase URL) is not set: the webhook cannot write"); + return errorResponse("supabase_admin_not_configured", 503); + } + + try { + const result = await processPaddleDelivery(supabaseBillingStore(admin), event, paddleWebhookContext(config.appTag)); + return Response.json({ ok: true, status: result.status }); + } catch (error) { + console.error(`[paddle] webhook ${event.event_type} ${event.event_id} failed:`, error); + return errorResponse("webhook_failed", 500); + } +} diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts new file mode 100644 index 0000000..8db80c6 --- /dev/null +++ b/src/app/api/webhooks/polar/route.ts @@ -0,0 +1,47 @@ +import { polarContext } from "@/lib/billing/polar/adapter"; +import { polarConfigOrResponse } from "@/lib/billing/polar/route-helpers"; +import { polarWebhookHeaders, verifyPolarSignature } from "@/lib/billing/polar/signature"; +import { parsePolarEvent, processPolarDelivery } from "@/lib/billing/polar/webhook"; +import { errorResponse } from "@/lib/billing/route-helpers"; +import { supabaseBillingStore } from "@/lib/billing/store"; +import { createAdminClient } from "@/utils/supabase/admin"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Polar webhook. Register an endpoint at `https:///api/webhooks/polar` with the format "Raw" (`npm run polar:webhook`). + * It is processed whichever provider `BILLING_PROVIDER` names: subscribers who bought through Polar keep being updated after a switch. + * + * Order matters: the signature is checked on the RAW body before anything is parsed, the delivery is claimed (idempotency) before it is + * applied, and any failure answers 500 so that Polar delivers it again. The rules for what is believed are in src/lib/billing/delivery.ts + * and src/lib/billing/polar/webhook.ts. + */ +export async function POST(request: Request) { + const configured = polarConfigOrResponse(); + if ("response" in configured) return configured.response; + const { config } = configured; + // A missing secret must never mean "accept everything". + if (!config.webhookSecret) return errorResponse("webhook_secret_not_configured", 503); + + const rawBody = await request.text(); + const headers = polarWebhookHeaders(request.headers); + if (!verifyPolarSignature(config.webhookSecret, rawBody, headers)) return errorResponse("invalid_signature", 401); + + const event = parsePolarEvent(rawBody, headers.id); + if (!event) return errorResponse("invalid_event", 400); + + const admin = createAdminClient(); + if (!admin) { + console.error("[polar] SUPABASE_SERVICE_ROLE_KEY (or the Supabase URL) is not set: the webhook cannot write"); + return errorResponse("supabase_admin_not_configured", 503); + } + + try { + const result = await processPolarDelivery(supabaseBillingStore(admin), event, polarContext); + return Response.json({ ok: true, status: result.status }); + } catch (error) { + console.error(`[polar] webhook ${event.event_type} ${event.event_id} failed:`, error); + return errorResponse("webhook_failed", 500); + } +} diff --git a/src/app/billing/page.tsx b/src/app/billing/page.tsx new file mode 100644 index 0000000..60425d5 --- /dev/null +++ b/src/app/billing/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ManageButton } from "@/components/billing/manage-button"; +import { formatDate, formatMinor, subscriptionNote } from "@/components/billing/format"; +import { billingConfig } from "@/config/billing"; +import { getEntitlement } from "@/lib/billing/entitlements"; +import { createClient } from "@/utils/supabase/server"; + +export const metadata: Metadata = { + title: "Billing", + robots: { index: false }, +}; + +export const dynamic = "force-dynamic"; + +/** The signed-in user's subscription and payments, and the button into the portal of the provider that bills them. All reads go through Row Level Security. */ +export default async function BillingPage() { + const supabase = await createClient(); + const user = supabase ? (await supabase.auth.getUser()).data.user : null; + if (!supabase || !user) redirect(`${billingConfig.loginPath}?next=/billing`); + + const entitlement = await getEntitlement(supabase, user.id); + const plan = billingConfig.plans.find((candidate) => candidate.key === entitlement.planKey); + const { data: transactions } = await supabase + .from("billing_transactions") + .select("id, billed_at, total, refunded, currency, status, invoice_number") + .order("billed_at", { ascending: false, nullsFirst: false }) + .limit(10); + + return ( +
+

Billing

+ +
+

+ Subscription +

+ {entitlement.status ? ( + <> +

+ {plan?.name ?? entitlement.planKey ?? "Unknown plan"} + {entitlement.interval ? ` · billed every ${entitlement.interval}` : null} +

+

+ {subscriptionNote(entitlement.status, entitlement.cancelAtPeriodEnd, entitlement.currentPeriodEnd)} +

+ + + ) : ( + <> +

You do not have a subscription.

+ + See plans + + + )} +
+ +
+

+ Payments +

+ {transactions && transactions.length > 0 ? ( +
    + {transactions.map((transaction) => ( +
  • + + {formatDate(transaction.billed_at as string | null)} + {transaction.invoice_number ? · #{transaction.invoice_number as string} : null} + + + {formatMinor(Number(transaction.total), transaction.currency as string)} + {Number(transaction.refunded) > 0 ? (refunded {formatMinor(Number(transaction.refunded), transaction.currency as string)}) : null} + +
  • + ))} +
+ ) : ( +

No payments yet.

+ )} +

Receipts and invoices are in the portal (“Manage billing”).

+
+
+ ); +} diff --git a/src/app/billing/success/page.tsx b/src/app/billing/success/page.tsx new file mode 100644 index 0000000..c77adda --- /dev/null +++ b/src/app/billing/success/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { SuccessPoller } from "@/components/billing/success-poller"; + +export const metadata: Metadata = { + title: "Thank you", + robots: { index: false }, +}; + +/** Where Paddle's checkout returns after a payment (`successUrl`). The poller waits for the webhook, and asks the server to sync if it is slow. */ +export default function BillingSuccessPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx new file mode 100644 index 0000000..a8bbd7b --- /dev/null +++ b/src/app/pricing/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { PricingTable } from "@/components/billing/pricing-table"; +import { billingConfig } from "@/config/billing"; +import { sellableItems } from "@/lib/billing/catalog"; +import { getEntitlement, NO_ENTITLEMENT } from "@/lib/billing/entitlements"; +import { chooseBillingProvider } from "@/lib/billing/provider"; +import { createClient } from "@/utils/supabase/server"; + +export const metadata: Metadata = { + title: "Pricing", + description: "Plans and pricing.", +}; + +// The signed-in user and the environment are read on every request. +export const dynamic = "force-dynamic"; + +/** + * Public pricing page (a merchant of record's domain review reads it: keep the prices, what each plan includes and links to your Terms, + * Privacy and Refund pages on it or in the footer, see BILLING.md). Buttons only appear for plans the provider that takes new checkouts can + * sell (its ids are in `.env`), so a fresh clone never shows a button that cannot work. + */ +export default async function PricingPage() { + const supabase = await createClient(); + const user = supabase ? (await supabase.auth.getUser()).data.user : null; + const entitlement = supabase && user ? await getEntitlement(supabase, user.id).catch(() => NO_ENTITLEMENT) : NO_ENTITLEMENT; + + const choice = chooseBillingProvider(); + const ready = "provider" in choice; + const available = ready + ? sellableItems(choice.provider) + .filter((item) => item.kind === "plan") + .map((item) => `${item.key}:${item.interval}`) + : []; + + return ( +
+
+

Pricing

+

Pick a plan. Cancel any time from your billing page.

+
+ + {!ready ? ( +

+ Payments are not configured yet. Follow BILLING.md: set up Paddle or Polar in .env.local, then run its catalog script (npm run paddle:catalog or npm run polar:catalog). +

+ ) : null} +
+ ); +} diff --git a/src/components/billing/checkout-button.tsx b/src/components/billing/checkout-button.tsx new file mode 100644 index 0000000..7473b11 --- /dev/null +++ b/src/components/billing/checkout-button.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { Button, type ButtonProps } from "@/components/ui/button"; +import type { BillingInterval } from "@/config/billing"; +import { nextCheckoutStep, type CheckoutAnswer } from "./checkout-flow"; +import { billingErrorMessage } from "./messages"; +import { getPaddle } from "./paddle-js"; + +type Target = { kind: "plan"; itemKey: string; interval: BillingInterval } | { kind: "product"; itemKey: string }; + +/** + * Buys one thing: asks the server to create the checkout (POST /api/billing/checkout), then continues the way the provider that takes new + * checkouts wants: Paddle's overlay opens on the transaction, Polar's checkout page replaces this one. The button does not need to know + * which one is configured. A visitor who is not signed in is sent to `loginPath` and brought back here afterwards. + */ +export function CheckoutButton({ + target, + loginPath, + successPath = "/billing/success", + children, + ...buttonProps +}: { target: Target; loginPath: string; successPath?: string } & Omit) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function buy() { + setBusy(true); + setError(null); + try { + const response = await fetch("/api/billing/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(target.kind === "plan" ? { kind: "plan", key: target.itemKey, interval: target.interval } : { kind: "product", key: target.itemKey }), + }); + const step = nextCheckoutStep(response.status, response.ok, (await response.json().catch(() => ({}))) as CheckoutAnswer); + if (step.action === "sign_in") { + window.location.href = `${loginPath}?next=${encodeURIComponent(window.location.pathname)}`; + } else if (step.action === "redirect") { + window.location.href = step.url; + } else if (step.action === "paddle_overlay") { + const paddle = await getPaddle(step.clientToken, step.environment); + paddle.Checkout.open({ + transactionId: step.transactionId, + settings: { displayMode: "overlay", successUrl: `${window.location.origin}${successPath}` }, + }); + } else { + setError(billingErrorMessage(step.code)); + } + } catch (caught) { + setError(billingErrorMessage(caught instanceof Error ? caught.message : null)); + } finally { + setBusy(false); + } + } + + return ( +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/components/billing/checkout-flow.ts b/src/components/billing/checkout-flow.ts new file mode 100644 index 0000000..cf6d10c --- /dev/null +++ b/src/components/billing/checkout-flow.ts @@ -0,0 +1,29 @@ +// No "use client" here on purpose: pure, so the tests can import it, and it stays free of any provider's script. + +/** What POST /api/billing/checkout answers (src/lib/billing/adapter.ts `CheckoutOutcome`), or `{ error }`. */ +export interface CheckoutAnswer { + error?: string; + provider?: string; + transactionId?: string; + clientToken?: string; + environment?: string; + url?: string; +} + +/** What the browser does with the answer. The button does not know which provider is configured: the server says. */ +export type CheckoutStep = + | { action: "sign_in" } + | { action: "error"; code: string | null } + | { action: "redirect"; url: string } + | { action: "paddle_overlay"; transactionId: string; clientToken: string; environment: "sandbox" | "production" }; + +export function nextCheckoutStep(status: number, ok: boolean, answer: CheckoutAnswer): CheckoutStep { + if (status === 401) return { action: "sign_in" }; + if (!ok) return { action: "error", code: answer.error ?? null }; + // Polar: its own checkout page. Only a real http(s) URL: never something that runs in this page. + if (answer.provider === "polar" && typeof answer.url === "string" && /^https:\/\//.test(answer.url)) return { action: "redirect", url: answer.url }; + if (answer.provider === "paddle" && answer.transactionId && answer.clientToken && (answer.environment === "sandbox" || answer.environment === "production")) { + return { action: "paddle_overlay", transactionId: answer.transactionId, clientToken: answer.clientToken, environment: answer.environment }; + } + return { action: "error", code: null }; +} diff --git a/src/components/billing/format.ts b/src/components/billing/format.ts new file mode 100644 index 0000000..938e332 --- /dev/null +++ b/src/components/billing/format.ts @@ -0,0 +1,35 @@ +// No "use client" here on purpose: these are called from server components (/billing) and from client components (the pricing table). +// A function exported from a "use client" file cannot be called from the server, which fails only at request time, for a user who has payments. + +/** 9 -> "$9", 9.5 -> "$9.50", in English. Pass your own locale here when you localise the app. */ +export function formatPrice(amount: number, currency: string): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency, minimumFractionDigits: Number.isInteger(amount) ? 0 : 2 }).format(amount); +} + +/** Paddle's minor units (990) as a price ("$9.90"). */ +export function formatMinor(minorUnits: number, currency: string): string { + return formatPrice(minorUnits / 100, currency); +} + +export function formatDate(iso: string | null | undefined): string { + return iso ? new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) : ""; +} + +const STATUS_TEXT: Record = { + active: "Active", + trialing: "In trial", + past_due: "Payment failed: it will be retried", + paused: "Paused", + canceled: "Canceled", +}; + +/** + * The sentence under a subscription on /billing: its state, and what happens next. Only a subscription that is still running (or being + * retried) has a next date to show: a canceled one is over, and "Renews on" would be false. + */ +export function subscriptionNote(status: string, cancelAtPeriodEnd: boolean, currentPeriodEnd: string | null | undefined): string { + const state = STATUS_TEXT[status] ?? status; + const when = formatDate(currentPeriodEnd); + if (when && (status === "active" || status === "trialing" || status === "past_due")) return `${state}. ${cancelAtPeriodEnd ? "Ends" : "Renews"} on ${when}.`; + return `${state}.`; +} diff --git a/src/components/billing/manage-button.tsx b/src/components/billing/manage-button.tsx new file mode 100644 index 0000000..f8dff0f --- /dev/null +++ b/src/components/billing/manage-button.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import { Button, type ButtonProps } from "@/components/ui/button"; +import { billingErrorMessage } from "./messages"; + +/** Opens the customer portal (card, invoices, plan, cancellation) of the provider that bills this user, with a fresh signed-in link. */ +export function ManageButton({ children = "Manage billing", ...buttonProps }: Omit) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function open() { + setBusy(true); + setError(null); + try { + const response = await fetch("/api/billing/portal", { method: "POST" }); + const json = (await response.json().catch(() => ({}))) as { error?: string; url?: string }; + if (!response.ok || !json.url) { + setError(billingErrorMessage(json.error)); + return; + } + window.location.href = json.url; + } catch { + setError(billingErrorMessage(null)); + } finally { + setBusy(false); + } + } + + return ( +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/components/billing/messages.ts b/src/components/billing/messages.ts new file mode 100644 index 0000000..7088b85 --- /dev/null +++ b/src/components/billing/messages.ts @@ -0,0 +1,23 @@ +/** + * What to tell the user for each error code the /api/billing routes answer with. English; replace it (or route it through your i18n) when + * you localise the app. An unknown code falls back to the generic text. + */ +const MESSAGES: Record = { + unauthorized: "Please sign in first.", + already_subscribed: "You already have a subscription. Use “Manage billing” to change or cancel it.", + email_required: "Your account has no e-mail address, which payment needs.", + email_not_verified: "Please confirm your e-mail address first (check your inbox), then try again.", + customer_conflict: "This e-mail is already linked to another account. Please contact support.", + price_not_configured: "This plan is not available yet.", + unknown_item: "This plan does not exist.", + billing_not_configured: "Payments are not set up yet.", + too_many_requests: "Please wait a moment and try again.", + no_customer: "There is nothing to manage yet: you have not bought anything.", + paddle_js_failed: "The payment window could not be loaded. Check your connection or any blocker, then try again.", +}; + +export const GENERIC_BILLING_ERROR = "Something went wrong with the payment. Please try again, and contact support if it keeps happening."; + +export function billingErrorMessage(code: string | undefined | null): string { + return (code && MESSAGES[code]) || GENERIC_BILLING_ERROR; +} diff --git a/src/components/billing/paddle-js.ts b/src/components/billing/paddle-js.ts new file mode 100644 index 0000000..af3dfff --- /dev/null +++ b/src/components/billing/paddle-js.ts @@ -0,0 +1,49 @@ +/** + * Paddle.js, loaded from Paddle's CDN the first time a checkout is opened (nothing to install, nothing on pages that never sell). + * https://developer.paddle.com/paddlejs/overview + */ +const SCRIPT_SRC = "https://cdn.paddle.com/paddle/v2/paddle.js"; + +interface PaddleGlobal { + Environment: { set: (environment: "sandbox" | "production") => void }; + Initialize: (options: { token: string; eventCallback?: (event: { name?: string }) => void }) => void; + Checkout: { open: (options: { transactionId: string; settings?: { displayMode?: "overlay" | "inline"; successUrl?: string; theme?: "light" | "dark" } }) => void }; +} + +declare global { + interface Window { + Paddle?: PaddleGlobal; + } +} + +let loading: Promise | null = null; +let initialisedWith: string | null = null; + +function loadScript(): Promise { + if (window.Paddle) return Promise.resolve(window.Paddle); + loading ??= new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = SCRIPT_SRC; + script.async = true; + script.onload = () => (window.Paddle ? resolve(window.Paddle) : reject(new Error("paddle_js_missing"))); + script.onerror = () => { + loading = null; + reject(new Error("paddle_js_failed")); + }; + document.head.appendChild(script); + }); + return loading; +} + +/** Paddle.js, initialised once per token and environment. */ +export async function getPaddle(token: string, environment: "sandbox" | "production"): Promise { + const paddle = await loadScript(); + const signature = `${environment}:${token}`; + if (initialisedWith !== signature) { + // Environment.set must come before Initialize, and only sandbox needs it. + if (environment === "sandbox") paddle.Environment.set("sandbox"); + paddle.Initialize({ token }); + initialisedWith = signature; + } + return paddle; +} diff --git a/src/components/billing/pricing-table.tsx b/src/components/billing/pricing-table.tsx new file mode 100644 index 0000000..58f3832 --- /dev/null +++ b/src/components/billing/pricing-table.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { BillingInterval, BillingPlan } from "@/config/billing"; +import { CheckoutButton } from "./checkout-button"; +import { formatPrice } from "./format"; + +/** + * The plan cards, with a month / year switch when any plan sells both. `currentPlanKey` marks the plan the user already has. When payments are + * not configured yet (no price ids in `.env`) the buttons are replaced by a notice, so a fresh clone never shows a button that cannot work. + */ +export function PricingTable({ + plans, + currency, + loginPath, + currentPlanKey, + available, +}: { + plans: readonly BillingPlan[]; + currency: string; + loginPath: string; + currentPlanKey: string | null; + /** keys of the "plan:interval" combinations that have a price id in the environment */ + available: readonly string[]; +}) { + const hasYear = plans.some((plan) => plan.prices.year !== undefined); + const hasMonth = plans.some((plan) => plan.prices.month !== undefined); + const [interval, setInterval] = useState(hasMonth ? "month" : "year"); + const visible = plans.filter((plan) => plan.prices[interval] !== undefined); + + return ( +
+ {hasYear && hasMonth ? ( +
+ {(["month", "year"] as const).map((option) => ( + + ))} +
+ ) : null} + +
+ {visible.map((plan) => { + const amount = plan.prices[interval] as number; + const isCurrent = currentPlanKey === plan.key; + const canBuy = available.includes(`${plan.key}:${interval}`); + return ( +
+
+

+ {plan.name} + {plan.popular ? Popular : null} +

+

{plan.description}

+
+

+ {formatPrice(amount, currency)} + / {interval === "month" ? "month" : "year"} +

+
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {isCurrent ? ( +

Your current plan

+ ) : canBuy ? ( + + Choose {plan.name} + + ) : ( +

Not available yet.

+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/billing/success-poller.tsx b/src/components/billing/success-poller.tsx new file mode 100644 index 0000000..2cabed2 --- /dev/null +++ b/src/components/billing/success-poller.tsx @@ -0,0 +1,75 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +type State = "waiting" | "active" | "slow"; + +const EVERY_MS = 3000; +const GIVE_UP_AFTER_MS = 60_000; + +/** + * The page after a payment. The subscription row is written by Paddle's webhook, which can take a few seconds, so this asks the server + * (POST /api/billing/sync, which also reads Paddle directly) every few seconds until the plan is active, and gives up politely after a minute. + */ +export function SuccessPoller() { + const [state, setState] = useState("waiting"); + + useEffect(() => { + let stopped = false; + const started = Date.now(); + + async function check() { + try { + const response = await fetch("/api/billing/sync", { method: "POST" }); + const json = (await response.json().catch(() => ({}))) as { entitlement?: { active?: boolean } }; + if (!stopped && json.entitlement?.active) { + setState("active"); + return; + } + } catch { + // try again below + } + if (stopped) return; + if (Date.now() - started > GIVE_UP_AFTER_MS) setState("slow"); + else setTimeout(check, EVERY_MS); + } + + void check(); + return () => { + stopped = true; + }; + }, []); + + if (state === "active") { + return ( + <> +

You are all set

+

Your subscription is active. A receipt is on its way to your e-mail.

+ + + ); + } + if (state === "slow") { + return ( + <> +

Thank you

+

Your payment went through, but it is taking longer than usual to show up here. Refresh this page in a minute; if it still does not, contact support.

+ + + ); + } + return ( + <> +

Thank you

+

+ Confirming your payment… +

+ + ); +} diff --git a/src/config/billing.ts b/src/config/billing.ts new file mode 100644 index 0000000..480c76c --- /dev/null +++ b/src/config/billing.ts @@ -0,0 +1,152 @@ +/** + * What this app sells. EDIT THIS FILE FIRST after cloning the template: it is the one place the pricing page, the checkout, + * the webhooks and the catalog scripts (`npm run paddle:catalog`, `npm run polar:catalog`: they create the products and prices from it) + * all read. It is the same whichever payment provider you use, and you can change provider later without touching it. + * + * Nothing secret lives here (it ships to the browser). The provider's ids live in the environment, never here: after the catalog + * script has run, `.env.local` holds one `PADDLE_PRICE__` line (Paddle) and/or one `POLAR_PRODUCT__` line + * (Polar) per item. See BILLING.md. + */ + +/** The two payment providers (merchants of record) the template can sell through. `BILLING_PROVIDER` picks the one for NEW checkouts. */ +export type BillingProviderName = "paddle" | "polar"; + +export const BILLING_PROVIDERS = ["paddle", "polar"] as const satisfies readonly BillingProviderName[]; + +export type BillingInterval = "month" | "year"; + +export interface BillingPlan { + /** Lowercase letters, digits and underscores. Stored in the database and part of the env var name `PADDLE_PRICE__`. */ + key: string; + name: string; + description: string; + features: readonly string[]; + /** Price per interval in MAJOR units of `billingConfig.currency` (9 = $9.00). Leave an interval out to not sell it. */ + prices: Partial>; + /** Highlight this plan on the pricing page. */ + popular?: boolean; +} + +export interface BillingProduct { + /** Same rules as a plan key. */ + key: string; + name: string; + description: string; + /** One-time price in major units. */ + price: number; +} + +export const billingConfig = { + /** ISO 4217. Paddle prices are created in this currency; Paddle shows buyers their local currency at checkout where it can. */ + currency: "USD", + + /** Where a visitor who is not signed in is sent to sign in (the template has no login page: add yours). `?next=` is appended. */ + loginPath: "/login", + + /** + * Subscription plans (recurring). Replace these two examples. A user with an active plan is found with `getEntitlement()` + * (src/lib/billing/entitlements.ts); what a plan allows is YOUR code, keyed on `plan.key`. + */ + plans: [ + { + key: "pro", + name: "Pro", + description: "For individuals who use the app every day.", + features: ["Everything in Free", "Unlimited projects", "Email support"], + prices: { month: 9, year: 90 }, + popular: true, + }, + { + key: "team", + name: "Team", + description: "For small teams that work together.", + features: ["Everything in Pro", "Up to 10 members", "Priority support"], + prices: { month: 29, year: 290 }, + }, + ], + + /** + * One-time purchases (a credit pack, a lifetime licence). Empty by default. Each one is bought once per checkout; a completed + * payment is recorded in `billing_transactions` with `item_key = ` and you grant whatever it unlocks. + */ + products: [] as readonly BillingProduct[], +} as const satisfies { + currency: string; + loginPath: string; + plans: readonly BillingPlan[]; + products: readonly BillingProduct[]; +}; + +export type PlanKey = (typeof billingConfig.plans)[number]["key"]; + +/** Every sellable thing, in one list: what the checkout route and the catalog script iterate. */ +export type CatalogItem = + | { kind: "plan"; key: string; interval: BillingInterval; name: string; description: string; amount: number } + | { kind: "product"; key: string; interval: null; name: string; description: string; amount: number }; + +export function catalogItems(config: { plans: readonly BillingPlan[]; products: readonly BillingProduct[] } = billingConfig): CatalogItem[] { + const items: CatalogItem[] = []; + for (const plan of config.plans) { + for (const interval of ["month", "year"] as const) { + const amount = plan.prices[interval]; + if (amount !== undefined) items.push({ kind: "plan", key: plan.key, interval, name: plan.name, description: plan.description, amount }); + } + } + for (const product of config.products) { + items.push({ kind: "product", key: product.key, interval: null, name: product.name, description: product.description, amount: product.price }); + } + return items; +} + +/** `PADDLE_PRICE_PRO_MONTH`, `PADDLE_PRICE_CREDITS_100` ... the env var that holds the Paddle price id of an item. */ +export function priceEnvName(item: { key: string; interval: BillingInterval | null }): string { + return `PADDLE_PRICE_${item.key.toUpperCase()}${item.interval ? `_${item.interval.toUpperCase()}` : ""}`; +} + +/** `POLAR_PRODUCT_PRO_MONTH`, `POLAR_PRODUCT_CREDITS_100` ... the env var that holds the Polar product id of an item (a Polar product has ONE interval). */ +export function productEnvName(item: { key: string; interval: BillingInterval | null }): string { + return `POLAR_PRODUCT_${item.key.toUpperCase()}${item.interval ? `_${item.interval.toUpperCase()}` : ""}`; +} + +/** The env var that holds a provider's id for an item (Paddle: a price id, Polar: a product id). */ +export function providerEnvName(provider: BillingProviderName, item: { key: string; interval: BillingInterval | null }): string { + return provider === "paddle" ? priceEnvName(item) : productEnvName(item); +} + +const KEY = /^[a-z][a-z0-9_]{0,30}$/; + +function hasSubCentPrecision(amount: number): boolean { + return Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-6; +} + +/** Mistakes in this file that would only show up as a broken checkout in production. Run by the tests. */ +export function validateBillingConfig(config: { currency: string; plans: readonly BillingPlan[]; products: readonly BillingProduct[] } = billingConfig): string[] { + const problems: string[] = []; + const keys = new Set(); + for (const entry of [...config.plans, ...config.products]) { + if (!KEY.test(entry.key)) problems.push(`key "${entry.key}" must be lowercase letters, digits and underscores, starting with a letter`); + if (keys.has(entry.key)) problems.push(`key "${entry.key}" is used twice`); + keys.add(entry.key); + } + for (const plan of config.plans) { + const intervals = Object.keys(plan.prices); + if (intervals.length === 0) problems.push(`plan "${plan.key}" has no price`); + for (const [interval, amount] of Object.entries(plan.prices)) { + if (interval !== "month" && interval !== "year") problems.push(`plan "${plan.key}": unknown interval "${interval}"`); + const price = amount as number; + if (!Number.isFinite(price) || price <= 0) problems.push(`plan "${plan.key}": ${interval} price must be above 0`); + else if (hasSubCentPrecision(price)) problems.push(`plan "${plan.key}": ${interval} price has more than two decimals`); + } + } + for (const product of config.products) { + if (!Number.isFinite(product.price) || product.price <= 0) problems.push(`product "${product.key}": price must be above 0`); + else if (hasSubCentPrecision(product.price)) problems.push(`product "${product.key}": price has more than two decimals`); + } + if (!/^[A-Z]{3}$/.test(config.currency)) problems.push(`currency "${config.currency}" is not an ISO 4217 code`); + return problems; +} + +/** Major units to the minor units Paddle wants, as a string ("9.99" -> "999"). Rounds half away from zero on the cent. */ +export function toMinorUnits(amount: number): string { + return String(Math.round(amount * 100)); +} diff --git a/src/lib/billing/adapter.ts b/src/lib/billing/adapter.ts new file mode 100644 index 0000000..7c5097f --- /dev/null +++ b/src/lib/billing/adapter.ts @@ -0,0 +1,36 @@ +/** + * What a payment provider has to do for the template, so that the routes (/api/billing/checkout, portal, sync) are the same for Paddle and for + * Polar. An adapter creates things at ITS provider and never decides who gets access: that is the webhooks' job (paddle/webhook.ts, + * polar/webhook.ts), which write the same rows whichever provider sent them. + * + * To add a third provider: implement this, map its payloads to the rows of types.ts, add a webhook route and register it in adapters.ts. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { CatalogItem } from "@/config/billing"; +import type { Provider } from "./types"; + +export interface AdapterUser { + id: string; + email: string | null; + emailVerified: boolean; +} + +/** What the browser needs to start paying: Paddle opens an overlay on a transaction; Polar sends the buyer to its own checkout page. */ +export type CheckoutOutcome = + | { provider: "paddle"; transactionId: string; clientToken: string; environment: "sandbox" | "production" } + | { provider: "polar"; url: string }; + +export interface ProviderAdapter { + provider: Provider; + /** + * Paddle finds its customers BY E-MAIL and shares them between every app on the account, so a checkout needs an e-mail the user has + * confirmed (otherwise an unverified sign-up with a stranger's address could open that stranger's portal). Polar's customer is the user id. + */ + requiresVerifiedEmail: boolean; + /** Creates the checkout at the provider. Everything that matters (item, quantity, customer) is decided here, never by the browser. `db` is the SERVICE-ROLE client. */ + createCheckout(input: { db: SupabaseClient; user: AdapterUser; item: CatalogItem; ref: string; origin: string }): Promise; + /** A fresh, signed-in link to the provider's customer portal; null when the provider has no customer for this user. `db` only reads (a user-session client is enough). */ + createPortal(input: { db: SupabaseClient; user: AdapterUser; subscriptionIds: readonly string[]; origin: string }): Promise<{ url: string; cancelUrl: string | null } | null>; + /** Reads the user's subscriptions and payments from the provider and stores them (what the webhook would have). A no-op when the user has none there. `db` is the SERVICE-ROLE client. */ + sync(input: { db: SupabaseClient; user: AdapterUser }): Promise; +} diff --git a/src/lib/billing/adapters.ts b/src/lib/billing/adapters.ts new file mode 100644 index 0000000..2fff4ca --- /dev/null +++ b/src/lib/billing/adapters.ts @@ -0,0 +1,10 @@ +import type { ProviderAdapter } from "./adapter"; +import { paddleAdapter } from "./paddle/adapter"; +import { polarAdapter } from "./polar/adapter"; +import type { Provider } from "./types"; + +const ADAPTERS: Record = { paddle: paddleAdapter, polar: polarAdapter }; + +export function adapterFor(provider: Provider): ProviderAdapter { + return ADAPTERS[provider]; +} diff --git a/src/lib/billing/catalog.ts b/src/lib/billing/catalog.ts new file mode 100644 index 0000000..30192b0 --- /dev/null +++ b/src/lib/billing/catalog.ts @@ -0,0 +1,23 @@ +/** + * Which of OUR catalog items (src/config/billing.ts) a provider's id is, and the reverse. The ids live in `.env` + * (`PADDLE_PRICE_*` for Paddle, `POLAR_PRODUCT_*` for Polar): only an id that is in the environment is ours, whatever else a payload says. + * Pure (reads only the `env` it is given). + */ +import { billingConfig, catalogItems, providerEnvName, type BillingInterval, type CatalogItem } from "@/config/billing"; +import type { Env, Provider } from "./types"; + +/** The provider's id of an item (a Paddle price id, a Polar product id) from `.env`, or null while it has not been created yet. */ +export function providerRefFor(provider: Provider, item: { key: string; interval: BillingInterval | null }, env: Env = process.env): string | null { + return env[providerEnvName(provider, item)]?.trim() || null; +} + +/** The reverse: which of OUR items a provider's id is (null: not ours, or not configured). */ +export function itemForProviderRef(provider: Provider, ref: string | undefined | null, env: Env = process.env, items: CatalogItem[] = catalogItems(billingConfig)): CatalogItem | null { + if (!ref) return null; + return items.find((item) => providerRefFor(provider, item, env) === ref) ?? null; +} + +/** The items a provider can sell right now: the ones whose id is in the environment. */ +export function sellableItems(provider: Provider, env: Env = process.env, items: CatalogItem[] = catalogItems(billingConfig)): CatalogItem[] { + return items.filter((item) => providerRefFor(provider, item, env) !== null); +} diff --git a/src/lib/billing/checkout.ts b/src/lib/billing/checkout.ts new file mode 100644 index 0000000..6e72809 --- /dev/null +++ b/src/lib/billing/checkout.ts @@ -0,0 +1,41 @@ +/** + * What a checkout request may ask for. The browser only NAMES the thing to buy (a plan and an interval, or a product); the provider's id, + * the quantity and the customer are decided on the server. Pure: tests/billing-checkout.test.ts. + */ +import { catalogItems, type BillingInterval, type CatalogItem } from "@/config/billing"; +import { providerRefFor } from "./catalog"; +import type { Env, Provider } from "./types"; + +export type CheckoutError = "invalid_request" | "unknown_item" | "price_not_configured"; + +export interface CheckoutRequest { + kind: "plan" | "product"; + key: string; + interval: BillingInterval | null; +} + +/** Parses the JSON body the browser sends. Anything unexpected is `invalid_request`. */ +export function parseCheckoutRequest(body: unknown): CheckoutRequest | null { + if (!body || typeof body !== "object") return null; + const { kind, key, interval } = body as Record; + if ((kind !== "plan" && kind !== "product") || typeof key !== "string" || key.length === 0 || key.length > 40) return null; + if (kind === "plan") { + if (interval !== "month" && interval !== "year") return null; + return { kind, key, interval }; + } + return { kind, key, interval: null }; +} + +/** The catalog item the request names, and the PROVIDER's id of it (a Paddle price id, a Polar product id) from `.env`. */ +export function resolveCheckoutItem( + request: CheckoutRequest, + provider: Provider, + env: Env = process.env, + items: CatalogItem[] = catalogItems(), +): { item: CatalogItem; ref: string } | { error: CheckoutError } { + const item = items.find((candidate) => candidate.kind === request.kind && candidate.key === request.key && candidate.interval === request.interval); + if (!item) return { error: "unknown_item" }; + const ref = providerRefFor(provider, item, env); + if (!ref) return { error: "price_not_configured" }; + return { item, ref }; +} diff --git a/src/lib/billing/customers.ts b/src/lib/billing/customers.ts new file mode 100644 index 0000000..a2d7cdd --- /dev/null +++ b/src/lib/billing/customers.ts @@ -0,0 +1,43 @@ +/** + * What links a user to each payment provider: `billing_customers` (one row per user). The two providers differ on purpose: + * + * Paddle customers are found BY E-MAIL and shared by every app on the account, so the server records which customer is the user's + * (`paddle_customer_id`, UNIQUE: a Paddle customer can never belong to two users; paddle/customers.ts). + * Polar the customer IS the user: the server sets `external_customer_id = user id` when it creates the checkout, and every Polar payload + * carries it back. Nothing to look up; only the moment of the first checkout is kept (`polar_linked_at`), so the sync knows to ask. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Provider } from "./types"; + +export interface CustomerLinks { + paddleCustomerId: string | null; + polarLinked: boolean; +} + +export const NO_LINKS: CustomerLinks = { paddleCustomerId: null, polarLinked: false }; + +/** Reads the links of a user. With a user-session client, Row Level Security lets a user read only their own row. */ +export async function readCustomerLinks(db: SupabaseClient, userId: string): Promise { + const { data, error } = await db.from("billing_customers").select("paddle_customer_id, polar_linked_at").eq("user_id", userId).limit(1); + if (error) throw new Error(`read billing customer: ${error.message}`); + const row = data?.[0] as { paddle_customer_id: string | null; polar_linked_at: string | null } | undefined; + if (!row) return NO_LINKS; + return { paddleCustomerId: row.paddle_customer_id || null, polarLinked: Boolean(row.polar_linked_at) }; +} + +/** The providers this user has been linked to (a checkout was opened with them). */ +export function linkedProviders(links: CustomerLinks): Provider[] { + const providers: Provider[] = []; + if (links.paddleCustomerId) providers.push("paddle"); + if (links.polarLinked) providers.push("polar"); + return providers; +} + +/** Remembers that this user opened a Polar checkout (idempotent: the first time wins). Needs the SERVICE-ROLE client. */ +export async function markPolarLinked(admin: SupabaseClient, user: { id: string }, now: () => string = () => new Date().toISOString()): Promise { + // A row may exist already (a Paddle link): create it only when missing, then set the moment only while it is still empty. + const created = await admin.from("billing_customers").upsert({ user_id: user.id }, { onConflict: "user_id", ignoreDuplicates: true }); + if (created.error) throw new Error(`link billing customer: ${created.error.message}`); + const linked = await admin.from("billing_customers").update({ polar_linked_at: now() }).eq("user_id", user.id).is("polar_linked_at", null); + if (linked.error) throw new Error(`link billing customer to Polar: ${linked.error.message}`); +} diff --git a/src/lib/billing/delivery.ts b/src/lib/billing/delivery.ts new file mode 100644 index 0000000..609f04c --- /dev/null +++ b/src/lib/billing/delivery.ts @@ -0,0 +1,38 @@ +/** + * The part of a webhook delivery that is the same for every provider: claim it (idempotency), apply it, record the outcome. What "apply" means + * is the provider's adapter (paddle/webhook.ts, polar/webhook.ts). Pure over a `BillingStore`: tests/billing-delivery.test.ts. + * + * The rules that keep this safe, on an account that is shared with other apps and that anybody on the internet can open a checkout on: + * + * 1. Who gets credited is decided by what the SERVER linked, never by anything the browser sent: the Paddle customer linked to the user + * before the checkout opened (billing_customers), or the userId the server set as the Polar customer's external id. A payment from a + * customer we do not know is ignored. + * 2. Only OUR items count: an id that is not in `.env` (PADDLE_PRICE_*, POLAR_PRODUCT_*) belongs to another app or to nobody: ignored. + * 3. Events are idempotent (providers deliver at least once) and may arrive out of order: an older state never overwrites a newer one. + * 4. A failure throws, the route answers 500 and the provider delivers again later. Ignoring is a decision, never an accident. + */ +import { UnknownUserError, type BillingStore, type ProcessResult, type WebhookEventRecord } from "./types"; + +export async function processDelivery( + store: BillingStore, + event: WebhookEventRecord, + apply: () => Promise, +): Promise { + const claimed = await store.claimEvent(event); + if (!claimed) return { status: "duplicate" }; + try { + let result: ProcessResult; + try { + result = await apply(); + } catch (error) { + // A payment of a user who was deleted: nothing to credit, and a retry can never change that. + if (!(error instanceof UnknownUserError)) throw error; + result = { status: "ignored", note: "unknown_user" }; + } + await store.finishEvent(event.provider, event.event_id, result.status, result.note); + return result; + } catch (error) { + await store.failEvent(event.provider, event.event_id, error instanceof Error ? error.message : String(error)).catch(() => undefined); + throw error; + } +} diff --git a/src/lib/billing/entitlements.ts b/src/lib/billing/entitlements.ts new file mode 100644 index 0000000..c2389e9 --- /dev/null +++ b/src/lib/billing/entitlements.ts @@ -0,0 +1,146 @@ +/** + * What a user has bought. This is the function the rest of YOUR app calls to decide what to show or allow: + * + * const entitlement = await getEntitlement(supabase, user.id); // a Supabase client with the user's session + * if (!entitlement.active) redirect("/pricing"); + * if (entitlement.planKey === "team") { ... } + * + * It does not care WHICH provider (Paddle or Polar) bills the user, so switching `BILLING_PROVIDER` never touches the code that gates your + * features: a user who subscribed through Paddle keeps their access while new customers go through Polar, and the other way round. + * + * It reads `billing_subscriptions` through Row Level Security, so a user can only ever read their own rows. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { linkedProviders, type CustomerLinks } from "./customers"; +import type { Provider } from "./types"; + +export interface Entitlement { + /** Has a running subscription (active or in a trial). Paused, canceled and unpaid ones are not active. */ + active: boolean; + /** The plan key (src/config/billing.ts) of the running subscription, or of the one that is past due. */ + planKey: string | null; + interval: string | null; + status: string | null; + /** When the paid period ends (or the trial). */ + currentPeriodEnd: string | null; + /** The user cancelled: it stays active until `currentPeriodEnd`, then ends. */ + cancelAtPeriodEnd: boolean; + /** The last payment failed and the provider is retrying. Not `active`: decide yourself whether to keep access during the retries. */ + pastDue: boolean; + /** The provider's id of the subscription. */ + subscriptionId: string | null; + /** Who bills it: "paddle" or "polar". */ + provider: Provider | null; +} + +export const NO_ENTITLEMENT: Entitlement = { + active: false, + planKey: null, + interval: null, + status: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + pastDue: false, + subscriptionId: null, + provider: null, +}; + +export interface StoredSubscription { + id: string; + provider: Provider; + status: string; + plan_key: string | null; + billing_interval: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; +} + +function toEntitlement(row: StoredSubscription): Entitlement { + return { + active: row.status === "active" || row.status === "trialing", + planKey: row.plan_key, + interval: row.billing_interval, + status: row.status, + currentPeriodEnd: row.current_period_end, + cancelAtPeriodEnd: row.cancel_at_period_end, + pastDue: row.status === "past_due", + subscriptionId: row.id, + provider: row.provider, + }; +} + +const RANK: Record = { active: 3, trialing: 3, past_due: 2, paused: 1, canceled: 0 }; + +/** Newest period first within a status: compared as instants (providers write timestamps with and without milliseconds, and Postgres its own way). */ +const periodEnd = (row: StoredSubscription): number => { + const instant = Date.parse(String(row.current_period_end ?? "")); + return Number.isNaN(instant) ? 0 : instant; +}; + +/** The one subscription that speaks for a user with several rows: a running one beats a past-due one beats the rest; newest period wins a tie. */ +export function bestSubscription(rows: readonly StoredSubscription[]): StoredSubscription | null { + return ( + [...rows].sort((a, b) => { + const byStatus = (RANK[b.status] ?? 0) - (RANK[a.status] ?? 0); + return byStatus !== 0 ? byStatus : periodEnd(b) - periodEnd(a); + })[0] ?? null + ); +} + +export function entitlementFrom(rows: readonly StoredSubscription[]): Entitlement { + const best = bestSubscription(rows); + return best ? toEntitlement(best) : NO_ENTITLEMENT; +} + +/** + * Which provider's portal and sync a user belongs to: the provider of their best subscription (a canceled one still points at the place + * where its invoices are), else of their latest payment, else a provider they started a checkout with (`preferred`, the one that is used + * for new checkouts, first). null: they never started one. + */ +export function providerForUser(input: { + subscriptions: readonly StoredSubscription[]; + lastPaymentProvider: Provider | null; + links: CustomerLinks; + preferred?: Provider | null; +}): Provider | null { + const best = bestSubscription(input.subscriptions); + if (best) return best.provider; + if (input.lastPaymentProvider) return input.lastPaymentProvider; + const linked = linkedProviders(input.links); + if (input.preferred && linked.includes(input.preferred)) return input.preferred; + return linked[0] ?? null; +} + +/** All the subscription rows of a user, of every provider. */ +export async function readSubscriptions(supabase: SupabaseClient, userId: string): Promise { + const { data, error } = await supabase + .from("billing_subscriptions") + .select("id, provider, status, plan_key, billing_interval, current_period_end, cancel_at_period_end") + .eq("user_id", userId); + if (error) throw new Error(`read subscriptions: ${error.message}`); + return (data ?? []) as StoredSubscription[]; +} + +/** The provider of the user's most recent payment. */ +export async function readLastPaymentProvider(supabase: SupabaseClient, userId: string): Promise { + const { data, error } = await supabase.from("billing_transactions").select("provider").eq("user_id", userId).order("billed_at", { ascending: false, nullsFirst: false }).limit(1); + if (error) throw new Error(`read transactions: ${error.message}`); + const provider = data?.[0]?.provider; + return provider === "paddle" || provider === "polar" ? provider : null; +} + +export async function getEntitlement(supabase: SupabaseClient, userId: string): Promise { + return entitlementFrom(await readSubscriptions(supabase, userId)); +} + +/** Has this user paid for a one-time product (`billingConfig.products`) and not had it fully refunded? Whichever provider took the payment. */ +export async function hasPurchased(supabase: SupabaseClient, userId: string, productKey: string): Promise { + const { data, error } = await supabase + .from("billing_transactions") + .select("total, refunded") + .eq("user_id", userId) + .eq("item_key", productKey) + .eq("status", "completed"); + if (error) throw new Error(`read transactions: ${error.message}`); + return (data ?? []).some((row) => Number(row.refunded) < Number(row.total)); +} diff --git a/src/lib/billing/paddle/adapter.ts b/src/lib/billing/paddle/adapter.ts new file mode 100644 index 0000000..88ac854 --- /dev/null +++ b/src/lib/billing/paddle/adapter.ts @@ -0,0 +1,46 @@ +import type { ProviderAdapter } from "../adapter"; +import { itemForProviderRef } from "../catalog"; +import { readCustomerLinks } from "../customers"; +import { supabaseBillingStore } from "../store"; +import { createPortalSession, createTransaction } from "./api"; +import { getPaddleServerConfig, paddleClientConfig } from "./config"; +import { ensureBillingCustomer } from "./customers"; +import { syncPaddleCustomer } from "./sync"; +import type { WebhookContext } from "./webhook"; + +/** What the webhook and the sync need to know about this app: which prices are ours, and the tag that tells our events from another app's. */ +export function paddleWebhookContext(appTag: string): WebhookContext { + return { appTag, itemForPriceId: (priceId) => itemForProviderRef("paddle", priceId) }; +} + +export const paddleAdapter: ProviderAdapter = { + provider: "paddle", + requiresVerifiedEmail: true, + + async createCheckout({ db, user, item, ref }) { + const config = getPaddleServerConfig(); + const client = paddleClientConfig(); + if (!client.token) throw new Error("NEXT_PUBLIC_PADDLE_CLIENT_TOKEN is not set"); + if (!user.email) throw new Error("a Paddle checkout needs the user's e-mail"); + const customerId = await ensureBillingCustomer(db, { id: user.id, email: user.email }, config.appTag); + const transactionId = await createTransaction({ + priceId: ref, + customerId, + customData: { app: config.appTag, user_id: user.id, item_key: item.key, interval: item.interval }, + }); + return { provider: "paddle", transactionId, clientToken: client.token, environment: client.environment }; + }, + + async createPortal({ db, user, subscriptionIds }) { + const customerId = (await readCustomerLinks(db, user.id)).paddleCustomerId; + if (!customerId) return null; + const session = await createPortalSession(customerId, subscriptionIds); + return { url: session.overview, cancelUrl: session.cancel }; + }, + + async sync({ db, user }) { + const customerId = (await readCustomerLinks(db, user.id)).paddleCustomerId; + if (!customerId) return; + await syncPaddleCustomer(supabaseBillingStore(db), customerId, user.id, paddleWebhookContext(getPaddleServerConfig().appTag)); + }, +}; diff --git a/src/lib/billing/paddle/api.ts b/src/lib/billing/paddle/api.ts new file mode 100644 index 0000000..502d145 --- /dev/null +++ b/src/lib/billing/paddle/api.ts @@ -0,0 +1,113 @@ +/** + * The Paddle REST API, with `fetch` (no SDK, so nothing to install or keep up to date). Server only: it carries the secret API key. + * https://developer.paddle.com/api-reference/overview + */ +import type { JsonObject } from "../types"; +import { getPaddleServerConfig, PADDLE_API } from "./config"; +import type { PaddleSubscription, PaddleTransaction } from "./types"; + +/** An error answer from Paddle. Plain fields (Node's type stripping does not support constructor parameter properties). */ +export class PaddleApiError extends Error { + status: number; + code: string; + detail: string; + constructor(status: number, code: string, detail: string, path: string) { + super(`Paddle ${path} -> ${status} ${code}: ${detail}`); + this.name = "PaddleApiError"; + this.status = status; + this.code = code; + this.detail = detail; + } +} + +interface PaddleEnvelope { + data?: T; + error?: { code?: string; detail?: string }; + meta?: { pagination?: { has_more?: boolean; next?: string } }; +} + +type Fetch = typeof fetch; + +export async function paddleFetch(path: string, init: { method?: string; body?: unknown } = {}, fetcher: Fetch = fetch): Promise> { + const config = getPaddleServerConfig(); + const response = await fetcher(`${config.apiBase}${path}`, { + method: init.method ?? "GET", + headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json", "Paddle-Version": PADDLE_API.version }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + cache: "no-store", + }); + const json = (await response.json().catch(() => ({}))) as PaddleEnvelope; + if (!response.ok) throw new PaddleApiError(response.status, json.error?.code ?? "unknown", json.error?.detail ?? response.statusText, path); + return json; +} + +// ── customers ──────────────────────────────────────────────────────────────────────────────────────── + +/** The id of the Paddle customer with this e-mail, if any. A customer belongs to the whole Paddle account, so other apps may have made it. */ +export async function findCustomerIdByEmail(email: string, fetcher?: Fetch): Promise { + const json = await paddleFetch<{ id: string }[]>(`/customers?email=${encodeURIComponent(email)}`, {}, fetcher); + return json.data?.[0]?.id ?? null; +} + +/** + * The Paddle customer for this e-mail: found, or created. Creating a customer whose e-mail exists is a 409 that names the existing id, + * so two apps (or two clicks) racing each other still end up with the same customer. + */ +export async function ensureCustomerId(email: string, customData: JsonObject, fetcher?: Fetch): Promise { + const existing = await findCustomerIdByEmail(email, fetcher); + if (existing) return existing; + try { + const json = await paddleFetch<{ id: string }>("/customers", { method: "POST", body: { email, custom_data: customData } }, fetcher); + if (json.data?.id) return json.data.id; + } catch (error) { + if (!(error instanceof PaddleApiError) || error.status !== 409) throw error; + const named = /\b(ctm_[a-z0-9]+)\b/.exec(error.detail)?.[1]; + if (named) return named; + const again = await findCustomerIdByEmail(email, fetcher); + if (again) return again; + } + throw new Error("Paddle did not return a customer id"); +} + +// ── transactions and the customer portal ───────────────────────────────────────────────────────────── + +/** + * A transaction THIS server creates for one price, to be opened in the browser by its id (`Paddle.Checkout.open({ transactionId })`). + * The price, the quantity and the customer are fixed here; the browser cannot change them. + */ +export async function createTransaction(input: { priceId: string; customerId: string; customData: JsonObject }, fetcher?: Fetch): Promise { + const json = await paddleFetch<{ id: string }>( + "/transactions", + { + method: "POST", + body: { items: [{ price_id: input.priceId, quantity: 1 }], customer_id: input.customerId, collection_mode: "automatic", custom_data: input.customData }, + }, + fetcher, + ); + if (!json.data?.id) throw new Error("Paddle did not return a transaction id"); + return json.data.id; +} + +/** Short-lived, already-signed-in links to Paddle's customer portal (payment method, invoices, cancel). Make a new one per click. */ +export async function createPortalSession(customerId: string, subscriptionIds: readonly string[] = [], fetcher?: Fetch): Promise<{ overview: string; cancel: string | null }> { + const json = await paddleFetch<{ urls?: { general?: { overview?: string }; subscriptions?: { cancel_subscription?: string }[] } }>( + `/customers/${encodeURIComponent(customerId)}/portal-sessions`, + { method: "POST", body: subscriptionIds.length > 0 ? { subscription_ids: subscriptionIds } : {} }, + fetcher, + ); + const overview = json.data?.urls?.general?.overview; + if (!overview) throw new Error("Paddle did not return a portal link"); + return { overview, cancel: json.data?.urls?.subscriptions?.[0]?.cancel_subscription ?? null }; +} + +// ── reads for the self-heal sync ───────────────────────────────────────────────────────────────────── + +export async function listSubscriptions(customerId: string, fetcher?: Fetch): Promise<(PaddleSubscription & { updated_at?: string })[]> { + const json = await paddleFetch<(PaddleSubscription & { updated_at?: string })[]>(`/subscriptions?customer_id=${encodeURIComponent(customerId)}&per_page=50`, {}, fetcher); + return json.data ?? []; +} + +export async function listCompletedTransactions(customerId: string, fetcher?: Fetch): Promise { + const json = await paddleFetch(`/transactions?customer_id=${encodeURIComponent(customerId)}&status=completed&order_by=billed_at%5BDESC%5D&per_page=50`, {}, fetcher); + return json.data ?? []; +} diff --git a/src/lib/billing/paddle/config.ts b/src/lib/billing/paddle/config.ts new file mode 100644 index 0000000..0ce3fce --- /dev/null +++ b/src/lib/billing/paddle/config.ts @@ -0,0 +1,89 @@ +/** + * Paddle environment. Reads `process.env` only (no `server-only`, so the tests can import it); never import it from a client component, the + * API key is in here. The browser gets its token through `paddleClientConfig()`. Which of OUR items a price id is lives in ../catalog.ts. + */ +import type { Env } from "../types"; + +export type PaddleEnvironment = "sandbox" | "production"; + +export const PADDLE_API = { + production: "https://api.paddle.com", + sandbox: "https://sandbox-api.paddle.com", + version: "1", +} as const; + +/** What a mistake in the environment is called, so a route can answer 503 `billing_not_configured` instead of a 500. */ +export class PaddleConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "PaddleConfigError"; + } +} + +export function paddleEnvironment(env: Env = process.env): PaddleEnvironment { + return env.NEXT_PUBLIC_PADDLE_ENVIRONMENT === "production" ? "production" : "sandbox"; +} + +const APP_TAG = /^[a-z0-9][a-z0-9_-]{1,40}$/; + +export interface PaddleServerConfig { + apiKey: string; + apiBase: string; + environment: PaddleEnvironment; + /** Unique per app on a shared Paddle account: stamped on everything this app creates and checked on everything it receives. */ + appTag: string; + /** Needed by the webhook route only. */ + webhookSecret: string | null; +} + +/** + * The server-side configuration, or a PaddleConfigError saying what is missing. Three mistakes are refused on purpose, because each one + * silently charges (or fails to charge) the wrong account: a sandbox key in a production environment (and the reverse), and a missing app tag. + */ +export function getPaddleServerConfig(env: Env = process.env): PaddleServerConfig { + const apiKey = env.PADDLE_API_KEY?.trim(); + if (!apiKey) throw new PaddleConfigError("PADDLE_API_KEY is not set"); + const environment = paddleEnvironment(env); + if (environment === "production" && !apiKey.startsWith("pdl_live_")) { + throw new PaddleConfigError("NEXT_PUBLIC_PADDLE_ENVIRONMENT is production but PADDLE_API_KEY is not a live key (pdl_live_...)"); + } + if (environment === "sandbox" && !apiKey.startsWith("pdl_sdbx_")) { + throw new PaddleConfigError("NEXT_PUBLIC_PADDLE_ENVIRONMENT is sandbox (the default) but PADDLE_API_KEY is not a sandbox key (pdl_sdbx_...)"); + } + const appTag = env.PADDLE_APP_TAG?.trim() ?? ""; + if (!APP_TAG.test(appTag)) { + throw new PaddleConfigError("PADDLE_APP_TAG must be set to a short unique name for this app (lowercase letters, digits, - and _), e.g. my-app"); + } + return { + apiKey, + apiBase: PADDLE_API[environment], + environment, + appTag, + webhookSecret: env.PADDLE_WEBHOOK_SECRET?.trim() || null, + }; +} + +/** Why the server cannot talk to Paddle (portal, sync, webhook), or null: the key, the app tag are there and consistent. */ +export function paddleServerProblem(env: Env = process.env): string | null { + try { + getPaddleServerConfig(env); + } catch (error) { + if (error instanceof PaddleConfigError) return error.message; + throw error; + } + return null; +} + +/** Why Paddle cannot take a CHECKOUT (a sentence for the server log), or null when it can: the server side above plus the browser token. */ +export function paddleConfigProblem(env: Env = process.env): string | null { + return paddleServerProblem(env) ?? (env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN?.trim() ? null : "NEXT_PUBLIC_PADDLE_CLIENT_TOKEN is not set"); +} + +export function paddleConfigured(env: Env = process.env): boolean { + return paddleConfigProblem(env) === null; +} + +/** The token Paddle.js needs in the browser. Public by design (`test_...` / `live_...`). */ +export function paddleClientConfig(env: Env = process.env): { token: string | null; environment: PaddleEnvironment } { + return { token: env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN?.trim() || null, environment: paddleEnvironment(env) }; +} diff --git a/src/lib/billing/paddle/customers.ts b/src/lib/billing/paddle/customers.ts new file mode 100644 index 0000000..9f8e9ba --- /dev/null +++ b/src/lib/billing/paddle/customers.ts @@ -0,0 +1,48 @@ +/** + * Links a signed-in user to a Paddle customer BEFORE a checkout is opened. That link (billing_customers.paddle_customer_id, UNIQUE) is what + * every later webhook is credited through, so it is made only here, on the server, with the service role, from the e-mail of the authenticated + * session. The UNIQUE constraint is what makes it impossible for a Paddle customer to belong to two users. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { JsonObject } from "../types"; +import { ensureCustomerId } from "./api"; + +/** Another user is already linked to this Paddle customer (two accounts, one e-mail). Needs a human to look at it. */ +export class CustomerConflictError extends Error { + constructor() { + super("this Paddle customer is already linked to another user"); + this.name = "CustomerConflictError"; + } +} + +const UNIQUE_VIOLATION = "23505"; + +export async function ensureBillingCustomer( + db: SupabaseClient, + user: { id: string; email: string }, + appTag: string, + createCustomerId: (email: string, customData: JsonObject) => Promise = ensureCustomerId, +): Promise { + const stored = await db.from("billing_customers").select("paddle_customer_id").eq("user_id", user.id).limit(1); + if (stored.error) throw new Error(`read billing customer: ${stored.error.message}`); + const known = stored.data?.[0]?.paddle_customer_id as string | undefined; + if (known) return known; + + const customerId = await createCustomerId(user.email, { app: appTag, user_id: user.id }); + + // The user's row may exist already (a Polar link): create it only when missing, then set the Paddle customer only while it is still empty. + const created = await db.from("billing_customers").upsert({ user_id: user.id }, { onConflict: "user_id", ignoreDuplicates: true }); + if (created.error) throw new Error(`link billing customer: ${created.error.message}`); + const linked = await db.from("billing_customers").update({ paddle_customer_id: customerId }).eq("user_id", user.id).is("paddle_customer_id", null).select("paddle_customer_id"); + if (linked.error) { + if (linked.error.code === UNIQUE_VIOLATION) throw new CustomerConflictError(); // the customer belongs to somebody else + throw new Error(`link billing customer: ${linked.error.message}`); + } + if ((linked.data?.length ?? 0) > 0) return customerId; + + // Nothing was updated: a second request of this user linked first (fine: use that one). + const again = await db.from("billing_customers").select("paddle_customer_id").eq("user_id", user.id).limit(1); + const winner = again.data?.[0]?.paddle_customer_id as string | undefined; + if (winner) return winner; + throw new Error("could not link the Paddle customer"); +} diff --git a/src/lib/billing/paddle/route-helpers.ts b/src/lib/billing/paddle/route-helpers.ts new file mode 100644 index 0000000..455919c --- /dev/null +++ b/src/lib/billing/paddle/route-helpers.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { errorResponse } from "../route-helpers"; +import { getPaddleServerConfig, PaddleConfigError, type PaddleServerConfig } from "./config"; + +/** The Paddle configuration, or the 503 to answer with when it is incomplete (`billing_not_configured`, with the reason in the server log). */ +export function paddleConfigOrResponse(): { config: PaddleServerConfig } | { response: Response } { + try { + return { config: getPaddleServerConfig() }; + } catch (error) { + if (!(error instanceof PaddleConfigError)) throw error; + console.error(`[paddle] ${error.message}`); + return { response: errorResponse("billing_not_configured", 503) }; + } +} diff --git a/src/lib/billing/paddle/signature.ts b/src/lib/billing/paddle/signature.ts new file mode 100644 index 0000000..695286d --- /dev/null +++ b/src/lib/billing/paddle/signature.ts @@ -0,0 +1,39 @@ +/** + * Verifies the `Paddle-Signature` header of a webhook: `ts=;h1=`, an HMAC-SHA256 of `${ts}:${rawBody}` with the + * notification destination's secret. The RAW request body must be used, byte for byte: a body that was parsed and re-serialised never matches. + * + * While a secret is being rotated Paddle signs with both secrets and sends several `h1` values: any one that matches is enough. + * Redelivery of an old, valid request is handled by event-id idempotency (webhook.ts), so the timestamp only has to absorb clock skew. + * https://developer.paddle.com/webhooks/signature-verification + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const SIGNATURE_TOLERANCE_SECONDS = 300; + +export function verifyPaddleSignature(secret: string, rawBody: string, header: string | null | undefined, nowMs = Date.now()): boolean { + if (!secret || !header) return false; + let ts: string | null = null; + const candidates: string[] = []; + for (const part of header.split(";")) { + const at = part.indexOf("="); + if (at <= 0) continue; + const key = part.slice(0, at).trim(); + const value = part.slice(at + 1).trim(); + if (key === "ts") ts = value; + else if (key === "h1") candidates.push(value); + } + if (!ts || candidates.length === 0) return false; + const age = Math.abs(nowMs / 1000 - Number(ts)); + if (!Number.isFinite(age) || age > SIGNATURE_TOLERANCE_SECONDS) return false; + const expected = Buffer.from(createHmac("sha256", secret).update(`${ts}:${rawBody}`).digest("hex")); + return candidates.some((candidate) => { + const given = Buffer.from(candidate); + return given.length === expected.length && timingSafeEqual(given, expected); + }); +} + +/** For tests and for `scripts/paddle-setup.mjs send-test`: a header the verifier above accepts. */ +export function signPaddleBody(secret: string, rawBody: string, nowMs = Date.now()): string { + const ts = String(Math.floor(nowMs / 1000)); + return `ts=${ts};h1=${createHmac("sha256", secret).update(`${ts}:${rawBody}`).digest("hex")}`; +} diff --git a/src/lib/billing/paddle/sync.ts b/src/lib/billing/paddle/sync.ts new file mode 100644 index 0000000..865696f --- /dev/null +++ b/src/lib/billing/paddle/sync.ts @@ -0,0 +1,41 @@ +/** + * Self-heal: reads a customer's subscriptions and payments from the Paddle API and applies them the way the webhook would. The success page + * calls it (through /api/billing/sync) while it waits for the webhook, and it repairs a delivery that was lost. Idempotent, and safe next to the + * webhook: a subscription is only written if its Paddle `updated_at` is not older than what is stored. + */ +import type { BillingStore } from "../types"; +import { listCompletedTransactions, listSubscriptions } from "./api"; +import type { PaddleSubscription, PaddleTransaction } from "./types"; +import { subscriptionRowFrom, transactionRowFrom, type WebhookContext } from "./webhook"; + +export interface SyncSource { + listSubscriptions: (customerId: string) => Promise<(PaddleSubscription & { updated_at?: string })[]>; + listCompletedTransactions: (customerId: string) => Promise; +} + +export const paddleSyncSource: SyncSource = { listSubscriptions, listCompletedTransactions }; + +export async function syncPaddleCustomer( + store: BillingStore, + customerId: string, + userId: string, + ctx: WebhookContext, + source: SyncSource = paddleSyncSource, + now: () => string = () => new Date().toISOString(), +): Promise<{ subscriptions: number; transactions: number }> { + let subscriptions = 0; + let transactions = 0; + for (const subscription of await source.listSubscriptions(customerId)) { + const row = subscriptionRowFrom(subscription, userId, subscription.updated_at ?? now(), ctx); + if ("ignored" in row) continue; + await store.upsertSubscription(row); + subscriptions += 1; + } + for (const transaction of await source.listCompletedTransactions(customerId)) { + const row = transactionRowFrom(transaction, userId, ctx); + if ("ignored" in row) continue; + await store.upsertTransaction(row); + transactions += 1; + } + return { subscriptions, transactions }; +} diff --git a/src/lib/billing/paddle/types.ts b/src/lib/billing/paddle/types.ts new file mode 100644 index 0000000..4284a1e --- /dev/null +++ b/src/lib/billing/paddle/types.ts @@ -0,0 +1,58 @@ +/** + * The parts of Paddle Billing's payloads this template reads (the provider-neutral rows it writes are in ../types.ts). + * Paddle sends money as strings of MINOR units ("999" = 9.99) and every field may be missing: everything optional is read defensively. + * Reference: https://developer.paddle.com/webhooks/overview + */ +import type { JsonObject } from "../types"; + +export interface PaddleEvent { + event_id: string; + event_type: string; + occurred_at: string; + notification_id?: string; + data: T; +} + +export interface PaddlePrice { + id: string; + product_id?: string; + unit_price?: { amount?: string; currency_code?: string }; + billing_cycle?: { interval?: string; frequency?: number } | null; +} + +export interface PaddleSubscription { + id: string; + status: string; + customer_id: string; + currency_code?: string; + custom_data?: JsonObject | null; + canceled_at?: string | null; + current_billing_period?: { starts_at?: string; ends_at?: string } | null; + billing_cycle?: { interval?: string; frequency?: number } | null; + scheduled_change?: { action?: string; effective_at?: string; resume_at?: string | null } | null; + items?: { quantity?: number; recurring?: boolean; price?: PaddlePrice }[]; +} + +export interface PaddleTransaction { + id: string; + status: string; + customer_id?: string | null; + subscription_id?: string | null; + origin?: string; + currency_code?: string; + custom_data?: JsonObject | null; + invoice_number?: string | null; + billed_at?: string | null; + items?: { quantity?: number; price_id?: string; price?: PaddlePrice }[]; + details?: { totals?: { subtotal?: string; tax?: string; total?: string; grand_total?: string; currency_code?: string } }; +} + +export interface PaddleAdjustment { + id: string; + action: string; + status: string; + transaction_id: string; + customer_id?: string | null; + currency_code?: string; + totals?: { total?: string; currency_code?: string }; +} diff --git a/src/lib/billing/paddle/webhook.ts b/src/lib/billing/paddle/webhook.ts new file mode 100644 index 0000000..eef7efe --- /dev/null +++ b/src/lib/billing/paddle/webhook.ts @@ -0,0 +1,182 @@ +/** + * What a Paddle webhook does to the database: Paddle's payloads mapped to the provider-neutral rows (../types.ts), applied through a + * `BillingStore`. Pure logic, so every flow below is unit-tested without a database or a network: tests/paddle-webhook.test.ts. The rules that + * keep it safe (who is credited, which items count, idempotency, order) are listed in ../delivery.ts; for Paddle they mean: + * + * - the user is the one the server linked to the Paddle CUSTOMER id before the checkout opened (billing_customers + the index); + * - an event whose items are not in `.env` (PADDLE_PRICE_*), or whose custom data names another app, is ignored: the account is shared. + */ +import type { CatalogItem } from "@/config/billing"; +import { processDelivery } from "../delivery"; +import { + SUBSCRIPTION_STATUSES, + type AdjustmentRow, + type BillingStore, + type JsonObject, + type ProcessResult, + type SubscriptionRow, + type SubscriptionStatus, + type TransactionRow, +} from "../types"; +import type { PaddleAdjustment, PaddleEvent, PaddleSubscription, PaddleTransaction } from "./types"; + +export interface WebhookContext { + /** Looks up which of our catalog items a Paddle price id is (null: not ours). */ + itemForPriceId: (priceId: string | undefined | null) => CatalogItem | null; + appTag: string; +} + +const SUBSCRIPTION_EVENTS = new Set([ + "subscription.created", + "subscription.activated", + "subscription.updated", + "subscription.canceled", + "subscription.past_due", + "subscription.paused", + "subscription.resumed", +]); + +/** The Paddle events `npm run paddle:webhook` subscribes to: exactly the ones handled here (tests/billing-scripts.test.ts keeps the two lists equal). */ +export const PADDLE_EVENTS: readonly string[] = [...SUBSCRIPTION_EVENTS, "transaction.completed", "adjustment.created", "adjustment.updated"]; + +/** Paddle money is a string of minor units. Anything that is not a whole number counts as 0 rather than breaking the whole event. */ +export function minor(value: unknown): number { + const n = typeof value === "string" || typeof value === "number" ? Number(value) : Number.NaN; + return Number.isSafeInteger(n) ? n : 0; +} + +function isSubscriptionStatus(value: string): value is SubscriptionStatus { + return (SUBSCRIPTION_STATUSES as readonly string[]).includes(value); +} + +/** True when the payload says it was made by a DIFFERENT app of ours on the same Paddle account. A payload with no tag at all is not foreign. */ +function isForeign(customData: JsonObject | null | undefined, appTag: string): boolean { + const tag = customData?.app; + return typeof tag === "string" && tag !== appTag; +} + +/** The pure part of a subscription event: the row to store, or why it is not ours. `userId` comes from the customer link. */ +export function subscriptionRowFrom(data: PaddleSubscription, userId: string, occurredAt: string, ctx: WebhookContext): SubscriptionRow | { ignored: string } { + if (isForeign(data.custom_data, ctx.appTag)) return { ignored: "other_app" }; + const priced = (data.items ?? []).map((entry) => ({ entry, item: ctx.itemForPriceId(entry.price?.id) })).find((candidate) => candidate.item); + if (!priced?.item || !priced.entry.price) return { ignored: "not_our_price" }; + if (!isSubscriptionStatus(data.status)) return { ignored: `unknown_status_${data.status}` }; + const price = priced.entry.price; + const change = data.scheduled_change ?? null; + return { + id: data.id, + provider: "paddle", + user_id: userId, + customer_id: data.customer_id, + status: data.status, + plan_key: priced.item.kind === "plan" ? priced.item.key : null, + price_id: price.id, + billing_interval: data.billing_cycle?.interval ?? price.billing_cycle?.interval ?? null, + quantity: priced.entry.quantity ?? 1, + currency: data.currency_code ?? price.unit_price?.currency_code ?? null, + unit_amount: price.unit_price?.amount === undefined ? null : minor(price.unit_price.amount), + current_period_start: data.current_billing_period?.starts_at ?? null, + current_period_end: data.current_billing_period?.ends_at ?? null, + cancel_at_period_end: change?.action === "cancel", + scheduled_change: change ? (change as JsonObject) : null, + canceled_at: data.canceled_at ?? null, + event_occurred_at: occurredAt, + }; +} + +/** The pure part of `transaction.completed`: the row to store, or why it is not ours. */ +export function transactionRowFrom(data: PaddleTransaction, userId: string, ctx: WebhookContext): TransactionRow | { ignored: string } { + if (isForeign(data.custom_data, ctx.appTag)) return { ignored: "other_app" }; + const priced = (data.items ?? []).map((entry) => ({ entry, item: ctx.itemForPriceId(entry.price_id ?? entry.price?.id) })).find((candidate) => candidate.item); + if (!priced?.item) return { ignored: "not_our_price" }; + const totals = data.details?.totals; + return { + id: data.id, + provider: "paddle", + user_id: userId, + customer_id: data.customer_id ?? "", + subscription_id: data.subscription_id ?? null, + status: data.status, + origin: data.origin ?? null, + price_id: priced.entry.price_id ?? priced.entry.price?.id ?? null, + item_key: priced.item.key, + currency: data.currency_code ?? totals?.currency_code ?? "", + subtotal: minor(totals?.subtotal), + tax: minor(totals?.tax), + total: minor(totals?.grand_total ?? totals?.total), + invoice_number: data.invoice_number ?? null, + billed_at: data.billed_at ?? null, + }; +} + +async function applySubscription(store: BillingStore, event: PaddleEvent, ctx: WebhookContext): Promise { + const userId = await store.userIdForCustomer("paddle", event.data.customer_id); + if (!userId) return { status: "ignored", note: "unknown_customer" }; + const row = subscriptionRowFrom(event.data, userId, event.occurred_at, ctx); + if ("ignored" in row) return { status: "ignored", note: row.ignored }; + await store.upsertSubscription(row); + return { status: "processed", note: `${event.event_type} ${row.status}` }; +} + +async function applyTransaction(store: BillingStore, event: PaddleEvent, ctx: WebhookContext): Promise { + if (!event.data.customer_id) return { status: "ignored", note: "no_customer" }; + const userId = await store.userIdForCustomer("paddle", event.data.customer_id); + if (!userId) return { status: "ignored", note: "unknown_customer" }; + const row = transactionRowFrom(event.data, userId, ctx); + if ("ignored" in row) return { status: "ignored", note: row.ignored }; + await store.upsertTransaction(row); + return { status: "processed", note: "transaction recorded" }; +} + +async function applyAdjustment(store: BillingStore, event: PaddleEvent): Promise { + const transaction = await store.getTransaction("paddle", event.data.transaction_id); + // An adjustment carries no custom data and no price: it is ours only if we recorded the payment it adjusts. + if (!transaction) return { status: "ignored", note: "unknown_transaction" }; + const row: AdjustmentRow = { + id: event.data.id, + provider: "paddle", + transaction_id: transaction.id, + user_id: transaction.user_id, + action: event.data.action, + status: event.data.status, + currency: event.data.currency_code ?? event.data.totals?.currency_code ?? "", + total: minor(event.data.totals?.total), + event_occurred_at: event.occurred_at, + }; + await store.upsertAdjustment(row); + await store.recomputeRefunded("paddle", transaction.id); + return { status: "processed", note: `${event.data.action} ${event.data.status}` }; +} + +/** Applies one verified event. Returns what it ended as, throws when it could not be applied (the caller answers 500, Paddle retries). */ +export async function applyPaddleEvent(store: BillingStore, event: PaddleEvent, ctx: WebhookContext): Promise { + if (SUBSCRIPTION_EVENTS.has(event.event_type)) return applySubscription(store, event as unknown as PaddleEvent, ctx); + if (event.event_type === "transaction.completed") return applyTransaction(store, event as unknown as PaddleEvent, ctx); + if (event.event_type === "adjustment.created" || event.event_type === "adjustment.updated") return applyAdjustment(store, event as unknown as PaddleEvent); + return { status: "ignored", note: "unhandled_event_type" }; +} + +/** The whole delivery: claim (idempotency), apply, record the outcome. A duplicate is answered as a success without doing anything. */ +export async function processPaddleDelivery(store: BillingStore, event: PaddleEvent, ctx: WebhookContext): Promise { + return processDelivery( + store, + { provider: "paddle", event_id: event.event_id, event_type: event.event_type, occurred_at: event.occurred_at ?? null, payload: event }, + () => applyPaddleEvent(store, event, ctx), + ); +} + +/** The shape every Paddle event has, checked before anything else. Returns null for a body that is not an event. */ +export function parsePaddleEvent(rawBody: string): PaddleEvent | null { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const event = parsed as Partial; + if (typeof event.event_id !== "string" || typeof event.event_type !== "string" || typeof event.occurred_at !== "string") return null; + if (!event.data || typeof event.data !== "object") return null; + if (Number.isNaN(Date.parse(event.occurred_at))) return null; + return event as PaddleEvent; +} diff --git a/src/lib/billing/polar/adapter.ts b/src/lib/billing/polar/adapter.ts new file mode 100644 index 0000000..9afc1f1 --- /dev/null +++ b/src/lib/billing/polar/adapter.ts @@ -0,0 +1,40 @@ +import type { ProviderAdapter } from "../adapter"; +import { itemForProviderRef } from "../catalog"; +import { markPolarLinked, readCustomerLinks } from "../customers"; +import { supabaseBillingStore } from "../store"; +import { createCheckout, createPortalSession } from "./api"; +import { syncPolarCustomer } from "./sync"; +import type { PolarContext } from "./webhook"; + +/** What the webhook and the sync need to know about this app: which products are ours. */ +export const polarContext: PolarContext = { itemForProductId: (productId) => itemForProviderRef("polar", productId) }; + +export const polarAdapter: ProviderAdapter = { + provider: "polar", + // The Polar customer is the user id, set by the server: an unconfirmed e-mail cannot reach anybody else's customer. + requiresVerifiedEmail: false, + + async createCheckout({ db, user, item, ref, origin }) { + // Remember that this user went to Polar, so the sync knows to ask. Harmless if the checkout is never paid. + await markPolarLinked(db, user); + const checkout = await createCheckout({ + productId: ref, + userId: user.id, + email: user.email, + successUrl: `${origin}/billing/success?provider=polar&checkout_id={CHECKOUT_ID}`, + returnUrl: `${origin}/pricing`, + metadata: { user_id: user.id, item_key: item.key, ...(item.interval ? { interval: item.interval } : {}) }, + }); + return { provider: "polar", url: checkout.url }; + }, + + async createPortal({ user, origin }) { + const url = await createPortalSession(user.id, `${origin}/billing`); + return url ? { url, cancelUrl: null } : null; + }, + + async sync({ db, user }) { + if (!(await readCustomerLinks(db, user.id)).polarLinked) return; + await syncPolarCustomer(supabaseBillingStore(db), user.id, polarContext); + }, +}; diff --git a/src/lib/billing/polar/api.ts b/src/lib/billing/polar/api.ts new file mode 100644 index 0000000..1d7cabb --- /dev/null +++ b/src/lib/billing/polar/api.ts @@ -0,0 +1,117 @@ +/** + * The Polar REST API, with `fetch` (no SDK, so nothing to install or keep up to date). Server only: it carries the secret access token. + * https://docs.polar.sh/api-reference + */ +import type { JsonObject } from "../types"; +import { getPolarServerConfig } from "./config"; +import type { PolarOrder, PolarSubscription } from "./types"; + +/** An error answer from Polar. Plain fields (Node's type stripping does not support constructor parameter properties). */ +export class PolarApiError extends Error { + status: number; + detail: string; + constructor(status: number, detail: string, path: string) { + super(`Polar ${path} -> ${status}: ${detail}`); + this.name = "PolarApiError"; + this.status = status; + this.detail = detail; + } +} + +type Fetch = typeof fetch; + +export async function polarFetch(path: string, init: { method?: string; body?: unknown } = {}, fetcher: Fetch = fetch): Promise { + const config = getPolarServerConfig(); + const response = await fetcher(`${config.apiBase}${path}`, { + method: init.method ?? "GET", + headers: { Authorization: `Bearer ${config.accessToken}`, "Content-Type": "application/json", Accept: "application/json" }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + cache: "no-store", + }); + const text = await response.text(); + let json: unknown = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + // not JSON: reported below + } + if (!response.ok) { + const body = json as { detail?: unknown; error_description?: unknown; error?: unknown } | null; + const raw = body?.detail ?? body?.error_description ?? body?.error ?? response.statusText; + throw new PolarApiError(response.status, typeof raw === "string" ? raw : JSON.stringify(raw), path); + } + return json as T; +} + +/** + * Polar refuses a checkout when the prefilled `customer_email` is not deliverable (it looks the domain up). That must not cost us the sale: the + * buyer types the address on Polar's page instead. True for exactly that rejection. + */ +export function isCustomerEmailRejection(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : ""; + return /customer_email/.test(message) && /not a valid email address|value_error|string_pattern/.test(message); +} + +/** + * Is this "Polar has no such customer"? For an external id Polar does not know, the customer-session endpoint answers 422 with the validation + * message "Customer does not exist." (found against the real sandbox; 404 is accepted too). It means "this user has not paid at Polar yet", not an outage. + */ +export function isNoSuchCustomer(error: unknown): boolean { + if (!(error instanceof PolarApiError)) return false; + return error.status === 404 || (error.status === 422 && /customer does not exist/i.test(error.detail)); +} + +/** + * A checkout THIS server creates for one product. The customer is the signed-in user: `external_customer_id` is the userId, set here and never by + * the browser, and it is what every later payload is credited through. Returns the URL to send the buyer to. + */ +export async function createCheckout( + input: { productId: string; userId: string; email: string | null; successUrl: string; returnUrl: string; metadata: JsonObject }, + fetcher?: Fetch, +): Promise<{ id: string; url: string }> { + const body = { products: [input.productId], external_customer_id: input.userId, success_url: input.successUrl, return_url: input.returnUrl, metadata: input.metadata }; + let created: { id?: string; url?: string }; + try { + created = await polarFetch("/v1/checkouts/", { method: "POST", body: input.email ? { ...body, customer_email: input.email } : body }, fetcher); + } catch (error) { + if (!input.email || !isCustomerEmailRejection(error)) throw error; + created = await polarFetch("/v1/checkouts/", { method: "POST", body }, fetcher); + } + if (!created.id || !created.url) throw new Error("Polar did not return a checkout url"); + return { id: created.id, url: created.url }; +} + +/** A short-lived, already-signed-in link to Polar's customer portal (invoices, payment method, cancel). null: Polar has no customer for this user yet. */ +export async function createPortalSession(userId: string, returnUrl: string, fetcher?: Fetch): Promise { + try { + const session = await polarFetch<{ customer_portal_url?: string }>("/v1/customer-sessions/", { method: "POST", body: { external_customer_id: userId, return_url: returnUrl } }, fetcher); + if (!session.customer_portal_url) throw new Error("Polar did not return a portal link"); + return session.customer_portal_url; + } catch (error) { + if (isNoSuchCustomer(error)) return null; + throw error; + } +} + +// ── reads for the self-heal sync ───────────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 100; +const MAX_PAGES = 10; + +async function listAll(path: string, fetcher?: Fetch): Promise { + const items: T[] = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const json = await polarFetch<{ items?: T[]; pagination?: { max_page?: number } }>(`${path}&page=${page}&limit=${PAGE_SIZE}`, {}, fetcher); + items.push(...(json.items ?? [])); + if (page >= (json.pagination?.max_page ?? 1)) break; + } + return items; +} + +export function listSubscriptions(userId: string, fetcher?: Fetch): Promise { + return listAll(`/v1/subscriptions/?external_customer_id=${encodeURIComponent(userId)}`, fetcher); +} + +export function listOrders(userId: string, fetcher?: Fetch): Promise { + return listAll(`/v1/orders/?external_customer_id=${encodeURIComponent(userId)}`, fetcher); +} diff --git a/src/lib/billing/polar/config.ts b/src/lib/billing/polar/config.ts new file mode 100644 index 0000000..2069f77 --- /dev/null +++ b/src/lib/billing/polar/config.ts @@ -0,0 +1,55 @@ +/** + * Polar environment. Reads `process.env` only (no `server-only`, so the tests can import it); the access token is in here, so never import it + * from a client component. https://docs.polar.sh + */ +import type { Env } from "../types"; + +export type PolarServer = "sandbox" | "production"; + +export const POLAR_API = { + production: "https://api.polar.sh", + sandbox: "https://sandbox-api.polar.sh", +} as const; + +/** What a mistake in the environment is called, so a route can answer 503 `billing_not_configured` instead of a 500. */ +export class PolarConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "PolarConfigError"; + } +} + +/** Sandbox unless `POLAR_SERVER=production`: a missing or misspelled value can only ever fail to charge, never charge for real by accident. */ +export function polarServer(env: Env = process.env): PolarServer { + return env.POLAR_SERVER === "production" ? "production" : "sandbox"; +} + +export interface PolarServerConfig { + accessToken: string; + apiBase: string; + server: PolarServer; + /** Needed by the webhook route only. */ + webhookSecret: string | null; +} + +export function getPolarServerConfig(env: Env = process.env): PolarServerConfig { + const accessToken = env.POLAR_ACCESS_TOKEN?.trim(); + if (!accessToken) throw new PolarConfigError("POLAR_ACCESS_TOKEN is not set"); + const server = polarServer(env); + return { accessToken, apiBase: POLAR_API[server], server, webhookSecret: env.POLAR_WEBHOOK_SECRET?.trim() || null }; +} + +/** Why the server cannot talk to Polar (checkout, portal, sync), or null when it can. Polar needs nothing in the browser. */ +export function polarConfigProblem(env: Env = process.env): string | null { + try { + getPolarServerConfig(env); + } catch (error) { + if (error instanceof PolarConfigError) return error.message; + throw error; + } + return null; +} + +export function polarConfigured(env: Env = process.env): boolean { + return polarConfigProblem(env) === null; +} diff --git a/src/lib/billing/polar/product-check.ts b/src/lib/billing/polar/product-check.ts new file mode 100644 index 0000000..c50c58f --- /dev/null +++ b/src/lib/billing/polar/product-check.ts @@ -0,0 +1,43 @@ +/** + * Does a Polar product match what src/config/billing.ts says it should be? A merchant of record reviews that the price on your site is the price + * Polar charges, and a product created by hand in the dashboard is easy to get wrong (monthly instead of yearly, $9 instead of $9.00 in the wrong + * currency). `npm run polar:check` runs this against the real products. Pure, and no imports: scripts/polar-setup.mjs loads this file directly. + */ +export interface PolarProductLike { + id?: string; + is_archived?: boolean; + /** month | year for a subscription product, null for a one-time one. */ + recurring_interval?: string | null; + prices?: { is_archived?: boolean; amount_type?: string; price_amount?: number; price_currency?: string }[]; +} + +export interface ExpectedProduct { + /** null: a one-time product. */ + interval: "month" | "year" | null; + /** Minor units (cents) of `currency`. */ + amountMinor: number; + /** ISO 4217, any case. */ + currency: string; +} + +/** What is wrong with the product, in words for the person fixing it. An empty list: it matches. */ +export function productProblems(product: PolarProductLike, expected: ExpectedProduct): string[] { + const problems: string[] = []; + if (product.is_archived) problems.push("it is archived (an archived product cannot be bought)"); + const interval = product.recurring_interval ?? null; + if (interval !== expected.interval) { + problems.push(`it bills ${interval ? `every ${interval}` : "once"}, but the catalog wants ${expected.interval ? `every ${expected.interval}` : "a one-time payment"}`); + } + const live = (product.prices ?? []).filter((price) => !price.is_archived); + if (live.length === 0) problems.push("it has no active price"); + else if (live.length > 1) problems.push(`it has ${live.length} active prices: keep one, the checkout cannot choose`); + else { + const [price] = live; + if (price.amount_type !== "fixed") problems.push(`its price is "${price.amount_type}", not a fixed amount`); + else { + if (price.price_amount !== expected.amountMinor) problems.push(`its price is ${price.price_amount} (minor units), the catalog says ${expected.amountMinor}`); + if ((price.price_currency ?? "").toLowerCase() !== expected.currency.toLowerCase()) problems.push(`its currency is ${price.price_currency}, the catalog says ${expected.currency}`); + } + } + return problems; +} diff --git a/src/lib/billing/polar/route-helpers.ts b/src/lib/billing/polar/route-helpers.ts new file mode 100644 index 0000000..1ab7975 --- /dev/null +++ b/src/lib/billing/polar/route-helpers.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { errorResponse } from "../route-helpers"; +import { getPolarServerConfig, PolarConfigError, type PolarServerConfig } from "./config"; + +/** The Polar configuration, or the 503 to answer with when it is incomplete (`billing_not_configured`, with the reason in the server log). */ +export function polarConfigOrResponse(): { config: PolarServerConfig } | { response: Response } { + try { + return { config: getPolarServerConfig() }; + } catch (error) { + if (!(error instanceof PolarConfigError)) throw error; + console.error(`[polar] ${error.message}`); + return { response: errorResponse("billing_not_configured", 503) }; + } +} diff --git a/src/lib/billing/polar/signature.ts b/src/lib/billing/polar/signature.ts new file mode 100644 index 0000000..1706a13 --- /dev/null +++ b/src/lib/billing/polar/signature.ts @@ -0,0 +1,55 @@ +/** + * Verifies a Polar webhook: Polar signs with the Standard Webhooks scheme (https://www.standardwebhooks.com). Three headers come with the + * RAW body: `webhook-id`, `webhook-timestamp` (unix seconds) and `webhook-signature` (space separated `v1,` entries). The signature is + * the HMAC-SHA256 of `${id}.${timestamp}.${rawBody}`. The RAW body must be used, byte for byte: a body that was parsed and re-serialised never matches. + * + * Two key schemes exist, depending on when the secret was created, and both are accepted (the same rule as `standardwebhooks` plus Polar's + * older secrets): the secret without its `whsec_` prefix, base64-decoded; or the whole secret string as it is. Redelivery of an old, valid + * request is handled by event-id idempotency (../delivery.ts), so the timestamp only has to absorb clock skew. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const SIGNATURE_TOLERANCE_SECONDS = 300; + +export interface PolarWebhookHeaders { + id: string | null | undefined; + timestamp: string | null | undefined; + signature: string | null | undefined; +} + +/** The headers of a request (case does not matter: `Headers` is case-insensitive already). */ +export function polarWebhookHeaders(headers: Pick): PolarWebhookHeaders { + return { id: headers.get("webhook-id"), timestamp: headers.get("webhook-timestamp"), signature: headers.get("webhook-signature") }; +} + +function signingKeys(secret: string): Buffer[] { + const encoded = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret; + return [Buffer.from(encoded, "base64"), Buffer.from(secret, "utf8")]; +} + +export function verifyPolarSignature(secret: string, rawBody: string, headers: PolarWebhookHeaders, nowMs = Date.now()): boolean { + if (!secret || !headers.id || !headers.timestamp || !headers.signature) return false; + const age = Math.abs(nowMs / 1000 - Number(headers.timestamp)); + if (!Number.isFinite(age) || age > SIGNATURE_TOLERANCE_SECONDS) return false; + + const signed = `${headers.id}.${headers.timestamp}.${rawBody}`; + const given = headers.signature + .split(" ") + .map((entry) => entry.split(",")) + .filter(([version, value]) => version === "v1" && Boolean(value)) + .map(([, value]) => Buffer.from(value, "base64")); + if (given.length === 0) return false; + + return signingKeys(secret).some((key) => { + const expected = createHmac("sha256", key).update(signed).digest(); + return given.some((candidate) => candidate.length === expected.length && timingSafeEqual(candidate, expected)); + }); +} + +/** For tests and for `scripts/polar-setup.mjs send-test`: headers the verifier above accepts (the Standard Webhooks key scheme). */ +export function signPolarBody(secret: string, id: string, rawBody: string, nowMs = Date.now()): Record { + const timestamp = String(Math.floor(nowMs / 1000)); + const key = signingKeys(secret)[0]; + const signature = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"); + return { "webhook-id": id, "webhook-timestamp": timestamp, "webhook-signature": `v1,${signature}` }; +} diff --git a/src/lib/billing/polar/sync.ts b/src/lib/billing/polar/sync.ts new file mode 100644 index 0000000..e4499f1 --- /dev/null +++ b/src/lib/billing/polar/sync.ts @@ -0,0 +1,42 @@ +/** + * Self-heal: reads a user's subscriptions and orders from the Polar API and applies them the way the webhooks would. The success page calls it + * (through /api/billing/sync) while it waits for the webhook, and it repairs a delivery that was lost. Idempotent, and safe next to the + * webhooks: a subscription or a refund is only written if its `modified_at` is not older than what is stored. + */ +import type { BillingStore } from "../types"; +import { listOrders, listSubscriptions } from "./api"; +import type { PolarOrder, PolarSubscription } from "./types"; +import { applyPolarOrder, polarOrderRowsFrom, polarSubscriptionRowFrom, type PolarContext } from "./webhook"; + +export interface PolarSyncSource { + listSubscriptions: (userId: string) => Promise; + listOrders: (userId: string) => Promise; +} + +export const polarSyncSource: PolarSyncSource = { listSubscriptions, listOrders }; + +export async function syncPolarCustomer( + store: BillingStore, + userId: string, + ctx: PolarContext, + source: PolarSyncSource = polarSyncSource, +): Promise<{ subscriptions: number; transactions: number }> { + let subscriptions = 0; + let transactions = 0; + for (const subscription of await source.listSubscriptions(userId)) { + // The list is filtered by this user's external id already; checking again costs nothing and keeps a wrong answer from crediting anybody else. + if (subscription.customer?.external_id !== userId) continue; + const row = polarSubscriptionRowFrom(subscription, ctx); + if ("ignored" in row) continue; + await store.upsertSubscription(row); + subscriptions += 1; + } + for (const order of await source.listOrders(userId)) { + if (order.customer?.external_id !== userId) continue; + const rows = polarOrderRowsFrom(order, ctx); + if ("ignored" in rows) continue; + await applyPolarOrder(store, rows); + transactions += 1; + } + return { subscriptions, transactions }; +} diff --git a/src/lib/billing/polar/types.ts b/src/lib/billing/polar/types.ts new file mode 100644 index 0000000..3799f80 --- /dev/null +++ b/src/lib/billing/polar/types.ts @@ -0,0 +1,66 @@ +/** + * The parts of Polar's payloads this template reads, in Polar's own snake_case wire format (the same objects come from the webhooks and from + * the REST API). Every field may be missing or null: everything optional is read defensively. The provider-neutral rows it writes are in + * ../types.ts. Reference: https://docs.polar.sh/api-reference + */ + +export interface PolarCustomerRef { + id?: string | null; + /** The id of the customer in OUR system: the Firebase userId the server set when it created the checkout. */ + external_id?: string | null; + email?: string | null; +} + +export interface PolarSubscription { + id: string; + /** incomplete | incomplete_expired | trialing | active | past_due | canceled | unpaid */ + status: string; + amount?: number | null; + currency?: string | null; + recurring_interval?: string | null; + current_period_start?: string | null; + current_period_end?: string | null; + cancel_at_period_end?: boolean | null; + canceled_at?: string | null; + ends_at?: string | null; + ended_at?: string | null; + created_at: string; + modified_at?: string | null; + customer_id?: string | null; + product_id: string; + seats?: number | null; + customer?: PolarCustomerRef | null; +} + +export interface PolarOrder { + id: string; + /** pending | paid | refunded | partially_refunded | void */ + status: string; + paid?: boolean | null; + /** purchase | subscription_create | subscription_cycle | subscription_update */ + billing_reason?: string | null; + currency: string; + /** Before tax. */ + net_amount: number; + tax_amount?: number | null; + /** What the buyer was charged, tax included. */ + total_amount?: number | null; + refunded_amount?: number | null; + refunded_tax_amount?: number | null; + invoice_number?: string | null; + created_at: string; + modified_at?: string | null; + customer_id?: string | null; + product_id?: string | null; + subscription_id?: string | null; + customer?: PolarCustomerRef | null; +} + +/** A webhook delivery after parsing: Polar's `{ type, timestamp, data }` body plus the `webhook-id` header. */ +export interface PolarEvent { + /** The `webhook-id` header: the same for every redelivery of the same message. */ + event_id: string; + event_type: string; + occurred_at: string; + data: T; +} diff --git a/src/lib/billing/polar/webhook.ts b/src/lib/billing/polar/webhook.ts new file mode 100644 index 0000000..9997acc --- /dev/null +++ b/src/lib/billing/polar/webhook.ts @@ -0,0 +1,181 @@ +/** + * What a Polar webhook does to the database: Polar's payloads mapped to the provider-neutral rows (../types.ts), applied through a + * `BillingStore`. Pure logic, tested without a database or a network: tests/polar-webhook.test.ts. The rules that keep it safe are listed in + * ../delivery.ts; for Polar they mean: + * + * - the user is `customer.external_id`, which only THIS server sets (it creates every checkout with `external_customer_id = userId`). A payload + * without one is ignored. Do not create public checkout links for these products in the Polar dashboard: they would not carry a userId. + * - a product that is not in `.env` (POLAR_PRODUCT_*) is ignored; + * - Polar is one organization per app (webhooks are per organization), so unlike Paddle there is no app tag. + * + * Events handled (`npm run polar:webhook` registers exactly these): + * subscription.created | updated | active | canceled | uncanceled | revoked | past_due -> billing_subscriptions + * order.paid | updated | refunded -> billing_transactions (+ the refund, see below) + * A refund shows up as the order's cumulative `refunded_amount` (+ `refunded_tax_amount`), kept as ONE adjustment per order that only grows: + * Polar sends no other refund event we need, and the pull side (sync.ts) reads the same field. + */ +import type { CatalogItem } from "@/config/billing"; +import { processDelivery } from "../delivery"; +import type { AdjustmentRow, BillingStore, ProcessResult, SubscriptionRow, SubscriptionStatus, TransactionRow } from "../types"; +import type { PolarCustomerRef, PolarEvent, PolarOrder, PolarSubscription } from "./types"; + +export interface PolarContext { + /** Looks up which of our catalog items a Polar product id is (null: not ours). */ + itemForProductId: (productId: string | undefined | null) => CatalogItem | null; +} + +const SUBSCRIPTION_EVENTS = new Set(["subscription.created", "subscription.updated", "subscription.active", "subscription.canceled", "subscription.uncanceled", "subscription.revoked", "subscription.past_due"]); +const ORDER_EVENTS = new Set(["order.paid", "order.updated", "order.refunded"]); + +/** The Polar events `npm run polar:webhook` subscribes to. */ +export const POLAR_EVENTS: readonly string[] = [...SUBSCRIPTION_EVENTS, ...ORDER_EVENTS]; + +/** Polar's subscription status as the neutral one. `incomplete` (no payment yet) is not mapped: nothing to grant. */ +const STATUS: Record = { + active: "active", + trialing: "trialing", + past_due: "past_due", + canceled: "canceled", + unpaid: "canceled", + incomplete_expired: "canceled", +}; + +/** An order that carries a payment (a refunded one was paid first; the refund is tracked apart). */ +const PAID_STATUSES = new Set(["paid", "partially_refunded", "refunded"]); + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Our user id: the external customer id the server set. Nothing else (metadata and e-mail are never trusted for this). A Supabase user id is a + * UUID: anything else is not ours (another app that shares the Polar organization), and the database would refuse it with an error that no retry fixes. + */ +export function polarUid(customer: PolarCustomerRef | null | undefined): string | null { + const id = customer?.external_id; + return typeof id === "string" && UUID.test(id) ? id : null; +} + +const upper = (currency: string | null | undefined): string => (currency ?? "").toUpperCase(); +const whole = (value: number | null | undefined): number => (typeof value === "number" && Number.isSafeInteger(value) ? value : 0); + +/** The pure part of a subscription payload (webhook or API): the row to store, or why it is not ours. */ +export function polarSubscriptionRowFrom(data: PolarSubscription, ctx: PolarContext): SubscriptionRow | { ignored: string } { + const userId = polarUid(data.customer); + if (!userId) return { ignored: "no_customer" }; + const item = ctx.itemForProductId(data.product_id); + if (!item) return { ignored: "not_our_product" }; + const status = STATUS[data.status]; + if (!status) return { ignored: data.status === "incomplete" ? "not_started" : `unknown_status_${data.status}` }; + return { + id: data.id, + provider: "polar", + user_id: userId, + customer_id: data.customer_id ?? data.customer?.id ?? "", + status, + plan_key: item.kind === "plan" ? item.key : null, + price_id: data.product_id, + billing_interval: data.recurring_interval ?? null, + quantity: data.seats && data.seats > 0 ? data.seats : 1, + currency: data.currency ? upper(data.currency) : null, + unit_amount: typeof data.amount === "number" ? whole(data.amount) : null, + current_period_start: data.current_period_start ?? null, + current_period_end: data.current_period_end ?? null, + cancel_at_period_end: data.cancel_at_period_end === true, + scheduled_change: null, + canceled_at: data.canceled_at ?? null, + event_occurred_at: data.modified_at ?? data.created_at, + }; +} + +export interface PolarOrderRows { + transaction: TransactionRow; + /** The order's refund so far (null: nothing refunded). One per order, replaced by a newer state, never lowered by an older one. */ + refund: AdjustmentRow | null; +} + +/** The pure part of an order payload (webhook or API): the rows to store, or why it is not ours. */ +export function polarOrderRowsFrom(data: PolarOrder, ctx: PolarContext): PolarOrderRows | { ignored: string } { + const userId = polarUid(data.customer); + if (!userId) return { ignored: "no_customer" }; + const item = ctx.itemForProductId(data.product_id); + if (!item) return { ignored: "not_our_product" }; + if (!PAID_STATUSES.has(data.status)) return { ignored: "not_paid" }; + + const net = whole(data.net_amount); + const tax = whole(data.tax_amount); + const currency = upper(data.currency); + const refunded = whole(data.refunded_amount) + whole(data.refunded_tax_amount); + return { + transaction: { + id: data.id, + provider: "polar", + user_id: userId, + customer_id: data.customer_id ?? data.customer?.id ?? "", + subscription_id: data.subscription_id ?? null, + status: "completed", + origin: data.billing_reason ?? null, + price_id: data.product_id ?? null, + item_key: item.key, + currency, + subtotal: net, + tax, + total: data.total_amount === null || data.total_amount === undefined ? net + tax : whole(data.total_amount), + invoice_number: data.invoice_number ?? null, + billed_at: data.created_at, + }, + refund: + refunded > 0 + ? { id: `refund_${data.id}`, provider: "polar", transaction_id: data.id, user_id: userId, action: "refund", status: "approved", currency, total: refunded, event_occurred_at: data.modified_at ?? data.created_at } + : null, + }; +} + +/** Stores what an order says: the payment, and its refund so far. Shared by the webhook and the sync. */ +export async function applyPolarOrder(store: BillingStore, rows: PolarOrderRows): Promise { + await store.upsertTransaction(rows.transaction); + if (rows.refund) { + await store.upsertAdjustment(rows.refund); + await store.recomputeRefunded("polar", rows.transaction.id); + } +} + +/** Applies one verified event. Returns what it ended as, throws when it could not be applied (the caller answers 500, Polar retries). */ +export async function applyPolarEvent(store: BillingStore, event: PolarEvent, ctx: PolarContext): Promise { + if (SUBSCRIPTION_EVENTS.has(event.event_type)) { + const row = polarSubscriptionRowFrom(event.data as PolarSubscription, ctx); + if ("ignored" in row) return { status: "ignored", note: row.ignored }; + await store.upsertSubscription(row); + return { status: "processed", note: `${event.event_type} ${row.status}` }; + } + if (ORDER_EVENTS.has(event.event_type)) { + const rows = polarOrderRowsFrom(event.data as PolarOrder, ctx); + if ("ignored" in rows) return { status: "ignored", note: rows.ignored }; + await applyPolarOrder(store, rows); + return { status: "processed", note: `${event.event_type} order recorded${rows.refund ? ", refunded" : ""}` }; + } + return { status: "ignored", note: "unhandled_event_type" }; +} + +/** The whole delivery: claim (idempotency), apply, record the outcome. A duplicate is answered as a success without doing anything. */ +export async function processPolarDelivery(store: BillingStore, event: PolarEvent, ctx: PolarContext): Promise { + return processDelivery( + store, + { provider: "polar", event_id: event.event_id, event_type: event.event_type, occurred_at: event.occurred_at, payload: event }, + () => applyPolarEvent(store, event, ctx), + ); +} + +/** Polar's `{ type, timestamp, data }` body plus the `webhook-id` header. Returns null for a body that is not an event. */ +export function parsePolarEvent(rawBody: string, eventId: string | null | undefined, now: () => string = () => new Date().toISOString()): PolarEvent | null { + if (!eventId) return null; + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const body = parsed as { type?: unknown; timestamp?: unknown; data?: unknown }; + if (typeof body.type !== "string" || !body.data || typeof body.data !== "object") return null; + const occurred = typeof body.timestamp === "string" && !Number.isNaN(Date.parse(body.timestamp)) ? body.timestamp : now(); + return { event_id: eventId, event_type: body.type, occurred_at: occurred, data: body.data }; +} diff --git a/src/lib/billing/provider.ts b/src/lib/billing/provider.ts new file mode 100644 index 0000000..59a4402 --- /dev/null +++ b/src/lib/billing/provider.ts @@ -0,0 +1,43 @@ +/** + * Which payment provider takes NEW checkouts. `BILLING_PROVIDER=paddle|polar` says so; change it and redeploy to switch. + * + * Switching only changes where the NEXT purchase goes. Nothing that exists is touched: both webhooks keep being processed (as long as the + * provider's secret is still in `.env`), `getEntitlement()` reads the subscriptions of both, a user who subscribed through the old provider + * keeps their plan and their portal there, and a second subscription is refused while one is running (`already_subscribed`), so a switch can + * never bill a customer twice. Pure: tests/billing-provider.test.ts. + */ +import { BILLING_PROVIDERS } from "@/config/billing"; +import { paddleConfigProblem, paddleServerProblem } from "./paddle/config"; +import { polarConfigProblem } from "./polar/config"; +import type { Env, Provider } from "./types"; + +/** Why the provider cannot take a NEW checkout (a sentence for the server log), or null when it can. */ +export function providerConfigProblem(provider: Provider, env: Env = process.env): string | null { + return provider === "paddle" ? paddleConfigProblem(env) : polarConfigProblem(env); +} + +/** + * Why the server cannot talk to the provider for what already exists (a user's portal, a sync), or null. Looser than the above on purpose: + * after a switch the old provider only has to keep serving its subscribers (no browser token, no price ids needed any more). + */ +export function providerServerProblem(provider: Provider, env: Env = process.env): string | null { + return provider === "paddle" ? paddleServerProblem(env) : polarConfigProblem(env); +} + +export type ProviderChoice = { provider: Provider } | { error: "billing_not_configured"; reason: string }; + +export function chooseBillingProvider(env: Env = process.env): ProviderChoice { + const wanted = env.BILLING_PROVIDER?.trim().toLowerCase(); + if (wanted) { + if (!(BILLING_PROVIDERS as readonly string[]).includes(wanted)) { + return { error: "billing_not_configured", reason: `BILLING_PROVIDER must be "paddle" or "polar", not "${env.BILLING_PROVIDER}"` }; + } + const problem = providerConfigProblem(wanted as Provider, env); + return problem ? { error: "billing_not_configured", reason: `BILLING_PROVIDER is ${wanted} but ${problem}` } : { provider: wanted as Provider }; + } + // Not set: fine as long as there is no doubt about which one is meant. + const ready = BILLING_PROVIDERS.filter((provider) => providerConfigProblem(provider, env) === null); + if (ready.length === 1) return { provider: ready[0] }; + if (ready.length === 0) return { error: "billing_not_configured", reason: "no payment provider is configured (set up Paddle or Polar, see BILLING.md)" }; + return { error: "billing_not_configured", reason: "both Paddle and Polar are configured: set BILLING_PROVIDER to the one that should take new checkouts" }; +} diff --git a/src/lib/billing/route-helpers.ts b/src/lib/billing/route-helpers.ts new file mode 100644 index 0000000..ac71cd3 --- /dev/null +++ b/src/lib/billing/route-helpers.ts @@ -0,0 +1,24 @@ +import "server-only"; +import type { SupabaseClient, User } from "@supabase/supabase-js"; +import { createClient } from "@/utils/supabase/server"; +import type { AdapterUser } from "./adapter"; + +/** Error answers carry a stable code, never display text: the UI decides what to say (src/components/billing/messages.ts). */ +export function errorResponse(code: string, status: number): Response { + return Response.json({ error: code }, { status }); +} + +/** The signed-in user (from the session cookie) and a Supabase client that carries their session, or the response to send back. */ +export async function userOrResponse(): Promise<{ user: User; supabase: SupabaseClient } | { response: Response }> { + // getUser() asks Supabase to validate the session; getSession() would trust the cookie. + const supabase = await createClient(); + if (!supabase) return { response: errorResponse("supabase_not_configured", 503) }; + const { data, error } = await supabase.auth.getUser(); + if (error || !data.user) return { response: errorResponse("unauthorized", 401) }; + return { user: data.user, supabase }; +} + +/** What an adapter needs to know about the user. `email_confirmed_at` is set by Supabase once the address was confirmed (always for OAuth sign-ins). */ +export function adapterUser(user: User): AdapterUser { + return { id: user.id, email: user.email ?? null, emailVerified: Boolean(user.email_confirmed_at) }; +} diff --git a/src/lib/billing/store.ts b/src/lib/billing/store.ts new file mode 100644 index 0000000..de440a0 --- /dev/null +++ b/src/lib/billing/store.ts @@ -0,0 +1,119 @@ +/** + * The Supabase implementation of `BillingStore` (types.ts). Pass it the SERVICE-ROLE client (`createAdminClient()`): every table it writes has + * Row Level Security with no write policy, so only the service role can. Never pass it a client that carries a user's session. + * The same store serves every provider: the primary key of each table is (provider, id). + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { UnknownUserError, type AdjustmentRow, type BillingStore, type SubscriptionRow, type TransactionRow, type WebhookEventRecord } from "./types"; + +/** A delivery stuck in `received` for this long is taken to have crashed mid-way and may be processed again. */ +export const STALE_RECEIVED_MS = 5 * 60 * 1000; + +const UNIQUE_VIOLATION = "23505"; +const FOREIGN_KEY_VIOLATION = "23503"; +const KEY = "provider,id"; + +function fail(what: string, error: { message: string; code?: string } | null): asserts error is null { + if (error) throw new Error(`${what}: ${error.message}`); +} + +/** Like `fail`, for the writes of a user's rows: a foreign key violation means the user does not exist (any more), which is not an outage. */ +function failUnlessUnknownUser(what: string, error: { message: string; code?: string } | null): asserts error is null { + if (error?.code === FOREIGN_KEY_VIOLATION) throw new UnknownUserError(); + fail(what, error); +} + +export function supabaseBillingStore(db: SupabaseClient, now: () => number = Date.now): BillingStore { + return { + async claimEvent(event: WebhookEventRecord) { + const inserted = await db + .from("billing_webhook_events") + .insert({ provider: event.provider, event_id: event.event_id, event_type: event.event_type, occurred_at: event.occurred_at, payload: event.payload, status: "received" }); + if (!inserted.error) return true; + if (inserted.error.code !== UNIQUE_VIOLATION) fail("claim webhook event", inserted.error); + + // Seen before. Take it over only if that attempt FAILED, or crashed and went stale (still `received` long after); a processed or + // ignored one is a duplicate. Each takeover is ONE conditional update, so two deliveries racing for it cannot both win. + const retake = { status: "received", received_at: new Date(now()).toISOString(), note: null }; + const afterFailure = await db + .from("billing_webhook_events") + .update(retake) + .eq("provider", event.provider) + .eq("event_id", event.event_id) + .eq("status", "failed") + .select("event_id"); + fail("retake failed webhook event", afterFailure.error); + if ((afterFailure.data?.length ?? 0) > 0) return true; + + const stale = new Date(now() - STALE_RECEIVED_MS).toISOString(); + const afterCrash = await db + .from("billing_webhook_events") + .update(retake) + .eq("provider", event.provider) + .eq("event_id", event.event_id) + .eq("status", "received") + .lt("received_at", stale) + .select("event_id"); + fail("retake stale webhook event", afterCrash.error); + return (afterCrash.data?.length ?? 0) > 0; + }, + + async finishEvent(provider, eventId, status, note) { + const { error } = await db.from("billing_webhook_events").update({ status, note }).eq("provider", provider).eq("event_id", eventId); + fail("finish webhook event", error); + }, + + async failEvent(provider, eventId, message) { + const { error } = await db.from("billing_webhook_events").update({ status: "failed", note: message.slice(0, 500) }).eq("provider", provider).eq("event_id", eventId); + fail("fail webhook event", error); + }, + + async userIdForCustomer(provider, customerId) { + // Only Paddle needs a link made by the server (its customers are found by e-mail). A Polar payload carries the user id itself. + if (provider !== "paddle") return null; + const { data, error } = await db.from("billing_customers").select("user_id").eq("paddle_customer_id", customerId).limit(1); + fail("find customer", error); + return (data?.[0]?.user_id as string | undefined) ?? null; + }, + + async upsertSubscription(row: SubscriptionRow) { + // Two statements so that "only if not older" is decided by the database, atomically, not by a read in between: + // insert when new (a conflict is left alone), then update only while the stored state is not newer than this one. + const inserted = await db.from("billing_subscriptions").upsert(row, { onConflict: KEY, ignoreDuplicates: true }); + failUnlessUnknownUser("insert subscription", inserted.error); + const { error } = await db.from("billing_subscriptions").update(row).eq("provider", row.provider).eq("id", row.id).lte("event_occurred_at", row.event_occurred_at); + failUnlessUnknownUser("update subscription", error); + }, + + async upsertTransaction(row: TransactionRow) { + // `refunded` is not in the row: an update must never reset what the adjustments have added up. + const { error } = await db.from("billing_transactions").upsert(row, { onConflict: KEY }); + failUnlessUnknownUser("upsert transaction", error); + }, + + async getTransaction(provider, id) { + const { data, error } = await db.from("billing_transactions").select("id, user_id").eq("provider", provider).eq("id", id).limit(1); + fail("find transaction", error); + const row = data?.[0] as { id: string; user_id: string } | undefined; + return row ?? null; + }, + + async upsertAdjustment(row: AdjustmentRow) { + // Same two statements as for a subscription: a late, older delivery must not undo a newer state (an approved refund stays approved). + const inserted = await db.from("billing_adjustments").upsert(row, { onConflict: KEY, ignoreDuplicates: true }); + failUnlessUnknownUser("insert adjustment", inserted.error); + const { error } = await db.from("billing_adjustments").update(row).eq("provider", row.provider).eq("id", row.id).lte("event_occurred_at", row.event_occurred_at); + failUnlessUnknownUser("update adjustment", error); + }, + + async recomputeRefunded(provider, transactionId) { + const { data, error } = await db.from("billing_adjustments").select("total, action, status").eq("provider", provider).eq("transaction_id", transactionId); + fail("read adjustments", error); + const refunded = (data ?? []) + .filter((a) => a.status === "approved" && (a.action === "refund" || a.action === "chargeback")) + .reduce((sum, a) => sum + Number(a.total), 0); + const updated = await db.from("billing_transactions").update({ refunded }).eq("provider", provider).eq("id", transactionId); + fail("update refunded", updated.error); + }, + }; +} diff --git a/src/lib/billing/types.ts b/src/lib/billing/types.ts new file mode 100644 index 0000000..41b2908 --- /dev/null +++ b/src/lib/billing/types.ts @@ -0,0 +1,122 @@ +/** + * The provider-neutral shapes of the billing module: what a webhook or a sync turns a payment provider's payload INTO, and what is stored. + * Paddle and Polar each have an adapter (`paddle/`, `polar/`) that maps its own payloads to these rows; everything after that (the store, the + * entitlement, the pages, the routes) only knows these, so a user can have a Paddle subscription today and a Polar one next year. + * + * Dates are ISO 8601 strings; money is MINOR units (cents) as numbers. `provider` is on every row, and the primary key of every table is + * (`provider`, `id`), so a Paddle id and a Polar id can never collide. + */ +import type { BillingProviderName } from "@/config/billing"; + +export type Provider = BillingProviderName; + +/** What the functions that read the environment take: `process.env`, or any plain object (the tests pass one). */ +export type Env = Record; + +export type JsonObject = Record; + +export const SUBSCRIPTION_STATUSES = ["active", "trialing", "past_due", "paused", "canceled"] as const; +export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number]; + +export interface SubscriptionRow { + /** The provider's own id of the subscription. */ + id: string; + provider: Provider; + user_id: string; + /** The provider's id of the customer (Paddle `ctm_...`, Polar's customer uuid). */ + customer_id: string; + status: SubscriptionStatus; + plan_key: string | null; + /** What was bought, in the provider's terms: a Paddle price id, a Polar product id. */ + price_id: string; + billing_interval: string | null; + quantity: number; + currency: string | null; + unit_amount: number | null; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + scheduled_change: JsonObject | null; + canceled_at: string | null; + /** When the provider produced this state. An older state never overwrites a newer one (compared as instants). */ + event_occurred_at: string; +} + +export interface TransactionRow { + id: string; + provider: Provider; + user_id: string; + customer_id: string; + subscription_id: string | null; + /** `completed` for a payment that went through (a refund shows in `refunded`, not here). */ + status: string; + origin: string | null; + price_id: string | null; + item_key: string | null; + currency: string; + subtotal: number; + tax: number; + total: number; + invoice_number: string | null; + billed_at: string | null; +} + +/** A refund or a chargeback against a transaction. `approved` ones are what `billing_transactions.refunded` adds up. */ +export interface AdjustmentRow { + id: string; + provider: Provider; + transaction_id: string; + user_id: string; + action: string; + status: string; + currency: string; + total: number; + /** When the provider produced this state: a late older delivery must not undo a newer one (a refund that was approved stays approved). */ + event_occurred_at: string; +} + +export interface WebhookEventRecord { + provider: Provider; + /** The provider's id of the delivery: Paddle `evt_...`, Polar's `webhook-id` header. */ + event_id: string; + event_type: string; + occurred_at: string | null; + payload: unknown; +} + +/** + * The row belongs to a user that does not exist (any more): a payment or a renewal for an account that was deleted. The store throws this on the + * database's foreign key violation (23503); a delivery answers it with "ignored", because a retry can never fix it (an error would make the provider + * redeliver for days). + */ +export class UnknownUserError extends Error { + constructor() { + super("the user of this payment does not exist (any more)"); + this.name = "UnknownUserError"; + } +} + +/** What a webhook delivery ended as. Never an error: errors are thrown so the provider redelivers. */ +export interface ProcessResult { + status: "processed" | "ignored"; + note: string; +} + +/** What a webhook needs from storage. `supabaseBillingStore` (store.ts) is the implementation; the tests use a fake. */ +export interface BillingStore { + /** Records the delivery. True when THIS call should process it: new, or an earlier attempt failed or went stale. False for a duplicate. */ + claimEvent(event: WebhookEventRecord): Promise; + finishEvent(provider: Provider, eventId: string, status: "processed" | "ignored", note: string): Promise; + failEvent(provider: Provider, eventId: string, message: string): Promise; + + /** Which user a provider customer was linked to by the server (Paddle: customers are found by e-mail, so the link is ours; Polar: always null, its payload carries the user id). */ + userIdForCustomer(provider: Provider, customerId: string): Promise; + /** Insert, or update only if `row.event_occurred_at` is not older than what is stored. */ + upsertSubscription(row: SubscriptionRow): Promise; + upsertTransaction(row: TransactionRow): Promise; + getTransaction(provider: Provider, id: string): Promise<{ id: string; user_id: string } | null>; + /** Insert, or update only if `row.event_occurred_at` is not older than what is stored. */ + upsertAdjustment(row: AdjustmentRow): Promise; + /** Recomputes `billing_transactions.refunded` from the approved refunds and chargebacks of that transaction. */ + recomputeRefunded(provider: Provider, transactionId: string): Promise; +} diff --git a/src/utils/supabase/admin.ts b/src/utils/supabase/admin.ts new file mode 100644 index 0000000..b181f60 --- /dev/null +++ b/src/utils/supabase/admin.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +/** + * Supabase client with the SERVICE ROLE key: it bypasses Row Level Security. Server only (the `server-only` import fails the build if a + * client component ever imports this). Use it for what no signed-in user may do themselves: the Paddle and Polar webhooks, and the billing routes + * that write `billing_*` rows. Returns null when the environment is not configured, like the other Supabase helpers. + */ +export function createAdminClient(): SupabaseClient | null { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }); +} diff --git a/supabase/migrations/20261014120000_billing.sql b/supabase/migrations/20261014120000_billing.sql new file mode 100644 index 0000000..541c15b --- /dev/null +++ b/supabase/migrations/20261014120000_billing.sql @@ -0,0 +1,149 @@ +-- Billing for Paddle AND Polar (docs: BILLING.md). +-- +-- billing_customers what links a user to the providers. Paddle customers are found by e-mail and shared by every app on the account, +-- so the server records which one is the user's (`paddle_customer_id`, UNIQUE: it can never belong to two users). +-- A Polar customer IS the user (the server sets `external_customer_id = user id` at checkout), so only the moment +-- of the first Polar checkout is kept (`polar_linked_at`). Both are written BEFORE a checkout opens, by the server. +-- billing_subscriptions one row per subscription of either provider, kept in step by the webhooks. `event_occurred_at` makes it safe +-- against events that arrive out of order: an older state never overwrites a newer one. +-- billing_transactions every completed payment (Paddle transaction, Polar order: renewals and one-time purchases), with what has been refunded. +-- billing_adjustments refunds and chargebacks reported against a transaction (for Polar: one row per order that only grows). +-- billing_webhook_events every webhook delivery, for idempotency (providers deliver at least once) and for debugging. +-- +-- The primary key of every table is (provider, id): a Paddle id and a Polar id can never collide, and a user can have rows of both. +-- Everything here is written by the server with the service role (the webhooks and the /api/billing routes). A signed-in user can only +-- READ their own rows: there is no insert / update / delete policy, so Row Level Security refuses every write from the browser. +-- Money amounts are the provider's minor units (cents) as bigint; `currency` says which currency. + +create or replace function public.billing_set_updated_at() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +-- ── customers ──────────────────────────────────────────────────────────────────────────────────────── +create table public.billing_customers ( + user_id uuid primary key references auth.users (id) on delete cascade, + paddle_customer_id text unique, -- null until the user opened a Paddle checkout (several nulls are fine) + polar_linked_at timestamptz, -- null until the user opened a Polar checkout + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create trigger billing_customers_updated_at before update on public.billing_customers + for each row execute function public.billing_set_updated_at(); + +-- ── subscriptions ──────────────────────────────────────────────────────────────────────────────────── +create table public.billing_subscriptions ( + provider text not null check (provider in ('paddle', 'polar')), + id text not null, -- the provider's subscription id + user_id uuid not null references auth.users (id) on delete cascade, + customer_id text not null, -- the provider's customer id + status text not null check (status in ('active', 'trialing', 'past_due', 'paused', 'canceled')), + plan_key text, -- the plan in src/config/billing.ts this price / product belongs to (null: not a plan) + price_id text not null, -- what was bought, in the provider's terms: a Paddle price id, a Polar product id + billing_interval text, -- month | year | week | day + quantity integer not null default 1, + currency text, + unit_amount bigint, -- price per period in minor units + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at_period_end boolean not null default false, -- Paddle scheduled_change.action = 'cancel'; Polar cancel_at_period_end + scheduled_change jsonb, + canceled_at timestamptz, + event_occurred_at timestamptz not null, -- when the provider produced the newest state applied + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (provider, id) +); +create index billing_subscriptions_user_idx on public.billing_subscriptions (user_id); +create index billing_subscriptions_customer_idx on public.billing_subscriptions (provider, customer_id); +create trigger billing_subscriptions_updated_at before update on public.billing_subscriptions + for each row execute function public.billing_set_updated_at(); + +-- ── transactions ───────────────────────────────────────────────────────────────────────────────────── +create table public.billing_transactions ( + provider text not null check (provider in ('paddle', 'polar')), + id text not null, -- Paddle transaction id (txn_...) or Polar order id + user_id uuid not null references auth.users (id) on delete cascade, + customer_id text not null, + subscription_id text, + status text not null, -- completed: a payment that went through (a refund shows in `refunded`) + origin text, -- Paddle: api | subscription_recurring | web ...; Polar: purchase | subscription_create | subscription_cycle ... + price_id text, + item_key text, -- plan or product key in src/config/billing.ts + currency text not null, + subtotal bigint not null default 0, + tax bigint not null default 0, + total bigint not null default 0, -- what the customer paid (tax included) + refunded bigint not null default 0, -- sum of the approved refunds and chargebacks + invoice_number text, + billed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (provider, id) +); +create index billing_transactions_user_idx on public.billing_transactions (user_id, billed_at desc); +create trigger billing_transactions_updated_at before update on public.billing_transactions + for each row execute function public.billing_set_updated_at(); + +-- ── adjustments (refunds, chargebacks) ─────────────────────────────────────────────────────────────── +create table public.billing_adjustments ( + provider text not null check (provider in ('paddle', 'polar')), + id text not null, -- Paddle adjustment id (adj_...), or refund_ for Polar + transaction_id text not null, + user_id uuid not null references auth.users (id) on delete cascade, + action text not null, -- refund | chargeback | credit | ... + status text not null, -- pending_approval | approved | rejected | reversed + currency text not null, + total bigint not null default 0, + event_occurred_at timestamptz not null, -- when the provider produced this state: a late older delivery must not undo a newer one + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (provider, id), + foreign key (provider, transaction_id) references public.billing_transactions (provider, id) on delete cascade +); +create index billing_adjustments_transaction_idx on public.billing_adjustments (provider, transaction_id); +create trigger billing_adjustments_updated_at before update on public.billing_adjustments + for each row execute function public.billing_set_updated_at(); + +-- ── webhook events (idempotency) ───────────────────────────────────────────────────────────────────── +create table public.billing_webhook_events ( + provider text not null check (provider in ('paddle', 'polar')), + event_id text not null, -- Paddle event id (evt_...), or Polar's webhook-id header + event_type text not null, + occurred_at timestamptz, + received_at timestamptz not null default now(), + status text not null default 'received' check (status in ('received', 'processed', 'ignored', 'failed')), + note text, + payload jsonb not null, + primary key (provider, event_id) +); + +-- ── Row Level Security: read your own, write nothing ───────────────────────────────────────────────── +alter table public.billing_customers enable row level security; +alter table public.billing_subscriptions enable row level security; +alter table public.billing_transactions enable row level security; +alter table public.billing_adjustments enable row level security; +alter table public.billing_webhook_events enable row level security; + +create policy "billing_customers: read own" on public.billing_customers + for select to authenticated using (user_id = (select auth.uid())); +create policy "billing_subscriptions: read own" on public.billing_subscriptions + for select to authenticated using (user_id = (select auth.uid())); +create policy "billing_transactions: read own" on public.billing_transactions + for select to authenticated using (user_id = (select auth.uid())); +create policy "billing_adjustments: read own" on public.billing_adjustments + for select to authenticated using (user_id = (select auth.uid())); +-- billing_webhook_events: no policy at all = only the service role can touch it. + +-- Defence in depth: the browser roles get no write privilege on these tables, and none at all on the webhook log. +revoke insert, update, delete, truncate on + public.billing_customers, public.billing_subscriptions, public.billing_transactions, public.billing_adjustments + from anon, authenticated; +revoke all on public.billing_webhook_events from anon, authenticated; +revoke all on public.billing_customers, public.billing_subscriptions, public.billing_transactions, public.billing_adjustments from anon; diff --git a/tests/billing-catalog.test.ts b/tests/billing-catalog.test.ts new file mode 100644 index 0000000..d083672 --- /dev/null +++ b/tests/billing-catalog.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { BILLING_PROVIDERS, billingConfig, catalogItems, priceEnvName, productEnvName, providerEnvName, toMinorUnits, validateBillingConfig } from "@/config/billing"; +import { itemForProviderRef, providerRefFor, sellableItems } from "@/lib/billing/catalog"; + +describe("env var names", () => { + it("names the Paddle price var after the plan and the interval", () => { + assert.equal(priceEnvName({ key: "pro", interval: "month" }), "PADDLE_PRICE_PRO_MONTH"); + assert.equal(priceEnvName({ key: "team", interval: "year" }), "PADDLE_PRICE_TEAM_YEAR"); + assert.equal(priceEnvName({ key: "credits_100", interval: null }), "PADDLE_PRICE_CREDITS_100"); + }); + + it("names the Polar product var the same way (a Polar product has one interval)", () => { + assert.equal(productEnvName({ key: "pro", interval: "month" }), "POLAR_PRODUCT_PRO_MONTH"); + assert.equal(productEnvName({ key: "team", interval: "year" }), "POLAR_PRODUCT_TEAM_YEAR"); + assert.equal(productEnvName({ key: "credits_100", interval: null }), "POLAR_PRODUCT_CREDITS_100"); + assert.equal(providerEnvName("paddle", { key: "pro", interval: "month" }), "PADDLE_PRICE_PRO_MONTH"); + assert.equal(providerEnvName("polar", { key: "pro", interval: "month" }), "POLAR_PRODUCT_PRO_MONTH"); + }); + + it("knows exactly two providers", () => { + assert.deepEqual([...BILLING_PROVIDERS], ["paddle", "polar"]); + }); +}); + +describe("a provider's ids, in both directions", () => { + const paddle = { PADDLE_PRICE_PRO_MONTH: "pri_a", PADDLE_PRICE_PRO_YEAR: " pri_b " }; + const polar = { POLAR_PRODUCT_PRO_MONTH: "prod_a", POLAR_PRODUCT_PRO_YEAR: "prod_b" }; + + it("looks the id of an item up, trimmed, and null while it is not in .env", () => { + assert.equal(providerRefFor("paddle", { key: "pro", interval: "month" }, paddle), "pri_a"); + assert.equal(providerRefFor("paddle", { key: "pro", interval: "year" }, paddle), "pri_b"); + assert.equal(providerRefFor("paddle", { key: "team", interval: "month" }, paddle), null); + assert.equal(providerRefFor("polar", { key: "pro", interval: "month" }, polar), "prod_a"); + }); + + it("finds our item from an id, and an id that is not in .env is not ours", () => { + assert.deepEqual({ key: itemForProviderRef("paddle", "pri_b", paddle)?.key, interval: itemForProviderRef("paddle", "pri_b", paddle)?.interval }, { key: "pro", interval: "year" }); + assert.equal(itemForProviderRef("polar", "prod_a", polar)?.interval, "month"); + assert.equal(itemForProviderRef("paddle", "pri_other", paddle), null); + assert.equal(itemForProviderRef("paddle", undefined, paddle), null); + assert.equal(itemForProviderRef("paddle", "", paddle), null); + }); + + it("never mixes the providers: a Polar product id is nothing to Paddle and the other way round", () => { + const both = { ...paddle, ...polar }; + assert.equal(itemForProviderRef("paddle", "prod_a", both), null); + assert.equal(itemForProviderRef("polar", "pri_a", both), null); + }); + + it("lists what a provider can sell right now: the items whose id is in .env", () => { + assert.deepEqual(sellableItems("paddle", paddle).map((item) => `${item.key}:${item.interval}`), ["pro:month", "pro:year"]); + assert.deepEqual(sellableItems("polar", paddle), []); + }); +}); + +describe("src/config/billing.ts", () => { + it("ships valid", () => { + assert.deepEqual(validateBillingConfig(), []); + }); + + it("lists one catalog item per plan interval and per product", () => { + const items = catalogItems(); + assert.equal(items.length, billingConfig.plans.reduce((n, plan) => n + Object.keys(plan.prices).length, 0)); + assert.ok(items.every((item) => item.kind === "plan" && item.interval)); + }); + + it("catches the mistakes that would only show up as a broken checkout", () => { + const plan = { key: "pro", name: "Pro", description: "d", features: [], prices: { month: 9 } }; + const check = (plans: unknown[], products: unknown[] = [], currency = "USD") => validateBillingConfig({ currency, plans, products } as Parameters[0]); + assert.deepEqual(check([plan]), []); + assert.match(check([plan, plan]).join(), /used twice/); + assert.match(check([{ ...plan, key: "Pro Plan" }]).join(), /lowercase/); + assert.match(check([{ ...plan, prices: {} }]).join(), /no price/); + assert.match(check([{ ...plan, prices: { month: 0 } }]).join(), /above 0/); + assert.match(check([{ ...plan, prices: { month: 9.999 } }]).join(), /two decimals/); + assert.match(check([{ ...plan, prices: { week: 3 } }]).join(), /unknown interval/); + assert.match(check([plan], [{ key: "pro", name: "x", description: "x", price: 5 }]).join(), /used twice/); + assert.match(check([plan], [], "usd").join(), /ISO 4217/); + }); + + it("converts to the minor units the providers want without floating point drift", () => { + assert.equal(toMinorUnits(9), "900"); + assert.equal(toMinorUnits(9.99), "999"); + assert.equal(toMinorUnits(19.9), "1990"); + assert.equal(toMinorUnits(0.07), "7"); + assert.equal(toMinorUnits(1.005 * 100), "10050"); + }); +}); diff --git a/tests/billing-checkout.test.ts b/tests/billing-checkout.test.ts new file mode 100644 index 0000000..d46c369 --- /dev/null +++ b/tests/billing-checkout.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { parseCheckoutRequest, resolveCheckoutItem } from "@/lib/billing/checkout"; +import { ENV, ITEMS, POLAR_ENV } from "./support/billing-fixtures"; + +describe("parseCheckoutRequest", () => { + it("accepts a plan with an interval and a product without one", () => { + assert.deepEqual(parseCheckoutRequest({ kind: "plan", key: "pro", interval: "month" }), { kind: "plan", key: "pro", interval: "month" }); + assert.deepEqual(parseCheckoutRequest({ kind: "plan", key: "pro", interval: "year" }), { kind: "plan", key: "pro", interval: "year" }); + assert.deepEqual(parseCheckoutRequest({ kind: "product", key: "credits_100" }), { kind: "product", key: "credits_100", interval: null }); + }); + + it("refuses anything the browser should not be sending", () => { + for (const bad of [null, undefined, "plan", 7, [], {}, { kind: "plan", key: "pro" }, { kind: "plan", key: "pro", interval: "week" }, { kind: "plan", key: "", interval: "month" }, { kind: "plan", key: "x".repeat(41), interval: "month" }, { kind: "bundle", key: "pro" }, { kind: "plan", key: 3, interval: "month" }]) { + assert.equal(parseCheckoutRequest(bad), null, JSON.stringify(bad)); + } + }); + + it("ignores a price, an amount, a customer or a provider in the body: only the name of the thing counts", () => { + assert.deepEqual(parseCheckoutRequest({ kind: "plan", key: "pro", interval: "month", priceId: "pri_free", amount: 1, customerId: "ctm_x", provider: "polar", productId: "prod_free" }), { kind: "plan", key: "pro", interval: "month" }); + }); +}); + +describe("resolveCheckoutItem", () => { + it("finds the item and the Paddle price id from the environment", () => { + const resolved = resolveCheckoutItem({ kind: "plan", key: "pro", interval: "year" }, "paddle", ENV, ITEMS); + assert.ok(!("error" in resolved)); + assert.equal(resolved.ref, "pri_pro_year"); + assert.equal(resolved.item.key, "pro"); + const product = resolveCheckoutItem({ kind: "product", key: "credits_100", interval: null }, "paddle", ENV, ITEMS); + assert.ok(!("error" in product)); + assert.equal(product.ref, "pri_credits_100"); + }); + + it("finds the Polar product id of the same item, and never a Paddle id for Polar", () => { + const resolved = resolveCheckoutItem({ kind: "plan", key: "pro", interval: "year" }, "polar", POLAR_ENV, ITEMS); + assert.ok(!("error" in resolved)); + assert.equal(resolved.ref, "prod_pro_year"); + // only the Paddle ids are configured: Polar has nothing to sell + assert.deepEqual(resolveCheckoutItem({ kind: "plan", key: "pro", interval: "year" }, "polar", ENV, ITEMS), { error: "price_not_configured" }); + assert.deepEqual(resolveCheckoutItem({ kind: "plan", key: "pro", interval: "year" }, "paddle", POLAR_ENV, ITEMS), { error: "price_not_configured" }); + }); + + it("says unknown_item for a plan, an interval or a product that is not in src/config/billing.ts", () => { + assert.deepEqual(resolveCheckoutItem({ kind: "plan", key: "enterprise", interval: "month" }, "paddle", ENV, ITEMS), { error: "unknown_item" }); + assert.deepEqual(resolveCheckoutItem({ kind: "product", key: "pro", interval: null }, "paddle", ENV, ITEMS), { error: "unknown_item" }); + assert.deepEqual(resolveCheckoutItem({ kind: "plan", key: "credits_100", interval: "month" }, "polar", POLAR_ENV, ITEMS), { error: "unknown_item" }); + }); + + it("says price_not_configured while the id is not in .env yet", () => { + assert.deepEqual(resolveCheckoutItem({ kind: "plan", key: "pro", interval: "month" }, "paddle", {}, ITEMS), { error: "price_not_configured" }); + assert.deepEqual(resolveCheckoutItem({ kind: "plan", key: "pro", interval: "month" }, "polar", {}, ITEMS), { error: "price_not_configured" }); + }); +}); diff --git a/tests/billing-customers.test.ts b/tests/billing-customers.test.ts new file mode 100644 index 0000000..a43f1f1 --- /dev/null +++ b/tests/billing-customers.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { linkedProviders, markPolarLinked, NO_LINKS, readCustomerLinks } from "@/lib/billing/customers"; +import { PaddleApiError, ensureCustomerId } from "@/lib/billing/paddle/api"; +import { CustomerConflictError, ensureBillingCustomer } from "@/lib/billing/paddle/customers"; +import { billingDb } from "./support/fake-supabase"; + +const USER = { id: "11111111-1111-4111-8111-111111111111", email: "buyer@example.com" }; +const OTHER = "22222222-2222-4222-8222-222222222222"; +const client = (db: ReturnType) => db as unknown as SupabaseClient; + +describe("ensureBillingCustomer (Paddle)", () => { + it("creates the Paddle customer once and links it to the user", async () => { + const db = billingDb(); + let calls = 0; + const create = async () => { + calls += 1; + return "ctm_new"; + }; + assert.equal(await ensureBillingCustomer(client(db), USER, "my-app", create), "ctm_new"); + assert.equal(await ensureBillingCustomer(client(db), USER, "my-app", create), "ctm_new"); + assert.equal(calls, 1, "the second call reads the link and does not call Paddle"); + assert.deepEqual(db.tables.billing_customers, [{ user_id: USER.id, paddle_customer_id: "ctm_new" }]); + }); + + it("hands Paddle the e-mail of the session and the app tag, nothing the browser sent", async () => { + const db = billingDb(); + const seen: unknown[] = []; + await ensureBillingCustomer(client(db), USER, "my-app", async (email, customData) => { + seen.push(email, customData); + return "ctm_x"; + }); + assert.deepEqual(seen, ["buyer@example.com", { app: "my-app", user_id: USER.id }]); + }); + + it("uses the link a concurrent request made a moment before", async () => { + const db = billingDb(); + const create = async () => { + db.tables.billing_customers.push({ user_id: USER.id, paddle_customer_id: "ctm_winner" }); + return "ctm_loser"; + }; + assert.equal(await ensureBillingCustomer(client(db), USER, "my-app", create), "ctm_winner"); + assert.equal(db.tables.billing_customers.length, 1); + }); + + it("refuses to share a Paddle customer between two users (two accounts, one e-mail)", async () => { + const db = billingDb(); + db.tables.billing_customers.push({ user_id: OTHER, paddle_customer_id: "ctm_shared" }); + await assert.rejects(ensureBillingCustomer(client(db), USER, "my-app", async () => "ctm_shared"), CustomerConflictError); + assert.equal(db.tables.billing_customers.find((row) => row.user_id === OTHER)?.paddle_customer_id, "ctm_shared", "the other user's link is untouched"); + assert.equal(db.tables.billing_customers.find((row) => row.user_id === USER.id)?.paddle_customer_id ?? null, null, "and this user was not given it"); + }); + + it("keeps a Polar link the same user already has", async () => { + const db = billingDb(); + await markPolarLinked(client(db), USER); + await ensureBillingCustomer(client(db), USER, "my-app", async () => "ctm_both"); + assert.deepEqual(await readCustomerLinks(client(db), USER.id), { paddleCustomerId: "ctm_both", polarLinked: true }); + assert.equal(db.tables.billing_customers.length, 1); + }); +}); + +describe("the links of a user to the two providers", () => { + it("knows nothing about a user who never started a checkout", async () => { + const db = billingDb(); + assert.deepEqual(await readCustomerLinks(client(db), "nobody"), NO_LINKS); + assert.deepEqual(linkedProviders(NO_LINKS), []); + }); + + it("remembers that a user went to Polar, once: the first moment wins", async () => { + const db = billingDb(); + await markPolarLinked(client(db), USER, () => "2026-10-14T00:00:00.000Z"); + await markPolarLinked(client(db), USER, () => "2026-10-15T00:00:00.000Z"); + assert.equal(db.tables.billing_customers.length, 1); + assert.equal(db.tables.billing_customers[0].polar_linked_at, "2026-10-14T00:00:00.000Z"); + assert.deepEqual(await readCustomerLinks(client(db), USER.id), { paddleCustomerId: null, polarLinked: true }); + assert.deepEqual(linkedProviders({ paddleCustomerId: "ctm_1", polarLinked: true }), ["paddle", "polar"]); + }); + + it("surfaces a database error instead of answering 'not linked'", async () => { + const broken = { from: () => ({ select: () => ({ eq: () => ({ limit: async () => ({ data: null, error: { message: "boom" } }) }) }) }) } as unknown as SupabaseClient; + await assert.rejects(readCustomerLinks(broken, USER.id), /boom/); + }); +}); + +describe("ensureCustomerId (the Paddle API side)", () => { + const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status }); + const env = { PADDLE_API_KEY: "pdl_sdbx_apikey_x", PADDLE_APP_TAG: "my-app" }; + + it("reuses a customer that exists on the account (another app may have made it)", async () => { + Object.assign(process.env, env); + const seen: string[] = []; + const fetcher = (async (url: string) => { + seen.push(String(url)); + return json(200, { data: [{ id: "ctm_existing" }] }); + }) as unknown as typeof fetch; + assert.equal(await ensureCustomerId("buyer@example.com", {}, fetcher), "ctm_existing"); + assert.equal(seen.length, 1); + assert.match(seen[0], /sandbox-api\.paddle\.com\/customers\?email=buyer%40example\.com/); + }); + + it("creates one when there is none, and recovers from the 409 a racing request causes", async () => { + Object.assign(process.env, env); + const created = (async (url: string, init?: RequestInit) => (init?.method === "POST" ? json(201, { data: { id: "ctm_created" } }) : json(200, { data: [] }))) as unknown as typeof fetch; + assert.equal(await ensureCustomerId("a@example.com", {}, created), "ctm_created"); + + const conflict = (async (_url: string, init?: RequestInit) => + init?.method === "POST" ? json(409, { error: { code: "conflict", detail: "customer email conflicts with customer of id ctm_01racer" } }) : json(200, { data: [] })) as unknown as typeof fetch; + assert.equal(await ensureCustomerId("a@example.com", {}, conflict), "ctm_01racer"); + }); + + it("does not hide other Paddle errors", async () => { + Object.assign(process.env, env); + const broken = (async () => json(403, { error: { code: "forbidden", detail: "no permission" } })) as unknown as typeof fetch; + await assert.rejects(ensureCustomerId("a@example.com", {}, broken), (error: unknown) => error instanceof PaddleApiError && error.status === 403 && error.code === "forbidden"); + }); +}); diff --git a/tests/billing-delivery.test.ts b/tests/billing-delivery.test.ts new file mode 100644 index 0000000..ab5aac4 --- /dev/null +++ b/tests/billing-delivery.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { processDelivery } from "@/lib/billing/delivery"; +import { UnknownUserError, type WebhookEventRecord } from "@/lib/billing/types"; +import { MemoryStore } from "./support/billing-fixtures"; + +const record = (over: Partial = {}): WebhookEventRecord => ({ provider: "paddle", event_id: "evt_1", event_type: "x", occurred_at: null, payload: {}, ...over }); + +describe("processDelivery (the same for every provider)", () => { + it("claims, applies and records the outcome", async () => { + const store = new MemoryStore(); + let applied = 0; + const result = await processDelivery(store, record(), async () => { + applied += 1; + return { status: "processed", note: "done" }; + }); + assert.deepEqual(result, { status: "processed", note: "done" }); + assert.equal(applied, 1); + assert.deepEqual(store.events.get("paddle_evt_1"), { status: "processed", note: "done" }); + }); + + it("answers a duplicate without applying it again", async () => { + const store = new MemoryStore(); + const apply = async () => ({ status: "processed" as const, note: "" }); + await processDelivery(store, record(), apply); + let again = 0; + const result = await processDelivery(store, record(), async () => { + again += 1; + return { status: "processed", note: "" }; + }); + assert.deepEqual(result, { status: "duplicate" }); + assert.equal(again, 0); + }); + + it("keeps the two providers' delivery ids apart", async () => { + const store = new MemoryStore(); + const apply = async () => ({ status: "processed" as const, note: "" }); + await processDelivery(store, record({ provider: "paddle", event_id: "same" }), apply); + const other = await processDelivery(store, record({ provider: "polar", event_id: "same" }), apply); + assert.equal(other.status, "processed"); + assert.deepEqual([...store.events.keys()].sort(), ["paddle_same", "polar_same"]); + }); + + it("records a failure, rethrows it, and lets the redelivery through", async () => { + const store = new MemoryStore(); + await assert.rejects( + processDelivery(store, record(), async () => { + throw new Error("boom"); + }), + /boom/, + ); + assert.deepEqual(store.events.get("paddle_evt_1"), { status: "failed", note: "boom" }); + const retried = await processDelivery(store, record(), async () => ({ status: "ignored", note: "later" })); + assert.deepEqual(retried, { status: "ignored", note: "later" }); + }); + + it("answers a payment of a user who no longer exists with ignored, not an error (a retry could never fix it)", async () => { + const store = new MemoryStore(); + const result = await processDelivery(store, record(), async () => { + throw new UnknownUserError(); + }); + assert.deepEqual(result, { status: "ignored", note: "unknown_user" }); + assert.deepEqual(store.events.get("paddle_evt_1"), { status: "ignored", note: "unknown_user" }); + }); +}); diff --git a/tests/billing-entitlements.test.ts b/tests/billing-entitlements.test.ts new file mode 100644 index 0000000..c5ec072 --- /dev/null +++ b/tests/billing-entitlements.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { NO_LINKS } from "@/lib/billing/customers"; +import { bestSubscription, entitlementFrom, getEntitlement, hasPurchased, NO_ENTITLEMENT, providerForUser, readLastPaymentProvider, readSubscriptions, type StoredSubscription } from "@/lib/billing/entitlements"; +import { billingDb } from "./support/fake-supabase"; +import { USER_ID } from "./support/billing-fixtures"; + +const row = (over: Partial = {}): StoredSubscription => ({ + id: "sub_1", + provider: "paddle", + status: "active", + plan_key: "pro", + billing_interval: "month", + current_period_end: "2026-11-14T00:00:00Z", + cancel_at_period_end: false, + ...over, +}); + +describe("entitlementFrom", () => { + it("is nothing without a subscription", () => { + assert.deepEqual(entitlementFrom([]), NO_ENTITLEMENT); + assert.equal(NO_ENTITLEMENT.active, false); + assert.equal(NO_ENTITLEMENT.provider, null); + }); + + it("is active for active and trialing, and keeps the cancellation notice", () => { + for (const status of ["active", "trialing"]) assert.equal(entitlementFrom([row({ status })]).active, true, status); + const ending = entitlementFrom([row({ cancel_at_period_end: true })]); + assert.equal(ending.active, true); + assert.equal(ending.cancelAtPeriodEnd, true); + assert.equal(ending.currentPeriodEnd, "2026-11-14T00:00:00Z"); + assert.equal(ending.planKey, "pro"); + assert.equal(ending.subscriptionId, "sub_1"); + }); + + it("is not active when paused, canceled or past due, and says so for past due", () => { + for (const status of ["paused", "canceled", "past_due"]) assert.equal(entitlementFrom([row({ status })]).active, false, status); + assert.equal(entitlementFrom([row({ status: "past_due" })]).pastDue, true); + assert.equal(entitlementFrom([row({ status: "canceled" })]).pastDue, false); + }); + + it("prefers a running subscription over a past-due one over an old canceled one", () => { + const old = row({ id: "sub_old", status: "canceled", plan_key: "team", current_period_end: "2027-01-01T00:00:00Z" }); + const late = row({ id: "sub_late", status: "past_due", plan_key: "team" }); + const live = row({ id: "sub_live", status: "active", plan_key: "pro" }); + assert.equal(entitlementFrom([old, late, live]).subscriptionId, "sub_live"); + assert.equal(entitlementFrom([old, late]).subscriptionId, "sub_late"); + assert.equal(entitlementFrom([old]).subscriptionId, "sub_old"); + }); + + it("breaks a tie between two running subscriptions with the later period end, compared as instants", () => { + const a = row({ id: "a", current_period_end: "2026-11-01T00:00:00Z" }); + const b = row({ id: "b", current_period_end: "2026-12-01T00:00:00Z" }); + assert.equal(entitlementFrom([a, b]).subscriptionId, "b"); + assert.equal(entitlementFrom([b, a]).subscriptionId, "b"); + // "…00.5Z" sorts before "…00Z" as a string but is the later instant + const early = row({ id: "early", current_period_end: "2026-12-01T00:00:00Z" }); + const late = row({ id: "late", current_period_end: "2026-12-01T00:00:00.500000Z" }); + assert.equal(entitlementFrom([early, late]).subscriptionId, "late"); + }); +}); + +describe("a subscription of either provider counts the same", () => { + it("is active whether Paddle or Polar bills it, and says which", () => { + assert.deepEqual( + [entitlementFrom([row({ provider: "paddle" })]), entitlementFrom([row({ provider: "polar", id: "uuid-1" })])].map((e) => [e.active, e.provider, e.planKey]), + [ + [true, "paddle", "pro"], + [true, "polar", "pro"], + ], + ); + }); + + it("survives a switch: the running subscription of the old provider wins over the canceled one of the new", () => { + const paddleRunning = row({ provider: "paddle", id: "sub_p", status: "active" }); + const polarCanceled = row({ provider: "polar", id: "uuid-2", status: "canceled", current_period_end: "2027-06-01T00:00:00Z" }); + const entitlement = entitlementFrom([polarCanceled, paddleRunning]); + assert.equal(entitlement.active, true); + assert.equal(entitlement.provider, "paddle"); + assert.equal(bestSubscription([polarCanceled, paddleRunning])?.id, "sub_p"); + }); +}); + +describe("providerForUser: where this user's portal and sync live", () => { + const input = (over: Partial[0]> = {}) => ({ subscriptions: [], lastPaymentProvider: null, links: NO_LINKS, ...over }); + + it("is the provider of the best subscription, even after BILLING_PROVIDER moved on", () => { + const subscriptions = [row({ provider: "paddle", status: "active" })]; + assert.equal(providerForUser(input({ subscriptions, preferred: "polar", links: { paddleCustomerId: "ctm_1", polarLinked: true } })), "paddle"); + }); + + it("a canceled subscription still points at the place where its invoices are", () => { + assert.equal(providerForUser(input({ subscriptions: [row({ provider: "polar", status: "canceled" })], preferred: "paddle" })), "polar"); + }); + + it("without a subscription it is the provider of the latest payment (a one-time purchase)", () => { + assert.equal(providerForUser(input({ lastPaymentProvider: "polar", preferred: "paddle" })), "polar"); + }); + + it("without any of those it is a provider the user started a checkout with, the preferred one first", () => { + const links = { paddleCustomerId: "ctm_1", polarLinked: true }; + assert.equal(providerForUser(input({ links, preferred: "polar" })), "polar"); + assert.equal(providerForUser(input({ links, preferred: "paddle" })), "paddle"); + assert.equal(providerForUser(input({ links })), "paddle"); + assert.equal(providerForUser(input({ links: { paddleCustomerId: null, polarLinked: true }, preferred: "paddle" })), "polar"); + }); + + it("is null for a user who never started a checkout", () => { + assert.equal(providerForUser(input({ preferred: "paddle" })), null); + }); +}); + +describe("reading from the database (through the user's own session: Row Level Security keeps other users' rows out)", () => { + const setup = () => { + const db = billingDb(); + return { db, client: db as unknown as SupabaseClient }; + }; + const sub = (id: string, over: Record = {}) => ({ id, provider: "paddle", user_id: USER_ID, status: "active", plan_key: "pro", billing_interval: "month", current_period_end: "2026-11-14T00:00:00Z", cancel_at_period_end: false, ...over }); + + it("answers for the user id it is given, and only that one", async () => { + const { db, client } = setup(); + db.tables.billing_subscriptions.push(sub("sub_a", { plan_key: "team", cancel_at_period_end: true }), sub("sub_b", { user_id: "someone-else" })); + const entitlement = await getEntitlement(client, USER_ID); + assert.deepEqual({ active: entitlement.active, planKey: entitlement.planKey, cancelAtPeriodEnd: entitlement.cancelAtPeriodEnd, id: entitlement.subscriptionId, provider: entitlement.provider }, { active: true, planKey: "team", cancelAtPeriodEnd: true, id: "sub_a", provider: "paddle" }); + assert.equal((await getEntitlement(client, "nobody")).active, false); + }); + + it("sees a Polar subscriber and a subscriber of the old provider alike", async () => { + const { db, client } = setup(); + db.tables.billing_subscriptions.push(sub("uuid-1", { provider: "polar", status: "canceled", current_period_end: "2027-06-01T00:00:00Z" }), sub("sub_p")); + const entitlement = await getEntitlement(client, USER_ID); + assert.deepEqual({ active: entitlement.active, provider: entitlement.provider }, { active: true, provider: "paddle" }); + assert.equal((await readSubscriptions(client, USER_ID)).length, 2); + }); + + it("reads the provider of the latest payment", async () => { + const { db, client } = setup(); + assert.equal(await readLastPaymentProvider(client, USER_ID), null); + db.tables.billing_transactions.push( + { provider: "paddle", id: "t1", user_id: USER_ID, status: "completed", billed_at: "2026-10-01T00:00:00Z" }, + { provider: "polar", id: "t2", user_id: USER_ID, status: "completed", billed_at: "2026-11-01T00:00:00Z" }, + ); + assert.equal(await readLastPaymentProvider(client, USER_ID), "polar"); + }); + + it("knows whether a one-time product is paid for and not fully refunded, whichever provider took the payment", async () => { + const { db, client } = setup(); + const payment = (id: string, over: Record = {}) => ({ provider: "paddle", id, user_id: USER_ID, status: "completed", item_key: "credits_100", total: 1000, refunded: 0, ...over }); + assert.equal(await hasPurchased(client, USER_ID, "credits_100"), false); + db.tables.billing_transactions.push(payment("t1", { refunded: 1000 })); + assert.equal(await hasPurchased(client, USER_ID, "credits_100"), false, "fully refunded"); + db.tables.billing_transactions.push(payment("o2", { provider: "polar", refunded: 400 })); + assert.equal(await hasPurchased(client, USER_ID, "credits_100"), true, "partly refunded, bought through Polar"); + assert.equal(await hasPurchased(client, USER_ID, "other_product"), false); + db.tables.billing_transactions.push(payment("t3", { user_id: "u2", status: "billed" })); + assert.equal(await hasPurchased(client, "u2", "credits_100"), false, "not completed"); + }); + + it("throws when a read fails, rather than answering no", async () => { + const broken = { from: () => ({ select: () => ({ eq: () => ({ eq: () => ({ eq: async () => ({ data: null, error: { message: "boom" } }) }), then: (resolve: (value: unknown) => void) => resolve({ data: null, error: { message: "boom" } }) }) }) }) } as unknown as SupabaseClient; + await assert.rejects(hasPurchased(broken, USER_ID, "credits_100"), /boom/); + await assert.rejects(getEntitlement(broken, USER_ID), /boom/); + }); +}); diff --git a/tests/billing-provider.test.ts b/tests/billing-provider.test.ts new file mode 100644 index 0000000..583a293 --- /dev/null +++ b/tests/billing-provider.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { chooseBillingProvider, providerConfigProblem, providerServerProblem } from "@/lib/billing/provider"; + +const PADDLE = { PADDLE_API_KEY: "pdl_sdbx_apikey_x", PADDLE_APP_TAG: "my-app", NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: "test_abc" }; +const POLAR = { POLAR_ACCESS_TOKEN: "polar_oat_x" }; + +describe("chooseBillingProvider: who takes NEW checkouts", () => { + it("is the provider BILLING_PROVIDER names, when it is configured", () => { + assert.deepEqual(chooseBillingProvider({ ...PADDLE, ...POLAR, BILLING_PROVIDER: "paddle" }), { provider: "paddle" }); + assert.deepEqual(chooseBillingProvider({ ...PADDLE, ...POLAR, BILLING_PROVIDER: "polar" }), { provider: "polar" }); + assert.deepEqual(chooseBillingProvider({ ...POLAR, BILLING_PROVIDER: " Polar " }), { provider: "polar" }, "case and spaces do not matter"); + }); + + it("is an error, never a silent fallback, when the named provider is not configured", () => { + const choice = chooseBillingProvider({ ...POLAR, BILLING_PROVIDER: "paddle" }); + assert.ok("error" in choice); + assert.equal(choice.error, "billing_not_configured"); + assert.match(choice.reason, /BILLING_PROVIDER is paddle/); + assert.match(choice.reason, /PADDLE_API_KEY/); + }); + + it("is an error for a name that is neither", () => { + for (const name of ["stripe", "paddel", "both", "true"]) { + const choice = chooseBillingProvider({ ...PADDLE, ...POLAR, BILLING_PROVIDER: name }); + assert.ok("error" in choice, name); + assert.match(choice.reason, /"paddle" or "polar"/); + } + }); + + it("without BILLING_PROVIDER it is the one provider that is configured", () => { + assert.deepEqual(chooseBillingProvider(PADDLE), { provider: "paddle" }); + assert.deepEqual(chooseBillingProvider(POLAR), { provider: "polar" }); + assert.deepEqual(chooseBillingProvider({ ...POLAR, BILLING_PROVIDER: "" }), { provider: "polar" }); + }); + + it("without BILLING_PROVIDER and with both configured it refuses to guess (a switch must be a decision)", () => { + const choice = chooseBillingProvider({ ...PADDLE, ...POLAR }); + assert.ok("error" in choice); + assert.match(choice.reason, /BILLING_PROVIDER/); + }); + + it("is an error when nothing is configured", () => { + const choice = chooseBillingProvider({}); + assert.ok("error" in choice); + assert.match(choice.reason, /no payment provider/); + }); +}); + +describe("what the old provider still needs after a switch", () => { + it("a new checkout needs Paddle's browser token; an existing subscriber's portal and sync do not", () => { + const withoutToken = { ...PADDLE, NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: undefined }; + assert.match(providerConfigProblem("paddle", withoutToken) ?? "", /CLIENT_TOKEN/); + assert.equal(providerServerProblem("paddle", withoutToken), null); + }); + + it("Polar needs the same for both", () => { + assert.equal(providerConfigProblem("polar", POLAR), null); + assert.equal(providerServerProblem("polar", POLAR), null); + assert.match(providerServerProblem("polar", {}) ?? "", /POLAR_ACCESS_TOKEN/); + }); +}); diff --git a/tests/billing-routes.test.ts b/tests/billing-routes.test.ts new file mode 100644 index 0000000..3bb1640 --- /dev/null +++ b/tests/billing-routes.test.ts @@ -0,0 +1,666 @@ +// The route handlers, called for real with a fake Supabase (an in-memory database, a user session that tests switch on and off) and fake Paddle and +// Polar APIs. Covers both providers and what happens when `BILLING_PROVIDER` is switched under existing subscribers. +// Needs `--experimental-test-module-mocks` (the `test` script has it). +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { signPaddleBody } from "@/lib/billing/paddle/signature"; +import { signPolarBody } from "@/lib/billing/polar/signature"; +import { billingDb } from "./support/fake-supabase"; +import { adjustmentData, CUSTOMER_ID, event, polarOrderData, polarSubscriptionData, subscriptionData, transactionData, USER_ID } from "./support/billing-fixtures"; + +const src = (path: string) => new URL(`../src/${path}`, import.meta.url).href; + +// `mock.module` is in Node 22.3+ but not in the @types/node this project pins. +const mockModule = (mock as unknown as { module: (specifier: string, options: { namedExports: Record }) => void }).module.bind(mock); + +let db = billingDb(); +type TestUser = { id: string; email: string | null; email_confirmed_at: string | null }; +let user: TestUser | null = null; +const verified = (id: string, email: string | null): TestUser => ({ id, email, email_confirmed_at: "2026-01-01T00:00:00Z" }); +let adminAvailable = true; + +mockModule(src("utils/supabase/server.ts"), { + namedExports: { + createClient: async () => ({ + auth: { getUser: async () => ({ data: { user }, error: user ? null : { message: "no session" } }) }, + from: (table: string) => db.from(table), + }), + }, +}); +mockModule(src("utils/supabase/admin.ts"), { namedExports: { createAdminClient: () => (adminAvailable ? db : null) } }); + +const paddleWebhook = await import("@/app/api/webhooks/paddle/route"); +const polarWebhook = await import("@/app/api/webhooks/polar/route"); +const checkout = await import("@/app/api/billing/checkout/route"); +const portal = await import("@/app/api/billing/portal/route"); +const sync = await import("@/app/api/billing/sync/route"); +const { getEntitlement } = await import("@/lib/billing/entitlements"); + +const PADDLE_SECRET = "pdl_ntfset_test"; +const POLAR_SECRET = `whsec_${Buffer.from("test-polar-secret-0123456789abcdef").toString("base64")}`; +const ENV_KEYS = [ + "BILLING_PROVIDER", + "PADDLE_API_KEY", + "PADDLE_APP_TAG", + "PADDLE_WEBHOOK_SECRET", + "NEXT_PUBLIC_PADDLE_CLIENT_TOKEN", + "NEXT_PUBLIC_PADDLE_ENVIRONMENT", + "PADDLE_PRICE_PRO_MONTH", + "PADDLE_PRICE_PRO_YEAR", + "POLAR_ACCESS_TOKEN", + "POLAR_WEBHOOK_SECRET", + "POLAR_SERVER", + "POLAR_PRODUCT_PRO_MONTH", + "POLAR_PRODUCT_PRO_YEAR", +]; +const realFetch = globalThis.fetch; +type Call = { method: string; path: string; query: string; body: unknown }; +const paddleCalls: Call[] = []; +const polarCalls: Call[] = []; +let paddleAnswers: Record Response> = {}; +let polarAnswers: Record Response> = {}; + +const jsonResponse = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + Object.assign(process.env, { + BILLING_PROVIDER: "paddle", + PADDLE_API_KEY: "pdl_sdbx_apikey_test", + PADDLE_APP_TAG: "test-app", + PADDLE_WEBHOOK_SECRET: PADDLE_SECRET, + NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: "test_clienttoken", + PADDLE_PRICE_PRO_MONTH: "pri_pro_month", + PADDLE_PRICE_PRO_YEAR: "pri_pro_year", + POLAR_ACCESS_TOKEN: "polar_oat_test", + POLAR_WEBHOOK_SECRET: POLAR_SECRET, + POLAR_PRODUCT_PRO_MONTH: "prod_pro_month", + POLAR_PRODUCT_PRO_YEAR: "prod_pro_year", + }); + db = billingDb(); + user = null; + adminAvailable = true; + paddleCalls.length = 0; + polarCalls.length = 0; + paddleAnswers = {}; + polarAnswers = {}; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const parsed = new URL(String(url)); + const isPaddle = parsed.hostname === "sandbox-api.paddle.com"; + const isPolar = parsed.hostname === "sandbox-api.polar.sh"; + if (!isPaddle && !isPolar) throw new Error(`unexpected request to ${parsed.hostname}`); + const method = init?.method ?? "GET"; + (isPaddle ? paddleCalls : polarCalls).push({ method, path: parsed.pathname, query: parsed.search, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + const answers = isPaddle ? paddleAnswers : polarAnswers; + const key = Object.keys(answers).find((candidate) => `${method} ${parsed.pathname}`.startsWith(candidate)); + if (!key) throw new Error(`unexpected ${isPaddle ? "Paddle" : "Polar"} call ${method} ${parsed.pathname}`); + return answers[key](); + }) as typeof fetch; +}); +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const post = (path: string, body?: unknown, headers: Record = {}) => + new Request(`http://localhost${path}`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body) }); +const signed = (body: string) => post("/api/webhooks/paddle", body, { "paddle-signature": signPaddleBody(PADDLE_SECRET, body) }); +const signedEvent = (type: string, data: unknown, occurredAt?: string, id?: string) => signed(JSON.stringify(event(type, data, occurredAt, id))); + +let polarMessage = 0; +const polarBody = (type: string, data: unknown, timestamp = "2026-10-14T00:00:10Z") => JSON.stringify({ type, timestamp, data }); +const polarRequest = (body: string, id = `msg_route_${(polarMessage += 1)}`, secret = POLAR_SECRET) => post("/api/webhooks/polar", body, signPolarBody(secret, id, body)); +const signedPolar = (type: string, data: unknown, id?: string, timestamp?: string) => polarRequest(polarBody(type, data, timestamp), id); + +const linkPaddle = (userId: string, customerId: string) => db.tables.billing_customers.push({ user_id: userId, paddle_customer_id: customerId }); +const linkPolar = (userId: string) => db.tables.billing_customers.push({ user_id: userId, polar_linked_at: "2026-10-14T00:00:00.000Z" }); +const polarCustomer = (userId: string) => ({ id: "polar-customer", external_id: userId, email: `${userId}@example.com` }); +const storedSubscription = (provider: "paddle" | "polar", id: string, userId: string, over: Record = {}) => + db.tables.billing_subscriptions.push({ provider, id, user_id: userId, status: "active", plan_key: "pro", billing_interval: "month", current_period_end: "2099-11-14T00:00:00Z", cancel_at_period_end: false, ...over }); + +describe("POST /api/webhooks/paddle", () => { + it("answers 503 while Paddle is not configured, and never accepts anything without a webhook secret", async () => { + delete process.env.PADDLE_API_KEY; + assert.equal((await paddleWebhook.POST(post("/api/webhooks/paddle", "{}"))).status, 503); + process.env.PADDLE_API_KEY = "pdl_sdbx_apikey_test"; + delete process.env.PADDLE_WEBHOOK_SECRET; + const response = await paddleWebhook.POST(post("/api/webhooks/paddle", "{}", { "paddle-signature": signPaddleBody("", "{}") })); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: "webhook_secret_not_configured" }); + }); + + it("refuses an unsigned, wrongly signed or tampered request with 401, and writes nothing", async () => { + const body = JSON.stringify(event("subscription.created", subscriptionData())); + assert.equal((await paddleWebhook.POST(post("/api/webhooks/paddle", body))).status, 401); + assert.equal((await paddleWebhook.POST(post("/api/webhooks/paddle", body, { "paddle-signature": signPaddleBody("wrong", body) }))).status, 401); + assert.equal((await paddleWebhook.POST(post("/api/webhooks/paddle", `${body} `, { "paddle-signature": signPaddleBody(PADDLE_SECRET, body) }))).status, 401); + assert.equal(db.tables.billing_webhook_events.length, 0); + }); + + it("answers 400 for a signed body that is not an event", async () => { + assert.equal((await paddleWebhook.POST(signed("not json"))).status, 400); + assert.equal((await paddleWebhook.POST(signed(JSON.stringify({ hello: "world" })))).status, 400); + }); + + it("answers 503 when it cannot write (no service role key), so that Paddle tries again", async () => { + adminAvailable = false; + assert.equal((await paddleWebhook.POST(signedEvent("subscription.created", subscriptionData()))).status, 503); + }); + + it("applies a signed subscription event of a known customer, and a redelivery changes nothing", async () => { + linkPaddle(USER_ID, CUSTOMER_ID); + const first = await paddleWebhook.POST(signedEvent("subscription.created", subscriptionData(), "2026-10-14T00:00:10Z", "evt_route_1")); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { ok: true, status: "processed" }); + assert.equal(db.tables.billing_subscriptions.length, 1); + assert.equal(db.tables.billing_subscriptions[0].provider, "paddle"); + assert.equal(db.tables.billing_subscriptions[0].user_id, USER_ID); + assert.equal(db.tables.billing_subscriptions[0].plan_key, "pro"); + + db.tables.billing_subscriptions[0].status = "changed-by-test"; + const again = await paddleWebhook.POST(signedEvent("subscription.created", subscriptionData(), "2026-10-14T00:00:10Z", "evt_route_1")); + assert.deepEqual(await again.json(), { ok: true, status: "duplicate" }); + assert.equal(db.tables.billing_subscriptions[0].status, "changed-by-test", "the duplicate was not applied"); + assert.equal(db.tables.billing_webhook_events.length, 1); + }); + + it("answers 200 and records `ignored` for a customer it does not know (never a retry storm)", async () => { + const response = await paddleWebhook.POST(signedEvent("subscription.created", subscriptionData({ customer_id: "ctm_01stranger" }), "2026-10-14T00:00:10Z", "evt_stranger")); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, status: "ignored" }); + assert.equal(db.tables.billing_webhook_events[0].status, "ignored"); + assert.equal(db.tables.billing_subscriptions.length, 0); + }); + + it("records a payment and then its refund", async () => { + linkPaddle(USER_ID, CUSTOMER_ID); + assert.equal((await paddleWebhook.POST(signedEvent("transaction.completed", transactionData()))).status, 200); + assert.equal((await paddleWebhook.POST(signedEvent("adjustment.created", adjustmentData({ totals: { total: "500" } })))).status, 200); + assert.equal(db.tables.billing_transactions[0].total, 990); + assert.equal(db.tables.billing_transactions[0].refunded, 500); + }); + + it("answers 500 when applying fails, and processes the redelivery", async () => { + linkPaddle(USER_ID, CUSTOMER_ID); + const original = db.from.bind(db); + let broken = true; + db.from = ((table: string) => { + if (table === "billing_subscriptions" && broken) throw new Error("database is down"); + return original(table); + }) as typeof db.from; + const request = () => signedEvent("subscription.created", subscriptionData(), "2026-10-14T00:00:10Z", "evt_flaky"); + assert.equal((await paddleWebhook.POST(request())).status, 500); + assert.equal(db.tables.billing_webhook_events[0].status, "failed"); + broken = false; + assert.equal((await paddleWebhook.POST(request())).status, 200); + assert.equal(db.tables.billing_subscriptions.length, 1); + }); +}); + +describe("POST /api/webhooks/polar", () => { + it("answers 503 while Polar is not configured, and never accepts anything without a webhook secret", async () => { + delete process.env.POLAR_ACCESS_TOKEN; + assert.equal((await polarWebhook.POST(post("/api/webhooks/polar", "{}"))).status, 503); + process.env.POLAR_ACCESS_TOKEN = "polar_oat_test"; + delete process.env.POLAR_WEBHOOK_SECRET; + const response = await polarWebhook.POST(polarRequest("{}", "msg_1", "")); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: "webhook_secret_not_configured" }); + }); + + it("refuses an unsigned, wrongly signed or tampered request with 401, and writes nothing", async () => { + const body = polarBody("subscription.active", polarSubscriptionData()); + assert.equal((await polarWebhook.POST(post("/api/webhooks/polar", body))).status, 401); + assert.equal((await polarWebhook.POST(polarRequest(body, "msg_x", `whsec_${Buffer.from("another-secret-another-secret-12").toString("base64")}`))).status, 401); + assert.equal((await polarWebhook.POST(post("/api/webhooks/polar", `${body} `, signPolarBody(POLAR_SECRET, "msg_y", body)))).status, 401); + assert.equal(db.tables.billing_webhook_events.length, 0); + }); + + it("answers 400 for a signed body that is not an event, and 503 when it cannot write", async () => { + assert.equal((await polarWebhook.POST(polarRequest("not json"))).status, 400); + assert.equal((await polarWebhook.POST(polarRequest(JSON.stringify({ hello: "world" })))).status, 400); + adminAvailable = false; + assert.equal((await polarWebhook.POST(signedPolar("subscription.active", polarSubscriptionData({ customer: polarCustomer(USER_ID) })))).status, 503); + }); + + it("applies a signed subscription event for the user id the server set, and a redelivery changes nothing", async () => { + const data = polarSubscriptionData({ customer: polarCustomer(USER_ID) }); + const first = await polarWebhook.POST(signedPolar("subscription.active", data, "msg_route_a")); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { ok: true, status: "processed" }); + assert.equal(db.tables.billing_subscriptions[0].provider, "polar"); + assert.equal(db.tables.billing_subscriptions[0].user_id, USER_ID); + assert.equal(db.tables.billing_subscriptions[0].plan_key, "pro"); + + db.tables.billing_subscriptions[0].status = "changed-by-test"; + const again = await polarWebhook.POST(signedPolar("subscription.active", data, "msg_route_a")); + assert.deepEqual(await again.json(), { ok: true, status: "duplicate" }); + assert.equal(db.tables.billing_subscriptions[0].status, "changed-by-test", "the duplicate was not applied"); + }); + + it("answers 200 and records `ignored` for a payload without an external customer id or for a product that is not ours", async () => { + const noExternal = await polarWebhook.POST(signedPolar("subscription.active", polarSubscriptionData({ customer: { id: "c", external_id: null } }), "msg_ne")); + assert.deepEqual(await noExternal.json(), { ok: true, status: "ignored" }); + const foreign = await polarWebhook.POST(signedPolar("order.paid", polarOrderData({ product_id: "prod_other_app", customer: polarCustomer(USER_ID) }), "msg_fp")); + assert.deepEqual(await foreign.json(), { ok: true, status: "ignored" }); + assert.deepEqual(db.tables.billing_webhook_events.map((row) => [row.event_id, row.status, row.note]).sort(), [["msg_fp", "ignored", "not_our_product"], ["msg_ne", "ignored", "no_customer"]]); + assert.equal(db.tables.billing_subscriptions.length + db.tables.billing_transactions.length, 0); + }); + + it("answers 200 and records `ignored` for a payment of a user who no longer exists (never a retry storm)", async () => { + db.users = new Set(["someone-else"]); + const response = await polarWebhook.POST(signedPolar("order.paid", polarOrderData({ customer: polarCustomer(USER_ID) }), "msg_gone")); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, status: "ignored" }); + assert.deepEqual(db.tables.billing_webhook_events.map((row) => [row.event_id, row.status, row.note]), [["msg_gone", "ignored", "unknown_user"]]); + assert.equal(db.tables.billing_transactions.length, 0); + }); + + it("records a payment and then its refund", async () => { + const order = polarOrderData({ customer: polarCustomer(USER_ID) }); + assert.equal((await polarWebhook.POST(signedPolar("order.paid", order))).status, 200); + const refunded = { ...order, status: "partially_refunded", refunded_amount: 500, refunded_tax_amount: 0, modified_at: "2026-10-15T00:00:00Z" }; + assert.equal((await polarWebhook.POST(signedPolar("order.updated", refunded))).status, 200); + assert.equal(db.tables.billing_transactions[0].provider, "polar"); + assert.equal(db.tables.billing_transactions[0].total, 990); + assert.equal(db.tables.billing_transactions[0].refunded, 500); + }); + + it("is processed whatever BILLING_PROVIDER says (that setting only picks the provider of NEW checkouts)", async () => { + process.env.BILLING_PROVIDER = "paddle"; + assert.equal((await polarWebhook.POST(signedPolar("subscription.active", polarSubscriptionData({ customer: polarCustomer(USER_ID) })))).status, 200); + process.env.BILLING_PROVIDER = "polar"; + linkPaddle(USER_ID, CUSTOMER_ID); + assert.equal((await paddleWebhook.POST(signedEvent("subscription.created", subscriptionData()))).status, 200); + assert.deepEqual(db.tables.billing_subscriptions.map((row) => row.provider).sort(), ["paddle", "polar"]); + }); +}); + +describe("POST /api/billing/checkout (Paddle)", () => { + const body = { kind: "plan", key: "pro", interval: "month" }; + const withPaddle = () => { + paddleAnswers = { + "GET /customers": () => jsonResponse(200, { data: [] }), + "POST /customers": () => jsonResponse(201, { data: { id: "ctm_01created" } }), + "POST /transactions": () => jsonResponse(201, { data: { id: "txn_01created" } }), + }; + }; + + it("answers 503 while Paddle, or the browser token, is not configured", async () => { + delete process.env.PADDLE_APP_TAG; + assert.deepEqual(await (await checkout.POST(post("/api/billing/checkout", body))).json(), { error: "billing_not_configured" }); + process.env.PADDLE_APP_TAG = "test-app"; + delete process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN; + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 503); + }); + + it("answers 401 for a visitor who is not signed in, 400 for no e-mail, 400 for a bad body", async () => { + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 401); + user = verified(USER_ID, null); + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 400); + user = verified(USER_ID, "buyer@example.com"); + const bad = await checkout.POST(post("/api/billing/checkout", { kind: "plan", key: "pro" })); + assert.equal(bad.status, 400); + assert.deepEqual(await bad.json(), { error: "invalid_request" }); + }); + + it("refuses to link a Paddle customer to an e-mail address nobody has confirmed (it would expose the owner's customer portal)", async () => { + withPaddle(); + user = { id: USER_ID, email: "victim@example.com", email_confirmed_at: null }; + const response = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "email_not_verified" }); + assert.equal(paddleCalls.length, 0, "Paddle was not asked about that address"); + assert.equal(db.tables.billing_customers.length, 0); + }); + + it("answers 404 for an item that does not exist and 503 for a price that is not configured, and 503 without a service role key", async () => { + user = verified(USER_ID, "buyer@example.com"); + assert.equal((await checkout.POST(post("/api/billing/checkout", { kind: "plan", key: "enterprise", interval: "month" }))).status, 404); + delete process.env.PADDLE_PRICE_PRO_MONTH; + const response = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: "price_not_configured" }); + assert.equal(paddleCalls.length, 0, "nothing was sent to Paddle"); + process.env.PADDLE_PRICE_PRO_MONTH = "pri_pro_month"; + adminAvailable = false; + assert.deepEqual(await (await checkout.POST(post("/api/billing/checkout", body))).json(), { error: "supabase_admin_not_configured" }); + }); + + it("links the user to a Paddle customer, creates the transaction on the server and returns what the browser needs", async () => { + withPaddle(); + user = verified(USER_ID, "buyer@example.com"); + const response = await checkout.POST(post("/api/billing/checkout", { ...body, priceId: "pri_free", customerId: "ctm_other", provider: "polar" })); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { provider: "paddle", transactionId: "txn_01created", clientToken: "test_clienttoken", environment: "sandbox" }); + assert.deepEqual(db.tables.billing_customers, [{ user_id: USER_ID, paddle_customer_id: "ctm_01created" }]); + const transaction = paddleCalls.find((call) => call.method === "POST" && call.path === "/transactions"); + assert.deepEqual(transaction?.body, { + items: [{ price_id: "pri_pro_month", quantity: 1 }], + customer_id: "ctm_01created", + collection_mode: "automatic", + custom_data: { app: "test-app", user_id: USER_ID, item_key: "pro", interval: "month" }, + }); + const created = paddleCalls.find((call) => call.method === "POST" && call.path === "/customers"); + assert.deepEqual(created?.body, { email: "buyer@example.com", custom_data: { app: "test-app", user_id: USER_ID } }); + assert.equal(polarCalls.length, 0, "the body's `provider` is not a way to pick Polar"); + }); + + it("reuses the linked customer on the next purchase (no customer calls to Paddle)", async () => { + withPaddle(); + user = verified(USER_ID, "buyer@example.com"); + linkPaddle(USER_ID, "ctm_01existing"); + assert.equal((await checkout.POST(post("/api/billing/checkout", { kind: "plan", key: "pro", interval: "year" }))).status, 200); + assert.deepEqual(paddleCalls.map((call) => `${call.method} ${call.path}`), ["POST /transactions"]); + assert.equal((paddleCalls[0].body as { customer_id: string }).customer_id, "ctm_01existing"); + assert.equal((paddleCalls[0].body as { items: { price_id: string }[] }).items[0].price_id, "pri_pro_year"); + }); + + it("refuses a second subscription while one is running, past due or paused (the portal changes plans)", async () => { + withPaddle(); + user = verified(USER_ID, "buyer@example.com"); + for (const status of ["active", "trialing", "past_due", "paused"]) { + db.tables.billing_subscriptions.length = 0; + storedSubscription("paddle", "sub_1", USER_ID, { status }); + const response = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(response.status, 409, status); + assert.deepEqual(await response.json(), { error: "already_subscribed" }); + } + db.tables.billing_subscriptions.length = 0; + storedSubscription("paddle", "sub_1", USER_ID, { status: "canceled" }); + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 200, "a canceled one may subscribe again"); + }); + + it("answers 502 with a stable code when Paddle fails, and 409 when the customer belongs to someone else", async () => { + user = verified(USER_ID, "buyer@example.com"); + paddleAnswers = { "GET /customers": () => jsonResponse(500, { error: { code: "internal", detail: "boom" } }) }; + const failed = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(failed.status, 502); + assert.deepEqual(await failed.json(), { error: "checkout_failed" }); + + paddleAnswers = { "GET /customers": () => jsonResponse(200, { data: [{ id: "ctm_shared" }] }) }; + linkPaddle("uid-someone-else", "ctm_shared"); + const conflict = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(conflict.status, 409); + assert.deepEqual(await conflict.json(), { error: "customer_conflict" }); + assert.equal(db.tables.billing_customers.find((row) => row.user_id === "uid-someone-else")?.paddle_customer_id, "ctm_shared"); + }); +}); + +describe("POST /api/billing/checkout (Polar)", () => { + const body = { kind: "plan", key: "pro", interval: "month" }; + beforeEach(() => { + process.env.BILLING_PROVIDER = "polar"; + polarAnswers = { "POST /v1/checkouts": () => jsonResponse(201, { id: "chk_1", url: "https://sandbox.polar.sh/checkout/chk_1" }) }; + }); + + it("creates the checkout on the server for the signed-in user and returns where to send the buyer", async () => { + user = verified(USER_ID, "buyer@example.com"); + const response = await checkout.POST(post("/api/billing/checkout", { ...body, productId: "prod_free", externalCustomerId: "someone-else", provider: "paddle" })); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { provider: "polar", url: "https://sandbox.polar.sh/checkout/chk_1" }); + assert.deepEqual(polarCalls[0].body, { + products: ["prod_pro_month"], + external_customer_id: USER_ID, + customer_email: "buyer@example.com", + success_url: "http://localhost/billing/success?provider=polar&checkout_id={CHECKOUT_ID}", + return_url: "http://localhost/pricing", + metadata: { user_id: USER_ID, item_key: "pro", interval: "month" }, + }); + assert.equal(paddleCalls.length, 0); + assert.equal(db.tables.billing_customers.length, 1); + assert.equal(db.tables.billing_customers[0].user_id, USER_ID); + assert.ok(db.tables.billing_customers[0].polar_linked_at); + assert.equal(db.tables.billing_customers[0].paddle_customer_id, undefined); + }); + + it("does not need a confirmed e-mail (the Polar customer is the user id, not an address)", async () => { + user = { id: USER_ID, email: "unconfirmed@example.com", email_confirmed_at: null }; + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 200); + user = { id: "uid-no-mail", email: null, email_confirmed_at: null }; + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 200); + assert.equal("customer_email" in (polarCalls[1].body as object), false); + }); + + it("still sells when Polar refuses the prefilled e-mail address: the buyer types it on Polar's page", async () => { + user = verified(USER_ID, "buyer@nowhere.invalid"); + let attempt = 0; + polarAnswers = { + "POST /v1/checkouts": () => { + attempt += 1; + return attempt === 1 + ? jsonResponse(422, { detail: [{ loc: ["body", "customer_email"], msg: "value is not a valid email address: The domain does not exist", type: "value_error" }] }) + : jsonResponse(201, { id: "chk_2", url: "https://sandbox.polar.sh/checkout/chk_2" }); + }, + }; + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 200); + assert.equal(polarCalls.length, 2); + assert.equal("customer_email" in (polarCalls[1].body as object), false); + }); + + it("answers 503 for a product that is not configured, 404 for an unknown item, 401 without a session", async () => { + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 401); + user = verified(USER_ID, "buyer@example.com"); + delete process.env.POLAR_PRODUCT_PRO_MONTH; + assert.deepEqual(await (await checkout.POST(post("/api/billing/checkout", body))).json(), { error: "price_not_configured" }); + assert.equal((await checkout.POST(post("/api/billing/checkout", { kind: "plan", key: "enterprise", interval: "month" }))).status, 404); + delete process.env.POLAR_ACCESS_TOKEN; + assert.deepEqual(await (await checkout.POST(post("/api/billing/checkout", body))).json(), { error: "billing_not_configured" }); + assert.equal(polarCalls.length, 0); + }); + + it("answers 502 with a stable code when Polar fails", async () => { + user = verified(USER_ID, "buyer@example.com"); + polarAnswers = { "POST /v1/checkouts": () => jsonResponse(500, { detail: "boom" }) }; + const failed = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(failed.status, 502); + assert.deepEqual(await failed.json(), { error: "checkout_failed" }); + }); +}); + +describe("switching BILLING_PROVIDER under existing subscribers", () => { + const body = { kind: "plan", key: "pro", interval: "month" }; + + it("without BILLING_PROVIDER, and with both configured, no new checkout is taken (a switch must be a decision)", async () => { + delete process.env.BILLING_PROVIDER; + user = verified(USER_ID, "buyer@example.com"); + assert.deepEqual(await (await checkout.POST(post("/api/billing/checkout", body))).json(), { error: "billing_not_configured" }); + assert.equal(paddleCalls.length + polarCalls.length, 0); + }); + + it("a Paddle subscriber cannot start a second subscription at Polar after the switch (never billed twice)", async () => { + storedSubscription("paddle", "sub_p", USER_ID); + process.env.BILLING_PROVIDER = "polar"; + user = verified(USER_ID, "buyer@example.com"); + const response = await checkout.POST(post("/api/billing/checkout", body)); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "already_subscribed" }); + assert.equal(polarCalls.length, 0); + }); + + it("a Polar subscriber cannot start a second subscription at Paddle after switching back", async () => { + storedSubscription("polar", "uuid-1", USER_ID); + process.env.BILLING_PROVIDER = "paddle"; + user = verified(USER_ID, "buyer@example.com"); + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 409); + assert.equal(paddleCalls.length, 0); + }); + + it("keeps updating a Paddle subscriber after the switch: their cancellation arrives, and the entitlement follows", async () => { + linkPaddle(USER_ID, CUSTOMER_ID); + assert.equal((await paddleWebhook.POST(signedEvent("subscription.created", subscriptionData(), "2026-10-14T00:00:10Z"))).status, 200); + process.env.BILLING_PROVIDER = "polar"; + const scheduled = { action: "cancel", effective_at: "2026-11-14T00:00:00Z", resume_at: null }; + assert.equal((await paddleWebhook.POST(signedEvent("subscription.updated", subscriptionData({ scheduled_change: scheduled }), "2026-10-20T00:00:00Z"))).status, 200); + const entitlement = await getEntitlement(db as never, USER_ID); + assert.deepEqual({ active: entitlement.active, provider: entitlement.provider, cancelAtPeriodEnd: entitlement.cancelAtPeriodEnd }, { active: true, provider: "paddle", cancelAtPeriodEnd: true }); + }); + + it("opens the portal of the provider that BILLS the user, not the one that takes new checkouts", async () => { + user = verified(USER_ID, "buyer@example.com"); + linkPaddle(USER_ID, CUSTOMER_ID); + storedSubscription("paddle", "sub_p", USER_ID); + process.env.BILLING_PROVIDER = "polar"; + paddleAnswers = { "POST /customers": () => jsonResponse(201, { data: { urls: { general: { overview: "https://paddle.example/portal" }, subscriptions: [{ cancel_subscription: "https://paddle.example/cancel" }] } } }) }; + const response = await portal.POST(post("/api/billing/portal")); + assert.deepEqual(await response.json(), { provider: "paddle", url: "https://paddle.example/portal", cancelUrl: "https://paddle.example/cancel" }); + assert.equal(polarCalls.length, 0); + }); + + it("and a Polar subscriber's portal stays at Polar when new checkouts go to Paddle", async () => { + user = verified(USER_ID, "buyer@example.com"); + linkPolar(USER_ID); + storedSubscription("polar", "uuid-1", USER_ID); + process.env.BILLING_PROVIDER = "paddle"; + polarAnswers = { "POST /v1/customer-sessions": () => jsonResponse(201, { customer_portal_url: "https://sandbox.polar.sh/portal/x" }) }; + const response = await portal.POST(post("/api/billing/portal")); + assert.deepEqual(await response.json(), { provider: "polar", url: "https://sandbox.polar.sh/portal/x", cancelUrl: null }); + assert.deepEqual(polarCalls[0].body, { external_customer_id: USER_ID, return_url: "http://localhost/billing" }); + assert.equal(paddleCalls.length, 0); + }); + + it("the old provider only has to keep its server side after a switch: Paddle's portal needs no browser token or price ids any more", async () => { + user = verified(USER_ID, "buyer@example.com"); + linkPaddle(USER_ID, CUSTOMER_ID); + storedSubscription("paddle", "sub_p", USER_ID); + process.env.BILLING_PROVIDER = "polar"; + for (const key of ["NEXT_PUBLIC_PADDLE_CLIENT_TOKEN", "PADDLE_PRICE_PRO_MONTH", "PADDLE_PRICE_PRO_YEAR"]) delete process.env[key]; + paddleAnswers = { "POST /customers": () => jsonResponse(201, { data: { urls: { general: { overview: "https://paddle.example/portal" } } } }) }; + assert.equal((await portal.POST(post("/api/billing/portal"))).status, 200); + }); + + it("says so (503) instead of opening the wrong portal when the user's own provider is no longer configured", async () => { + user = verified(USER_ID, "buyer@example.com"); + linkPaddle(USER_ID, CUSTOMER_ID); + storedSubscription("paddle", "sub_p", USER_ID); + process.env.BILLING_PROVIDER = "polar"; + delete process.env.PADDLE_API_KEY; + assert.equal((await portal.POST(post("/api/billing/portal"))).status, 503); + assert.equal(polarCalls.length + paddleCalls.length, 0); + }); + + it("a user who moved on: the new provider's running subscription wins over the old one's canceled row, and they may buy again once it is over", async () => { + storedSubscription("paddle", "sub_old", USER_ID, { status: "canceled" }); + await polarWebhook.POST(signedPolar("subscription.active", polarSubscriptionData({ customer: polarCustomer(USER_ID) }))); + const entitlement = await getEntitlement(db as never, USER_ID); + assert.deepEqual({ active: entitlement.active, provider: entitlement.provider }, { active: true, provider: "polar" }); + process.env.BILLING_PROVIDER = "polar"; + db.tables.billing_subscriptions.find((row) => row.provider === "polar")!.status = "canceled"; + polarAnswers = { "POST /v1/checkouts": () => jsonResponse(201, { id: "chk_3", url: "https://sandbox.polar.sh/checkout/chk_3" }) }; + user = verified(USER_ID, "buyer@example.com"); + assert.equal((await checkout.POST(post("/api/billing/checkout", body))).status, 200); + }); +}); + +describe("POST /api/billing/portal", () => { + it("answers 401 without a session and 404 for someone who never bought anything", async () => { + assert.equal((await portal.POST(post("/api/billing/portal"))).status, 401); + user = verified(USER_ID, "buyer@example.com"); + const response = await portal.POST(post("/api/billing/portal")); + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "no_customer" }); + }); + + it("returns a fresh Paddle portal link for the user's own customer, scoped to their running subscriptions", async () => { + user = verified(USER_ID, "buyer@example.com"); + linkPaddle(USER_ID, CUSTOMER_ID); + storedSubscription("paddle", "sub_live", USER_ID); + storedSubscription("paddle", "sub_old", USER_ID, { status: "canceled" }); + storedSubscription("paddle", "sub_other", "uid-other"); + paddleAnswers = { + "POST /customers": () => jsonResponse(201, { data: { urls: { general: { overview: "https://sandbox-customer-portal.paddle.com/cpl_x" }, subscriptions: [{ cancel_subscription: "https://sandbox-customer-portal.paddle.com/cancel" }] } } }), + }; + const response = await portal.POST(post("/api/billing/portal")); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { provider: "paddle", url: "https://sandbox-customer-portal.paddle.com/cpl_x", cancelUrl: "https://sandbox-customer-portal.paddle.com/cancel" }); + assert.equal(paddleCalls[0].path, `/customers/${CUSTOMER_ID}/portal-sessions`); + assert.deepEqual(paddleCalls[0].body, { subscription_ids: ["sub_live"] }); + }); + + it("answers 404 when Polar does not know the user (nothing was paid there yet)", async () => { + user = verified(USER_ID, "buyer@example.com"); + linkPolar(USER_ID); + process.env.BILLING_PROVIDER = "polar"; + polarAnswers = { "POST /v1/customer-sessions": () => jsonResponse(422, { detail: [{ type: "value_error", loc: ["body", "external_customer_id"], msg: "Customer does not exist.", input: "x" }] }) }; + const response = await portal.POST(post("/api/billing/portal")); + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "no_customer" }); + }); +}); + +describe("POST /api/billing/sync", () => { + it("answers 401 without a session, and the empty entitlement for someone who never started a checkout", async () => { + assert.equal((await sync.POST()).status, 401); + user = verified("uid-new", "new@example.com"); + const response = await sync.POST(); + assert.equal(response.status, 200); + assert.equal(((await response.json()) as { entitlement: { active: boolean } }).entitlement.active, false); + assert.equal(paddleCalls.length + polarCalls.length, 0); + }); + + it("reads the Paddle customer's subscriptions, applies them and answers the entitlement; a second call right away is throttled", async () => { + user = verified("uid-sync", "sync@example.com"); + linkPaddle("uid-sync", "ctm_01sync"); + paddleAnswers = { + "GET /subscriptions": () => jsonResponse(200, { data: [{ ...subscriptionData({ customer_id: "ctm_01sync" }), updated_at: "2026-10-14T00:00:09Z" }] }), + "GET /transactions": () => jsonResponse(200, { data: [] }), + }; + const response = await sync.POST(); + assert.equal(response.status, 200); + const json = (await response.json()) as { entitlement: { active: boolean; planKey: string | null; provider: string } }; + assert.equal(json.entitlement.active, true); + assert.equal(json.entitlement.planKey, "pro"); + assert.equal(json.entitlement.provider, "paddle"); + assert.equal(db.tables.billing_subscriptions[0].user_id, "uid-sync"); + assert.equal((await sync.POST()).status, 429); + }); + + it("reads the user's subscriptions and orders from Polar (by external customer id), applies them, refunds included", async () => { + user = verified("33333333-3333-4333-8333-333333333333", "p@example.com"); + linkPolar("33333333-3333-4333-8333-333333333333"); + polarAnswers = { + "GET /v1/subscriptions": () => jsonResponse(200, { items: [polarSubscriptionData({ customer: polarCustomer("33333333-3333-4333-8333-333333333333") })], pagination: { total_count: 1, max_page: 1 } }), + "GET /v1/orders": () => jsonResponse(200, { items: [polarOrderData({ status: "partially_refunded", refunded_amount: 300, refunded_tax_amount: 30, modified_at: "2026-10-15T00:00:00Z", customer: polarCustomer("33333333-3333-4333-8333-333333333333") })], pagination: { total_count: 1, max_page: 1 } }), + }; + const response = await sync.POST(); + assert.equal(response.status, 200); + const json = (await response.json()) as { entitlement: { active: boolean; provider: string } }; + assert.deepEqual({ active: json.entitlement.active, provider: json.entitlement.provider }, { active: true, provider: "polar" }); + assert.ok(polarCalls.every((call) => call.query.includes("external_customer_id=33333333-3333-4333-8333-333333333333"))); + assert.equal(db.tables.billing_transactions[0].refunded, 330); + }); + + it("asks both providers of a user who has been to both, and still answers when only one of them fails", async () => { + user = verified("44444444-4444-4444-8444-444444444444", "both@example.com"); + db.tables.billing_customers.push({ user_id: "44444444-4444-4444-8444-444444444444", paddle_customer_id: "ctm_01both", polar_linked_at: "2026-10-14T00:00:00.000Z" }); + paddleAnswers = { "GET /subscriptions": () => jsonResponse(500, { error: { code: "internal", detail: "down" } }) }; + polarAnswers = { + "GET /v1/subscriptions": () => jsonResponse(200, { items: [polarSubscriptionData({ customer: polarCustomer("44444444-4444-4444-8444-444444444444") })], pagination: { max_page: 1 } }), + "GET /v1/orders": () => jsonResponse(200, { items: [], pagination: { max_page: 1 } }), + }; + const response = await sync.POST(); + assert.equal(response.status, 200); + assert.equal(((await response.json()) as { entitlement: { active: boolean } }).entitlement.active, true); + }); + + it("answers 502 only when every provider it asked failed, and 503 when it cannot write", async () => { + user = verified("uid-down", "down@example.com"); + linkPolar("uid-down"); + polarAnswers = { "GET /v1/subscriptions": () => jsonResponse(500, { detail: "down" }) }; + const response = await sync.POST(); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "sync_failed" }); + user = verified("uid-no-admin", "na@example.com"); + linkPolar("uid-no-admin"); + adminAvailable = false; + assert.equal((await sync.POST()).status, 503); + }); + + it("does not ask a provider that is no longer configured, and still answers", async () => { + user = verified("uid-gone", "gone@example.com"); + linkPaddle("uid-gone", "ctm_01gone"); + delete process.env.PADDLE_API_KEY; + const response = await sync.POST(); + assert.equal(response.status, 200); + assert.equal(paddleCalls.length, 0); + }); +}); diff --git a/tests/billing-scripts.test.ts b/tests/billing-scripts.test.ts new file mode 100644 index 0000000..b2259ba --- /dev/null +++ b/tests/billing-scripts.test.ts @@ -0,0 +1,83 @@ +// The scripts (plain Node, no `@/` imports) repeat a few rules of the library. These tests keep the copies in step with the originals. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import { chooseBillingProvider } from "@/lib/billing/provider"; +import { PADDLE_EVENTS } from "@/lib/billing/paddle/webhook"; +import { productProblems } from "@/lib/billing/polar/product-check"; +import { POLAR_EVENTS } from "@/lib/billing/polar/webhook"; + +const root = new URL("../", import.meta.url); +const script = (name: string) => readFileSync(new URL(`scripts/${name}`, root), "utf8"); +const listIn = (source: string): string[] => { + const block = /const EVENTS = \[([\s\S]*?)\];/.exec(source)?.[1] ?? ""; + return [...block.matchAll(/"([^"]+)"/g)].map((match) => match[1]).sort(); +}; + +describe("scripts/billing-check.mjs agrees with chooseBillingProvider", () => { + const PADDLE = { PADDLE_API_KEY: "pdl_sdbx_apikey_x", PADDLE_APP_TAG: "my-app", NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: "test_abc" }; + const POLAR = { POLAR_ACCESS_TOKEN: "polar_oat_x" }; + const cases: Record> = { + "nothing configured": {}, + "only Paddle": PADDLE, + "only Polar": POLAR, + "both, no choice": { ...PADDLE, ...POLAR }, + "both, Paddle": { ...PADDLE, ...POLAR, BILLING_PROVIDER: "paddle" }, + "both, Polar": { ...PADDLE, ...POLAR, BILLING_PROVIDER: "polar" }, + "both, Polar in capitals with spaces": { ...PADDLE, ...POLAR, BILLING_PROVIDER: " POLAR " }, + "Paddle named but only Polar configured": { ...POLAR, BILLING_PROVIDER: "paddle" }, + "a name that is neither": { ...PADDLE, ...POLAR, BILLING_PROVIDER: "stripe" }, + "Paddle without the browser token": { ...PADDLE, NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: "", ...POLAR }, + "a live key in the sandbox": { ...PADDLE, PADDLE_API_KEY: "pdl_live_apikey_x", BILLING_PROVIDER: "paddle" }, + }; + + for (const [name, env] of Object.entries(cases)) { + it(name, () => { + const run = spawnSync(process.execPath, ["--no-warnings", new URL("scripts/billing-check.mjs", root).pathname], { env: { PATH: process.env.PATH ?? "", ...env } as unknown as NodeJS.ProcessEnv, encoding: "utf8" }); + const choice = chooseBillingProvider(env); + const line = /New checkouts: (\w+)/.exec(run.stdout)?.[1]; + assert.equal(line, "provider" in choice ? choice.provider : "none", run.stdout); + assert.equal(run.status, "provider" in choice ? 0 : 1); + }); + } +}); + +describe("the webhook event lists", () => { + it("scripts/polar-setup.mjs registers exactly the events the Polar handler knows", () => { + assert.deepEqual(listIn(script("polar-setup.mjs")), [...POLAR_EVENTS].sort()); + }); + + it("scripts/paddle-setup.mjs registers exactly the events the Paddle handler knows", () => { + assert.deepEqual(listIn(script("paddle-setup.mjs")), [...PADDLE_EVENTS].sort()); + }); +}); + +describe("productProblems: does a Polar product match the catalog?", () => { + // The shape of a real Polar sandbox product (field names and types read from the API). + const monthly9 = { id: "p1", is_archived: false, recurring_interval: "month", prices: [{ is_archived: false, amount_type: "fixed", price_amount: 900, price_currency: "usd", type: "recurring", recurring_interval: "month" }] }; + const oneTime10 = { id: "p2", is_archived: false, recurring_interval: null, prices: [{ is_archived: false, amount_type: "fixed", price_amount: 1000, price_currency: "usd", type: "one_time", recurring_interval: null }] }; + + it("accepts a product that is what the catalog says", () => { + assert.deepEqual(productProblems(monthly9, { interval: "month", amountMinor: 900, currency: "USD" }), []); + assert.deepEqual(productProblems(oneTime10, { interval: null, amountMinor: 1000, currency: "usd" }), []); + }); + + it("names every difference: interval, price, currency, archived, free or pay-what-you-want, several prices, none", () => { + assert.match(productProblems(monthly9, { interval: "year", amountMinor: 900, currency: "USD" }).join(), /every month.*every year/); + assert.match(productProblems(monthly9, { interval: null, amountMinor: 900, currency: "USD" }).join(), /one-time/); + assert.match(productProblems(oneTime10, { interval: "month", amountMinor: 1000, currency: "USD" }).join(), /once/); + assert.match(productProblems(monthly9, { interval: "month", amountMinor: 990, currency: "USD" }).join(), /900.*990/); + assert.match(productProblems(monthly9, { interval: "month", amountMinor: 900, currency: "KRW" }).join(), /currency/); + assert.match(productProblems({ ...monthly9, is_archived: true }, { interval: "month", amountMinor: 900, currency: "USD" }).join(), /archived/); + assert.match(productProblems({ ...monthly9, prices: [{ ...monthly9.prices[0], amount_type: "free" }] }, { interval: "month", amountMinor: 900, currency: "USD" }).join(), /not a fixed amount/); + assert.match(productProblems({ ...monthly9, prices: [monthly9.prices[0], { ...monthly9.prices[0], price_amount: 500 }] }, { interval: "month", amountMinor: 900, currency: "USD" }).join(), /2 active prices/); + assert.match(productProblems({ ...monthly9, prices: [{ ...monthly9.prices[0], is_archived: true }] }, { interval: "month", amountMinor: 900, currency: "USD" }).join(), /no active price/); + assert.match(productProblems({}, { interval: "month", amountMinor: 900, currency: "USD" }).join(), /no active price/); + }); + + it("ignores an archived price next to the live one", () => { + const withOld = { ...monthly9, prices: [{ ...monthly9.prices[0], is_archived: true, price_amount: 500 }, monthly9.prices[0]] }; + assert.deepEqual(productProblems(withOld, { interval: "month", amountMinor: 900, currency: "USD" }), []); + }); +}); diff --git a/tests/billing-store.test.ts b/tests/billing-store.test.ts new file mode 100644 index 0000000..872fc36 --- /dev/null +++ b/tests/billing-store.test.ts @@ -0,0 +1,243 @@ +// The Supabase BillingStore over the in-memory fake (tests/support/fake-supabase.ts), for both providers. The SQL it relies on (composite keys, guarded +// updates, Row Level Security) was applied to a real Postgres engine when this template was built: BILLING.md says how to repeat that. +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { STALE_RECEIVED_MS, supabaseBillingStore } from "@/lib/billing/store"; +import { UnknownUserError, type AdjustmentRow, type SubscriptionRow, type TransactionRow, type WebhookEventRecord } from "@/lib/billing/types"; +import { billingDb } from "./support/fake-supabase"; +import { CUSTOMER_ID, USER_ID } from "./support/billing-fixtures"; + +const NOW = Date.UTC(2026, 9, 14, 12, 0, 0); +const setup = () => { + const db = billingDb(); + return { db, store: supabaseBillingStore(db as unknown as SupabaseClient, () => NOW) }; +}; +const delivery = (id = "evt_1", over: Partial = {}): WebhookEventRecord => ({ + provider: "paddle", + event_id: id, + event_type: "subscription.created", + occurred_at: "2026-10-14T00:00:00Z", + payload: { event_id: id }, + ...over, +}); + +const subscription = (over: Partial = {}): SubscriptionRow => ({ + id: "sub_1", + provider: "paddle", + user_id: USER_ID, + customer_id: CUSTOMER_ID, + status: "active", + plan_key: "pro", + price_id: "pri_pro_month", + billing_interval: "month", + quantity: 1, + currency: "USD", + unit_amount: 900, + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + scheduled_change: null, + canceled_at: null, + event_occurred_at: "2026-10-14T00:00:00Z", + ...over, +}); + +const transaction = (over: Partial = {}): TransactionRow => ({ + id: "txn_1", + provider: "paddle", + user_id: USER_ID, + customer_id: CUSTOMER_ID, + subscription_id: "sub_1", + status: "completed", + origin: "api", + price_id: "pri_pro_month", + item_key: "pro", + currency: "USD", + subtotal: 900, + tax: 90, + total: 990, + invoice_number: null, + billed_at: null, + ...over, +}); + +const adjustment = (over: Partial = {}): AdjustmentRow => ({ + id: "adj_1", + provider: "paddle", + transaction_id: "txn_1", + user_id: USER_ID, + action: "refund", + status: "approved", + currency: "USD", + total: 400, + event_occurred_at: "2026-10-14T00:00:00Z", + ...over, +}); + +describe("claimEvent", () => { + it("claims a new event, and refuses a processed or ignored one as a duplicate", async () => { + const { db, store } = setup(); + assert.equal(await store.claimEvent(delivery()), true); + assert.equal(db.tables.billing_webhook_events[0].provider, "paddle"); + await store.finishEvent("paddle", "evt_1", "processed", "ok"); + assert.equal(await store.claimEvent(delivery()), false); + await store.finishEvent("paddle", "evt_1", "ignored", "unknown_customer"); + assert.equal(await store.claimEvent(delivery()), false); + assert.equal(db.tables.billing_webhook_events.length, 1); + }); + + it("takes a failed event over so that the redelivery is processed, exactly once", async () => { + const { db, store } = setup(); + await store.claimEvent(delivery()); + await store.failEvent("paddle", "evt_1", "database is down"); + assert.equal(db.tables.billing_webhook_events[0].status, "failed"); + assert.equal(await store.claimEvent(delivery()), true); + assert.equal(await store.claimEvent(delivery()), false, "a second delivery racing for it does not also win"); + assert.equal(db.tables.billing_webhook_events[0].status, "received"); + }); + + it("leaves a fresh `received` event to the attempt that is running it, and takes over one that went stale", async () => { + const { db, store } = setup(); + await store.claimEvent(delivery()); + db.tables.billing_webhook_events[0].received_at = new Date(NOW - 1000).toISOString(); + assert.equal(await store.claimEvent(delivery()), false); + db.tables.billing_webhook_events[0].received_at = new Date(NOW - STALE_RECEIVED_MS - 1000).toISOString(); + assert.equal(await store.claimEvent(delivery()), true); + }); + + it("truncates a long failure message and keeps the payload", async () => { + const { db, store } = setup(); + await store.claimEvent(delivery()); + await store.failEvent("paddle", "evt_1", "x".repeat(2000)); + assert.equal(String(db.tables.billing_webhook_events[0].note).length, 500); + assert.deepEqual(db.tables.billing_webhook_events[0].payload, { event_id: "evt_1" }); + }); + + it("keeps the two providers' delivery ids apart: the same id from Paddle and from Polar are two deliveries", async () => { + const { db, store } = setup(); + assert.equal(await store.claimEvent(delivery("same")), true); + assert.equal(await store.claimEvent(delivery("same", { provider: "polar" })), true); + assert.equal(await store.claimEvent(delivery("same", { provider: "polar" })), false); + await store.failEvent("polar", "same", "boom"); + assert.deepEqual(db.tables.billing_webhook_events.map((row) => [row.provider, row.status]).sort(), [["paddle", "received"], ["polar", "failed"]]); + }); +}); + +describe("upsertSubscription", () => { + it("inserts a new subscription, and an event that is not older replaces it", async () => { + const { db, store } = setup(); + await store.upsertSubscription(subscription()); + await store.upsertSubscription(subscription({ status: "past_due", event_occurred_at: "2026-10-15T00:00:00Z" })); + assert.equal(db.tables.billing_subscriptions.length, 1); + assert.equal(db.tables.billing_subscriptions[0].status, "past_due"); + await store.upsertSubscription(subscription({ status: "paused", event_occurred_at: "2026-10-15T00:00:00Z" })); + assert.equal(db.tables.billing_subscriptions[0].status, "paused", "the same timestamp is not older"); + }); + + it("never lets an older event overwrite a newer one", async () => { + const { db, store } = setup(); + await store.upsertSubscription(subscription({ status: "canceled", event_occurred_at: "2026-11-14T00:00:00Z" })); + await store.upsertSubscription(subscription({ status: "active", event_occurred_at: "2026-10-20T00:00:00Z" })); + assert.equal(db.tables.billing_subscriptions[0].status, "canceled"); + }); + + it("compares instants, not strings (providers write timestamps with and without milliseconds)", async () => { + const { db, store } = setup(); + // As strings "…:10Z" sorts AFTER "…:10.500Z", but 10.500 is the later instant. + await store.upsertSubscription(subscription({ status: "active", event_occurred_at: "2026-10-14T00:00:10Z" })); + await store.upsertSubscription(subscription({ status: "canceled", event_occurred_at: "2026-10-14T00:00:10.500Z" })); + assert.equal(db.tables.billing_subscriptions[0].status, "canceled"); + await store.upsertSubscription(subscription({ status: "active", event_occurred_at: "2026-10-14T00:00:10Z" })); + assert.equal(db.tables.billing_subscriptions[0].status, "canceled", "and the earlier instant does not come back"); + }); + + it("keeps a Paddle and a Polar subscription with the same id apart", async () => { + const { db, store } = setup(); + await store.upsertSubscription(subscription({ id: "x", provider: "paddle", status: "active" })); + await store.upsertSubscription(subscription({ id: "x", provider: "polar", status: "canceled" })); + assert.deepEqual(db.tables.billing_subscriptions.map((row) => [row.provider, row.status]).sort(), [["paddle", "active"], ["polar", "canceled"]]); + }); +}); + +describe("transactions and refunds", () => { + it("does not reset what the refunds added up when the payment is delivered again", async () => { + const { db, store } = setup(); + await store.upsertTransaction(transaction()); + await store.upsertAdjustment(adjustment()); + await store.upsertAdjustment(adjustment({ id: "adj_2", action: "credit", total: 50 })); + await store.upsertAdjustment(adjustment({ id: "adj_3", status: "pending_approval", total: 90 })); + await store.recomputeRefunded("paddle", "txn_1"); + assert.equal(db.tables.billing_transactions[0].refunded, 400); + await store.upsertTransaction(transaction({ invoice_number: "1001" })); + assert.equal(db.tables.billing_transactions[0].refunded, 400); + assert.equal(db.tables.billing_transactions[0].invoice_number, "1001"); + }); + + it("counts chargebacks and takes back a reversed one", async () => { + const { db, store } = setup(); + await store.upsertTransaction(transaction()); + await store.upsertAdjustment(adjustment({ id: "adj_cb", action: "chargeback", total: 200 })); + await store.recomputeRefunded("paddle", "txn_1"); + assert.equal(db.tables.billing_transactions[0].refunded, 200); + await store.upsertAdjustment(adjustment({ id: "adj_cb", action: "chargeback", status: "reversed", total: 200, event_occurred_at: "2026-10-14T00:05:00Z" })); + await store.recomputeRefunded("paddle", "txn_1"); + assert.equal(db.tables.billing_transactions[0].refunded, 0); + }); + + it("never lets a late, older delivery undo an approved refund", async () => { + const { db, store } = setup(); + await store.upsertTransaction(transaction()); + await store.upsertAdjustment(adjustment({ status: "approved", event_occurred_at: "2026-10-14T00:10:00Z" })); + await store.upsertAdjustment(adjustment({ status: "pending_approval", event_occurred_at: "2026-10-14T00:05:00Z" })); + await store.recomputeRefunded("paddle", "txn_1"); + assert.equal(db.tables.billing_adjustments[0].status, "approved"); + assert.equal(db.tables.billing_transactions[0].refunded, 400); + }); + + it("finds a transaction it recorded, and not one it did not (nor one of the other provider)", async () => { + const { store } = setup(); + await store.upsertTransaction(transaction()); + assert.deepEqual(await store.getTransaction("paddle", "txn_1"), { id: "txn_1", user_id: USER_ID }); + assert.equal(await store.getTransaction("paddle", "txn_other"), null); + assert.equal(await store.getTransaction("polar", "txn_1"), null); + }); + + it("adds up a Polar order's refund without touching a Paddle payment of the same id", async () => { + const { db, store } = setup(); + await store.upsertTransaction(transaction({ id: "o1", provider: "paddle" })); + await store.upsertTransaction(transaction({ id: "o1", provider: "polar", customer_id: "polar-customer" })); + await store.upsertAdjustment(adjustment({ id: "refund_o1", provider: "polar", transaction_id: "o1", total: 330 })); + await store.recomputeRefunded("polar", "o1"); + const refunded = Object.fromEntries(db.tables.billing_transactions.map((row) => [row.provider as string, Number(row.refunded ?? 0)])); + assert.deepEqual(refunded, { paddle: 0, polar: 330 }); + }); +}); + +describe("userIdForCustomer", () => { + it("answers through the Paddle customer link only, and never for Polar (its payload carries the user id)", async () => { + const { db, store } = setup(); + db.tables.billing_customers.push({ user_id: USER_ID, paddle_customer_id: CUSTOMER_ID }); + assert.equal(await store.userIdForCustomer("paddle", CUSTOMER_ID), USER_ID); + assert.equal(await store.userIdForCustomer("paddle", "ctm_01stranger"), null); + assert.equal(await store.userIdForCustomer("polar", CUSTOMER_ID), null); + }); +}); + +describe("a user who does not exist (any more)", () => { + it("turns the database's foreign key refusal into UnknownUserError (a delivery answers it with ignored), for every write of a user's rows", async () => { + const { db, store } = setup(); + db.users = new Set(["someone-else"]); + await assert.rejects(store.upsertSubscription(subscription()), UnknownUserError); + await assert.rejects(store.upsertTransaction(transaction()), UnknownUserError); + await assert.rejects(store.upsertAdjustment(adjustment()), UnknownUserError); + assert.equal(db.tables.billing_subscriptions.length + db.tables.billing_transactions.length + db.tables.billing_adjustments.length, 0); + }); + + it("does not hide any other database error", async () => { + const db = billingDb(); + const broken = { from: () => ({ upsert: async () => ({ data: null, error: { code: "08006", message: "connection failure" } }) }) } as unknown as SupabaseClient; + await assert.rejects(supabaseBillingStore(broken).upsertSubscription(subscription()), /connection failure/); + assert.equal(db.tables.billing_subscriptions.length, 0); + }); +}); diff --git a/tests/billing-ui.test.ts b/tests/billing-ui.test.ts new file mode 100644 index 0000000..1ddc5e0 --- /dev/null +++ b/tests/billing-ui.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { billingErrorMessage, GENERIC_BILLING_ERROR } from "@/components/billing/messages"; +import { nextCheckoutStep } from "@/components/billing/checkout-flow"; +import { formatDate, formatMinor, formatPrice, subscriptionNote } from "@/components/billing/format"; +import { PricingTable } from "@/components/billing/pricing-table"; +import { billingConfig } from "@/config/billing"; + +const render = (available: string[], currentPlanKey: string | null = null) => + renderToStaticMarkup(createElement(PricingTable, { plans: billingConfig.plans, currency: "USD", loginPath: "/login", currentPlanKey, available })); + +describe("PricingTable", () => { + it("shows every plan with its monthly price first and a switch to the yearly one", () => { + const html = render(["pro:month", "team:month"]); + for (const plan of billingConfig.plans) assert.ok(html.includes(plan.name), plan.name); + assert.ok(html.includes("$9") && html.includes("$29"), "monthly prices"); + assert.ok(html.includes("Monthly") && html.includes("Yearly"), "the switch"); + assert.ok(!html.includes("$90"), "yearly prices are not shown until the switch is used"); + }); + + it("shows a buy button only for a price that is configured, and says so for the rest", () => { + const html = render(["pro:month"]); + assert.equal((html.match(/Choose /g) ?? []).length, 1); + assert.ok(html.includes("Choose Pro")); + assert.ok(html.includes("Not available yet.")); + assert.equal((render([]).match(/Choose /g) ?? []).length, 0, "a fresh clone shows no button that cannot work"); + }); + + it("marks the plan the user already has instead of offering it again", () => { + const html = render(["pro:month", "team:month"], "pro"); + assert.ok(html.includes("Your current plan")); + assert.ok(!html.includes("Choose Pro")); + assert.ok(html.includes("Choose Team")); + }); +}); + +describe("nextCheckoutStep: the button works the same for both providers", () => { + it("opens Paddle's overlay on the transaction the server created", () => { + const answer = { provider: "paddle", transactionId: "txn_1", clientToken: "test_abc", environment: "sandbox" }; + assert.deepEqual(nextCheckoutStep(200, true, answer), { action: "paddle_overlay", transactionId: "txn_1", clientToken: "test_abc", environment: "sandbox" }); + }); + + it("sends the buyer to Polar's checkout page", () => { + assert.deepEqual(nextCheckoutStep(200, true, { provider: "polar", url: "https://sandbox.polar.sh/checkout/abc" }), { action: "redirect", url: "https://sandbox.polar.sh/checkout/abc" }); + }); + + it("follows only an https URL, never something that would run in this page", () => { + for (const url of ["javascript:alert(1)", "data:text/html,x", "//evil.example", "http://plain.example", "", undefined]) { + assert.deepEqual(nextCheckoutStep(200, true, { provider: "polar", url }), { action: "error", code: null }, String(url)); + } + }); + + it("sends a signed-out visitor to sign in, and shows the server's error code otherwise", () => { + assert.deepEqual(nextCheckoutStep(401, false, { error: "unauthorized" }), { action: "sign_in" }); + assert.deepEqual(nextCheckoutStep(409, false, { error: "already_subscribed" }), { action: "error", code: "already_subscribed" }); + assert.deepEqual(nextCheckoutStep(503, false, {}), { action: "error", code: null }); + }); + + it("treats an answer that is neither shape as an error instead of doing something half way", () => { + assert.deepEqual(nextCheckoutStep(200, true, {}), { action: "error", code: null }); + assert.deepEqual(nextCheckoutStep(200, true, { provider: "paddle", transactionId: "txn_1" }), { action: "error", code: null }); + assert.deepEqual(nextCheckoutStep(200, true, { provider: "paddle", transactionId: "txn_1", clientToken: "t", environment: "staging" }), { action: "error", code: null }); + assert.deepEqual(nextCheckoutStep(200, true, { provider: "stripe", url: "https://x.example" }), { action: "error", code: null }); + }); +}); + +describe("formatPrice and the error texts", () => { + it("formats whole amounts without cents and others with", () => { + assert.equal(formatPrice(9, "USD"), "$9"); + assert.equal(formatPrice(9.5, "USD"), "$9.50"); + assert.equal(formatPrice(1000, "USD"), "$1,000"); + }); + + it("formats Paddle's minor units and dates", () => { + assert.equal(formatMinor(990, "USD"), "$9.90"); + assert.equal(formatMinor(900, "USD"), "$9"); + assert.equal(formatMinor(29000, "USD"), "$290"); + assert.equal(formatDate("2026-11-14T12:00:00Z"), "November 14, 2026"); + assert.equal(formatDate(null), ""); + }); + + it("says what happens next only for a subscription that can still renew or end (a canceled one is over: never 'Renews on')", () => { + assert.equal(subscriptionNote("active", false, "2026-11-14T12:00:00Z"), "Active. Renews on November 14, 2026."); + assert.equal(subscriptionNote("active", true, "2026-11-14T12:00:00Z"), "Active. Ends on November 14, 2026."); + assert.equal(subscriptionNote("trialing", false, "2026-11-14T12:00:00Z"), "In trial. Renews on November 14, 2026."); + assert.equal(subscriptionNote("past_due", false, "2026-11-14T12:00:00Z"), "Payment failed: it will be retried. Renews on November 14, 2026."); + assert.equal(subscriptionNote("canceled", false, "2026-11-14T12:00:00Z"), "Canceled."); + assert.equal(subscriptionNote("canceled", true, "2026-11-14T12:00:00Z"), "Canceled."); + assert.equal(subscriptionNote("paused", false, "2026-11-14T12:00:00Z"), "Paused."); + assert.equal(subscriptionNote("active", false, null), "Active."); + assert.equal(subscriptionNote("something_new", false, null), "something_new."); + }); + + it("gives every code the routes answer with a text, and a generic one for anything else", () => { + for (const code of ["unauthorized", "already_subscribed", "email_required", "customer_conflict", "price_not_configured", "unknown_item", "billing_not_configured", "too_many_requests", "no_customer", "paddle_js_failed"]) { + assert.notEqual(billingErrorMessage(code), GENERIC_BILLING_ERROR, code); + } + for (const unknown of ["checkout_failed", "weird", "", null, undefined]) assert.equal(billingErrorMessage(unknown), GENERIC_BILLING_ERROR, String(unknown)); + }); +}); + +// A function exported from a "use client" file cannot be called from the server. `next build` does not notice; the page fails at request time, +// and only for a user whose data makes it call the function (this once broke /billing for everybody who had a payment). A server file may import +// only COMPONENTS (capitalised names) and types from a client module; helpers belong in a module without the directive (components/billing/format.ts). +describe("server files and client modules", () => { + const root = new URL("../src/", import.meta.url); + const walk = (dir: URL): URL[] => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? walk(new URL(`${entry.name}/`, dir)) : [new URL(entry.name, dir)])); + const isClient = (file: URL) => /^\s*["']use client["'];?/.test(readFileSync(file, "utf8")); + const resolveImport = (from: URL, specifier: string): URL | null => { + const base = specifier.startsWith("@/") ? new URL(specifier.slice(2), root) : specifier.startsWith(".") ? new URL(specifier, from) : null; + if (!base) return null; + for (const suffix of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const candidate = new URL(base.href + suffix); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + // try the next suffix + } + } + return null; + }; + + it("never calls a helper that lives in a client module", () => { + const problems: string[] = []; + for (const file of walk(root).filter((url) => /\.(ts|tsx)$/.test(url.pathname) && !isClient(url))) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(/import\s+(type\s+)?\{([^}]*)\}\s+from\s+["']([^"']+)["']/g)) { + if (match[1]) continue; + const target = resolveImport(file, match[3]); + if (!target || !isClient(target)) continue; + for (const name of match[2].split(",").map((part) => part.trim()).filter(Boolean)) { + if (name.startsWith("type ")) continue; + const local = name.split(/\s+as\s+/)[0]; + if (!/^[A-Z]/.test(local)) problems.push(`${file.pathname.split("/src/")[1]} imports "${local}" from the client module ${match[3]}`); + } + } + } + assert.deepEqual(problems, []); + }); + + it("keeps format.ts free of the client directive", () => { + assert.equal(isClient(new URL("components/billing/format.ts", root)), false); + }); + + it("would have caught the original mistake (the guard itself works)", () => { + const clientFile = new URL("components/billing/pricing-table.tsx", root); + assert.equal(isClient(clientFile), true); + assert.ok(!/export function formatPrice/.test(readFileSync(clientFile, "utf8")), "formatPrice moved out of the client module"); + }); +}); diff --git a/tests/paddle-config.test.ts b/tests/paddle-config.test.ts new file mode 100644 index 0000000..0521d7e --- /dev/null +++ b/tests/paddle-config.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getPaddleServerConfig, paddleClientConfig, paddleConfigProblem, paddleConfigured, PaddleConfigError, paddleServerProblem } from "@/lib/billing/paddle/config"; + +const base = { PADDLE_API_KEY: "pdl_sdbx_apikey_x", PADDLE_APP_TAG: "my-app", NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: "test_abc" }; +const env = (extra: Record) => ({ ...base, ...extra }); + +describe("getPaddleServerConfig", () => { + it("reads a complete sandbox configuration (sandbox is the default environment)", () => { + const config = getPaddleServerConfig(base); + assert.equal(config.environment, "sandbox"); + assert.equal(config.apiBase, "https://sandbox-api.paddle.com"); + assert.equal(config.appTag, "my-app"); + assert.equal(config.webhookSecret, null); + assert.equal(getPaddleServerConfig(env({ PADDLE_WEBHOOK_SECRET: " pdl_ntfset_x " })).webhookSecret, "pdl_ntfset_x"); + }); + + it("reads a live configuration only with a live key", () => { + const live = env({ NEXT_PUBLIC_PADDLE_ENVIRONMENT: "production", PADDLE_API_KEY: "pdl_live_apikey_x" }); + assert.equal(getPaddleServerConfig(live).apiBase, "https://api.paddle.com"); + }); + + it("refuses a sandbox key in production and a live key in the sandbox, the two mistakes that charge the wrong account", () => { + assert.throws(() => getPaddleServerConfig(env({ NEXT_PUBLIC_PADDLE_ENVIRONMENT: "production" })), PaddleConfigError); + assert.throws(() => getPaddleServerConfig(env({ PADDLE_API_KEY: "pdl_live_apikey_x" })), PaddleConfigError); + }); + + it("refuses a missing key and a missing or malformed app tag", () => { + assert.throws(() => getPaddleServerConfig(env({ PADDLE_API_KEY: undefined })), /PADDLE_API_KEY/); + for (const tag of [undefined, "", "x", "My App", "UPPER", "-lead", "a".repeat(60)]) { + assert.throws(() => getPaddleServerConfig(env({ PADDLE_APP_TAG: tag })), /PADDLE_APP_TAG/, String(tag)); + } + }); +}); + +describe("paddleConfigured and the client config", () => { + it("is true only when the key, the tag and the browser token are all there", () => { + assert.equal(paddleConfigured(base), true); + assert.equal(paddleConfigured(env({ NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: undefined })), false); + assert.equal(paddleConfigured(env({ PADDLE_APP_TAG: undefined })), false); + assert.equal(paddleConfigured({}), false); + }); + + it("separates what a checkout needs from what an existing subscriber's portal needs (no browser token for the second)", () => { + const noToken = env({ NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: undefined }); + assert.equal(paddleServerProblem(noToken), null); + assert.match(paddleConfigProblem(noToken) ?? "", /NEXT_PUBLIC_PADDLE_CLIENT_TOKEN/); + assert.match(paddleServerProblem({}) ?? "", /PADDLE_API_KEY/); + assert.equal(paddleConfigProblem(base), null); + }); + + it("gives the browser only the token and the environment", () => { + assert.deepEqual(paddleClientConfig(base), { token: "test_abc", environment: "sandbox" }); + assert.deepEqual(paddleClientConfig({}), { token: null, environment: "sandbox" }); + }); +}); diff --git a/tests/paddle-signature.test.ts b/tests/paddle-signature.test.ts new file mode 100644 index 0000000..286cb59 --- /dev/null +++ b/tests/paddle-signature.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { describe, it } from "node:test"; +import { SIGNATURE_TOLERANCE_SECONDS, signPaddleBody, verifyPaddleSignature } from "@/lib/billing/paddle/signature"; + +const SECRET = "pdl_ntfset_secret"; +const BODY = JSON.stringify({ event_id: "evt_1", event_type: "subscription.created", occurred_at: "2026-10-14T00:00:00Z", data: {} }); +const NOW = Date.UTC(2026, 9, 14, 0, 0, 30); +const header = (ts: number, secret = SECRET, body = BODY) => `ts=${ts};h1=${createHmac("sha256", secret).update(`${ts}:${body}`).digest("hex")}`; +const seconds = (ms: number) => Math.floor(ms / 1000); + +describe("verifyPaddleSignature", () => { + it("accepts a correctly signed body", () => { + assert.equal(verifyPaddleSignature(SECRET, BODY, header(seconds(NOW)), NOW), true); + }); + + it("round-trips with signPaddleBody", () => { + assert.equal(verifyPaddleSignature(SECRET, BODY, signPaddleBody(SECRET, BODY, NOW), NOW), true); + }); + + it("refuses a body that was changed by one byte, even one that parses to the same JSON", () => { + assert.equal(verifyPaddleSignature(SECRET, `${BODY} `, header(seconds(NOW)), NOW), false); + assert.equal(verifyPaddleSignature(SECRET, JSON.stringify(JSON.parse(BODY), null, 2), header(seconds(NOW)), NOW), false); + }); + + it("refuses another secret, a missing secret and a missing or malformed header", () => { + assert.equal(verifyPaddleSignature("other", BODY, header(seconds(NOW)), NOW), false); + assert.equal(verifyPaddleSignature("", BODY, header(seconds(NOW), ""), NOW), false); + for (const bad of [null, undefined, "", "garbage", "ts=1", "h1=abc", `ts=${seconds(NOW)}`, `ts=abc;h1=${"0".repeat(64)}`, `ts=${seconds(NOW)};h1=short`]) { + assert.equal(verifyPaddleSignature(SECRET, BODY, bad, NOW), false, String(bad)); + } + }); + + it("refuses a timestamp outside the tolerance, in both directions", () => { + const limit = SIGNATURE_TOLERANCE_SECONDS; + assert.equal(verifyPaddleSignature(SECRET, BODY, header(seconds(NOW) - limit), NOW), true); + assert.equal(verifyPaddleSignature(SECRET, BODY, header(seconds(NOW) - limit - 5), NOW), false); + assert.equal(verifyPaddleSignature(SECRET, BODY, header(seconds(NOW) + limit + 5), NOW), false); + }); + + it("accepts any one of several h1 values (a secret rotation signs with both secrets)", () => { + const ts = seconds(NOW); + const wrong = createHmac("sha256", "old").update(`${ts}:${BODY}`).digest("hex"); + const right = createHmac("sha256", SECRET).update(`${ts}:${BODY}`).digest("hex"); + assert.equal(verifyPaddleSignature(SECRET, BODY, `ts=${ts};h1=${wrong};h1=${right}`, NOW), true); + assert.equal(verifyPaddleSignature(SECRET, BODY, `ts=${ts};h1=${right};h1=${wrong}`, NOW), true); + assert.equal(verifyPaddleSignature(SECRET, BODY, `ts=${ts};h1=${wrong}`, NOW), false); + }); + + it("does not trust the timestamp alone: the timestamp is part of what is signed", () => { + const ts = seconds(NOW); + const signedForAnotherTime = header(ts - 10).replace(`ts=${ts - 10}`, `ts=${ts}`); + assert.equal(verifyPaddleSignature(SECRET, BODY, signedForAnotherTime, NOW), false); + }); +}); diff --git a/tests/paddle-sync.test.ts b/tests/paddle-sync.test.ts new file mode 100644 index 0000000..3661351 --- /dev/null +++ b/tests/paddle-sync.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { PaddleSubscription, PaddleTransaction } from "@/lib/billing/paddle/types"; +import { syncPaddleCustomer, type SyncSource } from "@/lib/billing/paddle/sync"; +import { context, CUSTOMER_ID, MemoryStore, subscriptionData, transactionData, USER_ID } from "./support/billing-fixtures"; + +describe("syncPaddleCustomer", () => { + const source = (subscriptions: (PaddleSubscription & { updated_at?: string })[], transactions: PaddleTransaction[]): SyncSource => ({ + listSubscriptions: async () => subscriptions, + listCompletedTransactions: async () => transactions, + }); + const ctx = context(); + + it("applies a customer's subscriptions and payments the way the webhook would", async () => { + const store = new MemoryStore(); + const result = await syncPaddleCustomer(store, CUSTOMER_ID, USER_ID, ctx, source([{ ...subscriptionData(), updated_at: "2026-10-14T00:00:09Z" } as never], [transactionData() as never])); + assert.deepEqual(result, { subscriptions: 1, transactions: 1 }); + assert.equal(store.subscriptions.get("paddle_sub_01test")?.event_occurred_at, "2026-10-14T00:00:09Z"); + assert.equal(store.transactions.get("paddle_txn_01test")?.total, 990); + }); + + it("skips what is not ours", async () => { + const store = new MemoryStore(); + const foreign = subscriptionData({ custom_data: { app: "other-app" } }); + const result = await syncPaddleCustomer(store, CUSTOMER_ID, USER_ID, ctx, source([foreign as never], [transactionData({ items: [{ price_id: "pri_x" }] }) as never])); + assert.deepEqual(result, { subscriptions: 0, transactions: 0 }); + assert.equal(store.subscriptions.size + store.transactions.size, 0); + }); + + it("does not overwrite a newer state the webhook already wrote", async () => { + const store = new MemoryStore(); + const probe = new MemoryStore(); + await syncPaddleCustomer(probe, CUSTOMER_ID, USER_ID, ctx, source([subscriptionData({ status: "canceled" }) as never], [])); + await store.upsertSubscription({ ...probe.subscriptions.get("paddle_sub_01test")!, event_occurred_at: "2099-01-01T00:00:00Z" }); + await syncPaddleCustomer(store, CUSTOMER_ID, USER_ID, ctx, source([{ ...subscriptionData({ status: "active" }), updated_at: "2026-10-14T00:00:09Z" } as never], [])); + assert.equal(store.subscriptions.get("paddle_sub_01test")?.status, "canceled"); + }); +}); diff --git a/tests/paddle-webhook.test.ts b/tests/paddle-webhook.test.ts new file mode 100644 index 0000000..3c4c7dd --- /dev/null +++ b/tests/paddle-webhook.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { applyPaddleEvent, minor, parsePaddleEvent, processPaddleDelivery } from "@/lib/billing/paddle/webhook"; +import { adjustmentData, context, CUSTOMER_ID, event, MemoryStore, subscriptionData, transactionData, USER_ID } from "./support/billing-fixtures"; + +const ctx = context(); + +describe("subscription events", () => { + it("writes the subscription of a known customer, with the plan, the period and the price", async () => { + const store = new MemoryStore(); + const result = await applyPaddleEvent(store, event("subscription.created", subscriptionData({ status: "trialing" })), ctx); + assert.equal(result.status, "processed"); + assert.deepEqual(store.subscriptions.get("paddle_sub_01test"), { + id: "sub_01test", + user_id: USER_ID, + provider: "paddle", + customer_id: CUSTOMER_ID, + status: "trialing", + plan_key: "pro", + price_id: "pri_pro_month", + billing_interval: "month", + quantity: 1, + currency: "USD", + unit_amount: 900, + current_period_start: "2026-10-14T00:00:00Z", + current_period_end: "2026-11-14T00:00:00Z", + cancel_at_period_end: false, + scheduled_change: null, + canceled_at: null, + event_occurred_at: "2026-10-14T00:00:10Z", + }); + }); + + it("reads a scheduled cancellation as cancel_at_period_end, and a cancellation as canceled", async () => { + const store = new MemoryStore(); + const scheduled = { action: "cancel", effective_at: "2026-11-14T00:00:00Z", resume_at: null }; + await applyPaddleEvent(store, event("subscription.updated", subscriptionData({ scheduled_change: scheduled }), "2026-10-20T00:00:00Z"), ctx); + assert.equal(store.subscriptions.get("paddle_sub_01test")?.cancel_at_period_end, true); + await applyPaddleEvent(store, event("subscription.canceled", subscriptionData({ status: "canceled", canceled_at: "2026-11-14T00:00:00Z" }), "2026-11-14T00:00:01Z"), ctx); + const row = store.subscriptions.get("paddle_sub_01test"); + assert.equal(row?.status, "canceled"); + assert.equal(row?.cancel_at_period_end, false); + assert.equal(row?.canceled_at, "2026-11-14T00:00:00Z"); + }); + + it("never lets an older event overwrite a newer one (events arrive out of order)", async () => { + const store = new MemoryStore(); + await applyPaddleEvent(store, event("subscription.canceled", subscriptionData({ status: "canceled" }), "2026-11-14T00:00:00Z"), ctx); + await applyPaddleEvent(store, event("subscription.updated", subscriptionData({ status: "active" }), "2026-10-20T00:00:00Z"), ctx); + assert.equal(store.subscriptions.get("paddle_sub_01test")?.status, "canceled"); + }); + + it("understands a yearly price and the plan it belongs to", async () => { + const store = new MemoryStore(); + const yearly = { quantity: 1, recurring: true, price: { id: "pri_team_year", unit_price: { amount: "29000", currency_code: "USD" }, billing_cycle: { interval: "year", frequency: 1 } } }; + await applyPaddleEvent(store, event("subscription.activated", subscriptionData({ items: [yearly], billing_cycle: { interval: "year", frequency: 1 } })), ctx); + const row = store.subscriptions.get("paddle_sub_01test"); + assert.equal(row?.plan_key, "team"); + assert.equal(row?.billing_interval, "year"); + assert.equal(row?.unit_amount, 29000); + }); + + it("ignores a customer the server never linked to a user, whatever the custom data claims", async () => { + const store = new MemoryStore(); + const forged = subscriptionData({ customer_id: "ctm_01stranger", custom_data: { app: "test-app", user_id: USER_ID } }); + const result = await applyPaddleEvent(store, event("subscription.created", forged), ctx); + assert.deepEqual(result, { status: "ignored", note: "unknown_customer" }); + assert.equal(store.subscriptions.size, 0); + }); + + it("ignores another app's subscription (same Paddle account, other tag) and a price that is not ours", async () => { + const store = new MemoryStore(); + const foreign = await applyPaddleEvent(store, event("subscription.created", subscriptionData({ custom_data: { app: "other-app", user_id: USER_ID } })), ctx); + assert.deepEqual(foreign, { status: "ignored", note: "other_app" }); + const notOurs = { quantity: 1, price: { id: "pri_someone_elses", unit_price: { amount: "100", currency_code: "USD" } } }; + const stranger = await applyPaddleEvent(store, event("subscription.created", subscriptionData({ items: [notOurs] })), ctx); + assert.deepEqual(stranger, { status: "ignored", note: "not_our_price" }); + assert.equal(store.subscriptions.size, 0); + }); + + it("ignores a subscription event without a tag only when the price is not ours (an untagged event of our price is still ours)", async () => { + const store = new MemoryStore(); + const untagged = await applyPaddleEvent(store, event("subscription.updated", subscriptionData({ custom_data: null })), ctx); + assert.equal(untagged.status, "processed"); + }); + + it("ignores a status it does not know instead of storing something the table would refuse", async () => { + const store = new MemoryStore(); + const result = await applyPaddleEvent(store, event("subscription.updated", subscriptionData({ status: "teleported" })), ctx); + assert.deepEqual(result, { status: "ignored", note: "unknown_status_teleported" }); + }); +}); + +describe("transaction.completed", () => { + it("records a payment with what the customer paid (the grand total) and the invoice", async () => { + const store = new MemoryStore(); + const result = await applyPaddleEvent(store, event("transaction.completed", transactionData()), ctx); + assert.equal(result.status, "processed"); + assert.deepEqual(store.transactions.get("paddle_txn_01test"), { + id: "txn_01test", + user_id: USER_ID, + provider: "paddle", + customer_id: CUSTOMER_ID, + subscription_id: "sub_01test", + status: "completed", + origin: "api", + price_id: "pri_pro_month", + item_key: "pro", + currency: "USD", + subtotal: 900, + tax: 90, + total: 990, + refunded: 0, + invoice_number: "1001-10001", + billed_at: "2026-10-14T00:00:05Z", + }); + }); + + it("records a one-time product under its own key", async () => { + const store = new MemoryStore(); + const items = [{ quantity: 1, price_id: "pri_credits_100", price: { id: "pri_credits_100" } }]; + await applyPaddleEvent(store, event("transaction.completed", transactionData({ id: "txn_pack", subscription_id: null, items })), ctx); + assert.equal(store.transactions.get("paddle_txn_pack")?.item_key, "credits_100"); + assert.equal(store.transactions.get("paddle_txn_pack")?.subscription_id, null); + }); + + it("ignores a payment of an unknown customer, of another app, or for a price that is not ours", async () => { + const store = new MemoryStore(); + assert.equal((await applyPaddleEvent(store, event("transaction.completed", transactionData({ customer_id: "ctm_01stranger" })), ctx)).note, "unknown_customer"); + assert.equal((await applyPaddleEvent(store, event("transaction.completed", transactionData({ customer_id: null })), ctx)).note, "no_customer"); + assert.equal((await applyPaddleEvent(store, event("transaction.completed", transactionData({ custom_data: { app: "other-app" } })), ctx)).note, "other_app"); + assert.equal((await applyPaddleEvent(store, event("transaction.completed", transactionData({ items: [{ price_id: "pri_x", price: { id: "pri_x" } }] })), ctx)).note, "not_our_price"); + assert.equal(store.transactions.size, 0); + }); + + it("keeps what a refund added up when the same payment is delivered again", async () => { + const store = new MemoryStore(); + await applyPaddleEvent(store, event("transaction.completed", transactionData()), ctx); + await applyPaddleEvent(store, event("adjustment.updated", adjustmentData()), ctx); + await applyPaddleEvent(store, event("transaction.completed", transactionData()), ctx); + assert.equal(store.transactions.get("paddle_txn_01test")?.refunded, 990); + }); +}); + +describe("adjustments (refunds)", () => { + it("adds up approved refunds and chargebacks, and nothing else", async () => { + const store = new MemoryStore(); + await applyPaddleEvent(store, event("transaction.completed", transactionData()), ctx); + await applyPaddleEvent(store, event("adjustment.created", adjustmentData({ id: "adj_pending", status: "pending_approval", totals: { total: "300" } })), ctx); + assert.equal(store.transactions.get("paddle_txn_01test")?.refunded, 0, "a refund that is not approved yet is not a refund"); + await applyPaddleEvent(store, event("adjustment.updated", adjustmentData({ id: "adj_pending", status: "approved", totals: { total: "300" } })), ctx); + assert.equal(store.transactions.get("paddle_txn_01test")?.refunded, 300); + await applyPaddleEvent(store, event("adjustment.created", adjustmentData({ id: "adj_cb", action: "chargeback", totals: { total: "200" } })), ctx); + await applyPaddleEvent(store, event("adjustment.created", adjustmentData({ id: "adj_credit", action: "credit", totals: { total: "50" } })), ctx); + await applyPaddleEvent(store, event("adjustment.created", adjustmentData({ id: "adj_rej", status: "rejected", totals: { total: "40" } })), ctx); + assert.equal(store.transactions.get("paddle_txn_01test")?.refunded, 500); + await applyPaddleEvent(store, event("adjustment.updated", adjustmentData({ id: "adj_cb", action: "chargeback", status: "reversed", totals: { total: "200" } })), ctx); + assert.equal(store.transactions.get("paddle_txn_01test")?.refunded, 300, "a reversed chargeback is taken back"); + }); + + it("never lets a late, older delivery undo a refund that was already approved", async () => { + const store = new MemoryStore(); + await applyPaddleEvent(store, event("transaction.completed", transactionData()), ctx); + await applyPaddleEvent(store, event("adjustment.updated", adjustmentData({ status: "approved" }), "2026-10-14T00:10:00Z"), ctx); + await applyPaddleEvent(store, event("adjustment.created", adjustmentData({ status: "pending_approval" }), "2026-10-14T00:05:00Z"), ctx); + assert.equal(store.adjustments.get("paddle_adj_01test")?.status, "approved"); + assert.equal(store.transactions.get("paddle_txn_01test")?.refunded, 990); + }); + + it("ignores an adjustment of a payment we never recorded (it belongs to another app)", async () => { + const store = new MemoryStore(); + const result = await applyPaddleEvent(store, event("adjustment.created", adjustmentData({ transaction_id: "txn_other" })), ctx); + assert.deepEqual(result, { status: "ignored", note: "unknown_transaction" }); + assert.equal(store.adjustments.size, 0); + }); +}); + +describe("a delivery", () => { + it("is processed once: a second delivery of the same event id is a duplicate that changes nothing", async () => { + const store = new MemoryStore(); + const delivery = event("subscription.created", subscriptionData(), "2026-10-14T00:00:10Z", "evt_same"); + assert.equal((await processPaddleDelivery(store, delivery, ctx)).status, "processed"); + store.subscriptions.clear(); + assert.equal((await processPaddleDelivery(store, delivery, ctx)).status, "duplicate"); + assert.equal(store.subscriptions.size, 0, "the duplicate was not applied again"); + assert.equal(store.events.get("paddle_evt_same")?.status, "processed"); + }); + + it("records an ignored event as ignored, with the reason", async () => { + const store = new MemoryStore(); + await processPaddleDelivery(store, event("subscription.created", subscriptionData({ customer_id: "ctm_01stranger" }), "2026-10-14T00:00:10Z", "evt_stranger"), ctx); + assert.deepEqual(store.events.get("paddle_evt_stranger"), { status: "ignored", note: "unknown_customer" }); + }); + + it("answers an event type it does not handle with ignored, not an error (Paddle would retry an error)", async () => { + const store = new MemoryStore(); + const result = await processPaddleDelivery(store, event("customer.updated", { id: "ctm_1" }), ctx); + assert.deepEqual(result, { status: "ignored", note: "unhandled_event_type" }); + }); + + it("fails loudly when the database fails, records the failure, and processes the redelivery", async () => { + const store = new MemoryStore(); + const delivery = event("subscription.created", subscriptionData(), "2026-10-14T00:00:10Z", "evt_retry"); + store.failNextWrite = true; + await assert.rejects(processPaddleDelivery(store, delivery, ctx), /database is down/); + assert.equal(store.events.get("paddle_evt_retry")?.status, "failed"); + assert.equal(store.subscriptions.size, 0); + assert.equal((await processPaddleDelivery(store, delivery, ctx)).status, "processed"); + assert.equal(store.subscriptions.size, 1); + }); +}); + +describe("parsePaddleEvent and minor", () => { + it("accepts a well-formed event and refuses anything else", () => { + const body = JSON.stringify({ event_id: "evt_1", event_type: "subscription.created", occurred_at: "2026-10-14T00:00:10Z", data: { id: "sub_1" } }); + assert.equal(parsePaddleEvent(body)?.event_id, "evt_1"); + for (const bad of ["", "not json", "null", "[]", "{}", JSON.stringify({ event_id: "e", event_type: "t", occurred_at: "yesterday", data: {} }), JSON.stringify({ event_id: 1, event_type: "t", occurred_at: "2026-10-14T00:00:10Z", data: {} }), JSON.stringify({ event_id: "e", event_type: "t", occurred_at: "2026-10-14T00:00:10Z" })]) { + assert.equal(parsePaddleEvent(bad), null, bad); + } + }); + + it("reads Paddle's money strings, and a bad amount as 0", () => { + assert.equal(minor("990"), 990); + assert.equal(minor(990), 990); + for (const bad of [undefined, null, "", "12.5", "abc", {}, "9".repeat(30)]) assert.equal(minor(bad), 0, String(bad)); + }); +}); diff --git a/tests/polar-api.test.ts b/tests/polar-api.test.ts new file mode 100644 index 0000000..f0d0a9b --- /dev/null +++ b/tests/polar-api.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { createCheckout, createPortalSession, isCustomerEmailRejection, isNoSuchCustomer, listOrders, listSubscriptions, PolarApiError, polarFetch } from "@/lib/billing/polar/api"; + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +type Call = { url: string; method: string; headers: Record; body: unknown }; +const recorder = (answers: (call: Call, n: number) => Response) => { + const calls: Call[] = []; + const fetcher = (async (url: string, init?: RequestInit) => { + const call = { url: String(url), method: init?.method ?? "GET", headers: init?.headers as Record, body: init?.body ? JSON.parse(String(init.body)) : undefined }; + calls.push(call); + return answers(call, calls.length); + }) as unknown as typeof fetch; + return { calls, fetcher }; +}; + +const saved = { token: process.env.POLAR_ACCESS_TOKEN, server: process.env.POLAR_SERVER }; +beforeEach(() => { + process.env.POLAR_ACCESS_TOKEN = "polar_oat_test"; + delete process.env.POLAR_SERVER; +}); +afterEach(() => { + if (saved.token === undefined) delete process.env.POLAR_ACCESS_TOKEN; + else process.env.POLAR_ACCESS_TOKEN = saved.token; + if (saved.server === undefined) delete process.env.POLAR_SERVER; + else process.env.POLAR_SERVER = saved.server; +}); + +describe("polarFetch", () => { + it("sends the bearer token to the server POLAR_SERVER names (the sandbox by default)", async () => { + const { calls, fetcher } = recorder(() => json(200, { ok: true })); + await polarFetch("/v1/x", {}, fetcher); + assert.equal(calls[0].url, "https://sandbox-api.polar.sh/v1/x"); + assert.equal(calls[0].headers.Authorization, "Bearer polar_oat_test"); + process.env.POLAR_SERVER = "production"; + await polarFetch("/v1/x", {}, fetcher); + assert.equal(calls[1].url, "https://api.polar.sh/v1/x"); + }); + + it("turns an error answer into a PolarApiError with a readable detail, whatever shape Polar used", async () => { + for (const [body, expected] of [ + [{ detail: "Not found." }, "Not found."], + [{ detail: [{ loc: ["body", "x"], msg: "bad" }] }, '[{"loc":["body","x"],"msg":"bad"}]'], + [{ error: "insufficient_scope", error_description: "needs more privileges" }, "needs more privileges"], + [{}, "Server Error"], + ] as const) { + const { fetcher } = recorder(() => new Response(JSON.stringify(body), { status: 500, statusText: "Server Error" })); + await assert.rejects(polarFetch("/v1/x", {}, fetcher), (error: unknown) => error instanceof PolarApiError && error.status === 500 && error.detail === expected); + } + }); + + it("is not fooled by an answer that is not JSON", async () => { + const { fetcher } = recorder(() => new Response("bad gateway", { status: 502, statusText: "Bad Gateway" })); + await assert.rejects(polarFetch("/v1/x", {}, fetcher), (error: unknown) => error instanceof PolarApiError && error.status === 502 && error.detail === "Bad Gateway"); + }); +}); + +describe("createPortalSession", () => { + it("returns the portal link of the customer whose external id is the user", async () => { + const { calls, fetcher } = recorder(() => json(201, { customer_portal_url: "https://sandbox.polar.sh/org/portal?token=x" })); + assert.equal(await createPortalSession("user-1", "https://app.example/billing", fetcher), "https://sandbox.polar.sh/org/portal?token=x"); + assert.deepEqual(calls[0].body, { external_customer_id: "user-1", return_url: "https://app.example/billing" }); + }); + + it("answers null (no customer yet) for what Polar really says when the external id is unknown: 422 'Customer does not exist.'", async () => { + const real = [{ type: "value_error", loc: ["body", "external_customer_id"], msg: "Customer does not exist.", input: "user-1" }]; + const { fetcher } = recorder(() => json(422, { detail: real })); + assert.equal(await createPortalSession("user-1", "https://app.example/billing", fetcher), null); + }); + + it("answers null for a 404 too, and throws for every other failure (a wrong token is not 'no customer')", async () => { + assert.equal(await createPortalSession("u", "https://x", recorder(() => json(404, { error: "ResourceNotFound", detail: "Customer not found." })).fetcher), null); + for (const response of [json(401, { error: "invalid_token" }), json(403, { error: "insufficient_scope" }), json(500, { detail: "boom" }), json(422, { detail: [{ loc: ["body", "return_url"], msg: "invalid url" }] })]) { + await assert.rejects(createPortalSession("u", "https://x", recorder(() => response.clone()).fetcher), PolarApiError); + } + }); + + it("throws when Polar answers without a link", async () => { + await assert.rejects(createPortalSession("u", "https://x", recorder(() => json(201, {})).fetcher), /portal link/); + }); +}); + +describe("isNoSuchCustomer and isCustomerEmailRejection", () => { + it("recognises exactly the answers that mean 'this customer is not at Polar'", () => { + assert.equal(isNoSuchCustomer(new PolarApiError(404, "Not found.", "/x")), true); + assert.equal(isNoSuchCustomer(new PolarApiError(422, '[{"msg":"Customer does not exist."}]', "/x")), true); + assert.equal(isNoSuchCustomer(new PolarApiError(422, '[{"msg":"invalid url"}]', "/x")), false); + assert.equal(isNoSuchCustomer(new PolarApiError(500, "Customer does not exist.", "/x")), false); + assert.equal(isNoSuchCustomer(new Error("Customer does not exist.")), false); + }); + + it("recognises Polar's refusal of a prefilled e-mail address, and nothing else", () => { + assert.equal(isCustomerEmailRejection(new Error('Polar /v1/checkouts/ -> 422: [{"loc":["body","customer_email"],"msg":"value is not a valid email address: The domain does not exist","type":"value_error"}]')), true); + assert.equal(isCustomerEmailRejection(new Error('Polar /v1/checkouts/ -> 422: [{"loc":["body","products"],"msg":"bad","type":"value_error"}]')), false); + assert.equal(isCustomerEmailRejection("nope"), false); + }); +}); + +describe("createCheckout", () => { + const input = { productId: "prod_1", userId: "user-1", email: "a@example.com", successUrl: "https://app/success", returnUrl: "https://app/pricing", metadata: { item_key: "pro" } }; + + it("creates it for the external customer, with the e-mail prefilled, and returns the id and the page", async () => { + const { calls, fetcher } = recorder(() => json(201, { id: "chk_1", url: "https://sandbox.polar.sh/checkout/chk_1" })); + assert.deepEqual(await createCheckout(input, fetcher), { id: "chk_1", url: "https://sandbox.polar.sh/checkout/chk_1" }); + assert.deepEqual(calls[0].body, { products: ["prod_1"], external_customer_id: "user-1", customer_email: "a@example.com", success_url: "https://app/success", return_url: "https://app/pricing", metadata: { item_key: "pro" } }); + }); + + it("omits the e-mail when there is none, retries once without it when Polar refuses it, and gives up on any other refusal", async () => { + const noMail = recorder(() => json(201, { id: "c", url: "https://x" })); + await createCheckout({ ...input, email: null }, noMail.fetcher); + assert.equal("customer_email" in (noMail.calls[0].body as object), false); + + const refuse = recorder((_call, n) => (n === 1 ? json(422, { detail: [{ loc: ["body", "customer_email"], msg: "value is not a valid email address", type: "value_error" }] }) : json(201, { id: "c", url: "https://x" }))); + await createCheckout(input, refuse.fetcher); + assert.equal(refuse.calls.length, 2); + assert.equal("customer_email" in (refuse.calls[1].body as object), false); + + const other = recorder(() => json(422, { detail: [{ loc: ["body", "products"], msg: "bad product", type: "value_error" }] })); + await assert.rejects(createCheckout(input, other.fetcher), PolarApiError); + assert.equal(other.calls.length, 1, "no retry for a refusal that is not about the e-mail"); + }); + + it("throws when Polar answers without a checkout url", async () => { + await assert.rejects(createCheckout(input, recorder(() => json(201, { id: "c" })).fetcher), /checkout url/); + }); +}); + +describe("listSubscriptions and listOrders", () => { + it("filters by the external customer id (encoded) and follows the pages until the last", async () => { + const { calls, fetcher } = recorder((call) => { + const page = Number(new URL(call.url).searchParams.get("page")); + return json(200, { items: [{ id: `s${page}` }], pagination: { total_count: 3, max_page: 3 } }); + }); + const subscriptions = await listSubscriptions("user 1/&x", fetcher); + assert.deepEqual(subscriptions.map((s) => s.id), ["s1", "s2", "s3"]); + assert.equal(calls.length, 3); + assert.equal(new URL(calls[0].url).searchParams.get("external_customer_id"), "user 1/&x"); + assert.equal(new URL(calls[0].url).searchParams.get("limit"), "100"); + assert.equal(new URL(calls[0].url).pathname, "/v1/subscriptions/"); + const orders = await listOrders("u", recorder(() => json(200, { items: [{ id: "o1" }], pagination: { max_page: 1 } })).fetcher); + assert.deepEqual(orders.map((o) => o.id), ["o1"]); + }); + + it("stops at ten pages whatever Polar claims, and survives an answer without pagination", async () => { + const endless = recorder(() => json(200, { items: [{ id: "x" }], pagination: { max_page: 1_000_000 } })); + assert.equal((await listOrders("u", endless.fetcher)).length, 10); + assert.equal(endless.calls.length, 10); + assert.deepEqual(await listOrders("u", recorder(() => json(200, {})).fetcher), []); + }); + + it("throws on an error answer instead of returning an empty list (an outage is not 'no subscriptions')", async () => { + await assert.rejects(listSubscriptions("u", recorder(() => json(500, { detail: "down" })).fetcher), PolarApiError); + }); +}); diff --git a/tests/polar-config.test.ts b/tests/polar-config.test.ts new file mode 100644 index 0000000..edbfaf1 --- /dev/null +++ b/tests/polar-config.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getPolarServerConfig, polarConfigProblem, polarConfigured, PolarConfigError, polarServer } from "@/lib/billing/polar/config"; + +describe("getPolarServerConfig", () => { + it("is the sandbox unless POLAR_SERVER says production exactly, so a typo can never charge for real", () => { + assert.equal(polarServer({}), "sandbox"); + assert.equal(polarServer({ POLAR_SERVER: "production" }), "production"); + for (const other of ["Production", "prod", "live", "", "sandbox"]) assert.equal(polarServer({ POLAR_SERVER: other }), "sandbox", other); + }); + + it("reads the token, the API base of the server and the webhook secret", () => { + const sandbox = getPolarServerConfig({ POLAR_ACCESS_TOKEN: " polar_oat_x " }); + assert.equal(sandbox.accessToken, "polar_oat_x"); + assert.equal(sandbox.apiBase, "https://sandbox-api.polar.sh"); + assert.equal(sandbox.webhookSecret, null); + const live = getPolarServerConfig({ POLAR_ACCESS_TOKEN: "t", POLAR_SERVER: "production", POLAR_WEBHOOK_SECRET: " whsec_x " }); + assert.equal(live.apiBase, "https://api.polar.sh"); + assert.equal(live.webhookSecret, "whsec_x"); + }); + + it("refuses a missing or blank token", () => { + for (const token of [undefined, "", " "]) assert.throws(() => getPolarServerConfig({ POLAR_ACCESS_TOKEN: token }), PolarConfigError); + }); +}); + +describe("polarConfigured", () => { + it("needs only the token (Polar needs nothing in the browser)", () => { + assert.equal(polarConfigured({ POLAR_ACCESS_TOKEN: "t" }), true); + assert.equal(polarConfigured({}), false); + assert.match(polarConfigProblem({}) ?? "", /POLAR_ACCESS_TOKEN/); + assert.equal(polarConfigProblem({ POLAR_ACCESS_TOKEN: "t" }), null); + }); +}); diff --git a/tests/polar-signature.test.ts b/tests/polar-signature.test.ts new file mode 100644 index 0000000..d34b540 --- /dev/null +++ b/tests/polar-signature.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { describe, it } from "node:test"; +import { polarWebhookHeaders, SIGNATURE_TOLERANCE_SECONDS, signPolarBody, verifyPolarSignature, type PolarWebhookHeaders } from "@/lib/billing/polar/signature"; + +// A Standard Webhooks secret: "whsec_" + base64 of the key. Written out here so the test does not use the code it checks. +const KEY = Buffer.from("0123456789abcdef0123456789abcdef"); +const SECRET = `whsec_${KEY.toString("base64")}`; +const BODY = JSON.stringify({ type: "order.paid", timestamp: "2026-10-14T00:00:00Z", data: { id: "ord_1" } }); +const NOW = Date.UTC(2026, 9, 14, 0, 0, 30); +const seconds = (ms: number) => Math.floor(ms / 1000); + +const headersFor = (key: Buffer, over: Partial = {}, ts = seconds(NOW), id = "msg_1", body = BODY): PolarWebhookHeaders => ({ + id, + timestamp: String(ts), + signature: `v1,${createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64")}`, + ...over, +}); + +describe("verifyPolarSignature (Standard Webhooks)", () => { + it("accepts a correctly signed body", () => { + assert.equal(verifyPolarSignature(SECRET, BODY, headersFor(KEY), NOW), true); + }); + + it("round-trips with signPolarBody", () => { + const signed = signPolarBody(SECRET, "msg_2", BODY, NOW); + const headers = polarWebhookHeaders(new Headers(signed)); + assert.equal(verifyPolarSignature(SECRET, BODY, headers, NOW), true); + }); + + it("agrees with the reference library when it is installed", async () => { + let library: { Webhook: new (secret: string) => { sign: (id: string, date: Date, body: string) => string } }; + try { + const name = "standardwebhooks"; // a variable: not a dependency of the template, so TypeScript must not look for it + library = await import(name); + } catch { + return; // not a dependency of the template: this check only runs where somebody installed it + } + const signature = new library.Webhook(SECRET).sign("msg_lib", new Date(NOW), BODY); + assert.equal(verifyPolarSignature(SECRET, BODY, { id: "msg_lib", timestamp: String(seconds(NOW)), signature }, NOW), true); + }); + + it("also accepts the older Polar secrets, whose key is the whole secret string", () => { + const legacy = "polar_whs_legacy_secret"; + assert.equal(verifyPolarSignature(legacy, BODY, headersFor(Buffer.from(legacy, "utf8")), NOW), true); + }); + + it("refuses a body that was changed by one byte, even one that parses to the same JSON", () => { + assert.equal(verifyPolarSignature(SECRET, `${BODY} `, headersFor(KEY), NOW), false); + assert.equal(verifyPolarSignature(SECRET, JSON.stringify(JSON.parse(BODY), null, 2), headersFor(KEY), NOW), false); + }); + + it("refuses another secret and a missing secret", () => { + assert.equal(verifyPolarSignature(`whsec_${Buffer.from("another key entirely, 32 bytes!!!").toString("base64")}`, BODY, headersFor(KEY), NOW), false); + assert.equal(verifyPolarSignature("", BODY, headersFor(KEY), NOW), false); + }); + + it("refuses missing or malformed headers", () => { + const good = headersFor(KEY); + for (const bad of [ + { ...good, id: null }, + { ...good, id: "" }, + { ...good, timestamp: undefined }, + { ...good, timestamp: "yesterday" }, + { ...good, signature: null }, + { ...good, signature: "" }, + { ...good, signature: "garbage" }, + { ...good, signature: "v2,AAAA" }, + { ...good, signature: "v1," }, + { ...good, signature: "v1,AAAA" }, + ]) { + assert.equal(verifyPolarSignature(SECRET, BODY, bad, NOW), false, JSON.stringify(bad)); + } + }); + + it("does not accept a signature made for another delivery id or another timestamp", () => { + assert.equal(verifyPolarSignature(SECRET, BODY, { ...headersFor(KEY), id: "msg_other" }, NOW), false); + const signedForEarlier = headersFor(KEY, {}, seconds(NOW) - 10); + assert.equal(verifyPolarSignature(SECRET, BODY, { ...signedForEarlier, timestamp: String(seconds(NOW)) }, NOW), false); + }); + + it("refuses a timestamp outside the tolerance, in both directions", () => { + const limit = SIGNATURE_TOLERANCE_SECONDS; + assert.equal(verifyPolarSignature(SECRET, BODY, headersFor(KEY, {}, seconds(NOW) - limit), NOW), true); + assert.equal(verifyPolarSignature(SECRET, BODY, headersFor(KEY, {}, seconds(NOW) - limit - 5), NOW), false); + assert.equal(verifyPolarSignature(SECRET, BODY, headersFor(KEY, {}, seconds(NOW) + limit + 5), NOW), false); + }); + + it("accepts any one of several space separated signatures (a secret rotation)", () => { + const wrong = `v1,${createHmac("sha256", "old").update(`msg_1.${seconds(NOW)}.${BODY}`).digest("base64")}`; + const right = headersFor(KEY).signature!; + assert.equal(verifyPolarSignature(SECRET, BODY, { ...headersFor(KEY), signature: `${wrong} ${right}` }, NOW), true); + assert.equal(verifyPolarSignature(SECRET, BODY, { ...headersFor(KEY), signature: `${right} ${wrong}` }, NOW), true); + assert.equal(verifyPolarSignature(SECRET, BODY, { ...headersFor(KEY), signature: wrong }, NOW), false); + }); +}); diff --git a/tests/polar-sync.test.ts b/tests/polar-sync.test.ts new file mode 100644 index 0000000..9c7ad95 --- /dev/null +++ b/tests/polar-sync.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { PolarOrder, PolarSubscription } from "@/lib/billing/polar/types"; +import { syncPolarCustomer, type PolarSyncSource } from "@/lib/billing/polar/sync"; +import { MemoryStore, OTHER_USER_ID, polarContext, polarCustomer, polarOrderData, polarSubscriptionData, USER_ID } from "./support/billing-fixtures"; + +const SUB = "polar_6a1f0c52-0000-4000-8000-0000000000a1"; +const ORDER = "polar_9d2e7a10-0000-4000-8000-0000000000b1"; +const ctx = polarContext(); +const source = (subscriptions: unknown[], orders: unknown[]): PolarSyncSource => ({ + listSubscriptions: async () => subscriptions as PolarSubscription[], + listOrders: async () => orders as PolarOrder[], +}); + +describe("syncPolarCustomer", () => { + it("applies a user's subscriptions and orders the way the webhooks would, refunds included", async () => { + const store = new MemoryStore(); + const refunded = polarOrderData({ status: "partially_refunded", refunded_amount: 300, refunded_tax_amount: 30, modified_at: "2026-10-15T00:00:00Z" }); + const result = await syncPolarCustomer(store, USER_ID, ctx, source([polarSubscriptionData()], [refunded])); + assert.deepEqual(result, { subscriptions: 1, transactions: 1 }); + assert.equal(store.subscriptions.get(SUB)?.status, "active"); + assert.equal(store.transactions.get(ORDER)?.refunded, 330); + }); + + it("skips what is not ours: another product, an unpaid order, and anybody else's object", async () => { + const store = new MemoryStore(); + const result = await syncPolarCustomer( + store, + USER_ID, + ctx, + source( + [polarSubscriptionData({ product_id: "prod_x" }), polarSubscriptionData({ id: "other", customer: polarCustomer(OTHER_USER_ID) })], + [polarOrderData({ status: "pending" }), polarOrderData({ id: "o2", customer: polarCustomer(OTHER_USER_ID) })], + ), + ); + assert.deepEqual(result, { subscriptions: 0, transactions: 0 }); + assert.equal(store.subscriptions.size + store.transactions.size, 0); + }); + + it("does not overwrite a newer state a webhook already wrote", async () => { + const store = new MemoryStore(); + await store.upsertSubscription({ ...(polarRow("canceled")), event_occurred_at: "2099-01-01T00:00:00Z" }); + await syncPolarCustomer(store, USER_ID, ctx, source([polarSubscriptionData({ status: "active" })], [])); + assert.equal(store.subscriptions.get(SUB)?.status, "canceled"); + }); +}); + +function polarRow(status: "canceled" | "active") { + return { + id: "6a1f0c52-0000-4000-8000-0000000000a1", + provider: "polar" as const, + user_id: USER_ID, + customer_id: "c", + status, + plan_key: "pro", + price_id: "prod_pro_month", + billing_interval: "month", + quantity: 1, + currency: "USD", + unit_amount: 900, + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + scheduled_change: null, + canceled_at: null, + event_occurred_at: "2026-10-14T00:00:00Z", + }; +} diff --git a/tests/polar-webhook.test.ts b/tests/polar-webhook.test.ts new file mode 100644 index 0000000..331d40f --- /dev/null +++ b/tests/polar-webhook.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { applyPolarEvent, parsePolarEvent, polarOrderRowsFrom, polarSubscriptionRowFrom, polarUid, POLAR_EVENTS, processPolarDelivery } from "@/lib/billing/polar/webhook"; +import { MemoryStore, OTHER_USER_ID, polarCustomer, polarContext, polarEvent, polarOrderData, polarSubscriptionData, POLAR_CUSTOMER_ID, USER_ID } from "./support/billing-fixtures"; + +const ctx = polarContext(); +const SUB = "polar_6a1f0c52-0000-4000-8000-0000000000a1"; +const ORDER = "polar_9d2e7a10-0000-4000-8000-0000000000b1"; + +describe("subscription events", () => { + it("writes the subscription of the user the server set as the customer, with the plan, the period and the product", async () => { + const store = new MemoryStore(); + const result = await applyPolarEvent(store, polarEvent("subscription.active", polarSubscriptionData()), ctx); + assert.equal(result.status, "processed"); + assert.deepEqual(store.subscriptions.get(SUB), { + id: "6a1f0c52-0000-4000-8000-0000000000a1", + provider: "polar", + user_id: USER_ID, + customer_id: POLAR_CUSTOMER_ID, + status: "active", + plan_key: "pro", + price_id: "prod_pro_month", + billing_interval: "month", + quantity: 1, + currency: "USD", + unit_amount: 900, + current_period_start: "2026-10-14T00:00:00.000000Z", + current_period_end: "2026-11-14T00:00:00.000000Z", + cancel_at_period_end: false, + scheduled_change: null, + canceled_at: null, + event_occurred_at: "2026-10-14T00:00:03.000000Z", + }); + }); + + it("reads a scheduled cancellation as cancel_at_period_end (the plan stays active until the period ends), and the end as canceled", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("subscription.canceled", polarSubscriptionData({ cancel_at_period_end: true, canceled_at: "2026-10-20T00:00:00Z", ends_at: "2026-11-14T00:00:00Z", modified_at: "2026-10-20T00:00:00Z" })), ctx); + assert.equal(store.subscriptions.get(SUB)?.status, "active"); + assert.equal(store.subscriptions.get(SUB)?.cancel_at_period_end, true); + await applyPolarEvent(store, polarEvent("subscription.revoked", polarSubscriptionData({ status: "canceled", cancel_at_period_end: true, ended_at: "2026-11-14T00:00:00Z", modified_at: "2026-11-14T00:00:01Z" })), ctx); + assert.equal(store.subscriptions.get(SUB)?.status, "canceled"); + }); + + it("takes a cancellation back (subscription.uncanceled)", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("subscription.canceled", polarSubscriptionData({ cancel_at_period_end: true, modified_at: "2026-10-20T00:00:00Z" })), ctx); + await applyPolarEvent(store, polarEvent("subscription.uncanceled", polarSubscriptionData({ cancel_at_period_end: false, modified_at: "2026-10-21T00:00:00Z" })), ctx); + assert.equal(store.subscriptions.get(SUB)?.cancel_at_period_end, false); + }); + + it("maps the statuses: trialing, past_due, and the three that end access", async () => { + const expected: [string, string][] = [["trialing", "trialing"], ["past_due", "past_due"], ["canceled", "canceled"], ["unpaid", "canceled"], ["incomplete_expired", "canceled"]]; + for (const [polar, ours] of expected) { + const row = polarSubscriptionRowFrom(polarSubscriptionData({ status: polar }) as never, ctx); + assert.ok(!("ignored" in row), polar); + assert.equal(row.status, ours, polar); + } + }); + + it("ignores a subscription that has not been paid yet (incomplete) and a status it does not know", async () => { + const store = new MemoryStore(); + assert.deepEqual(await applyPolarEvent(store, polarEvent("subscription.created", polarSubscriptionData({ status: "incomplete" })), ctx), { status: "ignored", note: "not_started" }); + assert.deepEqual(await applyPolarEvent(store, polarEvent("subscription.updated", polarSubscriptionData({ status: "teleported" })), ctx), { status: "ignored", note: "unknown_status_teleported" }); + assert.equal(store.subscriptions.size, 0); + }); + + it("never lets an older state overwrite a newer one (events arrive out of order)", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("subscription.revoked", polarSubscriptionData({ status: "canceled", modified_at: "2026-11-14T00:00:00Z" })), ctx); + await applyPolarEvent(store, polarEvent("subscription.updated", polarSubscriptionData({ status: "active", modified_at: "2026-10-20T00:00:00.123456Z" })), ctx); + assert.equal(store.subscriptions.get(SUB)?.status, "canceled"); + }); + + it("uses created_at for a subscription that was never modified", () => { + const row = polarSubscriptionRowFrom(polarSubscriptionData({ modified_at: null }) as never, ctx); + assert.ok(!("ignored" in row)); + assert.equal(row.event_occurred_at, "2026-10-14T00:00:00.000000Z"); + }); + + it("understands a yearly product and the plan it belongs to", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("subscription.active", polarSubscriptionData({ product_id: "prod_team_year", recurring_interval: "year", amount: 29000 })), ctx); + const row = store.subscriptions.get(SUB); + assert.equal(row?.plan_key, "team"); + assert.equal(row?.billing_interval, "year"); + assert.equal(row?.unit_amount, 29000); + }); + + it("credits only the external customer id the server set: not the metadata, not the e-mail", async () => { + const store = new MemoryStore(); + const noExternal = polarSubscriptionData({ customer: polarCustomer(null), metadata: { user_id: USER_ID } }); + assert.deepEqual(await applyPolarEvent(store, polarEvent("subscription.active", noExternal), ctx), { status: "ignored", note: "no_customer" }); + assert.deepEqual(await applyPolarEvent(store, polarEvent("subscription.active", polarSubscriptionData({ customer: undefined })), ctx), { status: "ignored", note: "no_customer" }); + // a payload whose metadata names someone else is still the external customer's + await applyPolarEvent(store, polarEvent("subscription.active", polarSubscriptionData({ metadata: { user_id: OTHER_USER_ID } })), ctx); + assert.equal(store.subscriptions.get(SUB)?.user_id, USER_ID); + assert.equal(polarUid({ external_id: "" }), null); + assert.equal(polarUid(null), null); + }); + + it("ignores an external customer id that is not a user id of this app (another app sharing the Polar organization uses other ids)", async () => { + const store = new MemoryStore(); + for (const external of ["sbx-alice", "123", "not-a-uuid", "11111111-1111-4111-8111-11111111111", `${USER_ID}-x`]) { + assert.deepEqual(await applyPolarEvent(store, polarEvent("subscription.active", polarSubscriptionData({ customer: polarCustomer(external) })), ctx), { status: "ignored", note: "no_customer" }, external); + } + assert.equal(polarUid({ external_id: USER_ID.toUpperCase() }), USER_ID.toUpperCase(), "a UUID is a UUID in either case"); + assert.equal(store.subscriptions.size, 0); + }); + + it("ignores a product that is not in .env (another app's product, or one that was never configured)", async () => { + const store = new MemoryStore(); + assert.deepEqual(await applyPolarEvent(store, polarEvent("subscription.active", polarSubscriptionData({ product_id: "prod_someone_elses" })), ctx), { status: "ignored", note: "not_our_product" }); + assert.equal(store.subscriptions.size, 0); + }); +}); + +describe("order events", () => { + it("records a payment with what the customer paid (tax included), the net amount and the invoice", async () => { + const store = new MemoryStore(); + const result = await applyPolarEvent(store, polarEvent("order.paid", polarOrderData()), ctx); + assert.equal(result.status, "processed"); + assert.deepEqual(store.transactions.get(ORDER), { + id: "9d2e7a10-0000-4000-8000-0000000000b1", + provider: "polar", + user_id: USER_ID, + customer_id: POLAR_CUSTOMER_ID, + subscription_id: "6a1f0c52-0000-4000-8000-0000000000a1", + status: "completed", + origin: "subscription_create", + price_id: "prod_pro_month", + item_key: "pro", + currency: "USD", + subtotal: 900, + tax: 90, + total: 990, + refunded: 0, + invoice_number: "TEST-0001", + billed_at: "2026-10-14T00:00:05.000000Z", + }); + assert.equal(store.adjustments.size, 0, "nothing refunded: no adjustment"); + }); + + it("records a one-time product under its own key, and a renewal like any other payment", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("order.paid", polarOrderData({ id: "pack", product_id: "prod_credits_100", subscription_id: null, billing_reason: "purchase" })), ctx); + assert.equal(store.transactions.get("polar_pack")?.item_key, "credits_100"); + assert.equal(store.transactions.get("polar_pack")?.subscription_id, null); + await applyPolarEvent(store, polarEvent("order.paid", polarOrderData({ id: "renewal", billing_reason: "subscription_cycle" })), ctx); + assert.equal(store.transactions.get("polar_renewal")?.origin, "subscription_cycle"); + }); + + it("falls back to net + tax when Polar does not send the total", () => { + const rows = polarOrderRowsFrom(polarOrderData({ total_amount: null }) as never, ctx); + assert.ok(!("ignored" in rows)); + assert.equal(rows.transaction.total, 990); + }); + + it("ignores an order that was not paid (a draft or a pending one), another product, and a customer we did not set", async () => { + const store = new MemoryStore(); + assert.deepEqual(await applyPolarEvent(store, polarEvent("order.updated", polarOrderData({ status: "pending", paid: false })), ctx), { status: "ignored", note: "not_paid" }); + assert.deepEqual(await applyPolarEvent(store, polarEvent("order.paid", polarOrderData({ product_id: "prod_x" })), ctx), { status: "ignored", note: "not_our_product" }); + assert.deepEqual(await applyPolarEvent(store, polarEvent("order.paid", polarOrderData({ customer: polarCustomer(null) })), ctx), { status: "ignored", note: "no_customer" }); + assert.equal(store.transactions.size, 0); + }); + + it("turns a partial refund into one approved adjustment, tax included, and adds it up", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("order.paid", polarOrderData()), ctx); + await applyPolarEvent(store, polarEvent("order.updated", polarOrderData({ status: "partially_refunded", refunded_amount: 300, refunded_tax_amount: 30, modified_at: "2026-10-15T00:00:00Z" })), ctx); + assert.equal(store.transactions.get(ORDER)?.refunded, 330); + assert.equal(store.transactions.get(ORDER)?.status, "completed", "a refunded order was still a payment"); + assert.deepEqual( + [...store.adjustments.values()].map((a) => [a.id, a.action, a.status, a.total, a.currency]), + [[`refund_9d2e7a10-0000-4000-8000-0000000000b1`, "refund", "approved", 330, "USD"]], + ); + }); + + it("follows a refund to the whole payment, and never goes back down when an older delivery arrives late", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("order.paid", polarOrderData()), ctx); + await applyPolarEvent(store, polarEvent("order.refunded", polarOrderData({ status: "refunded", refunded_amount: 900, refunded_tax_amount: 90, modified_at: "2026-10-16T00:00:00Z" })), ctx); + assert.equal(store.transactions.get(ORDER)?.refunded, 990); + await applyPolarEvent(store, polarEvent("order.updated", polarOrderData({ status: "partially_refunded", refunded_amount: 300, refunded_tax_amount: 30, modified_at: "2026-10-15T00:00:00Z" })), ctx); + assert.equal(store.transactions.get(ORDER)?.refunded, 990); + }); + + it("keeps what a refund added up when the payment is delivered again", async () => { + const store = new MemoryStore(); + await applyPolarEvent(store, polarEvent("order.refunded", polarOrderData({ status: "refunded", refunded_amount: 900, refunded_tax_amount: 90, modified_at: "2026-10-16T00:00:00Z" })), ctx); + await applyPolarEvent(store, polarEvent("order.paid", polarOrderData()), ctx); + assert.equal(store.transactions.get(ORDER)?.refunded, 990); + }); +}); + +describe("a delivery", () => { + it("is processed once: a second delivery of the same message id is a duplicate that changes nothing", async () => { + const store = new MemoryStore(); + const delivery = polarEvent("subscription.active", polarSubscriptionData(), "2026-10-14T00:00:10Z", "msg_same"); + assert.equal((await processPolarDelivery(store, delivery, ctx)).status, "processed"); + store.subscriptions.clear(); + assert.equal((await processPolarDelivery(store, delivery, ctx)).status, "duplicate"); + assert.equal(store.subscriptions.size, 0, "the duplicate was not applied again"); + assert.equal(store.events.get("polar_msg_same")?.status, "processed"); + }); + + it("does not mix up the delivery ids of the two providers", async () => { + const store = new MemoryStore(); + store.events.set("paddle_msg_clash", { status: "processed", note: "x" }); + const result = await processPolarDelivery(store, polarEvent("subscription.active", polarSubscriptionData(), "2026-10-14T00:00:10Z", "msg_clash"), ctx); + assert.equal(result.status, "processed"); + }); + + it("answers an event type it does not handle with ignored, not an error (Polar would retry an error)", async () => { + const store = new MemoryStore(); + for (const type of ["customer.state_changed", "checkout.updated", "benefit_grant.created"]) { + assert.deepEqual(await processPolarDelivery(store, polarEvent(type, { id: "x" }), ctx), { status: "ignored", note: "unhandled_event_type" }, type); + } + }); + + it("fails loudly when the database fails, records the failure, and processes the redelivery", async () => { + const store = new MemoryStore(); + const delivery = polarEvent("subscription.active", polarSubscriptionData(), "2026-10-14T00:00:10Z", "msg_retry"); + store.failNextWrite = true; + await assert.rejects(processPolarDelivery(store, delivery, ctx), /database is down/); + assert.equal(store.events.get("polar_msg_retry")?.status, "failed"); + assert.equal(store.subscriptions.size, 0); + assert.equal((await processPolarDelivery(store, delivery, ctx)).status, "processed"); + assert.equal(store.subscriptions.size, 1); + }); +}); + +describe("parsePolarEvent", () => { + const body = JSON.stringify({ type: "order.paid", timestamp: "2026-10-14T00:00:10Z", data: { id: "o" } }); + + it("takes the delivery id from the webhook-id header and the rest from the body", () => { + assert.deepEqual(parsePolarEvent(body, "msg_1"), { event_id: "msg_1", event_type: "order.paid", occurred_at: "2026-10-14T00:00:10Z", data: { id: "o" } }); + }); + + it("refuses anything that is not an event, and an event without a delivery id", () => { + assert.equal(parsePolarEvent(body, null), null); + assert.equal(parsePolarEvent(body, ""), null); + for (const bad of ["", "not json", "null", "[]", "{}", JSON.stringify({ type: 1, data: {} }), JSON.stringify({ type: "x" }), JSON.stringify({ type: "x", data: "y" })]) { + assert.equal(parsePolarEvent(bad, "msg_1"), null, bad); + } + }); + + it("uses the time of arrival when the timestamp is missing or not a date", () => { + const fixed = () => "2026-01-01T00:00:00.000Z"; + assert.equal(parsePolarEvent(JSON.stringify({ type: "x", data: {} }), "m", fixed)?.occurred_at, "2026-01-01T00:00:00.000Z"); + assert.equal(parsePolarEvent(JSON.stringify({ type: "x", timestamp: "soon", data: {} }), "m", fixed)?.occurred_at, "2026-01-01T00:00:00.000Z"); + }); +}); + +describe("the events the template subscribes to", () => { + it("are exactly the ones the handler knows (scripts/polar-setup.mjs registers the same list)", () => { + assert.deepEqual( + [...POLAR_EVENTS].sort(), + ["order.paid", "order.refunded", "order.updated", "subscription.active", "subscription.canceled", "subscription.created", "subscription.past_due", "subscription.revoked", "subscription.uncanceled", "subscription.updated"], + ); + }); +}); diff --git a/tests/support/billing-fixtures.ts b/tests/support/billing-fixtures.ts new file mode 100644 index 0000000..d77e2aa --- /dev/null +++ b/tests/support/billing-fixtures.ts @@ -0,0 +1,225 @@ +// Shared by the billing tests: payload builders shaped like Paddle's and Polar's real webhooks, and an in-memory BillingStore that follows +// the same rules as the Supabase one (a duplicate is not processed twice, an older subscription or refund never overwrites a newer one). +import { billingConfig, catalogItems } from "@/config/billing"; +import { itemForProviderRef } from "@/lib/billing/catalog"; +import type { PaddleEvent } from "@/lib/billing/paddle/types"; +import type { WebhookContext } from "@/lib/billing/paddle/webhook"; +import type { PolarEvent } from "@/lib/billing/polar/types"; +import type { PolarContext } from "@/lib/billing/polar/webhook"; +import type { AdjustmentRow, BillingStore, JsonObject, Provider, SubscriptionRow, TransactionRow, WebhookEventRecord } from "@/lib/billing/types"; + +export const APP_TAG = "test-app"; +export const USER_ID = "11111111-1111-4111-8111-111111111111"; +export const OTHER_USER_ID = "22222222-2222-4222-8222-222222222222"; +export const CUSTOMER_ID = "ctm_01testcustomer"; +export const POLAR_CUSTOMER_ID = "0b1c2d3e-0000-4000-8000-000000000001"; + +/** The Paddle price ids "in .env": the plans of src/config/billing.ts plus one one-time product. */ +export const ENV = { + PADDLE_PRICE_PRO_MONTH: "pri_pro_month", + PADDLE_PRICE_PRO_YEAR: "pri_pro_year", + PADDLE_PRICE_TEAM_MONTH: "pri_team_month", + PADDLE_PRICE_TEAM_YEAR: "pri_team_year", + PADDLE_PRICE_CREDITS_100: "pri_credits_100", +}; + +/** The Polar product ids "in .env": the same catalog. */ +export const POLAR_ENV = { + POLAR_PRODUCT_PRO_MONTH: "prod_pro_month", + POLAR_PRODUCT_PRO_YEAR: "prod_pro_year", + POLAR_PRODUCT_TEAM_MONTH: "prod_team_month", + POLAR_PRODUCT_TEAM_YEAR: "prod_team_year", + POLAR_PRODUCT_CREDITS_100: "prod_credits_100", +}; + +export const ITEMS = catalogItems({ + plans: billingConfig.plans, + products: [{ key: "credits_100", name: "100 credits", description: "A pack of 100 credits", price: 10 }], +}); + +export function context(): WebhookContext { + return { appTag: APP_TAG, itemForPriceId: (id) => itemForProviderRef("paddle", id, ENV, ITEMS) }; +} + +export function polarContext(): PolarContext { + return { itemForProductId: (id) => itemForProviderRef("polar", id, POLAR_ENV, ITEMS) }; +} + +// ── Paddle payloads ────────────────────────────────────────────────────────────────────────────────── + +export function subscriptionData(overrides: Record = {}) { + return { + id: "sub_01test", + status: "active", + customer_id: CUSTOMER_ID, + currency_code: "USD", + custom_data: { app: APP_TAG, user_id: USER_ID }, + canceled_at: null, + current_billing_period: { starts_at: "2026-10-14T00:00:00Z", ends_at: "2026-11-14T00:00:00Z" }, + billing_cycle: { interval: "month", frequency: 1 }, + scheduled_change: null, + items: [{ quantity: 1, recurring: true, price: { id: "pri_pro_month", unit_price: { amount: "900", currency_code: "USD" }, billing_cycle: { interval: "month", frequency: 1 } } }], + ...overrides, + }; +} + +export function transactionData(overrides: Record = {}) { + return { + id: "txn_01test", + status: "completed", + customer_id: CUSTOMER_ID, + subscription_id: "sub_01test", + origin: "api", + currency_code: "USD", + custom_data: { app: APP_TAG, user_id: USER_ID }, + invoice_number: "1001-10001", + billed_at: "2026-10-14T00:00:05Z", + items: [{ quantity: 1, price_id: "pri_pro_month", price: { id: "pri_pro_month" } }], + details: { totals: { subtotal: "900", tax: "90", total: "990", grand_total: "990", currency_code: "USD" } }, + ...overrides, + }; +} + +export function adjustmentData(overrides: Record = {}) { + return { + id: "adj_01test", + action: "refund", + status: "approved", + transaction_id: "txn_01test", + customer_id: CUSTOMER_ID, + currency_code: "USD", + totals: { total: "990", currency_code: "USD" }, + ...overrides, + }; +} + +let sequence = 0; +export function event(type: string, data: unknown, occurredAt = "2026-10-14T00:00:10Z", id?: string): PaddleEvent { + sequence += 1; + return { event_id: id ?? `evt_${String(sequence).padStart(6, "0")}`, event_type: type, occurred_at: occurredAt, data: data as JsonObject }; +} + +// ── Polar payloads (the field names and types of the real sandbox objects) ─────────────────────────── + +const polarCustomer = (userId: string | null = USER_ID) => ({ id: POLAR_CUSTOMER_ID, external_id: userId, email: "buyer@example.com" }); + +export function polarSubscriptionData(overrides: Record = {}) { + return { + id: "6a1f0c52-0000-4000-8000-0000000000a1", + created_at: "2026-10-14T00:00:00.000000Z", + modified_at: "2026-10-14T00:00:03.000000Z", + status: "active", + amount: 900, + currency: "usd", + recurring_interval: "month", + recurring_interval_count: 1, + current_period_start: "2026-10-14T00:00:00.000000Z", + current_period_end: "2026-11-14T00:00:00.000000Z", + cancel_at_period_end: false, + canceled_at: null, + started_at: "2026-10-14T00:00:00.000000Z", + ends_at: null, + ended_at: null, + customer_id: POLAR_CUSTOMER_ID, + product_id: "prod_pro_month", + seats: null, + metadata: { user_id: USER_ID }, + customer: polarCustomer(), + ...overrides, + }; +} + +export function polarOrderData(overrides: Record = {}) { + return { + id: "9d2e7a10-0000-4000-8000-0000000000b1", + created_at: "2026-10-14T00:00:05.000000Z", + modified_at: null, + status: "paid", + paid: true, + subtotal_amount: 900, + net_amount: 900, + tax_amount: 90, + total_amount: 990, + refunded_amount: 0, + refunded_tax_amount: 0, + currency: "usd", + billing_reason: "subscription_create", + invoice_number: "TEST-0001", + customer_id: POLAR_CUSTOMER_ID, + product_id: "prod_pro_month", + subscription_id: "6a1f0c52-0000-4000-8000-0000000000a1", + metadata: { user_id: USER_ID }, + customer: polarCustomer(), + ...overrides, + }; +} + +export function polarEvent(type: string, data: unknown, occurredAt = "2026-10-14T00:00:10.000000Z", id?: string): PolarEvent { + sequence += 1; + return { event_id: id ?? `msg_${String(sequence).padStart(6, "0")}`, event_type: type, occurred_at: occurredAt, data }; +} + +export { polarCustomer }; + +// ── the in-memory store ────────────────────────────────────────────────────────────────────────────── + +const key = (provider: Provider, id: string) => `${provider}_${id}`; + +export class MemoryStore implements BillingStore { + /** Paddle customers the server linked: `paddle_` -> uid. Polar needs none (the uid is in the payload). */ + customers = new Map([[key("paddle", CUSTOMER_ID), USER_ID]]); + subscriptions = new Map(); + transactions = new Map(); + adjustments = new Map(); + events = new Map(); + /** Set to make the next write fail, to test that a failure is recorded and retried. */ + failNextWrite = false; + + async claimEvent(e: WebhookEventRecord) { + const id = key(e.provider, e.event_id); + const seen = this.events.get(id); + if (seen && seen.status !== "failed") return false; + this.events.set(id, { status: "received", note: null }); + return true; + } + async finishEvent(provider: Provider, id: string, status: "processed" | "ignored", note: string) { + this.events.set(key(provider, id), { status, note }); + } + async failEvent(provider: Provider, id: string, message: string) { + this.events.set(key(provider, id), { status: "failed", note: message }); + } + async userIdForCustomer(provider: Provider, customerId: string) { + return this.customers.get(key(provider, customerId)) ?? null; + } + private guard() { + if (this.failNextWrite) { + this.failNextWrite = false; + throw new Error("database is down"); + } + } + async upsertSubscription(row: SubscriptionRow) { + this.guard(); + const stored = this.subscriptions.get(key(row.provider, row.id)); + // Instants, not strings: providers write timestamps with and without milliseconds. + if (!stored || Date.parse(stored.event_occurred_at) <= Date.parse(row.event_occurred_at)) this.subscriptions.set(key(row.provider, row.id), row); + } + async upsertTransaction(row: TransactionRow) { + this.guard(); + this.transactions.set(key(row.provider, row.id), { ...row, refunded: this.transactions.get(key(row.provider, row.id))?.refunded ?? 0 }); + } + async getTransaction(provider: Provider, id: string) { + const stored = this.transactions.get(key(provider, id)); + return stored ? { id: stored.id, user_id: stored.user_id } : null; + } + async upsertAdjustment(row: AdjustmentRow) { + const stored = this.adjustments.get(key(row.provider, row.id)); + if (!stored || Date.parse(stored.event_occurred_at) <= Date.parse(row.event_occurred_at)) this.adjustments.set(key(row.provider, row.id), row); + } + async recomputeRefunded(provider: Provider, transactionId: string) { + const refunded = [...this.adjustments.values()] + .filter((a) => a.provider === provider && a.transaction_id === transactionId && a.status === "approved" && (a.action === "refund" || a.action === "chargeback")) + .reduce((sum, a) => sum + a.total, 0); + const stored = this.transactions.get(key(provider, transactionId)); + if (stored) stored.refunded = refunded; + } +} diff --git a/tests/support/fake-supabase.ts b/tests/support/fake-supabase.ts new file mode 100644 index 0000000..e4582d2 --- /dev/null +++ b/tests/support/fake-supabase.ts @@ -0,0 +1,191 @@ +// A small in-memory stand-in for the part of supabase-js the billing code uses: from(table).insert / upsert / update / select with eq, is, in, lt, lte, +// order, limit, and a thenable result of the shape { data, error }. It follows PostgREST where it matters here: a duplicate primary or unique key is +// error 23505 (on insert, on upsert AND on update), a unique column may be null in many rows, `upsert` only touches the columns it is given, +// `ignoreDuplicates` leaves an existing row alone, a composite `onConflict: "provider,id"` matches on every column, timestamps compare as instants, +// and `update ... select` returns the rows it changed. It does NOT check Row Level Security or types: the SQL has its own check (see BILLING.md). +type Row = Record; +type Result = { data: Row[] | null; error: { code?: string; message: string } | null }; + +interface TableSpec { + primaryKey: string | string[]; + unique?: string[]; +} + +const columnsOf = (key: string | string[]): string[] => (Array.isArray(key) ? key : key.split(",").map((column) => column.trim())); + +/** Compares two column values the way Postgres would for the types used here: instants for timestamps, strings otherwise. */ +function compare(a: unknown, b: unknown): number { + const isTime = (value: unknown) => typeof value === "string" && /^\d{4}-\d{2}-\d{2}[T ]/.test(value); + if (isTime(a) && isTime(b)) return Date.parse(String(a)) - Date.parse(String(b)); + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; +} + +export class FakeSupabase { + tables: Record = {}; + specs: Record; + /** Every statement that ran, for tests that care about how many round trips happened. */ + log: string[] = []; + /** When set, a row with a `user_id` that is not in here is refused like the foreign key to auth.users does (23503). */ + users: Set | null = null; + + constructor(specs: Record) { + this.specs = specs; + for (const name of Object.keys(specs)) this.tables[name] = []; + } + + from(table: string) { + if (!this.specs[table]) throw new Error(`unknown table ${table}`); + return new Query(this, table); + } +} + +const DUPLICATE = (table: string) => ({ code: "23505", message: `duplicate key value violates unique constraint on ${table}` }); +const NO_USER = (table: string) => ({ code: "23503", message: `insert or update on table "${table}" violates foreign key constraint to auth.users` }); + +class Query { + private op: "select" | "insert" | "upsert" | "update" = "select"; + private payload: Row = {}; + private options: { onConflict?: string; ignoreDuplicates?: boolean } = {}; + private filters: ((row: Row) => boolean)[] = []; + private returning = false; + private columns: string[] | null = null; + private max = Infinity; + private sort: { column: string; ascending: boolean } | null = null; + + private db: FakeSupabase; + private table: string; + + constructor(db: FakeSupabase, table: string) { + this.db = db; + this.table = table; + } + + insert(row: Row) { + this.op = "insert"; + this.payload = row; + return this; + } + upsert(row: Row, options: { onConflict?: string; ignoreDuplicates?: boolean } = {}) { + this.op = "upsert"; + this.payload = row; + this.options = options; + return this; + } + update(patch: Row) { + this.op = "update"; + this.payload = patch; + return this; + } + select(columns?: string) { + if (this.op !== "select") this.returning = true; + // Like PostgREST: only the columns that were asked for come back. + this.columns = columns && columns !== "*" ? columns.split(",").map((column) => column.trim()) : null; + return this; + } + eq(column: string, value: unknown) { + this.filters.push((row) => row[column] === value); + return this; + } + is(column: string, value: null | boolean) { + this.filters.push((row) => (value === null ? row[column] === null || row[column] === undefined : row[column] === value)); + return this; + } + in(column: string, values: unknown[]) { + this.filters.push((row) => values.includes(row[column])); + return this; + } + order(column: string, options?: { ascending?: boolean }) { + this.sort = { column, ascending: options?.ascending ?? true }; + return this; + } + lt(column: string, value: unknown) { + this.filters.push((row) => compare(row[column], value) < 0); + return this; + } + lte(column: string, value: unknown) { + this.filters.push((row) => compare(row[column], value) <= 0); + return this; + } + limit(n: number) { + this.max = n; + return this; + } + + then(resolve: (value: Result) => T, reject?: (reason: unknown) => T) { + try { + return Promise.resolve(resolve(this.run())); + } catch (error) { + return reject ? Promise.resolve(reject(error)) : Promise.reject(error); + } + } + + private run(): Result { + const rows = this.db.tables[this.table]; + const spec = this.db.specs[this.table]; + const primary = columnsOf(spec.primaryKey); + this.db.log.push(`${this.op} ${this.table}`); + const matching = () => rows.filter((row) => this.filters.every((f) => f(row))); + const samePrimary = (a: Row, b: Row, columns = primary) => columns.every((column) => a[column] === b[column]); + /** The existing row that `candidate` would collide with, other than `except` (primary key, or a unique column that is not null). */ + const clash = (candidate: Row, except?: Row) => + rows.find( + (row) => + row !== except && + ((primary.every((column) => candidate[column] !== undefined) && samePrimary(row, candidate)) || (spec.unique ?? []).some((column) => candidate[column] != null && row[column] === candidate[column])), + ); + + const project = (row: Row): Row => (this.columns ? Object.fromEntries(this.columns.map((column) => [column, row[column]])) : { ...row }); + + if (this.op === "select") { + const found = matching(); + if (this.sort) { + const { column, ascending } = this.sort; + found.sort((a, b) => (ascending ? 1 : -1) * compare(a[column], b[column])); + } + return { data: found.slice(0, this.max).map(project), error: null }; + } + + if ((this.op === "insert" || this.op === "upsert") && this.db.users && typeof this.payload.user_id === "string" && !this.db.users.has(this.payload.user_id)) { + return { data: null, error: NO_USER(this.table) }; + } + + if (this.op === "insert") { + if (clash(this.payload)) return { data: null, error: DUPLICATE(this.table) }; + rows.push({ ...this.payload }); + return { data: [], error: null }; + } + + if (this.op === "upsert") { + const conflictColumns = this.options.onConflict ? columnsOf(this.options.onConflict) : primary; + const existing = rows.find((row) => samePrimary(row, this.payload, conflictColumns)); + if (existing) { + if (!this.options.ignoreDuplicates) { + const other = clash({ ...existing, ...this.payload }, existing); + if (other) return { data: null, error: DUPLICATE(this.table) }; + Object.assign(existing, this.payload); + } + } else { + if (clash(this.payload)) return { data: null, error: DUPLICATE(this.table) }; + rows.push({ ...this.payload }); + } + return { data: [], error: null }; + } + + const changed = matching(); + for (const row of changed) { + if (clash({ ...row, ...this.payload }, row)) return { data: null, error: DUPLICATE(this.table) }; + } + for (const row of changed) Object.assign(row, this.payload); + return { data: this.returning ? changed.map(project) : [], error: null }; + } +} + +export function billingDb() { + return new FakeSupabase({ + billing_customers: { primaryKey: "user_id", unique: ["paddle_customer_id"] }, + billing_subscriptions: { primaryKey: ["provider", "id"] }, + billing_transactions: { primaryKey: ["provider", "id"] }, + billing_adjustments: { primaryKey: ["provider", "id"] }, + billing_webhook_events: { primaryKey: ["provider", "event_id"] }, + }); +} diff --git a/tests/support/loader.mjs b/tests/support/loader.mjs new file mode 100644 index 0000000..056cc96 --- /dev/null +++ b/tests/support/loader.mjs @@ -0,0 +1,42 @@ +// Module hooks for the unit tests (node:test, no bundler): load the TypeScript sources directly (Node strips the types of .ts; .tsx is compiled +// with the TypeScript compiler), resolve the "@/..." alias and extensionless relative imports the way Next does, stub `server-only`, and turn CSS +// imports into empty modules. Everything else is the real code. +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const SUFFIXES = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]; + +function find(base) { + for (const suffix of SUFFIXES) { + const candidate = base + suffix; + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +export async function resolve(specifier, context, nextResolve) { + if (specifier === "server-only") return { url: "data:text/javascript,export default {};", shortCircuit: true }; + let base = null; + if (specifier.startsWith("@/")) base = path.join(ROOT, "src", specifier.slice(2)); + else if ((specifier.startsWith("./") || specifier.startsWith("../")) && context.parentURL?.startsWith("file:") && !path.extname(specifier)) { + base = path.resolve(path.dirname(fileURLToPath(context.parentURL)), specifier); + } + const found = base && find(base); + return nextResolve(found ? pathToFileURL(found).href : specifier, context); +} + +export async function load(url, context, nextLoad) { + if (url.startsWith("file:") && url.endsWith(".css")) return { format: "module", source: "export default {};", shortCircuit: true }; + if (url.startsWith("file:") && url.endsWith(".tsx")) { + const file = fileURLToPath(url); + const out = ts.transpileModule(readFileSync(file, "utf8"), { + fileName: file, + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true }, + }); + return { format: "module", source: out.outputText, shortCircuit: true }; + } + return nextLoad(url, context); +} diff --git a/tests/support/setup.mjs b/tests/support/setup.mjs new file mode 100644 index 0000000..4ce9843 --- /dev/null +++ b/tests/support/setup.mjs @@ -0,0 +1,4 @@ +// Registers the loader next to it. Used as `node --import ./tests/support/setup.mjs --test ...` (see the `test` script in package.json). +import { register } from "node:module"; + +register("./loader.mjs", import.meta.url);