Skip to content

fix(billing): real access-cutoff enforcement for over-limit tenants (#494) - #501

Merged
guillermoscript merged 1 commit into
masterfrom
fix/access-cutoff-enforcement-494
Jul 24, 2026
Merged

fix(billing): real access-cutoff enforcement for over-limit tenants (#494)#501
guillermoscript merged 1 commit into
masterfrom
fix/access-cutoff-enforcement-494

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

Description

Non-payment previously produced no consequence: downgradeTenantToFree() only ever wrote platform_subscriptions.status, tenants.plan/billing_status/billing_period_end, and revenue_splits — it never touched courses, entitlements, or tenant_users. has_course_access(), the single source of truth for course access, checked only entitlements.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-built LimitReachedBanner component 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).
  • If the tenant is still over its limits when that deadline passes, has_course_access() starts returning false for 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 Stripe customer.subscription.deleted webhook and manual-transfer expiry cron (both via downgradeTenantToFree()), 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.
  • Misleading copy fixed: plan-downgraded.ts no longer unconditionally claims safety, downgrade-blocked.ts's "may be restricted" is now a concrete, accurate statement, and the previously-dead LimitReachedBanner is now wired into the admin billing page and shows the cutoff deadline when one is scheduled.
  • Decision documented in docs/MONETIZATION.md under a new "Post-Downgrade Access Enforcement" section.

Type of change

  • Bug fix (real enforcement was entirely absent — this closes the gap)

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-existing any-type lint errors in billing-dashboard-client.tsx that lint-staged surfaced because the file was touched.
  • npm run test:unit — 263/263 passing, including a new tests/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.
  • Manual SQL verification of has_course_access() against a throwaway tenant/course/entitlement inside a rolled-back transaction: no cutoff → access true; cutoff scheduled in the future → access still true (grace respected); cutoff in the past → access false (enforced); cutoff cleared → access true again. All four cases behaved exactly as designed.
  • Browser/visual verification not completed — the Claude-in-Chrome browser extension was not connected in this environment, so I could not capture live screenshots/GIF of the LimitReachedBanner cutoff 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

  1. In a local Supabase shell, temporarily lower a tenant's effective course/student limit below its current usage (e.g. 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 courses LimitReachedBanner now renders with the "at limit" copy.
  2. 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.
  3. Revert both temporary updates (limit back to original, access_cutoff_at back to NULL).
  4. As a student enrolled in that tenant, confirm course access is unaffected while access_cutoff_at is still in the future.
  5. Set access_cutoff_at to a past timestamp on that tenant and confirm the student's course page now denies access; clear it again and confirm access returns.

…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>
@guillermoscript guillermoscript self-assigned this Jul 24, 2026
@guillermoscript guillermoscript added bug Something isn't working payments Payment provider / billing related Sub-task labels Jul 24, 2026
@guillermoscript

Copy link
Copy Markdown
Owner Author

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 *.png, matching its existing verification-screenshot convention) at:

  • billing-before-cutoff.pngAdmin billing page, at-limit banner. Temporarily lowered the free plan's max_courses to 2 against Default School's existing 3 courses. LimitReachedBanner renders "You've reached your courses limit (2). Upgrade your plan to add more." with the destructive/red styling — confirms the previously-dead banner is now wired in and reads real usage data.
  • billing-with-cutoff.pngSame page, cutoff scheduled. Set tenants.access_cutoff_at = now() + 14 days. Banner now additionally shows "If this is not resolved by August 7, 2026, all students will lose access to all courses." — the date is exactly now()+14d from the moment it was set (2026-07-24 → 2026-08-07), confirming ACCESS_CUTOFF_GRACE_DAYS is applied and surfaced correctly.
  • student-access-before-cutoff.pngStudent course page, cutoff still in the future. Logged in as student@e2etest.com (enrolled via a real entitlements row) and loaded /dashboard/student/courses/1001 — page renders normally, access still granted (grace period respected).

Two more steps confirmed enforcement itself (no separate screenshot, but observed directly via the page URL after navigation):

  • Flipped access_cutoff_at to a timestamp in the past → reloading the same course URL redirected to /dashboard/student instead of rendering the course — has_course_access() correctly denied access tenant-wide.
  • Cleared access_cutoff_at back to NULL → reloading again stayed on the course page — access restored automatically, no manual intervention needed.

All seeded state (free plan max_courses, tenants.access_cutoff_at) was reverted to its original value afterward; local DB is back to its pre-verification state.

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.

@guillermoscript
guillermoscript marked this pull request as ready for review July 24, 2026 02:42
@guillermoscript
guillermoscript merged commit 8759872 into master Jul 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working payments Payment provider / billing related Sub-task

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely

1 participant