Skip to content

feat: Stripe billing — subscription tiers, usage metering & customer portal - #11

Open
amal66 wants to merge 13 commits into
mainfrom
feat/stripe-billing
Open

feat: Stripe billing — subscription tiers, usage metering & customer portal#11
amal66 wants to merge 13 commits into
mainfrom
feat/stripe-billing

Conversation

@amal66

@amal66 amal66 commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

Adds optional Stripe subscriptions, Checkout/Portal flows, signed webhook handling, and tier-based message-credit limits. Billing remains disabled unless Stripe is configured: with no STRIPE_SECRET_KEY, everything degrades to the pre-billing behaviour (default credit allowance, inert billing routes), so self-hosters are unaffected.

The branch is reconciled with current main (the olp UI sync) via a merge commit; no conflicts and no billing code needed adaptation.

Review hardening

  • Webhook idempotency now fails closed when the ledger is unavailable.
  • A failed profile/credit update releases the event claim so Stripe retries can process it; transient failures can no longer become permanent false-successes.
  • Database write failures are surfaced instead of being logged as successful billing updates.

Verification

  • Billing suite (apps/api/src/lib/billing/__tests__): 3 files, 35 tests pass — including duplicate delivery, ledger failure, and retry-after-side-effect-failure coverage.
  • Full API test suite after reconciliation with current main: 550 passed, 6 skipped (63 files passed, 3 skipped).
  • API production build (tsc) passes.
  • Web production build (Next.js) passes, covering the new account Billing page and account layout tab.

Billing is intentionally optional and self-host-compatible. Operational setup and limitations are documented in docs/billing.md and the billing ADR.

Merge sequencing

Merge this PR before #12 so billing migration 20260711000000 is deployed before developer-platform migration 20260711000001.

Credits & prior art

🤖 Generated with Claude Code

https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC

@amal66
amal66 force-pushed the feat/stripe-billing branch 6 times, most recently from fbfc22d to d1e51a9 Compare July 6, 2026 00:26
amal66 and others added 8 commits July 9, 2026 19:43
WHY THIS MATTERS
Mike already meters usage: PR Open-Legal-Products#157 gave every user a monthly message budget
(`message_credits_used` / `credits_reset_date`) with a single hard-coded
`MONTHLY_CREDIT_LIMIT`. What was missing was a way to *change* that limit by
charging money — i.e. subscription tiers. This is the most-requested feature in
the fork ecosystem (fpvetleseter/mike shipped a full Stripe integration;
CaseMark and marklok built their own usage budgets), so it's worth doing once,
cleanly, upstream.

WHAT IS A "SINGLE SOURCE OF TRUTH" PLAN CATALOGUE
Rather than scattering "Pro means 1500 messages" across the codebase, every fact
about a tier — its display name, its Stripe Price ID, and its monthly credit
allowance — lives in one module (`lib/billing/plans.ts`). Other code asks that
module questions ("what's the limit for this tier?", "what price buys Pro?")
instead of hard-coding answers. One place to read, one place to change.

WHY SELF-HOSTING STAYS FIRST-CLASS
Mike is AGPL software many people run for themselves. Billing must be opt-in:
`isBillingEnabled()` is true only when `STRIPE_SECRET_KEY` is set. When it's
unset, `creditLimitForTier()` returns the generous self-host default for every
tier — byte-for-byte the pre-billing behaviour. A self-hoster who never touches
Stripe notices nothing.

HOW IT WORKS
- `getPlans()` builds the catalogue from `process.env` on each call (so it is
  trivially unit-testable and always consistent with the running environment).
- `creditLimitForTier(tier)` returns the per-tier allowance when billing is on,
  or `SELF_HOST_CREDIT_LIMIT` when it's off.
- `priceIdForTier(tier)` / `tierForPriceId(priceId)` are the forward/reverse
  mappings used by Checkout and the webhook respectively.
- The `stripe` SDK is added to `apps/api`; the optional env vars are declared in
  `lib/env.ts` and documented in `.env.example`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY THIS MATTERS
A subscription is only meaningful if it actually changes what the user can do.
This commit is the hinge between billing and the existing meter: instead of a
fixed `MONTHLY_CREDIT_LIMIT`, the limit enforced on every chat request is now
derived from the user's tier.

WHAT CHANGED, AND WHY IT'S SAFE
`checkMessageCredits()` already selected `tier` from `user_profiles` but ignored
it. It now calls `creditLimitForTier(tier)`. Because that function returns the
generous self-host default whenever billing is disabled, deployments without
Stripe behave exactly as before — the existing credits unit tests (which run
with billing disabled) still pass unchanged. `MONTHLY_CREDIT_LIMIT` is kept as a
backwards-compatible alias for that default so other callers and tests don't
break.

HOW IT WORKS
- `lib/credits.ts`: `const limit = creditLimitForTier(data.tier)` replaces the
  constant; the denial payload still reports the concrete `used`/`limit`.
