Family confirmed as flat-price/capped-membership (not per-seat) — was previously framed as a choice, now decided. Added §1b: Stripe's native Coupons/Promotion Codes cover promotions entirely (one flag on the Checkout Session), no custom discount logic needed; code creation stays in Stripe's dashboard, an optional admin widget can list active codes read-only.
24 KiB
Stripe Integration Plan
Status: planning only — nothing in this doc is implemented yet
This plan builds on infrastructure that already exists in the repo (from an earlier security-audit pass), not a blank slate. Before touching anything, know what's already there:
Already built:
users.tierenum column (free | pro | family),packages/db/src/schema/users.ts:33users.stripeCustomerId(nullable, unique),users.ts:34— migration0013_damp_richard_fisk.sqlprocessed_stripe_eventstable (webhook dedup log),packages/db/src/schema/billing.ts:7-11— migration0024_moaning_roughhouse.sqltierDefinitionstable (per-tier numeric limits),packages/db/src/schema/tiers.ts:14-20, admin-editable via/admin/tiers+TierLimitsForm- A hand-rolled inbound webhook at
apps/web/app/api/webhooks/stripe/route.ts— manual HMAC-SHA256 signature verification (t=/v1=parsing,crypto.timingSafeEqual, 300s tolerance window), handling exactly two event types (checkout.session.completed,customer.subscription.deleted), everything else silently ignored STRIPE_WEBHOOK_SECRETenv var, read directly viaprocess.env, not throughsite-settings.tsapps/web/app/api/v1/admin/users/[id]/route.ts— a second, independent path that can setusers.tier(manual admin override), which will need to coexist with Stripe-driven tier changes without fighting them
Not built at all:
- The
stripenpm package — nothing in the repo imports it; the existing webhook route re-implements signature verification instead of usingstripe.webhooks.constructEvent - Checkout Session creation, Customer Portal, Price/Product IDs anywhere,
lib/stripe.ts, a Billing admin page, handling forsubscription.updated/invoice.payment_failed/ trial events
The plan below fills those gaps and asks you to make a handful of product decisions before code starts (marked DECISION NEEDED).
1. Scope & product decisions (answer before implementation starts)
These aren't engineering choices — they change what gets built. Flagging rather than guessing:
- DECISION NEEDED — Pricing amounts. What does Pro cost/month? Family? Annual discount? Stripe Prices are created once you know this.
- Family tier is multi-user, confirmed. One subscription, shared by several app accounts (Netflix/Spotify-Family model) — not one-tier-per-payer like Pro. This needs a new schema concept (§1a) and changes how a member's effective tier gets resolved. See below.
- DECISION NEEDED — Family member cap. How many accounts per family subscription? (Netflix Family = 5, Spotify Family = 6.) A flat number, enforced app-side — recommend 5 as a default, easy to change later since it's just a constant, not a schema value.
- DECISION NEEDED — Downgrade/cancellation behavior. Cancel immediately (lose Pro/Family features now) or at period end (keep access until the paid period runs out, matching what Stripe bills)? Recommend at period end — standard SaaS expectation, and Stripe's Customer Portal defaults to this. For Family specifically, also decide: if the owner cancels, do members lose access immediately at that point, or also ride out the period end? Recommend the latter for consistency.
- DECISION NEEDED — Trial period? None, or e.g. 14 days no card required? Affects Checkout Session config (
subscription_data.trial_period_days) and whethertiershould flip toprooptimistically before payment or wait forcheckout.session.completed. - Proration — Stripe handles this automatically on plan-switch (Pro↔Family); no schema work needed, just don't fight it by writing custom proration logic. Doesn't apply to adding/removing family members, since that's flat-price (§1a), not quantity-based.
Everything else below assumes: monthly + optional annual price per paid tier, flat-price Family (not per-seat billing), cancel-at-period-end, and trial handling deferred until the above is answered (the plumbing supports adding it later without further schema changes).
1a. Family groups — the multi-user piece (Netflix-style, decided)
This is genuinely new scope, not just a billing detail — Stripe only ever bills one Customer per subscription; grouping several app accounts under that one subscription is something Epicure has to model itself.
Decided: flat price, capped membership, no per-seat billing — one
Stripe Price for "Family" regardless of exact member count up to the cap
(§1, default 5), same shape as Netflix/Spotify Family. No Stripe API calls
on join/leave — membership is purely an app-side concern; Stripe only ever
sees the owner's one subscription. (Ruled out: per-seat/quantity-based
billing, where the subscription's quantity tracks member count and every
join/leave calls stripe.subscriptions.update — more correct if you wanted
to charge per head, but that's not what "Family" means here.)
New tables (packages/db/src/schema/billing.ts, alongside processedStripeEvents), same shape as the sharing tables you already have (collectionMembers, mealPlanMembers):
export const familyGroups = pgTable("family_groups", {
id: text("id").primaryKey(),
ownerId: text("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
stripeSubscriptionId: text("stripe_subscription_id"), // the owner's subscription — billing lives here, not per-member
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const familyMembers = pgTable("family_members", {
id: text("id").primaryKey(),
groupId: text("group_id").notNull().references(() => familyGroups.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(), // a user is in at most one family group
joinedAt: timestamp("joined_at").notNull().defaultNow(),
});
Tier resolution changes. Today apps/web/lib/tiers.ts:42-43 reads users.tier straight from the DB as the source of truth. With family groups, a member's effective tier is no longer just their own column — it's "family" if they're in a group whose owner has an active subscription, regardless of what their own users.tier sits at. checkAndIncrementTierLimit (and anywhere else users.tier is read for gating, e.g. getMessages's usage-quota-section in §7) needs a resolution step: look up group membership first, fall back to the user's own tier column if not in a group. Keep the owner's own users.tier as the real, webhook-driven value (that's what Stripe events update, per §5) — members don't get their own tier column changed at all, they're resolved dynamically through the group.
Invite flow — an owner needs a way to invite people into their family group (email invite or a shareable link, similar to the existing invites table/flow used for beta signups — apps/web/lib/invites.ts is a close precedent for the invite-token mechanics, though that system is signup-time only and would need adapting for "join an existing user into a group"). New endpoints: POST /api/v1/family/invite, POST /api/v1/family/join/{token}, DELETE /api/v1/family/members/{userId} (owner removes someone, or a member leaves).
What happens if the owner's subscription lapses (customer.subscription.deleted webhook) — every member in that group loses "family" status simultaneously, purely as a side effect of the tier-resolution fallback above (no per-member cleanup needed, since membership rows aren't what grants tier — the group's subscription status is). The group row itself can stay (in case they resubscribe) or get cleaned up — low-stakes choice, doesn't affect billing correctness either way.
1b. Promotions/discounts
Stripe already has this fully built (Coupons + Promotion Codes) — no custom discount logic needed. Two layers, both worth doing:
- Checkout-side (do this, it's one flag): pass
allow_promotion_codes: truewhen creating the Checkout Session (§5). Stripe then shows a "Add promotion code" field on the hosted Checkout page itself — percent-off, fixed-amount-off, free trial extension, redemption limits, expiry dates, first-time-customer-only, all handled by Stripe, none of it built by us. - Creating/managing codes: done in the Stripe Dashboard, not Epicure's admin — Stripe's Coupon/Promotion-code UI already covers this well, and duplicating it would be pure rebuild-the-wheel. No API integration required for this part at all.
- Optional — admin visibility page: if you want to see active promotions
without leaving Epicure, a read-only
/admin/billingwidget can list them viastripe.promotionCodes.list({ active: true })(a few lines, uses the samelib/stripe.tsclient from §2). This is display-only, reading Stripe's data — it does not need its own schema, table, or write path. Nice-to-have, not required for promotions to work.
So: promotions work the moment allow_promotion_codes: true ships with
Checkout — everything else here is optional polish.
2. Package & client setup
- Add
stripe(server SDK) toapps/web/package.json. Do not add@stripe/stripe-js/@stripe/react-stripe-js— Checkout/Portal are hosted redirects, no Stripe Elements needed for v1 (see §7 for why hosted over custom). - New
apps/web/lib/stripe.ts— singleStripeclient instance (mirrors the existing provider-factory pattern inlib/ai/factory.ts: one place that constructs the third-party client from a resolved secret key, everything else imports from here). - Replace the hand-rolled verifier in
apps/web/app/api/webhooks/stripe/route.tswithstripe.webhooks.constructEvent(rawBody, signature, secret). Same security properties (timing-safe compare, timestamp tolerance), less code to maintain, and gives typedStripe.Event/Stripe.Checkout.Session/etc. instead of the current loosely-typed inline interface at route.ts:71. Keep theprocessed_stripe_eventsdedup insert exactly as-is — it's correct and SDK-agnostic.
3. Secrets & config
Follow the existing site-settings.ts pattern (apps/web/lib/site-settings.ts) rather than plain env vars — it already does AES-256-GCM encryption for exactly this kind of admin-managed secret (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.), and it's the mechanism the admin UI in §6 will read/write.
- Add to
SiteSettingKeyunion:"STRIPE_SECRET_KEY","STRIPE_PUBLISHABLE_KEY","STRIPE_WEBHOOK_SECRET". - Add
"STRIPE_SECRET_KEY"and"STRIPE_WEBHOOK_SECRET"toSECRET_KEYS(encrypted at rest). Publishable key is not secret — store it plain, same as the VAPID public key is treated today. lib/stripe.ts's client construction callsgetSiteSetting("STRIPE_SECRET_KEY")(DB → env fallback, exactly like every other provider key already does) instead of readingprocess.envdirectly.- Update
.env.example's existing# Stripe (optional — webhook stub only)section: keepSTRIPE_WEBHOOK_SECRETas the bootstrap/self-hosted fallback, add commentedSTRIPE_SECRET_KEY=/STRIPE_PUBLISHABLE_KEY=for the same reason. Self-hosters without the admin UI set up yet still need an env-var path — don't remove it, just make DB-stored take precedence (matches currentgetSiteSettingfallback order). - Never let the secret key or webhook secret be readable from any API response, including admin ones — mirror how
getAllSiteSettingspresumably masks secret values today (verify this before adding Stripe keys to that list; if it doesn't mask, that's a pre-existing gap worth fixing first, not something to inherit).
4. Schema changes
Plus familyGroups/familyMembers from §1a — this section covers the
per-tier/per-user additions:
tierDefinitions (packages/db/src/schema/tiers.ts) — add:
stripeProductId: text("stripe_product_id"),
stripePriceIdMonthly: text("stripe_price_id_monthly"),
stripePriceIdYearly: text("stripe_price_id_yearly"), // nullable if no annual option
Nullable — the free row has none. This is how a webhook event (which carries a Price ID) maps back to a tier: tierDefinitions becomes the single source of truth for "which Stripe Price = which tier," looked up once and cached in memory per request (small table, 3 rows).
users — add:
stripeSubscriptionId: text("stripe_subscription_id"),
subscriptionStatus: pgEnum("subscription_status", ["active", "trialing", "past_due", "canceled", "incomplete"])(...).nullable(),
currentPeriodEnd: timestamp("current_period_end"),
Deliberately not a separate subscriptions table — the existing model is one-tier-per-user with no subscription history requirement anywhere else in the app. A join table would be over-engineering for a product that doesn't have multi-subscription users. If that ever changes (e.g. add-ons, multiple products per user), revisit then.
subscriptionStatus matters beyond just tier: a past_due user should probably keep Pro access for a grace period (Stripe retries the card automatically) rather than being instantly downgraded on invoice.payment_failed — that's a product call, not this plan's to make, but the column needs to exist either way to display "payment failed, update your card" in the UI (§7).
Generate + apply via the existing pnpm db:generate / pnpm db:migrate flow, same as every other schema change this session.
5. API routes
New, under apps/web/app/api/v1/billing/:
POST /api/v1/billing/checkout— body{ tier: "pro" | "family", interval: "month" | "year" }. Looks up the matchingstripePriceId*fromtierDefinitions, creates a Checkout Session (mode: "subscription",customer: existing stripeCustomerId ?? create one,success_url/cancel_urlback into the app,allow_promotion_codes: true— see §1b), returns{ url }for the client to redirect to. Gated byrequireSessionOrApiKey, rate-limited like other mutating routes (applyRateLimit).POST /api/v1/billing/portal— no body. Requiresusers.stripeCustomerIdto already exist (i.e., user has been through Checkout at least once); creates a Billing Portal Session, returns{ url }. This is where users self-serve cancel/upgrade/update card — don't rebuild that UI, Stripe's hosted portal already does it.GET /api/v1/billing/status— returns the current user's{ tier, subscriptionStatus, currentPeriodEnd, hasStripeCustomer }for the client UI in §7 to render without needing a webhook round-trip on every page load.
Rewrite apps/web/app/api/webhooks/stripe/route.ts to handle the full event set instead of two:
| Event | Action |
|---|---|
checkout.session.completed |
Set tier (looked up from the session's Price ID → tierDefinitions, not hardcoded to "pro" like today), stripeCustomerId, stripeSubscriptionId |
customer.subscription.updated |
Sync tier (plan switch), subscriptionStatus, currentPeriodEnd |
customer.subscription.deleted |
tier: "free", subscriptionStatus: "canceled" |
invoice.payment_failed |
subscriptionStatus: "past_due" — do not downgrade tier here, Stripe will retry and either recover (→ subscription.updated) or eventually cancel (→ subscription.deleted) |
invoice.paid |
Clear past_due back to active if it was set (belt-and-suspenders alongside subscription.updated) |
Every handler writes an auditLogs row (action: "billing.<event>"), same pattern already used for admin tier edits — gives you a history for support/debugging without querying Stripe's dashboard.
6. Admin panel
Add one nav entry to apps/web/app/admin/layout.tsx's adminNav array → apps/web/app/admin/billing/page.tsx.
Page contents:
- Connection status — is
STRIPE_SECRET_KEYconfigured (DB or env)? Test-mode or live-mode key (Stripe keys are prefixedsk_test_/sk_live_— detectable without an API call)? Link to configure, reusing the same settings-form pattern already used for AI provider keys in/admin/ai-config. - Price mapping — extend the existing
TierLimitsForm(apps/web/components/admin/tier-limits-form.tsx) with the three newstripe*fields per tier, saved through the samePATCH /api/v1/admin/tiers/{tier}route (widen its Zod schema andNUMERIC_FIELDS→include these as string fields). Keeps one form per tier instead of a second parallel UI. - Insights — computed from local DB (fast, no Stripe API call, no rate-limit exposure):
- Active subscriber count per tier (
count(*) from users where tier != 'free' group by tier) - MRR estimate:
active pro count × pro monthly price + active family count × family monthly price— price pulled fromtierDefinitionsor a small hardcoded display value if Stripe Prices aren't fetched live (fetching live pricing from Stripe on every admin page load is unnecessary; prices don't change often enough to justify the API call/latency) - Failed payments needing attention:
count(*) from users where subscriptionStatus = 'past_due', listed with links to each user's admin detail page - Recent billing events: last N rows from
auditLogsfiltered toaction LIKE 'billing.%'
- Active subscriber count per tier (
- Active promotions (optional, read-only) —
stripe.promotionCodes.list({ active: true }), displayed as a small table (code, discount, redemption count/limit, expiry). Creating/editing codes still happens in Stripe's own dashboard (§1b) — this widget is purely so you can glance at what's live without leaving Epicure. - Link out to Stripe Dashboard for anything deeper (full transaction history, disputes, tax, creating/editing promotion codes) — don't rebuild Stripe's own reporting, that's what their dashboard is for.
This matches the existing admin philosophy in this codebase: local DB for fast/cheap counts (see /admin/storage, /admin/page.tsx's overview stats), external dashboard link for anything that needs Stripe's own depth.
7. User-facing UI/UX
New page: /settings/billing (alongside the existing /settings/ai etc. — same route-group, same layout conventions).
Sections, top to bottom:
- Current plan card — tier name, price, renewal date (
currentPeriodEnd), status badge (reuse theBadge/color-variant pattern already used for tier/visibility badges elsewhere). Ifpast_due: a prominent warning with a "Manage billing" button (→ Portal) to fix the card — don't bury this. - Usage this month — this already exists!
apps/web/components/settings/usage-quota-section.tsx(built earlier this session) shows AI-calls/recipes/storage against tier limits with progress bars. Reuse it verbatim on this page instead of only on/settings/ai— it's exactly the "why upgrade" nudge a billing page wants, and it's already wired totierDefinitions/userUsage. - Plan comparison / upgrade cards — one card per tier (Free/Pro/Family), current plan visually distinguished (border/highlight), feature list pulled from
tierDefinitionsvalues formatted as copy ("50 recipes/month" vs "Unlimited"), monthly/annual toggle if annual pricing exists (§1), a primary button per non-current tier →POST /api/v1/billing/checkout→ redirect tosession.url. - Manage billing button (if
stripeCustomerIdexists) →POST /api/v1/billing/portal→ redirect. - Family group panel (only relevant once on the Family tier): if you're the owner — member list (avatar/name), an "Invite" button (email or copyable link, per §1a), a remove button per member. If you're a member (not owner) — who owns the group, a "Leave family" action, and no billing controls (owner manages that in Portal, member has no
stripeCustomerIdof their own from this subscription). Reuse the member-list UI patterns already built forcollectionMembers/mealPlanMemberssharing (apps/web/components/collections/*,apps/web/components/meal-plan/*) rather than inventing new list/avatar-row styling.
"Appealing and fluid" — concretely:
- No new animation library needed — this codebase already gets fluid-feeling transitions from Tailwind's
transition-*/animate-in/animate-pulseutilities and Base UI'sdata-starting-style/data-ending-stylehooks (seecomponents/ui/sheet.tsx), consistently across every dialog/sheet/dropdown already built. Match that, don't introduce framer-motion just for this page. - Skeleton/pulse loading state for the plan cards while
GET /billing/statusresolves (sameanimate-pulseplaceholder pattern used inexplore-page-content.tsx's search-loading skeleton). - Checkout/Portal redirects are full-page navigations by design (Stripe-hosted) — the "fluid" part is the return trip:
success_urlshould land back on/settings/billing?checkout=success, which shows an immediate optimistic "Welcome to Pro 🎉" state client-side (don't block on the webhook having landed yet — it usually beats the redirect back, but don't assume it) with aGET /billing/statuspoll (2–3 retries, short backoff) to confirm and clear the optimistic banner oncetieractually updated server-side. - Progress bars (usage section) and plan cards should use the same
Card/Progressprimitives already incomponents/ui/, not new one-off styling — visual consistency with the rest of Settings matters more than novelty here.
8. Security & correctness checklist
Carried over from patterns already established in this codebase, applied to the new surface:
- Webhook route: keep raw-body signature verification (SDK now, not hand-rolled) — never trust an unsigned request claiming to be Stripe.
- Idempotency: keep
processed_stripe_eventsdedup insert — Stripe retries webhooks, handlers must be safe to run twice. - Never let the client dictate
tierdirectly for a Stripe-driven change — only the webhook handler (server-side, signature-verified) writestierfrom Checkout/subscription events. The existing admin manual-override path (/api/v1/admin/users/[id]) stays as an explicit, audited escape hatch for support cases — document that using it while a Stripe subscription is active will get overwritten on the next webhook event, so support should cancel-in-Stripe-first if they want to downgrade someone. - Rate-limit
/billing/checkoutand/billing/portal(session-creation abuse / accidental double-submit) — sameapplyRateLimithelper already used everywhere else. - Checkout Session should set
client_reference_id/metadata.userIdso the webhook can resolve the user even beforestripeCustomerIdis set (first-time checkout). - Test with the Stripe CLI (
stripe listen --forward-to localhost:3000/api/webhooks/stripe) in dev before touching production keys — standard Stripe workflow, not specific to this app.
9. Rollout order
Suggested build order (each step shippable/testable on its own, matching this session's incremental-ship convention):
- Schema migration (§4) — no behavior change yet, safe to ship alone.
lib/stripe.ts+ site-settings keys (§2–3) — still no user-facing change.- Rewrite webhook route with real SDK + full event handling (§5) — testable via Stripe CLI against a manually-created test Product/Price, before any UI exists.
- Admin price-mapping UI (§6.2) — lets you wire real test-mode Price IDs without touching code again.
- Checkout/Portal API routes +
/settings/billingpage (§5, §7) — Pro works fully at this point; Family checkout works but sharing doesn't exist yet. Promotions (§1b) ship for free here too, it's the oneallow_promotion_codes: trueflag on the same Checkout Session call — no separate step. - Family groups:
familyGroups/familyMembersschema, invite/join/remove endpoints, tier-resolution change inlib/tiers.ts, the family panel in Settings (§1a, §7.5) — ship after Pro is proven working end-to-end, since it's the most novel piece and easiest to get subtly wrong (tier-resolution fallback logic especially). - Admin insights dashboard (§6.3) — pure read-side, can land anytime after step 3.
- Switch test-mode keys → live-mode keys as the very last step, after a full test-mode Checkout→webhook→tier-change dry run (Pro solo and Family group).