# 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.tier` enum column (`free | pro | family`), `packages/db/src/schema/users.ts:33` - `users.stripeCustomerId` (nullable, unique), `users.ts:34` — migration `0013_damp_richard_fisk.sql` - `processed_stripe_events` table (webhook dedup log), `packages/db/src/schema/billing.ts:7-11` — migration `0024_moaning_roughhouse.sql` - `tierDefinitions` table (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_SECRET` env var, read directly via `process.env`, not through `site-settings.ts` - `apps/web/app/api/v1/admin/users/[id]/route.ts` — a **second, independent** path that can set `users.tier` (manual admin override), which will need to coexist with Stripe-driven tier changes without fighting them **Not built at all:** - The `stripe` npm package — nothing in the repo imports it; the existing webhook route re-implements signature verification instead of using `stripe.webhooks.constructEvent` - Checkout Session creation, Customer Portal, Price/Product IDs anywhere, `lib/stripe.ts`, a Billing admin page, handling for `subscription.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 whether `tier` should flip to `pro` optimistically before payment or wait for `checkout.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`): ```ts 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: true` when 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/billing` widget can list them via `stripe.promotionCodes.list({ active: true })` (a few lines, uses the same `lib/stripe.ts` client 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) to `apps/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` — single `Stripe` client instance (mirrors the existing provider-factory pattern in `lib/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.ts` with `stripe.webhooks.constructEvent(rawBody, signature, secret)`. Same security properties (timing-safe compare, timestamp tolerance), less code to maintain, and gives typed `Stripe.Event`/`Stripe.Checkout.Session`/etc. instead of the current loosely-typed inline interface at route.ts:71. Keep the `processed_stripe_events` dedup 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 `SiteSettingKey` union: `"STRIPE_SECRET_KEY"`, `"STRIPE_PUBLISHABLE_KEY"`, `"STRIPE_WEBHOOK_SECRET"`. - Add `"STRIPE_SECRET_KEY"` and `"STRIPE_WEBHOOK_SECRET"` to `SECRET_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 calls `getSiteSetting("STRIPE_SECRET_KEY")` (DB → env fallback, exactly like every other provider key already does) instead of reading `process.env` directly. - Update `.env.example`'s existing `# Stripe (optional — webhook stub only)` section: keep `STRIPE_WEBHOOK_SECRET` as the bootstrap/self-hosted fallback, add commented `STRIPE_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 current `getSiteSetting` fallback order). - **Never** let the secret key or webhook secret be readable from any API response, including admin ones — mirror how `getAllSiteSettings` presumably 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: ```ts 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: ```ts 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 matching `stripePriceId*` from `tierDefinitions`, creates a Checkout Session (`mode: "subscription"`, `customer: existing stripeCustomerId ?? create one`, `success_url`/`cancel_url` back into the app, `allow_promotion_codes: true` — see §1b), returns `{ url }` for the client to redirect to. Gated by `requireSessionOrApiKey`, rate-limited like other mutating routes (`applyRateLimit`). - **`POST /api/v1/billing/portal`** — no body. Requires `users.stripeCustomerId` to 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."`), 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:** 1. **Connection status** — is `STRIPE_SECRET_KEY` configured (DB or env)? Test-mode or live-mode key (Stripe keys are prefixed `sk_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`. 2. **Price mapping** — extend the existing `TierLimitsForm` (`apps/web/components/admin/tier-limits-form.tsx`) with the three new `stripe*` fields per tier, saved through the same `PATCH /api/v1/admin/tiers/{tier}` route (widen its Zod schema and `NUMERIC_FIELDS`→include these as string fields). Keeps one form per tier instead of a second parallel UI. 3. **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 from `tierDefinitions` or 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 `auditLogs` filtered to `action LIKE 'billing.%'` 4. **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. 5. **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: 1. **Current plan card** — tier name, price, renewal date (`currentPeriodEnd`), status badge (reuse the `Badge`/color-variant pattern already used for tier/visibility badges elsewhere). If `past_due`: a prominent warning with a "Manage billing" button (→ Portal) to fix the card — don't bury this. 2. **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 to `tierDefinitions`/`userUsage`. 3. **Plan comparison / upgrade cards** — one card per tier (Free/Pro/Family), current plan visually distinguished (border/highlight), feature list pulled from `tierDefinitions` values 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 to `session.url`. 4. **Manage billing** button (if `stripeCustomerId` exists) → `POST /api/v1/billing/portal` → redirect. 5. **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 `stripeCustomerId` of their own from this subscription). Reuse the member-list UI patterns already built for `collectionMembers`/`mealPlanMembers` sharing (`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-pulse` utilities and Base UI's `data-starting-style`/`data-ending-style` hooks (see `components/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/status` resolves (same `animate-pulse` placeholder pattern used in `explore-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_url` should 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 a `GET /billing/status` poll (2–3 retries, short backoff) to confirm and clear the optimistic banner once `tier` actually updated server-side. - Progress bars (usage section) and plan cards should use the same `Card`/`Progress` primitives already in `components/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_events` dedup insert — Stripe retries webhooks, handlers must be safe to run twice. - Never let the client dictate `tier` directly for a Stripe-driven change — only the webhook handler (server-side, signature-verified) writes `tier` from 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/checkout` and `/billing/portal` (session-creation abuse / accidental double-submit) — same `applyRateLimit` helper already used everywhere else. - Checkout Session should set `client_reference_id`/`metadata.userId` so the webhook can resolve the user even before `stripeCustomerId` is 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): 1. Schema migration (§4) — no behavior change yet, safe to ship alone. 2. `lib/stripe.ts` + site-settings keys (§2–3) — still no user-facing change. 3. 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. 4. Admin price-mapping UI (§6.2) — lets you wire real test-mode Price IDs without touching code again. 5. Checkout/Portal API routes + `/settings/billing` page (§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 one `allow_promotion_codes: true` flag on the same Checkout Session call — no separate step. 6. Family groups: `familyGroups`/`familyMembers` schema, invite/join/remove endpoints, tier-resolution change in `lib/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). 7. Admin insights dashboard (§6.3) — pure read-side, can land anytime after step 3. 8. 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).