Skip to content

Commit 79445ea

Browse files
fix(billing): platform billing cleanup (#468) (#480)
* fix(billing): platform billing cleanup (#468) Address the platform-billing correctness/consistency items from issue #468 (Phase 4 cleanup of epic #458): - instructions_sent / payment_received were rendered in the UI but never written. Add a super-admin `sendPaymentInstructions` action (pending → instructions_sent + in-app notification to tenant admins) and advance a request to payment_received when the school uploads transfer proof, so the intermediate manual-flow states are actually reachable. - forceTenantPlanChange updated tenants.plan + revenue_splits but left platform_subscriptions pointing at the old plan. It now syncs the subscription row atomically (updates plan_id on the existing row, cancels it when forcing to free, or creates a manual override row when none exists). - Stripe checkout/portal success/cancel/return URLs hardcoded /en/. Thread the requester's locale (from the POST body, with a referer fallback) via a shared resolveRequestLocale helper, validated against the supported locale set. - Surface the plan's current price next to the snapshotted request amount on the super-admin confirm view so price drift is visible at confirm time. The hardcoded 10/90 split on cancellation (item 3) was already resolved on master by lib/billing/downgrade-tenant.ts (reads the free plan's transaction_fee_percent); verified, no change needed. The price snapshot itself (item 5) already exists as platform_payment_requests.amount; only confirm-time visibility was missing. Pre-existing no-explicit-any lint errors in the touched files are not introduced by this change (repo lint baseline is non-blocking; npm run build passes clean). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTd2ffE5QnJphufL8YD7KV * fix(billing): make forced plan overrides indefinite so the expiry cron can't undo them forceTenantPlanChange created its manual override row with a +1 month current_period_end, which put it squarely in the path of the expire-platform-subscriptions cron (#471): the school would get renewal reminders for a payment it never requested, then be silently downgraded back to free a month later. The reactivation branch had the same bug via a stale period end on a previously-canceled row. Every cron phase filters on `.not('current_period_end', 'is', null)`, so a NULL period end is the natural "indefinite override" marker: the row is never reminded, never lapses, never auto-downgrades. Keep a real period only when reactivating a sub already on a live paid cycle; a school that later pays via confirmManualPayment upserts a dated cycle over the row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HveVmDShzscMQVQdd9LuDZ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4e837dd commit 79445ea

9 files changed

Lines changed: 270 additions & 17 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
AlertDialogTitle,
1919
} from '@/components/ui/alert-dialog'
2020
import { IconExternalLink, IconRefresh, IconCreditCard, IconPhoto, IconClock, IconLoader2 } from '@tabler/icons-react'
21-
import { useTranslations } from 'next-intl'
21+
import { useTranslations, useLocale } from 'next-intl'
2222
import { uploadPaymentProof, requestManualRenewal, cancelSubscription } from '@/app/actions/admin/billing'
2323

2424
interface BillingDashboardClientProps {
@@ -66,6 +66,7 @@ interface BillingDashboardClientProps {
6666
export function BillingDashboardClient({ status, paymentRequests }: BillingDashboardClientProps) {
6767
const router = useRouter()
6868
const t = useTranslations('dashboard.admin.billing.overview')
69+
const locale = useLocale()
6970
const [portalLoading, setPortalLoading] = useState(false)
7071
const [renewalLoading, setRenewalLoading] = useState(false)
7172
const [switchLoading, setSwitchLoading] = useState(false)
@@ -76,7 +77,11 @@ export function BillingDashboardClient({ status, paymentRequests }: BillingDashb
7677
const handleManageBilling = async () => {
7778
setPortalLoading(true)
7879
try {
79-
const response = await fetch('/api/stripe/billing-portal', { method: 'POST' })
80+
const response = await fetch('/api/stripe/billing-portal', {
81+
method: 'POST',
82+
headers: { 'Content-Type': 'application/json' },
83+
body: JSON.stringify({ locale }),
84+
})
8085
const data = await response.json()
8186
if (data.url) {
8287
window.location.href = data.url

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function UpgradePageClient({ plans, currentPlan, preselectedPlan, presele
6666
const response = await fetch('/api/stripe/checkout-session', {
6767
method: 'POST',
6868
headers: { 'Content-Type': 'application/json' },
69-
body: JSON.stringify({ planId, interval }),
69+
body: JSON.stringify({ planId, interval, locale }),
7070
})
7171
const data = await response.json()
7272
if (data.url) {

app/[locale]/platform/billing/billing-actions.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,33 @@ import {
1313
} from "@/components/ui/dialog"
1414
import { Textarea } from "@/components/ui/textarea"
1515
import { confirmManualPayment } from "@/app/actions/admin/billing"
16-
import { rejectManualPayment } from "@/app/actions/platform/plans"
16+
import { rejectManualPayment, sendPaymentInstructions } from "@/app/actions/platform/plans"
1717

1818
interface Props {
1919
requestId: string
20+
status: string
2021
}
2122

22-
export function BillingActions({ requestId }: Props) {
23+
export function BillingActions({ requestId, status }: Props) {
2324
const router = useRouter()
2425
const [loadingConfirm, setLoadingConfirm] = useState(false)
2526
const [showRejectModal, setShowRejectModal] = useState(false)
2627
const [reason, setReason] = useState('')
2728
const [loadingReject, setLoadingReject] = useState(false)
29+
const [loadingInstructions, setLoadingInstructions] = useState(false)
30+
31+
async function handleSendInstructions() {
32+
setLoadingInstructions(true)
33+
try {
34+
await sendPaymentInstructions(requestId)
35+
toast.success('Instructions marked as sent — school notified')
36+
router.refresh()
37+
} catch (e: any) {
38+
toast.error(e.message)
39+
} finally {
40+
setLoadingInstructions(false)
41+
}
42+
}
2843

2944
async function handleConfirm() {
3045
setLoadingConfirm(true)
@@ -60,6 +75,17 @@ export function BillingActions({ requestId }: Props) {
6075
return (
6176
<>
6277
<div className="flex items-center justify-end gap-2">
78+
{status === 'pending' && (
79+
<Button
80+
size="sm"
81+
variant="outline"
82+
onClick={handleSendInstructions}
83+
disabled={loadingInstructions}
84+
data-testid="send-instructions-btn"
85+
>
86+
{loadingInstructions ? 'Sending…' : 'Send instructions'}
87+
</Button>
88+
)}
6389
<Button
6490
size="sm"
6591
onClick={handleConfirm}

app/[locale]/platform/billing/page.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default async function PlatformBillingPage({
2525

2626
let query = adminClient
2727
.from('platform_payment_requests')
28-
.select('*, platform_plans(name, slug), tenants(name, slug)')
28+
.select('*, platform_plans(name, slug, price_monthly, price_yearly), tenants(name, slug)')
2929
.order('created_at', { ascending: false })
3030
.limit(100)
3131

@@ -89,7 +89,18 @@ export default async function PlatformBillingPage({
8989
</tr>
9090
</thead>
9191
<tbody>
92-
{(requests || []).map((req: any) => (
92+
{(requests || []).map((req: any) => {
93+
// Item 5: `amount` is the price snapshotted at request time.
94+
// Surface the plan's *current* price so a super admin can see
95+
// if it drifted (e.g. the plan was re-priced before confirm).
96+
const currentPlanPrice =
97+
req.interval === 'yearly'
98+
? req.platform_plans?.price_yearly
99+
: req.platform_plans?.price_monthly
100+
const priceDrifted =
101+
typeof currentPlanPrice === 'number' &&
102+
Number(currentPlanPrice) !== Number(req.amount)
103+
return (
93104
<tr key={req.request_id} className="border-b last:border-0 transition-colors hover:bg-muted/40" data-testid="billing-request-row" data-request-id={req.request_id} data-status={req.status}>
94105
<td className="px-4 py-3 font-medium">
95106
{req.tenants?.name || req.tenant_id}
@@ -99,6 +110,15 @@ export default async function PlatformBillingPage({
99110
</td>
100111
<td className="px-4 py-3 text-right font-semibold tabular-nums">
101112
{new Intl.NumberFormat('en-US', { style: 'currency', currency: req.currency || 'USD' }).format(req.amount)}
113+
{priceDrifted && (
114+
<span
115+
className="mt-0.5 block text-[10px] font-normal text-amber-600 dark:text-amber-500"
116+
data-testid="price-drift-note"
117+
title="The plan price changed after this request was created."
118+
>
119+
plan now {new Intl.NumberFormat('en-US', { style: 'currency', currency: req.currency || 'USD' }).format(currentPlanPrice)}
120+
</span>
121+
)}
102122
</td>
103123
<td className="px-4 py-3 capitalize text-muted-foreground">{req.interval}</td>
104124
<td className="px-4 py-3">
@@ -125,11 +145,12 @@ export default async function PlatformBillingPage({
125145
</td>
126146
<td className="px-4 py-3 text-right">
127147
{['pending', 'instructions_sent', 'payment_received'].includes(req.status) && (
128-
<BillingActions requestId={req.request_id} />
148+
<BillingActions requestId={req.request_id} status={req.status} />
129149
)}
130150
</td>
131151
</tr>
132-
))}
152+
)
153+
})}
133154
{(!requests || requests.length === 0) && (
134155
<tr>
135156
<td colSpan={8} className="px-4 py-12 text-center text-sm text-muted-foreground">

app/actions/admin/billing.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,9 +628,19 @@ export async function uploadPaymentProof(requestId: string, formData: FormData)
628628

629629
const proofUrl = signedUrlData?.signedUrl || urlData.publicUrl
630630

631+
// Uploading proof advances the request to `payment_received` so a super
632+
// admin can see the school has paid — unless it's already been
633+
// confirmed/rejected, in which case the status is left untouched.
634+
const advanceStatus =
635+
request.status === 'pending' || request.status === 'instructions_sent'
636+
631637
await adminClient
632638
.from('platform_payment_requests')
633-
.update({ proof_url: proofUrl, updated_at: new Date().toISOString() })
639+
.update({
640+
proof_url: proofUrl,
641+
...(advanceStatus ? { status: 'payment_received' } : {}),
642+
updated_at: new Date().toISOString(),
643+
})
634644
.eq('request_id', requestId)
635645

636646
revalidatePath('/dashboard/admin/billing')

app/actions/platform/plans.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,75 @@ export async function rejectManualPayment(requestId: string, reason: string) {
121121
return { success: true }
122122
}
123123

124+
/**
125+
* Super admin: mark bank-transfer instructions as sent for a manual payment
126+
* request. Moves it `pending → instructions_sent` and notifies the tenant's
127+
* admins in-app so the dead intermediate state is actually reachable.
128+
*/
129+
export async function sendPaymentInstructions(requestId: string) {
130+
const userId = await verifySuperAdmin()
131+
const adminClient = createAdminClient()
132+
133+
const { data: request } = await adminClient
134+
.from('platform_payment_requests')
135+
.select('request_id, tenant_id, status')
136+
.eq('request_id', requestId)
137+
.single()
138+
139+
if (!request) throw new Error('Request not found')
140+
if (request.status !== 'pending') {
141+
throw new Error('Instructions can only be sent for pending requests')
142+
}
143+
144+
await adminClient
145+
.from('platform_payment_requests')
146+
.update({ status: 'instructions_sent', updated_at: new Date().toISOString() })
147+
.eq('request_id', requestId)
148+
149+
// Best-effort in-app notification to the tenant's admins — never block the
150+
// status change on a notification failure.
151+
try {
152+
const { data: adminUsers } = await adminClient
153+
.from('tenant_users')
154+
.select('user_id')
155+
.eq('tenant_id', request.tenant_id)
156+
.eq('role', 'admin')
157+
.eq('status', 'active')
158+
159+
const adminIds = (adminUsers || []).map((u: { user_id: string }) => u.user_id)
160+
if (adminIds.length > 0) {
161+
const { data: notification } = await adminClient
162+
.from('notifications')
163+
.insert({
164+
title: 'Bank transfer instructions sent',
165+
content:
166+
'Bank transfer instructions for your plan payment are on the way. Check your email, complete the transfer, then upload your proof of payment from the billing page.',
167+
notification_type: 'info',
168+
priority: 'normal',
169+
target_type: 'user',
170+
target_user_ids: adminIds,
171+
status: 'sent',
172+
sent_at: new Date().toISOString(),
173+
created_by: userId,
174+
tenant_id: request.tenant_id,
175+
})
176+
.select('id')
177+
.single()
178+
179+
if (notification) {
180+
await adminClient
181+
.from('user_notifications')
182+
.insert(adminIds.map((uid) => ({ notification_id: notification.id, user_id: uid })))
183+
}
184+
}
185+
} catch (err) {
186+
console.error('Failed to notify tenant admins about payment instructions:', err)
187+
}
188+
189+
revalidatePath('/platform/billing')
190+
return { success: true }
191+
}
192+
124193
export async function forceTenantPlanChange(tenantId: string, planSlug: string) {
125194
await verifySuperAdmin()
126195
const adminClient = createAdminClient()
@@ -133,9 +202,11 @@ export async function forceTenantPlanChange(tenantId: string, planSlug: string)
133202

134203
if (!plan) throw new Error('Plan not found')
135204

205+
const nowIso = new Date().toISOString()
206+
136207
await adminClient
137208
.from('tenants')
138-
.update({ plan: planSlug, updated_at: new Date().toISOString() })
209+
.update({ plan: planSlug, updated_at: nowIso })
139210
.eq('id', tenantId)
140211

141212
// Update revenue split
@@ -145,9 +216,85 @@ export async function forceTenantPlanChange(tenantId: string, planSlug: string)
145216
tenant_id: tenantId,
146217
platform_percentage: plan.transaction_fee_percent,
147218
school_percentage: 100 - plan.transaction_fee_percent,
148-
updated_at: new Date().toISOString(),
219+
updated_at: nowIso,
149220
}, { onConflict: 'tenant_id' })
150221

222+
// Keep platform_subscriptions in sync with the forced plan (issue #468).
223+
// Previously this action changed tenants.plan + the split but left the
224+
// subscription row pointing at the old plan, so the two disagreed after an
225+
// override.
226+
const { data: existingSub } = await adminClient
227+
.from('platform_subscriptions')
228+
.select('subscription_id, status, current_period_end')
229+
.eq('tenant_id', tenantId)
230+
.maybeSingle()
231+
232+
if (planSlug === 'free') {
233+
// Free needs no active subscription — cancel any existing row so the
234+
// subscription and the tenant's plan agree.
235+
if (existingSub) {
236+
await adminClient
237+
.from('platform_subscriptions')
238+
.update({
239+
plan_id: plan.plan_id,
240+
status: 'canceled',
241+
canceled_at: nowIso,
242+
updated_at: nowIso,
243+
})
244+
.eq('tenant_id', tenantId)
245+
}
246+
} else if (existingSub) {
247+
// Point the existing subscription at the forced plan. Preserve the billing
248+
// period only when the sub is on a live paid cycle (active with a future
249+
// period end); otherwise — canceled/expired row, or a lapsed period —
250+
// reactivating with the stale current_period_end would hand the row
251+
// straight to the expire-platform-subscriptions cron (past_due, then
252+
// auto-downgrade after grace), silently undoing the override. Clear the
253+
// period instead so the override is indefinite, like the insert below.
254+
const liveCycle =
255+
existingSub.status === 'active' &&
256+
existingSub.current_period_end != null &&
257+
new Date(existingSub.current_period_end) > new Date()
258+
await adminClient
259+
.from('platform_subscriptions')
260+
.update({
261+
plan_id: plan.plan_id,
262+
status: 'active',
263+
updated_at: nowIso,
264+
...(liveCycle
265+
? {}
266+
: {
267+
current_period_end: null,
268+
grace_period_end: null,
269+
cancel_at_period_end: false,
270+
canceled_at: null,
271+
}),
272+
})
273+
.eq('tenant_id', tenantId)
274+
} else {
275+
// No subscription yet (e.g. tenant was on free): create a manual override
276+
// row so the paid plan is backed by an active subscription.
277+
//
278+
// current_period_end stays NULL on purpose: a super-admin override is
279+
// indefinite, not a one-month grant. The expire-platform-subscriptions
280+
// cron filters every phase on `.not('current_period_end', 'is', null)`,
281+
// so a NULL period end is never reminded, never lapses to past_due, and
282+
// never auto-downgrades — the override holds until a super admin changes
283+
// it again or the school starts paying (confirmManualPayment then upserts
284+
// a real dated cycle over this row).
285+
await adminClient
286+
.from('platform_subscriptions')
287+
.insert({
288+
tenant_id: tenantId,
289+
plan_id: plan.plan_id,
290+
status: 'active',
291+
payment_method: 'manual_transfer',
292+
interval: 'monthly',
293+
current_period_start: nowIso,
294+
current_period_end: null,
295+
})
296+
}
297+
151298
revalidatePath('/platform/tenants')
152299
return { success: true }
153300
}

app/api/stripe/billing-portal/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,18 @@ import { getStripe } from '@/lib/stripe'
33
import { createClient } from '@/lib/supabase/server'
44
import { createAdminClient } from '@/lib/supabase/admin'
55
import { getCurrentTenantId } from '@/lib/supabase/tenant'
6+
import { resolveRequestLocale } from '@/lib/i18n/request-locale'
67

78
export async function POST(req: NextRequest) {
89
try {
10+
// Optional JSON body carrying the caller's locale; tolerate an empty body.
11+
let bodyLocale: unknown
12+
try {
13+
bodyLocale = (await req.json())?.locale
14+
} catch {
15+
bodyLocale = undefined
16+
}
17+
918
const supabase = await createClient()
1019
const adminClient = await createAdminClient()
1120
const tenantId = await getCurrentTenantId()
@@ -40,7 +49,8 @@ export async function POST(req: NextRequest) {
4049
}
4150

4251
const origin = req.headers.get('origin') || req.headers.get('referer')?.replace(/\/[^/]*$/, '') || ''
43-
const returnUrl = `${origin}/en/dashboard/admin/billing`
52+
const locale = resolveRequestLocale(req, bodyLocale)
53+
const returnUrl = `${origin}/${locale}/dashboard/admin/billing`
4454

4555
const session = await getStripe().billingPortal.sessions.create({
4656
customer: tenant.stripe_customer_id,

0 commit comments

Comments
 (0)