Skip to content

Payment batches (Phase 2): design

Status: decided under the owner’s “complete phase 2” instruction (2026-09-27); decisions D1–D14 below; compliance-sensitive ones are repeated under “For the owner to confirm”. Roadmap: Phase 2, “Payment batches → one bank file (DuitNow bulk / GIRO)”. Architecture: §19 Phase 2 table (“Select N payables → one batch → one bank file → one settlement”), §9.6 (“Bulk payment files — a channel may later declare an export profile”), §10 (ledger), §5 (workflow semantics), §11A (retention, records in Malaysia). Builds on: AP (docs/superpowers/specs/2026-09-26-accounts-payable-design.md, D4 supplier payment, D10 verified bank details) — code in internal/platform/payables. Migrations: 00330–00339 (this item uses 00330, 00331).

Today a supplier payment (supplier_payment, kind supplier_payment) goes draft → pending_approval → paid, and the one edge into paid is both the approval and the posting (PaymentPayoutRule: Dr Trade payables per allocation / Cr bank). The money itself moves outside the system: somebody keys each transfer into the bank’s portal. Paying twenty suppliers is twenty approvals and twenty manual transfers, and nothing ties what the bank paid to what the ledger says was paid.

A batch makes it: select N submitted payments from one bank account → a second person approves the batch → the system writes one bank file (hash stored) → someone uploads it to the bank → once the bank has paid, the batch is marked paid, and each payment moves into its payout state through the existing edge, so the existing posting rule posts it. No new journal-writing path.

In: batches of supplier payments; one pluggable bank-file format interface with one concrete format (generic_csv_v1); approval with segregation of duties; file generation, regeneration and download with the file’s bytes and SHA-256 kept; mark paid with per-payment bank failures; cancel; the web screens; one e2e.

Out, and why:

  • Expense-claim and advance payouts to employees (D2). Their payee details are sealed payout snapshots per channel (§9.4, payout.Snapshot, AES-GCM), some channels are petty cash or need a settlement proof, and their payout edge freezes a settlement first (expenses.Transition step 4). A later item adds expense_claim_id / advance_id to the batch line (the ap_allocation exactly-one-source pattern, AP Ruling P21). §19’s “paying 200 claims is one batch” is therefore not met by v1; it is recorded as a follow-up roadmap line.
  • Bank-specific formats (Maybank, CIMB, Public Bank, RHB …) — D5.
  • Host-to-host submission / bank APIs — the file is downloaded and uploaded by a person.
  • Bank acknowledgement / return-file import — failures are marked by hand at mark-paid (D8); reading the bank’s return file belongs with bank statement import.
  • One journal entry per batch (“one settlement” in §19) — each payment posts its own entry through PaymentPayoutRule (the brief’s “no new journal-writing path”). Each entry’s bank-line memo carries the batch reference, so the bank-reconciliation item can match the bank’s single bulk debit to the batch by reference (its spec’s “Dependencies” asks for exactly this).
  • Foreign-currency bank files, cross-border rails — v1 files are single-currency; the rails a generic file is meant for are domestic MYR (D5).
  • WHT — a batch pays each payment’s amount; if the WHT item makes a payment net of tax, the batch pays the net unchanged.

D1. A batch is its own record, not a workflow kind

Section titled “D1. A batch is its own record, not a workflow kind”

payment_batch has a string status ∈ {draft, approved, paid, cancelled} and explicit who/when columns (prepared_by, approved_by/at, paid_by/at, cancelled_by/at/reason), the shape of AP’s bank verification (D10), not a workflow instance.

Why: a workflow kind would need a seeded graph, a provisioning step for existing organizations and a way to tell “approved” from “paid” among non-posting states — and payment_batch’s code would end up comparing state names, which AP’s Global Constraints forbid. The engine’s real value here — grants, amount floors, self-approval refusal, the decision log — is already applied per payment (D3, D8), which is where authority over money lives. The batch’s own control is one rule (preparer ≠ approver), which a DB CHECK states exactly.

D2. v1 batches hold supplier payments only

Section titled “D2. v1 batches hold supplier payments only”

