Skip to content

Payment Batches Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A clerk selects submitted supplier payments paid from one bank account into a payment batch; a second person approves it, which freezes each payee’s verified bank details and writes one bank file whose bytes and SHA-256 are kept; the clerk downloads and uploads it to the bank; once the bank has paid, the batch is marked paid (payments the bank failed are released), and each payment moves through its own existing payout edge, so PaymentPayoutRule posts it. No new journal-writing path.

Architecture: Everything lives in the existing AP packages. internal/platform/payables gains bankfile.go (the BankFileFormat interface, its registry, generic_csv_v1) and batch.go (the batch record, eligibility, approve, regenerate, mark paid, cancel); transition.go gains the “a batched payment moves only with its batch” guard and splits its payout into an unexported path the batch reuses. internal/modules/payables gains batch_handlers.go and nine routes. Three tables in 00330, grants in 00331. The web gains web/src/features/payables/batches. A batch is not a workflow kind (spec D1): authority over money stays with each payment’s own workflow grants.

Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · kernel/money · React 19 · TanStack Router/Query/Table · i18next (en/ms/zh) · Vitest + MSW · Playwright

Spec: docs/superpowers/specs/2026-09-27-payment-batches-design.md (D1–D14)

Every task’s requirements implicitly include this section.

  • Migrations 00330–00339 only. This plan uses 00330, 00331. Task 1 narrows AP’s {300, 339, "Phase 2 AP"} in internal/kernel/db/migrationranges_test.go to {300, 309, "Phase 2 AP"} and adds {330, 339, "Phase 2 payment batches"} (if the AR item already split the line, add only the batches line).
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire (OpenAPI Money). Compare with Cmp. No float64 in any file this plan touches; web/src/drift/nofloat.test.ts scans the web. A bank file’s amount is Amount.String() with its two trailing zeros cut (Task 2), never a float format.
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, CONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and only composite (org_id, x) foreign keys (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Named constraints, CONSTRAINT <table>_<rule>, none ending _check/_key/_excl (TestEveryConstraintIsNamed). Trigger refusals RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name.
  • Pinned functions: SET search_path = pg_catalog, public, pg_temp, every table schema-qualified (TestEveryFunctionPinsItsSearchPath). No SECURITY DEFINER.
  • Every SQL statement names org_id = $n for each org-scoped table it touches, joins included; every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org from tenancy.OrgFromContext (orgOf); nothing takes an org argument.
  • Retention. payment_batch, payment_batch_line → amendable (REVOKE DELETE); payment_batch_file → appendOnly (REVOKE UPDATE, DELETE), in internal/kernel/db/retention_test.go.
  • Vocabulary values are strings: batch status, line outcome, file format are TEXT with named CHECKs and OpenAPI enums.
  • Accounting reacts to semantics; no new journal path. Nothing in this plan registers a posting rule or builds a ledger.Line. A payment’s payout edge is found by the target state’s semantic (posting.Payout), never by a state name; no Go or SQL here compares a state_key to a literal except tests.
  • Lock order, one for the whole plan: payment_batch row FOR UPDATE → supplier_payment rows FOR UPDATE in id order → party_supplier rows FOR SHARE (bank check) → invoices FOR UPDATE in id order (inside allocatePayment) → workflow.Transition → this plan’s audit.Write, last. The only advisory lock any of these takes is class 1 (audit) — the Poster takes none (accounting Ruling R4), org-tree reads are lock-free — so N payment transitions in one transaction keep internal/kernel/audit’s rule (Ruling B7).
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); handlers open transactions; every handler whose transaction can post passes db.InTx’s error through ledger.MapError before mapErr.
  • Business date is clock.TodayUTC(c); every DATE in Go is midnight UTC; never time.Now() in code under test.
  • Record-bearing text (cancel and failure reasons, regeneration reason, the payee snapshot) is English or Bahasa Malaysia: recordtext.Check in Go and a named CHECK (… !~ '[぀-ヿ㐀-䶿一-鿿가-힯]') in SQL, written out in full at every use.
  • Errors are RFC 7807. Platform code returns sentinels or typed errors; handlers map them in internal/modules/payables/errors.go. A field refusal is 422 field=<name> rule=<code>: <sentence>.
  • Every route goes through httpx.Protected with a payables.* permission, is in api/openapi.yaml in the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces), and is pinned in internal/modules/payables/routes_test.go (or wherever AP’s route/permission pins live). api/public_routes.txt gains nothing. make generate after every migration, OpenAPI or constants.yaml change; generated files are committed; git status clean afterwards.
  • i18n. Every UI string is a key in web/src/lib/i18n/locales/{en,ms,zh}/payables.json under batches.* (or nav.batches); web/src/drift/i18nparity.test.ts requires identical keys and placeholders. Malay is the Malay given in Task 8, never English copied.
  • Presence before absence (AGENTS.md). Every refusal test shows the accepted case first. Every new guard is watched failing once: each such task has a Sabotage step — break the guard, run the test, read the red message, restore, and record the message in the commit body.
  • Posting is process-global. A test that posts calls payablestest.Env.UsePayablesPoster(t); never t.Parallel().
  • Tests: payablestest.New (→ testdb.Start) per test; while iterating run only go test ./internal/platform/payables/... ./internal/modules/payables/... -count=1; one full go test -p 2 -count=1 ./... in Task 9 only.
  • E2E lane 2: Postgres 56422, MinIO 59120, API 3120, Vite 5193. Never the user’s dev stack (55432/59000/3000/5173).
  • Never bare git stash. Use a WIP commit.
  • Git commits end with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Where the spec is silent or cannot be built exactly as written. Ruling: <decision> — <why>.

  • B1 Ruling: Batch code lives in internal/platform/payables (not a new package) — it needs lockPayment, allocatePayment and the payout path unexported, and a second package would force exporting them.
  • B2 Ruling: TransitionPayment keeps its signature. Its body becomes transitionPayment(ctx, tx, c, in, snap *bankSnapshot); the public function first refuses a payment with a live batch line (ErrPaymentInBatch) and then calls it with snap == nil (today’s supplier-row bank check). Mark paid calls it with the line’s snapshot, which replaces the supplier-row check (spec D8). The function returns the before/after it would audit instead of auditing, so the batch can write every audit row after every transition; TransitionPayment audits right after, exactly as today.
  • B3 Ruling: A live line is outcome IS NULL. The “at most one batch” index also covers outcome = 'paid', so a paid payment can never be re-batched; after a batch is paid its payments are no longer “in a batch” for the guard (their line is not live), so voiding one works as today.
  • B4 Ruling: The batch’s bank account and format are fixed at creation; a draft edit (PUT) changes value_date and the payment list only. The currency is the bank account’s currency, or — for an account with no currency — the first payment’s, and every payment must match it.
  • B5 Ruling: The file’s line_no column is the row’s position (1…n) in payment_batch_line.line_no order; line numbers left by removed lines are not renumbered in the table.
  • B6 Ruling: Candidates (GET /payables/batches/candidates?bank_account_id=) are payments of that bank account whose state’s semantic is pending and which have no live or paid line, filtered in Go by the full D3 check; capped at 500, ordered by created_at, id. Each candidate carries eligible and, when false, the rule that fails, so the dialog can show why a payment is greyed out.
  • B7 Ruling: Mark paid calls workflow.Transition once per payment inside one transaction. workflow.Transition’s caller constraint (“call Transition before any audit.Write”) exists for lock order; the second and later transitions acquire no advisory class that is not already held (audit class 1 from the first), so the rule’s purpose holds. The batch’s own audit rows and every payment’s audit row are written after the last transition.
  • B8 Ruling: Approval’s per-payment authority check is workflow.AvailableTransitions(instance, approver) containing the payout edge with Allowed == true. A refusal returns *NotApprovableError{PaymentID, Reference, Code} where Code is the engine’s refusal code (self_approval, not_in_band, …) — 409 payment_not_approvable.
  • B9 Ruling: value_date must be ≥ the business date at create, edit, approve and regenerate (422 value_date_past). No upper bound.
  • B10 Ruling: Download is GET …/files/{file_id}/content under payables.batch.manage, returning the stored bytes with Content-Type from the row, Content-Disposition: attachment; filename="<file_name>", X-Content-SHA256: <hex>. An approved batch serves only its newest file (409 file_superseded); a paid or cancelled batch serves any; a draft has none. The download is audited (action: "download", the file id and hash) — a bank file leaving the system is an event worth a row.
  • B11 Ruling: Cancel is one route under payables.batch.manage; for an approved batch the handler also requires payables.batch.approve (read with member.Permissions in the same transaction) and passes mayCancelApproved to the platform function; a draft is cancelled by its preparer only.
  • B12 Ruling: Mark paid runs under payables.payment.transition, the permission of the transition it performs; authority per payment comes from its grants (B8’s engine, re-run for real by workflow.Transition).
  • B13 Ruling: The payment view (GET /payables/payments/{id}) gains batch — {id, reference, status} of its live-or-paid line’s batch, or null. The payment list does not (no extra join on the list path).
  • B14 Ruling: Line views mask account_no with payables.MaskAccountNo unless the caller holds payables.supplier.verify_bank (AP Ruling P26). A draft line shows the supplier’s current masked details (no snapshot yet); an approved line its snapshot.
  • B15 Ruling: PaymentPayoutRule stays the only rule; its bank line’s memo gains the batch reference when the payment has a live batch line (SP-… <bank ref> PB-…), so the bank-reconciliation item’s reference matcher can pair one bulk bank debit with the batch’s entries (its spec, “Dependencies”). That is a memo, not a new journal path. No other reconciliation hook.
  • B17 Ruling (merge with the WHT item): WHT changes TransitionPayment (a reversing-edge guard) and PaymentPayoutRule (a withholding credit line). Whichever lands second puts WHT’s move-time checks inside the unexported transitionPayment (Ruling B2), so the batch path gets them too; a batch pays supplier_payment.amount (the net) and never creates payments, so it cannot bypass checkPayment.
  • B18 Ruling: The file carries no debit account number in v1. If the bank-reconciliation item’s banking.GetByGLAccount has landed when a bank-specific format is added, that format reads the company’s account number from it; generic_csv_v1 does not change.
  • B16 Ruling (e2e ports): scripts/dev-stack.sh, web/vite.config.ts and web/playwright.config.ts read STACK_NAME, PG_PORT, MINIO_PORT, API_PORT, WEB_PORT with today’s values as defaults, and keep .dev-stack state per stack name. If another Phase 2 item has already landed this on phase-2, use it and skip Task 9 Step 1.

File Task Responsibility
00330_payment_batch.sql 1 payment_batch, payment_batch_line, payment_batch_file, their guards, RLS, grants.
00331_payment_batch_grants.sql 6 Existing Owners get the three payables.batch.* keys; existing Finance roles read and manage.
Path Task Change
internal/kernel/db/migrationranges_test.go 1 Split AP’s range; add {330, 339, …}.
internal/kernel/db/retention_test.go 1 Three tables into their lists.
internal/platform/payables/batch_schema_test.go 1 Raw-SQL invariants of 00330.
internal/platform/payables/bankfile.go, bankfile_test.go, bankfile_drift_test.go, testdata/generic_csv_v1.golden.csv 2 Format interface, registry, generic_csv_v1, golden and drift tests.
internal/platform/payables/batch.go 3, 4, 5 Batch types, create/edit/list/get/candidates, approve, regenerate, file read, cancel, mark paid.
internal/platform/payables/transition.go 3, 5 Live-batch guard; transitionPayment split (Ruling B2).
internal/platform/payables/doc.go 3 EntityBatch, ScopeBatch, permission name constants the platform reads.
internal/platform/payables/batch_test.go 3, 4, 5 Behaviour tests.
internal/platform/payables/payablestest/env.go 3 SubmittedPaymentFor, Batch, ApproveBatch helpers.
constants.yaml (+ generated vocab) 6 Three permissions.
internal/modules/payables/permissions.go 6 Register the three; Finance gets read + manage.
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) 6 Nine operations, schemas, SupplierPaymentView.batch.
internal/modules/payables/batch_handlers.go, batch_handlers_test.go, routes.go, errors.go, payment_handlers.go 7 Handlers, routes, error mapping, payment view batch.
Path Task Change
web/src/features/payables/batches/{api.ts,BatchesPage.tsx,BatchDialog.tsx,BatchDrawer.tsx,MarkPaidDialog.tsx,download.ts} 8 The feature.
web/src/features/payables/batches/__tests__/BatchesPage.test.tsx 8 Vitest + MSW.
web/src/features/payables/routes.tsx, PaymentDrawer.tsx 8 Route; “In batch” chip.
web/src/app/shell/nav.ts 8 Nav item.
web/src/lib/i18n/locales/{en,ms,zh}/payables.json 8 nav.batches, batches.*.
scripts/dev-stack.sh, web/vite.config.ts, web/playwright.config.ts 9 Ruling B16 (if not already done).
web/e2e/support.ts, web/e2e/payment-batch.spec.ts 9 Finance’s batch permissions; the e2e.
docs/ROADMAP.md, docs/DEPLOY_NOTES.md 9 Tick the line; deploy note.

Wave Tasks Why together
1 1 Every later task needs the tables.
2 2, 3, 6 Disjoint files: bankfile*.go / batch.go+transition.go+payablestest / api, constants.yaml, 00331, permissions.go. Tasks 2 and 3 share the package: they use only the names in their Interfaces blocks.
3 4 Needs 2 (render) and 3 (batch record).
4 5 Needs 4 (approved batches).
5 7, 8 7 needs 5 and 6; 8 needs 6’s generated schema and mocks the API with MSW. Disjoint files.
6 9 Last: e2e, roadmap, deploy notes, full suite.

Task 1: Migration 00330, ranges, retention, schema tests

Section titled “Task 1: Migration 00330, ranges, retention, schema tests”

Files:

  • Create: db/migrations/00330_payment_batch.sql
  • Modify: internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go
  • Test: internal/platform/payables/batch_schema_test.go

