Skip to main content

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 fill amount and paymentReference; the rest pass through. The pure builder lives in src/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 designated wise_recipient_id + wise_email as the recipient identity. Migration 0025 adds 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.mjs seeds 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 gitignored data/).
  • Amounts are the exact PHP payout; source defaults to USD. amountCurrency is always target and amount is payments.amount_centavos / 100 (PHP, adjustments already folded in). sourceCurrency defaults to USD and targetCurrency to PHP, both overridable per download via ?source=&target= (validated against a USD/PHP allowlist).
  • Owner-only download, per pay run. GET /admin/payroll/[batchId]/wise-batch re-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_id is 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_detail is display-only — Wise authoritatively matches on recipientId, so a stale hint never misroutes a payment; worst case it reads oddly in the file.
  • receiverType is hard-coded PERSON (all current contractors are individuals). A business recipient would need a per-recipient column — deferred until one exists.
  • src/db/types.ts gains the new column; regenerate after further schema changes.

Update (2026-06-14)

  • Owner picks the funding currency at download. sourceCurrency is no longer fixed to USD: the download control (WiseBatchDownload) offers USD or PHP and passes it as ?source=. Target stays PHP and amount is 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) and contractors.wisetag (the @wisetag handle), as reference fields beside wise_recipient_id (the UUID the batch upload matches on). Nullable; not used by the batch file, which still keys on the UUID.