docs: settle Family plan as Netflix-style, add promotions section to Stripe plan

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.
This commit is contained in:
Arnaud
2026-07-18 00:34:46 +02:00
parent c8f4b50ef3
commit 4e9e23c080
+40 -17
View File
@@ -40,24 +40,20 @@ Everything else below assumes: monthly + optional annual price per paid tier, fl
--- ---
## 1a. Family groups — the multi-user piece ## 1a. Family groups — the multi-user piece (Netflix-style, decided)
This is genuinely new scope, not just a billing detail — Stripe only ever This is genuinely new scope, not just a billing detail — Stripe only ever
bills one Customer per subscription; grouping several *app accounts* under bills one Customer per subscription; grouping several *app accounts* under
that one subscription is something Epicure has to model itself. Two ways that one subscription is something Epicure has to model itself.
to charge for it (pick one — recommend the first):
- **Flat price, capped membership** (recommended): one Stripe Price for **Decided: flat price, capped membership, no per-seat billing** — one
"Family," regardless of exact member count up to the cap in the decision Stripe Price for "Family" regardless of exact member count up to the cap
above. No Stripe API calls needed when members join/leave — membership is (§1, default 5), same shape as Netflix/Spotify Family. No Stripe API calls
purely an app-side concern. Simpler, matches how Family plans read to on join/leave — membership is purely an app-side concern; Stripe only ever
users (a household, not per-head billing). sees the owner's one subscription. (Ruled out: per-seat/quantity-based
- **Per-seat (quantity-based)**: the subscription's `quantity` = member billing, where the subscription's `quantity` tracks member count and every
count; Stripe bills `quantity × price`. Requires calling join/leave calls `stripe.subscriptions.update` — more correct if you wanted
`stripe.subscriptions.update(id, { items: [{ id: itemId, quantity }] })` to charge per head, but that's not what "Family" means here.)
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`): **New tables** (`packages/db/src/schema/billing.ts`, alongside `processedStripeEvents`), same shape as the sharing tables you already have (`collectionMembers`, `mealPlanMembers`):
@@ -85,6 +81,32 @@ export const familyMembers = pgTable("family_members", {
--- ---
## 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 ## 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). - 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).
@@ -136,7 +158,7 @@ Generate + apply via the existing `pnpm db:generate` / `pnpm db:migrate` flow, s
New, under `apps/web/app/api/v1/billing/`: 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/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. - **`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. - **`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.
@@ -165,7 +187,8 @@ Add one nav entry to `apps/web/app/admin/layout.tsx`'s `adminNav` array → `app
- 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) - 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 - 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.%'` - 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. 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. 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.
@@ -211,7 +234,7 @@ Suggested build order (each step shippable/testable on its own, matching this se
2. `lib/stripe.ts` + site-settings keys (§23) — still no user-facing change. 2. `lib/stripe.ts` + site-settings keys (§23) — 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. 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. 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. 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). 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. 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). 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).