See Scope. The line table has a payment_id column, not a polymorphic source, so a later claims item adds a second nullable FK and an exactly-one CHECK without reshaping anything.

A payment is eligible when, in one read:

  • its workflow’s current state has exactly one edge into a payout-semantic state (found by semantic, never by state name — with the seeded graph that is pending_approval → paid);
  • it is paid from the batch’s bank account, in the batch’s currency;
  • its amount has at most two decimal places (bank rails carry sen; money.Amount carries four);
  • its supplier’s current bank details exist and are verified (D10 of AP);
  • it is in no other live batch (D4).

A batch has 1–500 payments, one bank account (an active bank-control account, as a payment’s own bank account is), one currency (the payments’), one value_date (the date the bank is asked to pay; ≥ today at create, edit, approve and regenerate) and one format. A draft is edited by replacing its payment list (the P16/P38 whole-shape PUT pattern), by its preparer only, under If-Match.

D4. A payment is in at most one live batch, and a batched payment moves only with its batch

Section titled “D4. A payment is in at most one live batch, and a batched payment moves only with its batch”
  • payment_batch_line.outcome is NULL (in the batch), paid, failed, removed or cancelled. A partial unique index (org_id, payment_id) WHERE outcome IS NULL OR outcome = 'paid' makes “at most one batch that has not let go of it” a database fact.
  • payables.TransitionPayment refuses (409 payment_in_batch) any move of a payment that has a line with outcome IS NULL — its individual approval, rejection, withdrawal — so the same money cannot be paid once by the file and again by hand. The batch’s own mark-paid is the only caller that moves it (through an unexported path).

D5. Bank files: one interface, one honest format

Section titled “D5. Bank files: one interface, one honest format”
type BankFileFormat interface {
Key() string // stored on the batch; a SQL CHECK lists the known keys
ContentType() string
Extension() string
Check(FileLine) error // what this layout cannot carry, refused before approval
Render(FileHeader, []FileLine) ([]byte, error)
}

Registered in a map in internal/platform/payables/bankfile.go; a drift test holds the registry and the payment_batch_format_known CHECK to the same set.

v1’s one format is generic_csv_v1, a layout this project defines and documents, not any bank’s. I could not find a Malaysian bank’s bulk-payment upload layout published openly; they are given to corporate customers under their cash-management agreements, and PayNet’s IBG / DuitNow specifications are for member banks, not for their customers’ files. Writing “the Maybank format” from memory would produce files that look right and are wrong — a rejected upload at best, a misrouted payment at worst. So v1 ships a documented generic file, and the first bank-specific format is added (as <bank>_<product>_v1) when a customer supplies the bank’s specification. Whether a given bank portal can import it through a column-mapping template is unverified.

generic_csv_v1: RFC 4180 CSV (Go encoding/csv, UseCRLF), UTF-8 without BOM, one header row, then one row per payment in line order. Columns, in order:

# Column Content
1 batch_reference PB-2026-0001
2 file_sequence 1, 2, … (D7)
3 line_no 1…
4 value_date YYYY-MM-DD
5 currency MYR
6 amount 1234.50 — two decimals, . separator, no grouping
7 beneficiary_name the supplier’s bank account name
8 beneficiary_bank_key my_banks registry key, e.g. maybank
9 beneficiary_bank_name registry name, e.g. Maybank
10 beneficiary_account_no digits, in full
11 beneficiary_id the supplier’s registration number (SSM/BRN), may be empty
12 beneficiary_email the supplier’s email, may be empty
13 payment_reference the payment’s reference, SP-2026-0042
14 payment_description the payment’s bank_reference, may be empty

Check refuses a text value that starts with =, +, -, @, tab or CR (spreadsheet formula injection — refused, never escaped, because escaping changes what the bank receives) or contains a control character, and an amount with more than two decimals. The file carries no debit account number: the chart of accounts does not store the company’s own account number, and bank portals choose the debit account at upload.

Bank identification is the registry key and name only. There is no BIC or bank code column, because the my_banks list itself is unverified (Plan 6 Ruling P34) and a bank format that needs codes will need its own mapping, reviewed against that bank’s list.

