Skip to content

Commit 30064cf

Browse files
feat(payments): money-viewing flow for students, admins & super-admin
Wire the standard "view it" surfaces now that multi-provider checkout works: - Student billing hub (/dashboard/student/billing): all-provider purchase receipts from transactions, active subscription (read-only), and offline invoice downloads from payment_requests. - Admin payouts (/dashboard/admin/payouts) and invoices (/dashboard/admin/invoices) registry pages. - Super-admin revenue dashboard (/platform/revenue): platform fees earned, GMV, SaaS MRR, and by-provider / by-school / monthly breakdowns, backed by a new cross-tenant get_platform_revenue() RPC whose fee math mirrors the school revenue view so the numbers reconcile. - Checkout success page (/checkout/success) with plan-vs-product CTAs; repoint the three success redirects in checkout-form to it. - Sidebar nav entries + en/es i18n. Receipts are backed by transactions + payment_requests (the invoices table is schema-only). The RPC migration is local-only, consistent with the rest of this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f1ad7c commit 30064cf

11 files changed

Lines changed: 1633 additions & 3 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client';
2+
3+
import { Suspense } from 'react';
4+
import Link from 'next/link';
5+
import { useSearchParams } from 'next/navigation';
6+
import { useTranslations } from 'next-intl';
7+
import { IconCircleCheckFilled } from '@tabler/icons-react';
8+
import { Button } from '@/components/ui/button';
9+
import { Card, CardContent } from '@/components/ui/card';
10+
11+
function CheckoutSuccessInner() {
12+
const searchParams = useSearchParams();
13+
const t = useTranslations('checkout.success');
14+
15+
const type = searchParams.get('type') ?? 'product';
16+
const isPlan = type === 'plan';
17+
18+
const primaryHref = isPlan ? '/dashboard/student/browse' : '/dashboard/student/courses';
19+
const primaryLabel = isPlan ? t('browseCourses') : t('goToCourses');
20+
21+
return (
22+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-16">
23+
<Card className="w-full max-w-md border-border shadow-sm">
24+
<CardContent className="flex flex-col items-center gap-6 px-8 py-10 text-center">
25+
{/* Icon */}
26+
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
27+
<IconCircleCheckFilled className="h-9 w-9 text-emerald-600 dark:text-emerald-400" />
28+
</div>
29+
30+
{/* Copy */}
31+
<div className="space-y-2">
32+
<h1 className="text-xl font-semibold tracking-tight">{t('title')}</h1>
33+
<p className="text-sm leading-relaxed text-muted-foreground">{t('subtitle')}</p>
34+
</div>
35+
36+
{/* CTAs */}
37+
<div className="flex w-full flex-col gap-3 pt-2">
38+
<Link href={primaryHref} className="w-full">
39+
<Button className="w-full">{primaryLabel}</Button>
40+
</Link>
41+
<Link href="/dashboard/student/billing" className="w-full">
42+
<Button variant="outline" className="w-full">{t('viewBilling')}</Button>
43+
</Link>
44+
</div>
45+
</CardContent>
46+
</Card>
47+
</div>
48+
);
49+
}
50+
51+
export default function CheckoutSuccessPage() {
52+
return (
53+
<Suspense fallback={null}>
54+
<CheckoutSuccessInner />
55+
</Suspense>
56+
);
57+
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { createAdminClient } from '@/lib/supabase/admin'
2+
import { redirect } from 'next/navigation'
3+
import { getTranslations } from 'next-intl/server'
4+
import { format } from 'date-fns'
5+
import { es, enUS } from 'date-fns/locale'
6+
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
7+
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
8+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
9+
import { Badge } from '@/components/ui/badge'
10+
import Link from 'next/link'
11+
import {
12+
IconFileInvoice,
13+
IconAlertCircle,
14+
IconInfoCircle,
15+
IconExternalLink,
16+
} from '@tabler/icons-react'
17+
18+
export default async function AdminInvoicesPage({
19+
params,
20+
}: {
21+
params: Promise<{ locale: string }>
22+
}) {
23+
const { locale } = await params
24+
const t = await getTranslations('dashboard.admin.invoices')
25+
const tBreadcrumbs = await getTranslations('dashboard.admin.breadcrumbs')
26+
const dateLocale = locale === 'es' ? es : enUS
27+
28+
const userId = await getCurrentUserId()
29+
if (!userId) {
30+
redirect('/auth/login')
31+
}
32+
33+
const tenantId = await getCurrentTenantId()
34+
const supabase = createAdminClient()
35+
36+
// Verify admin role for this tenant
37+
const { data: membership } = await supabase
38+
.from('tenant_users')
39+
.select('role')
40+
.eq('user_id', userId)
41+
.eq('tenant_id', tenantId)
42+
.eq('status', 'active')
43+
.single()
44+
45+
if (!membership || membership.role !== 'admin') {
46+
return (
47+
<div className="flex min-h-screen items-center justify-center bg-background">
48+
<div className="text-center">
49+
<IconAlertCircle className="mx-auto mb-3 h-10 w-10 text-destructive" strokeWidth={1.5} />
50+
<h2 className="text-lg font-semibold">{t('accessDenied')}</h2>
51+
<p className="mt-1 text-sm text-muted-foreground">{t('accessDeniedDesc')}</p>
52+
</div>
53+
</div>
54+
)
55+
}
56+
57+
// Query payment_requests that have an invoice_number (the only real invoice documents)
58+
const { data: requests } = await supabase
59+
.from('payment_requests')
60+
.select('request_id, invoice_number, status, payment_amount, payment_currency, created_at, payment_confirmed_at, user_id, product_id')
61+
.eq('tenant_id', tenantId)
62+
.not('invoice_number', 'is', null)
63+
.order('created_at', { ascending: false })
64+
65+
const rows = requests || []
66+
67+
// Resolve product names in one batch
68+
const productIds = [...new Set(rows.map((r) => r.product_id).filter(Boolean))]
69+
const { data: products } = productIds.length > 0
70+
? await supabase.from('products').select('product_id, name').in('product_id', productIds)
71+
: { data: [] }
72+
73+
const productsMap = new Map((products || []).map((p) => [p.product_id, p.name]))
74+
75+
const fmt = (amount: number | null, currency: string | null) =>
76+
new Intl.NumberFormat(locale, {
77+
style: 'currency',
78+
currency: (currency || 'USD').toUpperCase(),
79+
}).format(amount || 0)
80+
81+
const statusBadge = (status: string) => {
82+
switch (status) {
83+
case 'approved':
84+
case 'completed':
85+
return (
86+
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 text-[10px]">
87+
{t('status.paid')}
88+
</Badge>
89+
)
90+
case 'pending':
91+
return (
92+
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400 text-[10px]">
93+
{t('status.pending')}
94+
</Badge>
95+
)
96+
case 'rejected':
97+
case 'cancelled':
98+
return (
99+
<Badge variant="destructive" className="text-[10px]">
100+
{t('status.cancelled')}
101+
</Badge>
102+
)
103+
default:
104+
return (
105+
<Badge variant="secondary" className="text-[10px]">
106+
{status}
107+
</Badge>
108+
)
109+
}
110+
}
111+
112+
return (
113+
<div className="min-h-screen bg-background" data-testid="invoices-page">
114+
{/* Header */}
115+
<header className="border-b bg-card">
116+
<div className="mx-auto max-w-7xl px-4 py-5 sm:px-6 lg:px-8">
117+
<div className="mb-4">
118+
<AdminBreadcrumb
119+
items={[
120+
{ label: tBreadcrumbs('admin'), href: '/dashboard/admin' },
121+
{ label: tBreadcrumbs('monetization'), href: '/dashboard/admin/monetization' },
122+
{ label: t('title') },
123+
]}
124+
/>
125+
</div>
126+
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
127+
<p className="mt-0.5 text-sm text-muted-foreground">{t('description')}</p>
128+
</div>
129+
</header>
130+
131+
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
132+
{/* Contextual note */}
133+
<div className="mb-6 flex items-start gap-3 rounded-lg border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
134+
<IconInfoCircle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={1.75} />
135+
<p>
136+
{t('note.text')}{' '}
137+
<Link
138+
href="/dashboard/admin/transactions"
139+
className="font-medium text-foreground underline-offset-4 hover:underline"
140+
>
141+
{t('note.transactionsLink')}
142+
</Link>
143+
.
144+
</p>
145+
</div>
146+
147+
{/* Invoices table */}
148+
<Card>
149+
<CardHeader>
150+
<CardTitle>{t('table.title')}</CardTitle>
151+
</CardHeader>
152+
<CardContent>
153+
<div className="overflow-x-auto">
154+
<table className="w-full text-sm">
155+
<thead className="border-b">
156+
<tr>
157+
<th className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
158+
{t('table.headers.invoiceNumber')}
159+
</th>
160+
<th className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
161+
{t('table.headers.item')}
162+
</th>
163+
<th className="px-4 py-3 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
164+
{t('table.headers.amount')}
165+
</th>
166+
<th className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
167+
{t('table.headers.status')}
168+
</th>
169+
<th className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
170+
{t('table.headers.date')}
171+
</th>
172+
<th className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
173+
{t('table.headers.actions')}
174+
</th>
175+
</tr>
176+
</thead>
177+
<tbody className="divide-y">
178+
{rows.length > 0 ? (
179+
rows.map((req) => (
180+
<tr
181+
key={req.request_id}
182+
className="border-b last:border-0 transition-colors hover:bg-muted/40"
183+
>
184+
<td className="px-4 py-3">
185+
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono">
186+
{req.invoice_number}
187+
</code>
188+
</td>
189+
<td className="px-4 py-3">
190+
<p className="font-medium">
191+
{req.product_id
192+
? (productsMap.get(req.product_id) ?? t('table.unknownProduct'))
193+
: t('table.unknownProduct')}
194+
</p>
195+
</td>
196+
<td className="px-4 py-3 text-right font-semibold tabular-nums">
197+
{fmt(req.payment_amount, req.payment_currency)}
198+
</td>
199+
<td className="px-4 py-3">
200+
{statusBadge(req.status || '')}
201+
{req.payment_confirmed_at && (
202+
<p className="mt-0.5 text-[10px] text-muted-foreground">
203+
{format(new Date(req.payment_confirmed_at), 'MMM d, yyyy', { locale: dateLocale })}
204+
</p>
205+
)}
206+
</td>
207+
<td className="px-4 py-3 text-xs tabular-nums text-muted-foreground">
208+
{req.created_at
209+
? format(new Date(req.created_at), 'MMM d, yyyy', { locale: dateLocale })
210+
: '—'}
211+
</td>
212+
<td className="px-4 py-3">
213+
<Link
214+
href={`/api/invoices/${req.invoice_number}`}
215+
target="_blank"
216+
rel="noopener noreferrer"
217+
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
218+
>
219+
{t('table.viewInvoice')}
220+
<IconExternalLink className="h-3 w-3" strokeWidth={2} />
221+
</Link>
222+
</td>
223+
</tr>
224+
))
225+
) : (
226+
<tr>
227+
<td colSpan={6} className="py-12 text-center">
228+
<div className="flex flex-col items-center gap-3">
229+
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
230+
<IconFileInvoice className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
231+
</div>
232+
<div>
233+
<p className="font-medium text-sm">{t('empty.title')}</p>
234+
<p className="mt-1 text-xs text-muted-foreground max-w-sm mx-auto">
235+
{t('empty.description')}
236+
</p>
237+
</div>
238+
</div>
239+
</td>
240+
</tr>
241+
)}
242+
</tbody>
243+
</table>
244+
</div>
245+
</CardContent>
246+
</Card>
247+
</main>
248+
</div>
249+
)
250+
}

0 commit comments

Comments
 (0)