- `modules/user/user.routes.ts`: the profile serializer computes
  `creditsRemaining` from the same tier-derived limit (and now also returns
  `messageCreditsLimit`), so the number shown in Settings always matches what is
  actually enforced — one source of truth, no drift.

Builds on PR Open-Legal-Products#157's credit enforcement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dling

WHY THIS MATTERS
Checkout happens on Stripe's servers, not ours — when a user finishes paying we
are not in the request path. Stripe tells us what happened out-of-band by
POSTing signed "events" to a webhook. This commit adds the Stripe client plus
the logic that turns those events into database changes. It is the heart of the
sync between Stripe (the source of truth for money) and Mike (the source of
truth for access).

WHAT IS WEBHOOK SIGNATURE VERIFICATION, AND WHY RAW BODY
A webhook URL is public, so anyone could forge a "you are now Enterprise" POST.
Stripe signs every request with an HMAC computed over the *exact bytes* it sent,
keyed by `STRIPE_WEBHOOK_SECRET`. We recompute that HMAC and reject mismatches.
Crucially the HMAC is over the raw bytes — if we parsed the JSON and
re-serialised it first, key order and whitespace would change and verification
would always fail. So `constructWebhookEvent()` operates on the raw Buffer (the
route mounts `express.raw`; see the routing commit).

WHAT IS IDEMPOTENCY, AND WHY PAYMENTS REQUIRE IT
Stripe guarantees *at-least-once* delivery and retries on timeout, so the same
event can arrive twice. Payment side effects must be safe to replay — we must
not, say, reset a user's credits twice for one renewal. `claimEvent()` inserts
the event id into `billing_events` (primary key); a duplicate insert fails the
unique constraint, which is our signal to skip. It fails open if the table is
missing so a not-yet-migrated DB still runs.

HOW IT WORKS
- `lib/billing/stripe.ts`: lazily constructs the SDK with a PINNED API version
  (so a restart never silently adopts a new, differently-shaped API),
  `getOrCreateCustomer()` (one Stripe customer per user, with `userId` stamped
  in customer metadata as a recovery path), and `tierFromSubscription()` /
  `snapshotFromSubscription()` mappers. A `__setStripeForTests` seam lets tests
  inject a fake.
- `lib/billing/webhook.ts`: verifies the signature, claims the event, then
  branches on `checkout.session.completed`,
  `customer.subscription.created|updated|deleted`, and `invoice.paid` (which
  resets the monthly credits using the existing columns). A non-live
  subscription maps to Free, so a cancellation auto-demotes the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY THIS MATTERS
This exposes the billing capability over HTTP so the web app (and any SDK) can
drive it: start a purchase, manage an existing subscription, and read current
usage.

WHY PRICES ARE RESOLVED SERVER-SIDE
`POST /billing/checkout` accepts a *tier* name, never a price. The server maps
tier → Stripe Price ID via `priceIdForTier()`. If the client could send a price,
a tampered request could check out at an attacker-chosen amount (e.g. $0). Tier
names are validated against a fixed allow-list with Zod; only tiers that have a
configured price are purchasable.

WHAT IS A CUSTOMER PORTAL SESSION
Rather than building our own "change plan / view invoices / cancel" UI (and
handling proration, payment methods, dunning ourselves), `POST /billing/portal`
creates a short-lived Stripe-hosted Customer Portal session and returns its URL.
Stripe owns that surface; we just redirect the user to it and they come back.

WHY THE WEBHOOK IS MOUNTED SPECIALLY
`POST /billing/webhook` is registered in `app.ts` with `express.raw` *before*
the global `express.json` parser, because signature verification needs the raw
bytes (see the webhook-lib commit). It is also placed before the general rate
limiter, since Stripe retries and can burst — we don't want to throttle
legitimate, signature-verified events. The handler verifies first (400 on
failure, so Stripe retries) and only then syncs state.

HOW IT WORKS
- `modules/billing/billing.routes.ts`: `billingRouter` (checkout/portal/
  subscription, all `requireAuth`) + `billingWebhookHandler`. Every route gates
  on `isBillingEnabled()` and returns `503 BILLING_DISABLED` when Stripe is
  absent. `GET /billing/subscription` combines the plan catalogue with the live
  credits read and tolerates pre-migration databases (42703 fallback).
- `routes/billing.ts`: thin compatibility re-export, matching the repo's
  module/route convention.
