Skip to content

Commit d2cef7c

Browse files
fix(billing): make school billing lifecycle reversible, bounded and consistently counted (#546) (#557)
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. Claude-Session: https://claude.ai/code/session_01U63Vu8MHB1MzE3e9oFk2kc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 01e6591 commit d2cef7c

20 files changed

Lines changed: 1711 additions & 99 deletions

app/[locale]/dashboard/admin/billing/billing-dashboard-client.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import {
1919
} from '@/components/ui/alert-dialog'
2020
import { IconExternalLink, IconRefresh, IconCreditCard, IconPhoto, IconClock, IconLoader2 } from '@tabler/icons-react'
2121
import { useTranslations, useLocale } from 'next-intl'
22-
import { uploadPaymentProof, requestManualRenewal, cancelSubscription } from '@/app/actions/admin/billing'
22+
import {
23+
uploadPaymentProof,
24+
requestManualRenewal,
25+
cancelSubscription,
26+
reactivateSubscription,
27+
} from '@/app/actions/admin/billing'
2328

2429
interface BillingDashboardClientProps {
2530
status: {
@@ -75,6 +80,7 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
7580
const [uploadingFor, setUploadingFor] = useState<string | null>(null)
7681
const [cancelOpen, setCancelOpen] = useState(false)
7782
const [cancelLoading, setCancelLoading] = useState(false)
83+
const [reactivateLoading, setReactivateLoading] = useState(false)
7884

7985
const handleManageBilling = async () => {
8086
setPortalLoading(true)
@@ -134,6 +140,19 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
134140
}
135141
}
136142

143+
const handleReactivate = async () => {
144+
setReactivateLoading(true)
145+
try {
146+
await reactivateSubscription()
147+
toast.success(t('reactivateSuccess'))
148+
router.refresh()
149+
} catch (e) {
150+
toast.error(e instanceof Error ? e.message : t('reactivateError'))
151+
} finally {
152+
setReactivateLoading(false)
153+
}
154+
}
155+
137156
const handleProofUpload = async (requestId: string, file: File) => {
138157
setUploadingFor(requestId)
139158
try {
@@ -181,6 +200,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
181200
accessCutoffAt={status.accessCutoffAt}
182201
onManageClick={status.hasStripeCustomer ? handleManageBilling : undefined}
183202
onCancelClick={() => setCancelOpen(true)}
203+
onReactivateClick={handleReactivate}
204+
reactivateLoading={reactivateLoading}
184205
/>
185206

186207
{/* Manual Subscription Renewal Section */}

app/actions/admin/billing.ts

Lines changed: 148 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
import { createClient } from '@/lib/supabase/server'
44
import { createAdminClient } from '@/lib/supabase/admin'
55
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
6-
import { checkPlanLimits, formatPlanLimitError } from '@/lib/billing/plan-limits'
6+
import { checkPlanLimits, countTenantUsage, formatPlanLimitError } from '@/lib/billing/plan-limits'
77
import { classifyPlanChange } from '@/lib/billing/plan-change'
88
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
9+
import {
10+
OPEN_REQUEST_STATUSES,
11+
isRequestOpen,
12+
requestExpiresAt,
13+
} from '@/lib/billing/payment-request-ttl'
914
import { revalidatePath } from 'next/cache'
1015

1116
async function verifyAdminAccess() {
@@ -29,15 +34,43 @@ async function verifyAdminAccess() {
2934
return { userId, tenantId, supabase }
3035
}
3136

37+
/**
38+
* Is there an open (pending and not lapsed) platform payment request for this
39+
* tenant? One helper for both duplicate guards so a renewal can never be
40+
* created alongside a pending upgrade — the combination that used to disable
41+
* both guards permanently, because each returned `PGRST116 / data: null` from
42+
* `.single()` once two rows matched.
43+
*/
44+
async function hasOpenPaymentRequest(
45+
adminClient: Awaited<ReturnType<typeof createAdminClient>>,
46+
tenantId: string,
47+
): Promise<boolean> {
48+
const { data } = await adminClient
49+
.from('platform_payment_requests')
50+
.select('request_id, status, expires_at')
51+
.eq('tenant_id', tenantId)
52+
.in('status', OPEN_REQUEST_STATUSES as unknown as string[])
53+
.order('created_at', { ascending: false })
54+
.limit(20)
55+
56+
return ((data as { status: string; expires_at: string | null }[] | null) || []).some((r) =>
57+
isRequestOpen(r)
58+
)
59+
}
60+
3261
/**
3362
* Get current subscription status and usage statistics
3463
*/
3564
export async function getSubscriptionStatus() {
3665
const { tenantId } = await verifyAdminAccess()
3766
const adminClient = await createAdminClient()
3867

39-
// Fetch tenant, subscription, plan, and usage in parallel
40-
const [tenantResult, subscriptionResult, coursesCount, studentsCount] = await Promise.all([
68+
// Fetch tenant, subscription, plan, and usage in parallel.
69+
// Usage goes through countTenantUsage (#546 §5) so the number an admin reads
70+
// here is the same number the downgrade pre-flight and course-creation
71+
// enforcement compare against — it used to count archived courses that
72+
// neither of those did.
73+
const [tenantResult, subscriptionResult, usage] = await Promise.all([
4174
adminClient
4275
.from('tenants')
4376
.select('plan, billing_status, billing_period_end, billing_email, stripe_customer_id, access_cutoff_at')
@@ -48,16 +81,7 @@ export async function getSubscriptionStatus() {
4881
.select('*, platform_plans(*)')
4982
.eq('tenant_id', tenantId)
5083
.single(),
51-
adminClient
52-
.from('courses')
53-
.select('*', { count: 'exact', head: true })
54-
.eq('tenant_id', tenantId),
55-
adminClient
56-
.from('tenant_users')
57-
.select('*', { count: 'exact', head: true })
58-
.eq('tenant_id', tenantId)
59-
.eq('role', 'student')
60-
.eq('status', 'active'),
84+
countTenantUsage(adminClient, tenantId),
6185
])
6286

6387
const tenant = tenantResult.data
@@ -102,11 +126,11 @@ export async function getSubscriptionStatus() {
102126
} : null,
103127
usage: {
104128
courses: {
105-
current: coursesCount.count || 0,
129+
current: usage.courses,
106130
limit: limits.max_courses,
107131
},
108132
students: {
109-
current: studentsCount.count || 0,
133+
current: usage.students,
110134
limit: limits.max_students,
111135
},
112136
},
@@ -182,15 +206,12 @@ export async function requestManualPlanUpgrade(planId: string, interval: 'monthl
182206
throw new Error(formatPlanLimitError(limitCheck) || 'Plan limits exceeded')
183207
}
184208

185-
// Check for existing pending request
186-
const { data: existingRequest } = await adminClient
187-
.from('platform_payment_requests')
188-
.select('request_id')
189-
.eq('tenant_id', tenantId)
190-
.in('status', ['pending', 'instructions_sent', 'payment_received'])
191-
.single()
192-
193-
if (existingRequest) {
209+
// Check for an existing open request. Bounded list + array check, never
210+
// `.single()` (#546 §2): with two or more matching rows `.single()` returns
211+
// PGRST116 and `data: null`, so the guard PASSED and the table grew unbounded
212+
// per tenant. Lapsed requests are ignored so a stale row cannot lock a school
213+
// out of paying.
214+
if (await hasOpenPaymentRequest(adminClient, tenantId)) {
194215
throw new Error('You already have a pending plan change request. Please wait for it to be processed.')
195216
}
196217

@@ -207,6 +228,7 @@ export async function requestManualPlanUpgrade(planId: string, interval: 'monthl
207228
request_type: requestType,
208229
bank_reference: bankReference || null,
209230
notes: notes || null,
231+
expires_at: requestExpiresAt(),
210232
})
211233
.select('request_id')
212234
.single()
@@ -334,6 +356,18 @@ export async function confirmManualPayment(requestId: string) {
334356
grace_period_end: null,
335357
// Reset the reminder stamp so the next cycle can remind again.
336358
renewal_reminder_sent_at: null,
359+
// A confirmed payment is an un-cancel (#546 §1). PostgREST's ON CONFLICT
360+
// DO UPDATE only touches the columns supplied here, so omitting these two
361+
// left a stale `cancel_at_period_end = true` on the row: the school paid
362+
// for a full period and was then silently dropped to free at the end of
363+
// it by the cron's cancel phase, with no reminder and no grace window
364+
// (phases 1 and 2 both filter on `cancel_at_period_end = false`).
365+
cancel_at_period_end: false,
366+
canceled_at: null,
367+
// A real payment supersedes any super-admin comp (#546 §3) — this is one
368+
// of the override's exits, so portal changes start syncing again.
369+
plan_override_by: null,
370+
plan_override_at: null,
337371
updated_at: now.toISOString(),
338372
}, { onConflict: 'tenant_id' })
339373

@@ -503,6 +537,11 @@ export async function changePlan(planId: string, interval: 'monthly' | 'yearly'
503537
await ctx.stripe.subscriptions.update(ctx.subId, {
504538
items: [{ id: ctx.itemId, price: ctx.targetPriceId }],
505539
proration_behavior: 'create_prorations',
540+
// Paying for a different plan un-cancels (#546 §1). `resolvePlatformPlanChange`
541+
// accepts a still-`active` subscription, which a cancel-at-period-end sub is
542+
// right up to its last day — without this the school changes plan, is billed,
543+
// and is still dropped to free at period end.
544+
cancel_at_period_end: false,
506545
metadata: {
507546
tenant_id: tenantId,
508547
plan_id: ctx.plan.plan_id,
@@ -517,7 +556,18 @@ export async function changePlan(planId: string, interval: 'monthly' | 'yearly'
517556
const now = new Date().toISOString()
518557
await adminClient
519558
.from('platform_subscriptions')
520-
.update({ plan_id: ctx.plan.plan_id, interval, updated_at: now })
559+
.update({
560+
plan_id: ctx.plan.plan_id,
561+
interval,
562+
cancel_at_period_end: false,
563+
canceled_at: null,
564+
// An admin-initiated, Stripe-backed change supersedes a super-admin comp
565+
// (#546 §3): Stripe and the DB now agree again, so the reconciler should
566+
// resume applying portal changes for this tenant.
567+
plan_override_by: null,
568+
plan_override_at: null,
569+
updated_at: now,
570+
})
521571
.eq('tenant_id', tenantId)
522572
await adminClient
523573
.from('tenants')
@@ -593,6 +643,65 @@ export async function cancelSubscription() {
593643
return { success: true }
594644
}
595645

646+
/**
647+
* Undo a pending cancellation (#546 §1).
648+
*
649+
* Cancellation was one-way: the only writers of `cancel_at_period_end` were
650+
* `cancelSubscription` (sets it) and the Stripe webhook (mirrors Stripe), the
651+
* billing UI imported no reactivate action at all, and re-checkout is blocked
652+
* while the subscription is still `active` — which a cancel-at-period-end
653+
* subscription is until its last day. A school that changed its mind had no way
654+
* back short of contacting support.
655+
*/
656+
export async function reactivateSubscription() {
657+
const { tenantId } = await verifyAdminAccess()
658+
const adminClient = await createAdminClient()
659+
660+
const { data: subscription } = await adminClient
661+
.from('platform_subscriptions')
662+
.select('stripe_subscription_id, payment_method, status, cancel_at_period_end, current_period_end')
663+
.eq('tenant_id', tenantId)
664+
.single()
665+
666+
if (!subscription) throw new Error('No subscription to reactivate')
667+
if (!subscription.cancel_at_period_end) {
668+
throw new Error('This subscription is not scheduled for cancellation')
669+
}
670+
// Once the period has lapsed the cron has already downgraded (or is about
671+
// to); reactivating would revive a plan nobody is paying for. Those schools
672+
// go through checkout / a renewal request instead.
673+
if (subscription.status !== 'active') {
674+
throw new Error('This subscription has already ended. Please start a new plan instead.')
675+
}
676+
if (
677+
subscription.current_period_end &&
678+
new Date(subscription.current_period_end) <= new Date()
679+
) {
680+
throw new Error('Your billing period has already ended. Please start a new plan instead.')
681+
}
682+
683+
if (subscription.payment_method === 'stripe' && subscription.stripe_subscription_id) {
684+
// Stripe is authoritative for Stripe subs — clear it there first so a
685+
// failure leaves both sides still cancelling rather than disagreeing.
686+
const { getStripe } = await import('@/lib/stripe')
687+
await getStripe().subscriptions.update(subscription.stripe_subscription_id, {
688+
cancel_at_period_end: false,
689+
})
690+
}
691+
692+
await adminClient
693+
.from('platform_subscriptions')
694+
.update({
695+
cancel_at_period_end: false,
696+
canceled_at: null,
697+
updated_at: new Date().toISOString(),
698+
})
699+
.eq('tenant_id', tenantId)
700+
701+
revalidatePath('/dashboard/admin/billing')
702+
return { success: true }
703+
}
704+
596705
/**
597706
* Upload payment proof for a platform payment request (school admin)
598707
*/
@@ -681,21 +790,22 @@ export async function requestManualRenewal() {
681790
const now = new Date()
682791
const daysUntilEnd = Math.ceil((periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
683792

684-
if (daysUntilEnd > 30 && subscription.status === 'active') {
793+
// The window guard used to be bypassed entirely for any non-`active` status
794+
// (#546 §2), so a canceled or long-lapsed subscription could mint the row
795+
// that pauses the downgrade at any time. A school in grace (`past_due`) must
796+
// still be able to pay — that is the whole point of the pause — so allow it
797+
// explicitly rather than by falling through the guard.
798+
const lapsed = daysUntilEnd <= 0 || subscription.status === 'past_due'
799+
if (daysUntilEnd > 30 && !lapsed) {
685800
throw new Error('Renewal can only be requested within 30 days of period end')
686801
}
687802

688-
// Check for existing pending renewal request
689-
const { data: existingRequest } = await adminClient
690-
.from('platform_payment_requests')
691-
.select('request_id')
692-
.eq('tenant_id', tenantId)
693-
.eq('request_type', 'renewal')
694-
.in('status', ['pending', 'instructions_sent', 'payment_received'])
695-
.single()
696-
697-
if (existingRequest) {
698-
throw new Error('You already have a pending renewal request')
803+
// One open request per tenant, whatever its type: the old guard filtered on
804+
// `request_type = 'renewal'` while the upgrade guard did not, so creating a
805+
// renewal alongside a pending upgrade was the ordinary way to get two rows —
806+
// after which `.single()` returned PGRST116 and BOTH guards passed forever.
807+
if (await hasOpenPaymentRequest(adminClient, tenantId)) {
808+
throw new Error('You already have a pending payment request. Please wait for it to be processed.')
699809
}
700810

701811
const plan = subscription.platform_plans as { plan_id: string; price_monthly: number; price_yearly: number }
@@ -712,6 +822,7 @@ export async function requestManualRenewal() {
712822
currency: 'usd',
713823
status: 'pending',
714824
request_type: 'renewal',
825+
expires_at: requestExpiresAt(),
715826
})
716827
.select('request_id')
717828
.single()

0 commit comments

Comments
 (0)