“DuitNow bulk / GIRO” in the roadmap line names the rails the file is for (Interbank GIRO and DuitNow account-number transfers, both domestic MYR); which rail a bank uses for an uploaded file is the bank portal’s choice, not a column in the generic file.

D6. The file is stored in Postgres with its hash

Section titled “D6. The file is stored in Postgres with its hash”

payment_batch_file keeps the bytes (BYTEA, ≤ 5 MiB; 500 rows of the generic CSV is ~100 KiB), byte_size, sha256 with CHECK (sha256 = sha256(content)) — the database itself refuses a hash that does not match — the control totals (payment_count, total_amount, value_date), who generated it, when, and why (required from the second file on). Append-only: REVOKE UPDATE, DELETE. Stored in Postgres, not the object store, so the file and the approval that produced it commit together, and the file stays with the rest of the records in Malaysia for seven years (§11A.1, §11A.2).

D7. Approval, and every file after it, is a second person’s act

Section titled “D7. Approval, and every file after it, is a second person’s act”
  • Approve (draft → approved) needs payables.batch.approve, If-Match, and an approver who is not the preparer (Go 403 batch_self_approval; DB payment_batch_approver_differs).
  • The approver must be able to approve every payment in it under that payment’s own workflow: for each line, workflow.AvailableTransitions(payment instance, approver) must allow the payout edge found in D3. So grants, amount floors, delegation and the engine’s self-approval refusal (the payment’s preparer) all apply exactly as if each payment were approved on its own. A refusal names the payment and the engine’s refusal code (409 payment_not_approvable).
  • In the same transaction: every eligibility rule of D3 is re-checked, each line snapshots the payee details (bank account name, registry entry id/key/name, account number, registration no., email, and the bank_verified_by/at that made them trustworthy), the batch becomes approved, and file 1 is rendered from the snapshots and stored.
  • Regenerate (a lost file, a portal that refused a re-used file name, a value date that has passed) writes file n+1 from the same snapshots, with a required reason and optionally a new value_date, by a member holding payables.batch.approve who is not the preparer. Files are numbered, never replaced; only the newest file of an approved batch can be downloaded (409 file_superseded for older ones), so a superseded file is not uploaded by mistake. Once paid or cancelled, every file can be downloaded, as a record.

D8. Marking paid posts through the existing payment path

Section titled “D8. Marking paid posts through the existing payment path”

Mark paid (approved → paid) needs payables.payment.transition and If-Match, and takes a list of payments the bank failed (each with a reason; at least one payment must remain paid — otherwise cancel). In one transaction, in lock order:

  1. batch FOR UPDATE; payments FOR UPDATE in id order;
  2. failed lines get outcome = 'failed': the payment stays in its pending state, free for a new batch, or to be rejected and revised (for example after its supplier fixes the bank details);
  3. for every other line, in payment-id order, the payment takes its payout edge as the member marking paid, through the same code TransitionPayment runs (allocations become effective, workflow.Transition authorizes against the payment’s grants and dispatches the payout, and PaymentPayoutRule builds the entry). The decision’s reason is Paid in batch PB-…;
  4. the lines get outcome = 'paid', the batch paid_by/at, then the batch’s audit rows, last.

The one difference from an individual payout: the bank-verification check reads the line’s snapshot (verified at approval), not the supplier’s current details. The money went to the snapshot’s account; a change to the supplier’s bank details after approval (which clears their verification) must not stop the ledger recording a payment the bank has already made. The engine still refuses a marker who is the payment’s preparer or holds no grant for it.

The posting date is the business date of the mark-paid transaction (clock.TodayUTC), as for every other posting; the file’s value_date is not the posting date (AP open decision, same question).

A draft is cancelled by its preparer (payables.batch.manage); an approved batch only by a member holding payables.batch.approve, with a required reason, because it frees payments that a file may already have paid. Every line gets outcome = 'cancelled'; the files stay. A paid batch is never cancelled: a wrong payment inside it is voided on its own (paid → voided, the existing reversal), which the batch no longer blocks (its line is paid, not live).

  • Reference PB-<Y>-NNNN from numbering.Allocate scope payment_batch, <Y> = fiscal.YearContaining(TodayUTC).EndYear, allocated at creation (AP Ruling P12’s pattern).
  • Every write is an audit_log row (payment_batch, before/after); each file generation is one, naming the file’s hash. No activity-feed rows (AP Ruling P33).
  • payment_batch and payment_batch_line are amendable (REVOKE DELETE), payment_batch_file append-only (REVOKE UPDATE, DELETE). Seven years.

