Skip to content

getPayoutsOwed reads the ledger unpaginated — a PostgREST row cap silently misstates payout balances #533

Description

@guillermoscript

Follow-up to #493 — found reviewing the epic's shipped code (2026-07-25).

Problem

getPayoutsOwed() fetches the entire transaction and payout ledger in one unpaginated PostgREST call. PostgREST caps rows server-side (Supabase's default API "Max rows" is 1000), and a capped response is indistinguishable from a complete one — the balance simply comes out low, with no error.

Evidence

app/actions/platform/payouts.ts:27-40:

admin.from('tenants').select('id, name'),
admin.from('revenue_splits').select('tenant_id, school_percentage'),
admin.from('transactions')
  .select('tenant_id, payment_provider, amount, currency, school_percentage_snapshot, status, transaction_date')
  .in('status', ['successful', 'refunded'])
  .in('payment_provider', PLATFORM_SETTLED_PROVIDERS),
admin.from('payouts')
  .select('tenant_id, amount, currency, period_end, paid_at, created_at')
  .eq('payout_method', 'manual').eq('status', 'paid'),

No .range(), no .limit(), no count check on any of the four. computeOwedBalances() is pure arithmetic over whatever array it is handed (lib/payments/payouts-owed.ts:183), so a truncated input yields a confidently wrong number rather than a failure.

Which side the truncation lands on decides the direction of the error:

  • transactions truncated → grossOwed too low → underpays the school
  • payouts truncated → alreadyPaid too low → overpays it

The same shape appears in app/api/cron/enforce-plan-limits/route.ts:49 (from('tenants').select('id') with no pagination) — a truncated tenant list silently skips the tail of the sweep, which is #494's only organic-growth trigger.

Impact

Latent today, not reproducible: the cloud project currently holds 0 transactions and 0 payouts rows (checked 2026-07-25) — the manual-payout feature was verified against local data only. It bites silently at scale, in the one place this epic exists to make trustworthy, and it fails without any signal — the exact class of error #496/#497/#511 were about.

Wanted

  1. Page the reads (.range() loop until a short page comes back), or move the aggregation into SQL — a SECURITY DEFINER RPC returning per-tenant-per-currency sums would remove the row-count exposure and most of the transfer entirely. The per-transaction split snapshot makes this a plain SUM(amount * school_percentage_snapshot / 100) group-by, so the arithmetic stays where payouts-owed.ts already documents it.
  2. Whatever the mechanism, assert completeness — a { count: 'exact' } head query compared against the rows actually processed, and a loud error (not a silent low number) when they disagree.
  3. Same treatment for the tenants sweep in enforce-plan-limits.
  4. Confirm the project's configured API "Max rows" while doing this, and write it down — the fix should not depend on the reader knowing the default.

Metadata

Metadata

Labels

Sub-taskbugSomething isn't workingpaymentsPayment provider / billing related

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions