Skip to content

fix(billing): make school billing lifecycle reversible, bounded and consistently counted (#546) - #557

Merged
guillermoscript merged 1 commit into
masterfrom
fix/school-billing-lifecycle-546
Jul 26, 2026
Merged

fix(billing): make school billing lifecycle reversible, bounded and consistently counted (#546)#557
guillermoscript merged 1 commit into
masterfrom
fix/school-billing-lifecycle-546

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

What

Fixes all five defects in the school→platform billing lifecycle from #546: cancellation is reversible and a confirmed payment un-cancels, an unpaid renewal request can no longer pause the expiry downgrade forever, a super-admin comp no longer causes the webhook to bill the school for its own comp, the grace window survives a cron outage, and the four disagreeing course counts collapse to one.

Closes #546

Why

Each item is a real money/access bug, and grep -rl "platform-webhook\|expire-platform-subscriptions" tests/ returned nothing, so none of them could regress loudly.

§1 — cancel was irreversible, and paying again did not undo it. PostgREST's ON CONFLICT DO UPDATE only writes the columns you name, so confirmManualPayment's upsert left a stale cancel_at_period_end = true on the row. Phases 1 and 2 of the cron both filter on cancel_at_period_end = false, phase 4 downgrades on it — so a school could cancel, change its mind, pay for a full year, have it confirmed, and be silently dropped to free at the end of that year having received no warning at all. Same shape on Stripe via changePlan, and the billing UI imported no reactivate action whatsoever while checkout-session blocks re-checkout for a still-active subscription.

  • confirmManualPayment and changePlan now clear cancel_at_period_end / canceled_at, and changePlan sends cancel_at_period_end: false to Stripe.
  • New reactivateSubscription() action + button on the billing page (Stripe first, then the local mirror; refuses when nothing is scheduled or the period already lapsed). Strings added to en.json and es.json.

§2 — a never-paid renewal request paused the downgrade forever. Nothing ever moved a request out of an open status except a super admin, so "click request renewal, never pay" kept the paid plan, its limits and its reduced platform fee indefinitely — #462's revenue leak reopened through the pause. Both duplicate guards used .single(), which returns PGRST116 / data: null once two rows match, so the guards passed; and because the two guards had different filters, creating a renewal alongside a pending upgrade was the ordinary way to get there.

  • New platform_payment_requests.expires_at (created + 14 days, NOT NULL, defaulted) — migration 20260726130000.
  • New cron phase 0 sweeps lapsed open requests to expired with an admin email, and the downgrade pause only honours open requests (a lapsed one stops counting the instant it lapses, not when the sweep runs — tying it to the sweep would hand the leak back during any cron outage).
  • Both guards moved to a bounded list + array check in one shared helper, so one open request of any type blocks the next.
  • The renewal window guard is no longer bypassed by every non-active status; a past_due school can still pay, a canceled one 200 days from its period end cannot.

§3 — forceTenantPlanChange on a live Stripe subscriber billed the school for the comped plan. The override never calls Stripe, so the next customer.subscription.updated carried Stripe's real (unchanged) price, missed the no-op guard, read it as a downgrade, found the tenant over the real plan's limits — which is why it was comped — and "reverted" Stripe onto the forced plan's price. Masked by the dead webhook until #544 landed.

  • New platform_subscriptions.plan_override_by / plan_override_at — migration 20260726130100.
  • applyPortalPlanChange returns action: 'ignored' for an overridden tenant.
  • Three exits so the marker can't strand a tenant: confirmManualPayment, changePlan, and a new super-admin clearTenantPlanOverride().

§4 — the grace window collapsed to zero when the cron missed a week. graceEnd came from the subscription's current_period_end, and phase 3 re-queries in the same request, so after an outage longer than GRACE_DAYS one pass sent "your payment is overdue" and "you have been downgraded". Silent: both counters incremented, so the response looked healthy.

  • graceEnd = max(period_end, now) + GRACE_DAYS, plus phase 3 skips any tenant that entered grace in the same run.

§5 — four definitions of "how many courses does this tenant have". The pre-flight and cutoff reconciler counted non-archived; creation enforcement and the number shown to the admin counted all courses; and checkCourseLimit resolved the limit itself through a hardcoded PLAN_LIMITS_FALLBACK map. A school with 30 courses (20 archived) on pro saw 30/100, was approved to downgrade to starter (10 active ≤ 15), and then could not create a single course — with an error telling it to archive courses it had already archived.

  • All four sites route through countTenantUsage; PLAN_LIMITS_FALLBACK deleted in favour of a new getTenantPlanLimits() reading platform_plans (no is_active filter — retiring a plan must not change what its existing subscribers may do; a missing row fails open to unlimited, matching every other caller in that module).

Tenants affected by the §5 loosening: 0

Counted on the production project before writing the change:

Tenants total 6
Tenants with any archived course 0
Tenants over their plan's course limit on the old (all-courses) count 0
Tenants unblocked by the switch to the non-archived count 0
Tenants still over the limit after the change 0

No tenant's enforced limit changes today; the fix is about the state the pre-flight can put a school into tomorrow.

How to QA

Requires the two new migrations (supabase db push / npm run db:reset). They have not been applied to cloud — that is a deploy step for this PR.

  1. Cancel → repay → not silently dropped. As owner@e2etest.com on default.lvh.me:3000, put the tenant on a manual-transfer plan, cancel it from /dashboard/admin/billing, then confirm a renewal request as super admin at /platform/billing. Check platform_subscriptions: cancel_at_period_end is false and canceled_at is NULL.
  2. Reactivate. Cancel again — a Reactivate plan button appears next to the plan badge. Click it; the pending-cancellation notice clears.
  3. Request TTL. Create a renewal request, then UPDATE platform_payment_requests SET expires_at = now() - interval '1 day'. Hit GET /api/cron/expire-platform-subscriptions with Authorization: Bearer $CRON_SECRET: the response reports requestsExpired: 1, the row is expired, an email goes to the tenant admins, and a past_due subscription past its grace window is no longer paused.
  4. Two open requests. With one open upgrade request, "Renew via Bank Transfer" is refused ("You already have a pending payment request"), and so is a second upgrade.
  5. Grace after an outage. Set a manual sub's current_period_end 20 days in the past and run the cron: graceStarted: 1, downgraded: 0, and grace_period_end ≈ now + 7 days.
  6. Comped tenant. Force a tenant onto a higher plan from /platform/tenants; plan_override_at is stamped and any subsequent customer.subscription.updated returns ignored without touching Stripe.
  7. Course counts. Archive some courses and compare the number on /dashboard/admin/billing with what course creation enforces — they match.

Automated: npx vitest run tests/unit/expire-platform-subscriptions.test.ts tests/unit/platform-billing-lifecycle.test.ts tests/unit/plan-limit-count-parity.test.ts tests/unit/payment-request-ttl.test.ts tests/unit/platform-plan-change.test.ts

Screenshots / GIF

The only visible change is a Reactivate plan button that appears in the billing header when a subscription is scheduled for cancellation (previously that state had no exit in the UI). No GIF recorded — the flow needs a manual-transfer subscription mid-cancellation, which is faster to reach via step 2 above than to stage for a recording.

Tests

411 → 447 (37 files). Every new assertion was verified to fail with the corresponding fix patched back out:

Fix removed Result
max(period_end, now)period_end 1 failure
TTL sweep + open-request pause 2 failures
un-cancel columns in the confirm upsert 1 failure
guard back to .single() 2 failures
override guard in applyPortalPlanChange 1 failure (reverted instead of ignored — Stripe repriced onto the comped plan)
enforcement back to the all-courses count 3 failures

tests/unit/support/fake-supabase.ts is a small in-memory PostgREST stand-in — these bugs are about what a query returns for a given table state (a .single() that yields PGRST116 once two rows match, an upsert that leaves an unnamed column untouched, a count that does or doesn't include archived rows), which canned per-call replies cannot express.

Checklist

  • npm run typecheck and npm run test:unit pass (447/447)
  • npm run build passes
  • Every new tenant-scoped query filters by tenant_id
  • Tested with every relevant role (school admin, super admin; the cron runs unauthenticated behind CRON_SECRET)
  • Loading and error states handled (reactivate has its own loading flag and toasts)
  • New UI strings added to both messages/en.json and messages/es.json
  • Migrations apply cleanly (verified against local Postgres inside a rolled-back transaction, including the NOT NULL backfill and the 14-day default)
  • lib/database.types.ts regenerated — deliberately not, since it is generated from the linked cloud project and these migrations are not applied there yet. createAdminClient() is untyped, so nothing depends on it.

Lint: repo-wide npm run lint keeps its large pre-existing error baseline; npx eslint over every file this PR touches reports 0 errors (4 warnings, all pre-existing unused-variable ones on lines this PR did not introduce).

🤖 Generated with Claude Code

https://claude.ai/code/session_01U63Vu8MHB1MzE3e9oFk2kc

…onsistently counted (#546)

Five independent defects in the school→platform billing lifecycle, none of
which had any test coverage.

1. Cancel was irreversible. PostgREST's ON CONFLICT DO UPDATE only writes the
   columns you name, so confirmManualPayment's upsert left a stale
   cancel_at_period_end = true: a school could cancel, change its mind, pay for
   a full year and still be dropped to free at the end of it, with no reminder
   and no grace (both cron phases filter on cancel_at_period_end = false).
   The confirm upsert and changePlan now clear the flag (and pass
   cancel_at_period_end: false to Stripe), and a new reactivateSubscription()
   action is wired into the billing page — there was no reactivate action in
   the UI at all.

2. An unpaid renewal request paused the expiry downgrade forever. Requests now
   carry expires_at (created + 14 days); a new cron phase sweeps lapsed ones to
   'expired' with an admin email, and the pause only honours open requests.
   Both duplicate guards moved off .single() — with two matching rows PostgREST
   returns PGRST116 / data: null, so the guards PASSED — and the renewal window
   guard is no longer bypassed by any non-active subscription status.

3. forceTenantPlanChange on a live Stripe subscriber billed the school for the
   comped plan: the next customer.subscription.updated read Stripe's real price
   as a downgrade, found the tenant over the real plan's limits (the reason it
   was comped) and repriced Stripe onto the comped plan. The override is now
   stamped on the subscription and applyPortalPlanChange ignores overridden
   tenants; clearTenantPlanOverride, a confirmed payment or an in-app plan
   change lift it.

4. The grace window collapsed to zero after a cron outage — graceEnd came from
   the subscription's period end, so one pass could send both "overdue" and
   "downgraded". It is now max(period_end, now) + GRACE_DAYS, and phase 3 skips
   tenants that entered grace in the same run.

5. Four definitions of "how many courses does this tenant have" collapsed to
   one: the billing page, the downgrade pre-flight and creation enforcement all
   route through countTenantUsage, and the hardcoded PLAN_LIMITS_FALLBACK map
   is gone in favour of platform_plans.

Tests: 411 → 447. New cron, action, TTL and count-parity suites; each new
assertion was verified to fail with the corresponding fix patched back out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U63Vu8MHB1MzE3e9oFk2kc
@guillermoscript
guillermoscript marked this pull request as ready for review July 26, 2026 17:02
@guillermoscript
guillermoscript merged commit d2cef7c into master Jul 26, 2026
2 checks passed
@guillermoscript
guillermoscript deleted the fix/school-billing-lifecycle-546 branch July 26, 2026 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

School billing lifecycle: irreversible cancel, stale renewal requests pause expiry forever

1 participant