Skip to content

Access-cutoff loop closure: nothing clears the cutoff, and a ledger failure emails admins daily foreverΒ #550

Description

@guillermoscript

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.

Out of scope

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_at synchronously, 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.

Metadata

Metadata

Labels

Sub-taskbugSomething isn't workingpaymentsPayment provider / billing related

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions