Skip to content

Commit 4fe0d29

Browse files
committed
fix(billing): surface subscription-past-due and cutoff-scheduled tenants in billing health (#514)
`getAtRiskTenants()` fetched a single population — `tenants.billing_status = 'past_due'` — so two of the three groups #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 #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.
1 parent 69613aa commit 4fe0d29

4 files changed

Lines changed: 329 additions & 34 deletions

File tree

app/[locale]/platform/billing-health/page.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,39 @@
11
import { getAtRiskTenants } from '@/app/actions/platform/billing-health'
2+
import type { AtRiskReason } from '@/lib/billing/billing-health'
23
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
34
import { Badge } from '@/components/ui/badge'
45
import { format } from 'date-fns'
56
import {
67
IconAlertTriangle,
78
IconClockPause,
89
IconCreditCardOff,
10+
IconLockExclamation,
911
} from '@tabler/icons-react'
1012

13+
const REASON_LABELS: Record<AtRiskReason, string> = {
14+
tenant_past_due: 'Past due',
15+
subscription_past_due: 'Subscription past due',
16+
access_cutoff_scheduled: 'Cutoff scheduled',
17+
}
18+
1119
export default async function PlatformBillingHealthPage() {
1220
const atRisk = await getAtRiskTenants()
1321

14-
const manualTransfer = atRisk.filter((t) => t.paymentMethod === 'manual_transfer')
15-
const stripeDunning = atRisk.filter((t) => t.paymentMethod !== 'manual_transfer')
22+
// The metric cards deliberately keep counting past-due tenants only, so the
23+
// numbers do not silently change meaning now that #514 also lists tenants
24+
// that are merely over their plan limits. Cutoffs get their own card.
25+
const pastDue = atRisk.filter(
26+
(t) => t.reasons.includes('tenant_past_due') || t.reasons.includes('subscription_past_due'),
27+
)
28+
const manualTransfer = pastDue.filter((t) => t.paymentMethod === 'manual_transfer')
29+
const stripeDunning = pastDue.filter((t) => t.paymentMethod !== 'manual_transfer')
30+
const cutoffScheduled = atRisk.filter((t) => t.reasons.includes('access_cutoff_scheduled'))
1631
const urgent = manualTransfer.filter((t) => t.daysUntilDowngrade !== null && t.daysUntilDowngrade <= 3)
1732

1833
const metricCards = [
1934
{
2035
title: 'Past-Due Schools',
21-
value: String(atRisk.length),
36+
value: String(pastDue.length),
2237
sub: 'Total schools currently behind on payment',
2338
icon: IconAlertTriangle,
2439
bg: 'bg-red-50 dark:bg-red-950/40',
@@ -40,18 +55,27 @@ export default async function PlatformBillingHealthPage() {
4055
bg: 'bg-blue-50 dark:bg-blue-950/40',
4156
iconColor: 'text-blue-600 dark:text-blue-400',
4257
},
58+
{
59+
title: 'Access Cutoff Scheduled',
60+
value: String(cutoffScheduled.length),
61+
sub: 'Over plan limits — access pauses on the cutoff date',
62+
icon: IconLockExclamation,
63+
bg: 'bg-purple-50 dark:bg-purple-950/40',
64+
iconColor: 'text-purple-600 dark:text-purple-400',
65+
},
4366
]
4467

4568
return (
4669
<main className="flex-1 px-4 py-6 sm:px-6 lg:px-8" data-testid="platform-billing-health">
4770
<div className="mb-8">
4871
<h1 className="text-2xl font-bold tracking-tight">Billing Health</h1>
4972
<p className="text-sm text-muted-foreground mt-1">
50-
Schools currently past-due, with their grace-period countdown to automatic downgrade.
73+
Schools that are past-due or over their plan limits, with their countdown to automatic
74+
downgrade or access cutoff.
5175
</p>
5276
</div>
5377

54-
<div className="mb-8 grid gap-3 sm:grid-cols-3" data-testid="billing-health-metrics">
78+
<div className="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" data-testid="billing-health-metrics">
5579
{metricCards.map((card) => (
5680
<Card key={card.title} className="relative overflow-hidden">
5781
<CardContent className="p-5">
@@ -80,14 +104,17 @@ export default async function PlatformBillingHealthPage() {
80104
</CardHeader>
81105
<CardContent>
82106
{atRisk.length === 0 ? (
83-
<p className="text-muted-foreground text-sm">No schools currently past-due.</p>
107+
<p className="text-muted-foreground text-sm">
108+
No schools are past-due or over their plan limits.
109+
</p>
84110
) : (
85111
<div className="overflow-x-auto">
86112
<table className="w-full text-sm">
87113
<thead>
88114
<tr className="border-b text-left text-[11px] uppercase tracking-wider text-muted-foreground">
89115
<th className="pb-2 font-medium">School</th>
90116
<th className="pb-2 font-medium">Plan</th>
117+
<th className="pb-2 font-medium">Reason</th>
91118
<th className="pb-2 font-medium">Payment method</th>
92119
<th className="pb-2 font-medium">Past due since</th>
93120
<th className="pb-2 font-medium">Downgrades in</th>
@@ -99,6 +126,15 @@ export default async function PlatformBillingHealthPage() {
99126
<tr key={t.tenantId} className="border-b last:border-0" data-testid="at-risk-row" data-tenant-id={t.tenantId}>
100127
<td className="py-2.5 font-medium">{t.tenantName}</td>
101128
<td className="py-2.5 capitalize text-muted-foreground">{t.plan || '—'}</td>
129+
<td className="py-2.5" data-testid="at-risk-reasons" data-reasons={t.reasons.join(' ')}>
130+
<div className="flex flex-wrap gap-1">
131+
{t.reasons.map((reason) => (
132+
<Badge key={reason} variant="secondary" className="text-[10px] font-normal">
133+
{REASON_LABELS[reason]}
134+
</Badge>
135+
))}
136+
</div>
137+
</td>
102138
<td className="py-2.5 text-muted-foreground">
103139
{t.paymentMethod === 'manual_transfer' ? 'Manual transfer' : t.paymentMethod === 'stripe' ? 'Stripe' : '—'}
104140
</td>

app/actions/platform/billing-health.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import { createAdminClient } from '@/lib/supabase/admin'
44
import { isSuperAdmin } from '@/lib/supabase/get-user-role'
55
import { getCurrentUserId } from '@/lib/supabase/tenant'
6-
import { computeBillingHealth, type AtRiskTenant } from '@/lib/billing/billing-health'
6+
import {
7+
computeBillingHealth,
8+
type AtRiskReason,
9+
type AtRiskTenant,
10+
} from '@/lib/billing/billing-health'
711

812
async function verifySuperAdmin() {
913
const userId = await getCurrentUserId()
@@ -12,36 +16,101 @@ async function verifySuperAdmin() {
1216
return userId
1317
}
1418

19+
/**
20+
* #514: "at risk" is a union across two tables, which PostgREST cannot express
21+
* as a single `.or()`. Three reads, merged by tenant id:
22+
*
23+
* 1. `tenants.billing_status = 'past_due'`
24+
* 2. tenants owning a `platform_subscriptions` row with `status = 'past_due'`
25+
* (drifts from #1 when only one side is synced)
26+
* 3. `tenants.access_cutoff_at IS NOT NULL` (#494 scheduled a cutoff — the
27+
* tenant may be paying perfectly well and simply be over its plan limits)
28+
*
29+
* Each contributes a reason, so the UI can keep the three cases apart.
30+
*/
1531
export async function getAtRiskTenants(): Promise<AtRiskTenant[]> {
1632
await verifySuperAdmin()
1733
const admin = createAdminClient()
1834

19-
const { data: tenants } = await admin
20-
.from('tenants')
21-
.select('id, name, plan, access_cutoff_at')
22-
.eq('billing_status', 'past_due')
35+
const [
36+
{ data: pastDueTenants },
37+
{ data: pastDueSubscriptions },
38+
{ data: cutoffTenants },
39+
] = await Promise.all([
40+
admin
41+
.from('tenants')
42+
.select('id, name, plan, access_cutoff_at')
43+
.eq('billing_status', 'past_due'),
44+
admin
45+
.from('platform_subscriptions')
46+
.select('tenant_id')
47+
.eq('status', 'past_due'),
48+
admin
49+
.from('tenants')
50+
.select('id, name, plan, access_cutoff_at')
51+
.not('access_cutoff_at', 'is', null),
52+
])
2353

24-
const tenantIds = (tenants || []).map((t) => t.id)
54+
const reasonsByTenant = new Map<string, Set<AtRiskReason>>()
55+
const addReason = (tenantId: string, reason: AtRiskReason) => {
56+
const existing = reasonsByTenant.get(tenantId)
57+
if (existing) existing.add(reason)
58+
else reasonsByTenant.set(tenantId, new Set([reason]))
59+
}
60+
61+
type TenantRow = { id: string; name: string; plan: string | null; access_cutoff_at: string | null }
62+
const tenantById = new Map<string, TenantRow>()
63+
64+
for (const tenant of (pastDueTenants || []) as TenantRow[]) {
65+
tenantById.set(tenant.id, tenant)
66+
addReason(tenant.id, 'tenant_past_due')
67+
}
68+
for (const tenant of (cutoffTenants || []) as TenantRow[]) {
69+
tenantById.set(tenant.id, tenant)
70+
addReason(tenant.id, 'access_cutoff_scheduled')
71+
}
72+
73+
// Subscription-only past-due tenants have no row yet from either read above.
74+
const subscriptionPastDueIds = [...new Set((pastDueSubscriptions || []).map((s) => s.tenant_id))]
75+
for (const tenantId of subscriptionPastDueIds) {
76+
addReason(tenantId, 'subscription_past_due')
77+
}
78+
const missingTenantIds = subscriptionPastDueIds.filter((id) => !tenantById.has(id))
79+
80+
if (missingTenantIds.length) {
81+
const { data: extraTenants } = await admin
82+
.from('tenants')
83+
.select('id, name, plan, access_cutoff_at')
84+
.in('id', missingTenantIds)
85+
for (const tenant of (extraTenants || []) as TenantRow[]) {
86+
tenantById.set(tenant.id, tenant)
87+
}
88+
}
89+
90+
const tenantIds = [...tenantById.keys()]
2591

2692
const { data: subscriptions } = tenantIds.length
2793
? await admin
2894
.from('platform_subscriptions')
29-
.select('tenant_id, payment_method, current_period_end, grace_period_end')
95+
.select('tenant_id, status, payment_method, current_period_end, grace_period_end, updated_at')
3096
.in('tenant_id', tenantIds)
3197
: { data: [] }
3298

3399
return computeBillingHealth(
34-
(tenants || []).map((t) => ({
100+
[...tenantById.values()].map((t) => ({
35101
tenantId: t.id,
36102
tenantName: t.name,
37103
plan: t.plan,
38104
accessCutoffAt: t.access_cutoff_at,
105+
reasons: [...(reasonsByTenant.get(t.id) ?? [])],
39106
})),
40107
(subscriptions || []).map((s) => ({
41108
tenantId: s.tenant_id,
109+
status: s.status,
42110
paymentMethod: s.payment_method,
43111
currentPeriodEnd: s.current_period_end,
44112
gracePeriodEnd: s.grace_period_end,
113+
updatedAt: s.updated_at,
45114
})),
46115
new Date(),
47116
)

lib/billing/billing-health.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
/**
22
* Computes the "billing health" view for super admins: which tenants are
3-
* currently `past_due` and how much runway they have before an automatic
4-
* downgrade to free.
3+
* at risk and how much runway they have before an automatic downgrade to
4+
* free.
5+
*
6+
* #514: "at risk" is a union of three populations, not just one. A tenant
7+
* qualifies when `tenants.billing_status = 'past_due'`, **or** it has a
8+
* `platform_subscriptions` row with `status = 'past_due'` (the two can drift
9+
* apart, and the sub-only case used to be invisible here), **or** it has an
10+
* `access_cutoff_at` scheduled by #494 — an over-limit tenant that is paying
11+
* on time never showed up at all, so the cutoff column could only ever hold
12+
* a value for a tenant that was *also* past due. Each row carries the
13+
* `reasons` it qualified under; a tenant can qualify under several, and
14+
* collapsing that would hide exactly the drift this view exists to surface.
515
*
616
* Two past-due paths write `tenants.billing_status = 'past_due'`, and they
717
* carry different amounts of information:
@@ -20,18 +30,33 @@
2030

2131
const DAY_MS = 24 * 60 * 60 * 1000
2232

33+
/** Why a tenant appears in the at-risk list. A tenant may qualify under several. */
34+
export type AtRiskReason =
35+
| 'tenant_past_due'
36+
| 'subscription_past_due'
37+
| 'access_cutoff_scheduled'
38+
39+
const REASON_ORDER: AtRiskReason[] = [
40+
'tenant_past_due',
41+
'subscription_past_due',
42+
'access_cutoff_scheduled',
43+
]
44+
2345
export interface AtRiskTenantInput {
2446
tenantId: string
2547
tenantName: string
2648
plan: string | null
2749
accessCutoffAt: string | null
50+
reasons: AtRiskReason[]
2851
}
2952

3053
export interface PastDueSubscriptionInput {
3154
tenantId: string
55+
status: string | null
3256
paymentMethod: string | null
3357
currentPeriodEnd: string | null
3458
gracePeriodEnd: string | null
59+
updatedAt: string | null
3560
}
3661

3762
export interface AtRiskTenant {
@@ -46,6 +71,44 @@ export interface AtRiskTenant {
4671
/** True when there is no fixed downgrade date to report (Stripe-managed dunning). */
4772
isEstimate: boolean
4873
accessCutoffAt: string | null
74+
/** Which of the three at-risk conditions this tenant met, in a stable order. */
75+
reasons: AtRiskReason[]
76+
}
77+
78+
/**
79+
* Ranks two subscription rows for the same tenant; higher wins.
80+
*
81+
* `platform_subscriptions` carries `UNIQUE (tenant_id)` today and every write
82+
* path upserts on it, so a tenant cannot actually hold two rows — but the old
83+
* plain `.set()` over an unordered list meant that if the constraint were ever
84+
* relaxed (per-plan history, say), a stale `canceled` row could donate its
85+
* `payment_method` and `grace_period_end`, and therefore its countdown, to the
86+
* dashboard. Ranking explicitly costs nothing and removes the trap.
87+
*/
88+
function subscriptionRank(sub: PastDueSubscriptionInput): number {
89+
if (sub.status === 'past_due') return 2
90+
if (sub.status === 'active') return 1
91+
return 0
92+
}
93+
94+
function isMoreRelevantSubscription(
95+
candidate: PastDueSubscriptionInput,
96+
incumbent: PastDueSubscriptionInput,
97+
): boolean {
98+
const candidateRank = subscriptionRank(candidate)
99+
const incumbentRank = subscriptionRank(incumbent)
100+
if (candidateRank !== incumbentRank) return candidateRank > incumbentRank
101+
102+
const candidateUpdated = candidate.updatedAt ? new Date(candidate.updatedAt).getTime() : null
103+
const incumbentUpdated = incumbent.updatedAt ? new Date(incumbent.updatedAt).getTime() : null
104+
if (candidateUpdated === null && incumbentUpdated === null) return false
105+
if (incumbentUpdated === null) return true
106+
if (candidateUpdated === null) return false
107+
return candidateUpdated > incumbentUpdated
108+
}
109+
110+
function normalizeReasons(reasons: AtRiskReason[]): AtRiskReason[] {
111+
return REASON_ORDER.filter((reason) => reasons.includes(reason))
49112
}
50113

51114
export function computeBillingHealth(
@@ -55,7 +118,10 @@ export function computeBillingHealth(
55118
): AtRiskTenant[] {
56119
const subByTenant = new Map<string, PastDueSubscriptionInput>()
57120
for (const sub of subscriptions) {
58-
subByTenant.set(sub.tenantId, sub)
121+
const incumbent = subByTenant.get(sub.tenantId)
122+
if (!incumbent || isMoreRelevantSubscription(sub, incumbent)) {
123+
subByTenant.set(sub.tenantId, sub)
124+
}
59125
}
60126

61127
const results = tenants.map((tenant) => {
@@ -79,6 +145,7 @@ export function computeBillingHealth(
79145
daysUntilDowngrade,
80146
isEstimate: !isManualTransfer,
81147
accessCutoffAt: tenant.accessCutoffAt,
148+
reasons: normalizeReasons(tenant.reasons),
82149
}
83150
})
84151

0 commit comments

Comments
 (0)