Part of EPIC #540. §3.1–3.4.
Four money-accuracy defects that survived epic #493's payout hardening. #511/#515/#516's clawback arithmetic itself was re-audited and is correct — these are different problems.
1. A partial refund erases the school's entire share 🔴
lib/payments/webhook-dispatch.ts:252-257 — refund.succeeded unconditionally does .update({ status: 'refunded' }). No refunded amount is read or stored, and the mapped event carries none: lib/payments/paypal-provider.ts:601-611 and lib/payments/binance-provider.ts:319-326 drop the amount into raw and never surface it.
All three platform-settled providers support partial refunds programmatically — paypal-provider.ts:750-751 ("Partial refunds need the currency…"), binance-provider.ts:348 (refundAmount), and supportsRefunds: true for paypal/lemonsqueezy/binance in types.ts. But lib/payments/payouts-owed.ts:121 types status as a binary 'successful' | 'refunded', and :256-261 drops the whole sale.
So a $10 goodwill refund on a $100 PayPal sale removes $100 from grossCollected/grossOwed — under-paying the school $72 at an 80% split. It also fires entitlements → revoked (:259-266), so a partially-refunded student loses course access outright. Silent in both directions.
The codebase already knows partials exist: app/api/stripe/webhook/route.ts:227-228 computes isFullRefund = charge.amount_refunded >= charge.amount and keeps the row successful on a partial. Stripe Connect is the one provider excluded from the payout math, so that guard protects the one case that did not need it.
Fix: carry amount/currency through NormalizedEvent; persist a refunded amount; give PlatformSettledTxn a refundedAmount so grossOwed subtracts the refunded slice rather than the row; revoke entitlements only on a full refund. Mind the per-provider units (PayPal major units vs Binance).
2. Two revenue screens query a column that does not exist 🔴
app/[locale]/dashboard/teacher/revenue/page.tsx:30 — .select('amount, status, payment_provider, created_at') … .order('created_at', …), and :45 filters on t.created_at.
app/[locale]/dashboard/admin/analytics/page.tsx:69 — .select('amount, status, created_at') … .gte('created_at', …) .order('created_at', …).
transactions has transaction_date, not created_at — confirmed absent from the live table and from lib/database.types.ts. PostgREST rejects the whole request; both call sites destructure { data } with no error check, so transactions is null.
Every school sees $0.00 total revenue, $0.00 school share, $0.00 last-30-days and a count of 0 on every render, permanently, with nothing logged. This is the school-side counterpart of the payout numbers epic #493 spent seven sub-issues getting right.
Fix: created_at → transaction_date at all four references; surface error instead of falling through to zeros.
3. The platform page and the school page disagree by the entire platform fee 🔴
app/actions/admin/revenue.ts:68-72 classifies every transaction as t.stripe_payment_intent_id ? 'stripe' : 'manual', then charges the platform fee only if that label appears in applies_to_providers — which defaults to ARRAY['stripe'] (20260216212440_create_revenue_infrastructure.sql:20).
A PayPal / Lemon Squeezy / Binance sale is therefore labelled manual, bears 0% fee, and the school's netRevenue shows 100% of it. Meanwhile app/actions/platform/payouts.ts:20-22 + payouts-owed.ts:255 apply the 80% school_percentage to those same rows, and neither the payout math nor the #512 backstop trigger reads applies_to_providers at all.
Two shipped, authoritative-looking screens differing by the whole platform fee, on every platform-settled sale.
Fix — pick a side: either retire applies_to_providers (it names stripe/manual, not the eight real provider slugs — it reads like a stale abstraction from the Connect-only era) and have getRevenueOverview apply platform_percentage to the same settlesToPlatformAccount set getPayoutsOwed uses; or populate it with real slugs and have both readers honour it. State which you chose and why in the PR.
4. Rounding residue and payout idempotency 🟡
Residue. payouts-owed.ts:255 — scaledAmount = (txn.amount * effectivePercentage) / 100, in float, summed unrounded. One $49.99 sale at 80% → 39.992. The dialog pre-fills netOwed.toFixed(2) = 39.99 (mark-payout-paid-dialog.tsx:31), payouts.amount is NUMERIC(10,2), so alreadyPaid is exactly 39.99 and netOwed settles at 0.002 — never 0. app/[locale]/platform/payouts/page.tsx:298 renders that as $0.00 while mark-payout-paid-dialog.tsx:82 gates the button on disabled={netOwed <= 0}, so it stays enabled. Every school whose owed total isn't an exact cent parks a permanent phantom balance on a permanently actionable row, and residue accumulates across cycles (any .99 price at 80%, any 3%/7% split). Operators learn to ignore a row reading $0.00 that still demands action.
Idempotency. app/actions/platform/payouts.ts:126-135 inserts into payouts with no period_start/period_end and no idempotency key. The table's only uniqueness is UNIQUE (tenant_id, period_start, period_end) (20260216212440:90), and 20260724120000_manual_payouts.sql:15-16 made both columns nullable for exactly this path — Postgres treats NULLs as distinct, so the constraint never fires for a manual row. mark-payout-paid-dialog.tsx:147 disables the button only while loading, which does not survive a reload, a second tab, a second super admin, or a server-action retry. Two inserts of the same wire double alreadyPaid; netOwed floors to 0 and the excess surfaces only as overpaid, which the module itself notes is recovered by carry-forward "only for a school that keeps selling". For a dormant school the platform has paid twice with no reversal path — CHECK (amount > 0) forbids a correcting negative row.
Fix: round each scaledAmount to the currency's minor unit before accumulating (or compute in integer minor units throughout — decide the rounding mode so the platform never systematically rounds in its own favour), and use a < 0.005 epsilon for both the floor and the button gate. Add a client-generated idempotency_key uuid with a partial unique index WHERE payout_method='manual', generated once per dialog open; on conflict return {status:'ok'} rather than erroring.
Out of scope — audited and correct, do not change
computeOwedBalances' clawback arithmetic (clawback is deliberately absent from netOwed; refunded rows leave grossOwed, their payouts stay in alreadyPaid, so the overpayment nets out exactly once — #511 got this right). wasPlausiblyPaidOut (same-tenant and same-currency, parses dates rather than string-comparing, fails closed). Double-clawback of the same transaction (impossible — webhook-dispatch.ts:249 breaks unless status === 'successful' and the update carries .eq('status','successful')). The #516 mismatch-retry interaction (mismatch is cleared on every keystroke, so confirmMismatch: true can only accompany the exact amount warned against). Currency-case handling (transactions.currency is a lowercase enum; no zero-decimal currency exists in it). The #512 backstop trigger (live on cloud, correct).
Acceptance criteria
Effort: M · Risk: MED for the refund path (touches the shared dispatcher and the entitlement-revoke branch; a wrong units assumption moves real money) and for §3 (whichever side changes, one set of schools sees their revenue figure move). LOW for §2 and §4.
Part of EPIC #540. §3.1–3.4.
Four money-accuracy defects that survived epic #493's payout hardening. #511/#515/#516's clawback arithmetic itself was re-audited and is correct — these are different problems.
1. A partial refund erases the school's entire share 🔴
lib/payments/webhook-dispatch.ts:252-257—refund.succeededunconditionally does.update({ status: 'refunded' }). No refunded amount is read or stored, and the mapped event carries none:lib/payments/paypal-provider.ts:601-611andlib/payments/binance-provider.ts:319-326drop the amount intorawand never surface it.All three platform-settled providers support partial refunds programmatically —
paypal-provider.ts:750-751("Partial refunds need the currency…"),binance-provider.ts:348(refundAmount), andsupportsRefunds: truefor paypal/lemonsqueezy/binance intypes.ts. Butlib/payments/payouts-owed.ts:121types status as a binary'successful' | 'refunded', and:256-261drops the whole sale.So a $10 goodwill refund on a $100 PayPal sale removes $100 from
grossCollected/grossOwed— under-paying the school $72 at an 80% split. It also firesentitlements → revoked(:259-266), so a partially-refunded student loses course access outright. Silent in both directions.The codebase already knows partials exist:
app/api/stripe/webhook/route.ts:227-228computesisFullRefund = charge.amount_refunded >= charge.amountand keeps the rowsuccessfulon a partial. Stripe Connect is the one provider excluded from the payout math, so that guard protects the one case that did not need it.Fix: carry
amount/currencythroughNormalizedEvent; persist a refunded amount; givePlatformSettledTxnarefundedAmountsogrossOwedsubtracts the refunded slice rather than the row; revoke entitlements only on a full refund. Mind the per-provider units (PayPal major units vs Binance).2. Two revenue screens query a column that does not exist 🔴
app/[locale]/dashboard/teacher/revenue/page.tsx:30—.select('amount, status, payment_provider, created_at') … .order('created_at', …), and:45filters ont.created_at.app/[locale]/dashboard/admin/analytics/page.tsx:69—.select('amount, status, created_at') … .gte('created_at', …) .order('created_at', …).transactionshastransaction_date, notcreated_at— confirmed absent from the live table and fromlib/database.types.ts. PostgREST rejects the whole request; both call sites destructure{ data }with no error check, sotransactionsisnull.Every school sees $0.00 total revenue, $0.00 school share, $0.00 last-30-days and a count of 0 on every render, permanently, with nothing logged. This is the school-side counterpart of the payout numbers epic #493 spent seven sub-issues getting right.
Fix:
created_at→transaction_dateat all four references; surfaceerrorinstead of falling through to zeros.3. The platform page and the school page disagree by the entire platform fee 🔴
app/actions/admin/revenue.ts:68-72classifies every transaction ast.stripe_payment_intent_id ? 'stripe' : 'manual', then charges the platform fee only if that label appears inapplies_to_providers— which defaults toARRAY['stripe'](20260216212440_create_revenue_infrastructure.sql:20).A PayPal / Lemon Squeezy / Binance sale is therefore labelled
manual, bears 0% fee, and the school'snetRevenueshows 100% of it. Meanwhileapp/actions/platform/payouts.ts:20-22+payouts-owed.ts:255apply the 80%school_percentageto those same rows, and neither the payout math nor the #512 backstop trigger readsapplies_to_providersat all.Two shipped, authoritative-looking screens differing by the whole platform fee, on every platform-settled sale.
Fix — pick a side: either retire
applies_to_providers(it namesstripe/manual, not the eight real provider slugs — it reads like a stale abstraction from the Connect-only era) and havegetRevenueOverviewapplyplatform_percentageto the samesettlesToPlatformAccountsetgetPayoutsOweduses; or populate it with real slugs and have both readers honour it. State which you chose and why in the PR.4. Rounding residue and payout idempotency 🟡
Residue.
payouts-owed.ts:255—scaledAmount = (txn.amount * effectivePercentage) / 100, in float, summed unrounded. One $49.99 sale at 80% →39.992. The dialog pre-fillsnetOwed.toFixed(2)=39.99(mark-payout-paid-dialog.tsx:31),payouts.amountisNUMERIC(10,2), soalreadyPaidis exactly39.99andnetOwedsettles at0.002— never 0.app/[locale]/platform/payouts/page.tsx:298renders that as$0.00whilemark-payout-paid-dialog.tsx:82gates the button ondisabled={netOwed <= 0}, so it stays enabled. Every school whose owed total isn't an exact cent parks a permanent phantom balance on a permanently actionable row, and residue accumulates across cycles (any.99price at 80%, any 3%/7% split). Operators learn to ignore a row reading$0.00that still demands action.Idempotency.
app/actions/platform/payouts.ts:126-135inserts intopayoutswith noperiod_start/period_endand no idempotency key. The table's only uniqueness isUNIQUE (tenant_id, period_start, period_end)(20260216212440:90), and20260724120000_manual_payouts.sql:15-16made both columns nullable for exactly this path — Postgres treats NULLs as distinct, so the constraint never fires for a manual row.mark-payout-paid-dialog.tsx:147disables the button only whileloading, which does not survive a reload, a second tab, a second super admin, or a server-action retry. Two inserts of the same wire doublealreadyPaid;netOwedfloors to 0 and the excess surfaces only asoverpaid, which the module itself notes is recovered by carry-forward "only for a school that keeps selling". For a dormant school the platform has paid twice with no reversal path —CHECK (amount > 0)forbids a correcting negative row.Fix: round each
scaledAmountto the currency's minor unit before accumulating (or compute in integer minor units throughout — decide the rounding mode so the platform never systematically rounds in its own favour), and use a< 0.005epsilon for both the floor and the button gate. Add a client-generatedidempotency_key uuidwith a partial unique indexWHERE payout_method='manual', generated once per dialog open; on conflict return{status:'ok'}rather than erroring.Out of scope — audited and correct, do not change
computeOwedBalances' clawback arithmetic (clawbackis deliberately absent fromnetOwed; refunded rows leavegrossOwed, their payouts stay inalreadyPaid, so the overpayment nets out exactly once — #511 got this right).wasPlausiblyPaidOut(same-tenant and same-currency, parses dates rather than string-comparing, fails closed). Double-clawback of the same transaction (impossible —webhook-dispatch.ts:249breaks unlessstatus === 'successful'and the update carries.eq('status','successful')). The #516 mismatch-retry interaction (mismatchis cleared on every keystroke, soconfirmMismatch: truecan only accompany the exact amount warned against). Currency-case handling (transactions.currencyis a lowercase enum; no zero-decimal currency exists in it). The #512 backstop trigger (live on cloud, correct).Acceptance criteria
grossOwedby the refunded slice only, and does not revoke the entitlement. Unit-tested per provider, with the units asserted.netOwedreconcile to the same split. Assert it in a test with a PayPal sale.netOwedfor a $49.99 sale at 80%, fully paid, is exactly0, and the Mark-as-paid button is disabled.payoutsrow.tests/unit/payouts-owed.test.tsgains fractional cases (49.99 @ 80%, 33.33 @ 70%) — all 31 existing cases use round amounts and round percentages, which is why the residue was invisible.markPayoutPaid's mismatch guard has direct coverage at thenetOwed === 0boundary (today it has none —grep -rl "markPayoutPaid" tests/returns only comments).npm run test:unitgreen (370/370 baseline at146a8cfa).Effort: M · Risk: MED for the refund path (touches the shared dispatcher and the entitlement-revoke branch; a wrong units assumption moves real money) and for §3 (whichever side changes, one set of schools sees their revenue figure move). LOW for §2 and §4.