feat: real Stripe billing -- Checkout, Customer Portal, admin billing dashboard (v0.73.0)

Implements plans/STRIPE_PLAN.md sections 1-9 for solo Pro/Family
billing (family multi-user sharing, section 1a, deliberately deferred
-- flagged in that plan as the most novel/error-prone piece).

Decisions locked in: cancel/downgrade at period end (Stripe Portal
default), no trial period.

- lib/stripe.ts: single client factory reading STRIPE_SECRET_KEY via
  site-settings (DB overrides env, same pattern as every other
  provider key in this codebase).
- Webhook route rewritten on the real `stripe` SDK
  (stripe.webhooks.constructEvent replaces the hand-rolled HMAC
  verifier) and now handles the full event set: checkout.session.completed,
  customer.subscription.{updated,deleted}, invoice.{payment_failed,paid}.
  past_due deliberately never downgrades tier on its own -- Stripe
  retries the card first, recovering via invoice.paid or eventually
  giving up via subscription.deleted. Every handler audit-logs under
  billing.<event>. Dedup via the existing processed_stripe_events
  table, unchanged.
- Schema: tierDefinitions gained stripe{ProductId,PriceIdMonthly,
  PriceIdYearly} (the lookup table mapping a Price back to a tier on
  checkout); users gained stripeSubscriptionId/subscriptionStatus/
  currentPeriodEnd.
- New POST /api/v1/billing/checkout (creates a subscription Checkout
  Session, allow_promotion_codes: true), POST /api/v1/billing/portal
  (Stripe's hosted self-serve cancel/upgrade/card-update), GET
  /api/v1/billing/status.
- /settings/billing: current plan + renewal date, past_due warning,
  usage-vs-limits (reuses the existing UsageQuotaSection), plan
  comparison cards with per-tier Checkout buttons, manage-billing
  button once a Stripe customer exists.
- /admin/billing: connection status (test/live mode detection),
  subscriber counts, past-due list, recent billing audit events, link
  to Stripe Dashboard. Tier Limits page extended with the three Stripe
  price fields per tier (own render branch, not the numeric+Unlimited-
  switch machinery the existing fields use).

Before going live: an admin needs to create real Products/Prices in
Stripe, enter the IDs on Tier Limits, and configure the Stripe-side
webhook endpoint -- all operational steps the plan always called for,
none of it code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-23 13:37:14 +02:00
parent 8d5787e56e
commit f871f4f588
30 changed files with 6880 additions and 116 deletions
+12 -1
View File
@@ -535,6 +535,14 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/users/me/feature-prefs", summary: "Get your feature visibility preferences", description: "Features with no saved row default to visible.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: FeaturePrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/feature-prefs", summary: "Set your feature visibility preferences", description: "Purely cosmetic (hides nav items) — does not restrict access to the underlying pages.", security, request: { body: { content: { "application/json": { schema: UpdateFeaturePrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/users/me/developer-access", summary: "Self-serve enable or disable developer access", description: "Enabling requires a paid tier (free at no extra charge) — free-tier users need an admin grant instead. Disabling is always allowed, regardless of tier or who originally granted it. Only covers webhooks/API keys — BYOK is a separate, admin-only permission.", security, request: { body: { content: { "application/json": { schema: z.object({ enabled: z.boolean() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), isDeveloper: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Free tier — upgrade or ask an admin", content: { "application/json": { schema: ApiErrorRef } } } } });
const BillingStatusRef = registry.register("BillingStatus", z.object({
tier: z.enum(["free", "pro", "family"]), subscriptionStatus: z.enum(["active", "trialing", "past_due", "canceled", "incomplete"]).nullable(),
currentPeriodEnd: z.string().datetime().nullable(), hasStripeCustomer: z.boolean(),
}));
registry.registerPath({ method: "get", path: "/api/v1/billing/status", summary: "Get your current tier, subscription status, and renewal date", security, responses: { 200: { description: "Status", content: { "application/json": { schema: BillingStatusRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/billing/checkout", summary: "Start a Stripe Checkout session to subscribe or switch plans", description: "Rate-limited: 10 req/min. Redirects to the returned url. Promotion codes are enabled on the Checkout page.", security, request: { body: { content: { "application/json": { schema: z.object({ tier: z.enum(["pro", "family"]), interval: z.enum(["month", "year"]) }) } }, required: true } }, responses: { 200: { description: "Checkout URL", content: { "application/json": { schema: z.object({ url: z.string() }) } } }, 400: { description: "Billing not configured, or no price set for that tier/interval", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/billing/portal", summary: "Open the Stripe Customer Portal to manage or cancel your subscription", description: "Rate-limited: 10 req/min. Requires having been through Checkout at least once.", security, responses: { 200: { description: "Portal URL", content: { "application/json": { schema: z.object({ url: z.string() }) } } }, 400: { description: "Billing not configured, or no billing account yet", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals, or a multi-day trend", description: "Pass `range` (7, 30, or 90) instead of `date` for a daily calorie/macro trend over that many days — days with nothing cooked appear with zero totals.", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC). Ignored if range is set."), range: z.enum(["7", "30", "90"]).optional().describe("Switches to trend mode: returns { range, days: [{date, calories, proteinG, carbsG, fatG}], goalCalories } instead of the single-day shape.") }) }, responses: { 200: { description: "Diary or trend", content: { "application/json": { schema: z.union([NutritionDiaryRef, NutritionTrendRef]) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -864,10 +872,13 @@ export function generateOpenApiSpec(): object {
const UpdateTierDefinitionBodyRef = registry.register("UpdateTierDefinitionBody", z.object({
maxRecipes: z.number().int().optional(), aiCallsPerMonth: z.number().int().optional(),
storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
}).describe("Each field must be a non-negative integer, or -1 for unlimited."));
stripeProductId: z.string().nullable().optional(), stripePriceIdMonthly: z.string().nullable().optional(),
stripePriceIdYearly: z.string().nullable().optional(),
}).describe("Numeric fields must be a non-negative integer, or -1 for unlimited. Stripe fields map a checkout Price back to this tier — null clears."));
const TierDefinitionRef = registry.register("TierDefinition", z.object({
tier: z.enum(["free", "pro", "family"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
storageMb: z.number().int(), maxPublicRecipes: z.number().int(),
stripeProductId: z.string().nullable(), stripePriceIdMonthly: z.string().nullable(), stripePriceIdYearly: z.string().nullable(),
}));
const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({