Interfaces:

  • Consumes: supplier_payment, party_supplier, account, organization_member, payout_registry_entry.

  • Produces: tables payment_batch, payment_batch_line, payment_batch_file; constraint names payment_batch_approver_differs, payment_batch_status_flow, payment_batch_final, payment_batch_immutable, payment_batch_line_one_live (index), payment_batch_line_frozen, payment_batch_line_outcome_flow, payment_batch_file_hash_matches, payment_batch_file_batch_approved, payment_batch_format_known.

  • Step 1: Ranges and the failing schema test.

In migrationranges_test.go replace {300, 339, "Phase 2 AP"}, with:

{300, 309, "Phase 2 AP"},
{330, 339, "Phase 2 payment batches"},

Create internal/platform/payables/batch_schema_test.go (package payables_test; reuse requirePgError from schema_test.go). Each test runs as erp_app in e.Tx, inserting rows directly so the DB invariant is tested without the Go checks. Fixture: rawBatch(ctx, tx, org, preparer) inserts a draft payment_batch (bank account 1520, MYR, value date expensestest.Today); rawLine(ctx, tx, org, batch, payment) inserts a line; the payment comes from e.SubmittedPayment(t, e.Payment(...)) on a posted invoice (payablestest helpers).

Tests (each PRESENCE first, then ABSENCE):

  1. TestBatchApproverMustDifferFromPreparer — UPDATE … SET status='approved', approved_by=<Owner> on a batch prepared by Staff succeeds (roll back via savepoint); the same with approved_by=<Staff> fails 23514 payment_batch_approver_differs.
  2. TestAPaymentIsInOneLiveBatch — a second line for the same payment in another draft batch fails 23505 payment_batch_line_one_live; after setting the first line’s outcome='removed' (outcome_by/at set), the second insert succeeds.
  3. TestBatchFileHashMatchesItsBytes — on an approved batch, inserting content='a,b\r\n' with sha256=sha256('a,b\r\n'::bytea) succeeds; with sha256=sha256('x'::bytea) fails 23514 payment_batch_file_hash_matches.
  4. TestBatchStatusFlow — draft→approved→paid succeeds; paid→cancelled fails payment_batch_final; on a fresh draft, draft→paid fails payment_batch_status_flow.
  5. TestBatchLineFrozenOnceApproved — on an approved batch, inserting a line fails payment_batch_line_frozen; setting a live line’s outcome='removed' fails payment_batch_line_outcome_flow; setting outcome='failed' with a reason succeeds.
  6. TestBatchFileCannotBeChanged — UPDATE payment_batch_file SET file_name='x' and DELETE fail 42501 (permission denied) as erp_app; the same row is readable (presence).

Run: go test ./internal/platform/payables/ -run 'TestBatch|TestAPaymentIsInOneLiveBatch' -count=1 → FAIL (no such table).

  • Step 2: Write the migration.
-- +goose Up
-- PAYMENT BATCHES (spec 2026-09-27-payment-batches-design). A batch groups
-- submitted supplier payments paid from one bank account into one bank file.
-- It posts nothing: marking it paid moves each payment through its own payout
-- edge, and PaymentPayoutRule posts it (D8). Not a workflow kind (D1).
CREATE TABLE payment_batch (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
reference TEXT NOT NULL,
bank_account_id UUID NOT NULL,
currency TEXT NOT NULL CONSTRAINT payment_batch_currency_code CHECK (currency ~ '^[A-Z]{3}$'),
value_date DATE NOT NULL,
format TEXT NOT NULL CONSTRAINT payment_batch_format_known CHECK (format IN ('generic_csv_v1')),
status TEXT NOT NULL DEFAULT 'draft'
CONSTRAINT payment_batch_status_known CHECK (status IN ('draft','approved','paid','cancelled')),
payment_count INT NOT NULL DEFAULT 0 CONSTRAINT payment_batch_count_range CHECK (payment_count BETWEEN 0 AND 500),
total_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT payment_batch_total_nonnegative CHECK (total_amount >= 0),
prepared_by UUID NOT NULL,
approved_by UUID,
approved_at TIMESTAMPTZ,
paid_by UUID,
paid_at TIMESTAMPTZ,
cancelled_by UUID,
cancelled_at TIMESTAMPTZ,
cancel_reason TEXT,
revision INT NOT NULL DEFAULT 1 CONSTRAINT payment_batch_revision_positive CHECK (revision >= 1),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT payment_batch_tenant_id UNIQUE (org_id, id),
CONSTRAINT payment_batch_reference_once UNIQUE (org_id, reference),
-- D7: segregation of duties, stated where no code path can skip it.
CONSTRAINT payment_batch_approver_differs CHECK (approved_by IS NULL OR approved_by <> prepared_by),
CONSTRAINT payment_batch_status_fields CHECK (
(status = 'draft' AND approved_by IS NULL AND approved_at IS NULL AND paid_by IS NULL AND paid_at IS NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL)
OR (status = 'approved' AND approved_by IS NOT NULL AND approved_at IS NOT NULL AND paid_by IS NULL AND paid_at IS NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL)
OR (status = 'paid' AND approved_by IS NOT NULL AND approved_at IS NOT NULL AND paid_by IS NOT NULL AND paid_at IS NOT NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL)
OR (status = 'cancelled' AND paid_by IS NULL AND paid_at IS NULL AND cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL
AND btrim(coalesce(cancel_reason, '')) <> '')),
CONSTRAINT payment_batch_reason_language CHECK (coalesce(cancel_reason, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT payment_batch_bank_account_fk FOREIGN KEY (org_id, bank_account_id) REFERENCES account (org_id, id),
CONSTRAINT payment_batch_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id),
CONSTRAINT payment_batch_approved_by_fk FOREIGN KEY (org_id, approved_by) REFERENCES organization_member (org_id, id),
CONSTRAINT payment_batch_paid_by_fk FOREIGN KEY (org_id, paid_by) REFERENCES organization_member (org_id, id),
CONSTRAINT payment_batch_cancelled_by_fk FOREIGN KEY (org_id, cancelled_by) REFERENCES organization_member (org_id, id)
);
CREATE INDEX payment_batch_by_created ON payment_batch (org_id, created_at DESC, id DESC);
-- One payment in one batch. outcome NULL = in the batch (live). The payee
-- columns are the snapshot written at approval (D7): what the file was
-- rendered from and what the money went to.
CREATE TABLE payment_batch_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
batch_id UUID NOT NULL,
payment_id UUID NOT NULL,
line_no INT NOT NULL CONSTRAINT payment_batch_line_no_positive CHECK (line_no >= 1),
amount NUMERIC(19,4) NOT NULL CONSTRAINT payment_batch_line_amount_positive CHECK (amount > 0),
supplier_party_id UUID NOT NULL,
payee_name TEXT,
bank_entry_id UUID,
bank_key TEXT,
bank_name TEXT,
account_no TEXT CONSTRAINT payment_batch_line_account_no_shape CHECK (account_no IS NULL OR account_no ~ '^[0-9]{6,20}$'),
beneficiary_id TEXT,
beneficiary_email TEXT,
bank_verified_by UUID,
bank_verified_at TIMESTAMPTZ,
outcome TEXT CONSTRAINT payment_batch_line_outcome_known CHECK (outcome IN ('paid','failed','removed','cancelled')),
outcome_reason TEXT,
outcome_by UUID,
outcome_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT payment_batch_line_tenant_id UNIQUE (org_id, id),
CONSTRAINT payment_batch_line_no_once UNIQUE (org_id, batch_id, line_no),
CONSTRAINT payment_batch_line_snapshot_complete CHECK (
num_nulls(payee_name, bank_entry_id, bank_key, bank_name, account_no, bank_verified_by, bank_verified_at) IN (0, 7)),
CONSTRAINT payment_batch_line_settled_has_snapshot CHECK (outcome NOT IN ('paid','failed') OR account_no IS NOT NULL),
CONSTRAINT payment_batch_line_outcome_fields CHECK (
(outcome IS NULL) = (outcome_by IS NULL) AND (outcome IS NULL) = (outcome_at IS NULL)
AND (outcome IS DISTINCT FROM 'failed' OR btrim(coalesce(outcome_reason, '')) <> '')),
CONSTRAINT payment_batch_line_text_language CHECK (
coalesce(payee_name, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]' AND coalesce(outcome_reason, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT payment_batch_line_batch_fk FOREIGN KEY (org_id, batch_id) REFERENCES payment_batch (org_id, id),
CONSTRAINT payment_batch_line_payment_fk FOREIGN KEY (org_id, payment_id) REFERENCES supplier_payment (org_id, id),
CONSTRAINT payment_batch_line_supplier_fk FOREIGN KEY (org_id, supplier_party_id) REFERENCES party_supplier (org_id, party_id),
CONSTRAINT payment_batch_line_bank_entry_fk FOREIGN KEY (org_id, bank_entry_id) REFERENCES payout_registry_entry (org_id, id),
CONSTRAINT payment_batch_line_verified_by_fk FOREIGN KEY (org_id, bank_verified_by) REFERENCES organization_member (org_id, id),
CONSTRAINT payment_batch_line_outcome_by_fk FOREIGN KEY (org_id, outcome_by) REFERENCES organization_member (org_id, id)
);
-- D4: at most one batch that has not let go of a payment (Ruling B3).
CREATE UNIQUE INDEX payment_batch_line_one_live ON payment_batch_line (org_id, payment_id)
WHERE outcome IS NULL OR outcome = 'paid';
CREATE INDEX payment_batch_line_by_batch ON payment_batch_line (org_id, batch_id, line_no);
-- Every file a batch produced (D6, D7). Append-only; the database refuses a
-- hash that does not match the bytes.
CREATE TABLE payment_batch_file (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
batch_id UUID NOT NULL,
seq INT NOT NULL CONSTRAINT payment_batch_file_seq_positive CHECK (seq >= 1),
format TEXT NOT NULL CONSTRAINT payment_batch_file_format_known CHECK (format IN ('generic_csv_v1')),
file_name TEXT NOT NULL CONSTRAINT payment_batch_file_name_shape CHECK (file_name ~ '^[A-Za-z0-9._-]{1,100}$'),
content_type TEXT NOT NULL,
content BYTEA NOT NULL CONSTRAINT payment_batch_file_size CHECK (octet_length(content) BETWEEN 1 AND 5242880),
byte_size INT NOT NULL,
sha256 BYTEA NOT NULL,
payment_count INT NOT NULL CONSTRAINT payment_batch_file_count_positive CHECK (payment_count >= 1),
total_amount NUMERIC(19,4) NOT NULL CONSTRAINT payment_batch_file_total_positive CHECK (total_amount > 0),
value_date DATE NOT NULL,
reason TEXT,
generated_by UUID NOT NULL,
generated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT payment_batch_file_tenant_id UNIQUE (org_id, id),
CONSTRAINT payment_batch_file_seq_once UNIQUE (org_id, batch_id, seq),
CONSTRAINT payment_batch_file_hash_matches CHECK (sha256 = sha256(content)),
CONSTRAINT payment_batch_file_size_matches CHECK (byte_size = octet_length(content)),
-- The first file comes with the approval; every later one says why (D7).
CONSTRAINT payment_batch_file_reason_after_first CHECK ((seq = 1) = (reason IS NULL)
AND (reason IS NULL OR btrim(reason) <> '')),
CONSTRAINT payment_batch_file_reason_language CHECK (coalesce(reason, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT payment_batch_file_batch_fk FOREIGN KEY (org_id, batch_id) REFERENCES payment_batch (org_id, id),
CONSTRAINT payment_batch_file_generated_by_fk FOREIGN KEY (org_id, generated_by) REFERENCES organization_member (org_id, id)
);

sha256(bytea) is a pg_catalog built-in (Postgres 11+); a CHECK is not a function body, so the pinned-search-path rule does not apply to it.

-- +goose StatementBegin
-- The batch's own state machine (D3, D7–D9). A paid or cancelled batch is
-- final; identity columns never change; once approved, what was approved
-- never changes (regeneration may move value_date only).
CREATE FUNCTION payment_batch_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF OLD.status IN ('paid','cancelled') THEN
RAISE EXCEPTION 'payment batch % is %', OLD.id, OLD.status
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_final';
END IF;
IF (OLD.status, NEW.status) NOT IN (('draft','draft'),('draft','approved'),('draft','cancelled'),
('approved','approved'),('approved','paid'),('approved','cancelled')) THEN
RAISE EXCEPTION 'payment batch %: % -> % is not a move', OLD.id, OLD.status, NEW.status
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_status_flow';
END IF;
IF (NEW.id, NEW.org_id, NEW.reference, NEW.prepared_by, NEW.created_at, NEW.format, NEW.bank_account_id, NEW.currency)
IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.reference, OLD.prepared_by, OLD.created_at, OLD.format, OLD.bank_account_id, OLD.currency)
OR (OLD.status = 'approved' AND (NEW.payment_count, NEW.total_amount, NEW.approved_by, NEW.approved_at)
IS DISTINCT FROM (OLD.payment_count, OLD.total_amount, OLD.approved_by, OLD.approved_at)) THEN
RAISE EXCEPTION 'payment batch %: an approved or identifying column changed', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_immutable';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER payment_batch_guard BEFORE UPDATE ON payment_batch
FOR EACH ROW EXECUTE FUNCTION payment_batch_guard();
-- +goose StatementBegin
-- Lines join and leave only a draft; the snapshot is written once, while the
-- batch is still a draft (the approval writes it just before flipping the
-- status); an outcome is written once, and only the outcomes the batch's
-- status allows.
CREATE FUNCTION payment_batch_line_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE s TEXT;
BEGIN
SELECT b.status INTO s FROM public.payment_batch b WHERE b.org_id = NEW.org_id AND b.id = NEW.batch_id;
IF TG_OP = 'INSERT' THEN
IF s IS DISTINCT FROM 'draft' OR NEW.outcome IS NOT NULL OR NEW.account_no IS NOT NULL THEN
RAISE EXCEPTION 'payment batch %: lines join only a draft, empty', NEW.batch_id
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_frozen';
END IF;
RETURN NEW;
END IF;
IF (NEW.id, NEW.org_id, NEW.batch_id, NEW.payment_id, NEW.line_no, NEW.amount, NEW.supplier_party_id, NEW.created_at)
IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.batch_id, OLD.payment_id, OLD.line_no, OLD.amount, OLD.supplier_party_id, OLD.created_at)
OR OLD.outcome IS NOT NULL
OR (OLD.account_no IS NOT NULL AND (NEW.payee_name, NEW.bank_entry_id, NEW.bank_key, NEW.bank_name, NEW.account_no,
NEW.beneficiary_id, NEW.beneficiary_email, NEW.bank_verified_by, NEW.bank_verified_at)
IS DISTINCT FROM (OLD.payee_name, OLD.bank_entry_id, OLD.bank_key, OLD.bank_name, OLD.account_no,
OLD.beneficiary_id, OLD.beneficiary_email, OLD.bank_verified_by, OLD.bank_verified_at))
OR (OLD.account_no IS NULL AND NEW.account_no IS NOT NULL AND s <> 'draft') THEN
RAISE EXCEPTION 'payment batch line %: frozen', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_frozen';
END IF;
IF NEW.outcome IS NOT NULL AND NOT (
(NEW.outcome = 'removed' AND s = 'draft')
OR (NEW.outcome = 'cancelled' AND s IN ('draft','approved'))
OR (NEW.outcome IN ('paid','failed') AND s = 'approved')) THEN
RAISE EXCEPTION 'payment batch line %: % while the batch is %', OLD.id, NEW.outcome, s
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_outcome_flow';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER payment_batch_line_guard BEFORE INSERT OR UPDATE ON payment_batch_line
FOR EACH ROW EXECUTE FUNCTION payment_batch_line_guard();
-- +goose StatementBegin
CREATE FUNCTION payment_batch_file_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM public.payment_batch b
WHERE b.org_id = NEW.org_id AND b.id = NEW.batch_id AND b.status = 'approved') THEN
RAISE EXCEPTION 'payment batch %: files are written for an approved batch only', NEW.batch_id
USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_file_batch_approved';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER payment_batch_file_guard BEFORE INSERT ON payment_batch_file
FOR EACH ROW EXECUTE FUNCTION payment_batch_file_guard();
ALTER TABLE payment_batch ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_batch FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON payment_batch
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE payment_batch_line ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_batch_line FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON payment_batch_line
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE payment_batch_file ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_batch_file FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON payment_batch_file
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- erp_app's SELECT/INSERT/UPDATE/DELETE come from default privileges
-- (00015's note); retention takes them away (00304's pattern).
REVOKE DELETE ON payment_batch, payment_batch_line FROM erp_app;
REVOKE UPDATE, DELETE ON payment_batch_file FROM erp_app;
-- +goose Down
DROP TABLE payment_batch_file;
DROP TABLE payment_batch_line;
DROP TABLE payment_batch;
DROP FUNCTION payment_batch_file_guard();
DROP FUNCTION payment_batch_line_guard();
DROP FUNCTION payment_batch_guard();

