Stripe Subscription Billing — Implementation Plan
Status: Draft — model selected: organization-level subscription · Author: AI Review · Date: 2026-07-06
Billing is a recurring Stripe subscription per organization (monthly / yearly), implemented directly against the Stripe API. The credit-based licensing model (prototyped in
deadcode/src/lib/clerk/CreditAllocator.tsx) is deferred as a future improvement — see §15 — and this plan's foundations are deliberately shaped so it can be incorporated seamlessly later.
1. Context
ROPA 2.0 is a multi-tenant B2B SaaS application. Each tenant is a Clerk Organization mapped to a MongoDB Organization document, identified by a stable shortName subdomain ({shortName}.rat.gd). The database already tracks a manual license window (licenseStart, licenseEnd, licenseCost) but has no real payment processing and no runtime enforcement.
The billing model
- One recurring Stripe Subscription per organization, with a monthly / yearly choice (one Product, two Prices).
- Billing is strictly org-scoped: one Stripe Customer = one Clerk Organization = one MongoDB Organization.
- The license window derives from the subscription: a webhook
syncSubscription()helper mirrors Stripe state onto the org (licenseEnd←current_period_end). licenseEndis the single source of truth for access control. Hard enforcement (theexpiredbanner and thecancelledmodal) is computed purely fromlicenseEndand time constants; the mirrored subscription flags only refine the cosmetic pre-expiry states (trial/expiring/active, §2). This is the key design rule that lets the credit model (§15) plug in later without touching enforcement: credits also just movelicenseEnd.
Why Stripe direct
Full-featured recurring billing: monthly/annual prices, proration, coupons, Stripe Tax, invoicing, the Customer Portal, dunning, and revenue reporting — with complete ownership of billing data (stripeCustomerId on the org, license state in MongoDB). The existing Clerk auth webhook (src/app/api/webhooks/clerk/route.ts) demonstrates the exact pattern the Stripe webhook will follow.
2. License Model Specification (normative)
Every later section refers to these rules.
Constants
All time periods and prices live in src/constants/constants.ts (day-based periods, matching the existing ACTIVITY_EXPIRATION_WARNING = 90 / CONTRACT_EXPIRATION_WARNING = 90 convention):
const LICENSE_TRIAL_PERIOD = 90; // days of trial licenseEnd granted at org creation
const LICENSE_EXPIRATION_WARNING = 90; // days before licenseEnd → status "expiring"
const LICENSE_CANCELLATION_GRACE = 90; // days after licenseEnd → status "cancelled"
const LICENSE_REMINDER_LEAD = 14; // days of lead time for email reminders (§13)
const LICENSE_PRICE_MONTHLY = 29; // EUR, tax-exclusive — placeholder, must match the Stripe monthly Price
const LICENSE_PRICE_YEARLY = 290; // EUR, tax-exclusive — placeholder, must match the Stripe yearly Price
No literal day/ms values may appear anywhere else — helper code derives milliseconds from these constants. The price constants are display-only (billing UI); the Stripe Prices in the dashboard remain the billing source of truth, and the two must be kept in sync.
VAT
Prices are tax-exclusive; Stripe Tax adjusts VAT per country at checkout (automatic_tax: { enabled: true }). B2B customers can enter their VAT ID (tax_id_collection: { enabled: true }) for EU reverse charge — organizationVatNumber already exists on the self-partner for pre-filling.
License statuses
Derived from licenseEnd (Unix ms), the constants above, and two flags mirrored from the subscription sub-document:
hasSubscription=subscription.stripeSubscriptionId != null(the org has ever subscribed)renewalExpected=subscription.statusisactive/trialingand notcancelAtPeriodEnd(the subscription will auto-renew)
| Status | Condition | Effect |
|---|---|---|
none | licenseEnd is 0 / absent | Treated like expired (banner) — see open question §16.4 |
trial | now ≤ licenseEnd and not hasSubscription | Normal operation; info banner (admins) with days remaining + subscribe prompt |
active | now ≤ licenseEnd and hasSubscription and (renewalExpected or licenseEnd − now > 90 days) | Normal operation |
expiring | 0 ≤ licenseEnd − now ≤ 90 days (LICENSE_EXPIRATION_WARNING) and hasSubscription and not renewalExpected | Warning banner (admins) |
expired | licenseEnd < now ≤ licenseEnd + 90 days | Error banner (all members); data still visible |
cancelled | now > licenseEnd + 90 days (LICENSE_CANCELLATION_GRACE) | No ROPA data is shown — a blocking warning modal appears instead (§9) |
Precedence: cancelled > expired > trial > expiring > active. Since LICENSE_TRIAL_PERIOD (90 d) equals LICENSE_EXPIRATION_WARNING (90 d), a never-subscribed org is trial for its entire window — the trial banner itself counts down the remaining days. expiring fires only when the license end is real: a healthy auto-renewing subscription always has licenseEnd within one billing cycle (≤ 31 days for monthly), so without the renewalExpected condition every monthly subscriber would show a permanent warning — instead, healthy subscribers stay active and the warning appears only once renewal is off (portal cancellation via cancelAtPeriodEnd, or past_due/unpaid/canceled payment state).
The trial window is granted at org creation: organizationService.createOrganizationFromJson sets the initial licenseEnd = now + LICENSE_TRIAL_PERIOD days (replacing today's hardcoded ~1-year default). Past licenseEnd, trial and paid orgs walk the same path — expired → cancelled modal.
Naming note: the cancelled license status is time-derived (grace period elapsed) and is distinct from Stripe's canceled subscription status. A customer who cancels in the portal keeps access until current_period_end, then walks down expired → cancelled like any lapsed org.
Subscription → license sync
- On activation/renewal:
licenseEnd = subscription.current_period_end × 1000; setlicenseStart = nowon first activation only. - On payment failure (
past_due/unpaid): change nothing —licenseEndsimply stops advancing and the org drifts throughexpiring→expired→cancelledon its own. - On subscription deletion: change nothing — same natural drift.
3. Schema Changes
Organization model additions (src/models/organization.ts)
stripeCustomerId: { type: String, trim: true, sparse: true, index: true },
subscription: {
stripeSubscriptionId: { type: String, trim: true, default: null },
stripePriceId: { type: String, trim: true, default: null }, // monthly or yearly
status: { // mirrors Stripe subscription status — feeds the trial/expiring/active distinction (§2); never used for access gating
type: String,
enum: ["active", "trialing", "past_due", "canceled", "unpaid", "incomplete", null],
default: null,
},
cancelAtPeriodEnd: { type: Boolean, default: false },
},
- Bump
schemaVersiondefault (13 → 14). No data migration — new fields have defaults; existinglicenseEndvalues keep working unchanged. licenseCostis deprecated as an input: kept as a historical/manual record; the subscription flow never writes it.isBlockedstays untouched (separate manual kill-switch).- Option-A readiness: the schema deliberately keeps license state (
licenseStart/licenseEnd) separate from subscription state. Addingcredits+creditLedgerlater (§15) is additive — no field here changes. - Update
docs/dev/data-models/organization.md(Phase 6).
4. License Status Helper
New file src/lib/billing/licenseStatus.ts — pure and isomorphic (no server-only, no mongoose imports), reading the §2 constants from @/constants; unit-testable and shared by service, dialog, banner, and the cancelled-modal gate:
licenseStatus(
licenseEnd: number | null | undefined,
hasSubscription: boolean,
renewalExpected: boolean,
now = Date.now(),
): "none" | "trial" | "active" | "expiring" | "expired" | "cancelled"
Both flags are derived from the subscription sub-document per §2 and passed in by the caller so the helper stays pure (no model imports).
Barrel src/lib/billing/index.ts. (When Option A lands, its extendLicenseEnd / coveredUntil helpers join this file — §15.)
5. New Environment Variables
STRIPE_SECRET_KEY=sk_... # server-only
STRIPE_WEBHOOK_SECRET=whsec_... # server-only — Stripe CLI or dashboard
# Price IDs (Dashboard → Products → "ROPA License")
STRIPE_PRICE_LICENSE_MONTHLY=price_...
STRIPE_PRICE_LICENSE_ANNUAL=price_...
# Email reminders (§13)
RESEND_API_KEY=re_... # server-only — auto-provisioned by the Resend Vercel Marketplace integration
CRON_SECRET=... # Vercel Cron auth for /api/cron/license-reminders
No NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY — Checkout and the Customer Portal are redirect-based; no Stripe.js on the client. Add to .env.local, the e2e env example, and Vercel project settings.
6. New Files & Routes
src/
constants/constants.ts # + LICENSE_EXPIRATION_WARNING, LICENSE_CANCELLATION_GRACE,
# LICENSE_PRICE_MONTHLY, LICENSE_PRICE_YEARLY
lib/
billing/ # single billing dir (Stripe client + domain logic)
index.ts
stripeClient.ts # lazy singleton (pattern: src/services/mongodb.ts)
licenseStatus.ts (+ .test.ts) # §4, Tier-1 tested
mongoose/
organizationSubscriptionCheckoutStart.ts (+ .test.ts) # "use server" actions,
organizationSubscriptionPortalStart.ts (+ .test.ts) # re-exported from index.ts
ui/
modals/OrgBilling.tsx # client dialog
modals/LicenseCancelledModal.tsx # blocking modal, replaces ROPA content (§9)
billing/LicenseStatusChip.tsx # salvaged from the prototype
MainPage/LicenseBanner.tsx # §9
services/
billingService.ts (+ tests) # barrel-exported from src/services/index.ts
emails/
LicenseTrialEnding.tsx # React Email templates (§13)
LicenseExpired.tsx
LicenseCancellationWarning.tsx
app/
[locale]/(app)/e/org/billing/page.tsx # server page (pattern: e/org/locales/page.tsx)
api/
webhooks/stripe/route.ts # sig verify → switch → syncSubscription
cron/license-reminders/route.ts # daily reminder scan (§13, CRON_SECRET-guarded)
messages/{en,es,ca,de,eu,fr,gl,it,nl,pt}.json # new "billing" namespace + editorg card keys
Modified: src/models/organization.ts (§3 + lastLicenseReminder, §13), src/lib/ui/grids/OrganizationSettingsGrid.tsx (settings card), src/app/[locale]/(app)/layout.tsx (banner + cancelled gate), public export routes (§9 cancelled gate), src/services/index.ts + src/lib/mongoose/index.ts (barrels), export/import services (§10), Vercel config (crons entry, §13).
7. Server-Side Flows
7a. Checkout start — organizationSubscriptionCheckoutStart(interval)
Thin "use server" action following src/lib/mongoose/organizationLanguageUpdate.ts: resolve shortName via getHostnameServer(), delegate to billingService.startSubscriptionCheckout(shortName, interval, origin):
- Admin guard via
src/lib/auth/requireOrgAccess.ts—fail()if the caller is not an org admin. fail()if the org already has an active subscription (changes go through the portal instead).- Resolve
interval("monthly" | "yearly") → price ID from env. - Lazy Stripe customer: if the org has no
stripeCustomerId,stripe.customers.create({ name: org.organizationName, metadata: { shortName, clerkOrganizationId } })and persist the id. stripe.checkout.sessions.create({ mode: "subscription", customer, line_items: [{ price, quantity: 1 }], client_reference_id: shortName, metadata: { shortName }, automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, customer_update: { address: "auto", name: "auto" }, success_url: "{origin}/{locale}/e/org/billing?checkout=success", cancel_url: "…?checkout=cancelled" })— Stripe Tax computes the per-country VAT on top of the tax-exclusive price (§2);customer_updateis required so Checkout can save the address automatic tax needs.- Return
ok({ url })— client doeswindow.location.href = url.
7b. Webhook — POST /api/webhooks/stripe
Mirror the Clerk route (src/app/api/webhooks/clerk/route.ts). Read the raw body with await req.text() — signature verification breaks if the body is JSON-parsed first — then stripe.webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET) and switch:
| Event | Action |
|---|---|
checkout.session.completed | syncSubscription() |
customer.subscription.updated | syncSubscription() — renewals, plan/interval changes, cancel_at_period_end |
customer.subscription.deleted | syncSubscription() — records status; licenseEnd untouched (natural drift, §2) |
invoice.payment_succeeded | syncSubscription() — refresh licenseEnd |
invoice.payment_failed | syncSubscription() — records past_due; log a warning |
billingService.syncSubscription(stripeSubscription):
- Resolve the org by
metadata.shortName(fallback:stripeCustomerId). - Update
subscription.*(id, price id, status,cancelAtPeriodEnd) and, per the §2 sync rules,licenseEnd/licenseStart. - Idempotency: the update is a plain state mirror — replaying an event writes the same values, so no dedup key is needed.
cacheInvalidate(shortName, …)— the layout/banner/modal read the org throughwithCache, so a stale cache would show stale license state.
Always return 200 for handled events, 400 on bad signature. Note: the endpoint must be reachable regardless of tenant subdomain — the handler resolves the org from metadata, not from the hostname.
7c. Customer Portal — organizationSubscriptionPortalStart()
Thin action → billingService.startPortalSession(shortName, origin): admin guard, then stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: "{origin}/{locale}/e/org/billing" }) → ok({ url }) → client redirects. The portal handles cancel, monthly↔yearly switch, payment-method updates, and invoice downloads — no custom UI.
8. UI Spec — Billing Section
Settings grid card
Append to settingsCards in src/lib/ui/grids/OrganizationSettingsGrid.tsx:
{ title, description, icon: CreditCardIcon, buttonText, href: "/e/org/billing" } — strings under the editorg namespace (editBilling, editBillingDesc, editBillingButtonText), mirroring the existing editLanguages* / editPartners* keys.
Billing page — src/app/[locale]/(app)/e/org/billing/page.tsx
Server component modeled on e/org/locales/page.tsx: getHostnameServer(), getTranslations("billing"), getOrgMembership(shortName) for isAdmin, and a new cached read billingService.getOrganizationBillingRO(shortName) returning only { licenseStart, licenseEnd, subscription } — deliberately not the whole org document.
OrgBilling dialog (client, src/lib/ui/modals/)
MUI Dialog (close → router.push("/e/org")), single scroll layout:
- Status header —
LicenseStatusChip(salvaged from the prototype'sStatusChip, extended with thetrialandcancelledstates) + formattedlicenseEnddate (dayjs). - No subscription yet — monthly/yearly price cards (two
Cards, same pattern as the prototype's package grid), amounts from theLICENSE_PRICE_MONTHLY/LICENSE_PRICE_YEARLYconstants with a "+ VAT" note (Stripe computes the country-specific VAT at checkout, §2) + subscribe button →organizationSubscriptionCheckoutStart(interval)→ redirect to Stripe. - Active subscription — current plan, renewal date,
cancelAtPeriodEndnotice if set, and a "Manage billing" button →organizationSubscriptionPortalStart()→ portal redirect.
Handle the ?checkout=success|cancelled search param with a toast (conventions: docs/dev/components/ui/toasts.md); success copy must say the license will update shortly (webhook latency). Non-admins: status visible, action buttons hidden. Repo conventions: consult mui-mcp before writing MUI code; dayjs for date formatting.
Option-A readiness: the dialog is sectioned (status / purchase / manage) so a future "credits" block (§15) slots in as an additional section without restructuring.
9. Enforcement: Banner & Cancelled Modal
Both are driven exclusively by licenseStatus(organization.licenseEnd) (§4) — never by Stripe state.
LicenseBanner (non-blocking)
Client component using useOrganization() (org + membership already in context), rendered in src/app/[locale]/(app)/layout.tsx inside OrganizationProvider, above the page content:
| Status | Who sees it | Severity |
|---|---|---|
trial | admins only | info — days remaining + subscribe prompt |
expiring (≤ 90 days, renewal not expected) | admins only | warning |
expired / none | all members | error |
"Manage billing" button (→ /e/org/billing) renders only for admins. Skip when organization.isDemo. Dismissible per browser session (sessionStorage).
LicenseCancelledModal (blocking)
When status is cancelled, no ROPA data is rendered — a warning modal appears instead:
- Gate location: the
(app)layout (src/app/[locale]/(app)/layout.tsx), after the org fetch. Oncancelled(and notisDemo), render the app shell without children (no dashboard, no activities, no OU tree) and mountLicenseCancelledModal— a non-dismissible MUIDialogexplaining that the license lapsed more thanLICENSE_CANCELLATION_GRACEdays ago. - Admin carve-out: the
/e/org/billingroute (and the modal's "Reactivate" button linking to it, admins only) must remain reachable — otherwise no one can fix the situation. Non-admins see a "contact your administrator" message. - Public surfaces: the public ROPA renderer and export routes (
src/app/[locale]/[id]/route.ts,…/pdf/route.ts) must apply the same check and return 403 forcancelledorgs — public visitors get no data and no modal, just the refusal. - i18n strings in the
billingnamespace across all 10 locales.
10. Export / Import & Data Integrity
Org JSON/CSV export–import round-trips organization documents (exportService, importService, csvService, zod src/lib/schemas/importSchema.ts).
Decision: stripeCustomerId and subscription are excluded from export and stripped/ignored on import — a tenant must not be able to graft a paid subscription state onto another org via an export file. Import preserves the existing org's billing fields, the same way licenseStart/licenseEnd handling is already asserted in importService tests.
Existing orgs need no backfill. New orgs created via organizationService.createOrganizationFromJson receive the 90-day trial window (licenseEnd = now + LICENSE_TRIAL_PERIOD, §2), replacing today's hardcoded ~1-year default.
11. Implementation Phases
Phase 0 — Stripe dashboard setup (0.5 d)
- Create Product "ROPA License" with a monthly and a yearly EUR Price (tax-exclusive, amounts matching the
LICENSE_PRICE_*constants, tax behavior "exclusive"). - Enable Stripe Tax (Settings → Tax): set origin address + tax registrations — VAT is computed per country at checkout.
- Configure the Customer Portal (Settings → Billing → Customer portal): allow cancel + price switch.
- Copy API keys + price IDs into env (
.env.local, Vercel). - Enable Stripe emails (Settings → Billing → Subscriptions and emails): receipts, failed-payment/dunning, expiring-card, and upcoming-renewal notices (§13).
Phase 1 — Foundation (1 d)
npm install stripe(server package only).LICENSE_TRIAL_PERIOD,LICENSE_EXPIRATION_WARNING,LICENSE_CANCELLATION_GRACE,LICENSE_PRICE_MONTHLY,LICENSE_PRICE_YEARLYinsrc/constants/constants.ts.- Schema additions +
schemaVersionbump (§3);createOrganizationFromJsoninitiallicenseEndfromLICENSE_TRIAL_PERIOD(§2). src/lib/billing/licenseStatus.tswith Tier-1 tests (all six statuses, boundary cases).src/lib/billing/stripeClient.ts— lazy singleton.
Phase 2 — Checkout + portal (1–1.5 d)
billingService.ts:startSubscriptionCheckout,startPortalSession,getOrganizationBillingRO; barrel export.organizationSubscriptionCheckoutStart+organizationSubscriptionPortalStartactions (+ Tier-2 tests); barrel export.
Phase 3 — Webhook (1 d)
src/app/api/webhooks/stripe/route.ts(§7b).billingService.syncSubscription+ cache invalidation (+ Tier-3 tests incl. replayed events).- Local dev:
stripe listen --forward-to localhost:3000/api/webhooks/stripe.
Phase 4 — Billing UI (1.5–2 d)
- Billing page +
OrgBillingdialog +LicenseStatusChip(§8). - Settings-grid card.
- i18n: new
billingnamespace +editorgkeys — all 10 locale files (en first, then translate; the app has no missing-key fallback).
Phase 5 — Enforcement (1–1.5 d)
LicenseBanner+ layout wiring (§9).LicenseCancelledModal+ layout gate + admin carve-out + public-route 403s (§9) + i18n.
Phase 6 — Hardening & e2e (1–1.5 d)
- Export/import exclusions (§10).
- Tier-5 e2e (§12).
Phase 7 — Email reminders (1–1.5 d)
- Install the Resend integration (Vercel Marketplace);
LICENSE_REMINDER_LEADconstant;lastLicenseReminderschema field. - React Email templates in
src/emails/(3 stages × locale rendering via thebillingnamespace). billingService.licenseRemindersRun()(+ Tier-3 tests: stage selection, dedup marker,isDemoexclusion)./api/cron/license-remindersroute +cronsentry in the Vercel config +CRON_SECRET.
Phase 8 — Build testing (0.5–1 d)
- Full local gate: lint, typecheck, and the complete Vitest suite (Tiers 1–4) green.
- Production build (
next build) with all new env vars present — catches missing envs at build time, server-only imports leaking into client components (stripeClient, Resend), and i18n message compilation across all 10 locales. - Preview deploy through the GitHub-Actions
vercel deployworkflow with Stripe test-mode keys; confirm thecronsentry registers and/api/webhooks/stripeis reachable on the preview URL (stripe listen --forward-tothe preview for a smoke test). - Smoke test on the preview: subscribe with a test card (see the Stripe test-cards reference), watch the webhook land,
licenseEndupdate, banner/modal states flip at fixture dates. - Only then: production env vars + live webhook endpoint registration (§12) and production deploy.
Phase 9 — Build user docs & dev docs (1–1.5 d)
User docs (Docusaurus user site, docs/user/ + website/i18n/{locale}/ translations, per docs/dev/documentation/localization.md):
- New
docs/user/billing.mdguide: the 90-day trial, subscribing (monthly vs yearly, prices + VAT), managing/cancelling via the Stripe portal, invoices/receipts, what the banner states mean, and what happens at expiry and cancellation (data hidden after the grace period — the GDPR-relevant part users must not be surprised by). - Translate the guide into the 9 other locales under
website/i18n/; add it tosidebars-user.ts.
Dev docs (docs/dev/):
- New
docs/dev/billing/billing.md: architecture doc — status ladder + precedence (§2), constants, checkout/portal/webhook flows,syncSubscription, reminder pipeline, Option-A readiness notes. - Update
docs/dev/data-models/organization.md(newsubscription,lastLicenseReminderfields, deprecatedlicenseCost),docs/dev/infrastructure/(env vars, cron), and this file's status; add both new pages tosidebars-dev.ts. - Verify both Docusaurus builds pass (
npm run buildandnpm run build:devinwebsite/) — MDX-safe content (braces/angle brackets inside code spans).
| Phase | Effort |
|---|---|
| 0 — Stripe setup | 0.5 d |
| 1 — Foundation | 1 d |
| 2 — Checkout + portal | 1–1.5 d |
| 3 — Webhook | 1 d |
| 4 — Billing UI + i18n | 1.5–2 d |
| 5 — Enforcement (banner + cancelled modal) | 1–1.5 d |
| 6 — Hardening / e2e | 1–1.5 d |
| 7 — Email reminders | 1–1.5 d |
| 8 — Build testing | 0.5–1 d |
| 9 — User & dev docs | 1–1.5 d |
| Total | ~10–13 days |
12. Webhook Registration Checklist & Testing
Registration
- Stripe Dashboard → Developers → Webhooks → Add endpoint.
- URL:
https://{domain}/api/webhooks/stripe— must be reachable regardless of tenant subdomain (org resolved from metadata, not hostname). - Events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_succeeded,invoice.payment_failed. - Copy signing secret to
STRIPE_WEBHOOK_SECRET. - Clerk webhook at
/api/webhooks/clerk— untouched.
Testing (per docs/dev/tests/ tier structure)
| Tier | What to test |
|---|---|
| Tier 1 (unit) | licenseStatus — all six statuses, the 90-day boundaries (LICENSE_EXPIRATION_WARNING, LICENSE_CANCELLATION_GRACE), trial/active/expiring resolution by hasSubscription + renewalExpected (a healthy monthly subscriber stays active; cancelAtPeriodEnd flips it to expiring), none handling; interval → price-ID mapping. |
| Tier 2 (server actions) | organizationSubscriptionCheckoutStart / organizationSubscriptionPortalStart with the standard mocks — admin-gate rejection, already-subscribed rejection, unknown interval. |
| Tier 3 (service) | syncSubscription: activation sets licenseStart once and mirrors licenseEnd; payment failure / deletion leave licenseEnd untouched; replayed events are harmless; org resolution fallback via stripeCustomerId. licenseRemindersRun: stage selection at the LICENSE_REMINDER_LEAD boundary, dedup via lastLicenseReminder, reset after renewal. |
| Tier 4 (component) | LicenseCancelledModal gating: cancelled org renders modal and no ROPA content; admin sees the billing link, member sees the contact-admin message. |
| Tier 5 (e2e) | Stripe test mode: stripe trigger checkout/subscription events (CLI) or a signed synthetic POST → assert licenseEnd in MongoDB → banner states at ±90-day fixtures → cancelled org shows modal and public export returns 403. |
13. Email Reminders
Reminders are split between what Stripe already sends and app-level lifecycle emails keyed to the §2 status ladder — Stripe cannot send the latter because it knows nothing about licenseEnd, the app-level trial, or the cancellation grace period.
Covered by Stripe (dashboard configuration only, Phase 0)
Receipts/invoices, failed-payment dunning (Smart Retries + update-your-card emails), expiring-card reminders, and upcoming-renewal notices — all enabled in Settings → Billing → Subscriptions and emails. No code.
App-level reminders (Resend + Vercel Cron)
- Provider: Resend via the Vercel Marketplace integration (env auto-provisioned, EU region, free tier ≈3k emails/month — ample). Templates as React Email components in
src/emails/, rendered per the org's default ROPA locale using the existingbillingi18n namespace. - Recipients: the org's Clerk admins (
getOrganizationMembershipList, same lookup asrequireOrgAccess). - Stages (lead time as a §2 constant,
LICENSE_REMINDER_LEAD = 14days):
| Stage | Trigger | Audience |
|---|---|---|
trial-ending | trial org, licenseEnd − now ≤ LICENSE_REMINDER_LEAD | admins |
expired | org just entered expired | admins |
cancellation-warning | licenseEnd + LICENSE_CANCELLATION_GRACE − now ≤ LICENSE_REMINDER_LEAD | admins |
- Dedup marker on the org document (additive, no migration):
lastLicenseReminder: { stage: String, sentAt: Number }— a stage is sent at most once perlicenseEndvalue (a renewal/extension resets the cycle). - Trigger: daily Vercel Cron →
GET /api/cron/license-reminders, declared in the Vercel config (cronsentry, e.g.0 6 * * *). Hobby-plan compatible: needs 1 of the 2 allowed jobs, once-daily is exactly the allowed frequency, and hour-imprecision is irrelevant here. Works with the GitHub-Actions CLI deploy flow (config ships with the deploy). The route accepts Vercel'sAuthorization: Bearer {CRON_SECRET}header or the existingx-admin-secret(manual trigger). Fallback if ever needed: a scheduled GitHub Actions workflow curling the same route. - Logic:
billingService.licenseRemindersRun()— scan orgs (excludingisDemo) computing status + stage, send via Resend, update the marker; idempotent by design (marker check), safe to re-run.
14. Future Work
- Per-seat pricing (pass Clerk member count as
quantity). - Stripe-managed trials (
trial_period_days) — largely superseded by the app-level 90-daytrialstatus (§2); revisit only if payment-method-upfront trials are wanted. - Credit-based licensing — see §15.
15. Future Improvement — Credit-Based Licensing (Option A, deferred)
The credit model from the deadcode/src/lib/clerk/CreditAllocator.tsx prototype remains the candidate second billing mode. Summary of the deferred design (full spec recoverable from this file's git history):
- 1 credit = 1 month of license for one organization; credits bought in volume-discounted packages via one-time Stripe Checkout (
mode: "payment"). - Purchases are user-scoped and spread evenly across all orgs the buyer administers; admins can redistribute unapplied credits across their orgs provided every org keeps license coverage.
- Applying N credits extends
licenseEndby N calendar months (extendLicenseEndhelper); schema gainscredits+ an auditablecreditLedger(idempotent webhook grants keyed by Stripe session id). - UI: package grid, apply stepper with live expiry preview, multi-org redistribute view — all already prototyped.
How this plan keeps the door open (Option-A readiness)
| Decision in this plan | Why it enables Option A |
|---|---|
licenseEnd is the sole access-control input (§1, §9) | Credits extend licenseEnd; banner/modal/statuses work unchanged |
Constants (time periods + prices) centralised in src/constants/constants.ts (§2) | Credit coverage math and package pricing reuse the same convention |
licenseStatus.ts is pure/isomorphic (§4) | extendLicenseEnd / coveredUntil join the same module |
Subscription state kept in its own subscription sub-document (§3) | credits / creditLedger are additive fields — no schema conflict |
| Webhook route = verify → switch → handler (§7b) | One-time-payment events (checkout.session.completed with mode: "payment") add new switch cases |
OrgBilling dialog is sectioned (§8) | A "credits" section slots in beside subscribe/manage |
| Export/import excludes billing fields (§10) | The exclusion list simply grows by two fields |
Both models can even coexist (subscription for steady tenants, credits for consultants managing many orgs) — a product decision for later.
16. Open Questions
- Final price amounts —
LICENSE_PRICE_MONTHLY = 29/LICENSE_PRICE_YEARLY = 290are placeholders; confirm the real tax-exclusive EUR amounts (VAT itself is decided: Stripe Tax, per country, §2). - Trial retroactivity — new orgs get the 90-day trial (
LICENSE_TRIAL_PERIOD); existing never-subscribed orgs keep their current (~1-year)licenseEnd. Should those be clamped to the new window? - Existing manually-licensed orgs — how are current
licenseCost > 0deals migrated (manual subscriptions in the dashboard vs staying manual)? nonestatus — orgs withlicenseEnd = 0are treated likeexpired(banner, data visible). Should they instead fall under thecancelledgate?- Demo orgs — exempt from banner and modal (
isDemo); confirm. - Cancelled-org data retention — the modal hides ROPA data; how long until data is archived/deleted (GDPR storage-limitation angle)?
17. File Reference (Existing Code to Reuse)
| Existing file | How it informs new code |
|---|---|
src/app/api/webhooks/clerk/route.ts | Template for the Stripe webhook (signature verify → switch → handlers) |
src/services/mongodb.ts | Singleton pattern for src/lib/billing/stripeClient.ts |
src/lib/auth/requireOrgAccess.ts | Admin guard for checkout/portal actions |
src/constants/constants.ts | Home of the §2 license time constants (existing *_EXPIRATION_WARNING convention) |
src/lib/mongoose/organizationLanguageUpdate.ts | Thin "use server" action template |
src/app/[locale]/(app)/e/org/locales/page.tsx | Server-page template for /e/org/billing |
src/app/[locale]/(app)/layout.tsx | Where the banner renders and the cancelled gate short-circuits |
src/lib/cache/withCache.ts + cacheInvalidate | Cached reads; every sync must invalidate |
deadcode/src/lib/clerk/CreditAllocator.tsx | StatusChip → LicenseStatusChip; price-card grid pattern; full salvage list applies when §15 lands |
src/lib/ui/buttongroups/ButtonStack.tsx | Subscribe / manage-billing buttons |
docs/dev/components/ui/toasts.md | Toast conventions for success/error feedback |