fix(billing): surface subscription-past-due and cutoff-scheduled tenants in billing health (#514) - #523
Conversation
…nts in billing health (guillermoscript#514) `getAtRiskTenants()` fetched a single population — `tenants.billing_status = 'past_due'` — so two of the three groups guillermoscript#493 §1.2 asked for were invisible: - a tenant whose `platform_subscriptions.status` went past due without `tenants.billing_status` being synced never appeared, which is exactly the drift the epic wanted visible; - a tenant over its plan limits with an `access_cutoff_at` scheduled by guillermoscript#494 but healthy billing never appeared either, so the cutoff column could only ever hold a value for a tenant that was *also* past due. Fetch the union of the three conditions and carry the `reasons` a tenant qualified under through the pure layer, so the cases stay distinguishable instead of collapsing into one undifferentiated list. PostgREST cannot express a disjunction spanning two tables, hence three reads merged by tenant id rather than a single `.or()`. Also rank subscription rows deterministically (past_due > active > other, tie broken by `updated_at` desc) instead of last-wins over an unordered list. To be precise about severity: `platform_subscriptions` carries UNIQUE (tenant_id) and every write path upserts on it, so a tenant cannot hold two rows today — this closes a latent trap rather than a live miscount. The metric cards keep counting past-due tenants only, so their meaning does not silently change now that over-limit tenants are listed; scheduled cutoffs get their own card, and the empty state no longer claims "no schools past-due" for a list that is no longer only about payment.
57c702f to
4fe0d29
Compare
|
CI verification: the repo's own Noting it here because fork PRs need a maintainer to approve the workflow run before checks appear on this page, so there is nothing to see under Checks until you do. |
guillermoscript
left a comment
There was a problem hiding this comment.
Thanks for this — the write-up is unusually careful, and I verified the claims in it rather than taking them on trust. All of them hold:
npm run test:unit→ 323 passed / 27 files,npx tsc --noEmit→ clean,npx eslinton the four changed files → clean (run against4fe0d29in a clean worktree).- The §3 downgrade you argued for is correct:
platform_subscriptions_tenant_unique UNIQUE (tenant_id)is at20260217040000_platform_billing.sql:46and is never dropped later, and every writer upsertsonConflict: 'tenant_id'. Ranking is defensive, not a live fix — good call flagging that instead of overclaiming. - Diff is 4 files, one commit, no migration, no dependency, no
package.json/lockfile change.verifySuperAdmin()is intact,createAdminClient()usage is unchanged in scope, and nothing new is exposed to the client. No security concerns from me.
The union-fetch shape (three reads merged by tenant id + per-row reasons) is the right answer to #514 §1/§2 — PostgREST genuinely can't express that disjunction, and keeping reasons as a set instead of one enum is the correct call.
One thing needs fixing before merge.
🔴 Blocking: cutoff-only tenants inherit the past-due-only columns, and sort to the top
computeBillingHealth derives pastDueSince, graceEndsAt, daysUntilDowngrade and isEstimate purely from payment_method, with no reference to whether the tenant is actually past due. That was safe while the fetch set was billing_status = 'past_due' only. It isn't safe now that reason 3 admits healthy tenants.
The concrete case is the most common cutoff-only population there is — a school that already lapsed to free and is now over the free plan's limits:
downgradeTenantToFree()(lib/billing/downgrade-tenant.ts:34-41) setsstatus: 'canceled'and never clearsgrace_period_end— onlyconfirmManualPaymentand the non-liveCyclebranch ofchangePlando that.- The same function then calls
reconcileAccessCutoff()(:64), which is exactly what stamps theaccess_cutoff_atyour new query selects on.
So that tenant arrives with payment_method: 'manual_transfer' + a months-old grace_period_end. Confirmed against the branch:
computeBillingHealth(
[{ tenantId: 't', tenantName: 'Lapsed School', plan: 'free',
accessCutoffAt: '2026-08-07T00:00:00.000Z', reasons: ['access_cutoff_scheduled'] }],
[{ tenantId: 't', status: 'canceled', paymentMethod: 'manual_transfer',
currentPeriodEnd: '2026-06-01T00:00:00.000Z', gracePeriodEnd: '2026-06-08T00:00:00.000Z',
updatedAt: '2026-06-08T00:00:00.000Z' }],
new Date('2026-07-24T00:00:00.000Z'),
)
// → pastDueSince: '2026-06-01…', graceEndsAt: '2026-06-08…',
// daysUntilDowngrade: -46, isEstimate: falseRendered, that row reads "Past due since Jun 1, 2026" and a red "Overdue" in Downgrades in — for a tenant that is not past due and already downgraded. And because the sort is ascending on daysUntilDowngrade with nulls last, -46 puts it above every genuinely urgent past-due school. The dashboard's top rows become its least actionable ones, which is a step backwards from the page it replaces.
The healthy-Stripe variant is milder but same root:
// cutoff-only tenant, status 'active', payment_method 'stripe', currentPeriodEnd in the future
// → pastDueSince: '2026-08-20…' (a *future* date under "Past due since")
// isEstimate: true (renders the "Stripe-managed" dunning badge)Your own cutoff-only test passes only because it leaves currentPeriodEnd/gracePeriodEnd null — the fixture is healthier than the rows this query will actually return.
Suggested fix — gate the past-due-derived fields on a past-due reason, in the pure layer where it's testable:
const isPastDue =
tenant.reasons.includes('tenant_past_due') || tenant.reasons.includes('subscription_past_due')
const isManualTransfer = isPastDue && paymentMethod === 'manual_transfer'
const graceEndsAt = isManualTransfer ? sub?.gracePeriodEnd ?? null : null
// …
pastDueSince: isPastDue ? sub?.currentPeriodEnd ?? null : null,
isEstimate: isPastDue && !isManualTransfer,isEstimate in particular currently means "no fixed downgrade date", and for a cutoff-only row the honest answer is "no downgrade pending at all" — — rather than a dunning badge. Worth a test that a cutoff-only tenant carrying a stale grace_period_end still reports daysUntilDowngrade: null and does not sort above a live past-due row.
🟡 Worth addressing
The union-merge logic is the new code, and it's the only part with no test. Everything you added tests for lives in the pure layer that was already tested; the three-read merge, the reason accumulation, and the follow-up .in('id', missingTenantIds) fetch are untested. Consider extracting the merge into a pure mergeAtRiskTenants(pastDue, subPastDue, cutoff) alongside computeBillingHealth — same rationale as the existing pure/no-I/O split, and it makes the sub-only path assertable.
as TenantRow[] casts drop the generated types. access_cutoff_at, status and updated_at all exist in lib/database.types.ts, so the casts aren't compensating for a missing column — they're papering over the union Promise.all produces. A typo in a select list would now compile fine and fail at runtime. A shared const TENANT_SELECT = 'id, name, plan, access_cutoff_at' as const plus separate awaits (or typing each promise) keeps the checking.
access_cutoff_at IS NOT NULL includes cutoffs already in the past — access has already stopped, but the badge says "Cutoff scheduled". Either label past cutoffs distinctly ("Access paused") or note the intent; a super admin reading the list can't currently tell scheduled from enforced.
Unbounded reads. Neither the cutoff query nor the tenants query has a limit, so on a platform with more than PostgREST's max-rows (1000 by default) at-risk tenants the list truncates silently. Fine at current scale; a comment or an explicit cap would stop it becoming a mystery later. Related nit: platform_subscriptions.status has no index (only tenant_id and stripe_subscription_id) — irrelevant at one row per tenant, mentioning it only so it isn't a surprise.
Fourth round-trip. missingTenantIds triggers an extra query whenever a sub-only tenant exists. Small, but you could fetch tenants for pastDueSubscriptions' ids inside the same Promise.all by reordering the reads (subscriptions first, then one tenants read with .or(...) over the ids you now know) — optional, only if you're touching the file anyway.
ℹ️ On i18n
Flagging this because it will come up: the page is all hardcoded English (REASON_LABELS, card titles, the reworded description). That matches the existing convention — none of the 9 pages under app/[locale]/platform/ use next-intl, since this console is super-admin-only. So this PR isn't regressing anything and I'm not asking you to localize it here. If we do want the platform console in messages/*.json, that's a separate issue covering all 9 pages, not something to bolt onto this one.
🟢 Nits
subscriptionRankscoresunpaid/incompleteat 0, i.e. belowactive. Defensible, butunpaidis arguably closer topast_duethan to a stale row — a one-line comment on why the three-way split is enough would help the next reader.updated_athas no DB trigger onplatform_subscriptions; every writer sets it by hand. The tie-break therefore depends on app discipline. Unreachable today, so just noting it.- "Past-Due Schools" now counts
tenant_past_due ∪ subscription_past_due, so the number can legitimately grow versus master. That's the fix working as intended, but the PR description says the metric cards "keep counting past-due tenants only" — worth a word so nobody reads a jump as a regression. data-reasons={t.reasons.join(' ')}is a nice touch for future E2E; no E2E covers this page yet, so nothing to update.
Requesting changes on the first item only; everything else is take-it-or-leave-it. Re the missing screenshots — no problem given the environment constraint, but once the past-due gating is in, a capture of the table with a cutoff-only row would make the ordering behaviour obvious to whoever reviews next. Agreed on the billing_status ↔ platform_subscriptions.status reconciliation being its own issue.
…sk fetch (guillermoscript#514) Follow-ups from the review of guillermoscript#523, stacked on top of the union fetch in this PR rather than replacing it. Widening the at-risk population to `access_cutoff_at IS NOT NULL` admits tenants whose billing is healthy, but `computeBillingHealth` still derived `pastDueSince`, `graceEndsAt`, `daysUntilDowngrade` and `isEstimate` from `payment_method` alone. `downgradeTenantToFree()` sets status='canceled' without clearing `grace_period_end` and then schedules the very cutoff that lists the tenant, so an already-downgraded school rendered "Past due since <old date>" with a large negative countdown — and sorted above the schools that still had a downgrade to avert. Those fields are now gated on the tenant actually being past due, and the no-countdown tail is ordered by soonest cutoff instead of arbitrarily. Also: - `mergeAtRiskTenants()` extracted as a pure, exported function so the union itself is unit-testable without a database. - Fetch flattened from four sequential round-trips to two: the past-due subscription read now takes the full column set, which makes the follow-up tenant fetch independent of the subscription fetch. Safe to feed both subscription lists to `computeBillingHealth` because it ranks duplicates rather than taking the last one. The super-admin check stays ahead of the reads on purpose. - Row mapping goes through typed helpers instead of `as TenantRow[]`, so a drifting select list fails typecheck. - `accessCutoffActive` distinguishes a cutoff already in force ("Access paused") from a scheduled one, and the metric cards count in one pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJDnXnWmdBDTX33bvTx62K
Live `/platform/billing-health` captures against five seeded fixtures (a lapsed cutoff-only school, an already-paused one, a subscription-only past-due school, a manual-transfer school two days from downgrade, and a Stripe dunning one): - master lists only the two `billing_status='past_due'` schools. - PR #523's union lists all five, but the already-downgraded school reports "Past due since Jun 10" with a red "Overdue" and sorts above the school that actually downgrades in two days. - The follow-up gates those columns on the tenant being past due, so the live countdowns come back to the top. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJDnXnWmdBDTX33bvTx62K
|
I pushed the review follow-ups straight onto this branch ( Verified against a running instanceYou couldn't capture this, so here it is — five seeded fixtures on a local stack: a school that already lapsed to free and is over the free plan's limits, one whose cutoff has already passed, one with subscription-only drift, one two days from downgrade, and one in Stripe dunning. master — only the two Your commit — all five appear, with reasons, which is exactly what §1 and §2 asked for. But look at the first row:
Both come from the same place:
So the already-downgraded school is not an exotic case — it is the most common cutoff-only row there is, and it arrives carrying a stale grace stamp. After the follow-up — the live countdowns come back to the top, the cutoff-only rows report The metric cards are identical across the last two shots, which is the property you designed for — adding over-limit schools doesn't move the past-due numbers. What else is in the commit
Tests are 20 in this file (was 11) and 332 across the suite (was 323); On the two things you flaggedYour §3 write-up was right and I checked it independently: The No i18n here on purpose: none of the nine pages under Re-reviewing now; thanks for a careful first contribution — the PR description in particular did most of my homework for me. |
Addressed by bdc9640, pushed to this branch: the past-due gating, the ordered no-countdown tail, the flattened fetch, the pure mergeAtRiskTenants() with tests, and the typed row mappers. Verified against a running instance — captures in the comment above. Dismissing my own stale review so this can merge.
|
Merged as 8715795. Thanks @DPS0340 — the union fetch and the per-row reasons were the right shape for this, and the honest framing of §3 (unreachable under the current UNIQUE constraint, so trap-removal rather than a live fix) is exactly what made the fetch flattening safe to layer on top. Good first contribution here. Shipped state: past-due schools keep their countdowns and stay at the top, subscription-only drift and scheduled cutoffs are both visible with their reason, and a cutoff already in force reads "Access paused". Before/after captures are in Still open from the discussion, unchanged: reconciling |




Closes #514.
Problem
getAtRiskTenants()fetched a single population —tenants.billing_status = 'past_due'— andcomputeBillingHealthdecorated it. Every gap in the issue follows from that one.eq(): a tenant is either in the fetch set or it does not exist as far as this dashboard is concerned.Two of the three groups #493 §1.2 asked for were therefore invisible:
platform_subscriptions.statuswent past due withouttenants.billing_statusbeing synced never appeared — the exact drift the epic wanted surfaced.access_cutoff_atscheduled by Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely #494 but healthy billing never appeared either.lib/billing/billing-health.tsalready carriedaccessCutoffAtthrough and the page already rendered a column for it, but only past-due tenants were ever fetched — so that column could only ever hold a value for a tenant that was also past due.Changes
Union fetch with reasons (
app/actions/platform/billing-health.ts). Three reads merged by tenant id:tenants.billing_status = 'past_due', tenants owning apast_duesubscription row, andtenants.access_cutoff_at IS NOT NULL. Not a single.or()— PostgREST cannot express a disjunction spanning two tables, and the subscriptions read is keyed off the resolved tenant ids either way.Each condition contributes a
reason, carried through the pure layer asreasons: AtRiskReason[]on both the input and output types. A tenant can qualify under several at once, and collapsing that to one enum would discard precisely the drift signal §1 is about.Deterministic subscription ranking (
lib/billing/billing-health.ts).subByTenantdid a plain.set()over an unordered list, so a stale row could donate itspayment_methodandgrace_period_end— and therefore the countdown. Now rankspast_due>active> other, tie-broken byupdated_atdesc.Being precise about severity, because the issue slightly overstates this one:
platform_subscriptionscarriesCONSTRAINT platform_subscriptions_tenant_unique UNIQUE (tenant_id)(20260217040000_platform_billing.sql:46), never dropped in any later migration, and every write path is an.upsert(..., { onConflict: 'tenant_id' }). A tenant cannot hold two rows today, so §3 is unreachable, not a live miscount. It is a trap that springs the moment that constraint is relaxed (per-plan history rows, say). Ranking explicitly costs nothing and makes the function correct under either schema.UI (
app/[locale]/platform/billing-health/page.tsx). A "Reason" column of badges. The three existing metric cards deliberately keep counting past-due tenants only, so the numbers at the top do not silently change meaning now that over-limit tenants are listed; a fourth card counts scheduled cutoffs. The empty state and page description no longer claim the list is only about payment.Verification
npm run test:unit— 323 passed (27 files).tests/unit/billing-health.test.tsgoes 6 → 11 cases: subscription-only past-due surfacing, cutoff-only surfacing with no fabricated countdown, multi-reason ordering, stale-vs-current row ranking asserted under both input orders, and anupdated_attie-break. The original six are unchanged apart from the new required field.npm run typecheck— clean.npx eslinton all four changed files — clean.No migration, no schema change, no new dependency.
Notes for the reviewer
I could not exercise this against a running instance — no
.env.localand no Supabase CLI in my environment — so there are no before/after screenshots on this PR, unlike #520/#521. The behaviour change lives entirely in the union fetch plus the pure function, and the unit tests cover both; the UI diff is a badge column, a fourth metric card, and two reworded strings. Happy to add captures if you would like them before merging.Out of scope, worth its own issue: reconciling
tenants.billing_statusagainstplatform_subscriptions.statusat write time. This PR makes the dashboard stop hiding the drift; removing the drift is a separate change.