fix(payments): report payout overpayments instead of clamping them away (#516) - #524
Conversation
…ay (guillermoscript#516) `netOwed = Math.max(grossOwed - alreadyPaid, 0)` hid the size of an overpayment, and `disabled={netOwed <= 0}` on the Mark-as-Paid button removed the only control that writes a payout row. The platform could neither see nor act on "we paid this school more than we owed". Add `overpaid` (max(alreadyPaid - grossOwed, 0)) alongside `netOwed`, which keeps its floor so the metric cards, the mismatch guard and the school-facing view are unaffected. Surface it as a table column and a banner. On the issue's suggested fix: a negative/adjusting payout row is not available — `payouts.amount` carries CHECK (amount > 0) from the original revenue migration, untouched by the manual-payouts migration, and `markPayoutPaid` rejects non-positive amounts. Recording one means a migration plus a sign convention running through `alreadyPaid`, `clawback` and the school-facing history. The other half of the ask — carry the overpayment forward — already works: `alreadyPaid` is an all-time sum, so the next cycle starts in the hole and absorbs the excess with no operator action. What was missing was any way to see that happening, which is what this change fixes. Tests pin the carry-forward across three successive states. §2 of the issue (mismatch guard skipped on retry) does not reproduce: the amount input has cleared `mismatch` on every change since the dialog was written in 3a36763, so an edited amount re-submits with confirmMismatch=false and is re-validated. Documented both sides of that contract in place, since reading `mismatch !== null` in isolation is what made it look bypass-prone.
guillermoscript
left a comment
There was a problem hiding this comment.
Reviewed by fetching the branch and running it locally, plus re-deriving the claims in the description rather than taking them on trust.
Verification
npm run test:unit→ 322 passed / 27 files.npx tsc --noEmit→ clean.git diff master...is exactly the 4 files described — no dependency, migration, config or CI changes.payouts.amount NUMERIC(10,2) NOT NULL CHECK (amount > 0)confirmed at20260216212440_create_revenue_infrastructure.sql:66, and20260724120000_manual_payouts.sqlonly relaxesperiod_start/period_end+ rewritespayouts_period_order. The constraint claim holds.markPayoutPaiddoes reject non-positive amounts (app/actions/platform/payouts.ts).- §2 confirmed as not reproducing.
git log -S 'setMismatch(null)' -- components/platform/mark-payout-paid-dialog.tsxreturns only3a36763, the commit that introduced the dialog in #507. TheonChangehas clearedmismatchsince day one, so an edited amount re-submits withconfirmMismatch: false. The issue text was wrong, not the code — thanks for showing the evidence instead of "fixing" it.
The arithmetic is right, netOwed keeps its floor so no existing consumer moves, overpaid is purely additive, and the tests are the right ones (per-currency isolation and the three-state carry-forward assertion are exactly what would regress silently). Non-blocking overall; the items below are copy/UX and one scope question.
1. The banner copy overstates the recovery — please change
It recovers itself — the excess is deducted from their next payout automatically … Nothing to collect, and nothing to record here.
That is only true while the school keeps selling. A school that churns, goes dormant, or is deleted never generates the sales that absorb the excess, and the money is then genuinely unrecovered — while the page instructs the operator to do nothing about it. That is a worse failure than the silent 0 in #516: a confident wrong instruction beats an ambiguous one for causing lost money.
Related: in practice the most common cause of a below-zero balance is a mis-keyed payout row (extra zero, wrong currency — note a payout recorded in a currency with no transactions produces a pure overpaid row via the payouts-only currency union). Those need operator action too.
Suggested rewording:
Overpaid: {total}. These schools have been paid more than they were owed. The excess is deducted from their next payout automatically, so their balance stays at 0 until new sales catch up. If a school has no further sales, it has to be recovered outside the platform.
2. Overpaid and Of which refunded double-report the same money
1000 successful + a covered refunded 100 at 80%, with 880 already paid → clawback 80 and overpaid 80. Same 80, two adjacent columns, and both banners stack, each ending with "nothing to do" — it reads as 160 of anomaly. Worth either naming the overlap in the overpaid banner ("…of which {x} is the refunded portion shown as clawback") or merging the two banners when both are non-zero.
3. Purple is the brand primary hue
Per CLAUDE.md's design context the default primary is ~293 OKLCH (purple), and this page already codes amber = owed, red = refund, emerald = paid. purple-600 for an anomaly state reads as "branded", not "needs attention" — amber or slate would sit better in the existing scale. Also the state is conveyed by colour plus a title tooltip only; title is not keyboard-reachable and is unreliably announced by screen readers. The clawback cell has the same problem, so this is consistency rather than a regression, but an icon or a short visible label would fix both.
4. Scope question — for me, not for you
#516 §1 asked for "a negative/adjusting payout row or carry the overpayment forward". Your reasoning for not writing the negative row is correct and I accept it: the CHECK blocks it, and a sign convention would silently reinterpret every existing reader of payouts.amount. But leaving disabled={netOwed <= 0} untouched keeps the other half of what the issue complained about — there is still no way to record a real payment to a school sitting at 0 (advance, goodwill, out-of-band settlement).
Cheaper middle path, if I decide I want it: let the dialog open at netOwed === 0 and let the existing guard do the work — with netOwed === 0, Math.abs(amount - 0) > 0 * 0.1 is true for any positive amount, so the operator always gets the confirm step. That is exactly the behaviour you'd want for an off-cycle payment. Not asking for it in this PR — I'll open a follow-up.
5. i18n
The new strings are hardcoded English. That is consistent with the file: none of the 22 pages under app/[locale]/platform/ use next-intl, unlike the school-facing /dashboard/admin/payouts, which does. So not a defect here, and I don't want you to translate one page in an untranslated tree. Flagging it so the decision stays explicit — platform-side i18n is its own sweep.
Nits
- The 6-line comment inside the table-cell JSX restates module-level rationale that
payouts-owed.tsalready carries at length. One line at the render site would read better. hasOverpaymentsrunssome(amount > 0)over a map that only ever receives values> 0— harmless, and it mirrors the clawback code, so fine either way.- Agreed on skipping the render test: introducing jsdom + testing-library for a two-line invariant is disproportionate. If we ever want it covered, Playwright against a seeded overpaid tenant is the cheaper route.
Fix §1 (and ideally §2) and this is good to merge.
|
Addendum — ran this past our React/composition guidelines ( 1. i18n — I do want this translated (revising what I said above)I said "don't translate one page in an untranslated tree". Changing that call: the strings this PR adds are user-facing and I don't want more untranslated surface added, so let's start the namespace here. The pattern already exists on the school-facing side — Please add a On the Spanish: write it literally and I'll adjust the wording — I'm a native speaker and payout terminology here is opinionated. Don't block on getting the es copy perfect. If you'd rather keep this PR to the fix, say so and I'll take the i18n conversion myself in a follow-up — but then the new strings need to land already keyed, not hardcoded. 2. Composition — the Overpaid cell is a clone of the clawback cellThe new Per function ReportedAmountCell({ amount, currency, className, hint, ...rest }) {
return (
<td className="py-2.5 text-right tabular-nums" {...rest}>
{amount > 0
? <span className={`font-medium ${className}`} title={hint}>{money(amount, currency)}</span>
: <span className="text-muted-foreground">—</span>}
</td>
)
}
function ClawbackCell(props) { return <ReportedAmountCell className="text-red-600 dark:text-red-400" hint={t('clawbackHint')} {...props} /> }
function OverpaidCell(props) { return <ReportedAmountCell className="text-purple-600 dark:text-purple-400" hint={t('overpaidHint')} {...props} /> }Pairs well with the color point from my first review, since the tone then lives in one place. Also folds nicely into the i18n work above — the tooltip strings become keys instead of inline literals. 3. Forward-looking, for the follow-up issue — not this PRWhen we enable recording at Rules I checked that do NOT apply — don't go chasing these
|
…overpaid copy (guillermoscript#516) Review follow-up on top of the overpayment fix. Four changes, none of them to the arithmetic: - Copy. The banner promised that an overpayment "recovers itself". That only holds while the school keeps selling: a dormant or departed school never generates the sales that absorb the excess, and neither does a mis-keyed payout row, so the page was telling the operator to do nothing about real money. It now says the excess comes off the next payout automatically and that a school with no further sales has to be settled outside the platform. Same caveat added to the module doc in payouts-owed.ts. - Overlap. A refund drops a sale out of grossOwed while its payout stays inside alreadyPaid, so the same money can show up as both clawback and overpaid, in adjacent columns, under two banners that each said "nothing to do". The overpaid banner now names that overlap when both are present. - i18n. /platform was English-only, unlike the school-facing payouts page. The page and the Mark-as-Paid dialog now read from a new platform.payouts namespace in en.json and es.json, and amounts format in the request locale rather than a hardcoded en-US. A unit test asserts en/es parity for that namespace (missing keys fall back silently, so nothing else would catch it). - Composition. The Overpaid cell was a near-verbatim copy of the clawback cell. Both are now explicit variants over one ReportedAmountCell, and each carries an icon so the two columns aren't distinguished by colour alone (WCAG 1.4.1). Overpaid moves off purple, which is the brand primary hue, onto sky. Verified against local Supabase with a seeded overpaid tenant: both banners, both cells, the ICU plural and the mismatch guard render correctly in English and Spanish. Spanish copy needs a native review pass before merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAaTqrkXy9kdWn5yk9eu5F
|
Rather than send you a list, I pushed the review follow-up onto this branch myself (56814ab) — you enabled maintainer edits, so it lands here instead of in a second PR. Your commit is untouched underneath it; the arithmetic, the tests and the §2 analysis are all yours and unchanged. Pull before you continue. What I changed1. Banner copy. "It recovers itself … Nothing to collect, and nothing to record here" is only true while the school keeps selling. A dormant or departed school never generates the sales that absorb the excess, and neither does a mis-keyed payout row — the page was telling the operator to do nothing about money that is genuinely gone. It now ends with "If a school has no further sales, it has to be recovered outside the platform", and the same caveat is in the 2. Clawback / overpaid overlap. With 1000 successful, a covered refunded 100 at 80%, and 880 paid, 3. i18n. New 4. Composition + colour. VerifiedLocal Supabase, seeded so one tenant is overpaid with a covered refund and another is plainly owed:
English Spanish Mismatch guard, Spanish — entering 999 against a 240 balance still trips the 10% check after an edit, which is the §2 behaviour you documented: Still open
Thanks for the analysis in the description — the §2 evidence in particular saved a round trip, and the carry-forward tests are the ones that would have caught a regression here. |



Closes #516.
Two halves to this issue. §1 is real and fixed here; §2 does not reproduce, and I would rather show the evidence than quietly "fix" working code.
§1 — overpayment is invisible and unrecoverable
netOwed = Math.max(grossOwed - alreadyPaid, 0)(lib/payments/payouts-owed.ts:239) clamps the negative away, so once payouts exceed what is owed the balance reads 0 and the size of the overpayment is nowhere on screen.disabled={netOwed <= 0}(mark-payout-paid-dialog.tsx:69) then removes the only control that writes apayoutsrow.On the suggested fix. The issue asks for "a negative/adjusting payout row or carry the overpayment forward". The first option is not available:
payouts.amountcarriesCHECK (amount > 0)from20260216212440_create_revenue_infrastructure.sql:66, and20260724120000_manual_payouts.sqlrelaxedperiod_start/period_endbut left that check alone;markPayoutPaidalso rejects non-positive amounts atpayouts.ts:88. Writing one means a migration plus a sign convention threading throughalreadyPaid,clawback, and the school-facing payout history — larger than this issue, and it would silently reinterpret every existing reader of that column.The second option already works.
alreadyPaidis an all-time sum, so the next cycle'sgrossOwed - alreadyPaidstarts in the hole and absorbs the excess with no operator action. The recovery was never missing — the visibility was. A balance sitting at 0 while quietly working off a debt is indistinguishable from a balance that is simply settled.So: add
overpaid(max(alreadyPaid - grossOwed, 0)) besidenetOwed, and let the existing arithmetic do the recovery.netOwedkeeps its floor. It feeds the metric cards, the mismatch guard inmarkPayoutPaid, and the school-facing view; letting it go negative would ripple into all three.overpaidis purely additive — at most one of the two is ever non-zero.netOwed <= 0. With nothing owed, enabling it would only let the operator overpay further; the banner points at the carry-forward instead.§2 — mismatch guard skipped on retry: does not reproduce
mismatchis cleared on every amount change (mark-payout-paid-dialog.tsx:88-91):git log -S 'setMismatch(null)' -- components/platform/mark-payout-paid-dialog.tsxreturns exactly one commit — 3a36763, the one that introduced the dialog in #507. It was never absent and nothing removed it. An edited amount therefore re-submits withconfirmMismatch: falseand the 10% check re-runs on the new value.My read is that the report came from the
handleConfirmline alone, whereconfirmMismatch = mismatch !== nulldoes look bypass-prone without theonChangebeside it. No code change; I documented the contract at both ends so the next reader sees why the two lines have to stay in step.I wanted to pin this with a rendering test, but
vitest.config.tsrunsenvironment: 'node'withinclude: ['tests/unit/**/*.test.ts']and the repo has no@testing-library/react— a component test would mean introducing a DOM environment and a testing-library dependency, which felt out of proportion for a regression guard on a two-line invariant. Say the word if you would like that infrastructure added and I will do it in a separate PR.Verification
npm run test:unit— 322 passed (27 files).payouts-owed.test.tsgoes 26 → 30: the overpayment reported whilenetOwedstays floored, zero in the settled and underpaid cases, per-currency isolation (a USD overpayment must not touch the EUR balance), and the carry-forward asserted across three successive states as sales catch up.npm run typecheck— clean.npx eslinton all four changed files — clean..github/workflows/ci.yml) green on my fork against this commit: https://github.com/DPS0340/lms-front/actions/runs/30148455504No migration, no change to
netOwedsemantics, no new dependency.Same caveat as #523: no
.env.localor Supabase CLI here, so no before/after screenshots. The change is one field in a pure function plus a column and a banner that render from it. Happy to add captures if you want them before merging.