feat(billing): access-cutoff reminder ladder, admin banner and blast-radius docs (#517) - #520
Conversation
…ast-radius docs (#517) #494 shipped the access cutoff as a tenant-wide hard block announced by a single email at scheduling time, with no in-app signal for admins and no written statement of how wide the block reaches. This closes all three gaps. Notification ladder. `decideAccessCutoffAction()` returns 'schedule' exactly once per cutoff, so the school got exactly one message, 14 days out, and nothing at T-7, T-1 or on the day access actually stopped. A new `access_cutoff_notifications` ledger — unique on (tenant_id, cutoff_at, stage) — lets the existing daily sweep send whichever rung is due without repeating itself. A row is written only when at least one admin address actually received the mail, so an undelivered rung is retried by the next sweep; `dueCutoffNotificationStage()` returns at most one rung (the most urgent reached-but-unsent one) so a stale warning can never land beside "access is now paused". The cron response now reports per-stage sends and failures. Delivery is judged on sendEmail's boolean rather than on whether it threw: lib/email/send.ts swallows both a missing Mailgun config and an API error and returns false, which made #494's try/catch around the send near-decorative. Admin banner. <AccessCutoffBanner /> renders in the dashboard shell for admins on every admin page, not just billing, in a scheduled state (days remaining) and an active state (access paused). Not dismissible: a dismissed countdown to losing all student access is indistinguishable from no countdown. The student half of this gap was already closed by #509 (/dashboard/student/access-suspended), verified as a regression check. Blast radius. docs/MONETIZATION.md now states explicitly that the cutoff is tenant-wide and all-or-nothing (a free-plan school's 51st student costs all 51 their access), what it does not touch (enrollments, entitlements, purchases, staff and author access, preview lessons), and why per-student narrowing was considered and rejected. Also fixes a locale bug found during verification: both the new banner and the #509 student page formatted dates with toLocaleDateString(undefined), which resolves to the Node process locale on the server, so /es pages rendered English dates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168MCSb3b3iWM5TSb25Rp3v
Visual verificationPlaywright against the local stack ( Admin banner — cutoff scheduledOn Admin banner — cutoff activeOn Spanish, after the locale fix
Student — no admin bannerSame tenant, same active cutoff, logged in as Student — #509 regression checkOpening a course past the cutoff still lands on |
|
Merged as Cloud deploy done ( Verified live on cloud: 6 columns, RLS enabled with zero policies (the intended deny-by-default; every writer is a service-role caller), What shipped: the reminder ladder ( Two bugs found on the way that were worth more than the feature: Note for whoever watches the first real cutoff: the ladder only runs when the cron actually fires. That depends on the GitHub Actions scheduler from #513 (PR #519, still open) plus the |





Closes #517.
Follow-up to #493 / #494. The access cutoff shipped as a tenant-wide hard block announced by a single email, with no in-app signal for admins and no written statement of how wide the block reaches. This closes all three gaps.
What was wrong
decideAccessCutoffAction()returns'schedule'exactly once per cutoff — the!currentCutoffAtguard makes every later call a deliberate no-op, which is right for scheduling and wrong for communication. BecausereconcileAccessCutoff()only mailed insideif (decision.action === 'schedule'), the school got exactly one message, 14 days out. Nothing ran at T-7, T-1, or on the day access actually stopped, and a failed send leftaccess_cutoff_atset with nobody informed.While verifying that, a second problem surfaced:
lib/email/send.tsreturnsfalserather than throwing, both when Mailgun is unconfigured and when the API call fails. #494'stry/catcharound the send was therefore near-decorative — a dead mail provider produced a silent no-op indistinguishable from success.The in-app half was asymmetric. #509 had already closed the student side (
/dashboard/student/access-suspended, reached viarequireCourseAccess()), but the admin side had nothing:getSubscriptionStatus()returnsaccessCutoffAtand only/dashboard/admin/billingever read it, so an admin who never opened that page had no signal at all.What changed
1. Reminder ladder with a persisted send ledger. New table
access_cutoff_notifications, unique on(tenant_id, cutoff_at, stage), migration20260725100000. Four rungs:scheduled→reminder_7d→reminder_1d→enforced.cutoff_atas well as tenant, so a cleared-then-rescheduled cutoff correctly starts a fresh ladder for its new deadline.sendEmail's boolean rather than on whether it threw. An undelivered rung stays unrecorded and is retried by the next daily sweep — the retry the issue asks for.dueCutoffNotificationStage()returns at most one rung, always the most urgent reached-but-unsent one. A stale early rung is superseded rather than queued, so "you have 7 days" can never land beside "access is now paused".accessCutoffWarningTemplate({ stage, ... })gives each rung its own subject and copy;enforcedis written in the past tense.enforce-plan-limitsdaily sweep via a newnotifyDueStagesoption — no new cron entry, since the sweep already visits every tenant at exactly the right cadence. Its response now reportsnotified(per stage) andnotifyFailures, so a failing mail provider is visible in the cron log instead of silent. Event-driven call sites (join-school, course creation, plan changes) deliberately do not pass the flag, so user actions never pay email latency for a reminder the cron sends anyway.2. Persistent in-app banner for tenant admins.
<AccessCutoffBanner />renders in the dashboard shell forrole === 'admin'— every admin page, not just billing. Two states, scheduled (days remaining, amber) and active (access paused, destructive), both naming the school-wide blast radius in the same words as the email. Not dismissible: a dismissed countdown to losing all student access is indistinguishable from no countdown. One indexed single-row read, gated to admins.3. Blast radius documented.
docs/MONETIZATION.mdnow states explicitly that the cutoff is tenant-wide and all-or-nothing — a free-plan school's 51st student costs all 51 their access — what it does not touch (enrollments, entitlements, purchases, staff/author access, preview lessons), and why per-student narrowing was considered and rejected.Scope note. The issue offered "or narrow enforcement" as an alternative to documenting. I took the documentation route deliberately: narrowing to "only the students over the limit" would make one student's access depend on the join order of their peers and would need a per-student ordering rule inside a
STABLE SECURITY DEFINERfunction on the hot path of every content read. That is a product decision, not a follow-up sub-task. If you want it, it should be its own issue — say so and I'll open it.Drive-by fix. Both the new banner and the #509 student page formatted dates with
toLocaleDateString(undefined), which resolves to the Node process locale on the server — so/espages rendered English dates ("Desde el July 23, 2026"). Both now pass the request locale. Caught by the QA below, not by any test.Testing
npm run test:unit— 305 passed, 27 files. 21 new intests/unit/access-cutoff-notifications.test.tscovering the ladder (each rung due at its trigger, no repeat once recorded, retry of a failed rung, supersession of a stale rung, enforced-only after the cutoff, fresh ladder for a rescheduled cutoff, unparseable input), the banner's day math, and the per-stage email copy.access-cutoff.test.tsandaccess-cutoff-call-sites.test.tsstay green —decideAccessCutoffAction's contract is unchanged.npm run typecheck— clean.npm run build— clean.npx eslinton all 8 changed files — clean, no new errors.Live QA against the local Supabase stack (migration applied, temporary throwaway plan with
max_courses: 0to force a real violation on Default School, temp harness deleted before commit):schedule+scheduledsent — "Action required: student access to Default School will be cut off on August 8, 2026"reminder_7d— "1 week left: …"reminder_1d— "Tomorrow: …"enforced— "Student access to Default School is now paused"recipient_count > 0notifyFailed: true, 0 ledger rowsclear,access_cutoff_atnull, banner renders nothingBrowser QA (Playwright,
default.lvh.me:3005, adminowner@e2etest.comand studentstudent@e2etest.com) — screenshots in the comment below:/dashboard/admin/coursesand/dashboard/admin/users, i.e. away from billing, in both scheduled and active states./esrenders the localised date after the fix./dashboard/student/access-suspendedwith the correct explanation — regression check on Lesson/exercise/exam pages have no access gate — #494 cutoff is a no-op there and any tenant member can read paid content #509.No GIF: the change has no interactive flow to record — a static banner in two states plus four emails. The still frames are the evidence.
Follow-ups (not in this PR)
chore(...)deploy step, as with20260724130000in chore(payments): deploy manual-payouts + related migrations to cloud (#500) #508.lib/database.types.tsis not regenerated —access_cutoff_notificationsis only reached through untyped service-role clients, so nothing needs it to compile. It'll come with the cloud deploy.