Skip to content

feat(billing): access-cutoff reminder ladder, admin banner and blast-radius docs (#517) - #520

Merged
guillermoscript merged 1 commit into
masterfrom
feat/517-access-cutoff-comms
Jul 25, 2026
Merged

feat(billing): access-cutoff reminder ladder, admin banner and blast-radius docs (#517)#520
guillermoscript merged 1 commit into
masterfrom
feat/517-access-cutoff-comms

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

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 !currentCutoffAt guard makes every later call a deliberate no-op, which is right for scheduling and wrong for communication. Because reconcileAccessCutoff() only mailed inside if (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 left access_cutoff_at set with nobody informed.

While verifying that, a second problem surfaced: lib/email/send.ts returns false rather than throwing, both when Mailgun is unconfigured and when the API call fails. #494's try/catch around 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 via requireCourseAccess()), but the admin side had nothing: getSubscriptionStatus() returns accessCutoffAt and only /dashboard/admin/billing ever 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), migration 20260725100000. Four rungs: scheduledreminder_7dreminder_1denforced.

  • Keyed on cutoff_at as well as tenant, so a cleared-then-rescheduled cutoff correctly starts a fresh ladder for its new deadline.
  • A row is written only when at least one admin address actually received the mail, judged on 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; enforced is written in the past tense.
  • Runs from the existing enforce-plan-limits daily sweep via a new notifyDueStages option — no new cron entry, since the sweep already visits every tenant at exactly the right cadence. Its response now reports notified (per stage) and notifyFailures, 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 for role === '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.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/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 DEFINER function 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 /es pages 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:unit305 passed, 27 files. 21 new in tests/unit/access-cutoff-notifications.test.ts covering 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.ts and access-cutoff-call-sites.test.ts stay green — decideAccessCutoffAction's contract is unchanged.
  • npm run typecheck — clean.
  • npm run build — clean.
  • npx eslint on all 8 changed files — clean, no new errors.

Live QA against the local Supabase stack (migration applied, temporary throwaway plan with max_courses: 0 to force a real violation on Default School, temp harness deleted before commit):

Step Result
First sweep schedule + scheduled sent — "Action required: student access to Default School will be cut off on August 8, 2026"
Same-day re-run No email, ledger stays at 1 row (idempotent)
T-7 reminder_7d"1 week left: …"
T-6.5 No repeat
T-1 reminder_1d"Tomorrow: …"
Past cutoff enforced"Student access to Default School is now paused"
Cutoff + 10d Silent — no daily nagging
Ledger 4 rows, one per stage, all with recipient_count > 0
Failed send notifyFailed: true, 0 ledger rows
Next sweep after failure Same rung retried, delivered, 1 ledger row
Violation resolved clear, access_cutoff_at null, banner renders nothing

Browser QA (Playwright, default.lvh.me:3005, admin owner@e2etest.com and student student@e2etest.com) — screenshots in the comment below:

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)

  • The migration is applied locally only. Cloud deploy follows the repo's usual separate chore(...) deploy step, as with 20260724130000 in chore(payments): deploy manual-payouts + related migrations to cloud (#500) #508.
  • lib/database.types.ts is not regenerated — access_cutoff_notifications is only reached through untyped service-role clients, so nothing needs it to compile. It'll come with the cloud deploy.

…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
@guillermoscript guillermoscript added enhancement New feature or request payments Payment provider / billing related Sub-task severity:medium Medium severity security finding labels Jul 25, 2026
@guillermoscript guillermoscript self-assigned this Jul 25, 2026
@guillermoscript

Copy link
Copy Markdown
Owner Author

Visual verification

Playwright against the local stack (default.lvh.me:3005), Default School forced over a plan limit. No GIF — there is no interactive flow here; the change is a static banner in two states plus four emails, so still frames are the evidence.

Admin banner — cutoff scheduled

On /dashboard/admin/courses, i.e. away from the billing page that was previously the only place this state was visible. Amber, days remaining, blast radius stated in the banner text itself.

Admin dashboard with an amber banner reading: Course access stops for your whole school in 10 days. On August 4, 2026, every student will lose access to all courses unless usage is back within your plan's limits.

Admin banner — cutoff active

On /dashboard/admin/users. Destructive styling, lock icon, past tense, and the button changes to "Restore access".

Admin dashboard with a red banner reading: Course access is paused for your whole school. Since July 23, 2026, every student has lost access to all courses because the school is over its plan limits.

Spanish, after the locale fix

toLocaleDateString(undefined) resolves to the Node process locale on the server, so this said "Desde el July 23, 2026" before the fix. Now "Desde el 23 de julio de 2026".

Spanish admin dashboard with the red banner reading: El acceso a los cursos está pausado para toda tu escuela. Desde el 23 de julio de 2026...

Student — no admin banner

Same tenant, same active cutoff, logged in as student@e2etest.com. The banner is admin-only; students are not shown a billing problem they cannot act on.

Student dashboard with no cutoff banner present

Student — #509 regression check

Opening a course past the cutoff still lands on /dashboard/student/access-suspended, which names the school's plan limits as the cause and confirms the enrollment is intact. Unchanged by this PR beyond the date-locale fix.

Student access-suspended page reading: Your school's access is suspended. Default School is over its plan limits, so access to course content is paused for everyone at the school.

@guillermoscript
guillermoscript marked this pull request as ready for review July 25, 2026 01:42
@guillermoscript
guillermoscript merged commit 5641c1b into master Jul 25, 2026
2 checks passed
@guillermoscript

Copy link
Copy Markdown
Owner Author

Merged as 5641c1be and deployed.

Cloud deploy done (chore(db) cd91aa33 on master): migration 20260725100000 applied to the linked Supabase project, and lib/database.types.ts regenerated from it. The migration record was realigned to the repo's 20260725100000 filename — apply_migration stamps its own timestamp — so a later supabase db push won't try to re-run it.

Verified live on cloud: 6 columns, RLS enabled with zero policies (the intended deny-by-default; every writer is a service-role caller), access_cutoff_notifications_unique_stage present, 3 indexes, 0 rows. The only new advisor finding is the corresponding INFO rls_enabled_no_policy, which is the design rather than a gap.

What shipped: the reminder ladder (scheduled → reminder_7d → reminder_1d → enforced) riding the existing enforce-plan-limits daily sweep via notifyDueStages, deduplicated by the new ledger and retried whenever a rung was not actually delivered; the persistent admin banner in the dashboard shell; and the tenant-wide blast radius written down explicitly in docs/MONETIZATION.md, with the per-student narrowing recorded as considered and rejected.

Two bugs found on the way that were worth more than the feature: lib/email/send.ts returns false rather than throwing, so #494's try/catch around the send never fired and a dead mail provider was indistinguishable from success; and toLocaleDateString(undefined) in a server component resolves to the Node process locale, so /es pages rendered English dates in both the new banner and the #509 student page.

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 CRON_SECRET secret and CRON_BASE_URL variable being set on the repo. Until those land, the sweep does not run and no rung is sent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request payments Payment provider / billing related severity:medium Medium severity security finding Sub-task

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Access cutoff: single warning email, no in-app signal, undocumented tenant-wide blast radius (#494 follow-up)

1 participant