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
15 changes: 13 additions & 2 deletions app/[locale]/dashboard/admin/payouts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export default async function AdminPayoutsPage({
// Fetch payouts for this tenant
const { data: payouts } = await supabase
.from('payouts')
.select('payout_id, amount, currency, status, period_start, period_end, stripe_payout_id, paid_at, failure_reason, created_at')
.select(
'payout_id, amount, currency, status, period_start, period_end, stripe_payout_id, paid_at, failure_reason, created_at, note, payout_method'
)
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })

Expand Down Expand Up @@ -202,6 +204,9 @@ export default async function AdminPayoutsPage({
<TableHead className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('table.headers.status')}
</TableHead>
<TableHead className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('table.headers.method')}
</TableHead>
<TableHead className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('table.headers.paidAt')}
</TableHead>
Expand Down Expand Up @@ -235,6 +240,12 @@ export default async function AdminPayoutsPage({
<p className="mt-0.5 text-[10px] text-destructive">{payout.failure_reason}</p>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{payout.payout_method === 'manual' ? t('method.manual') : t('method.stripeConnect')}
{payout.note && (
<p className="mt-0.5 max-w-[16rem] text-[10px] text-muted-foreground/70">{payout.note}</p>
)}
</TableCell>
<TableCell className="text-xs tabular-nums text-muted-foreground">
{payout.paid_at
? format(new Date(payout.paid_at), 'MMM d, yyyy HH:mm', { locale: dateLocale })
Expand All @@ -255,7 +266,7 @@ export default async function AdminPayoutsPage({
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<IconBuildingBank className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
Expand Down
24 changes: 23 additions & 1 deletion app/actions/platform/payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,30 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
)
}

export async function markPayoutPaid(tenantId: string, amount: number, currency: string, note?: string) {
// A payout more than 10% off the currently owed balance is flagged for confirmation
// (catches typos like an extra zero) without ever hard-blocking a legitimate rounded
// or ahead-of-schedule payment.
const MISMATCH_THRESHOLD_PCT = 0.1

export async function markPayoutPaid(
tenantId: string,
amount: number,
currency: string,
note?: string,
confirmMismatch = false,
): Promise<{ status: 'ok' } | { status: 'warning'; netOwed: number }> {
const userId = await verifySuperAdmin()
if (!(amount > 0)) throw new Error('Amount must be positive')

if (!confirmMismatch) {
const owed = await getPayoutsOwed()
const netOwed =
owed.find((o) => o.tenantId === tenantId)?.balances.find((b) => b.currency === currency)?.netOwed ?? 0
if (Math.abs(amount - netOwed) > netOwed * MISMATCH_THRESHOLD_PCT) {
return { status: 'warning', netOwed }
}
}

const admin = createAdminClient()
const { error } = await admin.from('payouts').insert({
tenant_id: tenantId,
Expand All @@ -77,4 +97,6 @@ export async function markPayoutPaid(tenantId: string, amount: number, currency:
if (error) throw new Error(error.message)

revalidatePath('/platform/payouts')
revalidatePath('/dashboard/admin/payouts')
return { status: 'ok' }
}
41 changes: 36 additions & 5 deletions components/platform/mark-payout-paid-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed, currency }
const [amount, setAmount] = useState(netOwed.toFixed(2))
const [note, setNote] = useState('')
const [loading, setLoading] = useState(false)
const [mismatch, setMismatch] = useState<{ netOwed: number } | null>(null)

function resetForm() {
setNote('')
setMismatch(null)
}

async function handleConfirm() {
const parsed = Number(amount)
Expand All @@ -38,10 +44,14 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed, currency }
}
setLoading(true)
try {
await markPayoutPaid(tenantId, parsed, currency, note.trim() || undefined)
const result = await markPayoutPaid(tenantId, parsed, currency, note.trim() || undefined, mismatch !== null)
if (result.status === 'warning') {
setMismatch({ netOwed: result.netOwed })
return
}
toast.success(`Recorded ${parsed.toFixed(2)} ${currency.toUpperCase()} paid to ${tenantName}`)
setOpen(false)
setNote('')
resetForm()
router.refresh()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to record payout')
Expand All @@ -62,7 +72,13 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed, currency }
Mark as Paid
</Button>

<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) resetForm()
}}
>
<DialogContent data-testid="mark-paid-dialog">
<DialogHeader>
<DialogTitle>Record payout to {tenantName}</DialogTitle>
Expand All @@ -76,7 +92,10 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed, currency }
step="0.01"
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onChange={(e) => {
setAmount(e.target.value)
setMismatch(null)
}}
data-testid="mark-paid-amount-input"
/>
</div>
Expand All @@ -91,11 +110,23 @@ export function MarkPayoutPaidDialog({ tenantId, tenantName, netOwed, currency }
data-testid="mark-paid-note-input"
/>
</div>
{mismatch && (
<div
className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
data-testid="mark-paid-mismatch-warning"
>
<p className="font-medium">Amount differs from suggested payout</p>
<p className="mt-1 text-xs">
Suggested (net owed): {mismatch.netOwed.toFixed(2)} {currency.toUpperCase()} — entered:{' '}
{Number(amount).toFixed(2)} {currency.toUpperCase()}. Confirm you want to record this amount.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleConfirm} disabled={loading} data-testid="confirm-mark-paid-btn">
{loading ? 'Recording…' : 'Record Payout'}
{loading ? 'Recording…' : mismatch ? 'Confirm anyway' : 'Record Payout'}
</Button>
</DialogFooter>
</DialogContent>
Expand Down
9 changes: 7 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1604,7 +1604,7 @@
"welcome": "Manage your LMS platform",
"payouts": {
"title": "Payouts",
"description": "Stripe Connect disbursements to your school account.",
"description": "Disbursements to your school account, whether via Stripe Connect or recorded manually by the platform.",
"accessDenied": "Access denied",
"accessDeniedDesc": "You must be a school admin to view this page.",
"stats": {
Expand All @@ -1619,19 +1619,24 @@
"pending": "Pending",
"failed": "Failed"
},
"method": {
"manual": "Manual",
"stripeConnect": "Stripe Connect"
},
"table": {
"title": "Payout history",
"headers": {
"period": "Period",
"amount": "Amount",
"status": "Status",
"method": "Method",
"paidAt": "Paid at",
"stripeRef": "Stripe ref"
}
},
"empty": {
"title": "No payouts yet",
"description": "Payouts appear here once Stripe Connect disburses funds to your connected bank account. Make sure your Stripe account is fully verified."
"description": "Payouts appear here once Stripe Connect disburses funds to your connected bank account, or once the platform records a manual payout."
}
},
"invoices": {
Expand Down
9 changes: 7 additions & 2 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1604,7 +1604,7 @@
"welcome": "Gestiona tu plataforma LMS",
"payouts": {
"title": "Pagos recibidos",
"description": "Desembolsos de Stripe Connect a la cuenta de tu escuela.",
"description": "Desembolsos a la cuenta de tu escuela, ya sea vía Stripe Connect o registrados manualmente por la plataforma.",
"accessDenied": "Acceso denegado",
"accessDeniedDesc": "Debes ser administrador de la escuela para ver esta página.",
"stats": {
Expand All @@ -1619,19 +1619,24 @@
"pending": "Pendiente",
"failed": "Fallido"
},
"method": {
"manual": "Manual",
"stripeConnect": "Stripe Connect"
},
"table": {
"title": "Historial de pagos",
"headers": {
"period": "Período",
"amount": "Monto",
"status": "Estado",
"method": "Método",
"paidAt": "Pagado el",
"stripeRef": "Ref. Stripe"
}
},
"empty": {
"title": "Sin pagos aún",
"description": "Los pagos aparecerán aquí una vez que Stripe Connect transfiera los fondos a tu cuenta bancaria. Asegúrate de que tu cuenta Stripe esté completamente verificada."
"description": "Los pagos aparecerán aquí una vez que Stripe Connect transfiera los fondos a tu cuenta bancaria, o cuando la plataforma registre un pago manual."
}
},
"invoices": {
Expand Down
Loading