c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
218 lines
22 KiB
Markdown
218 lines
22 KiB
Markdown
# 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
|
||
|
||
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. Two ways
|
||
to charge for it (pick one — recommend the first):
|
||
|
||
- **Flat price, capped membership** (recommended): one Stripe Price for
|
||
"Family," regardless of exact member count up to the cap in the decision
|
||
above. No Stripe API calls needed when members join/leave — membership is
|
||
purely an app-side concern. Simpler, matches how Family plans read to
|
||
users (a household, not per-head billing).
|
||
- **Per-seat (quantity-based)**: the subscription's `quantity` = member
|
||
count; Stripe bills `quantity × price`. Requires calling
|
||
`stripe.subscriptions.update(id, { items: [{ id: itemId, quantity }] })`
|
||
every time someone joins/leaves, and Stripe will prorate the change
|
||
automatically. More correct if you want to charge per additional person,
|
||
more moving parts to keep in sync.
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
## 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), 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.<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:**
|
||
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. **Link out to Stripe Dashboard** for anything deeper (full transaction history, disputes, tax) — 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.
|
||
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).
|