Skip to main content

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 (licenseEndcurrent_period_end).
  • licenseEnd is the single source of truth for access control. Hard enforcement (the expired banner and the cancelled modal) is computed purely from licenseEnd and 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 move licenseEnd.

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.status is active/trialing and not cancelAtPeriodEnd (the subscription will auto-renew)
StatusConditionEffect
nonelicenseEnd is 0 / absentTreated like expired (banner) — see open question §16.4
trialnow ≤ licenseEnd and not hasSubscriptionNormal operation; info banner (admins) with days remaining + subscribe prompt
activenow ≤ licenseEnd and hasSubscription and (renewalExpected or licenseEnd − now > 90 days)Normal operation
expiring0 ≤ licenseEnd − now ≤ 90 days (LICENSE_EXPIRATION_WARNING) and hasSubscription and not renewalExpectedWarning banner (admins)
expiredlicenseEnd < now ≤ licenseEnd + 90 daysError banner (all members); data still visible
cancellednow > 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 — expiredcancelled 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 expiredcancelled like any lapsed org.

Subscription → license sync

  • On activation/renewal: licenseEnd = subscription.current_period_end × 1000; set licenseStart = now on first activation only.
  • On payment failure (past_due / unpaid): change nothing — licenseEnd simply stops advancing and the org drifts through expiringexpiredcancelled on 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 schemaVersion default (13 → 14). No data migration — new fields have defaults; existing licenseEnd values keep working unchanged.
  • licenseCost is deprecated as an input: kept as a historical/manual record; the subscription flow never writes it.
  • isBlocked stays untouched (separate manual kill-switch).
  • Option-A readiness: the schema deliberately keeps license state (licenseStart/licenseEnd) separate from subscription state. Adding credits + creditLedger later (§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.tspure 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):

  1. Admin guard via src/lib/auth/requireOrgAccess.tsfail() if the caller is not an org admin.
  2. fail() if the org already has an active subscription (changes go through the portal instead).
  3. Resolve interval ("monthly" | "yearly") → price ID from env.
  4. Lazy Stripe customer: if the org has no stripeCustomerId, stripe.customers.create({ name: org.organizationName, metadata: { shortName, clerkOrganizationId } }) and persist the id.
  5. 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_update is required so Checkout can save the address automatic tax needs.
  6. Return ok({ url }) — client does window.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:

EventAction
checkout.session.completedsyncSubscription()
customer.subscription.updatedsyncSubscription() — renewals, plan/interval changes, cancel_at_period_end
customer.subscription.deletedsyncSubscription() — records status; licenseEnd untouched (natural drift, §2)
invoice.payment_succeededsyncSubscription() — refresh licenseEnd
invoice.payment_failedsyncSubscription() — records past_due; log a warning

