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).
The problem
Section titled “The problem”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.Transitionstep 4). A later item addsexpense_claim_id/advance_idto the batch line (theap_allocationexactly-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.
Decisions
Section titled “Decisions”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.
D3. What can go into a batch
Section titled “D3. What can go into a batch”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 ispending_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.Amountcarries 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.outcomeisNULL(in the batch),paid,failed,removedorcancelled. 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.TransitionPaymentrefuses (409payment_in_batch) any move of a payment that has a line withoutcome 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 403batch_self_approval; DBpayment_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 (409payment_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/atthat made them trustworthy), the batch becomesapproved, 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 holdingpayables.batch.approvewho is not the preparer. Files are numbered, never replaced; only the newest file of an approved batch can be downloaded (409file_supersededfor 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:
- batch
FOR UPDATE; paymentsFOR UPDATEin id order; - 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); - for every other line, in payment-id order, the payment takes its payout edge as the member
marking paid, through the same code
TransitionPaymentruns (allocations become effective,workflow.Transitionauthorizes against the payment’s grants and dispatches the payout, andPaymentPayoutRulebuilds the entry). The decision’s reason isPaid in batch PB-…; - the lines get
outcome = 'paid', the batchpaid_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).
D9. Cancel
Section titled “D9. Cancel”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).
D10. Numbering, audit, retention
Section titled “D10. Numbering, audit, retention”- Reference
PB-<Y>-NNNNfromnumbering.Allocatescopepayment_batch,<Y>=fiscal.YearContaining(TodayUTC).EndYear, allocated at creation (AP Ruling P12’s pattern). - Every write is an
audit_logrow (payment_batch, before/after); each file generation is one, naming the file’s hash. No activity-feed rows (AP Ruling P33). payment_batchandpayment_batch_lineare amendable (REVOKE DELETE),payment_batch_fileappend-only (REVOKE UPDATE, DELETE). Seven years.
D11. Permissions
Section titled “D11. Permissions”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.
D12. Concurrency
Section titled “D12. Concurrency”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).
D13. Web
Section titled “D13. Web”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.
D14. API
Section titled “D14. API”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.
Tests that must exist
Section titled “Tests that must exist”- 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
sha256does 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 andsubledger_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.
Dependencies on other Phase 2 items
Section titled “Dependencies on other Phase 2 items”- WHT changes
TransitionPaymentandPaymentPayoutRule. A batch pays each payment’samount(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). Itsbanking.GetByGLAccount(the company’s own account number) is not used bygeneric_csv_v1; a bank-specific format may use it later (B18). - AR narrows AP’s migration range too; both edits to
migrationranges_test.goare additive. - E2E lanes:
scripts/dev-stack.sh,web/vite.config.ts,web/playwright.config.tsneed port parameters; whichever Phase 2 item lands first owns that change (plan B16).
For the owner to confirm
Section titled “For the owner to confirm”- 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 keepgeneric_csv_v1and label it “not a bank format” in the download dialog. - Bank identifiers — the file names the beneficiary bank by registry key and name only, no
BIC / bank code (the seeded
my_bankslist 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. - 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). - 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.