Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 65 additions & 10 deletions app/[locale]/platform/billing-health/page.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,100 @@
import { getAtRiskTenants } from '@/app/actions/platform/billing-health'
import type { AtRiskReason } from '@/lib/billing/billing-health'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import {
IconAlertTriangle,
IconClockPause,
IconCreditCardOff,
IconLockExclamation,
} from '@tabler/icons-react'

const REASON_LABELS: Record<AtRiskReason, string> = {
tenant_past_due: 'Past due',
subscription_past_due: 'Subscription past due',
access_cutoff_scheduled: 'Cutoff scheduled',
}

function reasonLabel(reason: AtRiskReason, cutoffActive: boolean): string {
// A cutoff date in the past means access is already paused, not pending.
if (reason === 'access_cutoff_scheduled' && cutoffActive) return 'Access paused'
return REASON_LABELS[reason]
}

export default async function PlatformBillingHealthPage() {
const atRisk = await getAtRiskTenants()

const manualTransfer = atRisk.filter((t) => t.paymentMethod === 'manual_transfer')
const stripeDunning = atRisk.filter((t) => t.paymentMethod !== 'manual_transfer')
const urgent = manualTransfer.filter((t) => t.daysUntilDowngrade !== null && t.daysUntilDowngrade <= 3)
// The metric cards deliberately keep counting past-due tenants only, so the
// numbers do not silently change meaning now that #514 also lists tenants
// that are merely over their plan limits. Cutoffs get their own card.
// Counted in one pass — five chained .filter() calls over the same list was
// five allocations for four numbers.
const counts = { pastDue: 0, manualTransfer: 0, stripeDunning: 0, cutoffScheduled: 0, urgent: 0 }
for (const t of atRisk) {
const isPastDue =
t.reasons.includes('tenant_past_due') || t.reasons.includes('subscription_past_due')
if (isPastDue) {
counts.pastDue++
if (t.paymentMethod === 'manual_transfer') {
counts.manualTransfer++
if (t.daysUntilDowngrade !== null && t.daysUntilDowngrade <= 3) counts.urgent++
} else {
counts.stripeDunning++
}
}
if (t.reasons.includes('access_cutoff_scheduled')) counts.cutoffScheduled++
}

const metricCards = [
{
title: 'Past-Due Schools',
value: String(atRisk.length),
value: String(counts.pastDue),
sub: 'Total schools currently behind on payment',
icon: IconAlertTriangle,
bg: 'bg-red-50 dark:bg-red-950/40',
iconColor: 'text-red-600 dark:text-red-400',
},
{
title: 'Manual-Transfer Grace Running',
value: String(manualTransfer.length),
sub: urgent.length > 0 ? `${urgent.length} downgrading within 3 days` : 'None expiring imminently',
value: String(counts.manualTransfer),
sub:
counts.urgent > 0
? `${counts.urgent} downgrading within 3 days`
: 'None expiring imminently',
icon: IconClockPause,
bg: 'bg-amber-50 dark:bg-amber-950/40',
iconColor: 'text-amber-600 dark:text-amber-400',
},
{
title: 'Stripe Dunning In Progress',
value: String(stripeDunning.length),
value: String(counts.stripeDunning),
sub: 'Downgrade timing controlled by Stripe, not this app',
icon: IconCreditCardOff,
bg: 'bg-blue-50 dark:bg-blue-950/40',
iconColor: 'text-blue-600 dark:text-blue-400',
},
{
title: 'Access Cutoff Scheduled',
value: String(counts.cutoffScheduled),
sub: 'Over plan limits — access pauses on the cutoff date',
icon: IconLockExclamation,
bg: 'bg-purple-50 dark:bg-purple-950/40',
iconColor: 'text-purple-600 dark:text-purple-400',
},
]

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

<div className="mb-8 grid gap-3 sm:grid-cols-3" data-testid="billing-health-metrics">
<div className="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4" data-testid="billing-health-metrics">
{metricCards.map((card) => (
<Card key={card.title} className="relative overflow-hidden">
<CardContent className="p-5">
Expand Down Expand Up @@ -80,14 +123,17 @@ export default async function PlatformBillingHealthPage() {
</CardHeader>
<CardContent>
{atRisk.length === 0 ? (
<p className="text-muted-foreground text-sm">No schools currently past-due.</p>
<p className="text-muted-foreground text-sm">
No schools are past-due or over their plan limits.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="pb-2 font-medium">School</th>
<th className="pb-2 font-medium">Plan</th>
<th className="pb-2 font-medium">Reason</th>
<th className="pb-2 font-medium">Payment method</th>
<th className="pb-2 font-medium">Past due since</th>
<th className="pb-2 font-medium">Downgrades in</th>
Expand All @@ -99,6 +145,15 @@ export default async function PlatformBillingHealthPage() {
<tr key={t.tenantId} className="border-b last:border-0" data-testid="at-risk-row" data-tenant-id={t.tenantId}>
<td className="py-2.5 font-medium">{t.tenantName}</td>
<td className="py-2.5 capitalize text-muted-foreground">{t.plan || '—'}</td>
<td className="py-2.5" data-testid="at-risk-reasons" data-reasons={t.reasons.join(' ')}>
<div className="flex flex-wrap gap-1">
{t.reasons.map((reason) => (
<Badge key={reason} variant="secondary" className="text-[10px] font-normal">
{reasonLabel(reason, t.accessCutoffActive)}
</Badge>
))}
</div>
</td>
<td className="py-2.5 text-muted-foreground">
{t.paymentMethod === 'manual_transfer' ? 'Manual transfer' : t.paymentMethod === 'stripe' ? 'Stripe' : '—'}
</td>
Expand Down
134 changes: 110 additions & 24 deletions app/actions/platform/billing-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import { createAdminClient } from '@/lib/supabase/admin'
import { isSuperAdmin } from '@/lib/supabase/get-user-role'
import { getCurrentUserId } from '@/lib/supabase/tenant'
import { computeBillingHealth, type AtRiskTenant } from '@/lib/billing/billing-health'
import {
computeBillingHealth,
mergeAtRiskTenants,
type AtRiskTenant,
type AtRiskTenantRow,
type PastDueSubscriptionInput,
} from '@/lib/billing/billing-health'

async function verifySuperAdmin() {
const userId = await getCurrentUserId()
Expand All @@ -12,37 +18,117 @@ async function verifySuperAdmin() {
return userId
}

const TENANT_SELECT = 'id, name, plan, access_cutoff_at'
const SUBSCRIPTION_SELECT =
'tenant_id, status, payment_method, current_period_end, grace_period_end, updated_at'

/**
* Mapped through helpers rather than an `as` cast so a drifting select list
* fails `tsc` here instead of rendering blanks in production.
*/
function toTenantRow(row: {
id: string
name: string
plan: string | null
access_cutoff_at: string | null
}): AtRiskTenantRow {
return {
tenantId: row.id,
tenantName: row.name,
plan: row.plan,
accessCutoffAt: row.access_cutoff_at,
}
}

function toSubscriptionInput(row: {
tenant_id: string
status: string | null
payment_method: string | null
current_period_end: string | null
grace_period_end: string | null
updated_at: string | null
}): PastDueSubscriptionInput {
return {
tenantId: row.tenant_id,
status: row.status,
paymentMethod: row.payment_method,
currentPeriodEnd: row.current_period_end,
gracePeriodEnd: row.grace_period_end,
updatedAt: row.updated_at,
}
}

/**
* #514: "at risk" is a union across two tables, which PostgREST cannot express
* as a single `.or()`. Three reads, merged by tenant id:
*
* 1. `tenants.billing_status = 'past_due'`
* 2. tenants owning a `platform_subscriptions` row with `status = 'past_due'`
* (drifts from #1 when only one side is synced)
* 3. `tenants.access_cutoff_at IS NOT NULL` (#494 scheduled a cutoff — the
* tenant may be paying perfectly well and simply be over its plan limits)
*
* Each contributes a reason, so the UI can keep the three cases apart.
*
* Two round-trips, not four. The past-due subscription read takes the full
* column set, so the rows belonging to subscription-only tenants are already
* in hand; that makes the follow-up `tenants` fetch for those ids independent
* of the subscription fetch for the tenants we already know about, and the two
* go out together. Feeding both subscription lists to `computeBillingHealth`
* is safe because it ranks duplicate rows rather than taking the last one.
*
* The auth check deliberately stays *before* the reads and is never folded
* into the `Promise.all` — a non-super-admin caller must not cause tenant
* billing data to be read at all.
*/
export async function getAtRiskTenants(): Promise<AtRiskTenant[]> {
await verifySuperAdmin()
const admin = createAdminClient()

const { data: tenants } = await admin
.from('tenants')
.select('id, name, plan, access_cutoff_at')
.eq('billing_status', 'past_due')
const [
{ data: pastDueTenants },
{ data: pastDueSubscriptions },
{ data: cutoffTenants },
] = await Promise.all([
admin.from('tenants').select(TENANT_SELECT).eq('billing_status', 'past_due'),
admin.from('platform_subscriptions').select(SUBSCRIPTION_SELECT).eq('status', 'past_due'),
admin.from('tenants').select(TENANT_SELECT).not('access_cutoff_at', 'is', null),
])

const pastDueTenantRows = (pastDueTenants ?? []).map(toTenantRow)
const cutoffTenantRows = (cutoffTenants ?? []).map(toTenantRow)
const pastDueSubscriptionRows = (pastDueSubscriptions ?? []).map(toSubscriptionInput)

const tenantIds = (tenants || []).map((t) => t.id)
const knownTenantIds = new Set([
...pastDueTenantRows.map((t) => t.tenantId),
...cutoffTenantRows.map((t) => t.tenantId),
])
const subscriptionPastDueTenantIds = [
...new Set(pastDueSubscriptionRows.map((s) => s.tenantId)),
]
// Subscription-only past-due tenants appear in neither read above.
const missingTenantIds = subscriptionPastDueTenantIds.filter((id) => !knownTenantIds.has(id))

const { data: subscriptions } = tenantIds.length
? await admin
.from('platform_subscriptions')
.select('tenant_id, payment_method, current_period_end, grace_period_end')
.in('tenant_id', tenantIds)
: { data: [] }
const [{ data: extraTenants }, { data: knownSubscriptions }] = await Promise.all([
missingTenantIds.length
? admin.from('tenants').select(TENANT_SELECT).in('id', missingTenantIds)
: Promise.resolve({ data: [] as never[] }),
knownTenantIds.size
? admin
.from('platform_subscriptions')
.select(SUBSCRIPTION_SELECT)
.in('tenant_id', [...knownTenantIds])
: Promise.resolve({ data: [] as never[] }),
])

return computeBillingHealth(
(tenants || []).map((t) => ({
tenantId: t.id,
tenantName: t.name,
plan: t.plan,
accessCutoffAt: t.access_cutoff_at,
})),
(subscriptions || []).map((s) => ({
tenantId: s.tenant_id,
paymentMethod: s.payment_method,
currentPeriodEnd: s.current_period_end,
gracePeriodEnd: s.grace_period_end,
})),
mergeAtRiskTenants({
pastDueTenants: pastDueTenantRows,
cutoffTenants: cutoffTenantRows,
subscriptionPastDueTenantIds,
extraTenants: (extraTenants ?? []).map(toTenantRow),
}),
[...pastDueSubscriptionRows, ...(knownSubscriptions ?? []).map(toSubscriptionInput)],
new Date(),
)
}
Loading
Loading