payables.batch.read (list and view), payables.batch.manage (create, edit and cancel a draft; candidates; download), payables.batch.approve (approve, regenerate, cancel an approved batch). Mark paid reuses payables.payment.transition — it is the payment transition — with authority from each payment’s grants. Owner gets all three; Finance gets read and manage, not approve (the preparer/approver split mirrors AP’s verify-bank rule). New organizations through provisioning.RegisterRolePermissions; existing ones through migration 00331.

A line’s account number is masked on the wire (payables.MaskAccountNo) unless the caller holds payables.supplier.verify_bank, as supplier bank details are (AP Ruling P26). The downloaded file is necessarily in full; downloading needs payables.batch.manage.

If-Match revisions on every batch write; status is re-read under the row lock. A payment added to two draft batches concurrently fails on the unique index (409 payment_in_batch). Mark paid holds every batched payment’s row lock before any transition, and a batched payment cannot be moved individually (D4), so no second path contends for them. Every advisory lock a payout takes is class 1 (audit), already held after the first payment’s workflow.Transition; the Poster takes none (accounting Ruling R4) and org-tree reads are lock-free, so moving N payments in one transaction keeps the audit lock-order rule (internal/kernel/audit).

Sidebar accounting group gains Payment batches (/payables/batches, payables.batch.read). List (DataTable, tableId payables.batches); a New batch dialog (bank account, value date, the candidate payments as a checkbox table with the running count and total); a drawer with the header (status chip, preparer, approver, marker), lines (payment, supplier, masked account, amount, outcome), files (sequence, name, SHA-256, who/when/why, Download on the newest) and the actions the caller may take: Edit, Approve, Regenerate file, Mark paid (tick the payments the bank failed), Cancel. The payment drawer shows “In batch PB-…” when a payment is batched. en/ms/zh.