In retention_test.go add payment_batch_file to appendOnly and payment_batch, payment_batch_line to amendable.

  • Step 3: Run. go test ./internal/platform/payables/ -run 'TestBatch|TestAPaymentIsInOneLiveBatch' -count=1 → PASS; go test ./internal/kernel/db/... -count=1 → PASS (RLS coverage, named constraints, composite FKs, pinned functions, retention, ranges). make generate → clean git status apart from the new files.

  • Step 4: Sabotage. Drop payment_batch_approver_differs from the migration, reset the test DB, run test 1 → red (want *pgconn.PgError, got <nil>). Change the index predicate to WHERE outcome IS NULL only, run a test that inserts a second line after outcome='paid' (add it as test 2b: a paid line blocks a new one) → red. Restore both. Record both messages in the commit body.

  • Step 5: Commit Payment batches: tables, guards, retention (00330).


Task 2: The bank-file format interface and generic_csv_v1

Section titled “Task 2: The bank-file format interface and generic_csv_v1”

Files:

  • Create: internal/platform/payables/bankfile.go
  • Test: internal/platform/payables/bankfile_test.go, internal/platform/payables/bankfile_drift_test.go, internal/platform/payables/testdata/generic_csv_v1.golden.csv

Interfaces:

  • Consumes: money.Amount, recordtext.

  • Produces: type BankFileFormat interface, type FileHeader, type FileLine, func Format(key string) (BankFileFormat, bool), func FormatKeys() []string, const FormatGenericCSV = "generic_csv_v1", type FileLineError{LineNo int; Field, Rule string}, func sen(a money.Amount) (string, bool).

  • Step 1: Failing tests.

bankfile_test.go (package payables, internal — it tests sen too):

func TestGenericCSVMatchesTheGoldenFile(t *testing.T) {
f, ok := Format(FormatGenericCSV)
require.True(t, ok)
h := FileHeader{BatchReference: "PB-2026-0001", Sequence: 1, ValueDate: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Currency: "MYR"}
lines := []FileLine{
{Amount: money.MustParse("1250.50"), PayeeName: "Bekalan Maju Sdn Bhd", BankKey: "maybank", BankName: "Maybank",
AccountNo: "514012345678", BeneficiaryID: "201901000123", BeneficiaryEmail: "akaun@bekalanmaju.my",
PaymentReference: "SP-2026-0042", Description: "Invois INV-77, INV-78"},
{Amount: money.MustParse("99.00"), PayeeName: `Kedai "Ah Seng"`, BankKey: "public_bank", BankName: "Public Bank",
AccountNo: "3123456789", PaymentReference: "SP-2026-0043"},
}
for i, l := range lines {
require.NoError(t, f.Check(l), "line %d", i+1)
}
got, err := f.Render(h, lines)
require.NoError(t, err)
want, err := os.ReadFile("testdata/generic_csv_v1.golden.csv")
require.NoError(t, err)
require.Equal(t, string(want), string(got)) // byte-for-byte: CRLF, quoting, no BOM
}
func TestGenericCSVRefusesWhatABankFileCannotCarry(t *testing.T) {
f, _ := Format(FormatGenericCSV)
ok := FileLine{Amount: money.MustParse("10.00"), PayeeName: "Syarikat A", BankKey: "cimb", BankName: "CIMB Bank",
AccountNo: "8001234567", PaymentReference: "SP-2026-0001"}
require.NoError(t, f.Check(ok)) // PRESENCE
for name, mut := range map[string]func(*FileLine){
"formula": func(l *FileLine) { l.PayeeName = "=HYPERLINK(\"x\")" },
"plus": func(l *FileLine) { l.Description = "+60123" },
"at": func(l *FileLine) { l.PayeeName = "@x" },
"control": func(l *FileLine) { l.Description = "a\x07b" },
"sub_sen": func(l *FileLine) { l.Amount = money.MustParse("10.005") },
"no_amount": func(l *FileLine) { l.Amount = money.Zero() },
} {
l := ok
mut(&l)
require.Error(t, f.Check(l), name)
}
}
func TestSen(t *testing.T) {
s, ok := sen(money.MustParse("1250.5"))
require.True(t, ok)
require.Equal(t, "1250.50", s)
_, ok = sen(money.MustParse("0.001"))
require.False(t, ok)
}

The golden file (testdata/generic_csv_v1.golden.csv, written with CRLF line endings, no trailing blank line beyond the last CRLF):

batch_reference,file_sequence,line_no,value_date,currency,amount,beneficiary_name,beneficiary_bank_key,beneficiary_bank_name,beneficiary_account_no,beneficiary_id,beneficiary_email,payment_reference,payment_description
PB-2026-0001,1,1,2026-10-01,MYR,1250.50,Bekalan Maju Sdn Bhd,maybank,Maybank,514012345678,201901000123,akaun@bekalanmaju.my,SP-2026-0042,"Invois INV-77, INV-78"
PB-2026-0001,1,2,2026-10-01,MYR,99.00,"Kedai ""Ah Seng""",public_bank,Public Bank,3123456789,,,SP-2026-0043,

Run: go test ./internal/platform/payables/ -run 'TestGenericCSV|TestSen' -count=1 → FAIL (undefined).

  • Step 2: Implement bankfile.go.
package payables
import (
"bytes"
"encoding/csv"
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/mcom/erp/internal/kernel/money"
)
// BankFileFormat is one bank-file layout (spec D5). A format is pure: it
// sees the frozen snapshot and writes bytes; it reads nothing and never
// changes once shipped — a changed layout is a new key, because a stored
// file's hash is evidence of what was sent.
type BankFileFormat interface {
Key() string // stored on the batch and file; payment_batch_format_known lists them
ContentType() string
Extension() string
// Check refuses what this layout cannot carry. Called for every line
// before approval, so a batch never approves into a file it cannot write.
Check(FileLine) error
Render(FileHeader, []FileLine) ([]byte, error)
}
// FileHeader is what a file says once.
type FileHeader struct {
BatchReference string
Sequence int
ValueDate time.Time
Currency string
}
// FileLine is one payment, from its line's snapshot (never live rows).
type FileLine struct {
Amount money.Amount
PayeeName, BankKey, BankName, AccountNo string
BeneficiaryID, BeneficiaryEmail string
PaymentReference, Description string
}
type FileLineError struct {
LineNo int // 0 from Check; the renderer's caller fills it
Field, Rule string
}
func (e *FileLineError) Error() string {
return fmt.Sprintf("payables: bank file line %d: %s (%s)", e.LineNo, e.Field, e.Rule)
}
var formats = map[string]BankFileFormat{}
func registerFormat(f BankFileFormat) { formats[f.Key()] = f }
func init() { registerFormat(genericCSV{}) }
func Format(key string) (BankFileFormat, bool) { f, ok := formats[key]; return f, ok }
func FormatKeys() []string {
out := make([]string, 0, len(formats))
for k := range formats {
out = append(out, k)
}
sort.Strings(out)
return out
}
// sen renders a to two decimals, refusing anything finer: bank rails carry
// sen, money.Amount carries four places. String() is fixed at Scale (4).
func sen(a money.Amount) (string, bool) {
s := a.String()
if !strings.HasSuffix(s, "00") {
return "", false
}
return s[:len(s)-2], true
}
const FormatGenericCSV = "generic_csv_v1"
// genericCSV is this project's own documented layout (spec D5), not a
// bank's: RFC 4180, CRLF, UTF-8 without BOM, one header row.
type genericCSV struct{}
func (genericCSV) Key() string { return FormatGenericCSV }
func (genericCSV) ContentType() string { return "text/csv; charset=utf-8" }
func (genericCSV) Extension() string { return "csv" }
var genericCSVHeader = []string{"batch_reference", "file_sequence", "line_no", "value_date", "currency", "amount",
"beneficiary_name", "beneficiary_bank_key", "beneficiary_bank_name", "beneficiary_account_no", "beneficiary_id",
"beneficiary_email", "payment_reference", "payment_description"}
// safeText refuses spreadsheet-formula leads and control characters rather
// than escaping them: escaping would change what the bank receives.
func safeText(field, v string) error {
if v != "" && strings.ContainsRune("=+-@\t\r", rune(v[0])) {
return &FileLineError{Field: field, Rule: "formula_lead"}
}
for _, r := range v {
if unicode.IsControl(r) {
return &FileLineError{Field: field, Rule: "control_character"}
}
}
return nil
}
func (genericCSV) Check(l FileLine) error {
if _, ok := sen(l.Amount); !ok || !l.Amount.IsPositive() {
return &FileLineError{Field: "amount", Rule: "positive_sen"}
}
for _, f := range []struct{ name, v string }{
{"beneficiary_name", l.PayeeName}, {"beneficiary_bank_key", l.BankKey}, {"beneficiary_bank_name", l.BankName},
{"beneficiary_account_no", l.AccountNo}, {"beneficiary_id", l.BeneficiaryID}, {"beneficiary_email", l.BeneficiaryEmail},
{"payment_reference", l.PaymentReference}, {"payment_description", l.Description},
} {
if err := safeText(f.name, f.v); err != nil {
return err
}
}
return nil
}
func (g genericCSV) Render(h FileHeader, lines []FileLine) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
w.UseCRLF = true
if err := w.Write(genericCSVHeader); err != nil {
return nil, err
}
for i, l := range lines {
if err := g.Check(l); err != nil {
err.(*FileLineError).LineNo = i + 1
return nil, err
}
amt, _ := sen(l.Amount)
if err := w.Write([]string{h.BatchReference, strconv.Itoa(h.Sequence), strconv.Itoa(i + 1),
h.ValueDate.Format("2006-01-02"), h.Currency, amt, l.PayeeName, l.BankKey, l.BankName, l.AccountNo,
l.BeneficiaryID, l.BeneficiaryEmail, l.PaymentReference, l.Description}); err != nil {
return nil, err
}
}
w.Flush()
return buf.Bytes(), w.Error()
}

(Check returns only *FileLineError, so the type assertion in Render is safe; if a future check returns another type, change Render to errors.As.)

  • Step 3: The format drift test (needs Task 1’s table), in bankfile_drift_test.go (package payables_test):
// The formats Go can render and the formats the database accepts are one
// set. Presence first: both sides are non-empty.
func TestEveryStoredFormatIsRenderableAndBack(t *testing.T) {
e := payablestest.New(t)
var defs []string
rows, err := e.H.Admin.Query(context.Background(), `
SELECT pg_get_constraintdef(c.oid) FROM pg_constraint c
WHERE c.conname IN ('payment_batch_format_known', 'payment_batch_file_format_known')`)
require.NoError(t, err)
for rows.Next() {
var d string
require.NoError(t, rows.Scan(&d))
defs = append(defs, d)
}
require.NoError(t, rows.Err())
require.Len(t, defs, 2, "both format CHECKs must exist")
re := regexp.MustCompile(`'([a-z0-9_]+)'`)
for _, d := range defs {
var sql []string
for _, m := range re.FindAllStringSubmatch(d, -1) {
sql = append(sql, m[1])
}
sort.Strings(sql)
require.NotEmpty(t, sql, "no keys parsed from %s", d)
require.Equal(t, payables.FormatKeys(), sql, "Go formats vs %s", d)
}
}
  • Step 4: Run go test ./internal/platform/payables/ -run 'TestGenericCSV|TestSen|TestEveryStoredFormat' -count=1 → PASS.

  • Step 5: Sabotage. (a) Add 'maybank_m2e_v1' to payment_batch_format_known in 00330 (fresh DB) → TestEveryStoredFormatIsRenderableAndBack red with the diff; restore. (b) Remove w.UseCRLF = true → golden test red; restore. Record messages.

  • Step 6: Commit Payment batches: bank-file format interface and generic_csv_v1.


