feat: Stripe billing — subscription tiers, usage metering & customer portal - #11
Open
amal66 wants to merge 13 commits into
Open
feat: Stripe billing — subscription tiers, usage metering & customer portal#11amal66 wants to merge 13 commits into
amal66 wants to merge 13 commits into
Conversation
amal66
force-pushed
the
feat/stripe-billing
branch
6 times, most recently
from
July 6, 2026 00:26
fbfc22d to
d1e51a9
Compare
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
force-pushed
the
feat/stripe-billing
branch
from
July 10, 2026 02:56
d1e51a9 to
7cd7558
Compare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Verification
apps/api/src/lib/billing/__tests__): 3 files, 35 tests pass — including duplicate delivery, ledger failure, and retry-after-side-effect-failure coverage.main: 550 passed, 6 skipped (63 files passed, 3 skipped).tsc) passes.Billing is intentionally optional and self-host-compatible. Operational setup and limitations are documented in
docs/billing.mdand the billing ADR.Merge sequencing
Merge this PR before #12 so billing migration
20260711000000is deployed before developer-platform migration20260711000001.Credits & prior art
user_profiles.message_credits_usedfrom a no-op UI gauge into a server-side pre-call enforcement check. The fork's original credit-enforcement commit records fix: enforce monthly message-credit limit before chat LLM calls Open-Legal-Products/mike#157 as its precedent; the tier-based limits in this PR are layered on that same enforcement point (a layering fix: enforce monthly message-credit limit before chat LLM calls Open-Legal-Products/mike#157's own comments anticipated).🤖 Generated with Claude Code
https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC