You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of EPIC #540. Β§4.6. Follow-up to #494 / #517, and interacts with the still-open #513.
1. Nothing clears access_cutoff_at when a school does what the notice tells it to do π΄
reconcileAccessCutoff has exactly five call sites β lib/billing/downgrade-tenant.ts:64, lib/payments/platform-plan-change.ts:111,162, app/actions/admin/billing.ts:363,540 β plus app/api/cron/enforce-plan-limits/route.ts:82. All five are plan-change events. Nothing on the usage side.
But the cutoff email's remediation is a usage action: lib/billing/access-cutoff.ts:116-121 tells the admin "20 active courses exceed the Free plan's limit of 15". Archiving courses or removing members is what the school is being asked to do β and app/actions/teacher/courses.ts (archive/delete) and app/actions/join-school.ts do not import reconcileAccessCutoff at all (verified by full-repo grep).
So the asymmetry is: a plan downgrade schedules a cutoff synchronously; a usage reduction does not clear it. A school that complies stays locked out until the next daily sweep β or, if #513's answer is "nothing on the Dokploy host calls /api/cron/*", indefinitely. The only recovery paths today are buying a bigger plan or a super admin editing the row. app/[locale]/dashboard/student/access-suspended/page.tsx correctly redirects the student out as soon as the cutoff clears; nothing clears it.
Doc drift:docs/MONETIZATION.md:277 states "Event-driven call sites (join-school, course creation, plan changes) do not pass the flag" β asserting join-school and course creation are call sites. They are not. docs/OPERATIONS_GUIDE.md:218 needs the same correction.
2. A failed ledger write makes the notification ladder re-send the same email daily, forever π΄
lib/billing/access-cutoff.ts:198-207 β the access_cutoff_notifications upsert's error is logged (if (error) console.error(...)) and then the function returns { delivered: true } unconditionally.
:279-284 β the next sweep computes the due stage from fetchSentStages(...), reading that same ledger. An unwritten row means the rung is still "unsent", so :113 (reached.find((stage) => !sent.has(stage))) returns the same rung again. And app/api/cron/enforce-plan-limits/route.ts:87-89 counts it under notified, so the run reports healthy.
deliverCutoffStage's own contract comment says a row is written only after at least one recipient send succeeded. The inverse β a send succeeded but was never recorded β is silently violated. Trigger conditions: RLS is enabled on access_cutoff_notifications with no policy (20260725100000_access_cutoff_notifications.sql:45), so any non-service-role caller of reconcileAccessCutoff writes nothing at all; plus any constraint or transient error.
Result: every tenant admin gets the identical cutoff email every day until the cutoff clears. That is precisely the failure mode #517 was written to prevent, inverted.
Related, smaller: the schedule branch (:275-277) forces stage 'scheduled' without consulting the ledger, so a cron/plan-change race double-sends the first rung even though ignoreDuplicates keeps the ledger itself clean.
3. A missing platform_plans row clears live enforcement π‘
lib/billing/access-cutoff.ts:246-249 β a lookup miss yields violations = [], which clears an existing cutoff rather than leaving it alone. Fail-open is documented for checkPlanLimits, but "clear live enforcement on a lookup miss" is a different tradeoff than that doc covers. Decide it deliberately.
Scope
In scope
Call reconcileAccessCutoff (fire-and-forget, try/catch that logs and swallows β reconciliation must never fail the write that already succeeded) after every action that can reduce usage: course archive, course delete, tenant-member removal. Both already hold or can obtain an admin client. The function is idempotent by design (:62-71), so extra calls are no-ops.
Add an explicit "re-check limits" action to the admin billing page, so recovery never depends on a scheduler at all.
deliverCutoffStage: treat the ledger write as part of delivery β if (error) return { delivered: false } β so notifyFailures counts it instead of notified.
Consider consulting the ledger on the schedule branch too.
Decide and document the missing-plan-row behaviour in Β§3.
Fix docs/MONETIZATION.md:277 and docs/OPERATIONS_GUIDE.md:218.
has_course_access()'s cutoff clause (20260724130000:19-35) β correct; the SQL function does know about the cutoff, it is not app-layer-only.
Cutoff-clearing ordering in app/actions/admin/billing.ts:363,540 β both reconcile after the tenants.plan write, so the clear decision sees the new plan. Correct.
Test coverage this must add
reconcileAccessCutoff β the function that actually enforces #494/#517 β has zero coverage. tests/unit/access-cutoff.test.ts:2 imports only decideAccessCutoffAction; access-cutoff-notifications.test.ts:10-12 imports only the three pure helpers. The untested 76 lines (lib/billing/access-cutoff.ts:221-297) hold every stateful decision: the tenants write, the three-way effectiveCutoffAt selection (:266-271), the schedule-branch shortcut, deliverCutoffStage's recipient counting, and defect Β§2 above.
Write table-driven tests over (violations Γ currentCutoffAt Γ notifyDueStages Γ sendEmail result Γ ledger write result). tests/unit/course-access-state.test.ts:17-43 is a good stub-client template.
Also untested: app/actions/platform/billing-health.ts:84-133, the four-query union (incl. the missingTenantIds follow-up) that feeds the well-covered pure functions.
Acceptance criteria
Archiving enough courses to drop back under the limit clears access_cutoff_atsynchronously, and the student's /access-suspended page redirects them back in without waiting for a cron.
Removing a member below the student limit does the same.
The admin billing page has a working "re-check limits" action.
A failing ledger write results in delivered: false and is counted under notifyFailures, not notified β asserted with a stub whose upsert errors.
Running the sweep twice in a row with a failing ledger does not send the same rung twice.
docs/MONETIZATION.md and docs/OPERATIONS_GUIDE.md describe the call sites that actually exist.
npm run test:unit green (370/370 baseline at 146a8cfa) plus new reconcileAccessCutoff coverage.
Effort: SβM Β· Risk: LOW β decideAccessCutoffAction is idempotent, and returning delivered: false on a ledger error costs at most one extra send next sweep, which is strictly better than unbounded repeats.
Part of EPIC #540. Β§4.6. Follow-up to #494 / #517, and interacts with the still-open #513.
1. Nothing clears
access_cutoff_atwhen a school does what the notice tells it to do π΄reconcileAccessCutoffhas exactly five call sites βlib/billing/downgrade-tenant.ts:64,lib/payments/platform-plan-change.ts:111,162,app/actions/admin/billing.ts:363,540β plusapp/api/cron/enforce-plan-limits/route.ts:82. All five are plan-change events. Nothing on the usage side.But the cutoff email's remediation is a usage action:
lib/billing/access-cutoff.ts:116-121tells the admin "20 active courses exceed the Free plan's limit of 15". Archiving courses or removing members is what the school is being asked to do β andapp/actions/teacher/courses.ts(archive/delete) andapp/actions/join-school.tsdo not importreconcileAccessCutoffat all (verified by full-repo grep).So the asymmetry is: a plan downgrade schedules a cutoff synchronously; a usage reduction does not clear it. A school that complies stays locked out until the next daily sweep β or, if #513's answer is "nothing on the Dokploy host calls
/api/cron/*", indefinitely. The only recovery paths today are buying a bigger plan or a super admin editing the row.app/[locale]/dashboard/student/access-suspended/page.tsxcorrectly redirects the student out as soon as the cutoff clears; nothing clears it.Doc drift:
docs/MONETIZATION.md:277states "Event-driven call sites (join-school, course creation, plan changes) do not pass the flag" β asserting join-school and course creation are call sites. They are not.docs/OPERATIONS_GUIDE.md:218needs the same correction.2. A failed ledger write makes the notification ladder re-send the same email daily, forever π΄
lib/billing/access-cutoff.ts:198-207β theaccess_cutoff_notificationsupsert's error is logged (if (error) console.error(...)) and then the function returns{ delivered: true }unconditionally.:279-284β the next sweep computes the due stage fromfetchSentStages(...), reading that same ledger. An unwritten row means the rung is still "unsent", so:113(reached.find((stage) => !sent.has(stage))) returns the same rung again. Andapp/api/cron/enforce-plan-limits/route.ts:87-89counts it undernotified, so the run reports healthy.deliverCutoffStage's own contract comment says a row is written only after at least one recipient send succeeded. The inverse β a send succeeded but was never recorded β is silently violated. Trigger conditions: RLS is enabled onaccess_cutoff_notificationswith no policy (20260725100000_access_cutoff_notifications.sql:45), so any non-service-role caller ofreconcileAccessCutoffwrites nothing at all; plus any constraint or transient error.Result: every tenant admin gets the identical cutoff email every day until the cutoff clears. That is precisely the failure mode #517 was written to prevent, inverted.
Related, smaller: the
schedulebranch (:275-277) forces stage'scheduled'without consulting the ledger, so a cron/plan-change race double-sends the first rung even thoughignoreDuplicateskeeps the ledger itself clean.3. A missing
platform_plansrow clears live enforcement π‘lib/billing/access-cutoff.ts:246-249β a lookup miss yieldsviolations = [], which clears an existing cutoff rather than leaving it alone. Fail-open is documented forcheckPlanLimits, but "clear live enforcement on a lookup miss" is a different tradeoff than that doc covers. Decide it deliberately.Scope
In scope
reconcileAccessCutoff(fire-and-forget,try/catchthat logs and swallows β reconciliation must never fail the write that already succeeded) after every action that can reduce usage: course archive, course delete, tenant-member removal. Both already hold or can obtain an admin client. The function is idempotent by design (:62-71), so extra calls are no-ops.deliverCutoffStage: treat the ledger write as part of delivery βif (error) return { delivered: false }β sonotifyFailurescounts it instead ofnotified.schedulebranch too.docs/MONETIZATION.md:277anddocs/OPERATIONS_GUIDE.md:218.Out of scope
/api/cron/*β that is Access-cutoff reconciliation depends on a cron that may never run on Dokploy (#494 follow-up)Β #513, which has an open draft PR (fix(ops): schedule /api/cron/* from GitHub Actions and reconcile cutoff on usage change (#513)Β #519) blocked on two repo settings (secretCRON_SECRET, variableCRON_BASE_URL). This issue deliberately makes recovery work without a scheduler; Access-cutoff reconciliation depends on a cron that may never run on Dokploy (#494 follow-up)Β #513 makes the periodic sweep exist. Land Access-cutoff reconciliation depends on a cron that may never run on Dokploy (#494 follow-up)Β #513 too, but neither blocks the other.dueCutoffNotificationStage's ladder logic β audited and correct (most-urgent-unsent, failed-send retry, supersede-stale-rung, post-cutoffenforced-only, fresh ladder per cutoff; 14 test cases).AccessCutoffBanneris admin-only by design (app/[locale]/dashboard/layout.tsx:50) and students get the dedicated/access-suspendedpage; both clear correctly. Intentional, not a gap.has_course_access()'s cutoff clause (20260724130000:19-35) β correct; the SQL function does know about the cutoff, it is not app-layer-only.app/actions/admin/billing.ts:363,540β both reconcile after thetenants.planwrite, so the clear decision sees the new plan. Correct.Test coverage this must add
reconcileAccessCutoffβ the function that actually enforces #494/#517 β has zero coverage.tests/unit/access-cutoff.test.ts:2imports onlydecideAccessCutoffAction;access-cutoff-notifications.test.ts:10-12imports only the three pure helpers. The untested 76 lines (lib/billing/access-cutoff.ts:221-297) hold every stateful decision: thetenantswrite, the three-wayeffectiveCutoffAtselection (:266-271), the schedule-branch shortcut,deliverCutoffStage's recipient counting, and defect Β§2 above.Write table-driven tests over (violations Γ currentCutoffAt Γ notifyDueStages Γ sendEmail result Γ ledger write result).
tests/unit/course-access-state.test.ts:17-43is a good stub-client template.Also untested:
app/actions/platform/billing-health.ts:84-133, the four-query union (incl. themissingTenantIdsfollow-up) that feeds the well-covered pure functions.Acceptance criteria
access_cutoff_atsynchronously, and the student's/access-suspendedpage redirects them back in without waiting for a cron.delivered: falseand is counted undernotifyFailures, notnotifiedβ asserted with a stub whose upsert errors.docs/MONETIZATION.mdanddocs/OPERATIONS_GUIDE.mddescribe the call sites that actually exist.npm run test:unitgreen (370/370 baseline at146a8cfa) plus newreconcileAccessCutoffcoverage.Effort: SβM Β· Risk: LOW β
decideAccessCutoffActionis idempotent, and returningdelivered: falseon a ledger error costs at most one extra send next sweep, which is strictly better than unbounded repeats.