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
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):
that one subscription is something Epicure has to model itself.
- **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.
**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`):
@@ -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
- 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/`:
- **`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.
- **`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)
- 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.
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.
@@ -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.
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.
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).