- `app.ts`: raw webhook route + `app.use("/billing", billingRouter)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY THIS MATTERS
The backend needs somewhere to remember each user's Stripe customer and
subscription, and somewhere to record which webhook events it has already
processed. This adds both to the schema snapshot and as a dated, re-runnable
migration for existing databases.

WHY COLUMNS ON user_profiles (NOT A SEPARATE TABLE)
A Mike user has exactly one Stripe customer and one subscription, the credit
check already reads `user_profiles` on every chat request, and the
`handle_new_user()` trigger already provisions a profile row with the right RLS.
Putting `stripe_customer_id`, `stripe_subscription_id`, `subscription_status`,
and `subscription_current_period_end` directly on `user_profiles` avoids a join
on the hot path and reuses existing provisioning. The trade-off (one
subscription per user) is documented in ADR 0002.

WHAT IS THE billing_events LEDGER
`billing_events(event_id primary key, …)` is the idempotency store described in
the webhook commit: the webhook inserts each event id and skips duplicates. A
per-row lifecycle that genuinely differs from the user profile, so it earns its
own table.

WHY RLS + REVOKE
Following the repo's deny-all convention (20260524000000_rls_deny_all.sql),
`billing_events` has RLS enabled and `anon`/`authenticated` grants revoked.
Billing data is backend-only; the API uses the service-role key, which bypasses
RLS. Every migration statement is guarded (IF NOT EXISTS / existence checks) so
it is safe to re-run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY THIS MATTERS
Billing code touches money and access, so the branching logic must be pinned by
tests — especially the safety-critical paths: "billing disabled falls back to
the self-host limit", "a cancelled subscription demotes to Free", and "a forged
webhook is rejected". Tests also let a reviewer learn the intended behaviour by
reading examples.

WHAT IS MOCKED, AND WHY NOT THE NETWORK
We never hit Stripe's real API. The Stripe SDK is injected via the
`__setStripeForTests` seam, and Supabase is a small in-memory query-builder
mock. This keeps the tests fast, deterministic, and runnable offline in CI.

HOW IT WORKS
- plans.test.ts — plan → credit-limit resolution (enabled vs disabled), tier ↔
  price-id mapping, env overrides, and that Free is never purchasable.
- stripe.test.ts — tier mapping from a Stripe subscription across active /
  trialing / canceled / past_due / unknown-price, plus snapshot projection.
- webhook.test.ts — signature verification (valid / missing header / tampered /
  unconfigured secret), idempotency (duplicate event id is skipped),
  invoice.paid credit reset, subscription upgrade & cancellation demotion,
  checkout completion, and unknown-event no-op.

33 new tests; the full apps/api suite stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY THIS MATTERS
Users need to see what plan they're on, how much of their allowance they've
used, and a way to upgrade or manage their subscription — without leaving the
app. This adds that surface and the typed client methods behind it.

WHY THE CLIENT NEVER SENDS A PRICE
`createCheckoutSession(tier)` sends only a tier name; the server resolves it to
a Stripe price. The client cannot influence the amount charged. This mirrors the
server-side guarantee and keeps the trust boundary in one place.

HOW IT WORKS
- `@mike/api-client`: adds `getSubscription()`, `createCheckoutSession(tier)`,
  and `createBillingPortalSession()` plus the `SubscriptionInfo` /
  `BillingPlanOption` types, reusing the existing `apiRequest` helper.
- `account/billing/page.tsx`: fetches `/billing/subscription`, renders the
  current plan, a usage progress bar (amber near the cap, red at the cap), the
  reset date, per-tier upgrade buttons, and a "Manage subscription" button that
  redirects to the Customer Portal. Upgrade/portal buttons redirect to the
  Stripe-hosted URL returned by the API.
- Graceful degradation: when `billingEnabled` is false (self-hosted, no Stripe),
  the page shows a plain "billing is not enabled" notice instead of upgrade
  controls. A new "Billing" tab is added to the account layout.

Styling reuses the existing account components (`AccountSection`, the glass
section/button classes) so it matches the rest of Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY THIS MATTERS
A feature that touches money and self-hosting needs its reasoning written down,
not just its code. Good docs let a reviewer evaluate the design, let an operator
run it safely, and let a future maintainer understand why it looks the way it
does.

WHAT IS AN ADR
An Architecture Decision Record captures a decision, the context that forced it,
the alternatives weighed, and the consequences accepted. `docs/adr/0002-stripe-
billing.md` records why we chose tier subscriptions over usage-based metered
billing, webhooks over polling, and columns-on-user_profiles over a separate
billing table — plus the full security model. (It's numbered 0002 to avoid
colliding with a concurrently developed SDK ADR taking 0001.)

HOW IT WORKS
- `docs/billing.md`: operator + user guide — env vars, setting up products/
  prices in Stripe, running the webhook locally with the Stripe CLI
  (`stripe listen --forward-to`), how tiers map to credit limits, and how
  self-hosters disable billing.
- `docs/api.md` and `docs/architecture.md`: short notes pointing at the new
  billing surface and the ADR.
- `PR_BODY.md`: the full pull-request description — motivation citing the fork
  evidence (fpvetleseter, CaseMark, marklok) and PR Open-Legal-Products#157, an ASCII data-flow
  diagram (checkout → webhook → tier/credit update), the security model, testing
  notes, a local Stripe-test-mode walkthrough, and an explicit future-work list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@amal66
amal66 force-pushed the feat/stripe-billing branch from d1e51a9 to 7cd7558 Compare July 10, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant