diff --git a/STRIPE_PLAN.md b/STRIPE_PLAN.md index 413af5b..af6317d 100644 --- a/STRIPE_PLAN.md +++ b/STRIPE_PLAN.md @@ -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 (§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. +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).