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
28 changes: 28 additions & 0 deletions app/[locale]/platform/payouts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,22 @@ export default async function PlatformPayoutsPage() {
currency: string
grossCollected: number
alreadyPaid: number
clawback: number
netOwed: number
byProvider: Record<string, number>
}
const rows: Row[] = []
const totalClawbackByCurrency: Record<string, number> = {}

for (const tenant of owed) {
let tenantHasOwed = false
for (const balance of tenant.balances) {
totalOwedByCurrency[balance.currency] = (totalOwedByCurrency[balance.currency] ?? 0) + balance.netOwed
totalCollectedByCurrency[balance.currency] = (totalCollectedByCurrency[balance.currency] ?? 0) + balance.grossCollected
totalPaidOutByCurrency[balance.currency] = (totalPaidOutByCurrency[balance.currency] ?? 0) + balance.alreadyPaid
if (balance.clawback > 0) {
totalClawbackByCurrency[balance.currency] = (totalClawbackByCurrency[balance.currency] ?? 0) + balance.clawback
}
if (balance.netOwed > 0) tenantHasOwed = true
rows.push({
tenantId: tenant.tenantId,
Expand All @@ -60,12 +65,14 @@ export default async function PlatformPayoutsPage() {
currency: balance.currency,
grossCollected: balance.grossCollected,
alreadyPaid: balance.alreadyPaid,
clawback: balance.clawback,
netOwed: balance.netOwed,
byProvider: balance.byProvider,
})
}
if (tenantHasOwed) schoolsOwed++
}
const hasClawbacks = Object.values(totalClawbackByCurrency).some((amount) => amount > 0)

const metricCards = [
{
Expand Down Expand Up @@ -127,6 +134,17 @@ export default async function PlatformPayoutsPage() {
))}
</div>

{hasClawbacks && (
<div
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"
data-testid="payouts-clawback-banner"
>
<span className="font-medium">Refund clawback: {formatByCurrency(totalClawbackByCurrency)}.</span>{' '}
Some already-paid-out transactions were later refunded — this amount reduces what&apos;s
currently owed rather than being paid again.
</div>
)}

<Card data-testid="payouts-by-tenant">
<CardHeader>
<CardTitle>By school</CardTitle>
Expand All @@ -145,6 +163,7 @@ export default async function PlatformPayoutsPage() {
<th className="pb-2 text-right font-medium">Collected</th>
<th className="pb-2 text-right font-medium">School %</th>
<th className="pb-2 text-right font-medium">Paid so far</th>
<th className="pb-2 text-right font-medium">Clawback</th>
<th className="pb-2 text-right font-medium">Owed</th>
<th className="pb-2 text-right font-medium"></th>
</tr>
Expand All @@ -166,6 +185,15 @@ export default async function PlatformPayoutsPage() {
<td className="py-2.5 text-right tabular-nums text-muted-foreground">
{money(r.alreadyPaid, r.currency)}
</td>
<td className="py-2.5 text-right tabular-nums">
{r.clawback > 0 ? (
<span className="font-medium text-red-600 dark:text-red-400">
−{money(r.clawback, r.currency)}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="py-2.5 text-right tabular-nums font-medium text-amber-600 dark:text-amber-400">
{money(r.netOwed, r.currency)}
</td>
Expand Down
5 changes: 3 additions & 2 deletions app/actions/platform/payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
admin.from('revenue_splits').select('tenant_id, school_percentage'),
admin
.from('transactions')
.select('tenant_id, payment_provider, amount, currency, school_percentage_snapshot')
.eq('status', 'successful')
.select('tenant_id, payment_provider, amount, currency, school_percentage_snapshot, status')
.in('status', ['successful', 'refunded'])
.in('payment_provider', PLATFORM_SETTLED_PROVIDERS),
admin.from('payouts').select('tenant_id, amount, currency').eq('payout_method', 'manual').eq('status', 'paid'),
])
Expand All @@ -53,6 +53,7 @@ export async function getPayoutsOwed(): Promise<TenantOwed[]> {
amount: t.amount as number,
currency: t.currency || 'usd',
schoolPercentageSnapshot: t.school_percentage_snapshot as number | null,
status: t.status as 'successful' | 'refunded',
})),
(paid || []).map((p) => ({ tenantId: p.tenant_id, amount: p.amount, currency: p.currency || 'usd' }))
)
Expand Down
33 changes: 26 additions & 7 deletions lib/payments/payouts-owed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
* EUR sales owes two separate numbers, never one meaningless summed total.
* `transactions.currency` and `payouts.currency` are trusted as given; this
* module does no currency conversion, only grouping.
*
* Refunds/chargebacks (issue #498): a transaction can flip from `successful`
* to `refunded` after its payout was already recorded. Callers pass refunded
* transactions through alongside successful ones (tagged via `status`); this
* module scales each refund by the same split percentage a sale would've
* used and surfaces it as a distinct `clawback` figure, subtracted from
* `netOwed` on the next cycle rather than silently vanishing from
* `grossCollected`/`grossOwed` with no trace.
*/

/** Used when a tenant has no `revenue_splits` row yet (shouldn't normally happen, but keeps callers from dividing by an absent value). */
Expand All @@ -38,12 +46,14 @@ export interface TenantOwedInput {
export interface PlatformSettledTxn {
tenantId: string
paymentProvider: string
/** Successful transaction amount, major units, in `currency`. */
/** Transaction amount, major units, in `currency` — always positive regardless of `status`. */
amount: number
/** transactions.currency (e.g. 'usd', 'eur'). */
currency: string
/** revenue_splits.school_percentage in effect when this transaction was created (0–100), or null for pre-#496 rows. */
schoolPercentageSnapshot: number | null
/** 'successful' contributes to grossCollected/grossOwed as normal; 'refunded' contributes its scaled amount to `clawback` instead (issue #498). */
status: 'successful' | 'refunded'
}

export interface ManualPayoutRecord {
Expand All @@ -61,7 +71,9 @@ export interface CurrencyBalance {
grossOwed: number
/** Sum of manual payouts already recorded as paid in this currency. */
alreadyPaid: number
/** max(grossOwed - alreadyPaid, 0) — what's currently owed in this currency. */
/** Sum over refunded transactions of amount × (that transaction's snapshotted split, or the tenant's current split) — money already paid out for sales that later got refunded (issue #498). */
clawback: number
/** max(grossOwed - alreadyPaid - clawback, 0) — what's currently owed in this currency. */
netOwed: number
/** Per-provider breakdown of grossCollected in this currency. */
byProvider: Record<string, number>
Expand All @@ -83,7 +95,7 @@ export function computeOwedBalances(
const schoolPercentageByTenant = new Map(tenants.map((t) => [t.tenantId, t.schoolPercentage]))

// tenantId -> currency -> partial balance
const byTenantCurrency = new Map<string, Map<string, { grossCollected: number; grossOwed: number; byProvider: Record<string, number> }>>()
const byTenantCurrency = new Map<string, Map<string, { grossCollected: number; grossOwed: number; clawback: number; byProvider: Record<string, number> }>>()

function bucket(tenantId: string, currency: string) {
let byCurrency = byTenantCurrency.get(tenantId)
Expand All @@ -93,17 +105,22 @@ export function computeOwedBalances(
}
let entry = byCurrency.get(currency)
if (!entry) {
entry = { grossCollected: 0, grossOwed: 0, byProvider: {} }
entry = { grossCollected: 0, grossOwed: 0, clawback: 0, byProvider: {} }
byCurrency.set(currency, entry)
}
return entry
}

for (const txn of txns) {
const entry = bucket(txn.tenantId, txn.currency)
entry.grossCollected += txn.amount
const effectivePercentage = txn.schoolPercentageSnapshot ?? schoolPercentageByTenant.get(txn.tenantId) ?? 0
entry.grossOwed += (txn.amount * effectivePercentage) / 100
const scaledAmount = (txn.amount * effectivePercentage) / 100
if (txn.status === 'refunded') {
entry.clawback += scaledAmount
continue
}
entry.grossCollected += txn.amount
entry.grossOwed += scaledAmount
entry.byProvider[txn.paymentProvider] = (entry.byProvider[txn.paymentProvider] ?? 0) + txn.amount
}

Expand All @@ -129,13 +146,15 @@ export function computeOwedBalances(
const entry = collected.get(currency)
const grossCollected = entry?.grossCollected ?? 0
const grossOwed = entry?.grossOwed ?? 0
const clawback = entry?.clawback ?? 0
const alreadyPaid = paid.get(currency) ?? 0
const netOwed = Math.max(grossOwed - alreadyPaid, 0)
const netOwed = Math.max(grossOwed - alreadyPaid - clawback, 0)
return {
currency,
grossCollected,
grossOwed,
alreadyPaid,
clawback,
netOwed,
byProvider: entry?.byProvider ?? {},
}
Expand Down
Loading
Loading