Skip to content

fix(payments): clawback refunded transactions from payouts owed (#498) - #506

Merged
guillermoscript merged 1 commit into
masterfrom
fix/payout-refund-clawback-498
Jul 24, 2026
Merged

fix(payments): clawback refunded transactions from payouts owed (#498)#506
guillermoscript merged 1 commit into
masterfrom
fix/payout-refund-clawback-498

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

Description

Part of epic #493 (Phase 2 — payout money-accuracy). Branches directly off master, since #503/#504/#505 are all merged.

markPayoutPaid was a one-way ratchet: once a payout was recorded, nothing re-checked whether the underlying transactions it was based on later flipped to refunded. Since getPayoutsOwed() only ever queried status = 'successful', a refunded transaction simply vanished from the calculation with no trace — netOwed would silently drop to $0.00 for that tenant going forward, absorbing the loss with no record that a refund had happened.

Changes made

  • lib/payments/payouts-owed.tsPlatformSettledTxn gained a status: 'successful' | 'refunded' field. computeOwedBalances now includes refunded transactions in the aggregation instead of relying on the caller to filter them out: a refunded transaction no longer contributes to grossCollected/grossOwed/byProvider, but its scaled amount (same split-percentage logic as any sale — snapshot-or-current) is added to a new clawback figure. netOwed is now max(grossOwed - alreadyPaid - clawback, 0).
  • CurrencyBalance gained a clawback: number field — always present and visible, even when 0.
  • app/actions/platform/payouts.tsgetPayoutsOwed() now selects status and queries .in('status', ['successful', 'refunded']) instead of .eq('status', 'successful'), and passes status through to computeOwedBalances.
  • app/[locale]/platform/payouts/page.tsx — adds a "Clawback" column to the by-school table (shown as −$X.XX in red when non-zero, otherwise) and a red banner above the table summarizing total clawback per currency whenever any tenant has one, so a refund-after-payout is visible rather than silently netted away.

Type of change

  • Bug fix (money-accuracy — a refund on an already-paid-out sale previously vanished with no trace instead of reducing what's owed next cycle)

Closes #498 (Phase 2 sub-task of epic #493).

Testing

  • npx vitest run tests/unit/payouts-owed.test.ts — 18/18 passing, including 4 new tests specific to No refund/chargeback clawback for manual payouts already marked paid #498 (refund-after-payout produces a visible clawback and reduces netOwed; clawback is reported even with nothing else outstanding; a refund in one currency doesn't clawback a balance in another currency; clawback uses the transaction's own snapshotted split, not the tenant's current one).
  • npm run test:unit — 277/277 passing, no regressions.
  • npm run typecheck — clean.
  • npm run lint on changed files — no new errors (one pre-existing unused-var warning in payouts.ts predates this change, unrelated to it).
  • npm run build — succeeds.
  • Live-verified locally: seeded a $100 USD PayPal sale with status = 'refunded' (80% split snapshot) for a tenant that already had an $80 manual payout recorded against it, then loaded /platform/payouts as a super admin. Screenshot attached below shows: Collected $0.00 (the refunded sale no longer counts as collected), Paid so far $80.00, Clawback −$80.00 in red, Owed $0.00, and the red clawback banner summarizing it above the table. Test data cleaned up afterward.

QA verification steps

  1. In a local Supabase shell, create a successful PayPal transaction for a tenant, record a manual payout against it via the "Mark as Paid" dialog on /platform/payouts.
  2. Flip that transaction's status to refunded directly in the DB (simulating a later refund).
  3. Reload /platform/payouts — confirm a red "Refund clawback" banner appears, the tenant's row shows a −$X.XX clawback figure, and "Owed" reflects the reduction (floored at $0.00, never negative).
  4. Confirm the refunded transaction's amount is excluded from the "Collected" column (it never happened, from a bookkeeping standpoint) while still being visible via the clawback figure.

Screenshot

Attached as a PR comment: the payouts page showing the clawback banner and table row for the refund-after-payout scenario.

A transaction flipping to refunded after its payout was already recorded
previously just vanished from the successful-only query with no trace,
silently absorbing the loss. Refunded transactions are now included and
scaled by the same split, surfaced as a distinct clawback figure that
reduces netOwed on the next cycle instead of disappearing.

Closes #498.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@guillermoscript guillermoscript added the bug Something isn't working label Jul 24, 2026
@guillermoscript guillermoscript self-assigned this Jul 24, 2026
@guillermoscript guillermoscript added Sub-task payments Payment provider / billing related bug Something isn't working labels Jul 24, 2026
@guillermoscript

Copy link
Copy Markdown
Owner Author

Verification screenshot for the #498 refund clawback fix.

Seeded a $100 USD PayPal sale with an 80% split snapshot, refunded after an $80 manual payout had already been recorded for it. The payouts page now shows: Collected $0.00 (the refunded sale no longer counts as collected), Paid so far $80.00, a −$80.00 clawback figure in red, Owed $0.00, and a red banner summarizing the total clawback — visible instead of silently netted away.
issue-498-payouts-clawback

@guillermoscript
guillermoscript marked this pull request as ready for review July 24, 2026 16:48
@guillermoscript
guillermoscript merged commit 49e7fc7 into master Jul 24, 2026
2 checks passed
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.

No refund/chargeback clawback for manual payouts already marked paid

1 participant