30. Wise batch payment file
Date: 2026-06-14
Status
Accepted
Context
Contractors are paid exclusively through Wise, against recipients that already exist in the
owner's Wise account. Each bi-monthly pay run (payroll_batches + per-contractor payments, all in
PHP centavos) was funded by typing amounts into Wise by hand — slow and error-prone. Wise supports a
batch payment upload: you download a "Selected recipients" CSV, fill in amount and
paymentReference per row, and re-upload. Wise matches each row by recipientId (a recipient UUID);
the other columns are carried through.
The blockers were: (1) those recipient UUIDs lived only in the Wise export, not in our DB — the
existing contractors.wise_recipient_id column (migration 0002) was empty; and (2) the file must
match Wise's template exactly or it won't upload.
Decision
- Reproduce Wise's template verbatim, keyed by
recipientId. The generated CSV emits Wise's exact ten columns (recipientId, name, recipientEmail, recipientDetail, sourceCurrency, targetCurrency, amountCurrency, amount, paymentReference, receiverType). We fillamountandpaymentReference; the rest pass through. The pure builder lives insrc/lib/wise/batch.ts(testable, no I/O), mirroring the lib/service split used by Wise reconciliation. - Persist the recipient mapping on
contractors; do not invent a new home. Migration 0002 already designatedwise_recipient_id+wise_emailas the recipient identity. Migration0025adds the one missing field,wise_recipient_detail(the display-only "BPI ending ·· 6661" hint), so the file is faithful and reviewable.scripts/import-wise-recipients.mjsseeds all three from the Wise export, matching each row to an existing contractor by email then name (dry-run by default, idempotent, source CSV in the gitignoreddata/). - Amounts are the exact PHP payout; source defaults to USD.
amountCurrencyis alwaystargetandamountispayments.amount_centavos / 100(PHP, adjustments already folded in).sourceCurrencydefaults toUSDandtargetCurrencytoPHP, both overridable per download via?source=&target=(validated against a USD/PHP allowlist). - Owner-only download, per pay run.
GET /admin/payroll/[batchId]/wise-batchre-verifies the owner role (funding money, like prepare/close) and company-scopes the batch (404 otherwise), returning the CSV as an attachment — the same pattern as the/admin/reports/[report]exports. A "Download Wise batch" link sits on each prepared and funded run. - Never silently drop a payee. Wise rejects rows without a recipient id or with a non-positive
amount, so the builder excludes them and returns them in
skipped. The pay run surfaces this inline ("N of M ready to pay via Wise" plus a warning naming anyone missing a recipient) so the owner fixes it before funding rather than discovering an unpaid contractor later.
Consequences
- The owner downloads one CSV per pay run and uploads it to Wise — no manual amount entry.
- The app still never moves money: it prepares a file the owner uploads, preserving the read-only Wise posture of ADR-0028 (reconciliation). Creating transfers via the Wise API remains explicitly out of scope.
contractors.wise_recipient_idis now actually populated (it was dormant since 0002); a contractor onboarded without a Wise recipient shows up as a flagged gap on the pay run.wise_recipient_detailis display-only — Wise authoritatively matches onrecipientId, so a stale hint never misroutes a payment; worst case it reads oddly in the file.receiverTypeis hard-codedPERSON(all current contractors are individuals). A business recipient would need a per-recipient column — deferred until one exists.src/db/types.tsgains the new column; regenerate after further schema changes.
Update (2026-06-14)
- Owner picks the funding currency at download.
sourceCurrencyis no longer fixed to USD: the download control (WiseBatchDownload) offers USD or PHP and passes it as?source=. Target stays PHP andamountis still the exact PHP payout, so the choice only changes which balance Wise debits. - Extra Wise identifiers stored (migration
0026):contractors.wise_id(Wise short/numeric id) andcontractors.wisetag(the @wisetag handle), as reference fields besidewise_recipient_id(the UUID the batch upload matches on). Nullable; not used by the batch file, which still keys on the UUID.