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)
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00330–00339only. This plan uses00330,00331. Task 1 narrows AP’s{300, 339, "Phase 2 AP"}ininternal/kernel/db/migrationranges_test.goto{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.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (OpenAPIMoney). Compare withCmp. Nofloat64in any file this plan touches;web/src/drift/nofloat.test.tsscans the web. A bank file’s amount isAmount.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),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_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 refusalsRAISE 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). NoSECURITY DEFINER. - Every SQL statement names
org_id = $nfor each org-scoped table it touches, joins included; everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org fromtenancy.OrgFromContext(orgOf); nothing takes an org argument. - Retention.
payment_batch,payment_batch_line→amendable(REVOKE DELETE);payment_batch_file→appendOnly(REVOKE UPDATE, DELETE), ininternal/kernel/db/retention_test.go. - Vocabulary values are strings: batch
status, lineoutcome, fileformatareTEXTwith 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 astate_keyto a literal except tests. - Lock order, one for the whole plan:
payment_batchrowFOR UPDATE→supplier_paymentrowsFOR UPDATEinidorder →party_supplierrowsFOR SHARE(bank check) → invoicesFOR UPDATEinidorder (insideallocatePayment) →workflow.Transition→ this plan’saudit.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 keepinternal/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 passesdb.InTx’s error throughledger.MapErrorbeforemapErr. - Business date is
clock.TodayUTC(c); everyDATEin Go is midnight UTC; nevertime.Now()in code under test. - Record-bearing text (cancel and failure reasons, regeneration reason, the payee snapshot) is English or Bahasa Malaysia:
recordtext.Checkin Go and a namedCHECK (… !~ '[-ヿ㐀-䶿一-鿿가-]')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 422field=<name> rule=<code>: <sentence>. - Every route goes through
httpx.Protectedwith apayables.*permission, is inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces), and is pinned ininternal/modules/payables/routes_test.go(or wherever AP’s route/permission pins live).api/public_routes.txtgains nothing.make generateafter every migration, OpenAPI orconstants.yamlchange; generated files are committed;git statusclean afterwards. - i18n. Every UI string is a key in
web/src/lib/i18n/locales/{en,ms,zh}/payables.jsonunderbatches.*(ornav.batches);web/src/drift/i18nparity.test.tsrequires 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); nevert.Parallel(). - Tests:
payablestest.New(→testdb.Start) per test; while iterating run onlygo test ./internal/platform/payables/... ./internal/modules/payables/... -count=1; one fullgo test -p 2 -count=1 ./...in Task 9 only. - E2E lane 2: Postgres
56422, MinIO59120, API3120, Vite5193. 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>
Rulings
Section titled “Rulings”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 needslockPayment,allocatePaymentand the payout path unexported, and a second package would force exporting them. - B2 Ruling:
TransitionPaymentkeeps its signature. Its body becomestransitionPayment(ctx, tx, c, in, snap *bankSnapshot); the public function first refuses a payment with a live batch line (ErrPaymentInBatch) and then calls it withsnap == 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;TransitionPaymentaudits right after, exactly as today. - B3 Ruling: A live line is
outcome IS NULL. The “at most one batch” index also coversoutcome = '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) changesvalue_dateand 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_nocolumn is the row’s position (1…n) inpayment_batch_line.line_noorder; 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 ispendingand which have no live or paid line, filtered in Go by the full D3 check; capped at 500, ordered bycreated_at, id. Each candidate carrieseligibleand, when false, therulethat fails, so the dialog can show why a payment is greyed out. - B7 Ruling: Mark paid calls
workflow.Transitiononce 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 withAllowed == true. A refusal returns*NotApprovableError{PaymentID, Reference, Code}whereCodeis the engine’s refusal code (self_approval,not_in_band, …) — 409payment_not_approvable. - B9 Ruling:
value_datemust be ≥ the business date at create, edit, approve and regenerate (422value_date_past). No upper bound. - B10 Ruling: Download is
GET …/files/{file_id}/contentunderpayables.batch.manage, returning the stored bytes withContent-Typefrom the row,Content-Disposition: attachment; filename="<file_name>",X-Content-SHA256: <hex>. An approved batch serves only its newest file (409file_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 requirespayables.batch.approve(read withmember.Permissionsin the same transaction) and passesmayCancelApprovedto 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 byworkflow.Transition). - B13 Ruling: The payment view (
GET /payables/payments/{id}) gainsbatch—{id, reference, status}of its live-or-paid line’s batch, ornull. The payment list does not (no extra join on the list path). - B14 Ruling: Line views mask
account_nowithpayables.MaskAccountNounless the caller holdspayables.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:
PaymentPayoutRulestays 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) andPaymentPayoutRule(a withholding credit line). Whichever lands second puts WHT’s move-time checks inside the unexportedtransitionPayment(Ruling B2), so the batch path gets them too; a batch payssupplier_payment.amount(the net) and never creates payments, so it cannot bypasscheckPayment. - B18 Ruling: The file carries no debit account number in v1. If the bank-reconciliation item’s
banking.GetByGLAccounthas landed when a bank-specific format is added, that format reads the company’s account number from it;generic_csv_v1does not change. - B16 Ruling (e2e ports):
scripts/dev-stack.sh,web/vite.config.tsandweb/playwright.config.tsreadSTACK_NAME,PG_PORT,MINIO_PORT,API_PORT,WEB_PORTwith today’s values as defaults, and keep.dev-stackstate per stack name. If another Phase 2 item has already landed this onphase-2, use it and skip Task 9 Step 1.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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 namespayment_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):
TestBatchApproverMustDifferFromPreparer—UPDATE … SET status='approved', approved_by=<Owner>on a batch prepared by Staff succeeds (roll back via savepoint); the same withapproved_by=<Staff>fails23514 payment_batch_approver_differs.TestAPaymentIsInOneLiveBatch— a second line for the same payment in another draft batch fails23505 payment_batch_line_one_live; after setting the first line’soutcome='removed'(outcome_by/at set), the second insert succeeds.TestBatchFileHashMatchesItsBytes— on an approved batch, insertingcontent='a,b\r\n'withsha256=sha256('a,b\r\n'::bytea)succeeds; withsha256=sha256('x'::bytea)fails23514 payment_batch_file_hash_matches.TestBatchStatusFlow—draft→approved→paidsucceeds;paid→cancelledfailspayment_batch_final; on a fresh draft,draft→paidfailspayment_batch_status_flow.TestBatchLineFrozenOnceApproved— on an approved batch, inserting a line failspayment_batch_line_frozen; setting a live line’soutcome='removed'failspayment_batch_line_outcome_flow; settingoutcome='failed'with a reason succeeds.TestBatchFileCannotBeChanged—UPDATE payment_batch_file SET file_name='x'andDELETEfail42501(permission denied) aserp_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 StatementEndCREATE 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 StatementEndCREATE TRIGGER payment_batch_line_guard BEFORE INSERT OR UPDATE ON payment_batch_line FOR EACH ROW EXECUTE FUNCTION payment_batch_line_guard();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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 DownDROP 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→ cleangit statusapart from the new files. -
Step 4: Sabotage. Drop
payment_batch_approver_differsfrom the migration, reset the test DB, run test 1 → red (want *pgconn.PgError, got <nil>). Change the index predicate toWHERE outcome IS NULLonly, run a test that inserts a second line afteroutcome='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_descriptionPB-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(packagepayables_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'topayment_batch_format_knownin00330(fresh DB) →TestEveryStoredFormatIsRenderableAndBackred with the diff; restore. (b) Removew.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 — onlyFormat(key)andFileLine). - 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 orderfunc 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.goadd:
// 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(packagepayables_test):
TestCreateBatchTakesEligiblePayments— twoReadyPayments →e.Batch→ statusdraft,PaymentCount 2,TotalAmountthe sum, referencePB-<Y>-0001, two lines with nil snapshot and nil outcome; an audit rowpayment_batch/createexists.TestBatchRefusesIneligiblePayments— table-driven, each case first shows the accepted payment, then the refused one with itsIneligibleError.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 secondbankaccount or add one viachartin the test);other_currency: a USD payment into a MYR batch (useForeignInvoice+ 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 of10.005;bank_unverified: change the supplier’s bank after submission (payables.SetBankby Staff) so verification clears;in_other_batch: a payment already in another draft batch (→ErrPaymentInBatch, from the unique index mapped in Go: catch23505onpayment_batch_line_one_live).
TestBatchSizeIsOneTo500— empty list →ErrBatchSize(presence: one payment accepted). (Do not build 501 payments; the CHECKpayment_batch_count_rangeand 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.)TestUpdateBatchReplacesItsPayments—UpdateBatchwith[p2, p3]on a batch of[p1, p2]: p1’s line getsoutcome = removed, p3 joins with the nextline_no, count/total recomputed, revision bumped; wrong revision →*RevisionMismatchError; Owner (not preparer) →ErrNotPreparer.TestABatchedPaymentMovesOnlyWithItsBatch— PRESENCE: an unbatched submitted payment is approved by Owner viaTransitionPayment(e.ApprovePayment). ABSENCE: a batched payment’sTransitionPayment(approve, and reject) →ErrPaymentInBatch. PRESENCE again: afterUpdateBatchremoves it,e.ApprovePaymentsucceeds.TestBatchCandidates— returns a ready payment as eligible and a bank-unverified one withEligible false, Rule "bank_unverified"; a payment already batched is absent.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
liveguard inTransitionPayment→TestABatchedPaymentMovesOnlyWithItsBatchred at the ABSENCE step; restore. Makeeligibleskip the bank check → thebank_unverifiedcase 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 orderfunc 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.
TestApproveBatchWritesFileOne— Owner approves Staff’s batch of two: statusapproved,approved_by = Owner, both lines carry a snapshot equal to the supplier’s bank details at that moment (AccountNo,BankKey,VerifiedBy = Owner), oneBatchFilewithSeq 1,Reason nil,PaymentCount 2,TotalAmount= sum,SHA256 == sha256.Sum256(content);BatchFileContentreturns bytes that parse as CSV with 3 rows. Auditpayment_batch/approvenames the hash (hex).TestTheBatchPreparerCannotApprove— PRESENCE: Owner approves (in a savepoint-free second batch). ABSENCE: Staff approving its own →ErrBatchSelfApproval.TestTheApproverMustBeAbleToApproveEveryPayment— a third memberclerk, _ := e.Member(t, "Clerk", &e.OwnerSeat)(no grant onpaid) →*NotApprovableError{Code: workflow.RefuseNotInBand or the engine's code for "no grant"}; assert only thatCode != ""andReferencenames the payment. PRESENCE: Owner approves the same batch.TestApprovalRechecksEligibility— change one supplier’s bank after the draft was built → approve →*IneligibleError{Rule: "bank_unverified"}; nothing written (status still draft, no file).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:GetSuppliershows the new number andBankVerified() == false).TestRegenerateRecordsFileTwo—RegenerateBatchFile(reason "Portal refused a repeated file name")by Owner → fileSeq 2with the reason, different hash (thefile_sequencecolumn differs), same count/total;BatchFileContent(file1)→ErrFileSuperseded,(file2)→ bytes (PRESENCE). Staff regenerating →ErrBatchSelfApproval; empty reason →FieldError{reason, required}.TestCancelApprovedBatchFreesItsPayments—CancelBatch(approved, mayCancelApproved=false)→ErrCancelNeedsApprover; withtrueand a reason → statuscancelled, linescancelled; 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
AvailableTransitionscheck →TestTheApproverMustBeAbleToApproveEveryPaymentred; remove the superseded check →TestRegenerateRecordsFileTwored. 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.
TestMarkPaidPostsEachPaymentThroughItsPayout—e.UsePayablesPoster(t); three ready payments from two suppliers;e.ApprovedBatch;ControlBalanceByPartybefore;MarkBatchPaidby Owner with no failures → batchpaid,paid_by = Owner; every lineoutcome = paid; each payment’s state semanticpayout;e.EntryFor(t, p.InstanceID, "payout")exists for each (the entry isPaymentPayoutRule’s: Dr Trade payables with the supplier’s party, Cr1520); each supplier’s Trade payables balance fell by its payments; the decision’s reason contains the batch reference.TestMarkPaidReleasesWhatTheBankFailed— mark paid withfailed = [{p2, "Account closed"}]→ p2’s linefailedwith the reason, p2 stillpendingsemantic 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}.TestMarkPaidIsNotReplayable— a secondMarkBatchPaidwith the old revision →*RevisionMismatchError; with the new one →ErrBatchFinal; the payout entries are still one per payment.TestMarkPaidUsesTheApprovedSnapshotNotTodaysBank— approve; Staff changes a supplier’s bank (verification cleared). PRESENCE: an unbatched payment to the same supplier now fails individual approval withErrBankUnverified. ThenMarkBatchPaidsucceeds for the batched one.TestMarkPaidNeedsEachPaymentsAuthority— the Clerk (no grant) marks paid → the engine’s*workflow.RefusedError, nothing moved (PRESENCE: Owner then succeeds).- 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); whenref != nil && ref.Status == BatchApproved, the bank line’s memo isp.Reference + " " + p.BankReference + " " + ref.Reference. Test 1 asserts the bank line’s memo contains the batch reference; AP’sposting_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 changeTransitionPayment’s behaviour). -
Step 5: Sabotage. (a) Pass
nilinstead ofl.SnapshotinMarkBatchPaid→TestMarkPaidUsesTheApprovedSnapshotNotTodaysBankred withErrBankUnverified. (b) Swap the order so the batchaudit.Writeprecedes 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(copymigration00305_test.go’s shape) - Generated:
make generateoutputs
Interfaces:
-
Produces: permissions
payables.batch.read,payables.batch.manage,payables.batch.approve; OpenAPI operationslistPaymentBatches,listPaymentBatchCandidates,createPaymentBatch,getPaymentBatch,updatePaymentBatch,approvePaymentBatch,regeneratePaymentBatchFile,downloadPaymentBatchFile,markPaymentBatchPaid,cancelPaymentBatch; schemasPaymentBatch,PaymentBatchLine,PaymentBatchFile,PaymentBatchView,PaymentBatchInput,PaymentBatchUpdate,PaymentBatchCandidate,PaymentBatchRegenerate,PaymentBatchMarkPaid,PaymentBatchCancel,PaymentBatchRef;SupplierPaymentView.batch. -
Step 1:
constants.yamlpermissions:gains the three keys afterpayables.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— the00305pattern:
-- +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 DownDELETE 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 viewWrite 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 newinternal/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.
Task 7: Handlers and routes
Section titled “Task 7: Handlers and routes”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):
- 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).
POST /payables/batchesby Finance → 201,ETag: "1",lines[0].account_nomasked (••••5678perMaskAccountNo);GETby Owner (holdsverify_bank) → the number in full (PRESENCE of unmasking).POST …/approveby Finance → 403 (nopayables.batch.approve); by Owner withIf-Match: "1"→ 200,files[0].seq == 1,downloadable == true.GET …/files/{id}/content→ 200,Content-Type: text/csv; charset=utf-8,Content-Dispositionwith the file name, andhex(sha256(body)) == X-Content-SHA256 == files[0].sha256.POST …/mark-paidby Owner with{"failed": []}→ 200status: paid; replay with the old ETag → 409revision_mismatch.POST /payables/payments/{id}/transitionson a batched payment → 409payment_in_batch;GET /payables/payments/{id}→batch.referenceset.POST …/cancelof an approved batch by Finance (manage, no approve) → 403cancel_needs_approver.
- Step 2: Implement. Follow
payment_handlers.go:parseID,decodeStrict,ifMatch,setETag, abatchTxhelper mirroringpaymentTx(opensdb.InTx, runs the platform call aswho, builds the view, passes the error throughledger.MapErrorthenmapErr). The view builder loadsBatchLines,BatchFiles, the supplier names (oneListSuppliers-style query by the lines’ supplier ids, orGetSupplierper distinct supplier — ≤ 500), and masks per Ruling B14 usingmember.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, ¬Approvable): 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
approveunderPermissionBatchManage→ the route pin test red (Finance gets through); restore. Record. -
Step 5: Commit
Payment batches: HTTP handlers and routes.
Task 8: Web
Section titled “Task 8: Web”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); existingDataTable(tableId),apiclient,unwrap, the AP drawers’ chip/section components,useBankAccountsor howeverPaymentDialog.tsxlistsbankaccounts (reuse it). -
Produces: route
/payables/batches(?batch=<id>opens the drawer, as the invoice/payment pages do); hooksuseBatches,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):
- The list renders a batch row with reference, status chip, count, total, value date.
- 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 postspayment_idsof both. - 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.
- Mark paid: ticking one line as failed requires its reason before Confirm enables; the request body is
{"failed":[{"payment_id":…,"reason":…}]}. - 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.tsxstructure and components. Money is formatted with the existing money helpers (strings, neverNumber).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/driftandpnpm -C web typecheck(or the repo’s equivalents) → PASS. -
Step 5: Sabotage. Delete one
mskey →i18nparity.test.tsred; 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:
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-pgMINIO=erp-$STACK_NAME-minioPG_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()addspayables.batch.read,payables.batch.manage. AddensureOwnerBatches()only if the Owner role in the e2e stack lacks the batch keys (it will not after00331on 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:
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.mdPhase 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 ordelete a file). Existing Owners gain `payables.batch.read`, `.manage` and `.approve`; existingFinance 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: thePostgres 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→ cleangit status. -
Step 6: Commit
Payment batches: e2e, roadmap, deploy notes.
Self-review against the spec
Section titled “Self-review against the spec”| 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 |