Task 3: The batch record — create, edit, list, get, candidates; the live-batch guard

Section titled “Task 3: The batch record — create, edit, list, get, candidates; the live-batch guard”

Files:

  • Create: internal/platform/payables/batch.go, internal/platform/payables/batch_test.go
  • Modify: internal/platform/payables/transition.go, internal/platform/payables/doc.go, internal/platform/payables/payablestest/env.go

Interfaces:

  • Consumes: lockPayment, GetPayment, GetSupplier, lockSupplier, chart.GetAccount, workflow.Get, workflow.Load, numbering.Allocate/Format, fiscal.YearContaining, payout.GetRegistryEntry, Format (Task 2 — only Format(key) and FileLine).
  • Produces (all in package payables):
const (
EntityBatch = "payment_batch" // audit entity kind
ScopeBatch = "payment_batch" // numbering scope; prefix "PB"
MaxBatchPayments = 500
BatchDraft, BatchApproved, BatchPaid, BatchCancelled = "draft", "approved", "paid", "cancelled"
OutcomePaid, OutcomeFailed, OutcomeRemoved, OutcomeCancelled = "paid", "failed", "removed", "cancelled"
)
var (
ErrPaymentInBatch = errors.New("payables: the payment is in a payment batch; it moves with the batch")
ErrBatchNotDraft = errors.New("payables: the batch is no longer a draft")
ErrBatchNotApproved = errors.New("payables: the batch is not approved")
ErrBatchSize = errors.New("payables: a batch holds 1 to 500 payments")
ErrBatchFinal = errors.New("payables: the batch is paid or cancelled")
)
type IneligibleError struct{ PaymentID uuid.UUID; Reference, Rule string }
type Batch struct {
ID, BankAccountID, PreparedBy uuid.UUID
Reference, Currency, Format, Status string
ValueDate time.Time
PaymentCount int
TotalAmount money.Amount
ApprovedBy, PaidBy, CancelledBy *uuid.UUID
ApprovedAt, PaidAt, CancelledAt *time.Time
CancelReason *string
Revision int
CreatedAt, UpdatedAt time.Time
}
type BatchLine struct {
ID, PaymentID, SupplierID uuid.UUID
LineNo int
Amount money.Amount
Snapshot *BankSnapshot // nil until approval
Outcome, OutcomeReason *string
OutcomeBy *uuid.UUID
OutcomeAt *time.Time
}
type BankSnapshot struct {
PayeeName, BankKey, BankName, AccountNo string
BankEntryID, VerifiedBy uuid.UUID
VerifiedAt time.Time
BeneficiaryID, BeneficiaryEmail string
}
type BatchInput struct {
BankAccountID uuid.UUID // ignored by UpdateBatch (Ruling B4)
ValueDate time.Time
Format string // "" means FormatGenericCSV; ignored by UpdateBatch
PaymentIDs []uuid.UUID
}
type BatchFilter struct{ Status string; Limit int; After *Cursor }
type Candidate struct{ Payment Payment; Eligible bool; Rule string }
type BatchRef struct{ ID uuid.UUID; Reference, Status string }
func CreateBatch(ctx, tx, c clock.Clock, actor uuid.UUID, in BatchInput) (Batch, error)
func UpdateBatch(ctx, tx, c, id, actor uuid.UUID, expectedRevision int, in BatchInput) (Batch, error)
func GetBatch(ctx, tx, id uuid.UUID) (Batch, error)
func BatchLines(ctx, tx, batchID uuid.UUID) ([]BatchLine, error) // every line, line_no order
func ListBatches(ctx, tx, f BatchFilter) ([]Batch, *Cursor, error)
func BatchCandidates(ctx, tx, c, bankAccountID uuid.UUID) ([]Candidate, error)
func BatchOfPayment(ctx, tx, paymentID uuid.UUID) (*BatchRef, error) // live or paid line's batch
  • Step 1: Test helpers. In payablestest/env.go add:
// ReadyPayment is a submitted (pending_approval) payment of amount MYR to a
// fresh verified supplier, against a posted invoice of the same amount.
func (e *Env) ReadyPayment(t *testing.T, code, amount string) payables.Payment {
t.Helper()
s := e.VerifiedSupplier(t, code)
inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, code+"-INV", Line(e.Account(t, "6140"), amount)))
return e.SubmittedPayment(t, e.Payment(t, s, amount, nil, payables.PaymentLine{InvoiceID: inv.ID, Amount: money.MustParse(amount)}))
}
// Batch is a draft batch prepared by e.Staff from bank account 1520.
func (e *Env) Batch(t *testing.T, ps ...payables.Payment) payables.Batch {
t.Helper()
bank := e.Account(t, "1520")
var ids []uuid.UUID
for _, p := range ps {
ids = append(ids, p.ID)
}
var b payables.Batch
require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
var err error
b, err = payables.CreateBatch(ctx, tx, e.Clock, e.Staff, payables.BatchInput{BankAccountID: bank,
ValueDate: expensestest.Today, PaymentIDs: ids})
return err
}))
return b
}

(If e.Post needs the posting env, ReadyPayment callers call e.UsePayablesPoster(t) first, as AP’s tests do. Use the account code AP’s tests use for an expense line if 6140 is not it.)

  • Step 2: Failing tests in batch_test.go (package payables_test):
  1. TestCreateBatchTakesEligiblePayments — two ReadyPayments → e.Batch → status draft, PaymentCount 2, TotalAmount the sum, reference PB-<Y>-0001, two lines with nil snapshot and nil outcome; an audit row payment_batch/create exists.
  2. TestBatchRefusesIneligiblePayments — table-driven, each case first shows the accepted payment, then the refused one with its IneligibleError.Rule:
    • not_payable_now: a draft payment (not submitted);
    • other_bank_account: a submitted payment from another bank account (create one; the MY chart has a second bank account or add one via chart in the test);
    • other_currency: a USD payment into a MYR batch (use ForeignInvoice + a USD payment; the bank account must accept USD — use a currency-less bank account for this case and a MYR-first payment so the batch is MYR);
    • sub_sen: a payment of 10.005;
    • bank_unverified: change the supplier’s bank after submission (payables.SetBank by Staff) so verification clears;
    • in_other_batch: a payment already in another draft batch (→ ErrPaymentInBatch, from the unique index mapped in Go: catch 23505 on payment_batch_line_one_live).
  3. TestBatchSizeIsOneTo500 — empty list → ErrBatchSize (presence: one payment accepted). (Do not build 501 payments; the CHECK payment_batch_count_range and a unit-level length check cover the upper bound — assert the Go check with a 501-element slice of the same id, which is refused before any lookup.)
  4. TestUpdateBatchReplacesItsPayments — UpdateBatch with [p2, p3] on a batch of [p1, p2]: p1’s line gets outcome = removed, p3 joins with the next line_no, count/total recomputed, revision bumped; wrong revision → *RevisionMismatchError; Owner (not preparer) → ErrNotPreparer.
  5. TestABatchedPaymentMovesOnlyWithItsBatch — PRESENCE: an unbatched submitted payment is approved by Owner via TransitionPayment (e.ApprovePayment). ABSENCE: a batched payment’s TransitionPayment (approve, and reject) → ErrPaymentInBatch. PRESENCE again: after UpdateBatch removes it, e.ApprovePayment succeeds.
  6. TestBatchCandidates — returns a ready payment as eligible and a bank-unverified one with Eligible false, Rule "bank_unverified"; a payment already batched is absent.
  7. TestValueDateCannotBePast — ValueDate = yesterday → FieldError{Field: "value_date", Rule: "value_date_past"} (presence: today accepted).

Run go test ./internal/platform/payables/ -run 'Batch' -count=1 → FAIL.

  • Step 3: The guard in transition.go.

At the top of TransitionPayment, after lockPayment:

live, err := hasLiveBatchLine(ctx, tx, p.ID)
if err != nil {
return Payment{}, workflow.Decision{}, err
}
if live {
return Payment{}, workflow.Decision{}, ErrPaymentInBatch // spec D4
}

with, in batch.go:

