33import { createClient } from '@/lib/supabase/server'
44import { createAdminClient } from '@/lib/supabase/admin'
55import { 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'
77import { classifyPlanChange } from '@/lib/billing/plan-change'
88import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
9+ import {
10+ OPEN_REQUEST_STATUSES ,
11+ isRequestOpen ,
12+ requestExpiresAt ,
13+ } from '@/lib/billing/payment-request-ttl'
914import { revalidatePath } from 'next/cache'
1015
1116async 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 */
3564export 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