Skip to content

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

Description

@guillermoscript

Part of EPIC #540. §3.1–3.4.

Four money-accuracy defects that survived epic #493's payout hardening. #511/#515/#516's clawback arithmetic itself was re-audited and is correct — these are different problems.

1. A partial refund erases the school's entire share 🔴

lib/payments/webhook-dispatch.ts:252-257refund.succeeded unconditionally does .update({ status: 'refunded' }). No refunded amount is read or stored, and the mapped event carries none: lib/payments/paypal-provider.ts:601-611 and lib/payments/binance-provider.ts:319-326 drop the amount into raw and never surface it.

All three platform-settled providers support partial refunds programmatically — paypal-provider.ts:750-751 ("Partial refunds need the currency…"), binance-provider.ts:348 (refundAmount), and supportsRefunds: true for paypal/lemonsqueezy/binance in types.ts. But lib/payments/payouts-owed.ts:121 types status as a binary 'successful' | 'refunded', and :256-261 drops the whole sale.

So a $10 goodwill refund on a $100 PayPal sale removes $100 from grossCollected/grossOwed — under-paying the school $72 at an 80% split. It also fires entitlements → revoked (:259-266), so a partially-refunded student loses course access outright. Silent in both directions.

The codebase already knows partials exist: app/api/stripe/webhook/route.ts:227-228 computes isFullRefund = charge.amount_refunded >= charge.amount and keeps the row successful on a partial. Stripe Connect is the one provider excluded from the payout math, so that guard protects the one case that did not need it.

Fix: carry amount/currency through NormalizedEvent; persist a refunded amount; give PlatformSettledTxn a refundedAmount so grossOwed subtracts the refunded slice rather than the row; revoke entitlements only on a full refund. Mind the per-provider units (PayPal major units vs Binance).

2. Two revenue screens query a column that does not exist 🔴

  • app/[locale]/dashboard/teacher/revenue/page.tsx:30.select('amount, status, payment_provider, created_at') … .order('created_at', …), and :45 filters on t.created_at.
  • app/[locale]/dashboard/admin/analytics/page.tsx:69.select('amount, status, created_at') … .gte('created_at', …) .order('created_at', …).

transactions has transaction_date, not created_at — confirmed absent from the live table and from lib/database.types.ts. PostgREST rejects the whole request; both call sites destructure { data } with no error check, so transactions is null.

Every school sees $0.00 total revenue, $0.00 school share, $0.00 last-30-days and a count of 0 on every render, permanently, with nothing logged. This is the school-side counterpart of the payout numbers epic #493 spent seven sub-issues getting right.

Fix: created_attransaction_date at all four references; surface error instead of falling through to zeros.

3. The platform page and the school page disagree by the entire platform fee 🔴

app/actions/admin/revenue.ts:68-72 classifies every transaction as t.stripe_payment_intent_id ? 'stripe' : 'manual', then charges the platform fee only if that label appears in applies_to_providers — which defaults to ARRAY['stripe'] (20260216212440_create_revenue_infrastructure.sql:20).

A PayPal / Lemon Squeezy / Binance sale is therefore labelled manual, bears 0% fee, and the school's netRevenue shows 100% of it. Meanwhile app/actions/platform/payouts.ts:20-22 + payouts-owed.ts:255 apply the 80% school_percentage to those same rows, and neither the payout math nor the #512 backstop trigger reads applies_to_providers at all.

Two shipped, authoritative-looking screens differing by the whole platform fee, on every platform-settled sale.

Fix — pick a side: either retire applies_to_providers (it names stripe/manual, not the eight real provider slugs — it reads like a stale abstraction from the Connect-only era) and have getRevenueOverview apply platform_percentage to the same settlesToPlatformAccount set getPayoutsOwed uses; or populate it with real slugs and have both readers honour it. State which you chose and why in the PR.

4. Rounding residue and payout idempotency 🟡

