f2d423c8d7
§1c: Pro €4.99/mo (€39/yr), Family €8.99/mo (€69/yr), with reasoning — Stripe's fixed €0.25 fee makes sub-€3 pricing fee-inefficient, prices sized against the AI-call limits tierDefinitions already enforces (500/2000 calls) rather than competitor-matching, Family priced below 2× Pro so the household pitch actually holds up, and an explicit note to adjust price (not AI-call limits) once real usage-cost data exists.
285 lines
30 KiB
Markdown
285 lines
30 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:
|
||
|
||
- **Pricing amounts — recommended, see §1c.** Pro €4.99/mo (€39/yr), Family €8.99/mo (€69/yr). Still a decision to confirm, not locked — §1c has the full reasoning.
|
||
- **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 (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.
|
||
|
||
**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`):
|
||
|
||
```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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 1c. Recommended pricing (resolves the §1 "pricing amounts" decision)
|
||
|
||
A starting recommendation, not a locked-in number — see the "adjust after
|
||
launch" note at the end.
|
||
|
||
| Tier | Monthly | Annual | Annual discount |
|
||
|---|---|---|---|
|
||
| Free | €0 | — | — |
|
||
| Pro | **€4.99** | **€39** | ~35% off (39 vs 59.88) |
|
||
| Family | **€8.99** | **€69** | ~36% off (69 vs 107.88) |
|
||
|
||
**Why these numbers, not round-trip guesses:**
|
||
|
||
- **Stripe's fee floor makes very cheap pricing inefficient.** Domestic EEA rate is 1.5% + €0.25 (§10). At €0.99/month that flat €0.25 alone is >25% of revenue before the percentage even applies — pricing has to clear roughly €3–4 before the fixed component stops dominating. €4.99 clears it comfortably.
|
||
- **Priced against the AI cost the tier actually permits**, not against competitors. `tierDefinitions` already caps Pro at 500 AI calls/month, Family at 2000 (`packages/db/src/schema/tiers.ts`). Those calls cost real money once they leave OpenRouter's free tier — €4.99 needs to cover the *realistic* per-user AI spend plus Stripe's cut plus margin, not just "feel competitive." The actual per-call cost depends on which model users end up on (admin-configured default, or BYOK bypasses this entirely per `aiConfig.isByok` skip-quota logic already in `withAiQuota`) — **this is the part most likely to be wrong before you have real usage data**, not the psychology of the price itself.
|
||
- **Annual discount (~35%) is the standard SaaS anchor** — enough to visibly reward committing, not so steep it cannibalizes monthly revenue or makes monthly pricing look like a rip-off by comparison. Also improves cash flow (a year of revenue upfront) and reduces monthly churn-management overhead (11 fewer renewal events per converted customer per year).
|
||
- **Family priced below 2× Pro on purpose.** €8.99 vs €9.98 (2 × €4.99) — the whole pitch of a household plan is "cheaper than everyone buying Pro separately." If Family cost the same or more than 2 individual Pro subscriptions, nobody rational picks it over just having each person subscribe individually (or one person subscribing and not sharing at all, which the member cap in §1a doesn't prevent — sharing outside the app isn't something billing can stop, only the in-app group feature needs pricing that makes *joining the group* the obviously better option over each member paying separately).
|
||
- **Family's 4× AI-call allowance (2000 vs Pro's 500) for less than 2× the price** is intentional generosity, not an oversight — it's the concrete value-per-euro reason to pick Family over "everyone just buys their own Pro," beyond the flat convenience of one payment.
|
||
|
||
**Adjust after launch, and adjust the price — not the AI-call limits.** Limits are what users plan around; changing them after people are used to a number reads as a downgrade even if the price stays flat. Price is the variable meant to absorb reality once you have actual OpenRouter spend-per-active-user data — if real costs run higher than assumed here, raise Pro/Family prices (existing subscribers keep their price until they actively resubscribe/change plans, per Stripe's normal behavior — see §8's note on the admin manual-override path for edge cases), don't quietly shrink what €4.99 already promised someone.
|
||
|
||
---
|
||
|
||
## 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, `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.
|
||
|
||
**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. **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.
|
||
|
||
---
|
||
|
||
## 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. 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).
|
||
|
||
---
|
||
|
||
## 10. France legal/tax notes
|
||
|
||
**Not legal or tax advice** — this is a starting point for a conversation
|
||
with an actual French accountant (expert-comptable) before real money
|
||
moves through live-mode keys, not a substitute for one. Confirm the
|
||
specifics for your situation before launch; figures below are current as
|
||
of mid-2026 and can change.
|
||
|
||
- **Business structure**: for a solo launch, **auto-entrepreneur** (micro-entreprise) is the cheapest legal structure — no share capital, simplified accounting, charges (URSSAF) only apply to revenue actually invoiced, nothing owed at €0 revenue. Only worth moving to SASU/EURL if you want stricter personal/business asset separation, outside investment, or you exceed the auto-entrepreneur revenue ceiling.
|
||
- **VAT (TVA) thresholds, 2026, SaaS/subscriptions classified as "prestation de services"**:
|
||
- Franchise en base: **€37,500** revenue — below this, no VAT charged on French customers.
|
||
- Tolerance ceiling: **€41,250** — crossing this mid-year means VAT applies immediately from the 1st of that month; between the two thresholds you get until the following January 1st.
|
||
- Unchanged for 2026 (a threshold-unification reform was dropped by Parliament in late 2025).
|
||
- **Open question — cross-border EU customers**: the French franchise en base only covers domestic (France) sales. Selling subscriptions to customers in *other* EU countries falls under the EU's digital-services VAT regime (OSS — One-Stop-Shop), which has its own rules/threshold, separate from the French one above. **Needs accountant confirmation before enabling billing for non-French EU customers** — not resolved by this plan, flagging rather than guessing at numbers.
|
||
- **Stripe fees, France-domestic**: 1.5% + €0.25 per transaction (EEA cards). EEA corporate/commercial cards: 1.9% + €0.25. Non-EEA cards: +3.25% on top (~4.75% + €0.25 total). Card issued outside your Stripe account's country: +1.5%. Currency mismatch: +1–2%.
|
||
- **Stripe Tax** (optional automated VAT calculation/filing): 0.5% per transaction in jurisdictions where you're registered to collect tax, plus a small per-calculation API fee above the included quota. Worth it once billing spans multiple VAT jurisdictions and manual rate-tracking gets error-prone; skip it while VAT-exempt under the franchise en base.
|
||
|
||
Practical order: stay auto-entrepreneur + franchise en base (no VAT complexity) for the initial France-only launch, get the OSS/cross-border question answered before opening Checkout to other EU countries, and only evaluate Stripe Tax once that becomes relevant.
|