fix(billing): close the access-cutoff loop and stop the daily repeat email (#550) - #564
Merged
Merged
Conversation
…email (#550) Three defects in the #494/#517 access-cutoff machinery, plus the coverage that would have caught them. 1. Nothing cleared the cutoff when a school did what the notice asked. All six `reconcileAccessCutoff` call sites were plan-change events, but the email's remediation is a usage action ("20 active courses exceed the Free plan's limit of 15"). A complying school stayed locked out until the next daily sweep, or indefinitely since nothing on the host calls /api/cron/* (#513). Course archive, course delete and member removal now reconcile via `reconcileAccessCutoffSafely` (logs and swallows — the caller's write already succeeded), and the billing page gets an explicit "Re-check limits" button so recovery never depends on a scheduler. Member removal did not exist: `deactivateUser` only stamps `profiles.deactivated_at`, a column read nowhere but the admin users screens, while `countTenantUsage` counts active `tenant_users` rows. Added `removeTenantMember`, which sets `status = 'removed'` behind the existing last-admin guard, and taught `joinCurrentSchool` to reinstate such a row through the same student-limit pre-check so removal is not a one-way door. A removed admin re-joins as a student or teacher, never an admin. 2. A failed ledger write re-sent the same email daily, forever. `deliverCutoffStage` logged the upsert error and returned `{ delivered: true }`, so the rung stayed unrecorded, the next sweep re-derived it, and the cron counted each repeat under `notified` — a healthy-looking run behind an unbounded daily repeat. RLS on `access_cutoff_notifications` is enabled with no policy, so every non-service-role caller hit this. It now returns `delivered: false` and surfaces as `notifyFailures`. The schedule branch also consults the ledger instead of forcing 'scheduled', which removes the cron/plan-change double-send for free. 3. A missing platform_plans row cleared live enforcement. `decideAccessCutoffAction` grows `limitsKnown`: an unresolvable plan lookup now means "limits unknown", not "limits met". Nothing new is scheduled off limits that were never read, and an existing cutoff is left standing — clearing on an operator error lifts enforcement school-wide with only a plan purchase or a super admin row edit to recover, while preserving it costs nothing because the next successful reconcile clears it. `reconcileAccessCutoff` had zero coverage. Adds 17 table-driven tests over (violations x currentCutoffAt x notifyDueStages x sendEmail result x ledger write result); 4 of them fail against the pre-fix code, one per defect. The fake Supabase gains a `failWrites` option so an RLS-refused write can be expressed at all. Docs: MONETIZATION.md claimed join-school and course creation were call sites; they were not. Rewritten to describe the two call-site families that now exist, plus the §2 and §3 decisions. OPERATIONS_GUIDE.md is untouched — the issue cites line 218, but that file has no access-cutoff content. Closes #550 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015V35rmrQiZCxDJDiXas9GV
guillermoscript
marked this pull request as ready for review
July 26, 2026 19:34
…550) Both found by driving the flows in a browser rather than by reading code. `archiveCourse` exists twice. `app/actions/teacher/courses.ts` backs the teacher course card; `app/actions/admin/courses.ts` backs the admin course-status screen, and that is the one the admin UI actually calls. Only the first was reconciling, so archiving from the admin screen — the more likely path for an admin racing a cutoff — left the cutoff in place and the recovery loop open on exactly the half a reviewer would try first. `restoreCourse` is the mirror image: it raises the active course count, so it now reconciles too and schedules the cutoff at the moment the admin causes it, with the full grace period, instead of whenever a sweep next notices. The `/join-school` page gated on a `tenant_users` row existing at all, so a removed member — redirected there by proxy.ts precisely so they could re-join — was met with "You're Already a Member!" and no way forward. The server action already handled `status`; the page did not. Both membership reads on that page now require `status = 'active'`, matching `joinCurrentSchool()`. Verified end to end against local Supabase, no cron running: admin-screen archive clears `access_cutoff_at` synchronously and the student's /access-suspended page redirects them back into the course; Remove from School clears it on the student limit; re-joining while at the limit is still refused with "This school has reached its student limit", and re-joining under it updates the surviving row in place (one row, `active`) rather than inserting a duplicate. Re-check limits reports "Still over the plan limit" while over and "Access restored" once under. Also fixes six pre-existing lint errors in the join-school page, which the whole-file pre-commit hook makes blocking as soon as the file is touched: five unescaped apostrophes, and an `any` on the memberships map. Removing that `any` surfaced a real mismatch — the generated types model the `tenants` embed as an array while PostgREST returns a bare object — so the row now handles both shapes instead of casting the discrepancy away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015V35rmrQiZCxDJDiXas9GV
Owner
Author
|
Shipped in #564 (squash-merged to What landed
Corrections to the issue as written
Two defects found by browser QA that code review had missed
Both fixed in the same PR and re-verified. Eleven flows were exercised end to end against local Supabase with no cron running, and the database restored to its seeded baseline afterwards. No migration. This is app-layer only — Out of scope and still open: #513 (nothing on the Dokploy host calls |
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes #550
Fixes the three access-cutoff defects from §4.6 and adds the first coverage
reconcileAccessCutoffhas ever had.archive, course delete and member removal now reconcile. The admin billing page
gets an explicit Re-check limits button, so recovery never depends on a
scheduler at all.
deliverCutoffStagereturns
delivered: falseon an upsert error, and theschedulebranchconsults the ledger instead of forcing the
scheduledrung.platform_plansrow no longer clears live enforcement.Why
Two independent asymmetries plus one fail-open that reached too far.
§1 — all six
reconcileAccessCutoffcall sites were plan-change events, butthe cutoff email's remediation is a usage action ("20 active courses exceed the
Free plan's limit of 15"). A downgrade scheduled a cutoff synchronously; a usage
reduction cleared nothing. A complying school stayed locked out until the next
daily sweep — or indefinitely, since nothing on the Dokploy host currently calls
/api/cron/*(#513). The only recovery paths were buying a bigger plan or a superadmin editing the row.
§2 —
deliverCutoffStagelogged the ledger upsert error and returned{ delivered: true }anyway. The rung stayed unrecorded, sofetchSentStagesre-derived it on the next sweep, and the cron counted every repeat under
notifiedand reported a healthy run. RLS on
access_cutoff_notificationsis enabled withno policy, so every non-service-role caller hit this on every send. Result: every
tenant admin received the identical cutoff email daily until the cutoff cleared —
exactly the failure mode #517 was written to prevent, inverted.
§3 — a
platform_planslookup miss yieldedviolations = [], whichdecideAccessCutoffActionread as "compliant" and used to clear a live cutoff.Three of the issue's premises needed correcting first
"Both already hold or can obtain an admin client", implying one exists. It does
not.
deactivateUseronly stampsprofiles.deactivated_at— a columngrepfinds read nowhere except the two admin users screens, purely for display —
while
countTenantUsagecountstenant_usersrows withrole = 'student' AND status = 'active'. Every othertenant_userswrite in therepo is an INSERT. So a "deactivated" student still counted against the plan
limit and still held their membership. Acceptance criterion 2 was not "add a
call"; it was "build the removal the call would hang off".
docs/OPERATIONS_GUIDE.mdhas no access-cutoff content to correct.grep -n "cutoff\|enforce-plan-limits\|reconcile"returns nothing across all 732lines; line 218 is Stripe webhook setup. The drift is confined to
MONETIZATION.md:277, which is fixed. That file is left untouched.schedule-branch race fixed itself. On a fresh 14-day cutoffdueCutoffNotificationStagereaches exactly['scheduled']and returns it whenunsent,
nullwhen the ledger already holds it. Routing the schedule branchthrough the same function is both the de-race and a simplification.
Design decisions worth a reviewer's attention
Member removal keeps the row (
status = 'removed') rather than deleting it:it preserves
joined_at, keeps FK-owned rows from cascading away, and letsjoinCurrentSchoolreinstate the member. Without that reinstate path removal wouldbe a one-way door — the old code rejected any existing membership row with "You
are already a member of this school", stranding the user permanently. Reinstatement
runs the same
max_studentspre-check a first-time join runs, so it is not alimit bypass, and a removed admin comes back as a student or teacher, never an
admin — re-joining a school is not a way to restore privilege you were stripped of.
The membership read also moved to the admin client, since a removed member's own row
may be invisible to them under RLS and a false "no membership" would fall through to
an INSERT that dies on
tenant_users_uniquewith an opaque error.§3's trade is deliberate and asymmetric.
decideAccessCutoffActiongrows anoptional
limitsKnownflag. An unresolvable plan lookup now means "limits unknown",not "limits met": scheduling stays fail-open (nothing is enforced off limits never
read), while an existing cutoff is left standing. A lookup miss is an operator error
— a renamed slug, a deleted row. Clearing on it lifts enforcement school-wide with
only a plan purchase or a super admin row edit to recover; preserving it costs
nothing, because the very next successful reconcile clears it if the school really
is compliant. This is a narrower rule than
checkPlanLimits's blanket fail-open,which only ever governs whether a new restriction is applied.
Reconciliation never fails the caller.
reconcileAccessCutoffSafelywraps thereconciler in a
try/catchthat logs and swallows. The archive already succeeded bythe time it runs — throwing there would punish the school for doing exactly what the
cutoff email asked.
notifyDueStagesstays off on these paths, so the existing rulethat user-facing actions never pay email latency is intact.
How to QA
Fresh
npm run db:reset, thennpm run dev. Everything below is ondefault.lvh.me:3000.A — archiving clears the cutoff synchronously (AC 1)
enforcement is live rather than merely scheduled:
student@e2etest.com, open any enrolled course. You land on/dashboard/student/access-suspended. Leave that tab open.owner@e2etest.com, go to Teacher → Courses and archive courses until onlyone is left unarchived.
No cron is running. Before this PR the student stayed suspended.
select access_cutoff_at from tenants where id = '000…001';→NULL.B — member removal does the same (AC 2)
update platform_plans set limits = '{"max_courses": 100, "max_students": 1}' where slug = 'free';and setaccess_cutoff_at = now() - interval '1 day'again.owner@e2etest.com, Admin → Users → (a student) → ⋯ → Remove from School.access_cutoff_atisNULLand the student'stenant_usersrow readsstatus = 'removed'. The user vanishes from the users list./join-schooland join. They come back withstatus = 'active'— and, if the school is at its student limit, they are blockedby the same "reached its student limit" message a new joiner sees.
C — the manual re-check (AC 3)
offers Re-check limits; it renders only while a cutoff exists.
limit in SQL, click again → success toast "Access restored…" and the banner clears.
D — the ladder defects (AC 4, 5) are covered by
npx vitest run tests/unit/access-cutoff-reconcile.test.ts. To see them fail againstthe old code, revert the three hunks in
lib/billing/access-cutoff.ts— 4 of the 17tests fail, one per defect. That is how they were validated (see below).
Restore the seeded limits afterwards:
update platform_plans set limits = '{"max_courses": 5, "max_students": 50}' where slug = 'free';Screenshots / GIF
Not attached. The UI surface is two small controls reusing existing components
(a Remove from School item in the admin user-actions dropdown, and an amber
Re-check limits row in the billing usage section that renders only while
accessCutoffAtis set). Both were exercised in a real browser — the verifiedoutcomes are recorded under Browser verification below. Happy to record a GIF
if a reviewer wants one.
Testing
reconcileAccessCutoffhad zero coverage: the existing suites only reach the purehelpers. The untested 76 lines held every stateful decision, which is how a
return { delivered: true }sitting directly beneathif (error) console.error(...)survived review.
Adds
tests/unit/access-cutoff-reconcile.test.ts— 17 table-driven cases over(violations × currentCutoffAt × notifyDueStages × sendEmail result × ledger write
result), built on the real-rows
fake-supabase.tsbecause every assertion is aboutwhat a second call sees after the first one wrote (or failed to write). The fake
gains a
failWritesoption, since an RLS-refused write returns{ data: null, error }rather than throwing and could not be expressed at all before.
Validated by reverting each fix: against the pre-fix module, exactly 4 tests fail —
one per defect (
notifyFailedaccounting, the twice-run sweep, the schedule-branchdouble-send, and the missing-plan-row clear) — and all 17 pass after. This is not a
suite written to match the new behaviour.
The issue quotes a 370-test baseline at
146a8cfa; the tree is at 523 today.Browser verification
Driven end to end in Chromium against local Supabase on
default.lvh.me:3005,with no cron running, then the database restored to its seeded baseline
(plan limits, cutoff, course statuses and memberships all confirmed identical
afterwards). Every step below was performed through the real UI unless it says
SQL.
max_courses = 1,access_cutoff_at = now() - 1 dayhas_course_accessfalse for both courses/dashboard/student/courses/1001/access-suspendedaccess_cutoff_at→NULL,has_course_accesstrue again/access-suspended/dashboard/studentmax_courses = 0+ cutoff in past; Billing → Re-check limitsNULLmax_students = 0+ cutoff in past; Admin → Users → ⋯ → Remove from Schoolstatus = 'removed', active students 0, cutoffNULL/join-schoolmax_students = 0/dashboard/student; onetenant_usersrow,student/active— updated in place, no duplicateTwo defects this found that code reading had missed, both fixed in the
second commit:
archiveCourseexists twice. The admin course-status screen callsapp/actions/admin/courses.ts, not the teacher action I had patched — sostep 4 initially did nothing.
restoreCourse(which raises the count) nowreconciles as well.
/join-schoolpage gated on a membership row existing at all, so aremoved member redirected there to re-join was met with "You're Already a
Member!" and no way forward — step 9 dead-ended. The server action already
handled
status; the page did not.Both are the kind of gap unit tests structurally cannot catch: the logic was
right, the wiring pointed at a different function.
Checklist
npm run typecheckandnpm run test:unitpassnpm run buildpassestenant_id(or the table genuinely has no such column)messages/en.jsonandmessages/es.jsonnpm run db:reset— no migration;tenant_users.statusis an existing free-text column with no CHECK constraint, and'removed'needs no schema change