billingService.syncSubscription(stripeSubscription):

  1. Resolve the org by metadata.shortName (fallback: stripeCustomerId).
  2. Update subscription.* (id, price id, status, cancelAtPeriodEnd) and, per the §2 sync rules, licenseEnd / licenseStart.
  3. Idempotency: the update is a plain state mirror — replaying an event writes the same values, so no dedup key is needed.
  4. cacheInvalidate(shortName, …) — the layout/banner/modal read the org through withCache, 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:

  1. Status headerLicenseStatusChip (salvaged from the prototype's StatusChip, extended with the trial and cancelled states) + formatted licenseEnd date (dayjs).
  2. No subscription yet — monthly/yearly price cards (two Cards, same pattern as the prototype's package grid), amounts from the LICENSE_PRICE_MONTHLY / LICENSE_PRICE_YEARLY constants with a "+ VAT" note (Stripe computes the country-specific VAT at checkout, §2) + subscribe button → organizationSubscriptionCheckoutStart(interval) → redirect to Stripe.
  3. Active subscription — current plan, renewal date, cancelAtPeriodEnd notice 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:

StatusWho sees itSeverity
trialadmins onlyinfo — days remaining + subscribe prompt
expiring (≤ 90 days, renewal not expected)admins onlywarning
expired / noneall memberserror

"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. On cancelled (and not isDemo), render the app shell without children (no dashboard, no activities, no OU tree) and mount LicenseCancelledModal — a non-dismissible MUI Dialog explaining that the license lapsed more than LICENSE_CANCELLATION_GRACE days ago.
  • Admin carve-out: the /e/org/billing route (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 for cancelled orgs — public visitors get no data and no modal, just the refusal.
  • i18n strings in the billing namespace 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)

  1. npm install stripe (server package only).
  2. LICENSE_TRIAL_PERIOD, LICENSE_EXPIRATION_WARNING, LICENSE_CANCELLATION_GRACE, LICENSE_PRICE_MONTHLY, LICENSE_PRICE_YEARLY in src/constants/constants.ts.
  3. Schema additions + schemaVersion bump (§3); createOrganizationFromJson initial licenseEnd from LICENSE_TRIAL_PERIOD (§2).
  4. src/lib/billing/licenseStatus.ts with Tier-1 tests (all six statuses, boundary cases).
  5. src/lib/billing/stripeClient.ts — lazy singleton.

Phase 2 — Checkout + portal (1–1.5 d)

  1. billingService.ts: startSubscriptionCheckout, startPortalSession, getOrganizationBillingRO; barrel export.
  2. organizationSubscriptionCheckoutStart + organizationSubscriptionPortalStart actions (+ Tier-2 tests); barrel export.

Phase 3 — Webhook (1 d)

  1. src/app/api/webhooks/stripe/route.ts (§7b).
  2. billingService.syncSubscription + cache invalidation (+ Tier-3 tests incl. replayed events).
  3. Local dev: stripe listen --forward-to localhost:3000/api/webhooks/stripe.

Phase 4 — Billing UI (1.5–2 d)

  1. Billing page + OrgBilling dialog + LicenseStatusChip (§8).
  2. Settings-grid card.
  3. i18n: new billing namespace + editorg keys — all 10 locale files (en first, then translate; the app has no missing-key fallback).

Phase 5 — Enforcement (1–1.5 d)

  1. LicenseBanner + layout wiring (§9).
  2. LicenseCancelledModal + layout gate + admin carve-out + public-route 403s (§9) + i18n.

Phase 6 — Hardening & e2e (1–1.5 d)

  1. Export/import exclusions (§10).
  2. Tier-5 e2e (§12).

Phase 7 — Email reminders (1–1.5 d)

  1. Install the Resend integration (Vercel Marketplace); LICENSE_REMINDER_LEAD constant; lastLicenseReminder schema field.
  2. React Email templates in src/emails/ (3 stages × locale rendering via the billing namespace).
  3. billingService.licenseRemindersRun() (+ Tier-3 tests: stage selection, dedup marker, isDemo exclusion).
  4. /api/cron/license-reminders route + crons entry in the Vercel config + CRON_SECRET.

Phase 8 — Build testing (0.5–1 d)

  1. Full local gate: lint, typecheck, and the complete Vitest suite (Tiers 1–4) green.
  2. 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.
  3. Preview deploy through the GitHub-Actions vercel deploy workflow with Stripe test-mode keys; confirm the crons entry registers and /api/webhooks/stripe is reachable on the preview URL (stripe listen --forward-to the preview for a smoke test).
  4. Smoke test on the preview: subscribe with a test card (see the Stripe test-cards reference), watch the webhook land, licenseEnd update, banner/modal states flip at fixture dates.
  5. 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):

  1. New docs/user/billing.md guide: 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).
  2. Translate the guide into the 9 other locales under website/i18n/; add it to sidebars-user.ts.

