fix(payouts): stop the school payout history summing across currencies (#531) - #534
Merged
Merged
Conversation
#531) The school-facing "Total paid out" card added by #499 reduced every paid payout into one number and rendered it with a hardcoded 'USD', so a school selling in more than one currency read an invented figure — the arithmetic sum of its dollar and euro payouts, denominated in dollars — sitting above per-row amounts that were each correct in their own currency. The super-admin page had already been fixed for exactly this in #497, but its `money()` and `formatByCurrency()` helpers were private to that page module. The invariant lived in one file's local scope, so the next payout screen had nothing to inherit and re-derived the bug. Move both helpers into lib/payments/format-money.ts alongside a `sumByCurrency()` that groups rows by their own currency (normalizing the code, so 'usd' and 'USD' share a bucket) and point both pages at it. The school card now renders one figure per currency and no longer names a currency the data didn't supply; the platform page keeps its existing output, now from shared code. Covered by tests/unit/format-money.test.ts, including the two-currency case from the issue. Display only — no query, schema, or RLS change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kgmz9Co2tW8KNfCFKLGEfN
Owner
Author
guillermoscript
marked this pull request as ready for review
July 25, 2026 18:18
Owner
Author
|
Merged to Shipped exactly as described in the body; nothing changed between opening and merge.
Deferred, unchanged from the body: the "platform owes us $X" figure for schools (§2.4 of #493, raised at the end of #531) is not in this PR. It needs a tenant-side equivalent of |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.






What
The school-facing "Total paid out" card on
/dashboard/admin/payoutsno longer adds payout amounts across currencies, and no longer labels the result USD. It now renders one figure per currency —$1,240.50 · €860.00— the same way the super-admin page has since #497.The grouping and formatting behind both pages moved into a new shared module,
lib/payments/format-money.ts, with unit coverage.Closes #531
Why
app/[locale]/dashboard/admin/payouts/page.tsxreduced everypaidpayout into one scalar and rendered it with a hardcoded currency:The per-row amounts below it were correct — each formatted in its own
payouts.currency— so a school with USD and EUR payouts read honest rows underneath a summary that was their arithmetic sum, denominated in dollars. That figure corresponds to nothing, and it is the one a school owner would reconcile against. It made the #493 acceptance criterion "Payout totals never silently mix currencies" false for the tenant-facing half of the feature.#497 fixed exactly this on the super-admin page, but its
money()andformatByCurrency()helpers were private to that page module. The invariant lived in one file's local scope, so the payout history added later by #499 had nothing to inherit and re-derived the bug. Extracting the helpers is the part of this PR that stops it happening a third time:sumByCurrency(rows)— groups{ amount, currency }rows into per-currency totals. It only ever adds an amount to the bucket for its own currency, so no arrangement of inputs produces a cross-currency sum. Currency codes are normalized ('usd'and'USD'share a bucket) and a null currency falls back to USD, matching the column default.formatMoney(amount, currency, locale)— the currency code always comes from the data, never the call site.formatByCurrency(totals, locale)— one figure per currency, joined with·; zero-valued currencies dropped so a single-currency school still reads as one plain number; an empty set renders one zero rather than an empty card.The platform page now imports these instead of defining its own. Its behaviour is unchanged by design — same separator, same zero-filtering, same empty fallback — and I verified its rendering against a before screenshot (see comment).
No query, schema, RLS, or i18n change. The page already selected
currency; this is presentation only.pendingCountis a count and was never affected.How to QA
On a fresh
npm run db:reset, asowner@e2etest.com/password123ondefault.lvh.me:3000. The seed has no payouts, so QA needs a couple of rows:(
payoutshas a unique constraint on tenant + period, so keep the three periods distinct.)/en/dashboard/admin/payouts. "Total paid out" reads$1,240.50 · €860.00— two figures, not$2,100.50. Onmasterthis same data shows$2,100.50.$1,240.50,€860.00and$310.75each keep their own currency symbol, unchanged from before.1— it counts rows and is not currency-grouped./es/dashboard/admin/payouts. The card reads1240,50 US$ · 860,00 €— locale formatting applies, currency codes still come from the data.gbprow) if you want to push it further./en/platform/payouts. "Paid out (all time)" reads$1,240.50 · €860.00and the by-school table shows one row per (school, currency) — identical tomaster.delete from payouts where note = 'qa-531';when done.Screenshots / GIF
Before/after screenshots (school page, both locales, plus the platform page for the refactor regression check) and a verification GIF follow as a comment — the repo is private, so they have to be attached through the browser rather than linked.
Checklist
npm run typecheckandnpm run test:unitpass — typecheck clean; unit suite 358 passed (30 files), including the newtests/unit/format-money.test.ts(16 tests)npm run buildpassestenant_id(or the table genuinely has no such column) — no queries added or changed; the existingpayoutsquery keeps its.eq('tenant_id', tenantId)and the page'stenant_usersadmin check is untouchedowner@e2etest.com) on the school page and super admin on/platform/payouts; the page is admin-only by design (non-admins get the existing access-denied state, unchanged) and there is no student or teacher view of payoutsmessages/en.jsonandmessages/es.json— no new strings;stats.totalPaid/stats.totalPaidDesckeep their current wording in both locales, only the value expression changednpm run db:reset, has RLS policies, andlib/database.types.tswas regenerated — n/a, no migrationNot in this PR
§2.4 of #493 also asked for schools to see "the platform owes us $X" alongside "we were paid $Y", and #531 notes that this page still has no owed figure. That needs a tenant-side equivalent of
getPayoutsOwed()and is a feature rather than a correctness fix, so it is left for its own issue — this PR only stops the existing card from lying.