§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.
30 KiB
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.tierenum column (free | pro | family),packages/db/src/schema/users.ts:33users.stripeCustomerId(nullable, unique),users.ts:34— migration0013_damp_richard_fisk.sqlprocessed_stripe_eventstable (webhook dedup log),packages/db/src/schema/billing.ts:7-11— migration0024_moaning_roughhouse.sqltierDefinitionstable (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_SECRETenv var, read directly viaprocess.env, not throughsite-settings.tsapps/web/app/api/v1/admin/users/[id]/route.ts— a second, independent path that can setusers.tier(manual admin override), which will need to coexist with Stripe-driven tier changes without fighting them
Not built at all:
- The
stripenpm package — nothing in the repo imports it; the existing webhook route re-implements signature verification instead of usingstripe.webhooks.constructEvent - Checkout Session creation, Customer Portal, Price/Product IDs anywhere,
lib/stripe.ts, a Billing admin page, handling forsubscription.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 whethertiershould flip toprooptimistically before payment or wait forcheckout.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):
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: truewhen 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/billingwidget can list them viastripe.promotionCodes.list({ active: true })(a few lines, uses the samelib/stripe.tsclient 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.
tierDefinitionsalready 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 peraiConfig.isByokskip-quota logic already inwithAiQuota) — 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) toapps/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— singleStripeclient instance (mirrors the existing provider-factory pattern inlib/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.tswithstripe.webhooks.constructEvent(rawBody, signature, secret). Same security properties (timing-safe compare, timestamp tolerance), less code to maintain, and gives typedStripe.Event/Stripe.Checkout.Session/etc. instead of the current loosely-typed inline interface at route.ts:71. Keep theprocessed_stripe_eventsdedup 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
SiteSettingKeyunion:"STRIPE_SECRET_KEY","STRIPE_PUBLISHABLE_KEY","STRIPE_WEBHOOK_SECRET". - Add
"STRIPE_SECRET_KEY"and"STRIPE_WEBHOOK_SECRET"toSECRET_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 callsgetSiteSetting("STRIPE_SECRET_KEY")(DB → env fallback, exactly like every other provider key already does) instead of readingprocess.envdirectly.- Update
.env.example's existing# Stripe (optional — webhook stub only)section: keepSTRIPE_WEBHOOK_SECRETas the bootstrap/self-hosted fallback, add commentedSTRIPE_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 currentgetSiteSettingfallback order). - Never let the secret key or webhook secret be readable from any API response, including admin ones — mirror how
getAllSiteSettingspresumably 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:
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:
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 matchingstripePriceId*fromtierDefinitions, creates a Checkout Session (mode: "subscription",customer: existing stripeCustomerId ?? create one,success_url/cancel_urlback into the app,allow_promotion_codes: true— see §1b), returns{ url }for the client to redirect to. Gated byrequireSessionOrApiKey, rate-limited like other mutating routes (applyRateLimit).POST /api/v1/billing/portal— no body. Requiresusers.stripeCustomerIdto 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:
- Connection status — is
STRIPE_SECRET_KEYconfigured (DB or env)? Test-mode or live-mode key (Stripe keys are prefixedsk_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. - Price mapping — extend the existing
TierLimitsForm(apps/web/components/admin/tier-limits-form.tsx) with the three newstripe*fields per tier, saved through the samePATCH /api/v1/admin/tiers/{tier}route (widen its Zod schema andNUMERIC_FIELDS→include these as string fields). Keeps one form per tier instead of a second parallel UI. - 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 fromtierDefinitionsor 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
auditLogsfiltered toaction LIKE 'billing.%'
- Active subscriber count per tier (
- 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. - 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:
- Current plan card — tier name, price, renewal date (
currentPeriodEnd), status badge (reuse theBadge/color-variant pattern already used for tier/visibility badges elsewhere). Ifpast_due: a prominent warning with a "Manage billing" button (→ Portal) to fix the card — don't bury this. - 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 totierDefinitions/userUsage. - Plan comparison / upgrade cards — one card per tier (Free/Pro/Family), current plan visually distinguished (border/highlight), feature list pulled from
tierDefinitionsvalues 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 tosession.url. - Manage billing button (if
stripeCustomerIdexists) →POST /api/v1/billing/portal→ redirect. - 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
stripeCustomerIdof their own from this subscription). Reuse the member-list UI patterns already built forcollectionMembers/mealPlanMemberssharing (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-pulseutilities and Base UI'sdata-starting-style/data-ending-stylehooks (seecomponents/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/statusresolves (sameanimate-pulseplaceholder pattern used inexplore-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_urlshould 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 aGET /billing/statuspoll (2–3 retries, short backoff) to confirm and clear the optimistic banner oncetieractually updated server-side. - Progress bars (usage section) and plan cards should use the same
Card/Progressprimitives already incomponents/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_eventsdedup insert — Stripe retries webhooks, handlers must be safe to run twice. - Never let the client dictate
tierdirectly for a Stripe-driven change — only the webhook handler (server-side, signature-verified) writestierfrom 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/checkoutand/billing/portal(session-creation abuse / accidental double-submit) — sameapplyRateLimithelper already used everywhere else. - Checkout Session should set
client_reference_id/metadata.userIdso the webhook can resolve the user even beforestripeCustomerIdis 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):
- Schema migration (§4) — no behavior change yet, safe to ship alone.
lib/stripe.ts+ site-settings keys (§2–3) — still no user-facing change.- 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.
- Admin price-mapping UI (§6.2) — lets you wire real test-mode Price IDs without touching code again.
- Checkout/Portal API routes +
/settings/billingpage (§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 oneallow_promotion_codes: trueflag on the same Checkout Session call — no separate step. - Family groups:
familyGroups/familyMembersschema, invite/join/remove endpoints, tier-resolution change inlib/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). - Admin insights dashboard (§6.3) — pure read-side, can land anytime after step 3.
- 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.