Dev docs (docs/dev/):

  1. New docs/dev/billing/billing.md: architecture doc — status ladder + precedence (§2), constants, checkout/portal/webhook flows, syncSubscription, reminder pipeline, Option-A readiness notes.
  2. Update docs/dev/data-models/organization.md (new subscription, lastLicenseReminder fields, deprecated licenseCost), docs/dev/infrastructure/ (env vars, cron), and this file's status; add both new pages to sidebars-dev.ts.
  3. Verify both Docusaurus builds pass (npm run build and npm run build:dev in website/) — MDX-safe content (braces/angle brackets inside code spans).
PhaseEffort
0 — Stripe setup0.5 d
1 — Foundation1 d
2 — Checkout + portal1–1.5 d
3 — Webhook1 d
4 — Billing UI + i18n1.5–2 d
5 — Enforcement (banner + cancelled modal)1–1.5 d
6 — Hardening / e2e1–1.5 d
7 — Email reminders1–1.5 d
8 — Build testing0.5–1 d
9 — User & dev docs1–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)

TierWhat 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 existing billing i18n namespace.
  • Recipients: the org's Clerk admins (getOrganizationMembershipList, same lookup as requireOrgAccess).
  • Stages (lead time as a §2 constant, LICENSE_REMINDER_LEAD = 14 days):
StageTriggerAudience
trial-endingtrial org, licenseEnd − now ≤ LICENSE_REMINDER_LEADadmins
expiredorg just entered expiredadmins
cancellation-warninglicenseEnd + LICENSE_CANCELLATION_GRACE − now ≤ LICENSE_REMINDER_LEADadmins
  • Dedup marker on the org document (additive, no migration): lastLicenseReminder: { stage: String, sentAt: Number } — a stage is sent at most once per licenseEnd value (a renewal/extension resets the cycle).
  • Trigger: daily Vercel CronGET /api/cron/license-reminders, declared in the Vercel config (crons entry, 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's Authorization: Bearer {CRON_SECRET} header or the existing x-admin-secret (manual trigger). Fallback if ever needed: a scheduled GitHub Actions workflow curling the same route.
  • Logic: billingService.licenseRemindersRun() — scan orgs (excluding isDemo) 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-day trial status (§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 licenseEnd by N calendar months (extendLicenseEnd helper); schema gains credits + an auditable creditLedger (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 planWhy 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

  1. Final price amountsLICENSE_PRICE_MONTHLY = 29 / LICENSE_PRICE_YEARLY = 290 are placeholders; confirm the real tax-exclusive EUR amounts (VAT itself is decided: Stripe Tax, per country, §2).
  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?
  3. Existing manually-licensed orgs — how are current licenseCost > 0 deals migrated (manual subscriptions in the dashboard vs staying manual)?
  4. none status — orgs with licenseEnd = 0 are treated like expired (banner, data visible). Should they instead fall under the cancelled gate?
  5. Demo orgs — exempt from banner and modal (isDemo); confirm.
  6. 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 fileHow it informs new code
src/app/api/webhooks/clerk/route.tsTemplate for the Stripe webhook (signature verify → switch → handlers)
src/services/mongodb.tsSingleton pattern for src/lib/billing/stripeClient.ts
src/lib/auth/requireOrgAccess.tsAdmin guard for checkout/portal actions
src/constants/constants.tsHome of the §2 license time constants (existing *_EXPIRATION_WARNING convention)
src/lib/mongoose/organizationLanguageUpdate.tsThin "use server" action template
src/app/[locale]/(app)/e/org/locales/page.tsxServer-page template for /e/org/billing
src/app/[locale]/(app)/layout.tsxWhere the banner renders and the cancelled gate short-circuits
src/lib/cache/withCache.ts + cacheInvalidateCached reads; every sync must invalidate
deadcode/src/lib/clerk/CreditAllocator.tsxStatusChipLicenseStatusChip; price-card grid pattern; full salvage list applies when §15 lands
src/lib/ui/buttongroups/ButtonStack.tsxSubscribe / manage-billing buttons
docs/dev/components/ui/toasts.mdToast conventions for success/error feedback