Skip to content

Commit 8759872

Browse files
fix(billing): real access-cutoff enforcement for over-limit tenants (#494) (#501)
Non-payment previously produced no consequence — a downgraded or organically-over-limit tenant kept full course access indefinitely, and the "your courses and data are safe" downgrade email overpromised safety that no code enforced. `has_course_access()` now denies access tenant-wide once a scheduled 14-day grace period (`tenants.access_cutoff_at`) elapses while the tenant is still over its plan's course/student limits. `reconcileAccessCutoff()` is wired into every plan-state transition (webhook downgrade, in-app plan change, manual-payment confirm, portal change) plus a new daily cron for organic over-limit growth, and emails admins the exact deadline when a cutoff is scheduled. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f4317e7 commit 8759872

18 files changed

Lines changed: 465 additions & 47 deletions

File tree

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ interface BillingDashboardClientProps {
5050
}
5151
features: Record<string, unknown>
5252
transactionFeePercent: number
53+
accessCutoffAt: string | null
5354
}
5455
paymentRequests: Array<{
5556
request_id: string
@@ -59,6 +60,7 @@ interface BillingDashboardClientProps {
5960
interval: string
6061
created_at: string
6162
proof_url?: string | null
63+
request_type?: string
6264
platform_plans: { name: string; slug: string } | null
6365
}>
6466
}
@@ -99,8 +101,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
99101
await requestManualRenewal()
100102
toast.success('Renewal request submitted')
101103
router.refresh()
102-
} catch (e: any) {
103-
toast.error(e.message)
104+
} catch (e) {
105+
toast.error(e instanceof Error ? e.message : 'Failed to submit renewal request')
104106
} finally {
105107
setRenewalLoading(false)
106108
}
@@ -125,8 +127,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
125127
toast.success(t('cancelSuccess'))
126128
setCancelOpen(false)
127129
router.refresh()
128-
} catch (e: any) {
129-
toast.error(e.message || t('cancelError'))
130+
} catch (e) {
131+
toast.error(e instanceof Error ? e.message : t('cancelError'))
130132
} finally {
131133
setCancelLoading(false)
132134
}
@@ -140,8 +142,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
140142
await uploadPaymentProof(requestId, formData)
141143
toast.success('Proof uploaded successfully')
142144
router.refresh()
143-
} catch (e: any) {
144-
toast.error(e.message)
145+
} catch (e) {
146+
toast.error(e instanceof Error ? e.message : 'Failed to upload proof')
145147
throw e // re-throw so ProofUpload shows error state
146148
} finally {
147149
setUploadingFor(null)
@@ -158,7 +160,7 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
158160
const daysUntilEnd = periodEnd ? Math.ceil((periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) : null
159161
const showRenewalSection = isManualSub && daysUntilEnd !== null && daysUntilEnd <= 30
160162
const hasPendingRenewal = paymentRequests.some(
161-
(r) => (r as any).request_type === 'renewal' && !['confirmed', 'rejected', 'expired'].includes(r.status)
163+
(r) => r.request_type === 'renewal' && !['confirmed', 'rejected', 'expired'].includes(r.status)
162164
)
163165
const cancelDateStr = (() => {
164166
const raw = status.subscription?.currentPeriodEnd || status.billingPeriodEnd
@@ -176,6 +178,7 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
176178
upcomingPayment={status.upcomingPayment}
177179
usage={status.usage}
178180
transactionFeePercent={status.transactionFeePercent}
181+
accessCutoffAt={status.accessCutoffAt}
179182
onManageClick={status.hasStripeCustomer ? handleManageBilling : undefined}
180183
onCancelClick={() => setCancelOpen(true)}
181184
/>

app/actions/admin/billing.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
55
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
66
import { checkPlanLimits, formatPlanLimitError } from '@/lib/billing/plan-limits'
77
import { classifyPlanChange } from '@/lib/billing/plan-change'
8+
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
89
import { revalidatePath } from 'next/cache'
910

1011
async function verifyAdminAccess() {
@@ -39,7 +40,7 @@ export async function getSubscriptionStatus() {
3940
const [tenantResult, subscriptionResult, coursesCount, studentsCount] = await Promise.all([
4041
adminClient
4142
.from('tenants')
42-
.select('plan, billing_status, billing_period_end, billing_email, stripe_customer_id')
43+
.select('plan, billing_status, billing_period_end, billing_email, stripe_customer_id, access_cutoff_at')
4344
.eq('id', tenantId)
4445
.single(),
4546
adminClient
@@ -83,6 +84,7 @@ export async function getSubscriptionStatus() {
8384
billingPeriodEnd: tenant?.billing_period_end,
8485
billingEmail: tenant?.billing_email,
8586
hasStripeCustomer: !!tenant?.stripe_customer_id,
87+
accessCutoffAt: tenant?.access_cutoff_at ?? null,
8688
subscription: subscription ? {
8789
status: subscription.status,
8890
paymentMethod: subscription.payment_method,
@@ -356,6 +358,10 @@ export async function confirmManualPayment(requestId: string) {
356358
updated_at: now.toISOString(),
357359
}, { onConflict: 'tenant_id' })
358360

361+
// Activation already passed the pre-flight limit check above, so this
362+
// clears any cutoff scheduled from a prior over-limit period.
363+
await reconcileAccessCutoff(adminClient, request.tenant_id)
364+
359365
return { success: true }
360366
}
361367

@@ -529,6 +535,10 @@ export async function changePlan(planId: string, interval: 'monthly' | 'yearly'
529535
{ onConflict: 'tenant_id' }
530536
)
531537

538+
// Pre-flight check above already confirmed the new plan's limits are met,
539+
// so this clears any cutoff scheduled from a prior over-limit period.
540+
await reconcileAccessCutoff(adminClient, tenantId)
541+
532542
revalidatePath('/dashboard/admin/billing')
533543
return { success: true, plan: ctx.plan.slug }
534544
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
3+
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
4+
5+
export const runtime = 'nodejs'
6+
7+
/**
8+
* Cron job: daily sweep reconciling access-cutoff state for every tenant (issue #494).
9+
*
10+
* `reconcileAccessCutoff()` is already called from event-driven sites —
11+
* `downgradeTenantToFree()`, `changePlan()`, `confirmManualPayment()`, and
12+
* `applyPortalPlanChange()` — but none of those fire for a tenant that simply
13+
* grows over its own plan's limits organically (e.g. a free-plan school that
14+
* accumulates students with no plan-change event at all). This cron closes
15+
* that gap by walking every tenant once a day and reconciling
16+
* `tenants.access_cutoff_at` against its current plan/usage, regardless of
17+
* plan (a free-plan tenant needs this as much as a paid one).
18+
*
19+
* Secured by CRON_SECRET env var (set the same value in the cron scheduler).
20+
*/
21+
22+
function getSupabaseAdmin(): SupabaseClient {
23+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
24+
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
25+
if (!url || !serviceKey) throw new Error('Supabase env vars not set')
26+
return createClient(url, serviceKey)
27+
}
28+
29+
export async function GET(req: NextRequest) {
30+
const cronSecret = process.env.CRON_SECRET
31+
const provided = req.headers.get('authorization')?.replace('Bearer ', '')
32+
if (!cronSecret || provided !== cronSecret) {
33+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34+
}
35+
36+
const supabase = getSupabaseAdmin()
37+
38+
const { data: tenants } = await supabase.from('tenants').select('id')
39+
40+
const result = { scheduled: 0, cleared: 0, none: 0, errors: 0 }
41+
42+
for (const tenant of tenants || []) {
43+
try {
44+
const decision = await reconcileAccessCutoff(supabase, tenant.id)
45+
if (decision.action === 'schedule') result.scheduled++
46+
else if (decision.action === 'clear') result.cleared++
47+
else result.none++
48+
} catch (err) {
49+
console.error('enforce-plan-limits: reconcile failed for tenant', tenant.id, err)
50+
result.errors++
51+
}
52+
}
53+
54+
return NextResponse.json({ success: true, ...result })
55+
}

app/api/cron/expire-platform-subscriptions/route.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sendEmail } from '@/lib/email/send'
44
import { renewalReminderTemplate } from '@/lib/email/templates/renewal-reminder'
55
import { planDowngradedTemplate } from '@/lib/email/templates/plan-downgraded'
66
import { downgradeTenantToFree } from '@/lib/billing/downgrade-tenant'
7+
import { getTenantAdminEmails } from '@/lib/billing/tenant-admins'
78

89
export const runtime = 'nodejs'
910

@@ -41,26 +42,6 @@ function getSupabaseAdmin(): SupabaseClient {
4142
return createClient(url, serviceKey)
4243
}
4344

44-
/**
45-
* Resolve the active admin emails for a tenant. profiles has no email column,
46-
* so emails come from the auth admin API keyed by tenant_users membership.
47-
*/
48-
async function getTenantAdminEmails(supabase: SupabaseClient, tenantId: string): Promise<string[]> {
49-
const { data: adminUsers } = await supabase
50-
.from('tenant_users')
51-
.select('user_id')
52-
.eq('tenant_id', tenantId)
53-
.eq('role', 'admin')
54-
.eq('status', 'active')
55-
56-
const emails: string[] = []
57-
for (const admin of adminUsers || []) {
58-
const { data: authUser } = await supabase.auth.admin.getUserById(admin.user_id)
59-
if (authUser?.user?.email) emails.push(authUser.user.email)
60-
}
61-
return emails
62-
}
63-
6445
// Email sends must never abort a transition; failures are logged and swallowed.
6546
async function safeEmail(emails: string[], template: { subject: string; html: string }): Promise<void> {
6647
for (const to of emails) {

components/admin/billing-overview.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
44
import { Badge } from '@/components/ui/badge'
55
import { Button } from '@/components/ui/button'
66
import { UsageMeter } from './usage-meter'
7+
import { LimitReachedBanner } from '@/components/shared/limit-reached-banner'
78
import { IconCreditCard, IconCalendar, IconAlertTriangle, IconX } from '@tabler/icons-react'
89
import Link from 'next/link'
910
import { useTranslations } from 'next-intl'
@@ -33,6 +34,7 @@ interface BillingOverviewProps {
3334
students: { current: number; limit: number }
3435
}
3536
transactionFeePercent: number
37+
accessCutoffAt: string | null
3638
onManageClick?: () => void
3739
onCancelClick?: () => void
3840
}
@@ -46,6 +48,7 @@ export function BillingOverview({
4648
upcomingPayment,
4749
usage,
4850
transactionFeePercent,
51+
accessCutoffAt,
4952
onManageClick,
5053
onCancelClick,
5154
}: BillingOverviewProps) {
@@ -202,16 +205,32 @@ export function BillingOverview({
202205

203206
<section aria-label={`${t('currentPlan')} usage`} className="border-t pt-5">
204207
<div className="grid gap-5 md:grid-cols-2">
205-
<UsageMeter
206-
label={t('courses')}
207-
current={usage.courses.current}
208-
limit={usage.courses.limit}
209-
/>
210-
<UsageMeter
211-
label={t('students')}
212-
current={usage.students.current}
213-
limit={usage.students.limit}
214-
/>
208+
<div className="space-y-3">
209+
<UsageMeter
210+
label={t('courses')}
211+
current={usage.courses.current}
212+
limit={usage.courses.limit}
213+
/>
214+
<LimitReachedBanner
215+
resource="courses"
216+
current={usage.courses.current}
217+
limit={usage.courses.limit}
218+
cutoffAt={accessCutoffAt}
219+
/>
220+
</div>
221+
<div className="space-y-3">
222+
<UsageMeter
223+
label={t('students')}
224+
current={usage.students.current}
225+
limit={usage.students.limit}
226+
/>
227+
<LimitReachedBanner
228+
resource="students"
229+
current={usage.students.current}
230+
limit={usage.students.limit}
231+
cutoffAt={accessCutoffAt}
232+
/>
233+
</div>
215234
</div>
216235
</section>
217236
</CardContent>

components/shared/limit-reached-banner.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ interface LimitReachedBannerProps {
1111
current: number
1212
limit: number // -1 = unlimited
1313
className?: string
14+
cutoffAt?: string | null
1415
}
1516

16-
export function LimitReachedBanner({ resource, current, limit, className }: LimitReachedBannerProps) {
17+
export function LimitReachedBanner({ resource, current, limit, className, cutoffAt }: LimitReachedBannerProps) {
1718
const [dismissed, setDismissed] = useState(false)
1819

1920
if (dismissed || limit === -1) return null
@@ -33,12 +34,19 @@ export function LimitReachedBanner({ resource, current, limit, className }: Limi
3334
className
3435
)}>
3536
<IconAlertTriangle className="h-5 w-5 shrink-0" />
36-
<p className="flex-1 text-sm">
37-
{isAtLimit
38-
? `You've reached your ${resource} limit (${limit}). Upgrade your plan to add more.`
39-
: `You're using ${current} of ${limit} ${resource}. Consider upgrading for more capacity.`
40-
}
41-
</p>
37+
<div className="flex-1 text-sm">
38+
<p>
39+
{isAtLimit
40+
? `You've reached your ${resource} limit (${limit}). Upgrade your plan to add more.`
41+
: `You're using ${current} of ${limit} ${resource}. Consider upgrading for more capacity.`
42+
}
43+
</p>
44+
{cutoffAt && (
45+
<p className="font-semibold">
46+
If this is not resolved by {new Date(cutoffAt).toLocaleDateString(undefined, { dateStyle: 'long' })}, all students will lose access to all courses.
47+
</p>
48+
)}
49+
</div>
4250
<Link href="/dashboard/admin/billing/upgrade">
4351
<Button variant={isAtLimit ? 'destructive' : 'outline'} size="sm">
4452
Upgrade

docs/MONETIZATION.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,17 @@ function MyComponent() {
232232

233233
`app/actions/teacher/courses.ts` reads `platform_plans.limits.max_courses` from the database (not hardcoded). Returns `approaching: true` with `nextPlan` and `nextPlanPrice` when at 80%+ usage.
234234

235+
The two sections above are **creation-time soft caps only** — they block a *new* student from joining or a *new* course from being created once a tenant is at its limit. Neither one does anything about students/courses that already exist when a tenant ends up over its plan's limit (e.g. after a downgrade, or by organically outgrowing its plan with no plan-change event). That gap was issue #494; it's closed by the mechanism below.
236+
237+
### Post-Downgrade Access Enforcement (issue #494)
238+
239+
`lib/billing/access-cutoff.ts` is the single place that decides and schedules `tenants.access_cutoff_at`:
240+
241+
- Whenever a tenant's usage is checked, `countTenantUsage`/`computePlanLimitViolations` (`lib/billing/plan-limits.ts`) compare current course/active-student counts against the tenant's **current** plan limits. If either is over limit and no cutoff is already scheduled, `reconcileAccessCutoff()` schedules `access_cutoff_at` `ACCESS_CUTOFF_GRACE_DAYS` (14 days) out and emails the tenant's admins immediately via `accessCutoffWarningTemplate` (`lib/email/templates/access-cutoff-warning.ts`), naming the exact cutoff date and which limit(s) are exceeded. If usage drops back under the limit, the next reconciliation clears the scheduled cutoff automatically.
242+
- `reconcileAccessCutoff()` is called from every plan-state transition: `downgradeTenantToFree()` (both the Stripe `customer.subscription.deleted` webhook and the manual-transfer expiry cron), `changePlan()` and `confirmManualPayment()` in `app/actions/admin/billing.ts`, `applyPortalPlanChange()` (`lib/payments/platform-plan-change.ts`), and a daily cron `app/api/cron/enforce-plan-limits/route.ts` that sweeps every tenant to catch organic over-limit growth with no associated plan-change event.
243+
- Enforcement itself is in the database: once `access_cutoff_at` passes while the tenant is still over its plan's limits, `has_course_access()` (SQL function, migration `supabase/migrations/20260724130000_access_cutoff_enforcement.sql`) starts returning `false` for every student in that tenant — cutting off access tenant-wide, everywhere that function (or its TS counterpart `hasCourseAccess()`) gates access: lessons, exams, exercises, certificates, AI tutor chat. Access is restored automatically as soon as usage is back under the limit or the tenant upgrades.
244+
- This is a deliberate decision, not an oversight: with no production users yet, real enforcement was shipped directly rather than grandfathering pre-existing over-limit usage.
245+
235246
---
236247

237248
## Currency Support

0 commit comments

Comments
 (0)