fix(billing): real access-cutoff enforcement for over-limit tenants (#494) - #501
Conversation
…494) Non-payment previously produced no consequence — a downgraded or organically-over-limit tenant kept full course access indefinitely, and the "your courses and data are safe" downgrade email overpromised safety that no code enforced. `has_course_access()` now denies access tenant-wide once a scheduled 14-day grace period (`tenants.access_cutoff_at`) elapses while the tenant is still over its plan's course/student limits. `reconcileAccessCutoff()` is wired into every plan-state transition (webhook downgrade, in-app plan change, manual-payment confirm, portal change) plus a new daily cron for organic over-limit growth, and emails admins the exact deadline when a cutoff is scheduled. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live verification (Playwright, local dev)Claude-in-Chrome wasn't connected in this environment, so I drove the flow end-to-end with the Playwright MCP browser instead, against the running local dev server + local Supabase, seeding real over-limit state on the Default School tenant (reverted afterward). Screenshots are saved locally (not committed — this repo gitignores root-level
Two more steps confirmed enforcement itself (no separate screenshot, but observed directly via the page URL after navigation):
All seeded state (free plan This confirms the mechanism end-to-end in the real running app, not just via unit tests and the manual SQL check already noted in the PR body. Reviewer: the local screenshot files are available on disk if you want to eyeball them directly; happy to re-capture and attach inline if you'd rather see them in-thread. |
Description
Non-payment previously produced no consequence:
downgradeTenantToFree()only ever wroteplatform_subscriptions.status,tenants.plan/billing_status/billing_period_end, andrevenue_splits— it never touchedcourses,entitlements, ortenant_users.has_course_access(), the single source of truth for course access, checked onlyentitlements.status/expiry with no reference to a tenant's plan or limits. A school that stopped paying (or simply outgrew its plan with no plan-change event at all) kept every student's full course access forever, while the downgrade email claimed "your courses and data are safe" and a fully-builtLimitReachedBannercomponent sat unused, never rendered anywhere in the app.Per explicit product direction, this ships real enforcement, not grandfathering (this repo has no production users yet, so there's no legacy-data safety net to design around):
tenants.access_cutoff_at(new column) is scheduled 14 days out (ACCESS_CUTOFF_GRACE_DAYS) the moment a tenant's course/student usage exceeds its current plan's limits, and the admin is emailed immediately with the exact date and which limits are exceeded (accessCutoffWarningTemplate).has_course_access()starts returningfalsefor every student in the tenant — cutting off lessons, exams, exercises, certificates, and AI tutor chat tenant-wide — until usage drops back under the limit or the tenant upgrades, at which point the cutoff clears automatically.reconcileAccessCutoff()(the single decide-and-apply function,lib/billing/access-cutoff.ts) is wired into every plan-state transition: the Stripecustomer.subscription.deletedwebhook and manual-transfer expiry cron (both viadowngradeTenantToFree()), in-app plan change and manual-payment confirmation (app/actions/admin/billing.ts), and the Stripe-portal change handler (applyPortalPlanChange()). A new daily cron (app/api/cron/enforce-plan-limits) sweeps every tenant to catch organic over-limit growth with no associated plan-change event.plan-downgraded.tsno longer unconditionally claims safety,downgrade-blocked.ts's "may be restricted" is now a concrete, accurate statement, and the previously-deadLimitReachedBanneris now wired into the admin billing page and shows the cutoff deadline when one is scheduled.docs/MONETIZATION.mdunder a new "Post-Downgrade Access Enforcement" section.Type of change
Closes #494 (Phase 1 sub-task of epic #493).
Testing
npm run typecheck— clean, no errors.npm run lint— no new errors in any changed file (the tree has pre-existing unrelated lint debt elsewhere, unaffected by this change); fixed 4 pre-existingany-type lint errors inbilling-dashboard-client.tsxthat lint-staged surfaced because the file was touched.npm run test:unit— 263/263 passing, including a newtests/unit/access-cutoff.test.ts(6 tests) covering the issue's literal 10,000-students-vs-50-limit scenario, idempotency (no double-scheduling/double-emailing), clearing on resolution, and both violation types.npm run build— succeeds.has_course_access()against a throwaway tenant/course/entitlement inside a rolled-back transaction: no cutoff → accesstrue; cutoff scheduled in the future → access stilltrue(grace respected); cutoff in the past → accessfalse(enforced); cutoff cleared → accesstrueagain. All four cases behaved exactly as designed.LimitReachedBannercutoff messaging on/dashboard/admin/billing. The banner logic was reviewed by hand and is lint/typecheck-clean; a human reviewer should sanity-check it visually before merge (see QA steps below).QA verification steps
UPDATE platform_plans SET limits = jsonb_set(limits, '{max_courses}', '1') WHERE slug = 'free'for a free-plan tenant with 2+ courses), then load that tenant admin's/dashboard/admin/billing— confirm the coursesLimitReachedBannernow renders with the "at limit" copy.UPDATE tenants SET access_cutoff_at = now() + interval '14 days' WHERE id = '<tenant>'and reload the page — confirm the banner shows the additional bold cutoff-date line.access_cutoff_atback toNULL).access_cutoff_atis still in the future.access_cutoff_atto a past timestamp on that tenant and confirm the student's course page now denies access; clear it again and confirm access returns.