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
- 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.
- 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.
- Same treatment for the
tenants sweep in enforce-plan-limits.
- 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.
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: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:
grossOwedtoo low → underpays the schoolalreadyPaidtoo low → overpays itThe 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
transactionsand 0payoutsrows (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
.range()loop until a short page comes back), or move the aggregation into SQL — aSECURITY DEFINERRPC 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 plainSUM(amount * school_percentage_snapshot / 100)group-by, so the arithmetic stays wherepayouts-owed.tsalready documents it.{ count: 'exact' }head query compared against the rows actually processed, and a loud error (not a silent low number) when they disagree.tenantssweep inenforce-plan-limits.