func hasLiveBatchLine(ctx context.Context, tx pgx.Tx, paymentID uuid.UUID) (bool, error) {
org, err := orgOf(ctx)
if err != nil {
return false, err
}
var live bool
err = tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payment_batch_line
WHERE org_id = $1 AND payment_id = $2 AND outcome IS NULL)`, org, paymentID).Scan(&live)
return live, err
}

(The split into transitionPayment is Task 5; this task only adds the guard.)

  • Step 4: Implement create/edit/list/get/candidates in batch.go.

The non-obvious parts:

// payoutEdge is the payment's one exit into a payout-semantic state (spec
// D3), found by semantic so an administrator's renamed or re-shaped payment
// workflow still batches — or, with zero or two such exits, does not.
func payoutEdge(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID) (from, to string, ok bool, err error) {
inst, err := workflow.Get(ctx, tx, instanceID)
if err != nil {
return "", "", false, err
}
def, err := workflow.Load(ctx, tx, inst.DefinitionID)
if err != nil {
return "", "", false, err
}
var hits []string
for _, e := range def.EdgesFrom(inst.StateKey) {
if s, found := def.State(e.To); found && s.Semantic == posting.Payout {
hits = append(hits, e.To)
}
}
if len(hits) != 1 {
return inst.StateKey, "", false, nil
}
return inst.StateKey, hits[0], true, nil
}
// eligible is spec D3 for one payment, locked by the caller. It returns the
// failing rule, or "" when the payment may be batched. The live-batch rule
// is left to the unique index (and to candidates' own SQL).
func eligible(ctx context.Context, tx pgx.Tx, p Payment, bankAccountID uuid.UUID, currency string) (string, error) {
if _, _, ok, err := payoutEdge(ctx, tx, p.InstanceID); err != nil || !ok {
return "not_payable_now", err
}
switch {
case p.BankAccountID != bankAccountID:
return "other_bank_account", nil
case p.Currency != currency:
return "other_currency", nil
}
if _, ok := sen(p.Amount); !ok {
return "sub_sen", nil
}
sup, err := lockSupplier(ctx, tx, p.SupplierID, "SHARE")
if err != nil {
return "", err
}
if !sup.BankVerified() {
return "bank_unverified", nil
}
return "", nil
}

CreateBatch: orgOf; normalise Format ("" → FormatGenericCSV; unknown → fieldErr("format","format_known",…)); len(PaymentIDs) in 1..MaxBatchPayments and no duplicates else ErrBatchSize / fieldErr("payment_ids","once",…); ValueDate ≥ clock.TodayUTC(c) else fieldErr("value_date","value_date_past","the bank cannot pay on a past date"); bank account via chart.GetAccount — active, postable, control kind bank, else ErrBankAccount; currency per Ruling B4; allocate numbering.Allocate(ctx, tx, ScopeBatch, year.EndYear) → numbering.Format("PB", …); INSERT payment_batch; then writeBatchLines; then audit.Write(EntityBatch, "create", After: batch+lines), last.

writeBatchLines(ctx, tx, c, b Batch, actor, ids []uuid.UUID): sort ids by string; for each id lockPayment (id order — Global lock order), eligible (→ &IneligibleError{p.ID, p.Reference, rule} on a rule); mark live lines whose payment is not in ids outcome='removed', outcome_by=actor, outcome_at=now; insert lines for new ids with line_no = max(line_no)+1…, amount = p.Amount, supplier_party_id = p.SupplierID; map a 23505 on payment_batch_line_one_live to ErrPaymentInBatch; recompute payment_count, total_amount from live lines with one UPDATE payment_batch … revision = revision + 1, updated_at = now.

UpdateBatch: lockBatch (SELECT … FOR UPDATE); ErrNotPreparer unless actor == PreparedBy; revision check; ErrBatchNotDraft unless draft; value-date check; update value_date; writeBatchLines; audit last (Before/After).

BatchCandidates:

rows, err := tx.Query(ctx, paymentSelect+`
AND p.bank_account_id = $2 AND s.semantic = 'pending'
AND NOT EXISTS (SELECT 1 FROM payment_batch_line l
WHERE l.org_id = $1 AND l.payment_id = p.id AND (l.outcome IS NULL OR l.outcome = 'paid'))
ORDER BY p.created_at, p.id LIMIT 500`, org, bankAccountID)

then for each, eligible without row locks (use GetSupplier instead of lockSupplier — add a lock bool parameter to eligible, or a second tiny wrapper; candidates must not lock 500 suppliers). The currency for candidates is the bank account’s, or empty (then each candidate’s own currency is accepted; the dialog restricts the selection to one currency).

BatchOfPayment: one query joining payment_batch_line l (outcome NULL or paid) to payment_batch b, both org_id = $1.

ListBatches: keyset on (created_at DESC, id DESC) exactly like ListPayments, optional status.

  • Step 5: Run the Step 2 tests → PASS; go test ./internal/platform/payables/... -count=1 → PASS (AP’s own tests unaffected: no AP test batches a payment).

  • Step 6: Sabotage. Comment out the live guard in TransitionPayment → TestABatchedPaymentMovesOnlyWithItsBatch red at the ABSENCE step; restore. Make eligible skip the bank check → the bank_unverified case red; restore. Record.

  • Step 7: Commit Payment batches: batch record, eligibility, and a batched payment moves only with its batch.


Task 4: Approve, regenerate, read a file, cancel

Section titled “Task 4: Approve, regenerate, read a file, cancel”

Files:

  • Modify: internal/platform/payables/batch.go, internal/platform/payables/batch_test.go, internal/platform/payables/payablestest/env.go

Interfaces:

  • Consumes: Task 2 (Format, FileHeader, FileLine), Task 3 (everything), workflow.AvailableTransitions, payout.GetRegistryEntry.
  • Produces:
var (
ErrBatchSelfApproval = errors.New("payables: the member who prepared the batch cannot approve it or release its files")
ErrFileSuperseded = errors.New("payables: a newer file replaces this one")
ErrNoFile = errors.New("payables: a draft batch has no file")
ErrCancelNeedsApprover = errors.New("payables: cancelling an approved batch needs payables.batch.approve")
)
type NotApprovableError struct{ PaymentID uuid.UUID; Reference, Code string }
type BatchFile struct {
ID uuid.UUID; Seq int; Format, FileName, ContentType string
ByteSize int; SHA256 []byte; PaymentCount int; TotalAmount money.Amount
ValueDate time.Time; Reason *string; GeneratedBy uuid.UUID; GeneratedAt time.Time
}
func ApproveBatch(ctx, tx, c, id, actor uuid.UUID, expectedRevision int) (Batch, BatchFile, error)
func RegenerateBatchFile(ctx, tx, c, id, actor uuid.UUID, expectedRevision int, reason string, valueDate *time.Time) (Batch, BatchFile, error)
func BatchFiles(ctx, tx, batchID uuid.UUID) ([]BatchFile, error) // metadata, seq order
func BatchFileContent(ctx, tx, c, batchID, fileID, actor uuid.UUID) (BatchFile, []byte, error) // B10 rules; audits "download"
func CancelBatch(ctx, tx, c, id, actor uuid.UUID, expectedRevision int, reason string, mayCancelApproved bool) (Batch, error)
// payablestest: (e *Env) ApprovedBatch(t, ps ...payables.Payment) payables.Batch — e.Batch then ApproveBatch as e.Owner.
  • Step 1: Failing tests.
  1. TestApproveBatchWritesFileOne — Owner approves Staff’s batch of two: status approved, approved_by = Owner, both lines carry a snapshot equal to the supplier’s bank details at that moment (AccountNo, BankKey, VerifiedBy = Owner), one BatchFile with Seq 1, Reason nil, PaymentCount 2, TotalAmount = sum, SHA256 == sha256.Sum256(content); BatchFileContent returns bytes that parse as CSV with 3 rows. Audit payment_batch/approve names the hash (hex).
  2. TestTheBatchPreparerCannotApprove — PRESENCE: Owner approves (in a savepoint-free second batch). ABSENCE: Staff approving its own → ErrBatchSelfApproval.
  3. TestTheApproverMustBeAbleToApproveEveryPayment — a third member clerk, _ := e.Member(t, "Clerk", &e.OwnerSeat) (no grant on paid) → *NotApprovableError{Code: workflow.RefuseNotInBand or the engine's code for "no grant"}; assert only that Code != "" and Reference names the payment. PRESENCE: Owner approves the same batch.
  4. TestApprovalRechecksEligibility — change one supplier’s bank after the draft was built → approve → *IneligibleError{Rule: "bank_unverified"}; nothing written (status still draft, no file).
  5. TestSnapshotIsFrozenAfterApproval — approve; then Staff changes the supplier’s bank (SetBank) — the line’s snapshot and file 1’s bytes are unchanged (PRESENCE of the change: GetSupplier shows the new number and BankVerified() == false).
  6. TestRegenerateRecordsFileTwo — RegenerateBatchFile(reason "Portal refused a repeated file name") by Owner → file Seq 2 with the reason, different hash (the file_sequence column differs), same count/total; BatchFileContent(file1) → ErrFileSuperseded, (file2) → bytes (PRESENCE). Staff regenerating → ErrBatchSelfApproval; empty reason → FieldError{reason, required}.
  7. TestCancelApprovedBatchFreesItsPayments — CancelBatch(approved, mayCancelApproved=false) → ErrCancelNeedsApprover; with true and a reason → status cancelled, lines cancelled; then the payment can join a new batch (PRESENCE of freedom) and file 1 of the cancelled batch downloads (record retrieval). A draft cancelled by Owner (not preparer) → ErrNotPreparer; by Staff → ok.
  • Step 2: Implement. The approval, in lock order:
func ApproveBatch(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int) (Batch, BatchFile, error) {
org, err := orgOf(ctx)
if err != nil {
return Batch{}, BatchFile{}, err
}
before, err := lockBatch(ctx, tx, id)
switch {
case err != nil:
return Batch{}, BatchFile{}, err
case before.Revision != expectedRevision:
return Batch{}, BatchFile{}, &RevisionMismatchError{Current: before.Revision}
case before.Status != BatchDraft:
return Batch{}, BatchFile{}, ErrBatchNotDraft
case before.PreparedBy == actor:
return Batch{}, BatchFile{}, ErrBatchSelfApproval
case before.ValueDate.Before(clock.TodayUTC(c)):
return Batch{}, BatchFile{}, fieldErr("value_date", "value_date_past", "the value date has passed; edit the batch")
}
lines, err := liveLines(ctx, tx, id) // outcome IS NULL, ordered by payment_id (lock order)
if err != nil {
return Batch{}, BatchFile{}, err
}
if len(lines) == 0 {
return Batch{}, BatchFile{}, ErrBatchSize
}
f, _ := Format(before.Format)
now := c.Now()
for _, l := range lines {
p, err := lockPayment(ctx, tx, l.PaymentID)
if err != nil {
return Batch{}, BatchFile{}, err
}
if rule, err := eligible(ctx, tx, p, before.BankAccountID, before.Currency); err != nil {
return Batch{}, BatchFile{}, err
} else if rule != "" {
return Batch{}, BatchFile{}, &IneligibleError{PaymentID: p.ID, Reference: p.Reference, Rule: rule}
}
// B8: the approver approves every payment under its own workflow.
_, to, _, err := payoutEdge(ctx, tx, p.InstanceID)
if err != nil {
return Batch{}, BatchFile{}, err
}
avail, err := workflow.AvailableTransitions(ctx, tx, c, p.InstanceID, actor)
if err != nil {
return Batch{}, BatchFile{}, err
}
code := "no_edge"
for _, a := range avail {
if a.To == to {
if a.Allowed {
code = ""
} else if a.Refusal != nil {
code = a.Refusal.Code
}
}
}
if code != "" {
return Batch{}, BatchFile{}, &NotApprovableError{PaymentID: p.ID, Reference: p.Reference, Code: code}
}
snap, err := snapshotOf(ctx, tx, p.SupplierID) // supplier (already FOR SHARE) + registry entry
if err != nil {
return Batch{}, BatchFile{}, err
}
if err := f.Check(fileLine(p, snap)); err != nil {
return Batch{}, BatchFile{}, &IneligibleError{PaymentID: p.ID, Reference: p.Reference, Rule: err.(*FileLineError).Rule}
}
if _, err := tx.Exec(ctx, `
UPDATE payment_batch_line SET payee_name = $3, bank_entry_id = $4, bank_key = $5, bank_name = $6, account_no = $7,
beneficiary_id = $8, beneficiary_email = $9, bank_verified_by = $10, bank_verified_at = $11
WHERE org_id = $1 AND id = $2`, org, l.ID, snap.PayeeName, snap.BankEntryID, snap.BankKey, snap.BankName,
snap.AccountNo, nullable(snap.BeneficiaryID), nullable(snap.BeneficiaryEmail), snap.VerifiedBy, snap.VerifiedAt); err != nil {
return Batch{}, BatchFile{}, fmt.Errorf("payables: snapshot line: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE payment_batch SET status = 'approved', approved_by = $3, approved_at = $4,
revision = revision + 1, updated_at = $4 WHERE org_id = $1 AND id = $2`, org, id, actor, now); err != nil {
return Batch{}, BatchFile{}, mapBatchWrite(err)
}
after, err := GetBatch(ctx, tx, id)
if err != nil {
return Batch{}, BatchFile{}, err
}
file, err := writeBatchFile(ctx, tx, c, after, actor, nil) // renders from the stored snapshots, seq = max+1
if err != nil {
return Batch{}, BatchFile{}, err
}
return after, file, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityBatch, EntityID: id, Action: "approve",
Before: before, After: map[string]any{"batch": after, "file_id": file.ID, "sha256": hex.EncodeToString(file.SHA256)}})
}

writeBatchFile(ctx, tx, c, b Batch, actor, reason *string): read live lines in line_no order (Ruling B5) with their snapshots; build []FileLine (PaymentReference = the payment’s Reference, Description = its BankReference); seq = coalesce(max(seq),0)+1; Render(FileHeader{b.Reference, seq, b.ValueDate, b.Currency}, lines); sum := sha256.Sum256(content); file_name = fmt.Sprintf("%s-%d.%s", b.Reference, seq, f.Extension()); INSERT payment_batch_file (…) with payment_count, total_amount, value_date, reason. No audit inside (callers audit last).

RegenerateBatchFile: lockBatch; revision; ErrBatchNotApproved unless approved; ErrBatchSelfApproval if actor == PreparedBy; reason trimmed, non-empty, recordtext.Check (422 reason required / record_language); an optional new valueDate ≥ today → UPDATE payment_batch SET value_date, revision+1; writeBatchFile(…, &reason); audit regenerate with seq and hash, last.

BatchFileContent: GetBatch (no lock); draft → ErrNoFile; the file row must belong to the batch (ErrNotFound); approved and not the max seq → ErrFileSuperseded; read content; audit download (After: {file_id, seq, sha256}); return. The handler writes the bytes.

CancelBatch: lockBatch; revision; draft → only the preparer (ErrNotPreparer); approved → mayCancelApproved else ErrCancelNeedsApprover, and a reason is required; paid/cancelled → ErrBatchFinal; lines outcome='cancelled', outcome_reason=reason, outcome_by, outcome_at; batch status='cancelled'; audit last.

  • Step 3: Run go test ./internal/platform/payables/ -run 'Batch|Approve|Regenerate|Cancel|Snapshot' -count=1 → PASS.

  • Step 4: Sabotage. Remove the AvailableTransitions check → TestTheApproverMustBeAbleToApproveEveryPayment red; remove the superseded check → TestRegenerateRecordsFileTwo red. Restore. Record.

  • Step 5: Commit Payment batches: approval with per-payment authority, frozen snapshots, files, cancel.


Task 5: Mark paid through the existing payment payout

Section titled “Task 5: Mark paid through the existing payment payout”

Files:

  • Modify: internal/platform/payables/transition.go, internal/platform/payables/posting.go (memo only, Ruling B15), internal/platform/payables/batch.go, internal/platform/payables/batch_test.go, internal/platform/payables/subledger_test.go

Interfaces:

  • Consumes: allocatePayment, reversePaymentAllocations, workflow.Transition, Task 4.
  • Produces:
var ErrNothingPaid = errors.New("payables: at least one payment must be paid; cancel the batch instead")
type FailedPayment struct{ PaymentID uuid.UUID; Reason string }
func MarkBatchPaid(ctx, tx, c, id, actor uuid.UUID, expectedRevision int, failed []FailedPayment) (Batch, error)
// unexported, transition.go:
type paymentMove struct{ before, after Payment; decision workflow.Decision }
func transitionPayment(ctx, tx, c, in TransitionInput, snap *BankSnapshot) (paymentMove, error)
  • Step 1: Failing tests.
  1. TestMarkPaidPostsEachPaymentThroughItsPayout — e.UsePayablesPoster(t); three ready payments from two suppliers; e.ApprovedBatch; ControlBalanceByParty before; MarkBatchPaid by Owner with no failures → batch paid, paid_by = Owner; every line outcome = paid; each payment’s state semantic payout; e.EntryFor(t, p.InstanceID, "payout") exists for each (the entry is PaymentPayoutRule’s: Dr Trade payables with the supplier’s party, Cr 1520); each supplier’s Trade payables balance fell by its payments; the decision’s reason contains the batch reference.
  2. TestMarkPaidReleasesWhatTheBankFailed — mark paid with failed = [{p2, "Account closed"}] → p2’s line failed with the reason, p2 still pending semantic and no payout entry, p1 paid; then p2 goes into a new batch (PRESENCE of release). All failed → ErrNothingPaid; a failed id not in the batch → ErrNotFound; empty reason → FieldError{failed[0].reason, required}.
  3. TestMarkPaidIsNotReplayable — a second MarkBatchPaid with the old revision → *RevisionMismatchError; with the new one → ErrBatchFinal; the payout entries are still one per payment.
  4. TestMarkPaidUsesTheApprovedSnapshotNotTodaysBank — approve; Staff changes a supplier’s bank (verification cleared). PRESENCE: an unbatched payment to the same supplier now fails individual approval with ErrBankUnverified. Then MarkBatchPaid succeeds for the batched one.
  5. TestMarkPaidNeedsEachPaymentsAuthority — the Clerk (no grant) marks paid → the engine’s *workflow.RefusedError, nothing moved (PRESENCE: Owner then succeeds).
  6. In subledger_test.go, extend the mixed sequence with a batch of two payments marked paid, so “control = sub-ledger per supplier” covers the batch path.
  • Step 2: Split TransitionPayment (Ruling B2).
