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:
+12
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user