Skip to content

Commit a2032d6

Browse files
guillermoscriptDPS0340claude
committed
fix(billing): gate past-due fields on past-due reasons, flatten at-risk fetch (#514)
Builds on @DPS0340's union fetch (#523) with the review follow-ups. 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: Jiho Lee <optional.int@kakao.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJDnXnWmdBDTX33bvTx62K
1 parent 9750556 commit a2032d6

4 files changed

Lines changed: 393 additions & 99 deletions

File tree

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

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,48 +16,67 @@ const REASON_LABELS: Record<AtRiskReason, string> = {
1616
access_cutoff_scheduled: 'Cutoff scheduled',
1717
}
1818

19+
function reasonLabel(reason: AtRiskReason, cutoffActive: boolean): string {
20+
// A cutoff date in the past means access is already paused, not pending.
21+
if (reason === 'access_cutoff_scheduled' && cutoffActive) return 'Access paused'
22+
return REASON_LABELS[reason]
23+
}
24+
1925
export default async function PlatformBillingHealthPage() {
2026
const atRisk = await getAtRiskTenants()
2127

2228
// The metric cards deliberately keep counting past-due tenants only, so the
2329
// numbers do not silently change meaning now that #514 also lists tenants
2430
// 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'))
31-
const urgent = manualTransfer.filter((t) => t.daysUntilDowngrade !== null && t.daysUntilDowngrade <= 3)
31+
// Counted in one pass — five chained .filter() calls over the same list was
32+
// five allocations for four numbers.
33+
const counts = { pastDue: 0, manualTransfer: 0, stripeDunning: 0, cutoffScheduled: 0, urgent: 0 }
34+
for (const t of atRisk) {
35+
const isPastDue =
36+
t.reasons.includes('tenant_past_due') || t.reasons.includes('subscription_past_due')
37+
if (isPastDue) {
38+
counts.pastDue++
39+
if (t.paymentMethod === 'manual_transfer') {
40+
counts.manualTransfer++
41+
if (t.daysUntilDowngrade !== null && t.daysUntilDowngrade <= 3) counts.urgent++
42+
} else {
43+
counts.stripeDunning++
44+
}
45+
}
46+
if (t.reasons.includes('access_cutoff_scheduled')) counts.cutoffScheduled++
47+
}
3248

3349
const metricCards = [
3450
{
3551
title: 'Past-Due Schools',
36-
value: String(pastDue.length),
52+
value: String(counts.pastDue),
3753
sub: 'Total schools currently behind on payment',
3854
icon: IconAlertTriangle,
3955
bg: 'bg-red-50 dark:bg-red-950/40',
4056
iconColor: 'text-red-600 dark:text-red-400',
4157
},
4258
{
4359
title: 'Manual-Transfer Grace Running',
44-
value: String(manualTransfer.length),
45-
sub: urgent.length > 0 ? `${urgent.length} downgrading within 3 days` : 'None expiring imminently',
60+
value: String(counts.manualTransfer),
61+
sub:
62+
counts.urgent > 0
63+
? `${counts.urgent} downgrading within 3 days`
64+
: 'None expiring imminently',
4665
icon: IconClockPause,
4766
bg: 'bg-amber-50 dark:bg-amber-950/40',
4867
iconColor: 'text-amber-600 dark:text-amber-400',
4968
},
5069
{
5170
title: 'Stripe Dunning In Progress',
52-
value: String(stripeDunning.length),
71+
value: String(counts.stripeDunning),
5372
sub: 'Downgrade timing controlled by Stripe, not this app',
5473
icon: IconCreditCardOff,
5574
bg: 'bg-blue-50 dark:bg-blue-950/40',
5675
iconColor: 'text-blue-600 dark:text-blue-400',
5776
},
5877
{
5978
title: 'Access Cutoff Scheduled',
60-
value: String(cutoffScheduled.length),
79+
value: String(counts.cutoffScheduled),
6180
sub: 'Over plan limits — access pauses on the cutoff date',
6281
icon: IconLockExclamation,
6382
bg: 'bg-purple-50 dark:bg-purple-950/40',
@@ -130,7 +149,7 @@ export default async function PlatformBillingHealthPage() {
130149
<div className="flex flex-wrap gap-1">
131150
{t.reasons.map((reason) => (
132151
<Badge key={reason} variant="secondary" className="text-[10px] font-normal">
133-
{REASON_LABELS[reason]}
152+
{reasonLabel(reason, t.accessCutoffActive)}
134153
</Badge>
135154
))}
136155
</div>

app/actions/platform/billing-health.ts

Lines changed: 87 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { isSuperAdmin } from '@/lib/supabase/get-user-role'
55
import { getCurrentUserId } from '@/lib/supabase/tenant'
66
import {
77
computeBillingHealth,
8-
type AtRiskReason,
8+
mergeAtRiskTenants,
99
type AtRiskTenant,
10+
type AtRiskTenantRow,
11+
type PastDueSubscriptionInput,
1012
} from '@/lib/billing/billing-health'
1113

1214
async function verifySuperAdmin() {
@@ -16,6 +18,46 @@ async function verifySuperAdmin() {
1618
return userId
1719
}
1820

21+
const TENANT_SELECT = 'id, name, plan, access_cutoff_at'
22+
const SUBSCRIPTION_SELECT =
23+
'tenant_id, status, payment_method, current_period_end, grace_period_end, updated_at'
24+
25+
/**
26+
* Mapped through helpers rather than an `as` cast so a drifting select list
27+
* fails `tsc` here instead of rendering blanks in production.
28+
*/
29+
function toTenantRow(row: {
30+
id: string
31+
name: string
32+
plan: string | null
33+
access_cutoff_at: string | null
34+
}): AtRiskTenantRow {
35+
return {
36+
tenantId: row.id,
37+
tenantName: row.name,
38+
plan: row.plan,
39+
accessCutoffAt: row.access_cutoff_at,
40+
}
41+
}
42+
43+
function toSubscriptionInput(row: {
44+
tenant_id: string
45+
status: string | null
46+
payment_method: string | null
47+
current_period_end: string | null
48+
grace_period_end: string | null
49+
updated_at: string | null
50+
}): PastDueSubscriptionInput {
51+
return {
52+
tenantId: row.tenant_id,
53+
status: row.status,
54+
paymentMethod: row.payment_method,
55+
currentPeriodEnd: row.current_period_end,
56+
gracePeriodEnd: row.grace_period_end,
57+
updatedAt: row.updated_at,
58+
}
59+
}
60+
1961
/**
2062
* #514: "at risk" is a union across two tables, which PostgREST cannot express
2163
* as a single `.or()`. Three reads, merged by tenant id:
@@ -27,6 +69,17 @@ async function verifySuperAdmin() {
2769
* tenant may be paying perfectly well and simply be over its plan limits)
2870
*
2971
* Each contributes a reason, so the UI can keep the three cases apart.
72+
*
73+
* Two round-trips, not four. The past-due subscription read takes the full
74+
* column set, so the rows belonging to subscription-only tenants are already
75+
* in hand; that makes the follow-up `tenants` fetch for those ids independent
76+
* of the subscription fetch for the tenants we already know about, and the two
77+
* go out together. Feeding both subscription lists to `computeBillingHealth`
78+
* is safe because it ranks duplicate rows rather than taking the last one.
79+
*
80+
* The auth check deliberately stays *before* the reads and is never folded
81+
* into the `Promise.all` — a non-super-admin caller must not cause tenant
82+
* billing data to be read at all.
3083
*/
3184
export async function getAtRiskTenants(): Promise<AtRiskTenant[]> {
3285
await verifySuperAdmin()
@@ -37,81 +90,45 @@ export async function getAtRiskTenants(): Promise<AtRiskTenant[]> {
3790
{ data: pastDueSubscriptions },
3891
{ data: cutoffTenants },
3992
] = 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),
93+
admin.from('tenants').select(TENANT_SELECT).eq('billing_status', 'past_due'),
94+
admin.from('platform_subscriptions').select(SUBSCRIPTION_SELECT).eq('status', 'past_due'),
95+
admin.from('tenants').select(TENANT_SELECT).not('access_cutoff_at', 'is', null),
5296
])
5397

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-
}
98+
const pastDueTenantRows = (pastDueTenants ?? []).map(toTenantRow)
99+
const cutoffTenantRows = (cutoffTenants ?? []).map(toTenantRow)
100+
const pastDueSubscriptionRows = (pastDueSubscriptions ?? []).map(toSubscriptionInput)
72101

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()]
102+
const knownTenantIds = new Set([
103+
...pastDueTenantRows.map((t) => t.tenantId),
104+
...cutoffTenantRows.map((t) => t.tenantId),
105+
])
106+
const subscriptionPastDueTenantIds = [
107+
...new Set(pastDueSubscriptionRows.map((s) => s.tenantId)),
108+
]
109+
// Subscription-only past-due tenants appear in neither read above.
110+
const missingTenantIds = subscriptionPastDueTenantIds.filter((id) => !knownTenantIds.has(id))
91111

92-
const { data: subscriptions } = tenantIds.length
93-
? await admin
94-
.from('platform_subscriptions')
95-
.select('tenant_id, status, payment_method, current_period_end, grace_period_end, updated_at')
96-
.in('tenant_id', tenantIds)
97-
: { data: [] }
112+
const [{ data: extraTenants }, { data: knownSubscriptions }] = await Promise.all([
113+
missingTenantIds.length
114+
? admin.from('tenants').select(TENANT_SELECT).in('id', missingTenantIds)
115+
: Promise.resolve({ data: [] as never[] }),
116+
knownTenantIds.size
117+
? admin
118+
.from('platform_subscriptions')
119+
.select(SUBSCRIPTION_SELECT)
120+
.in('tenant_id', [...knownTenantIds])
121+
: Promise.resolve({ data: [] as never[] }),
122+
])
98123

99124
return computeBillingHealth(
100-
[...tenantById.values()].map((t) => ({
101-
tenantId: t.id,
102-
tenantName: t.name,
103-
plan: t.plan,
104-
accessCutoffAt: t.access_cutoff_at,
105-
reasons: [...(reasonsByTenant.get(t.id) ?? [])],
106-
})),
107-
(subscriptions || []).map((s) => ({
108-
tenantId: s.tenant_id,
109-
status: s.status,
110-
paymentMethod: s.payment_method,
111-
currentPeriodEnd: s.current_period_end,
112-
gracePeriodEnd: s.grace_period_end,
113-
updatedAt: s.updated_at,
114-
})),
125+
mergeAtRiskTenants({
126+
pastDueTenants: pastDueTenantRows,
127+
cutoffTenants: cutoffTenantRows,
128+
subscriptionPastDueTenantIds,
129+
extraTenants: (extraTenants ?? []).map(toTenantRow),
130+
}),
131+
[...pastDueSubscriptionRows, ...(knownSubscriptions ?? []).map(toSubscriptionInput)],
115132
new Date(),
116133
)
117134
}

0 commit comments

Comments
 (0)