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:
@@ -0,0 +1,7 @@
|
||||
CREATE TYPE "public"."subscription_status" AS ENUM('active', 'trialing', 'past_due', 'canceled', 'incomplete');--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "stripe_subscription_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "subscription_status" "subscription_status";--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "current_period_end" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "tier_definitions" ADD COLUMN "stripe_product_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "tier_definitions" ADD COLUMN "stripe_price_id_monthly" text;--> statement-breakpoint
|
||||
ALTER TABLE "tier_definitions" ADD COLUMN "stripe_price_id_yearly" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -435,6 +435,13 @@
|
||||
"when": 1784791587563,
|
||||
"tag": "0061_stormy_the_fallen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 62,
|
||||
"version": "7",
|
||||
"when": 1784805788971,
|
||||
"tag": "0062_mean_ikaris",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,12 @@ export const tierDefinitions = pgTable("tier_definitions", {
|
||||
aiCallsPerMonth: integer("ai_calls_per_month").notNull(),
|
||||
storageMb: integer("storage_mb").notNull(),
|
||||
maxPublicRecipes: integer("max_public_recipes").notNull(),
|
||||
// Null for the free tier (nothing to sell) and until an admin wires up
|
||||
// the matching Stripe Price in /admin/billing — this table is the single
|
||||
// source of truth mapping a Stripe Price back to a tier on checkout.
|
||||
stripeProductId: text("stripe_product_id"),
|
||||
stripePriceIdMonthly: text("stripe_price_id_monthly"),
|
||||
stripePriceIdYearly: text("stripe_price_id_yearly"),
|
||||
});
|
||||
|
||||
export const userUsage = pgTable("user_usage", {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
|
||||
export const tierEnum = pgEnum("tier", ["free", "pro", "family"]);
|
||||
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
|
||||
export const apiKeyScopeEnum = pgEnum("api_key_scope", ["full", "read"]);
|
||||
export const subscriptionStatusEnum = pgEnum("subscription_status", ["active", "trialing", "past_due", "canceled", "incomplete"]);
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -44,6 +45,13 @@ export const users = pgTable("users", {
|
||||
// self-serve path. See hasByokAccess() in lib/permissions.ts.
|
||||
isByokEnabled: boolean("is_byok_enabled").notNull().default(false),
|
||||
stripeCustomerId: text("stripe_customer_id").unique(),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
// Only the webhook handler (server-side, signature-verified) writes
|
||||
// these three — never trust a client to set its own tier. past_due
|
||||
// deliberately does NOT downgrade tier on its own (Stripe retries the
|
||||
// card automatically); only customer.subscription.deleted does.
|
||||
subscriptionStatus: subscriptionStatusEnum("subscription_status"),
|
||||
currentPeriodEnd: timestamp("current_period_end"),
|
||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||
locale: text("locale").notNull().default("en"),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
|
||||
|
||||
Reference in New Issue
Block a user