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:
+40
-17
@@ -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 (§2–3) — still no user-facing change.
|
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.
|
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).
|
||||||
|
|||||||
Reference in New Issue
Block a user