diff --git a/app/[locale]/dashboard/layout.tsx b/app/[locale]/dashboard/layout.tsx
index b1844d73..7b6c9495 100644
--- a/app/[locale]/dashboard/layout.tsx
+++ b/app/[locale]/dashboard/layout.tsx
@@ -8,6 +8,7 @@ import { UserNav } from "@/components/user-nav"
import { LanguageSwitcher } from "@/components/language-switcher"
import { GamificationHeaderCard } from "@/components/gamification/gamification-header-card"
import { VerifyEmailBanner } from "@/components/shared/verify-email-banner"
+import { AccessCutoffBanner } from "@/components/shared/access-cutoff-banner"
import type { Metadata } from "next"
export const metadata: Metadata = {
@@ -43,6 +44,10 @@ export default async function DashboardLayout({
{user?.email && !user.email_confirmed_at && (
)}
+ {/* #517: admins are the only ones who can act on a plan-limit
+ cutoff, and before this they only saw it if they happened to
+ open the billing page. */}
+ {role === 'admin' && }
{children}
diff --git a/app/[locale]/dashboard/student/access-suspended/page.tsx b/app/[locale]/dashboard/student/access-suspended/page.tsx
index 342283a9..820ba227 100644
--- a/app/[locale]/dashboard/student/access-suspended/page.tsx
+++ b/app/[locale]/dashboard/student/access-suspended/page.tsx
@@ -1,6 +1,6 @@
import { createAdminClient } from '@/lib/supabase/admin'
import { redirect } from 'next/navigation'
-import { getTranslations } from 'next-intl/server'
+import { getLocale, getTranslations } from 'next-intl/server'
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -14,7 +14,10 @@ import Link from 'next/link'
* real cause and points at the person who can fix it.
*/
export default async function AccessSuspendedPage() {
- const t = await getTranslations('dashboard.student.accessSuspended')
+ const [t, locale] = await Promise.all([
+ getTranslations('dashboard.student.accessSuspended'),
+ getLocale(),
+ ])
const userId = await getCurrentUserId()
if (!userId) redirect('/auth/login')
@@ -56,7 +59,9 @@ export default async function AccessSuspendedPage() {
{t('whatNowBody')}
{t('since', {
- date: new Date(cutoffAt).toLocaleDateString(undefined, { dateStyle: 'long' }),
+ // Explicit locale — `undefined` on the server resolves to the
+ // Node process locale, not the request's (#517).
+ date: new Date(cutoffAt).toLocaleDateString(locale, { dateStyle: 'long' }),
})}
diff --git a/app/api/cron/enforce-plan-limits/route.ts b/app/api/cron/enforce-plan-limits/route.ts
index 734de874..98b26249 100644
--- a/app/api/cron/enforce-plan-limits/route.ts
+++ b/app/api/cron/enforce-plan-limits/route.ts
@@ -16,6 +16,17 @@ export const runtime = 'nodejs'
* `tenants.access_cutoff_at` against its current plan/usage, regardless of
* plan (a free-plan tenant needs this as much as a paid one).
*
+ * It is also the only thing that runs repeatedly while a cutoff is pending,
+ * which makes it the right home for the #517 reminder ladder: `notifyDueStages`
+ * tells the reconciler to send whichever rung (T-7, T-1, or the day access
+ * actually stops) is due and not yet in the `access_cutoff_notifications`
+ * ledger. That is why no separate cron entry was added — the sweep that
+ * already visits every tenant daily is exactly the cadence the ladder needs.
+ *
+ * `notified` / `notifyFailures` in the response make send health visible:
+ * before #517 a failing mail provider left the schedule in place and said
+ * nothing, so the school's first signal was students losing access.
+ *
* Secured by CRON_SECRET env var (set the same value in the cron scheduler).
*/
@@ -37,14 +48,28 @@ export async function GET(req: NextRequest) {
const { data: tenants } = await supabase.from('tenants').select('id')
- const result = { scheduled: 0, cleared: 0, none: 0, errors: 0 }
+ const result = {
+ scheduled: 0,
+ cleared: 0,
+ none: 0,
+ errors: 0,
+ /** Reminder-ladder rungs delivered this run, keyed by stage (#517). */
+ notified: {} as Record,
+ /** Tenants where a rung was due but nobody received it; retried tomorrow. */
+ notifyFailures: 0,
+ }
for (const tenant of tenants || []) {
try {
- const decision = await reconcileAccessCutoff(supabase, tenant.id)
+ const decision = await reconcileAccessCutoff(supabase, tenant.id, { notifyDueStages: true })
if (decision.action === 'schedule') result.scheduled++
else if (decision.action === 'clear') result.cleared++
else result.none++
+
+ if (decision.notifiedStage) {
+ result.notified[decision.notifiedStage] = (result.notified[decision.notifiedStage] ?? 0) + 1
+ }
+ if (decision.notifyFailed) result.notifyFailures++
} catch (err) {
console.error('enforce-plan-limits: reconcile failed for tenant', tenant.id, err)
result.errors++
diff --git a/components/shared/access-cutoff-banner.tsx b/components/shared/access-cutoff-banner.tsx
new file mode 100644
index 00000000..d1f11390
--- /dev/null
+++ b/components/shared/access-cutoff-banner.tsx
@@ -0,0 +1,68 @@
+import Link from 'next/link'
+import { getLocale, getTranslations } from 'next-intl/server'
+import { IconAlertTriangle, IconLock } from '@tabler/icons-react'
+import { getCurrentTenantId } from '@/lib/supabase/tenant'
+import { getAccessCutoffNotice } from '@/lib/billing/access-cutoff-notice'
+
+/**
+ * Persistent in-app warning shown to tenant admins while an access cutoff is
+ * scheduled or in force (issue #517).
+ *
+ * Deliberately not dismissible: the thing it is counting down to is every
+ * student at the school losing access to every course, and a dismissed
+ * countdown is indistinguishable from no countdown. It also states the blast
+ * radius in the same words as the email, so an admin who reads only one of
+ * the two still learns that this is school-wide rather than per-student.
+ *
+ * Renders nothing when there is no cutoff, which is the overwhelmingly common
+ * case — one indexed single-row read on the admin dashboard path.
+ */
+export async function AccessCutoffBanner() {
+ const tenantId = await getCurrentTenantId()
+ const notice = await getAccessCutoffNotice(tenantId)
+ if (!notice) return null
+
+ const [t, locale] = await Promise.all([
+ getTranslations('components.accessCutoffBanner'),
+ getLocale(),
+ ])
+
+ // Explicit locale: on the server `undefined` resolves to the Node process
+ // locale, not the request's, so an /es page would render an English date.
+ const cutoffDate = new Date(notice.cutoffAt).toLocaleDateString(locale, { dateStyle: 'long' })
+
+ return (
+
+ )
+}
diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md
index 12362a60..71bd219b 100644
--- a/docs/MONETIZATION.md
+++ b/docs/MONETIZATION.md
@@ -246,6 +246,39 @@ The two sections above are **creation-time soft caps only** — they block a *ne
- **Page/route gates**: `requireCourseAccess()` (`lib/services/course-access-guard.ts`) on every student course route — course detail, lessons, exercises (list + detail), exams (list, taker, result, review), community — plus `hasCourseAccess()` in the certificate, AI tutor and exercise-submission APIs. A refusal caused by the tenant cutoff (rather than by a missing entitlement) sends the student to `/dashboard/student/access-suspended`, which explains that the school is over its limits and that their enrollment is intact.
- This is a deliberate decision, not an oversight: with no production users yet, real enforcement was shipped directly rather than grandfathering pre-existing over-limit usage.
+#### Blast radius — the cutoff is tenant-wide and all-or-nothing (issue #517)
+
+This is the single most important property of the mechanism and the easiest one to misread from the description above, so it is stated here explicitly.
+
+`has_course_access()` gates on `t.access_cutoff_at IS NULL OR t.access_cutoff_at > now()` with **no per-student predicate**. Once the cutoff passes, access is denied for **every student in the tenant, across every course**, regardless of who or what caused the tenant to go over its limit:
+
+- A free-plan school (50-student limit) that enrols its 51st student loses course access for **all 51** — not for the one student over the line, and not only for new enrolments.
+- A school over its *course* limit also loses **student** access; the two limits share one cutoff.
+- Restoration is equally all-or-nothing: the moment usage is back under the limit or the plan is upgraded, access returns for everyone at once. There is no partial or staged restoration.
+
+What the cutoff does **not** touch:
+
+- `enrollments`, `entitlements`, `transactions` and progress records are untouched — nothing is deleted, revoked or refunded. The cutoff is a gate in front of the data, not a mutation of it.
+- Teachers, admins and course authors keep access, via the `is_tenant_staff()` / author escape hatches in the RLS policies — so a school can still fix its content while cut off.
+- Published preview lessons (`is_preview`) stay public, so the school's funnel keeps working.
+- Other tenants are unaffected; `access_cutoff_at` lives on the `tenants` row.
+
+**Why all-or-nothing.** #493 §1.1 offered two options — "read-only mode past N days over limit" or "a hard student-count cutoff" — and the hard cutoff is the one that shipped. Narrowing it to only the students over the limit (e.g. the most recently enrolled) was considered and rejected: it would make one student's access depend on the join order of their peers, produce a support burden that is impossible to explain to the student affected, and require a per-student ordering rule inside a `STABLE SECURITY DEFINER` function on the hot path of every content read. The blunt version is legible to the school admin — who is the person who can actually fix it — and is reversible in one action. If per-student narrowing is ever revisited it should be its own issue, not a change smuggled into the enforcement function.
+
+#### Notification ladder (issue #517)
+
+Because the blast radius is this wide, the school has to be told more than once. #494 sent exactly one email at scheduling time — a design consequence of `decideAccessCutoffAction()` returning `'schedule'` only once per cutoff — with no retry if it failed and no message on the day access actually stopped.
+
+- **Ladder:** `scheduled` (immediately, T-14) → `reminder_7d` → `reminder_1d` → `enforced` (the first sweep after access actually stops). `accessCutoffWarningTemplate({ stage, ... })` gives each rung its own subject and copy; the `enforced` notice is written in the past tense.
+- **Ledger:** `access_cutoff_notifications` (migration `20260725100000_access_cutoff_notifications.sql`), one row per `(tenant_id, cutoff_at, stage)` under a unique constraint. Keyed on `cutoff_at` as well as tenant, so a cleared-then-rescheduled cutoff correctly starts a fresh ladder for its new deadline. Service-role only: RLS is enabled with no policies.
+- **Retry:** a ledger row is written **only** when at least one admin address actually received the mail. A rung nobody received stays unrecorded and is therefore still due on the next daily sweep. `dueCutoffNotificationStage()` returns at most one rung — always the most urgent reached-but-unsent one — so an undelivered early rung is superseded rather than queued behind an urgent one (no "you have 7 days" landing beside "access is now paused").
+- **Where it runs:** `app/api/cron/enforce-plan-limits/route.ts` passes `notifyDueStages: true`. No new cron entry was added — the sweep that already visits every tenant daily is exactly the cadence the ladder needs. Its response 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) do **not** pass the flag, so user-facing actions never pay email latency for a reminder the cron will send anyway.
+
+#### In-app signals (issue #517)
+
+- **Tenant admins**: `` (`components/shared/access-cutoff-banner.tsx`) renders in the dashboard shell (`app/[locale]/dashboard/layout.tsx`) for `role === 'admin'`, on every admin page rather than only on billing. Two states — scheduled (days remaining) and active (access paused) — both naming the school-wide blast radius in the same words as the email. Deliberately **not** dismissible: a dismissed countdown to losing all student access is indistinguishable from no countdown. Backed by `describeAccessCutoff()` / `getAccessCutoffNotice()` (`lib/billing/access-cutoff-notice.ts`), a single indexed single-row read gated to admins.
+- **Students**: `/dashboard/student/access-suspended` (shipped in #509, above) is the specific "your school's account needs attention" state — it names the school's plan limits as the cause and states that the enrolment is intact.
+
---
## Currency Support
diff --git a/lib/billing/access-cutoff-notice.ts b/lib/billing/access-cutoff-notice.ts
new file mode 100644
index 00000000..28cdb156
--- /dev/null
+++ b/lib/billing/access-cutoff-notice.ts
@@ -0,0 +1,72 @@
+/**
+ * The tenant-admin view of an access cutoff (issue #517).
+ *
+ * #494 stored `tenants.access_cutoff_at` and mailed it once. The only place
+ * that ever read it back for a human was `/dashboard/admin/billing`, so an
+ * admin who never opened that page had no in-app signal at all — the first
+ * they'd know was students reporting they'd lost access.
+ *
+ * This module answers one question for the dashboard shell: is there a cutoff
+ * an admin needs to see right now, and how urgent is it. The day arithmetic is
+ * pure and `now`-injectable so it can be reasoned about without a clock.
+ */
+
+import { createAdminClient } from '@/lib/supabase/admin'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+export interface AccessCutoffNotice {
+ cutoffAt: string
+ /** True once the cutoff has passed — students have already lost access. */
+ active: boolean
+ /**
+ * Whole days until access stops, rounded up, floored at 0. `1` means
+ * "tomorrow"; `0` alongside `active: false` means "later today".
+ */
+ daysRemaining: number
+}
+
+/**
+ * Pure: turn a stored cutoff timestamp into what the banner needs.
+ * Returns null when there is nothing to show (no cutoff, unparseable value).
+ */
+export function describeAccessCutoff(input: {
+ cutoffAt: string | null | undefined
+ now: Date
+}): AccessCutoffNotice | null {
+ const { cutoffAt, now } = input
+ if (!cutoffAt) return null
+
+ const cutoffMs = new Date(cutoffAt).getTime()
+ if (Number.isNaN(cutoffMs)) return null
+
+ const msRemaining = cutoffMs - now.getTime()
+
+ return {
+ cutoffAt,
+ active: msRemaining <= 0,
+ daysRemaining: msRemaining <= 0 ? 0 : Math.ceil(msRemaining / DAY_MS),
+ }
+}
+
+/**
+ * Read the current tenant's cutoff state. Uses the admin client because
+ * `tenants` is not readable in full by an authenticated member, and this runs
+ * on every admin dashboard render — a single indexed single-row read, gated by
+ * the caller to admins only.
+ */
+export async function getAccessCutoffNotice(
+ tenantId: string,
+ now: Date = new Date()
+): Promise {
+ if (!tenantId) return null
+
+ const supabase = createAdminClient()
+ const { data: tenant } = await supabase
+ .from('tenants')
+ .select('access_cutoff_at')
+ .eq('id', tenantId)
+ .maybeSingle()
+
+ return describeAccessCutoff({ cutoffAt: tenant?.access_cutoff_at, now })
+}
diff --git a/lib/billing/access-cutoff.ts b/lib/billing/access-cutoff.ts
index e63a80ff..0075587e 100644
--- a/lib/billing/access-cutoff.ts
+++ b/lib/billing/access-cutoff.ts
@@ -1,5 +1,6 @@
/**
- * Real access enforcement for over-limit tenants (issue #494).
+ * Real access enforcement for over-limit tenants (issue #494) and the
+ * notification ladder around it (issue #517).
*
* `has_course_access()` never considered a tenant's plan/billing state — a
* tenant downgraded (or that simply outgrew its plan) kept full access to
@@ -7,27 +8,44 @@
* and schedules `tenants.access_cutoff_at`, the timestamp
* `has_course_access()` checks (see migration 20260724130000).
*
- * Split mirrors `plan-limits.ts`: a pure decision function (unit-testable,
+ * Split mirrors `plan-limits.ts`: pure decision functions (unit-testable,
* no DB) plus an impure reconciler that every plan-state transition calls —
* the webhook-driven downgrade, both admin plan-change actions, the portal
* change handler, and a daily cron sweep for organic growth over a limit
* with no plan-change event. All of them can call `reconcileAccessCutoff`
* freely; the decision function's null-check on `currentCutoffAt` makes
* repeated calls idempotent (no double-scheduling, no duplicate emails).
+ *
+ * #517: that idempotence was also the communication bug. Because
+ * `decideAccessCutoffAction` returns `'schedule'` exactly once per cutoff,
+ * the school got exactly one email, 14 days out, with no retry if it failed
+ * and no word at all on the day access actually stopped. The ladder below
+ * (`scheduled` → `reminder_7d` → `reminder_1d` → `enforced`) fixes that: the
+ * daily sweep passes `notifyDueStages` and sends whichever rung is due,
+ * de-duplicated against the `access_cutoff_notifications` ledger.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { sendEmail } from '@/lib/email/send'
-import { accessCutoffWarningTemplate } from '@/lib/email/templates/access-cutoff-warning'
+import {
+ accessCutoffWarningTemplate,
+ type AccessCutoffStage,
+} from '@/lib/email/templates/access-cutoff-warning'
import { countTenantUsage, computePlanLimitViolations, type PlanLimitViolation } from '@/lib/billing/plan-limits'
import { getTenantAdminEmails } from '@/lib/billing/tenant-admins'
export const ACCESS_CUTOFF_GRACE_DAYS = 14
const DAY_MS = 24 * 60 * 60 * 1000
+export type { AccessCutoffStage }
+
export interface AccessCutoffDecision {
action: 'schedule' | 'clear' | 'none'
cutoffAt?: string
+ /** #517: which notification rung was delivered on this call, if any. */
+ notifiedStage?: AccessCutoffStage
+ /** #517: a rung was due but nobody received it — the next sweep retries. */
+ notifyFailed?: boolean
}
/**
@@ -53,6 +71,48 @@ export function decideAccessCutoffAction(input: {
return { action: 'none' }
}
+/**
+ * Pure decision: which rung of the notification ladder is due right now.
+ *
+ * Returns **at most one** stage — always the most urgent rung whose trigger
+ * point has been reached and which the ledger has not yet recorded. Sending
+ * two rungs in one sweep would put contradictory messages in the same inbox
+ * ("you have 7 days" beside "access is now paused"), so a less urgent rung
+ * that was never delivered is superseded rather than queued behind the
+ * urgent one.
+ *
+ * That "most urgent unsent" rule is also the retry #517 asks for: a stage
+ * whose sends all failed is never written to the ledger, so it is still
+ * unsent — and still the most urgent reached rung — on the next daily sweep.
+ *
+ * Once the cutoff has passed only `enforced` can be due; a future-tense
+ * warning delivered after the fact is worse than silence.
+ */
+export function dueCutoffNotificationStage(input: {
+ cutoffAt: string
+ sentStages: AccessCutoffStage[]
+ now: Date
+}): AccessCutoffStage | null {
+ const { cutoffAt, sentStages, now } = input
+
+ const msRemaining = new Date(cutoffAt).getTime() - now.getTime()
+ if (Number.isNaN(msRemaining)) return null
+
+ const sent = new Set(sentStages)
+
+ // Most urgent first; the first unsent one wins.
+ const reached: AccessCutoffStage[] = []
+ if (msRemaining <= 0) {
+ reached.push('enforced')
+ } else {
+ if (msRemaining <= DAY_MS) reached.push('reminder_1d')
+ if (msRemaining <= 7 * DAY_MS) reached.push('reminder_7d')
+ reached.push('scheduled')
+ }
+
+ return reached.find((stage) => !sent.has(stage)) ?? null
+}
+
function formatViolationReasons(violations: PlanLimitViolation[], planName: string): string[] {
return violations.map((v) =>
v.resource === 'courses'
@@ -61,16 +121,107 @@ function formatViolationReasons(violations: PlanLimitViolation[], planName: stri
)
}
+/** Which rungs the ledger already holds for this exact cutoff timestamp. */
+async function fetchSentStages(
+ admin: SupabaseClient,
+ tenantId: string,
+ cutoffAt: string
+): Promise {
+ const { data } = await admin
+ .from('access_cutoff_notifications')
+ .select('stage')
+ .eq('tenant_id', tenantId)
+ .eq('cutoff_at', cutoffAt)
+
+ return ((data as { stage: AccessCutoffStage }[] | null) ?? []).map((row) => row.stage)
+}
+
+/**
+ * Send one rung to every tenant admin, then record it.
+ *
+ * The ledger row is written only when at least one address actually received
+ * the mail. A rung nobody received stays unrecorded so the next sweep tries
+ * again — the difference between "we told them" and "we attempted to tell
+ * them" is the whole of #517's first gap.
+ *
+ * Delivery is judged on `sendEmail`'s boolean, not on whether it threw:
+ * `lib/email/send.ts` swallows both a missing Mailgun config and a Mailgun API
+ * error and returns `false`. #494's `try/catch` around the send was therefore
+ * near-decorative — a dead mail provider produced a silent no-op that looked
+ * exactly like success. Only `true` counts.
+ */
+async function deliverCutoffStage(
+ admin: SupabaseClient,
+ tenantId: string,
+ stage: AccessCutoffStage,
+ ctx: {
+ cutoffAt: string
+ schoolName: string
+ planName: string
+ violations: PlanLimitViolation[]
+ sendEmailFn: typeof sendEmail
+ }
+): Promise<{ delivered: boolean }> {
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.example.com'
+ const template = accessCutoffWarningTemplate({
+ stage,
+ schoolName: ctx.schoolName,
+ planName: ctx.planName,
+ reasons: formatViolationReasons(ctx.violations, ctx.planName),
+ cutoffDate: new Date(ctx.cutoffAt).toLocaleDateString('en-US', { dateStyle: 'long' }),
+ billingUrl: `${appUrl}/dashboard/admin/billing`,
+ })
+
+ const emails = await getTenantAdminEmails(admin, tenantId)
+ let recipientCount = 0
+ for (const to of emails) {
+ try {
+ if ((await ctx.sendEmailFn({ to, ...template })) === true) recipientCount++
+ else console.error(`reconcileAccessCutoff: ${stage} email not delivered to ${to}`)
+ } catch (err) {
+ console.error(`reconcileAccessCutoff: ${stage} email send failed`, err)
+ }
+ }
+
+ if (recipientCount === 0) {
+ // Either the tenant has no active admin at all, or no send succeeded.
+ // Both are worth retrying tomorrow; neither counts as delivered.
+ console.error(
+ `reconcileAccessCutoff: ${stage} notification not delivered for tenant ${tenantId} (${emails.length} candidate recipients)`
+ )
+ return { delivered: false }
+ }
+
+ // The unique (tenant_id, cutoff_at, stage) constraint is what actually
+ // prevents repeats; ignoreDuplicates turns a race between the sweep and an
+ // event-driven reconcile into a no-op rather than an error.
+ const { error } = await admin
+ .from('access_cutoff_notifications')
+ .upsert(
+ { tenant_id: tenantId, cutoff_at: ctx.cutoffAt, stage, recipient_count: recipientCount },
+ { onConflict: 'tenant_id,cutoff_at,stage', ignoreDuplicates: true }
+ )
+
+ if (error) console.error('reconcileAccessCutoff: ledger write failed', error)
+
+ return { delivered: true }
+}
+
/**
* Fetch a tenant's current plan/usage, decide, and apply: write
* `access_cutoff_at` and (on `schedule`) email the tenant's admins with the
* exact date and reasons. Safe to call from any plan-state transition —
* a no-op when nothing needs to change.
+ *
+ * With `notifyDueStages` (the daily sweep passes it) it additionally sends
+ * whichever rung of the reminder ladder is due for an already-scheduled
+ * cutoff. Off by default so user-facing actions — joining a school, creating
+ * a course — never pay email latency for a reminder the cron sends anyway.
*/
export async function reconcileAccessCutoff(
admin: SupabaseClient,
tenantId: string,
- opts?: { sendEmailFn?: typeof sendEmail; now?: Date }
+ opts?: { sendEmailFn?: typeof sendEmail; now?: Date; notifyDueStages?: boolean }
): Promise {
const sendEmailFn = opts?.sendEmailFn ?? sendEmail
const now = opts?.now ?? new Date()
@@ -103,32 +254,44 @@ export async function reconcileAccessCutoff(
now,
})
- if (decision.action === 'none') return decision
+ if (decision.action !== 'none') {
+ await admin
+ .from('tenants')
+ .update({ access_cutoff_at: decision.cutoffAt ?? null, updated_at: now.toISOString() })
+ .eq('id', tenantId)
+ }
- await admin
- .from('tenants')
- .update({ access_cutoff_at: decision.cutoffAt ?? null, updated_at: now.toISOString() })
- .eq('id', tenantId)
+ // The cutoff in force after this call: freshly scheduled, or the one already
+ // on the row that survived a `none` decision (still over limit).
+ const effectiveCutoffAt =
+ decision.action === 'schedule'
+ ? decision.cutoffAt!
+ : decision.action === 'clear'
+ ? null
+ : tenant.access_cutoff_at
- if (decision.action === 'schedule' && decision.cutoffAt) {
- const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.example.com'
- const template = accessCutoffWarningTemplate({
- schoolName: tenant.name || 'your school',
- planName: plan?.name || tenant.plan || 'Free',
- reasons: formatViolationReasons(violations, plan?.name || tenant.plan || 'Free'),
- cutoffDate: new Date(decision.cutoffAt).toLocaleDateString('en-US', { dateStyle: 'long' }),
- billingUrl: `${appUrl}/dashboard/admin/billing`,
- })
-
- const emails = await getTenantAdminEmails(admin, tenantId)
- for (const to of emails) {
- try {
- await sendEmailFn({ to, ...template })
- } catch (err) {
- console.error('reconcileAccessCutoff: email send failed', err)
- }
- }
- }
+ if (!effectiveCutoffAt) return decision
+
+ const stage: AccessCutoffStage | null =
+ decision.action === 'schedule'
+ ? 'scheduled'
+ : opts?.notifyDueStages
+ ? dueCutoffNotificationStage({
+ cutoffAt: effectiveCutoffAt,
+ sentStages: await fetchSentStages(admin, tenantId, effectiveCutoffAt),
+ now,
+ })
+ : null
+
+ if (!stage) return decision
+
+ const { delivered } = await deliverCutoffStage(admin, tenantId, stage, {
+ cutoffAt: effectiveCutoffAt,
+ schoolName: tenant.name || 'your school',
+ planName: plan?.name || tenant.plan || 'Free',
+ violations,
+ sendEmailFn,
+ })
- return decision
+ return delivered ? { ...decision, notifiedStage: stage } : { ...decision, notifyFailed: true }
}
diff --git a/lib/email/templates/access-cutoff-warning.ts b/lib/email/templates/access-cutoff-warning.ts
index 0b9e1419..bd10cc90 100644
--- a/lib/email/templates/access-cutoff-warning.ts
+++ b/lib/email/templates/access-cutoff-warning.ts
@@ -1,31 +1,102 @@
+/**
+ * Access-cutoff notification emails (issues #494, #517).
+ *
+ * #494 sent a single message at scheduling time. #517 turned that into a
+ * ladder — scheduled → T-7 → T-1 → enforced — so the template has to speak in
+ * four registers, the last of which is past tense: by the time `enforced`
+ * goes out, students have already lost access and telling them it "will be"
+ * restricted would be wrong.
+ *
+ * The per-stage copy is deliberately explicit about the blast radius (every
+ * student, all courses) because that is the fact a school most needs and is
+ * least likely to infer — see the "Blast radius" section of docs/MONETIZATION.md.
+ */
+
+export type AccessCutoffStage = 'scheduled' | 'reminder_7d' | 'reminder_1d' | 'enforced'
+
export interface AccessCutoffWarningData {
schoolName: string
planName: string
reasons: string[]
cutoffDate: string
billingUrl: string
+ /** Which rung of the reminder ladder this is. Defaults to the #494 behaviour. */
+ stage?: AccessCutoffStage
+}
+
+interface StageCopy {
+ subject: string
+ heading: string
+ lead: string
+ consequence: string
+ callToAction: string
+}
+
+function stageCopy(stage: AccessCutoffStage, data: AccessCutoffWarningData): StageCopy {
+ const { schoolName, cutoffDate, planName } = data
+
+ switch (stage) {
+ case 'reminder_7d':
+ return {
+ subject: `1 week left: student access to ${schoolName} will be cut off on ${cutoffDate}`,
+ heading: 'One Week Until Course Access Is Restricted',
+ lead: `${schoolName} is still over the limits of its ${planName} plan:`,
+ consequence: `In one week — on ${cutoffDate} — every student at your school will lose access to all courses, including lessons, exams, exercises and certificates. Access returns automatically once usage is back within the plan's limits or you upgrade.`,
+ callToAction: 'There is still time to resolve this without any interruption for your students.',
+ }
+ case 'reminder_1d':
+ return {
+ subject: `Tomorrow: student access to ${schoolName} will be cut off on ${cutoffDate}`,
+ heading: 'Course Access Is Restricted Tomorrow',
+ lead: `This is the final notice before access is paused. ${schoolName} is still over the limits of its ${planName} plan:`,
+ consequence: `Tomorrow — on ${cutoffDate} — every student at your school will lose access to all courses, including lessons, exams, exercises and certificates. Nothing is deleted and no enrollment is lost, but no student will be able to open any course until this is resolved.`,
+ callToAction: 'Upgrading or reducing usage today prevents the interruption entirely.',
+ }
+ case 'enforced':
+ return {
+ subject: `Student access to ${schoolName} is now paused`,
+ heading: 'Course Access Is Now Paused',
+ lead: `As of ${cutoffDate}, course access for ${schoolName} is paused because the school is over the limits of its ${planName} plan:`,
+ consequence: `Every student at your school has lost access to all courses — lessons, exams, exercises and certificates. Their enrollments, progress and purchases are all intact and nothing has been deleted; access is restored automatically, for everyone at once, as soon as usage is back within the plan's limits or you upgrade.`,
+ callToAction: 'Restore access for your students by upgrading your plan or reducing usage.',
+ }
+ case 'scheduled':
+ default:
+ return {
+ subject: `Action required: student access to ${schoolName} will be cut off on ${cutoffDate}`,
+ heading: 'Course Access Will Be Restricted',
+ lead: `${schoolName} is on the ${planName} plan and currently exceeds its limits:`,
+ consequence: `If this isn't resolved by ${cutoffDate}, every student at your school will lose access to all courses — lessons, exams, exercises, and certificates — until usage is brought back within the plan's limits or you upgrade.`,
+ callToAction:
+ 'To keep access uninterrupted, upgrade your plan or reduce usage (archive courses / remove students) before the date above.',
+ }
+ }
}
export function accessCutoffWarningTemplate(data: AccessCutoffWarningData): {
subject: string
html: string
} {
+ const stage = data.stage ?? 'scheduled'
+ const copy = stageCopy(stage, data)
+ const buttonLabel = stage === 'enforced' ? 'Restore Access' : 'Manage Billing'
+
return {
- subject: `Action required: student access to ${data.schoolName} will be cut off on ${data.cutoffDate}`,
+ subject: copy.subject,
html: `
-
Course Access Will Be Restricted
+
${copy.heading}
Hi,
-
${data.schoolName} is on the ${data.planName} plan and currently exceeds its limits:
+
${copy.lead}
${data.reasons.map((r) => `
${r}
`).join('\n ')}
-
If this isn't resolved by ${data.cutoffDate}, every student at your school will lose access to all courses — lessons, exams, exercises, and certificates — until usage is brought back within the plan's limits or you upgrade.
-
To keep access uninterrupted, upgrade your plan or reduce usage (archive courses / remove students) before the date above.
If you believe this is an error, please contact support.
diff --git a/messages/en.json b/messages/en.json
index 43e05a1c..98e30f13 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3700,6 +3700,15 @@
"resendSuccess": "Confirmation email sent. Check your inbox.",
"dismiss": "Dismiss"
},
+ "accessCutoffBanner": {
+ "activeTitle": "Course access is paused for your whole school.",
+ "scheduledTitle": "Course access stops for your whole school in {days} days.",
+ "scheduledTitleImminent": "Course access stops for your whole school tomorrow.",
+ "activeBody": "Since {date}, every student has lost access to all courses because the school is over its plan limits. Enrollments and purchases are intact — access returns for everyone as soon as you upgrade or reduce usage.",
+ "scheduledBody": "On {date}, every student will lose access to all courses unless usage is back within your plan's limits. Nothing is deleted, but no student will be able to open a course.",
+ "manageBilling": "Manage billing",
+ "restoreAccess": "Restore access"
+ },
"connectClaude": {
"title": "Connect Claude",
"description": "Add the LMS as a custom connector — no token needed, you sign in with your LMS account.",
diff --git a/messages/es.json b/messages/es.json
index 9eecc0f4..05e2fd80 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3545,6 +3545,15 @@
"resendSuccess": "Correo de confirmación enviado. Revisa tu bandeja.",
"dismiss": "Descartar"
},
+ "accessCutoffBanner": {
+ "activeTitle": "El acceso a los cursos está pausado para toda tu escuela.",
+ "scheduledTitle": "El acceso a los cursos se detiene para toda tu escuela en {days} días.",
+ "scheduledTitleImminent": "El acceso a los cursos se detiene para toda tu escuela mañana.",
+ "activeBody": "Desde el {date}, todos los estudiantes perdieron el acceso a los cursos porque la escuela superó los límites de su plan. Las inscripciones y las compras están intactas: el acceso vuelve para todos en cuanto mejores el plan o reduzcas el uso.",
+ "scheduledBody": "El {date}, todos los estudiantes perderán el acceso a los cursos si el uso no vuelve a estar dentro de los límites de tu plan. No se elimina nada, pero ningún estudiante podrá abrir un curso.",
+ "manageBilling": "Gestionar facturación",
+ "restoreAccess": "Restaurar acceso"
+ },
"connectClaude": {
"title": "Conectar Claude",
"description": "Agrega el LMS como conector personalizado — sin tokens, inicias sesión con tu cuenta del LMS.",
diff --git a/supabase/migrations/20260725100000_access_cutoff_notifications.sql b/supabase/migrations/20260725100000_access_cutoff_notifications.sql
new file mode 100644
index 00000000..7cfafe98
--- /dev/null
+++ b/supabase/migrations/20260725100000_access_cutoff_notifications.sql
@@ -0,0 +1,48 @@
+-- Issue #517 — a send ledger for access-cutoff notifications.
+--
+-- #494 shipped exactly one email per cutoff, sent from the single 'schedule'
+-- transition in reconcileAccessCutoff(). Nothing ran at T-7, T-1 or when the
+-- cutoff actually took effect, and a failed send was swallowed with no retry.
+--
+-- This table is what makes a reminder ladder possible: it records which stage
+-- of the ladder has already been delivered for a given cutoff, so the daily
+-- enforce-plan-limits sweep can send whatever is due without ever repeating
+-- itself. The unique constraint — not application logic — is what guarantees
+-- that, since the sweep runs unattended and may overlap with an event-driven
+-- reconcile.
+--
+-- Keyed on cutoff_at as well as tenant_id on purpose: a cutoff that is cleared
+-- (usage back under limit) and later rescheduled is a genuinely new 14-day
+-- deadline and deserves a fresh ladder, not silence because the old one was
+-- already notified.
+--
+-- A row is written ONLY after at least one recipient send succeeded. A stage
+-- whose sends all failed stays unrecorded, which is exactly what makes the
+-- next daily sweep retry it.
+
+CREATE TABLE IF NOT EXISTS public.access_cutoff_notifications (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
+ -- The tenants.access_cutoff_at value this notification was about. Scopes the
+ -- ladder to one specific deadline.
+ cutoff_at timestamptz NOT NULL,
+ -- 'scheduled' (immediate, at scheduling time) | 'reminder_7d' | 'reminder_1d'
+ -- | 'enforced' (first sweep after the cutoff took effect).
+ stage text NOT NULL CHECK (stage IN ('scheduled', 'reminder_7d', 'reminder_1d', 'enforced')),
+ sent_at timestamptz NOT NULL DEFAULT now(),
+ -- How many admin addresses actually received it; > 0 by construction.
+ recipient_count integer NOT NULL DEFAULT 0,
+ CONSTRAINT access_cutoff_notifications_unique_stage UNIQUE (tenant_id, cutoff_at, stage)
+);
+
+CREATE INDEX IF NOT EXISTS idx_access_cutoff_notifications_tenant
+ ON public.access_cutoff_notifications (tenant_id, cutoff_at);
+
+-- Service-role only. Every writer is the cron sweep or a server-side
+-- reconciler running with the service key; no authenticated user has any
+-- reason to read or write the ledger, so RLS is enabled with no policy at all
+-- (deny-by-default for anon/authenticated, service_role bypasses).
+ALTER TABLE public.access_cutoff_notifications ENABLE ROW LEVEL SECURITY;
+
+COMMENT ON TABLE public.access_cutoff_notifications IS
+ 'Issue #517: per-cutoff notification send ledger. One row per (tenant, cutoff_at, stage), written only on a successful send so failed stages are retried by the next daily sweep.';
diff --git a/tests/unit/access-cutoff-notifications.test.ts b/tests/unit/access-cutoff-notifications.test.ts
new file mode 100644
index 00000000..28a26e92
--- /dev/null
+++ b/tests/unit/access-cutoff-notifications.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Issue #517 — the access-cutoff notification ladder.
+ *
+ * #494 shipped one email per cutoff and no retry. These tests pin the two
+ * pure decisions that replace it: which rung of the ladder is due
+ * (`dueCutoffNotificationStage`) and what an admin should be told in-app
+ * (`describeAccessCutoff`).
+ */
+import { describe, it, expect } from 'vitest'
+import { dueCutoffNotificationStage } from '@/lib/billing/access-cutoff'
+import { describeAccessCutoff } from '@/lib/billing/access-cutoff-notice'
+import { accessCutoffWarningTemplate } from '@/lib/email/templates/access-cutoff-warning'
+
+const CUTOFF = '2026-08-07T00:00:00.000Z'
+const at = (iso: string) => new Date(iso)
+
+describe('dueCutoffNotificationStage', () => {
+ it('sends the scheduling notice immediately when the ledger is empty', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: [],
+ now: at('2026-07-24T00:00:00.000Z'), // T-14
+ })
+ ).toBe('scheduled')
+ })
+
+ it('stays quiet mid-window once the scheduling notice is recorded', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled'],
+ now: at('2026-07-28T00:00:00.000Z'), // T-10
+ })
+ ).toBeNull()
+ })
+
+ it('sends the 7-day reminder at T-7', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled'],
+ now: at('2026-07-31T00:00:00.000Z'),
+ })
+ ).toBe('reminder_7d')
+ })
+
+ it('does not repeat the 7-day reminder once recorded', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled', 'reminder_7d'],
+ now: at('2026-08-02T00:00:00.000Z'), // T-5
+ })
+ ).toBeNull()
+ })
+
+ it('sends the final reminder at T-1', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled', 'reminder_7d'],
+ now: at('2026-08-06T00:00:00.000Z'),
+ })
+ ).toBe('reminder_1d')
+ })
+
+ it('sends the enforced notice once the cutoff has passed', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled', 'reminder_7d', 'reminder_1d'],
+ now: at('2026-08-07T00:00:01.000Z'),
+ })
+ ).toBe('enforced')
+ })
+
+ it('goes silent after the enforced notice — no daily nagging', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled', 'reminder_7d', 'reminder_1d', 'enforced'],
+ now: at('2026-08-20T00:00:00.000Z'),
+ })
+ ).toBeNull()
+ })
+
+ // The retry the issue asks for: a send that threw is never written to the
+ // ledger, so the stage is still unsent on the next daily sweep.
+ it('retries a scheduling notice whose sends all failed', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: [],
+ now: at('2026-07-25T00:00:00.000Z'), // next sweep after a failed T-14 send
+ })
+ ).toBe('scheduled')
+ })
+
+ it('retries a failed 7-day reminder on the following sweep', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: ['scheduled'],
+ now: at('2026-08-01T00:00:00.000Z'), // T-6, reminder_7d still unsent
+ })
+ ).toBe('reminder_7d')
+ })
+
+ // A stale rung must never overtake an urgent one: two contradictory
+ // messages in one inbox is worse than one missing message.
+ it('supersedes an undelivered early rung once a more urgent one is reached', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: [],
+ now: at('2026-08-06T12:00:00.000Z'), // T-12h, nothing ever sent
+ })
+ ).toBe('reminder_1d')
+ })
+
+ it('only ever sends the enforced notice after the cutoff, never a warning', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: CUTOFF,
+ sentStages: [], // every earlier rung failed
+ now: at('2026-08-08T00:00:00.000Z'),
+ })
+ ).toBe('enforced')
+ })
+
+ // A cleared-then-rescheduled cutoff is a new deadline: the ledger is keyed
+ // on cutoff_at, so the new window starts with an empty sentStages list.
+ it('starts a fresh ladder for a rescheduled cutoff', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: '2026-09-15T00:00:00.000Z',
+ sentStages: [],
+ now: at('2026-09-01T00:00:00.000Z'),
+ })
+ ).toBe('scheduled')
+ })
+
+ it('returns null for an unparseable cutoff rather than mailing garbage', () => {
+ expect(
+ dueCutoffNotificationStage({
+ cutoffAt: 'not-a-date',
+ sentStages: [],
+ now: at('2026-08-01T00:00:00.000Z'),
+ })
+ ).toBeNull()
+ })
+})
+
+describe('describeAccessCutoff', () => {
+ it('returns null when no cutoff is scheduled', () => {
+ expect(describeAccessCutoff({ cutoffAt: null, now: at('2026-07-24T00:00:00.000Z') })).toBeNull()
+ })
+
+ it('reports days remaining while the cutoff is still in the future', () => {
+ expect(describeAccessCutoff({ cutoffAt: CUTOFF, now: at('2026-07-24T00:00:00.000Z') })).toEqual({
+ cutoffAt: CUTOFF,
+ active: false,
+ daysRemaining: 14,
+ })
+ })
+
+ it('rounds a partial day up so "tomorrow" never reads as 0 days', () => {
+ const notice = describeAccessCutoff({ cutoffAt: CUTOFF, now: at('2026-08-06T06:00:00.000Z') })
+ expect(notice).toEqual({ cutoffAt: CUTOFF, active: false, daysRemaining: 1 })
+ })
+
+ it('flags the cutoff as active once it has passed', () => {
+ expect(describeAccessCutoff({ cutoffAt: CUTOFF, now: at('2026-08-07T00:00:01.000Z') })).toEqual({
+ cutoffAt: CUTOFF,
+ active: true,
+ daysRemaining: 0,
+ })
+ })
+
+ it('returns null for an unparseable cutoff rather than rendering NaN days', () => {
+ expect(describeAccessCutoff({ cutoffAt: 'not-a-date', now: at('2026-08-01T00:00:00.000Z') })).toBeNull()
+ })
+})
+
+describe('accessCutoffWarningTemplate stages', () => {
+ const base = {
+ schoolName: 'Code Academy',
+ planName: 'Free',
+ reasons: ['51 active students exceed the Free plan’s limit of 50'],
+ cutoffDate: 'August 7, 2026',
+ billingUrl: 'https://code-academy.example.com/dashboard/admin/billing',
+ }
+
+ it('defaults to the #494 scheduling copy when no stage is given', () => {
+ const { subject } = accessCutoffWarningTemplate(base)
+ expect(subject).toBe(
+ 'Action required: student access to Code Academy will be cut off on August 7, 2026'
+ )
+ })
+
+ it('gives each rung a distinct subject so reminders are not threaded as duplicates', () => {
+ const subjects = (['scheduled', 'reminder_7d', 'reminder_1d', 'enforced'] as const).map(
+ (stage) => accessCutoffWarningTemplate({ ...base, stage }).subject
+ )
+ expect(new Set(subjects).size).toBe(4)
+ })
+
+ it('writes the enforced notice in the past tense — access is already gone', () => {
+ const { subject, html } = accessCutoffWarningTemplate({ ...base, stage: 'enforced' })
+ expect(subject).toBe('Student access to Code Academy is now paused')
+ expect(html).toContain('has lost access')
+ expect(html).not.toContain('will lose access')
+ })
+
+ it('states the school-wide blast radius on every rung', () => {
+ for (const stage of ['scheduled', 'reminder_7d', 'reminder_1d', 'enforced'] as const) {
+ expect(accessCutoffWarningTemplate({ ...base, stage }).html).toMatch(
+ /every student at your school/i
+ )
+ }
+ })
+})