Skip to content
Merged
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
17 changes: 10 additions & 7 deletions app/[locale]/dashboard/admin/billing/billing-dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface BillingDashboardClientProps {
}
features: Record<string, unknown>
transactionFeePercent: number
accessCutoffAt: string | null
}
paymentRequests: Array<{
request_id: string
Expand All @@ -59,6 +60,7 @@ interface BillingDashboardClientProps {
interval: string
created_at: string
proof_url?: string | null
request_type?: string
platform_plans: { name: string; slug: string } | null
}>
}
Expand Down Expand Up @@ -99,8 +101,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
await requestManualRenewal()
toast.success('Renewal request submitted')
router.refresh()
} catch (e: any) {
toast.error(e.message)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to submit renewal request')
} finally {
setRenewalLoading(false)
}
Expand All @@ -125,8 +127,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
toast.success(t('cancelSuccess'))
setCancelOpen(false)
router.refresh()
} catch (e: any) {
toast.error(e.message || t('cancelError'))
} catch (e) {
toast.error(e instanceof Error ? e.message : t('cancelError'))
} finally {
setCancelLoading(false)
}
Expand All @@ -140,8 +142,8 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
await uploadPaymentProof(requestId, formData)
toast.success('Proof uploaded successfully')
router.refresh()
} catch (e: any) {
toast.error(e.message)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to upload proof')
throw e // re-throw so ProofUpload shows error state
} finally {
setUploadingFor(null)
Expand All @@ -158,7 +160,7 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
const daysUntilEnd = periodEnd ? Math.ceil((periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) : null
const showRenewalSection = isManualSub && daysUntilEnd !== null && daysUntilEnd <= 30
const hasPendingRenewal = paymentRequests.some(
(r) => (r as any).request_type === 'renewal' && !['confirmed', 'rejected', 'expired'].includes(r.status)
(r) => r.request_type === 'renewal' && !['confirmed', 'rejected', 'expired'].includes(r.status)
)
const cancelDateStr = (() => {
const raw = status.subscription?.currentPeriodEnd || status.billingPeriodEnd
Expand All @@ -176,6 +178,7 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
upcomingPayment={status.upcomingPayment}
usage={status.usage}
transactionFeePercent={status.transactionFeePercent}
accessCutoffAt={status.accessCutoffAt}
onManageClick={status.hasStripeCustomer ? handleManageBilling : undefined}
onCancelClick={() => setCancelOpen(true)}
/>
Expand Down
12 changes: 11 additions & 1 deletion app/actions/admin/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
import { checkPlanLimits, formatPlanLimitError } from '@/lib/billing/plan-limits'
import { classifyPlanChange } from '@/lib/billing/plan-change'
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'
import { revalidatePath } from 'next/cache'

async function verifyAdminAccess() {
Expand Down Expand Up @@ -39,7 +40,7 @@ export async function getSubscriptionStatus() {
const [tenantResult, subscriptionResult, coursesCount, studentsCount] = await Promise.all([
adminClient
.from('tenants')
.select('plan, billing_status, billing_period_end, billing_email, stripe_customer_id')
.select('plan, billing_status, billing_period_end, billing_email, stripe_customer_id, access_cutoff_at')
.eq('id', tenantId)
.single(),
adminClient
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function getSubscriptionStatus() {
billingPeriodEnd: tenant?.billing_period_end,
billingEmail: tenant?.billing_email,
hasStripeCustomer: !!tenant?.stripe_customer_id,
accessCutoffAt: tenant?.access_cutoff_at ?? null,
subscription: subscription ? {
status: subscription.status,
paymentMethod: subscription.payment_method,
Expand Down Expand Up @@ -356,6 +358,10 @@ export async function confirmManualPayment(requestId: string) {
updated_at: now.toISOString(),
}, { onConflict: 'tenant_id' })

// Activation already passed the pre-flight limit check above, so this
// clears any cutoff scheduled from a prior over-limit period.
await reconcileAccessCutoff(adminClient, request.tenant_id)

return { success: true }
}

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

// Pre-flight check above already confirmed the new plan's limits are met,
// so this clears any cutoff scheduled from a prior over-limit period.
await reconcileAccessCutoff(adminClient, tenantId)

revalidatePath('/dashboard/admin/billing')
return { success: true, plan: ctx.plan.slug }
}
Expand Down
55 changes: 55 additions & 0 deletions app/api/cron/enforce-plan-limits/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { reconcileAccessCutoff } from '@/lib/billing/access-cutoff'

export const runtime = 'nodejs'

/**
* Cron job: daily sweep reconciling access-cutoff state for every tenant (issue #494).
*
* `reconcileAccessCutoff()` is already called from event-driven sites —
* `downgradeTenantToFree()`, `changePlan()`, `confirmManualPayment()`, and
* `applyPortalPlanChange()` — but none of those fire for a tenant that simply
* grows over its own plan's limits organically (e.g. a free-plan school that
* accumulates students with no plan-change event at all). This cron closes
* that gap by walking every tenant once a day and reconciling
* `tenants.access_cutoff_at` against its current plan/usage, regardless of
* plan (a free-plan tenant needs this as much as a paid one).
*
* Secured by CRON_SECRET env var (set the same value in the cron scheduler).
*/

function getSupabaseAdmin(): SupabaseClient {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !serviceKey) throw new Error('Supabase env vars not set')
return createClient(url, serviceKey)
}

export async function GET(req: NextRequest) {
const cronSecret = process.env.CRON_SECRET
const provided = req.headers.get('authorization')?.replace('Bearer ', '')
if (!cronSecret || provided !== cronSecret) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const supabase = getSupabaseAdmin()

const { data: tenants } = await supabase.from('tenants').select('id')

const result = { scheduled: 0, cleared: 0, none: 0, errors: 0 }

for (const tenant of tenants || []) {
try {
const decision = await reconcileAccessCutoff(supabase, tenant.id)
if (decision.action === 'schedule') result.scheduled++
else if (decision.action === 'clear') result.cleared++
else result.none++
} catch (err) {
console.error('enforce-plan-limits: reconcile failed for tenant', tenant.id, err)
result.errors++
}
}

return NextResponse.json({ success: true, ...result })
}
21 changes: 1 addition & 20 deletions app/api/cron/expire-platform-subscriptions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sendEmail } from '@/lib/email/send'
import { renewalReminderTemplate } from '@/lib/email/templates/renewal-reminder'
import { planDowngradedTemplate } from '@/lib/email/templates/plan-downgraded'
import { downgradeTenantToFree } from '@/lib/billing/downgrade-tenant'
import { getTenantAdminEmails } from '@/lib/billing/tenant-admins'

export const runtime = 'nodejs'

Expand Down Expand Up @@ -41,26 +42,6 @@ function getSupabaseAdmin(): SupabaseClient {
return createClient(url, serviceKey)
}

/**
* Resolve the active admin emails for a tenant. profiles has no email column,
* so emails come from the auth admin API keyed by tenant_users membership.
*/
async function getTenantAdminEmails(supabase: SupabaseClient, tenantId: string): Promise<string[]> {
const { data: adminUsers } = await supabase
.from('tenant_users')
.select('user_id')
.eq('tenant_id', tenantId)
.eq('role', 'admin')
.eq('status', 'active')

const emails: string[] = []
for (const admin of adminUsers || []) {
const { data: authUser } = await supabase.auth.admin.getUserById(admin.user_id)
if (authUser?.user?.email) emails.push(authUser.user.email)
}
return emails
}

// Email sends must never abort a transition; failures are logged and swallowed.
async function safeEmail(emails: string[], template: { subject: string; html: string }): Promise<void> {
for (const to of emails) {
Expand Down
39 changes: 29 additions & 10 deletions components/admin/billing-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { UsageMeter } from './usage-meter'
import { LimitReachedBanner } from '@/components/shared/limit-reached-banner'
import { IconCreditCard, IconCalendar, IconAlertTriangle, IconX } from '@tabler/icons-react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
Expand Down Expand Up @@ -33,6 +34,7 @@ interface BillingOverviewProps {
students: { current: number; limit: number }
}
transactionFeePercent: number
accessCutoffAt: string | null
onManageClick?: () => void
onCancelClick?: () => void
}
Expand All @@ -46,6 +48,7 @@ export function BillingOverview({
upcomingPayment,
usage,
transactionFeePercent,
accessCutoffAt,
onManageClick,
onCancelClick,
}: BillingOverviewProps) {
Expand Down Expand Up @@ -202,16 +205,32 @@ export function BillingOverview({

<section aria-label={`${t('currentPlan')} usage`} className="border-t pt-5">
<div className="grid gap-5 md:grid-cols-2">
<UsageMeter
label={t('courses')}
current={usage.courses.current}
limit={usage.courses.limit}
/>
<UsageMeter
label={t('students')}
current={usage.students.current}
limit={usage.students.limit}
/>
<div className="space-y-3">
<UsageMeter
label={t('courses')}
current={usage.courses.current}
limit={usage.courses.limit}
/>
<LimitReachedBanner
resource="courses"
current={usage.courses.current}
limit={usage.courses.limit}
cutoffAt={accessCutoffAt}
/>
</div>
<div className="space-y-3">
<UsageMeter
label={t('students')}
current={usage.students.current}
limit={usage.students.limit}
/>
<LimitReachedBanner
resource="students"
current={usage.students.current}
limit={usage.students.limit}
cutoffAt={accessCutoffAt}
/>
</div>
</div>
</section>
</CardContent>
Expand Down
22 changes: 15 additions & 7 deletions components/shared/limit-reached-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ interface LimitReachedBannerProps {
current: number
limit: number // -1 = unlimited
className?: string
cutoffAt?: string | null
}

export function LimitReachedBanner({ resource, current, limit, className }: LimitReachedBannerProps) {
export function LimitReachedBanner({ resource, current, limit, className, cutoffAt }: LimitReachedBannerProps) {
const [dismissed, setDismissed] = useState(false)

if (dismissed || limit === -1) return null
Expand All @@ -33,12 +34,19 @@ export function LimitReachedBanner({ resource, current, limit, className }: Limi
className
)}>
<IconAlertTriangle className="h-5 w-5 shrink-0" />
<p className="flex-1 text-sm">
{isAtLimit
? `You've reached your ${resource} limit (${limit}). Upgrade your plan to add more.`
: `You're using ${current} of ${limit} ${resource}. Consider upgrading for more capacity.`
}
</p>
<div className="flex-1 text-sm">
<p>
{isAtLimit
? `You've reached your ${resource} limit (${limit}). Upgrade your plan to add more.`
: `You're using ${current} of ${limit} ${resource}. Consider upgrading for more capacity.`
}
</p>
{cutoffAt && (
<p className="font-semibold">
If this is not resolved by {new Date(cutoffAt).toLocaleDateString(undefined, { dateStyle: 'long' })}, all students will lose access to all courses.
</p>
)}
</div>
<Link href="/dashboard/admin/billing/upgrade">
<Button variant={isAtLimit ? 'destructive' : 'outline'} size="sm">
Upgrade
Expand Down
11 changes: 11 additions & 0 deletions docs/MONETIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ function MyComponent() {

`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.

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.

### Post-Downgrade Access Enforcement (issue #494)

`lib/billing/access-cutoff.ts` is the single place that decides and schedules `tenants.access_cutoff_at`:

- 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.
- `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.
- 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.
- 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.

---

## Currency Support
Expand Down
Loading
Loading