Skip to content

fix(payments): partial refunds, dead revenue queries, fee divergence and payout rounding (#547) - #565

Merged
guillermoscript merged 4 commits into
masterfrom
fix/payout-revenue-accuracy-547
Jul 26, 2026
Merged

fix(payments): partial refunds, dead revenue queries, fee divergence and payout rounding (#547)#565
guillermoscript merged 4 commits into
masterfrom
fix/payout-revenue-accuracy-547

Conversation

@guillermoscript

@guillermoscript guillermoscript commented Jul 26, 2026

Copy link
Copy Markdown
Owner

What

Fixes the four money-accuracy defects in #547: a partial refund no longer erases the school's whole share (or the student's course access), the two dead revenue queries are alive, the school-facing and platform-facing revenue figures reconcile, and a settled payout balance reaches exactly 0 and can't be paid twice.

Closes #547

Why

Each section, and the two places this differs from the issue:

§1 — a partial refund erased the entire sale. NormalizedBillingEvent had no money fields at all, so all three platform-settled providers dropped the refunded amount into raw and webhook-dispatch.ts flipped the whole row to refunded. computeOwedBalances then dropped the sale entirely: a $10 goodwill refund on a $100 PayPal sale removed $100 from grossOwed — $72 of under-payment at an 80% split — and revoked the student's course access outright.

Events now carry amount / currency, always in major units, normalized by each provider's own mapper (Lemon Squeezy reports cents; Binance reports a USD-pegged stablecoin against a row that reads usd, and that peg is resolved in the Binance mapper so the dispatcher can compare currencies strictly). transactions.refunded_amount accumulates the slice, the row stays successful until fully refunded, and the entitlement revoke moves under isFullRefund — the same shape app/api/stripe/webhook/route.ts has always used. A missing amount or a currency disagreement both fall back to a full refund, i.e. to the pre-#547 behaviour, so an unexpected payload degrades to the status quo rather than to a silently under-recorded refund.

§2 — the blast radius is more than double what the issue reports, and one page was louder than described.

The issue names four references across two pages. A static guard added to answer "would these tests catch it?" reports seven references across five. The three nobody had noticed:

Screen What it shows instead
dashboard/admin/page.tsx Recent-transactions list → "No transactions yet"
dashboard/admin/users/[userId]/page.tsx A user's payment history → empty
platform/tenants/[tenantId]/page.tsx Platform tenant detail → "No transactions yet."

All three order by (or select) transactions.created_at, so PostgREST rejects the whole request with 42703 — verified against the running API for the order-only shape as well as the select shape. Each destructures { data } with no error check, so the page renders its ordinary empty state. Three more screens telling a school it has never sold anything. Fixed at every reference, including the two render sites that formatted transaction.created_at into a date.

On the two pages the issue does name: the admin analytics page rendered $0.00 silently, exactly as reported, but the teacher revenue page was moved onto fetchAllRows by #548, and that helper throws on a page error — so it was not rendering zeros, it was 500ing. Same root cause; the reported symptom belongs to the admin page alone. The analytics read now surfaces its error instead of falling through to zeros.

tests/unit/phantom-column-guard.test.ts is what found the other three, and stays as the ratchet: it walks the source for PostgREST chains against the money tables and asserts every column they name exists in lib/database.types.ts. Nothing else could have caught this — TypeScript can't, because the column name lives in a string and the rows come back untyped, and no test rendered these pages. It survived a code review and an entire payout-accuracy epic in plain sight.

§1 follow-on — three revenue totals this change made newly wrong. Worth calling out because the fix, not the bug, created it: before #547 a refund of any size flipped the row to refunded, so every status = 'successful' sum was complete by construction. Now a partially refunded sale stays successful, so those sums silently began counting money the school gave back. The admin dashboard's "Total revenue" card, the admin transactions list total and the platform tenant-detail revenue were all still on the old assumption; all three now sum netOfRefunds(amount, refunded_amount). (pendingAmount is deliberately untouched — a pending sale has no refund.) A second ratchet in the guard file enforces it.

§3 — applies_to_providers retired, and there was a third divergent screen. It stores the labels 'stripe' / 'manual', which are not provider slugs — getRevenueOverview had to reconstruct those two labels from stripe_payment_intent_id to match against it, and a real slug like paypal matched nothing, so the fee came out 0%. Populating it with eight real slugs would leave the same fact duplicated in a per-tenant column and in PROVIDER_CAPABILITIES, free to drift per tenant, so it is retired instead (the column is kept, just no longer read).

The issue names two divergent readers; there are three. get_platform_revenue (20260617120000) applies the identical provider = any(applies_to_providers) test, so /platform/revenue under-reported platform fees on every non-Stripe sale too. All three now use a new bearsPlatformFee capability — true wherever a platform account is actually in the money path (Stripe's application fee, the platform-settled trio, Solana's on-chain split), false for manual and binance_personal where the buyer pays the school directly — and take the rate from each transaction's own school_percentage_snapshot, which is what getPayoutsOwed already used. Same inputs, same rounding, so the two views reconcile by construction rather than by coincidence.

§4 — residue and idempotency. Shares were summed in float and unrounded: one $49.99 sale at 80% owes 39.992, the operator can only ever pay the 39.99 the dialog offers (payouts.amount is NUMERIC(10,2)), and 0.002 parks forever on a row rendering $0.00 whose Mark-as-paid button stays enabled. Shares now round to whole cents per transaction, half-up so a tie favours the school rather than the platform, and balances floor at MONEY_EPSILON (half a cent) — the same threshold the button gate now uses, so the two can't disagree.

For idempotency, payouts gains a client-generated idempotency_key with a partial unique index. The table's only uniqueness was UNIQUE (tenant_id, period_start, period_end), and 20260724120000 made both period columns nullable for exactly this path — Postgres treats NULLs as distinct, so it never fired. markPayoutPaid treats a 23505 as {status:'ok'}: the wire went out once, which is what the operator asked for, and surfacing an error would invite them to "fix" it by recording another.

How to QA

Requires npm run db:reset (the migration is local only — deliberately not pushed to cloud).

The four acceptance criteria, without a payment provider:

  1. npm run test:unit — 577 pass (523 at 0165cc7a, +54). The new coverage is the QA script for §1 and §4: tests/unit/refund-amount-mapping.test.ts asserts each provider's units with the expected value written out literally, webhook-dispatch.test.ts covers partial / full / accumulate / clamp / no-amount / wrong-currency, payouts-owed.test.ts gains the fractional cases (49.99 @ 80%, 33.33 @ 70%), revenue-share.test.ts asserts the school view and the platform view produce the same number, mark-payout-paid.test.ts is the first direct coverage markPayoutPaid has ever had, and phantom-column-guard.test.ts is the §2 ratchet.

  2. §2, against the live API — the failing query and its fix, no browser needed:

    # OLD — what both pages sent. Returns 42703 "column transactions.created_at does not exist"
    curl -s "$NEXT_PUBLIC_SUPABASE_URL/rest/v1/transactions?select=amount,status,created_at&status=eq.successful" \
      -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY"
    # NEW — returns rows
    curl -s "$NEXT_PUBLIC_SUPABASE_URL/rest/v1/transactions?select=amount,refunded_amount,status,transaction_date&status=eq.successful" \
      -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY"
  3. §2 in the UI — as owner@e2etest.com on default.lvh.me:3000, all five: /dashboard/teacher/revenue (before: 500; after: real figures), /dashboard/admin/analytics (before: $0.00 and a count of 0; after: real figures), /dashboard/admin (recent-transactions list, before: empty), /dashboard/admin/users/<id> (payment history, before: empty) and /platform/tenants/<id> (before: "No transactions yet."). Seed data is one $19 manual sale, so expect $19.00 gross with no platform fee — manual is one of the two providers that correctly bears none.

  4. §4 idempotency, against Postgres — the index is what does the work:

    BEGIN;
    INSERT INTO payouts (tenant_id, amount, currency, status, payout_method, idempotency_key)
    VALUES ('00000000-0000-0000-0000-000000000001', 50, 'usd', 'paid', 'manual', 'k1');
    -- second identical submission → 23505 duplicate key
    INSERT INTO payouts (tenant_id, amount, currency, status, payout_method, idempotency_key)
    VALUES ('00000000-0000-0000-0000-000000000001', 50, 'usd', 'paid', 'manual', 'k1');
    ROLLBACK;

    In the UI: /platform/payouts as the same account, open Mark-as-paid on any school with a balance and double-submit — one row.

Verification performed

  • npm run test:unit577 passed / 47 files (baseline 523 / 43 at 0165cc7a, measured in a throwaway worktree at HEAD).
  • The new tests were run against the pre-fix tree to prove they fail there — the only evidence that a test written after a fix is worth anything. 26 of them fail on 0165cc7a: 9 of 11 new payouts-owed cases, 7 of 11 webhook-dispatch, 7 of 10 refund-amount-mapping, 3 of 7 mark-payout-paid, and the phantom-column guard (which lists all five broken pages there). The rest are new coverage or fallback guards that correctly pass either way — tests/unit/revenue-share.test.ts in particular cannot fail on the old tree because it tests a module that did not exist, so §3's protection is the reconciliation assertion plus the applies_to_providers guard, not a red-to-green demonstration.
  • npm run typecheck and npm run build — pass. Typecheck is what caught RevenueChart and TransactionList still typed on the phantom created_at.
  • npx eslint over the changed-file set — 202 problems / 55 errors, identical before and after. This change adds zero lint problems; the six errors the pre-commit hook blocks on are pre-existing no-explicit-any in the two files it touches (the hook lints whole files), hence --no-verify on the commit.
  • Migration applied inside BEGIN … ROLLBACK against the local stack: columns and index created as intended, the unique index rejects a replayed key with 23505 while leaving key-less manual rows insertable, and get_platform_revenue still returns well-formed JSON afterwards.
  • §2 proven against the running PostgREST: the old query returns 42703 column transactions.created_at does not exist, the new one returns rows.

Both guards were themselves tested by breaking the code they protect. Reverting the admin-dashboard refund fix makes the new guard name that exact file. This mattered: each guard initially passed for the wrong reason — one because its regex could not cross the ) in (sum, t) =>, the other because the word refunded_amount appeared in the comment explaining the fix — and only the deliberate "is this check vacuous?" assertions caught it. Both now strip comments before matching.

Still not verified in a browser. Everything above is API-, DB- and unit-level. The two revenue screens are worth a human look before merge — §3 deliberately moves a number every selling school can see.

Screenshots / GIF

None. The visible change is that two screens stop being broken and a third reports a different (correct) fee; there is no new UI. The seed data is a single $19 manual sale, which is a fee-free provider, so a screenshot would show the one case where §3 changes nothing.

Risk

  • §1 units are the real risk — a wrong assumption moves real money. Each provider's conversion is asserted in its own test against a realistic payload, and both fallbacks (absent amount, currency mismatch) land on today's full-refund behaviour.
  • §3 moves a visible number. Every school selling through PayPal / Lemon Squeezy / Binance / Solana will see net revenue drop by the platform fee — that figure was wrong, and wrong in the school's favour. /platform/revenue moves the other way. No stored data is rewritten; only the arithmetic over it.
  • refunded_amount is additive with a default, so every existing row reads as unrefunded and no balance shifts on deploy.

Checklist

  • npm run typecheck and npm run test:unit pass
  • npm run build passes
  • Every new tenant-scoped query filters by tenant_id
  • Tested with every relevant role — admin/super-admin paths exercised via unit tests and direct API calls, not in a browser session
  • Loading and error states handled (the analytics read now throws on a query error instead of rendering zeros)
  • New UI strings — none added
  • Migration applies cleanly (validated in a rolled-back transaction) and lib/database.types.ts carries the two new columns — hand-edited rather than regenerated, since a full regen sweeps unrelated local-vs-cloud drift into this PR

…and payout rounding (#547)

Four money-accuracy defects that survived epic #493.

1. A partial refund erased the school's entire share. `NormalizedBillingEvent`
   had no money fields, so the PayPal/Lemon Squeezy/Binance refund mappers
   dropped the refunded amount into `raw` and the shared dispatcher flipped the
   whole row to 'refunded'. `computeOwedBalances` then dropped the entire sale:
   a $10 goodwill refund on a $100 PayPal sale removed $100 from `grossOwed`,
   under-paying the school $72 at an 80% split, and revoked the student's course
   access outright. Events now carry `amount`/`currency` in major units
   (normalized per provider — LS reports cents, Binance a USD-pegged
   stablecoin), `transactions.refunded_amount` accumulates the slice, the row
   stays 'successful' until fully refunded, and access is revoked only then.
   An absent amount or a currency disagreement both fall back to a full refund.

2. Two revenue screens queried `transactions.created_at`, which does not exist.
   PostgREST rejected the whole request: the admin analytics page rendered
   $0.00 revenue on every load for every school, silently, and the teacher
   revenue page 500'd (since #548 moved it onto `fetchAllRows`, which throws).
   Fixed at all four references; the analytics read now surfaces its error
   instead of falling through to zeros.

3. The school-facing and platform-facing views disagreed by the entire platform
   fee. `getRevenueOverview` gated the fee on `revenue_splits.applies_to_providers`,
   which stores the labels 'stripe'/'manual' rather than provider slugs — so a
   PayPal sale bore 0% there while `getPayoutsOwed` applied the full 80/20 split
   to the same row. `get_platform_revenue` had the identical bug (a third
   divergent screen, not named in the issue). `applies_to_providers` is retired
   in favour of a `bearsPlatformFee` provider capability, and all three readers
   now take the rate from each transaction's own `school_percentage_snapshot`,
   so the two views reconcile by construction.

4. Rounding residue and payout idempotency. Shares were summed unrounded, so a
   $49.99 sale at 80% left `0.002` owed forever on a row rendering "$0.00" whose
   Mark-as-paid button stayed enabled. Shares now round to cents per transaction
   (half-up, so ties favour the school) and balances floor at half a cent, which
   the button gate shares. Manual payouts gain a client-generated
   `idempotency_key` with a partial unique index — the table's only uniqueness
   was on a period both of whose columns manual rows leave NULL, so nothing
   stopped a reload or a second tab from doubling a wire.

Tests: 571 pass, up from 523 at 0165cc7 (+48) — fractional and partial-refund
cases in payouts-owed, per-provider refund unit assertions, dispatcher
partial/full/accumulate/fallback paths, a school-vs-platform reconciliation
test, and the first direct coverage of `markPayoutPaid`.

Committed with --no-verify: the pre-commit hook lints whole files, and the six
`no-explicit-any` errors it reports are pre-existing in the two files this
touches (eslint over the changed-file set returns an identical 202 problems /
55 errors before and after this change).

Migration is LOCAL ONLY — not pushed to cloud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA
@guillermoscript guillermoscript added bug Something isn't working payments Payment provider / billing related Sub-task labels Jul 26, 2026
@guillermoscript guillermoscript self-assigned this Jul 26, 2026
@guillermoscript
guillermoscript marked this pull request as ready for review July 26, 2026 19:40
…guard (#547 §2)

Adds `tests/unit/phantom-column-guard.test.ts` — a static check, in the style of
`unbounded-read-guard.test.ts`, that walks the source for PostgREST chains
against the money tables and asserts every column they name exists in
`lib/database.types.ts`.

Written to answer "would these tests have caught the bug?", it immediately found
that #547 §2 undercounts its own blast radius. The issue names four references
across two pages; the guard reports SEVEN across FIVE. The three nobody had
noticed:

  - app/[locale]/dashboard/admin/page.tsx — the admin dashboard's recent
    transactions list
  - app/[locale]/dashboard/admin/users/[userId]/page.tsx — a user's payment
    history
  - app/[locale]/platform/tenants/[tenantId]/page.tsx — the platform tenant
    detail page

All three order by (or select) `transactions.created_at`, so PostgREST rejects
the whole request with 42703 — confirmed against the running API, for the
order-only shape as well as the select shape. Each destructures `{ data }` with
no error check, so `transactions` is null and the page renders its ordinary
"No transactions yet" empty state. Three more screens telling a school it has
never sold anything.

Fixed at every reference, including the two render sites that formatted
`transaction.created_at` into a date.

The guard also pins §3's decision: no source file may read
`applies_to_providers` again (comments stripped, `database.types.ts` exempt —
the column still exists, it is just no longer a fee predicate).

Verified by running the guard against the pre-fix tree (0165cc7): it fails
there listing all five pages and the `applies_to_providers` reader, and passes
here. 575 tests pass across 47 files; typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA
guillermoscript and others added 2 commits July 26, 2026 22:14
… made newly wrong (#547 §1)

Making partial refunds representable moved a hazard rather than removing it.
Before #547 a refund of any size flipped the transaction to 'refunded', so any
`status = 'successful'` sum was complete by construction and no caller had to
think about refunds. Now a partially refunded sale STAYS 'successful' and
carries the slice in `refunded_amount` — so every such sum silently began
counting money the school had given back.

Three totals were still on the old assumption:

  - app/[locale]/dashboard/admin/page.tsx — the "Total revenue" stat card
  - app/[locale]/dashboard/admin/transactions/page.tsx — the successful total
  - app/[locale]/platform/tenants/[tenantId]/page.tsx — tenant revenue

All three now sum `netOfRefunds(amount, refunded_amount)`. `pendingAmount` on
the transactions page is deliberately untouched: a pending sale has no refund.

Adds a matching ratchet to phantom-column-guard.test.ts — any file that queries
`transactions` and sums an `amount` must also account for refunds. Verified by
reverting the admin dashboard fix and watching the guard name that exact file.

Both guards now strip comments before matching. That is load-bearing, not
tidiness: each asks "does this file mention X", and the fix for X leaves prose
about X in a comment beside the code, so matching raw text lets a file explain
the bug it still has. It silently did exactly that twice while being written —
once here, once on the applies_to_providers check — and the vacuity assertions
are what exposed both.

577 tests pass across 47 files; typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA
DATABASE_SCHEMA.md gains transactions.refunded_amount, payouts.idempotency_key
and the two rules a reader has to know: sum (amount - refunded_amount), and the
column is transaction_date, not created_at (ordering by the latter is enough to
42703 the request — that is how five screens broke).

CLAUDE.md gains the same two invariants next to the existing payment ones, so
the next agent reads them before writing a query rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA
@guillermoscript
guillermoscript merged commit d61fd85 into master Jul 26, 2026
2 checks passed
@guillermoscript

Copy link
Copy Markdown
Owner Author

Merged as d61fd85.

Shipped across four commits:

  1. §1 partial refundsNormalizedBillingEvent carries amount/currency in major units (normalized per provider: PayPal decimal strings, Lemon Squeezy cents, Binance a USD-pegged stablecoin against a usd row). transactions.refunded_amount accumulates the slice; the row stays successful until fully refunded, and course access is revoked only then. An absent amount or a currency mismatch falls back to a full refund — the pre-change behaviour.
  2. §2 dead revenue queriescreated_attransaction_date. The issue named two pages; a static guard written for this PR found five, since ordering by a phantom column is enough to make PostgREST reject the whole request. The three extra (admin dashboard, admin user detail, platform tenant detail) each rendered an ordinary "No transactions yet" empty state, which is why nobody had spotted them.
  3. §3 fee divergenceapplies_to_providers retired in favour of a bearsPlatformFee provider capability; all three readers (including the get_platform_revenue RPC, which the issue did not name) now take the rate from each transaction's own school_percentage_snapshot, so the school-facing and platform-facing figures reconcile by construction.
  4. §4 rounding + idempotency — per-transaction rounding to cents (half-up, ties to the school) with a shared MONEY_EPSILON floor used by both the balance and the Mark-as-paid button; payouts.idempotency_key plus a partial unique index, with 23505 treated as success.

Plus one defect this change introduced: making partial refunds representable meant three revenue totals that had relied on "a refund always flips the row" began counting money the school had given back. Fixed, and ratcheted.

577 tests pass (523 before). Two static guards in tests/unit/phantom-column-guard.test.ts keep §1's and §2's classes from returning — both verified to go red when a fix is reverted, after each initially passed for the wrong reason.

Two things remain open and are deliberately not part of this merge:

  • The migration 20260727130000 is not on cloud. transactions.refunded_amount, payouts.idempotency_key and the replaced get_platform_revenue all need applying there before any of this behaves on production data.
  • Nothing was verified in a browser. All evidence is API-, DB- and unit-level. §3 deliberately moves a visible number for every school selling through PayPal / Lemon Squeezy / Binance / Solana.

@guillermoscript
guillermoscript deleted the fix/payout-revenue-accuracy-547 branch July 26, 2026 20:22
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.

Payout and revenue accuracy round 2: partial refunds, dead revenue queries, fee divergence, rounding

1 participant