// TransitionPayment moves a payment on its own. A payment with a live batch
// line moves only with its batch (spec D4).
func TransitionPayment(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Payment, workflow.Decision, error) {
// Lock first, then check: a batch builder locks the payment before
// inserting its line, so under this lock the answer cannot change.
if _, err := lockPayment(ctx, tx, in.ID); err != nil {
return Payment{}, workflow.Decision{}, err
}
if live, err := hasLiveBatchLine(ctx, tx, in.ID); err != nil || live {
if err == nil {
err = ErrPaymentInBatch
}
return Payment{}, workflow.Decision{}, err
}
m, err := transitionPayment(ctx, tx, c, in, nil)
if err != nil {
return Payment{}, workflow.Decision{}, err
}
return m.after, m.decision, auditMove(ctx, tx, c, m)
}
// transitionPayment is the move itself, without the audit row, so a batch
// can move many payments before writing any audit (Ruling B7). snap, when
// set, is the verified bank snapshot the money went to (spec D8); nil reads
// the supplier's current details under FOR SHARE, as a single payment does.
func transitionPayment(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput, snap *BankSnapshot) (paymentMove, error) {
p, err := lockPayment(ctx, tx, in.ID)
if err != nil {
return paymentMove{}, err
}
_, to, err := destination(ctx, tx, p.InstanceID, in.From, in.To)
if err != nil {
return paymentMove{}, err
}
switch {
case to.Semantic == posting.Payout:
if snap == nil {
sup, err := lockSupplier(ctx, tx, p.SupplierID, "SHARE")
if err != nil {
return paymentMove{}, err
}
if !sup.BankVerified() {
return paymentMove{}, ErrBankUnverified // Ruling P27
}
}
if err := allocatePayment(ctx, tx, c, p, in.Actor); err != nil {
return paymentMove{}, err
}
case to.Semantic == posting.Cancelled || to.Semantic == posting.Rejected:
if err := reversePaymentAllocations(ctx, tx, c, p, in.Actor); err != nil {
return paymentMove{}, err
}
}
d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: p.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason})
if err != nil {
return paymentMove{}, err
}
after, err := GetPayment(ctx, tx, p.ID)
if err != nil {
return paymentMove{}, err
}
return paymentMove{before: p, after: after, decision: d}, nil
}
func auditMove(ctx context.Context, tx pgx.Tx, c clock.Clock, m paymentMove) error {
return audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPayment, EntityID: m.before.ID, Action: "transition",
Before: map[string]string{"state": m.before.State}, After: map[string]string{"state": m.after.State}})
}

transitionPayment locks the payment again; a second FOR UPDATE of a row the transaction already holds is a no-op.

  • Step 3: MarkBatchPaid.
func MarkBatchPaid(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, failed []FailedPayment) (Batch, error) {
org, err := orgOf(ctx)
if err != nil {
return Batch{}, err
}
before, err := lockBatch(ctx, tx, id)
switch {
case err != nil:
return Batch{}, err
case before.Revision != expectedRevision:
return Batch{}, &RevisionMismatchError{Current: before.Revision}
case before.Status == BatchPaid || before.Status == BatchCancelled:
return Batch{}, ErrBatchFinal
case before.Status != BatchApproved:
return Batch{}, ErrBatchNotApproved
}
lines, err := liveLines(ctx, tx, id) // payment_id order
if err != nil {
return Batch{}, err
}
failedBy := map[uuid.UUID]string{}
for i, f := range failed {
r := strings.TrimSpace(f.Reason)
if r == "" || recordtext.Check("reason", r) != nil {
return Batch{}, fieldErr(fmt.Sprintf("failed[%d].reason", i), "required", "say why the bank did not pay it, in English or Bahasa Malaysia")
}
failedBy[f.PaymentID] = r
}
var paying []BatchLine
for _, l := range lines {
if _, bad := failedBy[l.PaymentID]; !bad {
paying = append(paying, l)
}
}
if len(failedBy) != len(lines)-len(paying) {
return Batch{}, ErrNotFound // a failed id that is not a live line of this batch
}
if len(paying) == 0 {
return Batch{}, ErrNothingPaid
}
now := c.Now()
for _, l := range lines { // lock every batched payment before any transition (lock order)
if _, err := lockPayment(ctx, tx, l.PaymentID); err != nil {
return Batch{}, err
}
}
var moves []paymentMove
for _, l := range paying {
p, err := GetPayment(ctx, tx, l.PaymentID)
if err != nil {
return Batch{}, err
}
from, to, ok, err := payoutEdge(ctx, tx, p.InstanceID)
if err != nil {
return Batch{}, err
}
if !ok {
return Batch{}, &IneligibleError{PaymentID: p.ID, Reference: p.Reference, Rule: "not_payable_now"}
}
m, err := transitionPayment(ctx, tx, c, TransitionInput{ID: p.ID, From: from, To: to, Actor: actor,
Reason: "Paid in batch " + before.Reference}, l.Snapshot)
if err != nil {
return Batch{}, err
}
if m.decision.Pending { // a sign-off step recorded but did not move: not a payment
return Batch{}, &NotApprovableError{PaymentID: p.ID, Reference: p.Reference, Code: "signoff_pending"}
}
moves = append(moves, m)
}
if _, err := tx.Exec(ctx, `
UPDATE payment_batch_line SET outcome = CASE WHEN payment_id = ANY($3) THEN 'failed' ELSE 'paid' END,
outcome_reason = ($4::jsonb ->> payment_id::text), outcome_by = $5, outcome_at = $6
WHERE org_id = $1 AND batch_id = $2 AND outcome IS NULL`,
org, id, keys(failedBy), reasonsJSON(failedBy), actor, now); err != nil {
return Batch{}, mapBatchWrite(err)
}
if _, err := tx.Exec(ctx, `UPDATE payment_batch SET status = 'paid', paid_by = $3, paid_at = $4,
revision = revision + 1, updated_at = $4 WHERE org_id = $1 AND id = $2`, org, id, actor, now); err != nil {
return Batch{}, mapBatchWrite(err)
}
after, err := GetBatch(ctx, tx, id)
if err != nil {
return Batch{}, err
}
// Audit last (Ruling B7): every payment's row, then the batch's.
for _, m := range moves {
if err := auditMove(ctx, tx, c, m); err != nil {
return Batch{}, err
}
}
return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityBatch, EntityID: id, Action: "mark_paid",
Before: before, After: map[string]any{"batch": after, "failed": failedBy}})
}

keys returns []uuid.UUID; reasonsJSON a map[string]string marshalled to JSON (a paid line gets NULL from ->>). Plain helpers, same file. (If the implementer prefers two UPDATEs — one for failed, one for paid — that is equally correct and clearer; either.)

  • Step 3b: The memo (Ruling B15). In PaymentPayoutRule.Build, after reading the payment: ref, err := BatchOfPayment(ctx, tx, p.ID); when ref != nil && ref.Status == BatchApproved, the bank line’s memo is p.Reference + " " + p.BankReference + " " + ref.Reference. Test 1 asserts the bank line’s memo contains the batch reference; AP’s posting_test.go (unbatched) still sees the old memo (PRESENCE of the unchanged path).

  • Step 4: Run go test ./internal/platform/payables/... -count=1 → PASS (all AP tests included: the split must not change TransitionPayment’s behaviour).

  • Step 5: Sabotage. (a) Pass nil instead of l.Snapshot in MarkBatchPaid → TestMarkPaidUsesTheApprovedSnapshotNotTodaysBank red with ErrBankUnverified. (b) Swap the order so the batch audit.Write precedes the loop — no test goes red (the lock rule is not observable in a single-connection test); record that honestly in the commit body instead of claiming a guard. Restore.

  • Step 6: Commit Payment batches: mark paid moves each payment through its existing payout.


Task 6: Permissions, grants migration, OpenAPI

Section titled “Task 6: Permissions, grants migration, OpenAPI”

Files:

  • Modify: constants.yaml, internal/modules/payables/permissions.go, api/openapi.yaml
  • Create: db/migrations/00331_payment_batch_grants.sql
  • Test: internal/platform/payables/migration00331_test.go (copy migration00305_test.go’s shape)
  • Generated: make generate outputs

Interfaces:

  • Produces: permissions payables.batch.read, payables.batch.manage, payables.batch.approve; OpenAPI operations listPaymentBatches, listPaymentBatchCandidates, createPaymentBatch, getPaymentBatch, updatePaymentBatch, approvePaymentBatch, regeneratePaymentBatchFile, downloadPaymentBatchFile, markPaymentBatchPaid, cancelPaymentBatch; schemas PaymentBatch, PaymentBatchLine, PaymentBatchFile, PaymentBatchView, PaymentBatchInput, PaymentBatchUpdate, PaymentBatchCandidate, PaymentBatchRegenerate, PaymentBatchMarkPaid, PaymentBatchCancel, PaymentBatchRef; SupplierPaymentView.batch.

  • Step 1: constants.yaml permissions: gains the three keys after payables.payment.transition. permissions.go:

PermissionBatchRead = "payables.batch.read"
PermissionBatchManage = "payables.batch.manage"
PermissionBatchApprove = "payables.batch.approve"

registered with member.RegisterPermission (“Read payment batches and their bank files’ records”, “Build and edit payment batches, cancel a draft, and download bank files”, “Approve a payment batch, regenerate its file, or cancel it once approved; authority over each payment still comes from its workflow’s grants”), and PermissionBatchRead, PermissionBatchManage appended to the Finance RegisterRolePermissions call (spec D11).

  • Step 2: 00331_payment_batch_grants.sql — the 00305 pattern:
-- +goose Up
-- Payment batches (spec D11): existing Owners get all three keys; existing
-- Finance roles read and manage — approving stays with a second person.
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, k FROM role r,
unnest(ARRAY['payables.batch.read','payables.batch.manage','payables.batch.approve']) AS k
WHERE r.system AND r.name = 'Owner'
ON CONFLICT (role_id, permission_key) DO NOTHING;
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, k FROM role r,
unnest(ARRAY['payables.batch.read','payables.batch.manage']) AS k
WHERE r.system AND r.name = 'Finance'
ON CONFLICT (role_id, permission_key) DO NOTHING;
UPDATE organization o SET perm_version = o.perm_version + 1
WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner','Finance'));
-- +goose Down
DELETE FROM role_permission rp USING role r
WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name IN ('Owner','Finance')
AND rp.permission_key LIKE 'payables.batch.%';
UPDATE organization o SET perm_version = o.perm_version + 1
WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner','Finance'));

migration00331_test.go: migrate to 00330, provision an org, confirm Owner lacks payables.batch.approve (PRESENCE of absence-before), migrate up, Owner has all three, Finance has read+manage and not approve. Watch it fail once by commenting out the Finance insert.

  • Step 3: OpenAPI. Paths (reuse PayablesIfMatch, PayablesRevision, PayablesForbidden, PayablesNotFound, PayablesConflict, PayablesInvalid, BadRequestBody, Unauthenticated):
