Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your domain>/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://<your domain>/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=...
35 changes: 35 additions & 0 deletions .github/workflows/unit.yml
Original file line number Diff line number Diff line change
@@ -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
179 changes: 179 additions & 0 deletions BILLING.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<KEY>_<INTERVAL>` and `POLAR_PRODUCT_<KEY>_<INTERVAL>` 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

Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-tunnel>/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://<your-tunnel>/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:
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions scripts/billing-check.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading