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:
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.72.0";
|
||||
export const APP_VERSION = "0.73.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,15 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.73.0",
|
||||
date: "2026-07-23 14:00",
|
||||
added: [
|
||||
"Real Stripe billing for Pro/Family: subscribe and manage your plan yourself at Settings -> Billing (Checkout + Stripe's hosted Customer Portal for cancel/upgrade/card updates), instead of only an admin being able to change your tier. Cancel takes effect at the end of the paid period.",
|
||||
"Admin -> Billing: connection status, subscriber counts, past-due payments needing attention, recent billing events. Admin -> Tier Limits gained Stripe Product/Price ID fields per tier.",
|
||||
],
|
||||
notes: "Family tier is purchasable solo but doesn't yet support inviting other accounts into a shared subscription — that's a separate, larger piece tracked in plans/STRIPE_PLAN.md, deliberately shipped after solo billing.",
|
||||
},
|
||||
{
|
||||
version: "0.72.0",
|
||||
date: "2026-07-23 09:30",
|
||||
|
||||
+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({
|
||||
|
||||
@@ -23,7 +23,10 @@ export type SiteSettingKey =
|
||||
| "GITEA_TOKEN"
|
||||
| "GITEA_REPO"
|
||||
| "GITEA_WEBHOOK_SECRET"
|
||||
| "USDA_API_KEY";
|
||||
| "USDA_API_KEY"
|
||||
| "STRIPE_SECRET_KEY"
|
||||
| "STRIPE_PUBLISHABLE_KEY"
|
||||
| "STRIPE_WEBHOOK_SECRET";
|
||||
|
||||
const SECRET_KEYS: SiteSettingKey[] = [
|
||||
"OPENAI_API_KEY",
|
||||
@@ -33,6 +36,8 @@ const SECRET_KEYS: SiteSettingKey[] = [
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_WEBHOOK_SECRET",
|
||||
"USDA_API_KEY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
];
|
||||
|
||||
export function isSecretKey(key: SiteSettingKey): boolean {
|
||||
@@ -77,6 +82,9 @@ export async function getAllSiteSettings(): Promise<Record<string, { value: stri
|
||||
"GITEA_REPO",
|
||||
"GITEA_WEBHOOK_SECRET",
|
||||
"USDA_API_KEY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_PUBLISHABLE_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
];
|
||||
|
||||
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Stripe from "stripe";
|
||||
import { getSiteSetting } from "@/lib/site-settings";
|
||||
|
||||
/** Single Stripe client construction point — mirrors lib/ai/factory.ts's
|
||||
* provider-factory pattern (one place resolves the secret from
|
||||
* site-settings, DB taking precedence over env, everything else imports
|
||||
* the resolved client from here). Returns null if unconfigured so callers
|
||||
* can fail with a clear "billing not configured" message rather than a
|
||||
* cryptic Stripe SDK error. */
|
||||
export async function getStripeClient(): Promise<Stripe | null> {
|
||||
const secretKey = await getSiteSetting("STRIPE_SECRET_KEY");
|
||||
if (!secretKey) return null;
|
||||
return new Stripe(secretKey);
|
||||
}
|
||||
|
||||
export function isStripeTestMode(secretKey: string): boolean {
|
||||
return secretKey.startsWith("sk_test_");
|
||||
}
|
||||
Reference in New Issue
Block a user