Skip to content

fix(payouts): stop the school payout history summing across currencies (#531) - #534

Merged
guillermoscript merged 1 commit into
masterfrom
fix/payout-history-currency-mix-531
Jul 25, 2026
Merged

fix(payouts): stop the school payout history summing across currencies (#531)#534
guillermoscript merged 1 commit into
masterfrom
fix/payout-history-currency-mix-531

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

What

The school-facing "Total paid out" card on /dashboard/admin/payouts no 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.tsx reduced every paid payout into one scalar and rendered it with a hardcoded currency:

const totalPaid = rows.filter((p) => p.status === 'paid')
  .reduce((sum, p) => sum + (p.amount || 0), 0)
...
{fmt(totalPaid, 'USD')}

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() and formatByCurrency() 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. pendingCount is a count and was never affected.

How to QA

On a fresh npm run db:reset, as owner@e2etest.com / password123 on default.lvh.me:3000. The seed has no payouts, so QA needs a couple of rows:

insert into payouts (tenant_id, amount, currency, status, period_start, period_end, paid_at, payout_method, note)
values
 ('00000000-0000-0000-0000-000000000001', 1240.50, 'usd', 'paid',    '2026-05-01', '2026-05-31', '2026-06-02', 'manual', 'qa-531'),
 ('00000000-0000-0000-0000-000000000001',  860.00, 'eur', 'paid',    '2026-06-01', '2026-06-30', '2026-07-02', 'manual', 'qa-531'),
 ('00000000-0000-0000-0000-000000000001',  310.75, 'usd', 'pending', '2026-07-01', '2026-07-31', null,         'manual', 'qa-531');

(payouts has a unique constraint on tenant + period, so keep the three periods distinct.)

  1. Go to /en/dashboard/admin/payouts. "Total paid out" reads $1,240.50 · €860.00 — two figures, not $2,100.50. On master this same data shows $2,100.50.
  2. Check the "Payout history" table below: $1,240.50, €860.00 and $310.75 each keep their own currency symbol, unchanged from before.
  3. Check "Pending / Processing" still reads 1 — it counts rows and is not currency-grouped.
  4. Switch to /es/dashboard/admin/payouts. The card reads 1240,50 US$ · 860,00 € — locale formatting applies, currency codes still come from the data.
  5. Narrow the window to ~420px. The card wraps rather than clipping; add a third currency (e.g. a gbp row) if you want to push it further.
  6. Regression check on the super-admin side: as the same account (it is also a super admin), open /en/platform/payouts. "Paid out (all time)" reads $1,240.50 · €860.00 and the by-school table shows one row per (school, currency) — identical to master.
  7. 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 typecheck and npm run test:unit pass — typecheck clean; unit suite 358 passed (30 files), including the new tests/unit/format-money.test.ts (16 tests)
  • npm run build passes
  • Every new tenant-scoped query filters by tenant_id (or the table genuinely has no such column) — no queries added or changed; the existing payouts query keeps its .eq('tenant_id', tenantId) and the page's tenant_users admin check is untouched
  • Tested with every relevant role (student / teacher / admin) — admin (owner@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 payouts
  • Loading and error states handled — no new data fetching; the existing empty state ("no payouts yet") and the access-denied branch are untouched
  • New UI strings added to both messages/en.json and messages/es.json — no new strings; stats.totalPaid / stats.totalPaidDesc keep their current wording in both locales, only the value expression changed
  • Migration (if any) applies cleanly on npm run db:reset, has RLS policies, and lib/database.types.ts was regenerated — n/a, no migration

Not 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.

#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
@guillermoscript guillermoscript added bug Something isn't working Sub-task payments Payment provider / billing related labels Jul 25, 2026
@guillermoscript guillermoscript self-assigned this Jul 25, 2026
@guillermoscript

Copy link
Copy Markdown
Owner Author

Visual verification

Captured locally on default.lvh.me:3000 as owner@e2etest.com, against three seeded payouts for the Default School: 1,240.50 USD paid, 860.00 EUR paid, and 310.75 USD pending. The seed rows were removed afterwards.

School payout history — before

The bug as filed: the two paid payouts are summed to $2,100.50 and labelled in dollars, directly above rows that each show their own currency correctly.

Before — school payouts page showing $2,100.50

School payout history — after

Same data, same page: $1,240.50 · €860.00. No cross-currency sum, and no currency named that the data didn't supply. "Pending / Processing" still reads 1, and the table rows are unchanged.

After — school payouts page showing $1,240.50 · €860.00

Spanish locale

/es/dashboard/admin/payouts1240,50 US$ · 860,00 €. Locale formatting applies to each figure; the currency codes still come from the rows.

After — Spanish locale showing 1240,50 US$ · 860,00 €

Super-admin page — refactor regression check

This PR moves the platform page's private money() / formatByCurrency() helpers into the shared module, so it needs to be proven unchanged rather than merely still working. Before (left, on master) and after (right, on this branch) are the same render: $1,240.50 · €860.00 in "Paid out (all time)", one table row per (school, currency).

Before (master) After (this branch)
Platform payouts before Platform payouts after

Flow recording

The walkthrough: school page in English, the same page in Spanish, the super-admin page, then the school page narrowed to ~420px — where the card wraps instead of clipping, including a third currency (£4,300.00 · $1,240.50 · €860.00) added temporarily to push the layout.

Verification GIF

Please give the before/after pair a visual check before merging — the QA steps in the PR body reproduce this from a fresh npm run db:reset.

@guillermoscript
guillermoscript marked this pull request as ready for review July 25, 2026 18:18
@guillermoscript
guillermoscript merged commit 86d60fa into master Jul 25, 2026
2 checks passed
@guillermoscript
guillermoscript deleted the fix/payout-history-currency-mix-531 branch July 25, 2026 18:24
@guillermoscript

Copy link
Copy Markdown
Owner Author

Merged to master (squash) with CI green — both verify jobs passed on the final commit.

Shipped exactly as described in the body; nothing changed between opening and merge.

  • The school-facing "Total paid out" card on /dashboard/admin/payouts now renders one figure per currency instead of a cross-currency sum labelled USD. With the QA data (1,240.50 USD + 860.00 EUR paid), it reads $1,240.50 · €860.00 where master previously read $2,100.50.
  • lib/payments/format-money.ts is now the single home for payout money rendering — formatMoney, sumByCurrency, formatByCurrency. Both the school page and the super-admin page import it; the super-admin page's previously private helpers are gone. Any future screen showing payout money should go through this module rather than re-deriving the grouping, which is how the Payouts-owed sums transaction amounts across currencies with no grouping #497 fix came to be re-broken in the first place.
  • Covered by tests/unit/format-money.test.ts (16 tests), including the two-currency case from the issue and the case-insensitive currency-code merge.
  • Checks at merge: npm run typecheck clean, npm run test:unit 358 passed across 30 files, npm run build passed, ESLint clean on the changed files.
  • Verified in the browser on default.lvh.me:3000 in both locales, plus a regression check confirming the super-admin page renders identically to master after the refactor. Screenshots and the GIF are in the comment above.

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 getPayoutsOwed() and is a feature rather than a correctness fix, so it wants its own issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working payments Payment provider / billing related Sub-task

Projects

None yet

Development

Successfully merging this pull request may close these issues.

School-facing payout history sums across currencies and labels the total USD (#497 follow-up)

1 participant