/payables/batches:
get:
operationId: listPaymentBatches
summary: Payment batches, newest first
description: Keyset-paginated. Requires payables.batch.read.
parameters:
- { name: status, in: query, schema: { type: string, enum: [draft, approved, paid, cancelled] } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
- { name: cursor, in: query, schema: { type: string } }
responses:
'200':
description: A page of batches
content:
application/json:
schema:
type: object
required: [batches, next_cursor]
properties:
batches: { type: array, items: { $ref: '#/components/schemas/PaymentBatch' } }
next_cursor: { type: string, nullable: true }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/PayablesForbidden' }
'422': { $ref: '#/components/responses/PayablesInvalid' }
post:
operationId: createPaymentBatch
summary: Build a draft batch from submitted payments
description: >
Every payment must be eligible (spec D3): its workflow can pay it next,
it is paid from this bank account in the batch's currency, its amount
is in whole sen, its supplier's bank details are verified, and it is in
no other batch (409 payment_in_batch). Requires payables.batch.manage.
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PaymentBatchInput' } } } }
responses:
'201':
description: The draft batch
headers: { ETag: { $ref: '#/components/headers/PayablesRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/PaymentBatchView' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/PayablesForbidden' }
'409': { $ref: '#/components/responses/PayablesConflict' }
'422': { $ref: '#/components/responses/PayablesInvalid' }
/payables/batches/candidates:
get:
operationId: listPaymentBatchCandidates
summary: Submitted payments from one bank account, and whether each can be batched
description: At most 500. Requires payables.batch.manage.
parameters:
- { name: bank_account_id, in: query, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: The candidates
content:
application/json:
schema:
type: object
required: [candidates]
properties:
candidates: { type: array, items: { $ref: '#/components/schemas/PaymentBatchCandidate' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/PayablesForbidden' }
'422': { $ref: '#/components/responses/PayablesInvalid' }
/payables/batches/{id}:
get: # getPaymentBatch — PaymentBatchView with ETag; payables.batch.read; 401/403/404
put: # updatePaymentBatch — PaymentBatchUpdate, PayablesIfMatch; payables.batch.manage; 200 view; 400/401/403/404/409/422
/payables/batches/{id}/approve:
post: # approvePaymentBatch — PayablesIfMatch, no body; payables.batch.approve; 200 view;
# 403 batch_self_approval; 409 payment_not_approvable (with payment_id, reference, refusal), revision_mismatch; 422 payment_ineligible
/payables/batches/{id}/files:
post: # regeneratePaymentBatchFile — PayablesIfMatch, PaymentBatchRegenerate; payables.batch.approve; 201 view
/payables/batches/{id}/files/{file_id}/content:
get:
operationId: downloadPaymentBatchFile
summary: The bytes of one bank file
description: >
An approved batch serves only its newest file (409 file_superseded);
a paid or cancelled batch serves any, as a record. The download is
audited. Requires payables.batch.manage.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: file_id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: The file
headers:
Content-Disposition: { schema: { type: string }, description: 'attachment; filename="PB-2026-0001-1.csv"' }
X-Content-SHA256: { schema: { type: string, pattern: '^[0-9a-f]{64}$' }, description: The stored SHA-256, hex. }
content:
text/csv: { schema: { type: string, format: binary } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/PayablesForbidden' }
'404': { $ref: '#/components/responses/PayablesNotFound' }
'409': { $ref: '#/components/responses/PayablesConflict' }
/payables/batches/{id}/mark-paid:
post: # markPaymentBatchPaid — PayablesIfMatch, PaymentBatchMarkPaid; payables.payment.transition; 200 view;
# 403 (a payment's workflow refuses the caller); 409 batch_final, revision_mismatch; 422 nothing_paid, failed[i].reason
/payables/batches/{id}/cancel:
post: # cancelPaymentBatch — PayablesIfMatch, PaymentBatchCancel; payables.batch.manage (+ approve when approved: 403 cancel_needs_approver); 200 view

Write the four # … operations out in full in the same style as the three shown (they are routine). Schemas:

PaymentBatch:
type: object
required: [id, reference, bank_account_id, currency, value_date, format, status, payment_count, total_amount,
prepared_by, approved_by, approved_at, paid_by, paid_at, cancelled_by, cancelled_at, cancel_reason,
revision, created_at]
properties:
id: { type: string, format: uuid }
reference: { type: string }
bank_account_id: { type: string, format: uuid }
currency: { type: string }
value_date: { type: string, format: date }
format: { type: string, enum: [generic_csv_v1] }
status: { type: string, enum: [draft, approved, paid, cancelled] }
payment_count: { type: integer }
total_amount: { $ref: '#/components/schemas/Money' }
prepared_by: { type: string, format: uuid }
approved_by: { type: string, format: uuid, nullable: true }
approved_at: { type: string, format: date-time, nullable: true }
paid_by: { type: string, format: uuid, nullable: true }
paid_at: { type: string, format: date-time, nullable: true }
cancelled_by: { type: string, format: uuid, nullable: true }
cancelled_at: { type: string, format: date-time, nullable: true }
cancel_reason: { type: string, nullable: true }
revision: { type: integer }
created_at: { type: string, format: date-time }
PaymentBatchLine:
type: object
required: [id, line_no, payment_id, payment_reference, supplier_id, supplier_name, amount, payee_name, bank_name,
account_no, outcome, outcome_reason]
properties:
id: { type: string, format: uuid }
line_no: { type: integer }
payment_id: { type: string, format: uuid }
payment_reference: { type: string }
supplier_id: { type: string, format: uuid }
supplier_name: { type: string }
amount: { $ref: '#/components/schemas/Money' }
payee_name: { type: string, description: The snapshot once approved; the supplier's current details before. }
bank_name: { type: string }
account_no: { type: string, description: Masked unless the caller holds payables.supplier.verify_bank. }
outcome: { type: string, nullable: true, enum: [paid, failed, removed, cancelled, null] }
outcome_reason: { type: string, nullable: true }
PaymentBatchFile:
type: object
required: [id, seq, format, file_name, byte_size, sha256, payment_count, total_amount, value_date, reason,
generated_by, generated_at, downloadable]
properties:
id: { type: string, format: uuid }
seq: { type: integer }
format: { type: string, enum: [generic_csv_v1] }
file_name: { type: string }
byte_size: { type: integer }
sha256: { type: string, pattern: '^[0-9a-f]{64}$' }
payment_count: { type: integer }
total_amount: { $ref: '#/components/schemas/Money' }
value_date: { type: string, format: date }
reason: { type: string, nullable: true }
generated_by: { type: string, format: uuid }
generated_at: { type: string, format: date-time }
downloadable: { type: boolean, description: Ruling B10, from the server. }
PaymentBatchView:
type: object
required: [batch, lines, files]
properties:
batch: { $ref: '#/components/schemas/PaymentBatch' }
lines: { type: array, items: { $ref: '#/components/schemas/PaymentBatchLine' } }
files: { type: array, items: { $ref: '#/components/schemas/PaymentBatchFile' } }
PaymentBatchInput:
type: object
additionalProperties: false
required: [bank_account_id, value_date, payment_ids]
properties:
bank_account_id: { type: string, format: uuid }
value_date: { type: string, format: date }
format: { type: string, enum: [generic_csv_v1] }
payment_ids: { type: array, minItems: 1, maxItems: 500, items: { type: string, format: uuid } }
PaymentBatchUpdate:
type: object
additionalProperties: false
required: [value_date, payment_ids]
properties:
value_date: { type: string, format: date }
payment_ids: { type: array, minItems: 1, maxItems: 500, items: { type: string, format: uuid } }
PaymentBatchCandidate:
type: object
required: [payment, eligible, rule]
properties:
payment: { $ref: '#/components/schemas/SupplierPayment' }
eligible: { type: boolean }
rule: { type: string, nullable: true, enum: [not_payable_now, other_bank_account, other_currency, sub_sen, bank_unverified, null] }
PaymentBatchRegenerate:
type: object
additionalProperties: false
required: [reason]
properties:
reason: { type: string, minLength: 1, maxLength: 500 }
value_date: { type: string, format: date }
PaymentBatchMarkPaid:
type: object
additionalProperties: false
required: [failed]
properties:
failed:
type: array
items:
type: object
additionalProperties: false
required: [payment_id, reason]
properties:
payment_id: { type: string, format: uuid }
reason: { type: string, minLength: 1, maxLength: 500 }
PaymentBatchCancel:
type: object
additionalProperties: false
required: [reason]
properties:
reason: { type: string, maxLength: 500, description: Required (non-empty) for an approved batch. }
PaymentBatchRef:
type: object
required: [id, reference, status]
properties:
id: { type: string, format: uuid }
reference: { type: string }
status: { type: string, enum: [draft, approved, paid, cancelled] }

SupplierPaymentView gains batch: { allOf: [{ $ref: '#/components/schemas/PaymentBatchRef' }], nullable: true } and batch in required.

  • Step 4: make generate; go build ./... fails only because the generated server interface now has ten unimplemented methods — add stubs returning 501 in a new internal/modules/payables/batch_handlers.go (c.Error(apperr.New(apperr.KindInternal, "not built yet"))) and no routes yet, so the build is green; Task 7 replaces them. go test ./internal/modules/payables/... ./internal/kernel/... -count=1 → PASS (the OpenAPI/router drift test passes: no route is registered yet, and the test checks registered routes against the document — if it instead requires every documented path to be routed, register the ten routes now with the stubs and pin their permissions; record which).

  • Step 5: Commit Payment batches: permissions, grants (00331), OpenAPI.


Files:

  • Modify: internal/modules/payables/batch_handlers.go (replace stubs), routes.go, errors.go, payment_handlers.go
  • Test: internal/modules/payables/batch_handlers_test.go, the module’s route-permission pin test

Interfaces:

  • Consumes: Tasks 3–6.
  • Produces: routes
httpx.Protected(g, pl, "GET", "/payables/batches", PermissionBatchRead, h.listBatches)
httpx.Protected(g, pl, "GET", "/payables/batches/candidates", PermissionBatchManage, h.batchCandidates)
httpx.Protected(g, pl, "POST", "/payables/batches", PermissionBatchManage, h.createBatch)
httpx.Protected(g, pl, "GET", "/payables/batches/:id", PermissionBatchRead, h.getBatch)
httpx.Protected(g, pl, "PUT", "/payables/batches/:id", PermissionBatchManage, h.updateBatch)
httpx.Protected(g, pl, "POST", "/payables/batches/:id/approve", PermissionBatchApprove, h.approveBatch)
httpx.Protected(g, pl, "POST", "/payables/batches/:id/files", PermissionBatchApprove, h.regenerateBatchFile)
httpx.Protected(g, pl, "GET", "/payables/batches/:id/files/:file_id/content", PermissionBatchManage, h.downloadBatchFile)
httpx.Protected(g, pl, "POST", "/payables/batches/:id/mark-paid", PermissionPaymentTransition, h.markBatchPaid)
httpx.Protected(g, pl, "POST", "/payables/batches/:id/cancel", PermissionBatchManage, h.cancelBatch)

(/payables/batches/candidates must be registered before /payables/batches/:id if Gin’s router needs it; Gin accepts a static segment beside a param at the same level.)

  • Step 1: Failing handler tests (the AP handler-test harness in env_test.go):
  1. Routes pinned: each of the ten routes answers 403 for a member lacking its permission and not-403 for one holding it (the pattern AP’s route test uses).
  2. POST /payables/batches by Finance → 201, ETag: "1", lines[0].account_no masked (••••5678 per MaskAccountNo); GET by Owner (holds verify_bank) → the number in full (PRESENCE of unmasking).
  3. POST …/approve by Finance → 403 (no payables.batch.approve); by Owner with If-Match: "1" → 200, files[0].seq == 1, downloadable == true.
  4. GET …/files/{id}/content → 200, Content-Type: text/csv; charset=utf-8, Content-Disposition with the file name, and hex(sha256(body)) == X-Content-SHA256 == files[0].sha256.
  5. POST …/mark-paid by Owner with {"failed": []} → 200 status: paid; replay with the old ETag → 409 revision_mismatch.
  6. POST /payables/payments/{id}/transitions on a batched payment → 409 payment_in_batch; GET /payables/payments/{id} → batch.reference set.
  7. POST …/cancel of an approved batch by Finance (manage, no approve) → 403 cancel_needs_approver.
  • Step 2: Implement. Follow payment_handlers.go: parseID, decodeStrict, ifMatch, setETag, a batchTx helper mirroring paymentTx (opens db.InTx, runs the platform call as who, builds the view, passes the error through ledger.MapError then mapErr). The view builder loads BatchLines, BatchFiles, the supplier names (one ListSuppliers-style query by the lines’ supplier ids, or GetSupplier per distinct supplier — ≤ 500), and masks per Ruling B14 using member.Permissions(ctx, tx, who) once per request. downloadable = status approved ∧ seq is max, or status paid/cancelled.

The download writes bytes, not JSON:

func (h handlers) downloadBatchFile(c *gin.Context) {
id, err := parseID(c, "id")
fileID, err2 := parseID(c, "file_id")
if err := firstErr(err, err2); err != nil {
_ = c.Error(err)
return
}
who := memberOf(c) // however payment_handlers reads the caller
var f p.BatchFile
var body []byte
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
f, body, err = p.BatchFileContent(ctx, tx, h.deps.Clock, id, fileID, who)
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", f.FileName))
c.Header("X-Content-SHA256", hex.EncodeToString(f.SHA256))
c.Header("Cache-Control", "no-store") // full account numbers
c.Data(http.StatusOK, f.ContentType, body)
}

cancelBatch reads member.Permissions inside its transaction and passes mayCancelApproved := perms[PermissionBatchApprove] (Ruling B11).

errors.go additions (in mapErr’s switch, before the generic pg fallback):

case errors.Is(err, p.ErrPaymentInBatch):
return apperr.New(apperr.KindConflict, "the payment is in a payment batch; it moves with the batch").With("code", "payment_in_batch")
case errors.As(err, &ineligible):
return apperr.New(apperr.KindValidation, fmt.Sprintf("field=payment_ids rule=%s: %s cannot be batched", ineligible.Rule, ineligible.Reference)).
With("code", "payment_ineligible").With("payment_id", ineligible.PaymentID).With("rule", ineligible.Rule)
case errors.As(err, &notApprovable):
return apperr.New(apperr.KindConflict, fmt.Sprintf("you cannot approve %s under its payment workflow", notApprovable.Reference)).
With("code", "payment_not_approvable").With("payment_id", notApprovable.PaymentID).With("refusal", notApprovable.Code)
case errors.Is(err, p.ErrBatchSelfApproval):
return apperr.New(apperr.KindForbidden, "the member who prepared the batch cannot approve it or release its files").With("code", "batch_self_approval")
case errors.Is(err, p.ErrCancelNeedsApprover):
return apperr.New(apperr.KindForbidden, "cancelling an approved batch needs payables.batch.approve").With("code", "cancel_needs_approver")
case errors.Is(err, p.ErrFileSuperseded):
return apperr.New(apperr.KindConflict, "a newer file replaces this one; download the newest").With("code", "file_superseded")
case errors.Is(err, p.ErrNothingPaid):
return apperr.New(apperr.KindValidation, "field=failed rule=nothing_paid: at least one payment must be paid; cancel the batch instead")
case errors.Is(err, p.ErrBatchNotDraft), errors.Is(err, p.ErrBatchNotApproved), errors.Is(err, p.ErrBatchFinal), errors.Is(err, p.ErrNoFile):
return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "batch_state")
case errors.Is(err, p.ErrBatchSize):
return apperr.New(apperr.KindValidation, "field=payment_ids rule=batch_size: a batch holds 1 to 500 payments")

and the DB backstops payment_batch_approver_differs → 403 batch_self_approval, payment_batch_line_one_live → 409 payment_in_batch (the AP pg case pattern). Check the exact With / KindValidation names against errors.go as it stands.

payment_handlers.go: paymentViewDTO gains Batch *batchRefDTO \json:“batch”`, filled from p.BatchOfPayment`.

  • Step 3: Run go test ./internal/modules/payables/... ./internal/app/... -count=1 → PASS; make generate → clean.

  • Step 4: Sabotage. Register approve under PermissionBatchManage → the route pin test red (Finance gets through); restore. Record.

  • Step 5: Commit Payment batches: HTTP handlers and routes.


Files:

  • Create: web/src/features/payables/batches/{api.ts,BatchesPage.tsx,BatchDialog.tsx,BatchDrawer.tsx,MarkPaidDialog.tsx,download.ts}, web/src/features/payables/batches/__tests__/BatchesPage.test.tsx
  • Modify: web/src/features/payables/routes.tsx, web/src/features/payables/PaymentDrawer.tsx, web/src/app/shell/nav.ts, web/src/lib/i18n/locales/{en,ms,zh}/payables.json

Interfaces:

  • Consumes: generated schema.d.ts (Task 6); existing DataTable (tableId), api client, unwrap, the AP drawers’ chip/section components, useBankAccounts or however PaymentDialog.tsx lists bank accounts (reuse it).

  • Produces: route /payables/batches (?batch=<id> opens the drawer, as the invoice/payment pages do); hooks useBatches, useBatch(id), useBatchCandidates(bankAccountId), useCreateBatch, useUpdateBatch, useApproveBatch, useRegenerateBatchFile, useMarkBatchPaid, useCancelBatch, downloadBatchFile(batchId, file).

  • Step 1: Failing Vitest (BatchesPage.test.tsx, MSW handlers for the batch endpoints):

  1. The list renders a batch row with reference, status chip, count, total, value date.
  2. New batch: choosing a bank account loads candidates; an ineligible candidate is disabled and shows its reason (batches.rule.bank_unverified); ticking two shows “2 payments · MYR 1,349.50”; Save posts payment_ids of both.
  3. The drawer for an approved batch shows the newest file’s SHA-256 and a Download button; a superseded file shows “Superseded — do not upload” and no button.
  4. Mark paid: ticking one line as failed requires its reason before Confirm enables; the request body is {"failed":[{"payment_id":…,"reason":…}]}.
  5. The preparer does not see Approve (the drawer hides actions the server would refuse for the caller: Approve and Regenerate when batch.prepared_by === me; Edit unless draft and preparer).
  • Step 2: Implement. Follow PaymentsPage.tsx/PaymentDrawer.tsx/PaymentDialog.tsx structure and components. Money is formatted with the existing money helpers (strings, never Number). download.ts:
import { api } from '@/lib/api/client';
import { unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
// The file's bytes arrive with the session's auth like any API call; the
// browser saves them from an object URL, which is revoked straight after.
export async function downloadBatchFile(batchId: string, file: Schemas['PaymentBatchFile']) {
const blob = unwrap(
await api.GET('/payables/batches/{id}/files/{file_id}/content', {
params: { path: { id: batchId, file_id: file.id } },
parseAs: 'blob',
}),
) as Blob;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.file_name;
a.click();
URL.revokeObjectURL(url);
}

(If unwrap does not accept a blob result, check response.ok and read data directly; do not parse the CSV in the browser.)

The drawer’s file section shows sha256 in a monospace, copyable field with the line batches.file.checkInPortal. PaymentDrawer.tsx: when view.batch is set, a chip batches.inBatch linking to /payables/batches?batch=<id>, and the transition bar hidden while view.batch.status is draft or approved (the server refuses those moves anyway).

nav.ts: after the payments item:

{
to: '/payables/batches',
labelKey: 'payables:nav.batches',
icon: /* the icon the payments item uses, or the closest existing one */,
permission: 'payables.batch.read',
},
  • Step 3: Strings. payables.json (en / ms / zh), keys identical:
key en ms zh
nav.batches Payment batches Kelompok pembayaran 付款批次
batches.title Payment batches Kelompok pembayaran 付款批次
batches.new New batch Kelompok baharu 新建批次
batches.payFrom Pay from Bayar daripada 付款账户
batches.valueDate Value date Tarikh nilai 起息日
batches.payments Payments Bayaran 付款
batches.count {{count}} payments · {{currency}} {{total}} {{count}} bayaran · {{currency}} {{total}} {{count}} 笔付款 · {{currency}} {{total}}
batches.noCandidates No payments are ready for a batch from this account. Tiada bayaran yang sedia untuk kelompok daripada akaun ini. 该账户没有可加入批次的付款。
batches.status.draft Draft Draf 草稿
batches.status.approved Approved Diluluskan 已批准
batches.status.paid Paid Telah dibayar 已付款
batches.status.cancelled Cancelled Dibatalkan 已取消
batches.outcome.live In batch Dalam kelompok 批次中
batches.outcome.paid Paid Dibayar 已付款
batches.outcome.failed Failed at the bank Gagal di bank 银行付款失败
batches.outcome.removed Removed Dikeluarkan 已移除
batches.outcome.cancelled Cancelled Dibatalkan 已取消
batches.rule.not_payable_now Not waiting for payment approval Tidak menunggu kelulusan bayaran 未在等待付款批准
batches.rule.other_bank_account Paid from another account Dibayar daripada akaun lain 从其他账户付款
batches.rule.other_currency Another currency Mata wang lain 其他币种
batches.rule.sub_sen Amount is finer than one sen Amaun lebih halus daripada satu sen 金额精度超过一分
batches.rule.bank_unverified Supplier’s bank details not verified Butiran bank pembekal belum disahkan 供应商银行资料未核实
batches.preparedBy Prepared by Disediakan oleh 制单人
batches.approvedBy Approved by Diluluskan oleh 批准人
batches.paidBy Marked paid by Ditandakan dibayar oleh 标记付款人
batches.action.edit Edit Sunting 编辑
batches.action.approve Approve batch Luluskan kelompok 批准批次
batches.action.regenerate Regenerate file Jana semula fail 重新生成文件
batches.action.download Download file Muat turun fail 下载文件
batches.action.markPaid Mark paid Tandakan sebagai dibayar 标记为已付款
batches.action.cancel Cancel batch Batalkan kelompok 取消批次
batches.reason Reason Sebab 原因
batches.file.title Bank files Fail bank 银行文件
batches.file.seq File {{seq}} Fail {{seq}} 文件 {{seq}}
batches.file.superseded Superseded — do not upload Digantikan — jangan muat naik 已被取代——请勿上传
batches.file.checkInPortal Upload this file to your bank. Before authorising it there, check the count, total and SHA-256. Muat naik fail ini ke bank anda. Sebelum membenarkannya di sana, semak bilangan, jumlah dan SHA-256. 请将此文件上传至银行。在银行授权前,请核对笔数、总额和 SHA-256。
batches.markPaid.intro The bank paid every payment except the ones ticked below. Bank telah membayar setiap bayaran kecuali yang ditanda di bawah. 除下方勾选的付款外,银行已支付全部付款。
batches.markPaid.confirm Confirm payment Sahkan bayaran 确认付款
batches.cancel.warning If the bank has already paid this file, cancelling lets these payments be paid again. Jika bank telah membayar fail ini, pembatalan membolehkan bayaran ini dibayar sekali lagi. 如果银行已按此文件付款,取消后这些付款可能会被重复支付。
batches.inBatch In batch {{reference}} Dalam kelompok {{reference}} 在批次 {{reference}} 中
  • Step 4: Run pnpm -C web test --run src/features/payables src/drift and pnpm -C web typecheck (or the repo’s equivalents) → PASS.

  • Step 5: Sabotage. Delete one ms key → i18nparity.test.ts red; restore. Record.

  • Step 6: Commit Payment batches: web.


Task 9: E2E, roadmap, deploy notes, full suite

Section titled “Task 9: E2E, roadmap, deploy notes, full suite”

Files:

  • Modify (Ruling B16, only if not already on phase-2): scripts/dev-stack.sh, web/vite.config.ts, web/playwright.config.ts

  • Modify: web/e2e/support.ts, docs/ROADMAP.md, docs/DEPLOY_NOTES.md

  • Create: web/e2e/payment-batch.spec.ts

  • Step 1: Stack ports (Ruling B16). scripts/dev-stack.sh:

Terminal window
STACK_NAME=${STACK_NAME:-dev}
STATE="$ROOT/.dev-stack${STACK_NAME:+-$STACK_NAME}" # keep ".dev-stack" for the default:
[[ "$STACK_NAME" == dev ]] && STATE="$ROOT/.dev-stack"
PG=erp-$STACK_NAME-pg
MINIO=erp-$STACK_NAME-minio
PG_PORT=${PG_PORT:-55432}
MINIO_PORT=${MINIO_PORT:-59000}
API_PORT=${API_PORT:-3000}
WEB_PORT=${WEB_PORT:-5173}

and use $API_PORT for PORT=, the health curl, API_BASE and the final echo ("api :$API_PORT and worker running"), $WEB_PORT for PUBLIC_BASE_URL. web/vite.config.ts: proxy target `http://localhost:${process.env.API_PORT ?? 3000}`. web/playwright.config.ts: const API_PORT = process.env.API_PORT ?? '3000'; const WEB_PORT = process.env.WEB_PORT ?? '5173'; used in baseURL, both urls, the wait regex (new RegExp(\api :${API_PORT} and worker running`)) and pnpm dev –port ${WEB_PORT}; the webServercommands inherit the env.web/e2e/support.ts's API_BASEdefault becomes ``http://localhost:${process.env.API_PORT ?? 3000}/v1` ``. Defaults leave the user’s dev stack exactly as it is.

  • Step 2: The e2e. support.ts: ensureFinanceAP() adds payables.batch.read, payables.batch.manage. Add ensureOwnerBatches() only if the Owner role in the e2e stack lacks the batch keys (it will not after 00331 on a fresh stack; the Owner is seeded with every registered key).

web/e2e/payment-batch.spec.ts — steps 1–5 of payables.spec.ts up to “Pending approval” (copied into a preparePendingPayment(browser, tag) helper in support.ts, without editing payables.spec.ts), then:

test('submitted payment → batch → approve → download → mark paid → aging shows zero', async ({ browser }) => {
const tag = runTag();
const { fin, own, supplierName } = await preparePendingPayment(browser, tag);
// PRESENCE: posted and unpaid, the supplier is in AP aging.
await own.goto('/reports?report=ap_aging');
await expect(own.getByText(supplierName)).toBeVisible();
// Finance builds the batch.
await fin.goto('/payables/batches');
await fin.getByRole('button', { name: 'New batch' }).first().click();
const bd = fin.getByRole('dialog');
await bd.getByRole('combobox', { name: 'Pay from' }).click();
await fin.getByRole('option', { name: /1520/ }).click();
await bd.getByRole('row', { name: new RegExp(supplierName) }).getByRole('checkbox').check();
await expect(bd.getByText(/1 payments · MYR 250\.00/)).toBeVisible();
await bd.getByRole('button', { name: 'Save' }).click();
await expect(fin.getByRole('dialog').getByText('Draft')).toBeVisible();
// The preparer cannot approve their own batch.
await expect(fin.getByRole('button', { name: 'Approve batch' })).toHaveCount(0);
// The owner approves; file 1 exists.
await own.goto('/payables/batches');
await own.getByRole('row').filter({ hasText: 'PB-' }).first().click();
await own.getByRole('button', { name: 'Approve batch' }).click();
const drawer = own.getByRole('dialog');
await expect(drawer.getByText('Approved')).toBeVisible();
const shown = (await drawer.getByTestId('batch-file-sha256').first().textContent())!.trim();
// Finance downloads it; the bytes hash to what the drawer shows.
await fin.reload();
const [download] = await Promise.all([
fin.waitForEvent('download'),
fin.getByRole('button', { name: 'Download file' }).click(),
]);
const bytes = await fs.readFile((await download.path())!);
expect(createHash('sha256').update(bytes).digest('hex')).toBe(shown);
expect(bytes.toString('utf8')).toContain(supplierName.split(' ')[0]);
// The bank paid it: the owner marks the batch paid.
await drawer.getByRole('button', { name: 'Mark paid' }).click();
await own.getByRole('dialog').getByRole('button', { name: 'Confirm payment' }).click();
await expect(drawer.getByText('Paid').first()).toBeVisible();
// The payment itself is Paid, through its own workflow.
await own.goto('/payables/payments');
await own.getByRole('row').filter({ hasText: 'SP-' }).first().click();
await expect(own.getByRole('dialog').getByText('Paid').first()).toBeVisible();
// ABSENCE: paid in full, the supplier is gone from AP aging.
await own.goto('/reports?report=ap_aging');
await expect(own.getByText(supplierName)).toHaveCount(0);
});

(data-testid="batch-file-sha256" is set in Task 8’s drawer; add it there if Task 8 did not. Row filters by SP-/PB- pick the newest row because lists are newest first; if the e2e org holds older runs, filter by the payment reference captured in preparePendingPayment instead.)

Run on lane 2:

Terminal window
STACK_NAME=lane2 PG_PORT=56422 MINIO_PORT=59120 API_PORT=3120 WEB_PORT=5193 \
pnpm -C web exec playwright test e2e/payment-batch.spec.ts

→ PASS. Then STACK_NAME=lane2 scripts/dev-stack.sh down. Sabotage: comment out MarkBatchPaid’s transition loop call (return early after updating lines) → the e2e fails at “gone from AP aging” (or earlier the payment’s chip); restore. Record.

  • Step 3: Roadmap. In docs/ROADMAP.md Phase 2:
- [x] Payment batches → one bank file (DuitNow bulk / GIRO)
Design: [`2026-09-27-payment-batches-design.md`](superpowers/specs/2026-09-27-payment-batches-design.md) (D1–D14), migrations `00330`–`00331`
Plan: [`2026-09-27-payment-batches.md`](superpowers/plans/2026-09-27-payment-batches.md) (9 tasks, Rulings B1–B16)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — migrations only; no provisioning step
- Supplier payments only; the approver must be able to approve every payment under its own grants;
marking paid moves each payment through its existing payout edge (no new journal path)
- v1's one file format is `generic_csv_v1` (this project's layout, not a bank's)
- End to end: submitted payment → batch → approve → download (hash checked) → mark paid → aging
shows zero (`web/e2e/payment-batch.spec.ts`)
- [ ] Open decisions (owner's call): the ten lines of the spec's "For the owner to confirm"
- [ ] Follow-up: claim and advance payouts in a batch (§19 "200 claims"); a bank-specific format
once a customer supplies its bank's specification
  • Step 4: Deploy notes. Newest-first entry:
## Phase 2 payment batches
Migrations `00330`–`00331` add `payment_batch`, `payment_batch_line` and `payment_batch_file`
(the bank file's bytes and SHA-256, kept seven years in Postgres; `erp_app` cannot update or
delete a file). Existing Owners gain `payables.batch.read`, `.manage` and `.approve`; existing
Finance roles gain `read` and `manage`. `perm_version` bumps so live sessions refresh once.
No new variable, job or provisioning step. Bank files contain full account numbers: the
Postgres backups that already hold supplier bank details now also hold these files.
  • Step 5: Full suite, once. go test -p 2 -count=1 ./... → PASS; pnpm -C web test --run → PASS; make generate → clean git status.

  • Step 6: Commit Payment batches: e2e, roadmap, deploy notes.


Spec Task
D1 own record, not a workflow kind 1, 3
D2 suppliers only 3 (only supplier_payment FK)
D3 eligibility 3, re-checked 4
D4 one live batch; batched payment moves only with its batch 1 (index), 3 (guard), 5 (split)
D5 format interface, generic_csv_v1 2
D6 bytes + hash in Postgres 1, 4
D7 approval, per-payment authority, snapshots, regeneration 4
D8 mark paid through the existing payout 5
D9 cancel 4
D10 numbering, audit, retention 1, 3–5
D11 permissions, masking 6, 7
D12 concurrency Global Constraints, 5
D13 web 8
D14 API 6, 7
Tests list 1–9