28. Wise payout reconciliation
Date: 2026-06-10
Status
Accepted
Context
Per ADR-0020 the owner funds contractor pay manually in the Wise UI — the app never creates
transfers. But once money is sent, the payments rows (snapshotted at period close, status
prepared) need to reflect reality: which payout settled which payment, whether the amount sent
matched what we owed, and whether any Wise transfer corresponds to no payment at all. Without this,
prepared payments never advance and the owner has no single view of what still needs funding.
Decision
- Read-only reconciliation, not transfer creation. A new adapter
(
server/integrations/wise/client.ts) only reads the profile's recent transfers (GET /v1/transfers), normalised to integer centavos. Credentials (WISE_API_KEY,WISE_PROFILE_ID, optionalWISE_API_BASE) are read lazily, like the other integration adapters, so the app boots without them and a failure is surfaced rather than thrown. - Pure, tested matcher.
lib/wise/reconcile.tsmatches transfers → open payments by descending confidence: (1) transfer reference contains the payment id prefix; (2) same Wise recipient + amount within tolerance; (3) unique amount + pay-date-window fallback. Each payment/transfer is used at most once. It does no I/O and is exhaustively unit-tested. - Variance preservation. A linked transfer whose amount differs (beyond tolerance) is outcome
variance: it links the transfer but is never auto-completed — it staysprocessingfor the owner to resolve. Exact matches advance tocompleted(Wise sent) orprocessing. - No new table — snapshot in
settings. Matched payments are updated in place (the ADR-0021 payment-lock trigger still permits attachingwise_transfer_idand advancing status forward). The review picture (variances + orphan transfers + counts + last-run + any Wise error) is written as one jsonb row insettings(keywise_reconciliation) per company, read by the admin UI. This avoids a migration and keeps the orphan/variance list as a disposable, recomputed-each-run view. - Two entry points. A
cronroute (/api/cron/wise-reconcile,CRON_SHARED_SECRET, fails closed, system-wide) on a 2-hour pg_cron schedule, and an owner-only on-demand server action from the payroll screen.
Consequences
- The owner sees, on the payroll page, what reconciled, what has an amount variance, and which Wise transfers matched nothing — without the app ever moving money.
- Money stays integer PHP centavos end-to-end; the matcher is deterministic and tested.
- The snapshot is intentionally not historical: each run overwrites it. If an audit trail of reconciliation runs is later needed, promote the snapshot to its own table (a migration).
- Matching leans on the Wise transfer reference as the strongest key; the operational recommendation is to put the payment id prefix in the Wise reference when funding. Recipient- and amount-window matches are the fallbacks for transfers funded without it.