Milestone 10: Billing and trial
Purpose: Trial and paid plans work; Stripe Checkout and Portal; trial lifecycle (reminders, lock); locked state; inbox limit enforced.
Exit state: Subscription and trial state in DB; Stripe Checkout (Starter/Growth/Business) and Portal; webhook handler; trial cron (day-25, -30, -37); locked page and middleware; billing UI; inbox limit enforced.
Spec reference: §1.7 Pricing, free trial, §2.6 Billing & Subscriptions, §5.7 Subscription, §5.8 TenantEntitlement, §6.8 locked, §6.10 settings/billing, §9.7 Stripe, §10.10 Tenant delete.
Prerequisites: M09 (Invites and team). M05 already uses inbox limit (stub); M10 provides real limit from subscription.
10.1 Subscription and trial model
Tasks
-
Subscription row
- On signup (M02): create subscriptions row (tenant_id, plan_id 'trial', status 'trialing', inbox_limit 10, stripe_customer_id null, stripe_subscription_id null). Section 2.6. Ensure M02 does this or add migration to backfill.
-
Trial state
- tenants.trial_ends_at = now() + 30 days on signup. Section 1.7. While trial_ends_at > now and no paid subscription (stripe_subscription_id null or status active), tenant has Growth-level access (inbox_limit 10, advanced_reports). Entitlement sync: trial → advanced_reports; starter/growth/business/enterprise per Section 2.6 table.
-
Entitlement sync
- Function: given tenant_id and plan_id, delete tenant_entitlements for tenant, insert rows for that plan's features (trial: advanced_reports; starter: none; growth: advanced_reports; business: advanced_reports, public_api, webhooks; enterprise: + sso). Call after signup (trial), after Stripe webhook (checkout.session.completed, customer.subscription.updated/deleted). Section 2.6.
Acceptance criteria
| Criterion | Status |
|---|---|
| New tenant has subscription (trial) and trial_ends_at; entitlements match trial. | Pending |
| Entitlement sync idempotent; correct for each plan_id. | Pending |
10.2 Stripe Checkout and Portal
Tasks
-
Create Checkout Session
- tRPC billing.createCheckoutSession: input price_id or plan_id (starter/growth/business). Get Stripe price IDs from env (STRIPE_STARTER_PRICE_ID, etc.). Create Stripe Checkout Session: customer_email from tenant admin or session user; client_reference_id = tenantId; line_items price_id, quantity (e.g. 1 or inbox count); success_url, cancel_url (e.g. /app/[slug]/settings/billing). Return session.url; client redirects. Section 2.6. Enterprise: no Checkout; manual setup; no STRIPE_ENTERPRISE_PRICE_ID. Section 9.7.
-
Customer Portal
- tRPC billing.createPortalSession: load tenant's stripe_customer_id from subscriptions; create Stripe Billing Portal Session; return url; client redirects. Tenant manages subscription, payment, invoices in Stripe. Section 2.6.
-
Webhook handler
- POST /api/stripe: verify Stripe-Signature with STRIPE_WEBHOOK_SECRET; parse event. Section 2.6.
- checkout.session.completed: get client_reference_id (tenantId); create or update subscriptions (stripe_customer_id, stripe_subscription_id, plan_id from price lookup, status active, inbox_limit from plan); sync entitlements.
- customer.subscription.updated: find subscription by stripe_subscription_id; update plan_id, status, inbox_limit, current_period_end; sync entitlements.
- customer.subscription.deleted: set status cancelled; clear entitlements or set to starter; if past grace period set status locked (see 10.4).
- invoice.payment_succeeded: update current_period_end; clear past_due if set.
- invoice.payment_failed: set status past_due; send payment-failed email to tenant admins (template M08).
- invoice.payment_action_required: same + include payment link in email.
- All handlers idempotent (safe to replay).
Acceptance criteria
| Criterion | Status |
|---|---|
| Checkout session created and redirects to Stripe; on success webhook updates subscription and entitlements. | Pending |
| Portal session opens Stripe; subscription changes flow back via webhooks. | Pending |
| Payment failed triggers email to admins; subscription.deleted sets cancelled and syncs entitlements. | Pending |
10.3 Trial lifecycle
Tasks
-
Cron (daily)
- Inngest cron: daily. For each tenant with trial_ends_at set and no stripe_subscription_id (or status trialing):
- If trial_ends_at in 5 days (e.g. day 25): send trial-ending email to tenant admins. Section 1.7.
- If trial_ends_at today (day 30): send trial-expired email; block new inbox creation (already enforced by limit if subscription stays trial with inbox_limit 0 or keep 10 but block in UI when trial_ends_at passed); show banner in app. Grace 7 days: syncing continues, read-only suggested; after 7 days lock (10.4).
- If trial_ends_at + 7 days (day 37): send trial-locked email; set subscription status to locked (or tenant flag). Section 1.7.
- Inngest cron: daily. For each tenant with trial_ends_at set and no stripe_subscription_id (or status trialing):
-
Trial emails
- Templates: trial-ending (days_remaining, upgrade_url), trial-expired (grace_period_ends, upgrade_url), trial-locked (upgrade_url). Section 2.10b. Send to tenant admins (list from tenant_memberships where role admin).
-
Banner
- When trial_ends_at passed and no active subscription: sticky banner "Your trial ended. Subscribe to continue." Link to Checkout or billing. Dismissible only after subscribing (or never). Section 6.8.
Acceptance criteria
| Criterion | Status |
|---|---|
| Day-25 email sent; day-30 banner and optional inbox block; day-37 lock state and email. | Pending |
| Cron runs daily (or test manually); correct emails and state transitions. | Pending |
10.4 Locked state
Tasks
-
Definition
- Tenant is locked when: subscription status = 'locked' (or trial_ends_at + 7 days passed and no active subscription). Section 1.7, 6.8.
-
Middleware
- Already in M02: if locked and path not /app/[slug]/locked, redirect to /app/[slug]/locked. Section 4.4.
-
Locked page
- Content: "Account locked"; message about trial ended / subscription cancelled; "Subscribe now" CTA → Checkout; support link. Section 6.10. All tRPC mutations (reply, create inbox, etc.) reject with 403 when tenant locked; ticket list and detail read-only.
-
Enforce in API
- In tRPC context or middleware: if tenant subscription status locked, reject mutations (reply, create ticket, create inbox, etc.); allow read (tickets, messages, settings view). Optionally block list if product decision is full lock.
Acceptance criteria
| Criterion | Status |
|---|---|
| Locked tenant redirected to /app/[slug]/locked; page shows CTA; mutations blocked; read-only where specified. | Pending |
| After subscribing (Checkout success and webhook), status no longer locked; tenant can use app. | Pending |
| Subscription and locked state changes (e.g. after Stripe webhook or Portal) reflected in UI without manual refresh (SSE or refetch so user sees locked redirect or billing update in real time). | Pending |
10.5 Billing UI and inbox limit
Tasks
-
Settings → Billing
- Plan card: current plan name, inbox count used / limit, billing period, next invoice (if applicable). Section 6.10.
- Trial banner: if trialing, days remaining, progress bar, "Upgrade now" CTA.
- "Manage billing" button → createPortalSession and redirect.
- Upgrade CTA: link to Checkout with chosen plan (or pricing page). Enterprise → /contact or "Contact us."
-
Inbox limit
- Before creating inbox (M05): count active inboxes; if count >= subscriptions.inbox_limit, return error "Inbox limit reached; upgrade to add more." Use real subscription row in M10.
-
Tenant delete (danger zone M04)
- If not done in M04: on delete tenant, cancel Stripe subscription (if stripe_subscription_id); then delete or soft-delete tenant and related data; S3 cleanup async. Section 10.10.
Acceptance criteria
| Criterion | Status |
|---|---|
| Billing page shows plan, limit, trial state, and Manage billing; Checkout and Portal work from UI. | Pending |
| Inbox create blocked when at limit; error message clear. | Pending |
| Tenant delete cancels Stripe and removes data (or documents dependency). | Pending |
Milestone 10 sign-off
| Criterion | Status |
|---|---|
| All tasks in 10.1–10.5 complete. | Pending |
| All acceptance criteria met. | Pending |
| Trial and paid flows work; locked state enforced; billing UI and limit correct. | Pending |
| E2E: Checkout/Portal redirects and locked state (Stripe test mode; see INDEX — Testing strategy). | Pending |
| Ready for M11 (SLA and business hours). | Pending |