Residue. payouts-owed.ts:255scaledAmount = (txn.amount * effectivePercentage) / 100, in float, summed unrounded. One $49.99 sale at 80% → 39.992. The dialog pre-fills netOwed.toFixed(2) = 39.99 (mark-payout-paid-dialog.tsx:31), payouts.amount is NUMERIC(10,2), so alreadyPaid is exactly 39.99 and netOwed settles at 0.002 — never 0. app/[locale]/platform/payouts/page.tsx:298 renders that as $0.00 while mark-payout-paid-dialog.tsx:82 gates the button on disabled={netOwed <= 0}, so it stays enabled. Every school whose owed total isn't an exact cent parks a permanent phantom balance on a permanently actionable row, and residue accumulates across cycles (any .99 price at 80%, any 3%/7% split). Operators learn to ignore a row reading $0.00 that still demands action.

Idempotency. app/actions/platform/payouts.ts:126-135 inserts into payouts with no period_start/period_end and no idempotency key. The table's only uniqueness is UNIQUE (tenant_id, period_start, period_end) (20260216212440:90), and 20260724120000_manual_payouts.sql:15-16 made both columns nullable for exactly this path — Postgres treats NULLs as distinct, so the constraint never fires for a manual row. mark-payout-paid-dialog.tsx:147 disables the button only while loading, which does not survive a reload, a second tab, a second super admin, or a server-action retry. Two inserts of the same wire double alreadyPaid; netOwed floors to 0 and the excess surfaces only as overpaid, which the module itself notes is recovered by carry-forward "only for a school that keeps selling". For a dormant school the platform has paid twice with no reversal path — CHECK (amount > 0) forbids a correcting negative row.

Fix: round each scaledAmount to the currency's minor unit before accumulating (or compute in integer minor units throughout — decide the rounding mode so the platform never systematically rounds in its own favour), and use a < 0.005 epsilon for both the floor and the button gate. Add a client-generated idempotency_key uuid with a partial unique index WHERE payout_method='manual', generated once per dialog open; on conflict return {status:'ok'} rather than erroring.

Out of scope — audited and correct, do not change

computeOwedBalances' clawback arithmetic (clawback is deliberately absent from netOwed; refunded rows leave grossOwed, their payouts stay in alreadyPaid, so the overpayment nets out exactly once — #511 got this right). wasPlausiblyPaidOut (same-tenant and same-currency, parses dates rather than string-comparing, fails closed). Double-clawback of the same transaction (impossible — webhook-dispatch.ts:249 breaks unless status === 'successful' and the update carries .eq('status','successful')). The #516 mismatch-retry interaction (mismatch is cleared on every keystroke, so confirmMismatch: true can only accompany the exact amount warned against). Currency-case handling (transactions.currency is a lowercase enum; no zero-decimal currency exists in it). The #512 backstop trigger (live on cloud, correct).

Acceptance criteria

  • A partial refund reduces grossOwed by the refunded slice only, and does not revoke the entitlement. Unit-tested per provider, with the units asserted.
  • Teacher and admin revenue pages show real figures; a query error surfaces instead of rendering zeros.
  • For the same tenant and period, the school-facing net revenue and the platform-facing netOwed reconcile to the same split. Assert it in a test with a PayPal sale.
  • netOwed for a $49.99 sale at 80%, fully paid, is exactly 0, and the Mark-as-paid button is disabled.
  • Submitting the Mark-as-paid dialog twice records one payouts row.
  • tests/unit/payouts-owed.test.ts gains fractional cases (49.99 @ 80%, 33.33 @ 70%) — all 31 existing cases use round amounts and round percentages, which is why the residue was invisible.
  • markPayoutPaid's mismatch guard has direct coverage at the netOwed === 0 boundary (today it has none — grep -rl "markPayoutPaid" tests/ returns only comments).
  • npm run test:unit green (370/370 baseline at 146a8cfa).

Effort: M · Risk: MED for the refund path (touches the shared dispatcher and the entitlement-revoke branch; a wrong units assumption moves real money) and for §3 (whichever side changes, one set of schools sees their revenue figure move). LOW for §2 and §4.

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