GET /payables/batches, GET /payables/batches/candidates, POST /payables/batches, GET|PUT /payables/batches/{id}, POST /payables/batches/{id}/approve, POST /payables/batches/{id}/files (regenerate), GET /payables/batches/{id}/files/{file_id}/content (the bytes, Content-Disposition: attachment, X-Content-SHA256 hex), POST /payables/batches/{id}/mark-paid, POST /payables/batches/{id}/cancel. The payment view gains batch (id, reference, status) or null. All in api/openapi.yaml first; money as strings.

  • Schema: approver = preparer refused (presence: a different approver accepted); a second live line for the same payment refused (presence: accepted after the first is released); a file whose sha256 does not match its bytes refused (presence: the matching hash accepted); the status-flow guard; no UPDATE/DELETE on files, no DELETE on batches and lines.
  • Format: a golden file pins generic_csv_v1’s bytes; formula-leading text and sub-sen amounts refused (presence: the plain value renders); the format registry equals the SQL CHECK’s list (watched failing with an unregistered key added to the CHECK).
  • Eligibility: each D3 rule refuses (each after its accepted case).
  • Approval: self-approval refused; an approver the payment’s grants do not cover refused (presence: the Owner accepted); snapshots frozen (the supplier’s bank changed after approval → file 1 and the line are unchanged).
  • Individual transition of a batched payment refused (presence: the same transition succeeds on an unbatched payment, and on this one after its line is released).
  • Mark paid: N payments posted through PaymentPayoutRule, Trade payables per supplier falls by their amounts and subledger_test’s control = sub-ledger still holds; a failed payment stays pending and can join a new batch; a replay is 409; a supplier bank change after approval does not block it.
  • Regenerate: file 2 recorded with its reason and a new hash; file 1 then refused for download.
  • Cancel: an approved batch needs approve permission and a reason; its payments are free again.
  • Routes pinned to permissions; e2e: submitted payment → batch → approve → download (hash shown = hash of bytes) → mark paid → payment Paid, supplier gone from AP aging.
  • WHT changes TransitionPayment and PaymentPayoutRule. A batch pays each payment’s amount (the net once WHT exists) and never creates payments; WHT’s move-time checks belong in the payment move that both the single and the batch path run (plan Ruling B17).
  • Bank reconciliation matches by reference; the payout’s bank-line memo gains PB-… (plan B15). Its banking.GetByGLAccount (the company’s own account number) is not used by generic_csv_v1; a bank-specific format may use it later (B18).
  • AR narrows AP’s migration range too; both edits to migrationranges_test.go are additive.
  • E2E lanes: scripts/dev-stack.sh, web/vite.config.ts, web/playwright.config.ts need port parameters; whichever Phase 2 item lands first owns that change (plan B16).
  1. Bank file format — v1 writes generic_csv_v1, a layout this project defines, because no Malaysian bank’s upload layout is openly published that I could find; whether your bank’s portal can import it by column mapping is unverified. Alternative: implement one named bank’s format — needs that bank’s specification from you; guessing it risks rejected or misrouted files. Needs outside authority: the customer’s bank (Maybank2u Biz, CIMB BizChannel, etc.) — obtain the bulk-payment (DuitNow bulk / IBG) upload specification and a test upload. Until then keep generic_csv_v1 and label it “not a bank format” in the download dialog.
  2. Bank identifiers — the file names the beneficiary bank by registry key and name only, no BIC / bank code (the seeded my_banks list is itself unverified, Plan 6 P34). A bank format that needs codes adds a reviewed mapping. Needs outside authority: the bank’s beneficiary-bank code table (PayNet participant codes / BIC), obtained with item 1’s spec. Add a reviewed mapping then.
  3. Employees not in v1 — claim and advance payouts are not batchable yet; §19’s “pay 200 claims in one batch” waits for a follow-up item (≈4–5 more tasks: sealed payout snapshots, channel rules, settlement freeze). Ruled (Phase 2 completion C4, 2026-09-28): batches hold claim and advance payouts, the employee’s details verified by a second person and sealed at approval (00450–00452).
  4. Segregation — preparer ≠ approver (DB-enforced), and the approver must be able to approve every payment under its own grants; a single-Owner organization cannot approve its own batch (same as AP’s open decision). Mark paid needs each payment’s grant too (by default the Owner). Confirmed (2026-09-29): the single-Owner case is handled by paying individually under the audited sole-owner exception (D1 X1).
  5. File tampering after download — the ERP cannot stop an edited CSV being uploaded; it shows count, total and SHA-256 so the bank portal’s authoriser (who must be a different person) can check them. Alternative: only approvers may download — safer, slower for the clerk. Confirmed (2026-09-29): the bank portal’s own maker/checker is the control; the ERP records who took which hash. Restricting download would only move the clerk’s upload to the approver.
  6. Regeneration — a new file needs an approver (not the preparer) and a reason, and only the newest file of an approved batch downloads. Uploading two files for one batch would still pay twice at the bank; the ERP cannot see that. Confirmed (2026-09-29): as designed.
  7. Cancelling an approved batch frees its payments for another batch; if the bank did execute the file, that is a double payment. Approver-only, reason required. Alternative: forbid it — a file the bank rejected would then strand its payments. Confirmed (2026-09-29): forbidding it would strand payments on a bank-rejected file; the reason and audit cover the double-pay risk.
  8. Bank details changed after approval do not block mark paid (the money went to the verified snapshot). Alternative: block — the ledger could then not record a payment the bank made. Confirmed (2026-09-29): the ledger must be able to record what the bank actually did.
  9. Posting date is the mark-paid date, not the file’s value date nor the bank’s execution date (the same cut-off question as AP’s approval-date posting). Changed (2026-09-29, D2): the posting date is now the payment’s cash date — the batch’s value date for a batched payout, not the mark-paid date — through O-12’s first-open-date rule; the auto-match window (O-6) keys on the value date too.
  10. The file (with full account numbers) is kept in Postgres for seven years (ITA s.82A), like the supplier master it is copied from; a future PDPA anonymization must include the line snapshots and file bytes once their retention expires. Confirmed (2026-09-29): meets ITA s.82A.