Accounts Payable 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: An accounts clerk records suppliers (with bank details a second person verifies), enters their purchase invoices and credit notes, has them approved into the ledger (Dr expense [+ tax] / Cr Trade payables with the supplier’s party_id), records supplier payments against posted invoices (Dr Trade payables at the invoices' rates / Cr bank at the payment's rate, realised FX on fx_gain_loss), offsets credit notes against invoices, and reads each supplier’s balance and an AP aging report (JSON, CSV/XLSX/PDF, any “as of” date) whose total per supplier is provably equal to the Trade payables control account per party.
Architecture: One platform package and one module, the advances shape. internal/platform/payables owns every AP rule: suppliers on the existing party table (party_supplier role row), the three seeded workflow graphs (purchase_invoice, supplier_credit_note, supplier_payment) registered through workflow.RegisterSeededGraph, the invoice/credit-note document (one table, kind column), the payment document, the append-only ap_allocation table, three posting rules registered with accounting/, the document binder, the provisioning step that gives an existing organization its Trade payables account, and the pure aging computation. internal/modules/payables is the HTTP shell (routes, DTOs, error mapping, permissions, boot). internal/modules/reports serves AP aging as JSON and through the existing export pipeline. The web gains web/src/features/payables and an AP aging tab under Reports. accounting/ still reacts only to semantics; this plan never writes a journal line.
Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money · React 19 · TanStack Router/Query/Table · i18next (en/ms/zh) · Vitest + MSW · Playwright
Spec: docs/superpowers/specs/2026-09-26-accounts-payable-design.md
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00300–00339only. This plan uses00300–00306. Task 1 registers{300, 339, "Phase 2 AP"}ininternal/kernel/db/migrationranges_test.goin the same commit as00300. - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (the OpenAPIMoneyschema); exchange rates aremoney.Rate/NUMERIC(19,10)/ the OpenAPIRateschema. Compare withCmp, never==. Nofloat64anywhere ininternal/platform/payables,internal/modules/payablesorweb/src/features/payables(web/src/drift/nofloat.test.tsscans the web). - 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, aCONSTRAINT <table>_tenant_id UNIQUE (org_id, id)(or(org_id, party_id)), and composite(org_id, x)foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID). - Named constraints. Every
CHECK/UNIQUEis writtenCONSTRAINT <table>_<rule>; no name ends in_check,_keyor_excl(TestEveryConstraintIsNamed). A trigger’s refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name. - Pinned functions. Every function/trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table (public.purchase_invoice) (TestEveryFunctionPinsItsSearchPath). NoSECURITY DEFINER. - Every SQL statement names
org_id = $nfor each org-scoped table it touches, join equalities included, and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). One org per transaction: platform functions read the org fromtenancy.OrgFromContext; nothing takes an org argument. - Retention. Every new table is in exactly one list of
internal/kernel/db/retention_test.go:party_supplier,purchase_invoice,purchase_invoice_line,supplier_payment,supplier_payment_lineareamendable(REVOKE DELETE);ap_allocationisappendOnly(REVOKE UPDATE, DELETE). Seven years, like claims (spec D11). - Accounting reacts to semantics, never state names. Posting rules are keyed
(kind, semantic); no Go or SQL in this plan compares a workflowstate_keyto a literal except the seeded graphs themselves and tests. - Modules never write journal lines.
payables.Rules()is registered withaccounting.RegisterPostingRulefrominternal/modules/payables/boot.go’sinit(); rules return anaccounting.Draft. - Lock order, one for the whole plan: document header row
FOR UPDATE(invoice, credit note or payment) → supplier row (FOR SHARE, bank check only) → other invoice rowsFOR UPDATEinidorder (allocation) →workflow.Transition(instance lock, posting, its own audit) → this plan’saudit.Write, last. Nothing writes an audit row beforeworkflow.Transitionin a transaction that transitions (workflow/instance.goTransition doc comment). - No nested
db.InTx. Platform functions take(ctx, tx pgx.Tx, …); only handlers open transactions. Every handler whose transaction can post passesdb.InTx’s error throughledger.MapErrorbefore mapping it to HTTP. - Business date is
clock.TodayUTC(c); everyDATEhandled in Go is midnight UTC; nevertime.Now()in code under test. - Record-bearing text (party legal name and address, supplier invoice number, line description, reasons, bank account name) is English or Bahasa Malaysia:
recordtext.Checkin Go (internal/kernel/recordtext) and a namedCHECK (… !~ '[-ヿ㐀-䶿一-鿿가-]')in SQL (written out in full at every use). - Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. Platform code returns sentinel or typed errors, never*apperr.Problem. A field refusal is 422 with detailfield=<name> rule=<code>: <sentence>. - Every route goes through
httpx.Protectedwith apayables.*(orreports.*) permission and is documented inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces).api/public_routes.txtgains nothing.make generateafter every migration, OpenAPI orconstants.yamlchange; generated files are committed. - i18n. Every UI string is a key in
web/src/lib/i18n/locales/{en,ms,zh}/payables.json(or the named existing namespace);web/src/drift/i18nparity.test.tsrequires identical keys and placeholders in all three. Malay is genuine Malay (the strings in this plan are the ones to use), never English copied intoms. Seeded workflow labels carryen,ms,zh(TestEverySeededGraphShipsAllThreeLocales). - Presence before absence (
AGENTS.md). Every refusal test shows the accepted case first. Every guard is watched failing once: each task with a guard has a Sabotage step — break the guard, run the test, read the red message, restore. - Posting is process-global. A test that needs a Poster calls
payablestest.Env.UsePayablesPoster(t)(freshaccounting.NewRuleSet()holdingpayables.Rules()), nevert.Parallel(). - Tests:
testdb.Start(throughexpensestest.New) per test; run only the touched packages while iterating (go test ./internal/platform/payables/... -count=1); one fullgo test -p 2 -count=1 ./...in Task 18 only. - Git commits end with:
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”The spec’s “Open for the plan” items, and every place the spec is silent or cannot be built as written. Format: Ruling: <decision> — <why>.
- P1 Ruling: Module split is
internal/platform/payables(+payables/payablestest, a non-test fixture package likeadvancestest) andinternal/modules/payables— the advances precedent;reportsimports the platform package for aging. - P2 Ruling: Credit notes share the invoice table.
purchase_invoice.kind ∈ {'purchase_invoice','supplier_credit_note'}equals the workflow kind and the journalsource_kind; lines sharepurchase_invoice_line; the duplicate index includeskind, so the two are unique separately (D7) — identical header and lines (D5), half the code. - P3 Ruling (user amendment 2, replaces the Owner-only ruling): There is no “Accounts” role; the seeded Finance role plays it. Owner gets all nine
payables.*keys; Finance gets all of them exceptpayables.supplier.verify_bank(Owner-only), plusaccounting.ledger.read(P37). New organizations: a newprovisioning.RegisterRolePermissions(role, keys...)registry, applied byrolesSteponly to a role it creates (theEveryRolePermissionsrule, so an administrator’s removal stands), filled frominternal/modules/payables’sinit(). Existing organizations: migration00305, which grants Owners and system Finance roles (the00170pattern). - P4 Ruling: No
payables.aging.read. AP aging lives inreports:GET /reports/ap-agingunderreports.read, and CSV/XLSX/PDF through the existingPOST /reports/exportswithreport = 'ap_aging'underreports.export— the existing pattern for every report. - P5 Ruling:
party.kindwidens to('employee','organization','individual')under the new nameparty_kind_known; shared columns aretin,registration_no,country_code,address_line1,address_line2,address_city,address_postcode,address_state,email,phone;country_codeis the address’s country too (one column, not two). A supplier is deactivated on its role (party_supplier.is_active), never onparty. - P6 Ruling: A supplier’s id on the wire is its
party_id(party_supplier’s primary key). - P7 Ruling:
ap_trade(liability, sub-ledger kind: no manual journal lines) is seeded as MY account2105“Trade payables” under2100; the seed name of2110(controlap) becomes “Employee claims payable” for new organizations only. An existing organization gets2105from the new provisioning steppayables_trade_payables(order 75) on its nextPOST /v1/org/provision. - P8 Ruling: The posted state of an invoice/credit note (
approved, semanticaccrual, label “Posted”) and of a payment (paid, semanticpayout) is non-terminal, because the engine lets a terminal state be left only by a dispute; each is left byvoidintovoided(semanticcancelled, terminal,reverses_accrual, target gate).cancelled(terminal) from draft is “Withdrawn”. - P9 Ruling: Seeded grants name the OWNER seat on
approved,voidedandpaid; administrators re-grant. “Preparer ≠ approver” is the engine’s ownself_approvalrefusal (reporter = preparer). - P10 Ruling:
workflow.AccrualOnlyKinds = []string{"purchase_invoice", "supplier_credit_note"}; for those kinds the validator requires exactly one accrual state and no payout state (rule 3 codesaccrual_only_count,payout_in_accrual_only). The spec’sCommitmentKindsdoes not exist on this branch;SignoffKindsis the precedent copied. - P11 Ruling:
purchase_invoicejoinsworkflow.SignoffKinds; every draft save of an invoice callsworkflow.SetAmount(gross_base)andworkflow.SetShares(the claim algorithm), so an administrator can put a project-approval step on invoices unchanged. Credit notes and payments callSetAmountonly. - P12 Ruling: References
PI-<Y>-NNNN,CN-<Y>-NNNN,SP-<Y>-NNNNfromnumbering.Allocatescopespurchase_invoice,supplier_credit_note,supplier_payment,<Y>=fiscal.YearContaining(TodayUTC).EndYear, allocated at creation. - P13 Ruling: The caller supplies
fx_ratewhen currency ≠ base (required; a missing one is 422fx_rate_required) and it must be exactly 1 at base. Bases are frozen per line —net_base = net.MulRate(rate),tax_base = tax.MulRate(rate),gross_base = Σ— and entries post in the document currency with those frozen bases, so an accrual never needs an FX line; the payout’s realised FX (D9) is thefx_differencelineledger.Postitself adds onfx_gain_losswhen the frozen bases differ. - P14 Ruling (D8): With a tax code,
tax_amountmust be within0.05ofnet × rate%(net.Percent(rate)), and the code must be active, valid on the invoice date, and of usagepurchaseorboth(422tax_code_usage); without one,tax_amountmust be0. - P15 Ruling: A line’s account is postable, active, carries no control kind and is of type
expenseorasset(capital purchases, prepayments). - P16 Ruling: A draft save (
PUT) replaces the lines wholesale; old lines are withdrawn (withdrawn_at), never deleted. - P17 Ruling: One attachment per invoice/credit note (
document_id, the supplier’s document), required andverifiedat submission; binder kindpurchase_invoice;MayReadispayables.invoice.read. - P18 Ruling: Submission (
draft → pending_review) requires ≥ 1 line, the verified document, adocument_basiswhose record is complete (taxrecord.Normalize), and an active supplier. - P19 Ruling (D7): Duplicate = partial unique index
(org_id, kind, supplier_party_id, lower(supplier_invoice_no)) WHERE cancelled_at IS NULL;cancelled_atis set by the transition service on entering anycancelled-semantic state (withdrawn or voided); a rejected invoice keeps its number. The 409 names the existing reference. - P20 Ruling (D6): A payment’s allocations are planned in
supplier_payment_linewhile it is a draft and becomeap_allocationrows only when it enters its payout state, in that transaction and beforeworkflow.Transition; voiding the payment inserts the reversal rows. So everyap_allocationrow is an effective one, and outstanding = gross − Σap_allocation.amount. - P21 Ruling:
ap_allocationcarriespayment_id/credit_note_id(exactly one) with a generatedsource_kind, instead of a polymorphicsource_id, so both sides have composite foreign keys; it addscurrency,base_amount(at the invoice’s rate, or the invoice’s remaining base when the row clears it) andeffective_on(the business date, for historical aging). - P22 Ruling: Only a credit-note allocation can be reversed on its own (
POST /payables/allocations/{id}/reverse); a payment’s allocations are undone by voiding the payment, because the payment’s journal entry is what they mirror. - P23 Ruling: A credit note allocates only to an invoice of the same supplier, the same currency and the identical
fx_rate; otherwise 422rate_mismatch— offsetting two documents at different rates would leave a realised FX difference nothing posts. - P24 Ruling (D11): Voiding an invoice or credit note is refused (409
has_allocations) while its net allocation is non-zero; the clerk reverses the credit-note allocations and voids the payments first. The void itself is the engine’s reversal of the accrual. - P25 Ruling: A payment’s bank account is an active, postable account with control kind
bankwhose currency is null or the payment’s; its journal is the active journal whose default account it is, else the defaultbankjournal (advances.journalFor, copied). - P26 Ruling (D10): Bank fields live on
party_supplierwithbank_prev_*columns holding the values a change replaced, so the verifier sees old → new; every change and verification is anaudit_logrow with before/after. Account numbers are masked to the last four digits on the wire except for callers holdingpayables.supplier.verify_bank. The changer verifying is 403bank_verifier_is_changer(Go) andparty_supplier_bank_verifier_differs(DB). - P27 Ruling: Entering a payout-semantic state of a payment is refused (409
bank_unverified) unless the supplier’s current bank details are verified, checked under the supplier row lock. - P28 Ruling: No
Idempotency-Keyon payables transitions:fromis the concurrency control (workflow.ErrStaleState, 409), so a replayed approval finds the document moved and cannot post twice.If-Match/ETagrevisions guard every draft and supplier edit. - P29 Ruling: AP documents are organizational:
payables.invoice.read/payables.payment.readsee every one; there is no per-member visibility. - P30 Ruling: Invoice and payment lists are keyset-paginated on
(created_at DESC, id DESC)with an opaque cursor; the supplier list is ordered by code, capped at 1000, unpaginated. - P31 Ruling (D6 aging): Days past due =
as_of − due_on; bucketscurrent(≤ 0),days_1_30,days_31_60,days_61_90,over_90. A document is in the report as of a date when its accrual entry is dated on or before it and no reversal of that entry is; allocations count wheneffective_on≤ as of. A credit note’s unallocated remainder is a negative row (due_on= its date). Rows with a zero outstanding amount and zero base are dropped. Totals are in base currency. - P32 Ruling:
GET /payables/suppliers/{id}/balanceis the aging for one supplier, grouped by currency, underpayables.invoice.read. - P33 Ruling: Every write is audited (
party_supplier,purchase_invoice,supplier_payment,ap_allocation); no activity-feed rows — an AP document is not a member’s own request. - P34 Ruling: No reconciliation check is registered in v1; the spec’s “sub-ledger = control” requirement is the drift test of Task 11.
- P35 Ruling (web): The sidebar’s existing
accountinggroup gains Suppliers, Purchase invoices, Supplier payments (three items keep it under a dozen; no new group). Credit notes are a tab of the purchase-invoice page; AP aging is a tab of/reports. Routes are/payables/suppliers,/payables/invoices,/payables/payments. - P36 Ruling:
GET /payables/bankslists the activemy_banksregistry entries underpayables.supplier.read, because/payout/registry-entriesrequirespayout.channel.manage. - P37 Ruling: The invoice and payment forms read accounts and tax codes through the existing
/accounting/accountsand/accounting/tax-codes(accounting.ledger.read). A provisioned Finance role holds no grants today (only the dev seed gives itaccounting.ledger.read), so P3’s Finance set includesaccounting.ledger.read, for new and existing organizations alike. - P38 Ruling: Supplier edits are
PUTof the whole editable shape (not a partial PATCH); bank details have their ownPUT …/bank. - P40 Ruling (user amendment 1):
tax_code.usage ∈ {sales, purchase, both}(tax_code_usage_known, migration00303, existing codes backfilled tosales). The MY pack gains purchase-side codesPST10,PST5,PSVT6,PSVT8,PNTwith no account (the tax is cost, D8); existing organizations get them from the provisioning steppayables_purchase_tax_codes(order 76). Purchase invoice and credit-note lines accept onlypurchase/both; the web line picker lists only those. Expense claims are unchanged. - P41 Ruling (pre-flight B5): The invoice and payment dialogs submit through
useInvoiceTransition/usePaymentTransition(the id may be passed per call), never a raw POST, so the transition’sonSuccessreplaces the cached draft view and the drawer shows the new state. - P42 Ruling (pre-flight S6, spec D8): The web invoice line defaults
tax_amountto net × rate% (percentOfMoney, BigInt, half-up to cents) when a tax code is picked or the net changes under one; the clerk may still edit it. - P43 Ruling (pre-flight S7): P21 stands —
ap_allocationhaspayment_id/credit_note_id(composite FKs) with a generatedsource_kind, not D6’s polymorphicsource_id; spec D6 carries the amendment note. - P44 Ruling (pre-flight S8): P24 stands — a void is refused while allocations exist (reverse them or void the payments first, then void); no combined “reverse in the same action”. Spec D11 carries the amendment note. Surfaced to the user.
- P45 Ruling (pre-flight S13, S15): Supplier
email,phone,registration_noand a document’srelaxation_reasonare validated in Go (422field=… rule=…), not left to the DB CHECKs (500). - P46 Ruling (pre-flight S14): The DB backstops
party_supplier_bank_verifier_differs(403bank_verifier_is_changer) andparty_supplier_bank_change_clears_verification(409) are mapped inmapErr. - P47 Ruling (pre-flight S19): The payments list takes
currencytoo (spec API); “due before” does not apply to a payment. - P48 Ruling (pre-flight G10): A claim-line tax picker lists
usagesalesorbothonly, so claims see exactly today’s codes; none exists in the web today (Task 16 records the grep), and the server’s claim acceptance is unchanged. - P39 Ruling:
einvoice_validated_atis entered by the clerk (the date they validated the UUID on MyInvois); a CHECK requires the UUID with it. MyInvois lookups are the MyInvois item.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00300_party_supplier.sql |
1 | party shared columns + kind; party_supplier (role, bank, verification). |
00301_ap_trade_control_kind.sql |
2 | ap_trade in account and workflow_state CHECKs. |
00302_purchase_invoice.sql |
4 | purchase_invoice, purchase_invoice_line, payables_semantic(), draft guards, duplicate index. |
00303_tax_code_usage.sql |
5A | tax_code.usage (sales/purchase/both), existing rows sales. |
00304_supplier_payment_allocation.sql |
8 | supplier_payment, supplier_payment_line, ap_allocation and its guard. |
00305_payables_owner_grants.sql |
12 | Existing Owners get the nine payables.* keys; existing Finance roles get eight (not verify_bank) and accounting.ledger.read. |
00306_report_export_ap_aging.sql |
14 | report_export_report_known gains ap_aging. |
New Go packages
Section titled “New Go packages”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/payables/ |
1–11 | doc.go, supplier.go, step.go, graphs.go, invoice.go, binder.go, transition.go, payment.go, allocation.go, posting.go, aging.go, cursor.go |
internal/platform/payables/payablestest/ |
3 | Test world on expensestest.Env: supplier, invoice, payment and walk helpers, control-balance reader. |
internal/modules/payables/ |
12, 13 | permissions.go, routes.go, boot.go, http.go, errors.go, supplier_handlers.go, invoice_handlers.go, payment_handlers.go |
web/src/features/payables/ |
15–17 | API hooks, supplier/invoice/payment pages, drawers, forms. |
Modified (shared)
Section titled “Modified (shared)”| Path | Task | Change |
|---|---|---|
internal/kernel/db/migrationranges_test.go |
1 | {300, 339, "Phase 2 AP"} |
internal/kernel/db/retention_test.go |
1, 4, 8 | New tables into their lists. |
constants.yaml (+ generated vocab) |
2, 12 | ap_trade; nine permissions. |
internal/platform/accounting/chart/account.go, doc.go |
2 | IsSubledgerKind("ap_trade"). |
internal/platform/countrypack/my/my.go, countrypack/testpack_test.go, accounting/accountingtest/chart.go, accounting/chart/account_test.go |
2 | The new control kind in every chart fixture. |
internal/platform/workflow/model.go, validate.go |
5 | AccrualOnlyKinds, SignoffKinds += purchase_invoice, the rule. |
internal/platform/accounting/chart/taxcode.go, countrypack/{countrypack,seed}.go, countrypack/my/my.go, internal/modules/accounting/chart_handlers.go |
5A | Tax code usage; five MY purchase codes. |
internal/platform/provisioning/steps.go |
12 | RegisterRolePermissions; rolesStep applies it to roles it creates. |
internal/platform/reporting/export/{export.go,params.go,project.go} |
14 | ap_aging export. |
internal/modules/reports/{routes.go,report_handlers.go} |
14 | GET /reports/ap-aging. |
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) |
2, 12, 13, 14 | ap_trade enums; /payables/…; /reports/ap-aging; ap_aging export. |
sqlc.yaml |
4, 8 | money.Rate overrides for the two fx_rate columns. |
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go |
12 | Compose the module. |
web/src/app/shell/nav.ts, web/src/app/routes.ts |
15 | Three nav items; feature routes. |
web/src/features/accounting/accounts/AccountEditor.tsx, web/src/features/workflow/StateInspector.tsx, web/src/features/accounting/entries/ManualJournalPage.tsx, locales accounts.json, workflowEditor.json |
2 | ap_trade in the three UI lists and its label. |
web/src/features/reports/{ReportsPage.tsx,routes.tsx} + APAgingView.tsx |
17 | AP aging tab. |
web/e2e/seed.ts, web/e2e/support.ts, web/e2e/payables.spec.ts |
18 | Finance’s AP permissions; the e2e. |
docs/ROADMAP.md, docs/DEPLOY_NOTES.md |
18 | Tick AP; deploy notes. |
Task 1: Migration range, party roles, party_supplier
Section titled “Task 1: Migration range, party roles, party_supplier”Files:
- Create:
db/migrations/00300_party_supplier.sql - Create:
internal/platform/payables/doc.go - Modify:
internal/kernel/db/migrationranges_test.go - Modify:
internal/kernel/db/retention_test.go - Test:
internal/platform/payables/schema_test.go
Interfaces:
-
Consumes:
testdb.Start,testdb.Handles{Admin, App}; tablesparty,organization_member,account,tax_code,payout_registry_entry. -
Produces: tables
party(+10 columns,party_kind_known),party_supplier; Go constantspayables.KindInvoice = "purchase_invoice",payables.KindCreditNote = "supplier_credit_note",payables.KindPayment = "supplier_payment",payables.ControlKind = "ap_trade",payables.BinderKind = "purchase_invoice",payables.PermissionVerifyBank = "payables.supplier.verify_bank",payables.PermissionInvoiceRead = "payables.invoice.read",payables.BanksRegistry = "my_banks",payables.EntitySupplier/EntityInvoice/EntityPayment/EntityAllocation; typespayables.FieldError{Field, Rule, Detail},payables.RevisionMismatchError{Current int};payables.ErrNotFound. -
Step 1: Register the range and write the failing schema test.
In internal/kernel/db/migrationranges_test.go, append to migrationRanges after {270, 289, …}:
{300, 339, "Phase 2 AP"},Create internal/platform/payables/schema_test.go:
package payables_test
// Migration 00300's invariants, held with raw SQL as erp_app so that a later// task that forgets a Go check still cannot write the shape. Each refusal is// preceded by the accepted case (AGENTS.md: presence before absence).
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest")
func requirePgError(t *testing.T, err error, code, constraint string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAsf(t, err, &pgErr, "want a *pgconn.PgError, got %T: %v", err, err) require.Equal(t, code, pgErr.Code, "%s", pgErr.Message) require.Equal(t, constraint, pgErr.ConstraintName, "%s", pgErr.Message)}
// rawSupplier inserts a party and its supplier role as erp_app.func rawSupplier(ctx context.Context, tx pgx.Tx, org uuid.UUID, code, name string) (uuid.UUID, error) { var party uuid.UUID if err := tx.QueryRow(ctx, ` INSERT INTO party (org_id, kind, legal_name, country_code) VALUES ($1, 'organization', $2, 'MY') RETURNING id`, org, name).Scan(&party); err != nil { return uuid.Nil, err } _, err := tx.Exec(ctx, ` INSERT INTO party_supplier (org_id, party_id, code, payment_terms_days, currency, created_at, updated_at) VALUES ($1, $2, $3, 30, 'MYR', now(), now())`, org, party, code) return party, err}
func TestPartyTakesTheSupplierKindsAndTheSharedFields(t *testing.T) { e := expensestest.New(t) // PRESENCE: an organization party with every shared field is accepted. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO party (org_id, kind, legal_name, tin, registration_no, country_code, address_line1, address_city, address_postcode, address_state, email, phone) VALUES ($1, 'organization', 'Syarikat Bekalan Sdn Bhd', 'C20880050010', '201901000001', 'MY', 'Lot 5, Jalan Industri', 'Shah Alam', '40000', 'Selangor', 'ap@bekalan.my', '+60312345678')`, e.Org) return err }) // ABSENCE: an unknown kind and a malformed TIN are refused by name. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO party (org_id, kind, legal_name) VALUES ($1, 'robot', 'X')`, e.Org) return err }) requirePgError(t, err, "23514", "party_kind_known") err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO party (org_id, kind, legal_name, tin) VALUES ($1, 'organization', 'X', 'nope')`, e.Org) return err }) requirePgError(t, err, "23514", "party_tin_shape") err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO party (org_id, kind, legal_name, address_city) VALUES ($1, 'organization', 'X', '吉隆坡')`, e.Org) return err }) requirePgError(t, err, "23514", "party_address_language")}
func TestSupplierCodeIsUniquePerOrganization(t *testing.T) { e := expensestest.New(t) other := e.Another(t) // PRESENCE: the same code in two organizations is fine. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := rawSupplier(ctx, tx, e.Org, "BEKAL", "Bekalan Sdn Bhd") return err }) other.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := rawSupplier(ctx, tx, other.Org, "BEKAL", "Bekalan Sdn Bhd") return err }) // ABSENCE: twice in one is refused. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := rawSupplier(ctx, tx, e.Org, "BEKAL", "Another Sdn Bhd") return err }) requirePgError(t, err, "23505", "party_supplier_code_once")}
func TestBankVerifierMustDifferFromChanger(t *testing.T) { e := expensestest.New(t) var bank uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT id FROM payout_registry_entry WHERE org_id = $1 AND registry = 'my_banks' ORDER BY key LIMIT 1`, e.Org).Scan(&bank)) var party uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error party, err = rawSupplier(ctx, tx, e.Org, "BEKAL", "Bekalan Sdn Bhd") if err != nil { return err } _, err = tx.Exec(ctx, ` UPDATE party_supplier SET bank_entry_id = $3, bank_account_no = '5140123456', bank_account_name = 'Bekalan Sdn Bhd', bank_changed_by = $4, bank_changed_at = now() WHERE org_id = $1 AND party_id = $2`, e.Org, party, bank, e.Staff) return err }) verify := func(by uuid.UUID) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` UPDATE party_supplier SET bank_verified_by = $3, bank_verified_at = now() WHERE org_id = $1 AND party_id = $2`, e.Org, party, by) return err }) } // ABSENCE first would be vacuous; PRESENCE: the changer's refusal is // shown against a row a different member CAN verify, below. requirePgError(t, verify(e.Staff), "23514", "party_supplier_bank_verifier_differs") require.NoError(t, verify(e.Owner), "a different member verifies") // A change after verification must clear it: leaving verified_by set is refused. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` UPDATE party_supplier SET bank_account_no = '5140999999', bank_changed_by = $3, bank_changed_at = now() WHERE org_id = $1 AND party_id = $2`, e.Org, party, e.Owner) return err }) requirePgError(t, err, "23514", "party_supplier_bank_change_clears_verification")}Note on the ordering in the last test: the changer’s refusal is asserted and then the different member’s acceptance on the same row — the refusal cannot be vacuous because the very next statement succeeds against the unchanged row.
- Step 2: Run it — expect failure.
Run: go test ./internal/platform/payables/... -count=1
Expected: FAIL — no non-test Go files in …/internal/platform/payables (then, once doc.go exists, ERROR: column "tin" of relation "party" does not exist).
- Step 3: Write
doc.goand the migration.
internal/platform/payables/doc.go:
// Package payables is accounts payable (spec 2026-09-26 AP design): the// supplier role on party, purchase invoices and supplier credit notes (one// table, kind = the workflow kind), supplier payments, the append-only// allocation of payments and credit notes to invoices, and the aging derived// from them. Every posting goes through accounting's posting engine: this// package registers rules keyed (kind, semantic) and never writes a journal// line (spec §10.3).package payables
import ( "errors" "fmt")
const ( KindInvoice = "purchase_invoice" KindCreditNote = "supplier_credit_note" KindPayment = "supplier_payment" // ControlKind is the Trade payables control account (spec D2). ControlKind = "ap_trade" BinderKind = "purchase_invoice" // BanksRegistry is the payout registry a supplier's bank is chosen from. BanksRegistry = "my_banks"
PermissionVerifyBank = "payables.supplier.verify_bank" PermissionInvoiceRead = "payables.invoice.read"
EntitySupplier = "party_supplier" EntityInvoice = "purchase_invoice" EntityPayment = "supplier_payment" EntityAllocation = "ap_allocation")
// SourceKinds is every journal_entry.source_kind this package posts under.var SourceKinds = []string{KindInvoice, KindCreditNote, KindPayment}
// ErrNotFound: no such record in the calling organization.var ErrNotFound = errors.New("payables: no such record")
// FieldError is one refusal about one input field, in the wire shape// `field=<name> rule=<code>: <sentence>`.type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string { return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail)}
func fieldErr(field, rule, detail string) error { return &FieldError{Field: field, Rule: rule, Detail: detail} }
// RevisionMismatchError: the caller's If-Match is stale.type RevisionMismatchError struct{ Current int }
func (e *RevisionMismatchError) Error() string { return fmt.Sprintf("payables: the record is at revision %d", e.Current)}db/migrations/00300_party_supplier.sql:
-- +goose Up
-- AP spec D1: supplier is a ROLE on party. party gains the fields every role-- shares; what the party IS (a person or an organization) is party.kind,-- what it DOES is a role table. 00009's unnamed kind CHECK is replaced by a-- named one (wave-2 §3).ALTER TABLE party DROP CONSTRAINT party_kind_check;ALTER TABLE party ADD CONSTRAINT party_kind_known CHECK (kind IN ('employee','organization','individual'));
ALTER TABLE party ADD COLUMN tin TEXT, ADD COLUMN registration_no TEXT, ADD COLUMN country_code TEXT, ADD COLUMN address_line1 TEXT, ADD COLUMN address_line2 TEXT, ADD COLUMN address_city TEXT, ADD COLUMN address_postcode TEXT, ADD COLUMN address_state TEXT, ADD COLUMN email TEXT, ADD COLUMN phone TEXT;
-- mirrors expense_claim_line_tin_shape / taxrecord's supplierTIN.ALTER TABLE party ADD CONSTRAINT party_tin_shape CHECK (tin IS NULL OR tin ~ '^[A-Z]{1,2}[0-9]{8,13}$');ALTER TABLE party ADD CONSTRAINT party_registration_no_shape CHECK (registration_no IS NULL OR registration_no ~ '^[A-Z0-9-]{4,20}$');ALTER TABLE party ADD CONSTRAINT party_country_code CHECK (country_code IS NULL OR country_code ~ '^[A-Z]{2}$');ALTER TABLE party ADD CONSTRAINT party_email_shape CHECK (email IS NULL OR email ~ '^[^@[:space:]]+@[^@[:space:]]+$');ALTER TABLE party ADD CONSTRAINT party_phone_shape CHECK (phone IS NULL OR phone ~ '^\+?[0-9 -]{6,20}$');ALTER TABLE party ADD CONSTRAINT party_address_language CHECK ( coalesce(address_line1, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(address_line2, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(address_city, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(address_state, '') !~ '[-ヿ㐀-䶿一-鿿가-]');
-- The supplier role (D1) with its bank details (D10).CREATE TABLE party_supplier ( party_id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT party_supplier_code_format CHECK (code ~ '^[A-Z0-9][A-Z0-9_-]{0,31}$'), payment_terms_days INT NOT NULL CONSTRAINT party_supplier_terms_range CHECK (payment_terms_days BETWEEN 0 AND 365), currency TEXT NOT NULL CONSTRAINT party_supplier_currency_code CHECK (currency ~ '^[A-Z]{3}$'), default_expense_account_id UUID, default_tax_code_id UUID, bank_entry_id UUID, bank_account_no TEXT CONSTRAINT party_supplier_account_no_shape CHECK (bank_account_no IS NULL OR bank_account_no ~ '^[0-9]{6,20}$'), bank_account_name TEXT, bank_changed_by UUID, bank_changed_at TIMESTAMPTZ, bank_verified_by UUID, bank_verified_at TIMESTAMPTZ, -- What the last change replaced, so the verifier sees old -> new (Ruling P26). bank_prev_entry_id UUID, bank_prev_account_no TEXT, bank_prev_account_name TEXT, is_active BOOLEAN NOT NULL DEFAULT true, revision INT NOT NULL DEFAULT 1 CONSTRAINT party_supplier_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT party_supplier_tenant_id UNIQUE (org_id, party_id), CONSTRAINT party_supplier_code_once UNIQUE (org_id, code), CONSTRAINT party_supplier_bank_complete CHECK ( (bank_entry_id IS NULL) = (bank_account_no IS NULL) AND (bank_account_no IS NULL) = (bank_account_name IS NULL)), CONSTRAINT party_supplier_bank_changed_pair CHECK ((bank_changed_by IS NULL) = (bank_changed_at IS NULL)), CONSTRAINT party_supplier_bank_verified_pair CHECK ((bank_verified_by IS NULL) = (bank_verified_at IS NULL)), CONSTRAINT party_supplier_bank_verified_needs_bank CHECK (bank_verified_by IS NULL OR (bank_account_no IS NOT NULL AND bank_changed_by IS NOT NULL)), -- D10: a second person verifies. CONSTRAINT party_supplier_bank_verifier_differs CHECK (bank_verified_by IS NULL OR bank_verified_by <> bank_changed_by), CONSTRAINT party_supplier_bank_name_language CHECK (coalesce(bank_account_name, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(bank_prev_account_name, '') !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT party_supplier_party_fk FOREIGN KEY (org_id, party_id) REFERENCES party (org_id, id), CONSTRAINT party_supplier_expense_account_fk FOREIGN KEY (org_id, default_expense_account_id) REFERENCES account (org_id, id), CONSTRAINT party_supplier_tax_code_fk FOREIGN KEY (org_id, default_tax_code_id) REFERENCES tax_code (org_id, id), CONSTRAINT party_supplier_bank_entry_fk FOREIGN KEY (org_id, bank_entry_id) REFERENCES payout_registry_entry (org_id, id), CONSTRAINT party_supplier_bank_prev_entry_fk FOREIGN KEY (org_id, bank_prev_entry_id) REFERENCES payout_registry_entry (org_id, id), CONSTRAINT party_supplier_bank_changed_by_fk FOREIGN KEY (org_id, bank_changed_by) REFERENCES organization_member (org_id, id), CONSTRAINT party_supplier_bank_verified_by_fk FOREIGN KEY (org_id, bank_verified_by) REFERENCES organization_member (org_id, id));ALTER TABLE party_supplier ENABLE ROW LEVEL SECURITY;ALTER TABLE party_supplier FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON party_supplier USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON party_supplier FROM erp_app;
-- D10: any change to the bank details clears the verification.-- +goose StatementBeginCREATE FUNCTION party_supplier_bank_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (NEW.bank_entry_id, NEW.bank_account_no, NEW.bank_account_name) IS DISTINCT FROM (OLD.bank_entry_id, OLD.bank_account_no, OLD.bank_account_name) AND NEW.bank_verified_by IS NOT NULL THEN RAISE EXCEPTION 'supplier %: changing bank details clears their verification', NEW.party_id USING ERRCODE = '23514', CONSTRAINT = 'party_supplier_bank_change_clears_verification'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER party_supplier_bank_guard BEFORE UPDATE ON party_supplier FOR EACH ROW EXECUTE FUNCTION party_supplier_bank_guard();
-- +goose DownDROP TABLE party_supplier;DROP FUNCTION party_supplier_bank_guard();ALTER TABLE party DROP CONSTRAINT party_address_language, DROP CONSTRAINT party_phone_shape, DROP CONSTRAINT party_email_shape, DROP CONSTRAINT party_country_code, DROP CONSTRAINT party_registration_no_shape, DROP CONSTRAINT party_tin_shape;ALTER TABLE party DROP COLUMN phone, DROP COLUMN email, DROP COLUMN address_state, DROP COLUMN address_postcode, DROP COLUMN address_city, DROP COLUMN address_line2, DROP COLUMN address_line1, DROP COLUMN country_code, DROP COLUMN registration_no, DROP COLUMN tin;ALTER TABLE party DROP CONSTRAINT party_kind_known;ALTER TABLE party ADD CONSTRAINT party_kind_check CHECK (kind IN ('employee'));Confirm the old name before relying on it: psql -c "\d party" on a migrated dev DB shows party_kind_check; if it differs, use the name shown.
In internal/kernel/db/retention_test.go, add to amendable after "company_profile",:
// AP (spec D1, D10, D11): the supplier role and its bank details. // Deactivated (is_active), never deleted. "party_supplier",- Step 4: Run passing.
Run: make generate && go test ./internal/platform/payables/... ./internal/kernel/db/... -count=1
Expected: PASS (TestEveryTableIsTenantIsolated, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryConstraintIsNamed, TestEveryMigrationIsNumberedInItsOwnersRange included).
-
Step 5: Sabotage. Delete
CONSTRAINT party_supplier_bank_verifier_differs …from the migration, rerungo test ./internal/platform/payables/ -run TestBankVerifier -count=1, readwant a *pgconn.PgError, got <nil>, restore. Remove"party_supplier"fromamendable, rerungo test ./internal/kernel/db/ -run TestOnlyOperational -count=1, readthese appear in none: [party_supplier], restore. -
Step 6: Commit.
git add db/migrations/00300_party_supplier.sql internal/platform/payables internal/kernel/db internal/gengit commit -m "AP Task 1: party roles, party_supplier with two-person bank verification
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: ap_trade control kind, chart seed, provisioning for existing organizations
Section titled “Task 2: ap_trade control kind, chart seed, provisioning for existing organizations”Files:
- Create:
db/migrations/00301_ap_trade_control_kind.sql - Create:
internal/platform/payables/step.go - Modify:
constants.yaml(controlKinds), generatedinternal/kernel/vocab/vocab.gen.go,packages/contracts/vocab.ts - Modify:
internal/platform/accounting/chart/account.go(IsSubledgerKind),internal/platform/accounting/chart/doc.go(kind list comment) - Modify:
internal/platform/countrypack/my/my.go,internal/platform/countrypack/testpack_test.go,internal/platform/accounting/accountingtest/chart.go,internal/platform/accounting/chart/account_test.go - Modify:
api/openapi.yaml(the onecontrol_kindenum — the Account schema, ~line 7675 — and the manual-journal description at ~line 1846;WorkflowState.control_kindis a free string) - Modify:
web/src/features/accounting/accounts/AccountEditor.tsx,web/src/features/workflow/StateInspector.tsx,web/src/features/accounting/entries/ManualJournalPage.tsx,web/src/lib/i18n/locales/{en,ms,zh}/{accounts,workflowEditor}.json - Modify:
internal/platform/countrypack/my/my_test.go(require.Len(t, p.ChartOfAccounts(), 39)→40, and its comment “39 accounts” → “40 accounts”) - Test:
internal/platform/payables/step_test.go,internal/platform/countrypack/my/my_test.go
Interfaces:
-
Consumes:
chart.ResolveControl,chart.GetAccountByCode,chart.CreateAccount,countrypacktranslation shape,provisioning.Register,company.Get. -
Produces: control kind
"ap_trade"everywherevocab.ControlKindsis read;chart.IsSubledgerKind("ap_trade") == true; provisioning step keypayables.StepKeyTradePayables = "payables_trade_payables",payables.StepOrderTradePayables = 75; MY seed account2105. -
Step 1: Failing test. Create
internal/platform/payables/step_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/provisioning")
func TestANewMalaysianOrgHasTradePayables(t *testing.T) { e := expensestest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { a, err := chart.ResolveControl(ctx, tx, payables.ControlKind) require.NoError(t, err) require.Equal(t, "2105", a.Code) require.Equal(t, chart.TypeLiability, a.Type) require.True(t, chart.IsSubledgerKind(payables.ControlKind), "no manual journal lines on Trade payables") return nil })}
func TestTheStepGivesAnExistingOrgTradePayables(t *testing.T) { e := expensestest.New(t) // Simulate an organization provisioned before this plan: no ap_trade account. _, err := e.H.Admin.Exec(context.Background(), `UPDATE account SET control_kind = NULL, code = '2105-OLD', is_active = false WHERE org_id = $1 AND control_kind = 'ap_trade'`, e.Org) require.NoError(t, err) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE of the gap: nothing resolves. _, err := chart.ResolveControl(ctx, tx, payables.ControlKind) var none *chart.NoControlAccountError require.ErrorAs(t, err, &none) report, err := provisioning.Run(ctx, tx, e.Clock, provisioning.Input{}) require.NoError(t, err) var seen bool for _, s := range report.Steps { if s.Key == payables.StepKeyTradePayables { seen = true } } require.True(t, seen, "the step is registered") a, err := chart.ResolveControl(ctx, tx, payables.ControlKind) require.NoError(t, err, "the step repaired it") require.Equal(t, "2105", a.Code) return nil })}If provisioning.Run needs OwnerPermissions/OwnerMemberID for steps whose Check is false, the fixture’s other steps are all done, so an empty Input only reaches this step’s Run; if Run returns ErrNoGrantablePermissions, pass expensestest’s permission set by exporting it as expensestest.FixturePermissions (rename of the unexported var, same value).
-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run TradePayables -count=1→ FAIL:undefined: payables.StepKeyTradePayables(after adding a stub:chart: resolve control: no active account for control kind "ap_trade"). -
Step 3: Implement.
constants.yaml, controlKinds: — append - ap_trade after - fx_gain_loss. Run make generate.
db/migrations/00301_ap_trade_control_kind.sql:
-- +goose Up-- AP spec D2: Trade payables is its own control kind, a liability like ap.ALTER TABLE account DROP CONSTRAINT account_control_kind_known, ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade'));ALTER TABLE account DROP CONSTRAINT account_control_kind_type, ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL OR (control_kind IN ('ap','petty_cash_ap','ap_trade') AND type = 'liability') OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset') OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));-- 00030's CHECK is unnamed; replace it with a named one.ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_check, ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade'));
-- +goose DownALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known, ADD CONSTRAINT workflow_state_control_kind_check CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss'));ALTER TABLE account DROP CONSTRAINT account_control_kind_type, ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL OR (control_kind IN ('ap','petty_cash_ap') AND type = 'liability') OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset') OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));ALTER TABLE account DROP CONSTRAINT account_control_kind_known, ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss'));(workflow_state_control_kind_check is Postgres’s generated name for 00030’s column CHECK; confirm with \d workflow_state and use what it shows.)
internal/platform/accounting/chart/account.go — IsSubledgerKind:
case "ap", "employee_advances", "petty_cash_ap", "ar", "ap_trade":and update its doc comment’s list the same way; chart/doc.go’s kind list gains "ap_trade".
internal/platform/countrypack/my/my.go — insert after the 2100 header, and rename 2110:
{Code: "2105", ParentCode: "2100", Type: "liability", ControlKind: "ap_trade", Names: names("Trade payables", "Pemiutang perdagangan", "应付账款(贸易)")}, {Code: "2110", ParentCode: "2100", Type: "liability", ControlKind: "ap", Names: names("Employee claims payable", "Tuntutan pekerja belum bayar", "应付员工报销款")},internal/platform/countrypack/testpack_test.go — add after the 2100 ap leaf:
{Code: "2105", ParentCode: "2000", Type: chart.TypeLiability, ControlKind: "ap_trade", Names: names("Trade payables", "Pemiutang perdagangan", "应付账款(贸易)")},internal/platform/accounting/accountingtest/chart.go — add APTrade chart.Account to Chart (next to AP, PettyCashAP) and after the ap leaf:
apTrade := "ap_trade" out.APTrade = create(out.Liabilities, true, chart.AccountInput{ Code: "2105", Name: "Trade payables", Type: chart.TypeLiability, ControlKind: &apTrade})internal/platform/accounting/chart/account_test.go — typeFor gains "ap_trade": chart.TypeLiability,.
internal/platform/payables/step.go:
package payables
import ( "context" "errors" "fmt"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/provisioning")
// The Trade payables step (Ruling P7): a new organization's chart already// carries 2105 from the pack, so this step is done for it on arrival; an// organization provisioned before AP gets the account on its next// POST /v1/org/provision. Order 75: after the chart (70), fiscal year (74).const ( StepKeyTradePayables = "payables_trade_payables" StepOrderTradePayables = 75 tradePayablesCode = "2105" tradePayablesParent = "2100" countryMY = "MY")
func init() { provisioning.Register(tradePayablesStep{}) }
type tradePayablesStep struct{}
func (tradePayablesStep) Key() string { return StepKeyTradePayables }func (tradePayablesStep) Order() int { return StepOrderTradePayables }
// Check is done outside Malaysia, when an active ap_trade account exists, or// when code 2105 is taken by something else (then chart_of_accounts' own// control_kind_unmapped warning is what an administrator sees; re-running// could not repair it).func (tradePayablesStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) { profile, err := company.Get(ctx, tx) if err != nil { return false, nil, fmt.Errorf("payables: trade payables check: %w", err) } if profile.CountryCode != countryMY { return true, nil, nil } _, err = chart.ResolveControl(ctx, tx, ControlKind) var none *chart.NoControlAccountError switch { case err == nil: return true, nil, nil case !errors.As(err, &none): return false, nil, fmt.Errorf("payables: trade payables check: %w", err) } _, err = chart.GetAccountByCode(ctx, tx, tradePayablesCode) if errors.Is(err, chart.ErrNotFound) { return false, nil, nil } return err == nil, nil, err}
func (tradePayablesStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return fmt.Errorf("payables: trade payables run: %w", tenancy.ErrNoOrg) } kind := ControlKind in := chart.AccountInput{Code: tradePayablesCode, Name: "Trade payables", Type: chart.TypeLiability, ControlKind: &kind} if parent, err := chart.GetAccountByCode(ctx, tx, tradePayablesParent); err == nil && !parent.IsPostable { in.ParentID = &parent.ID } a, err := chart.CreateAccount(ctx, tx, c, in) if err != nil { return fmt.Errorf("payables: trade payables run: %w", err) } for locale, name := range map[string]string{"en": "Trade payables", "ms": "Pemiutang perdagangan", "zh": "应付账款(贸易)"} { if _, err := tx.Exec(ctx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, 'account', $2, 'name', $3, $4) ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO NOTHING`, orgID, a.ID, locale, name); err != nil { return fmt.Errorf("payables: trade payables translation: %w", err) } } // The same "translate" audit row countrypack.translate writes for a seeded account. return audit.Write(ctx, tx, c, audit.Entry{EntityKind: "account", EntityID: a.ID, Action: "translate", After: map[string]string{"en": "Trade payables", "ms": "Pemiutang perdagangan", "zh": "应付账款(贸易)"}})}api/openapi.yaml: the Account schema’s enum: [ap, employee_advances, petty_cash_ap, ar, bank, fx_gain_loss] becomes enum: [ap, employee_advances, petty_cash_ap, ar, bank, fx_gain_loss, ap_trade] (the only such enum; WorkflowState.control_kind is a free string); the manual-journal description’s list (ap, ar, employee_advances, petty_cash_ap) becomes (ap, ap_trade, ar, employee_advances, petty_cash_ap). make generate.
Web: append 'ap_trade' to KINDS in AccountEditor.tsx (after 'fx_gain_loss'), to CONTROL_KINDS in StateInspector.tsx, and to SUBLEDGER in ManualJournalPage.tsx. In each of accounts.json and workflowEditor.json, next to the existing "petty_cash_ap" key in the same object: en "ap_trade": "Trade payables", ms "ap_trade": "Pemiutang perdagangan", zh "ap_trade": "应付账款(贸易)".
-
Step 4: Run passing.
go test ./internal/platform/payables/... ./internal/platform/accounting/... ./internal/platform/countrypack/... ./internal/platform/workflow/... -count=1 && pnpm -C web test && pnpm -C web typecheck→ PASS. -
Step 5: Sabotage. Remove
"ap_trade"fromIsSubledgerKind;TestANewMalaysianOrgHasTradePayablesfails withno manual journal lines on Trade payables; restore. MakeCheckreturntrueunconditionally;TestTheStepGivesAnExistingOrgTradePayablesfails atthe step repaired it; restore. -
Step 6: Commit.
git add -A && git commit -m "AP Task 2: ap_trade control kind, Trade payables seed and repair step" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: Supplier service, bank change and verification, test world
Section titled “Task 3: Supplier service, bank change and verification, test world”Files:
- Create:
internal/platform/payables/supplier.go - Create:
internal/platform/payables/payablestest/env.go - Test:
internal/platform/payables/supplier_test.go
Interfaces:
- Consumes:
recordtext.Check(field, s string) error(internal/kernel/recordtext),taxrecord.Normalize,payout.GetRegistryEntry(ctx, tx, id) (payout.RegistryEntry, error),chart.GetAccount,chart.GetTaxCode,audit.Write,expensestest.New. - Produces:
type Address struct{ Line1, Line2, City, Postcode, State string }type Bank struct { EntryID uuid.UUID AccountNo string AccountName string}type Supplier struct { PartyID uuid.UUID Code, PartyKind, LegalName string TIN, RegistrationNo *string CountryCode string Address Address Email, Phone *string PaymentTermsDays int Currency string DefaultExpenseAccountID *uuid.UUID DefaultTaxCodeID *uuid.UUID Bank, PreviousBank *Bank BankChangedBy, BankVerifiedBy *uuid.UUID BankChangedAt, BankVerifiedAt *time.Time IsActive bool Revision int CreatedAt, UpdatedAt time.Time}func (s Supplier) BankVerified() booltype SupplierInput struct { Code, PartyKind, LegalName string TIN, RegistrationNo *string CountryCode string Address Address Email, Phone *string PaymentTermsDays int Currency string DefaultExpenseAccountID, DefaultTaxCodeID *uuid.UUID IsActive bool // UpdateSupplier only; CreateSupplier ignores it (always true)}func CreateSupplier(ctx context.Context, tx pgx.Tx, c clock.Clock, in SupplierInput) (Supplier, error)func GetSupplier(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Supplier, error)func lockSupplier(ctx context.Context, tx pgx.Tx, id uuid.UUID, mode string) (Supplier, error) // mode "UPDATE" | "SHARE"func ListSuppliers(ctx context.Context, tx pgx.Tx, f SupplierFilter) ([]Supplier, error)type SupplierFilter struct{ Query string; ActiveOnly bool }func UpdateSupplier(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in SupplierInput) (Supplier, error)func SetBank(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, b Bank) (Supplier, error)func VerifyBank(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int) (Supplier, error)func MaskAccountNo(no string) string // "••••3456"var ErrDuplicateSupplierCode, ErrSameVerifier, ErrNoBank, ErrAlreadyVerified error// payablestesttype Env struct{ *expensestest.Env }func New(t *testing.T) *Envfunc (e *Env) Another(t *testing.T) *Envfunc (e *Env) Bank(t *testing.T) uuid.UUID // first active my_banks entryfunc (e *Env) Supplier(t *testing.T, code string) payables.Supplier // created by e.Staff, MYR, 30 daysfunc (e *Env) VerifiedSupplier(t *testing.T, code string) payables.Supplier // bank set by Staff, verified by Ownerfunc (e *Env) Account(t *testing.T, code string) uuid.UUID- Step 1: Failing test.
internal/platform/payables/supplier_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
func asMember(e *payablestest.Env, m uuid.UUID) context.Context { return e.As(m) }
func TestCreateSupplierAndRefuseADuplicateCode(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") require.Equal(t, "organization", s.PartyKind) require.Equal(t, 30, s.PaymentTermsDays) require.True(t, s.IsActive) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateSupplier(ctx, tx, e.Clock, payables.SupplierInput{ Code: "bekal", PartyKind: "organization", LegalName: "Other Sdn Bhd", CountryCode: "MY", PaymentTermsDays: 30, Currency: "MYR"}) return err }) require.ErrorIs(t, err, payables.ErrDuplicateSupplierCode, "codes are upper-cased before the unique check")}
func TestSupplierRefusesAChineseScriptNameAndABadTIN(t *testing.T) { e := payablestest.New(t) base := payables.SupplierInput{Code: "OK1", PartyKind: "organization", LegalName: "Kedai Ok", CountryCode: "MY", PaymentTermsDays: 30, Currency: "MYR"} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE _, err := payables.CreateSupplier(ctx, tx, e.Clock, base) return err }) bad := base bad.Code, bad.LegalName = "BAD1", "好公司" var fe *payables.FieldError require.ErrorAs(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateSupplier(ctx, tx, e.Clock, bad) return err }), &fe) require.Equal(t, "legal_name", fe.Field) tin := "12345" bad = base bad.Code, bad.TIN = "BAD2", &tin require.ErrorAs(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateSupplier(ctx, tx, e.Clock, bad) return err }), &fe) require.Equal(t, "tin", fe.Field)}
func TestBankDetailsNeedASecondPerson(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") var after payables.Supplier require.NoError(t, e.DB.InTx(asMember(e, e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error after, err = payables.SetBank(ctx, tx, e.Clock, s.PartyID, e.Staff, s.Revision, payables.Bank{EntryID: e.Bank(t), AccountNo: "5140123456", AccountName: "Bekalan Sdn Bhd"}) return err })) require.False(t, after.BankVerified()) // ABSENCE: the changer cannot verify. err := e.DB.InTx(asMember(e, e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.VerifyBank(ctx, tx, e.Clock, s.PartyID, e.Staff, after.Revision) return err }) require.ErrorIs(t, err, payables.ErrSameVerifier) // PRESENCE: another member can. require.NoError(t, e.DB.InTx(asMember(e, e.Owner), func(ctx context.Context, tx pgx.Tx) error { v, err := payables.VerifyBank(ctx, tx, e.Clock, s.PartyID, e.Owner, after.Revision) require.True(t, v.BankVerified()) return err })) // A change clears it, and the previous details are kept for the verifier. var changed payables.Supplier require.NoError(t, e.DB.InTx(asMember(e, e.Owner), func(ctx context.Context, tx pgx.Tx) error { cur, _ := payables.GetSupplier(ctx, tx, s.PartyID) var err error changed, err = payables.SetBank(ctx, tx, e.Clock, s.PartyID, e.Owner, cur.Revision, payables.Bank{EntryID: e.Bank(t), AccountNo: "5140999999", AccountName: "Bekalan Sdn Bhd"}) return err })) require.False(t, changed.BankVerified()) require.Equal(t, "5140123456", changed.PreviousBank.AccountNo) require.Equal(t, "••••9999", payables.MaskAccountNo(changed.Bank.AccountNo)) require.Equal(t, 3, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'party_supplier' AND action IN ('bank_change','bank_verify')`, e.Org))}
func TestSupplierContactShapesAreFieldErrors(t *testing.T) { e := payablestest.New(t) ok, bad := "ap@bekalan.my", "not-an-email" in := payables.SupplierInput{Code: "S13", PartyKind: "organization", LegalName: "Kedai S13", CountryCode: "MY", PaymentTermsDays: 30, Currency: "MYR", Email: &ok} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE _, err := payables.CreateSupplier(ctx, tx, e.Clock, in) return err }) in.Code, in.Email = "S13B", &bad var fe *payables.FieldError require.ErrorAs(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateSupplier(ctx, tx, e.Clock, in) return err }), &fe) require.Equal(t, "email", fe.Field)}
func TestUpdateSupplierNeedsTheCurrentRevision(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") in := payables.SupplierInput{Code: s.Code, PartyKind: s.PartyKind, LegalName: "Bekalan Baru Sdn Bhd", CountryCode: "MY", PaymentTermsDays: 45, Currency: "MYR", IsActive: true} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE u, err := payables.UpdateSupplier(ctx, tx, e.Clock, s.PartyID, s.Revision, in) require.Equal(t, 45, u.PaymentTermsDays) return err }) var stale *payables.RevisionMismatchError require.ErrorAs(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payables.UpdateSupplier(ctx, tx, e.Clock, s.PartyID, s.Revision, in) return err }), &stale) require.Equal(t, s.Revision+1, stale.Current)}(Import github.com/google/uuid for asMember; drop the unused audit import.)
-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run Supplier -count=1→ FAIL:undefined: payablestest.New. -
Step 3: Implement
supplier.go.
package payables
import ( "context" "errors" "fmt" "regexp" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/recordtext "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/payout" "github.com/mcom/erp/internal/platform/taxrecord")
var ( ErrDuplicateSupplierCode = errors.New("payables: another supplier has this code") ErrSameVerifier = errors.New("payables: the member who changed the bank details cannot verify them") ErrNoBank = errors.New("payables: the supplier has no bank details to verify") ErrAlreadyVerified = errors.New("payables: the bank details are already verified"))
// (types Address, Bank, Supplier, SupplierInput, SupplierFilter exactly as in Interfaces)
func (s Supplier) BankVerified() bool { return s.Bank != nil && s.BankVerifiedAt != nil }
// MaskAccountNo shows the last four digits (Ruling P26).func MaskAccountNo(no string) string { if len(no) <= 4 { return no } return "••••" + no[len(no)-4:]}
const supplierSelect = ` SELECT p.id, s.code, p.kind, p.legal_name, p.tin, p.registration_no, coalesce(p.country_code, ''), coalesce(p.address_line1, ''), coalesce(p.address_line2, ''), coalesce(p.address_city, ''), coalesce(p.address_postcode, ''), coalesce(p.address_state, ''), p.email, p.phone, s.payment_terms_days, s.currency, s.default_expense_account_id, s.default_tax_code_id, s.bank_entry_id, s.bank_account_no, s.bank_account_name, s.bank_prev_entry_id, s.bank_prev_account_no, s.bank_prev_account_name, s.bank_changed_by, s.bank_changed_at, s.bank_verified_by, s.bank_verified_at, s.is_active, s.revision, s.created_at, s.updated_at FROM party_supplier s JOIN party p ON p.org_id = s.org_id AND p.id = s.party_id WHERE s.org_id = $1 AND p.org_id = $1 `
func scanSupplier(row pgx.Row) (Supplier, error) { var s Supplier var bankID, prevID *uuid.UUID var bankNo, bankName, prevNo, prevName *string if err := row.Scan(&s.PartyID, &s.Code, &s.PartyKind, &s.LegalName, &s.TIN, &s.RegistrationNo, &s.CountryCode, &s.Address.Line1, &s.Address.Line2, &s.Address.City, &s.Address.Postcode, &s.Address.State, &s.Email, &s.Phone, &s.PaymentTermsDays, &s.Currency, &s.DefaultExpenseAccountID, &s.DefaultTaxCodeID, &bankID, &bankNo, &bankName, &prevID, &prevNo, &prevName, &s.BankChangedBy, &s.BankChangedAt, &s.BankVerifiedBy, &s.BankVerifiedAt, &s.IsActive, &s.Revision, &s.CreatedAt, &s.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return Supplier{}, ErrNotFound } return Supplier{}, err } if bankID != nil { s.Bank = &Bank{EntryID: *bankID, AccountNo: *bankNo, AccountName: *bankName} } if prevID != nil { s.PreviousBank = &Bank{EntryID: *prevID, AccountNo: *prevNo, AccountName: *prevName} } return s, nil}
func orgOf(ctx context.Context) (uuid.UUID, error) { id, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("payables: %w", tenancy.ErrNoOrg) } return id, nil}
func GetSupplier(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Supplier, error) { org, err := orgOf(ctx) if err != nil { return Supplier{}, err } return scanSupplier(tx.QueryRow(ctx, supplierSelect+`AND s.party_id = $2`, org, id))}
func lockSupplier(ctx context.Context, tx pgx.Tx, id uuid.UUID, mode string) (Supplier, error) { org, err := orgOf(ctx) if err != nil { return Supplier{}, err } return scanSupplier(tx.QueryRow(ctx, supplierSelect+`AND s.party_id = $2 FOR `+mode+` OF s`, org, id))}
func ListSuppliers(ctx context.Context, tx pgx.Tx, f SupplierFilter) ([]Supplier, error) { org, err := orgOf(ctx) if err != nil { return nil, err } q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(f.Query)) + "%" rows, err := tx.Query(ctx, supplierSelect+` AND ($2 = '%%' OR s.code ILIKE $2 OR p.legal_name ILIKE $2) AND (NOT $3 OR s.is_active) ORDER BY s.code LIMIT 1000`, org, q, f.ActiveOnly) if err != nil { return nil, fmt.Errorf("payables: list suppliers: %w", err) } defer rows.Close() var out []Supplier for rows.Next() { s, err := scanSupplier(rows) if err != nil { return nil, err } out = append(out, s) } return out, rows.Err()}
// validate normalizes in and checks every field against the rules the// migration also enforces, so the caller gets a named field, not a 23514.func validate(ctx context.Context, tx pgx.Tx, in SupplierInput) (SupplierInput, error) { in.Code = strings.ToUpper(strings.TrimSpace(in.Code)) in.LegalName = strings.TrimSpace(in.LegalName) in.Currency = strings.ToUpper(strings.TrimSpace(in.Currency)) in.CountryCode = strings.ToUpper(strings.TrimSpace(in.CountryCode)) switch { case !supplierCode.MatchString(in.Code): return in, fieldErr("code", "code_format", "a code is 1 to 32 letters, digits, - or _") case in.PartyKind != "organization" && in.PartyKind != "individual": return in, fieldErr("party_kind", "kind_known", "a supplier is an organization or an individual") case in.LegalName == "" || len(in.LegalName) > 200: return in, fieldErr("legal_name", "required", "the supplier's registered name is required") case !countryCode.MatchString(in.CountryCode): return in, fieldErr("country_code", "country_code", "a two-letter country code, such as MY") case !currencyCode.MatchString(in.Currency): return in, fieldErr("currency", "currency_code", "a three-letter currency code, such as MYR") case in.PaymentTermsDays < 0 || in.PaymentTermsDays > 365: return in, fieldErr("payment_terms_days", "terms_range", "payment terms are 0 to 365 days") } // S13: the Go twins of 00300's party_*_shape CHECKs, so a bad value is a // 422 naming the field, not a 23514 surfacing as 500. switch { case in.Email != nil && *in.Email != "" && !emailShape.MatchString(*in.Email): return in, fieldErr("email", "email_shape", "an email address such as ap@supplier.my") case in.Phone != nil && *in.Phone != "" && !phoneShape.MatchString(*in.Phone): return in, fieldErr("phone", "phone_shape", "6 to 20 digits, spaces or dashes, optionally starting with +") case in.RegistrationNo != nil && *in.RegistrationNo != "" && !registrationShape.MatchString(strings.ToUpper(*in.RegistrationNo)): return in, fieldErr("registration_no", "registration_no_shape", "4 to 20 letters, digits or dashes") } in.Email, in.Phone = nullableP(in.Email), nullableP(in.Phone) if in.RegistrationNo = nullableP(in.RegistrationNo); in.RegistrationNo != nil { up := strings.ToUpper(*in.RegistrationNo) in.RegistrationNo = &up } for field, v := range map[string]string{"legal_name": in.LegalName, "address_line1": in.Address.Line1, "address_line2": in.Address.Line2, "address_city": in.Address.City, "address_state": in.Address.State} { if err := recordtext.Check(field, v); err != nil { return in, fieldErr(field, "record_language", "record text is English or Bahasa Malaysia") } } rec, err := taxrecord.Normalize(taxrecord.Record{SupplierTIN: in.TIN}) if err != nil { return in, fieldErr("tin", "tin_shape", "a tax identification number is one or two letters then 8 to 13 digits") } in.TIN = rec.SupplierTIN if in.DefaultExpenseAccountID != nil { if err := checkLineAccount(ctx, tx, *in.DefaultExpenseAccountID); err != nil { return in, fieldErr("default_expense_account_id", "account_usable", err.Error()) } } if in.DefaultTaxCodeID != nil { if tc, err := chart.GetTaxCode(ctx, tx, *in.DefaultTaxCodeID); err != nil || !tc.IsActive { return in, fieldErr("default_tax_code_id", "tax_code_active", "the default tax code must be an active tax code") } } return in, nil}
func nullable(s string) *string { if s = strings.TrimSpace(s); s == "" { return nil } return &s}
func CreateSupplier(ctx context.Context, tx pgx.Tx, c clock.Clock, in SupplierInput) (Supplier, error) { org, err := orgOf(ctx) if err != nil { return Supplier{}, err } if in, err = validate(ctx, tx, in); err != nil { return Supplier{}, err } now := c.Now() var party uuid.UUID if err := tx.QueryRow(ctx, ` INSERT INTO party (org_id, kind, legal_name, tin, registration_no, country_code, address_line1, address_line2, address_city, address_postcode, address_state, email, phone) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`, org, in.PartyKind, in.LegalName, in.TIN, in.RegistrationNo, in.CountryCode, nullable(in.Address.Line1), nullable(in.Address.Line2), nullable(in.Address.City), nullable(in.Address.Postcode), nullable(in.Address.State), in.Email, in.Phone).Scan(&party); err != nil { return Supplier{}, fmt.Errorf("payables: create party: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO party_supplier (org_id, party_id, code, payment_terms_days, currency, default_expense_account_id, default_tax_code_id, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)`, org, party, in.Code, in.PaymentTermsDays, in.Currency, in.DefaultExpenseAccountID, in.DefaultTaxCodeID, now); err != nil { return Supplier{}, mapSupplierWrite(err) } s, err := GetSupplier(ctx, tx, party) if err != nil { return Supplier{}, err } return s, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySupplier, EntityID: party, Action: "create", After: s})}
func mapSupplierWrite(err error) error { var pg *pgconn.PgError if errors.As(err, &pg) && pg.ConstraintName == "party_supplier_code_once" { return ErrDuplicateSupplierCode } return err}
func UpdateSupplier(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in SupplierInput) (Supplier, error) { org, err := orgOf(ctx) if err != nil { return Supplier{}, err } before, err := lockSupplier(ctx, tx, id, "UPDATE") if err != nil { return Supplier{}, err } if before.Revision != expectedRevision { return Supplier{}, &RevisionMismatchError{Current: before.Revision} } if in, err = validate(ctx, tx, in); err != nil { return Supplier{}, err } if _, err := tx.Exec(ctx, ` UPDATE party SET kind = $3, legal_name = $4, tin = $5, registration_no = $6, country_code = $7, address_line1 = $8, address_line2 = $9, address_city = $10, address_postcode = $11, address_state = $12, email = $13, phone = $14 WHERE org_id = $1 AND id = $2`, org, id, in.PartyKind, in.LegalName, in.TIN, in.RegistrationNo, in.CountryCode, nullable(in.Address.Line1), nullable(in.Address.Line2), nullable(in.Address.City), nullable(in.Address.Postcode), nullable(in.Address.State), in.Email, in.Phone); err != nil { return Supplier{}, fmt.Errorf("payables: update party: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE party_supplier SET code = $3, payment_terms_days = $4, currency = $5, default_expense_account_id = $6, default_tax_code_id = $7, is_active = $8, revision = revision + 1, updated_at = $9 WHERE org_id = $1 AND party_id = $2`, org, id, in.Code, in.PaymentTermsDays, in.Currency, in.DefaultExpenseAccountID, in.DefaultTaxCodeID, in.IsActive, c.Now()); err != nil { return Supplier{}, mapSupplierWrite(err) } after, err := GetSupplier(ctx, tx, id) if err != nil { return Supplier{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySupplier, EntityID: id, Action: "update", Before: before, After: after})}
// SetBank replaces the bank details, keeps what they replaced, and clears// any verification (D10).func SetBank(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, b Bank) (Supplier, error) { org, err := orgOf(ctx) if err != nil { return Supplier{}, err } before, err := lockSupplier(ctx, tx, id, "UPDATE") if err != nil { return Supplier{}, err } if before.Revision != expectedRevision { return Supplier{}, &RevisionMismatchError{Current: before.Revision} } b.AccountNo = strings.ReplaceAll(strings.TrimSpace(b.AccountNo), " ", "") b.AccountName = strings.TrimSpace(b.AccountName) entry, err := payout.GetRegistryEntry(ctx, tx, b.EntryID) switch { case err != nil || entry.Registry != BanksRegistry || !entry.IsActive: return Supplier{}, fieldErr("bank_entry_id", "bank_known", "choose an active bank") case !accountNo.MatchString(b.AccountNo): return Supplier{}, fieldErr("account_no", "account_no_shape", "an account number is 6 to 20 digits") case b.AccountName == "" || recordtext.Check("account_name", b.AccountName) != nil: return Supplier{}, fieldErr("account_name", "record_language", "the account holder's name, in English or Bahasa Malaysia") } var prevEntry *uuid.UUID var prevNo, prevName *string if before.Bank != nil { prevEntry, prevNo, prevName = &before.Bank.EntryID, &before.Bank.AccountNo, &before.Bank.AccountName } now := c.Now() if _, err := tx.Exec(ctx, ` UPDATE party_supplier SET bank_prev_entry_id = $3, bank_prev_account_no = $4, bank_prev_account_name = $5, bank_verified_by = NULL, bank_verified_at = NULL, bank_entry_id = $6, bank_account_no = $7, bank_account_name = $8, bank_changed_by = $9, bank_changed_at = $10, revision = revision + 1, updated_at = $10 WHERE org_id = $1 AND party_id = $2`, org, id, prevEntry, prevNo, prevName, b.EntryID, b.AccountNo, b.AccountName, actor, now); err != nil { return Supplier{}, fmt.Errorf("payables: set bank: %w", err) } after, err := GetSupplier(ctx, tx, id) if err != nil { return Supplier{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySupplier, EntityID: id, Action: "bank_change", Before: before.Bank, After: after.Bank})}
func VerifyBank(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int) (Supplier, error) { org, err := orgOf(ctx) if err != nil { return Supplier{}, err } before, err := lockSupplier(ctx, tx, id, "UPDATE") if err != nil { return Supplier{}, err } switch { case before.Revision != expectedRevision: return Supplier{}, &RevisionMismatchError{Current: before.Revision} case before.Bank == nil: return Supplier{}, ErrNoBank case before.BankVerifiedAt != nil: return Supplier{}, ErrAlreadyVerified case before.BankChangedBy != nil && *before.BankChangedBy == actor: return Supplier{}, ErrSameVerifier } now := c.Now() if _, err := tx.Exec(ctx, ` UPDATE party_supplier SET bank_verified_by = $3, bank_verified_at = $4, revision = revision + 1, updated_at = $4 WHERE org_id = $1 AND party_id = $2`, org, id, actor, now); err != nil { return Supplier{}, fmt.Errorf("payables: verify bank: %w", err) } after, err := GetSupplier(ctx, tx, id) if err != nil { return Supplier{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySupplier, EntityID: id, Action: "bank_verify", Before: map[string]any{"previous": before.PreviousBank, "current": before.Bank}, After: after.Bank})}Add to the top of supplier.go the patterns (the Go twins of the migration’s CHECKs):
var ( supplierCode = regexp.MustCompile(`^[A-Z0-9][A-Z0-9_-]{0,31}$`) countryCode = regexp.MustCompile(`^[A-Z]{2}$`) currencyCode = regexp.MustCompile(`^[A-Z]{3}$`) accountNo = regexp.MustCompile(`^[0-9]{6,20}$`) // The Go twins of 00300's party_email_shape / party_phone_shape / // party_registration_no_shape. emailShape = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`) phoneShape = regexp.MustCompile(`^\+?[0-9 -]{6,20}$`) registrationShape = regexp.MustCompile(`^[A-Z0-9-]{4,20}$`))
// nullableP trims an optional string and drops it when blank.func nullableP(s *string) *string { if s == nil { return nil } return nullable(*s)}and checkLineAccount (used again by Task 6):
// checkLineAccount is Ruling P15: postable, active, no control kind, and an// expense or asset account.func checkLineAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { a, err := chart.GetAccount(ctx, tx, id) switch { case err != nil: return errors.New("no such account") case !a.IsPostable || !a.IsActive: return errors.New("the account must be active and postable") case a.ControlKind != nil: return errors.New("a control account takes no purchase line") case a.Type != chart.TypeExpense && a.Type != chart.TypeAsset: return errors.New("a purchase line is charged to an expense or asset account") } return nil}payablestest/env.go:
// Package payablestest is the AP test world: expensestest's provisioned// Malaysian organization (Owner holds the OWNER seat, Staff reports to it)// with suppliers, invoices and payments one call away.package payablestest
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables")
type Env struct{ *expensestest.Env }
func New(t *testing.T) *Env { t.Helper(); return &Env{Env: expensestest.New(t)} }
func (e *Env) Another(t *testing.T) *Env { t.Helper(); return &Env{Env: e.Env.Another(t)} }
// As is e.Ctx with member as the audited actor.func (e *Env) As(member uuid.UUID) context.Context { return audit.WithActor(e.Ctx, e.Org, member)}
func (e *Env) Bank(t *testing.T) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT id FROM payout_registry_entry WHERE org_id = $1 AND registry = 'my_banks' AND is_active ORDER BY key LIMIT 1`, e.Org).Scan(&id)) return id}
func (e *Env) Account(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { a, err := chart.GetAccountByCode(ctx, tx, code) id = a.ID return err }) return id}
func (e *Env) Supplier(t *testing.T, code string) payables.Supplier { t.Helper() var s payables.Supplier require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error s, err = payables.CreateSupplier(ctx, tx, e.Clock, payables.SupplierInput{ Code: code, PartyKind: "organization", LegalName: code + " Sdn Bhd", CountryCode: "MY", PaymentTermsDays: 30, Currency: "MYR"}) return err })) return s}
func (e *Env) VerifiedSupplier(t *testing.T, code string) payables.Supplier { t.Helper() s := e.Supplier(t, code) require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error s, err = payables.SetBank(ctx, tx, e.Clock, s.PartyID, e.Staff, s.Revision, payables.Bank{EntryID: e.Bank(t), AccountNo: "5140123456", AccountName: s.LegalName}) return err })) require.NoError(t, e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { var err error s, err = payables.VerifyBank(ctx, tx, e.Clock, s.PartyID, e.Owner, s.Revision) return err })) return s}-
Step 4: Run passing.
go test ./internal/platform/payables/... -count=1→ PASS. -
Step 5: Sabotage. Delete the
case before.BankChangedBy != nil && *before.BankChangedBy == actorbranch;TestBankDetailsNeedASecondPersonnow fails with the DB’sparty_supplier_bank_verifier_differsinstead ofErrSameVerifier— read it, restore (both layers hold). -
Step 6: Commit.
git add internal/platform/payables && git commit -m "AP Task 3: supplier service, bank change and two-person verification" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Purchase invoice and credit note schema
Section titled “Task 4: Purchase invoice and credit note schema”Files:
- Create:
db/migrations/00302_purchase_invoice.sql - Modify:
sqlc.yaml(overridepurchase_invoice.fx_rate→money.Rate) - Modify:
internal/kernel/db/retention_test.go(purchase_invoice,purchase_invoice_line→amendable) - Test:
internal/kernel/db/retention_test.go(the drift test is this task’s test;invoice_schema_test.go, which startspurchase_invoiceinstances, is written and committed in Task 5, once the graph exists — so no commit carries a red test)
Interfaces:
-
Consumes:
workflow_instance,workflow_state,party_supplier,document,account,tax_code,organization_member. -
Produces: tables
purchase_invoice,purchase_invoice_line; SQL functionpayables_semantic(p_org UUID, p_instance UUID) RETURNS TEXT; triggerspurchase_invoice_draft_guard,purchase_invoice_line_draft_guard; indexpurchase_invoice_supplier_no_once; constraint names used by Go:purchase_invoice_is_draft,purchase_invoice_line_is_draft,purchase_invoice_supplier_no_once,purchase_invoice_basis_known. -
Step 1: Failing test. The retention drift test is the failing test here (Step 2); the schema’s own behaviour tests are Task 5’s
invoice_schema_test.go. -
Step 2: Run the drift tests — expect failure. Write the migration (Step 3) first with the tables but without the retention list entries, then:
go test ./internal/kernel/db/ -run TestOnlyOperational -count=1→ FAIL:these appear in none: [purchase_invoice purchase_invoice_line]. -
Step 3: Implement.
db/migrations/00302_purchase_invoice.sql:
-- +goose Up
-- PURCHASE INVOICES AND SUPPLIER CREDIT NOTES (AP spec D3, D5, D7; Ruling P2).-- One table: kind is the workflow kind and the journal source_kind. The-- workflow state lives on the instance; the header is editable only while-- that instance is a draft.CREATE TABLE purchase_invoice ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), kind TEXT NOT NULL CONSTRAINT purchase_invoice_kind_known CHECK (kind IN ('purchase_invoice','supplier_credit_note')), reference TEXT NOT NULL, supplier_party_id UUID NOT NULL, supplier_invoice_no TEXT NOT NULL CONSTRAINT purchase_invoice_supplier_no_present CHECK (btrim(supplier_invoice_no) <> '' AND length(supplier_invoice_no) <= 64), invoice_date DATE NOT NULL, due_on DATE NOT NULL, currency TEXT NOT NULL CONSTRAINT purchase_invoice_currency_code CHECK (currency ~ '^[A-Z]{3}$'), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT purchase_invoice_fx_rate_positive CHECK (fx_rate > 0), gross_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT purchase_invoice_gross_nonnegative CHECK (gross_amount >= 0), gross_base NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT purchase_invoice_gross_base_nonnegative CHECK (gross_base >= 0), document_id UUID, document_basis TEXT, supplier_tin TEXT, einvoice_uuid TEXT, einvoice_validated_at TIMESTAMPTZ, relaxation_reason TEXT, instance_id UUID NOT NULL, prepared_by UUID NOT NULL, cancelled_at TIMESTAMPTZ, revision INT NOT NULL DEFAULT 1 CONSTRAINT purchase_invoice_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT purchase_invoice_tenant_id UNIQUE (org_id, id), CONSTRAINT purchase_invoice_reference_once UNIQUE (org_id, reference), CONSTRAINT purchase_invoice_instance_once UNIQUE (org_id, instance_id), CONSTRAINT purchase_invoice_due_after_date CHECK (due_on >= invoice_date), -- D7: the tax record, mirroring expense_claim_line's CHECKs, without -- einvoice_employee (a supplier invoice is issued to the company). CONSTRAINT purchase_invoice_basis_known CHECK (document_basis IS NULL OR document_basis IN ('einvoice_company','foreign_supplier','relaxation_receipt')), CONSTRAINT purchase_invoice_einvoice_identifiers CHECK (document_basis IS DISTINCT FROM 'einvoice_company' OR (supplier_tin IS NOT NULL AND einvoice_uuid IS NOT NULL)), CONSTRAINT purchase_invoice_relaxation_reason CHECK (document_basis IS DISTINCT FROM 'relaxation_receipt' OR btrim(coalesce(relaxation_reason, '')) <> ''), CONSTRAINT purchase_invoice_tin_shape CHECK (supplier_tin IS NULL OR supplier_tin ~ '^[A-Z]{1,2}[0-9]{8,13}$'), CONSTRAINT purchase_invoice_einvoice_uuid_shape CHECK (einvoice_uuid IS NULL OR einvoice_uuid ~ '^[A-Z0-9]{10,64}$'), CONSTRAINT purchase_invoice_validated_needs_uuid CHECK (einvoice_validated_at IS NULL OR einvoice_uuid IS NOT NULL), CONSTRAINT purchase_invoice_text_language CHECK (supplier_invoice_no !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(relaxation_reason, '') !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT purchase_invoice_supplier_fk FOREIGN KEY (org_id, supplier_party_id) REFERENCES party_supplier (org_id, party_id), CONSTRAINT purchase_invoice_document_fk FOREIGN KEY (org_id, document_id) REFERENCES document (org_id, id), CONSTRAINT purchase_invoice_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT purchase_invoice_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id));-- D7 / Ruling P19: one live document per (kind, supplier, number).CREATE UNIQUE INDEX purchase_invoice_supplier_no_once ON purchase_invoice (org_id, kind, supplier_party_id, lower(supplier_invoice_no)) WHERE cancelled_at IS NULL;CREATE INDEX purchase_invoice_by_created ON purchase_invoice (org_id, kind, created_at DESC, id DESC);CREATE INDEX purchase_invoice_by_supplier ON purchase_invoice (org_id, supplier_party_id, due_on);
CREATE TABLE purchase_invoice_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), invoice_id UUID NOT NULL, line_no INT NOT NULL CONSTRAINT purchase_invoice_line_no_range CHECK (line_no BETWEEN 1 AND 200), description TEXT NOT NULL CONSTRAINT purchase_invoice_line_description_present CHECK (btrim(description) <> '' AND length(description) <= 500), account_id UUID NOT NULL, tax_code_id UUID, net_amount NUMERIC(19,4) NOT NULL CONSTRAINT purchase_invoice_line_net_positive CHECK (net_amount > 0), tax_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT purchase_invoice_line_tax_nonnegative CHECK (tax_amount >= 0), net_base NUMERIC(19,4) NOT NULL CONSTRAINT purchase_invoice_line_net_base_positive CHECK (net_base > 0), tax_base NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT purchase_invoice_line_tax_base_nonnegative CHECK (tax_base >= 0), shares JSONB NOT NULL DEFAULT '[]'::jsonb CONSTRAINT purchase_invoice_line_shares_array CHECK (jsonb_typeof(shares) = 'array'), withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT purchase_invoice_line_tenant_id UNIQUE (org_id, id), -- D8: tax without a tax code is refused. CONSTRAINT purchase_invoice_line_tax_needs_code CHECK (tax_code_id IS NOT NULL OR tax_amount = 0), CONSTRAINT purchase_invoice_line_description_language CHECK (description !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT purchase_invoice_line_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES purchase_invoice (org_id, id), CONSTRAINT purchase_invoice_line_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id), CONSTRAINT purchase_invoice_line_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id));CREATE UNIQUE INDEX purchase_invoice_line_no_once ON purchase_invoice_line (org_id, invoice_id, line_no) WHERE withdrawn_at IS NULL;
ALTER TABLE purchase_invoice ENABLE ROW LEVEL SECURITY;ALTER TABLE purchase_invoice FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON purchase_invoice USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE purchase_invoice_line ENABLE ROW LEVEL SECURITY;ALTER TABLE purchase_invoice_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON purchase_invoice_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON purchase_invoice, purchase_invoice_line FROM erp_app;
-- The semantic of an instance's current state (shared by 00304's guards).-- +goose StatementBeginCREATE FUNCTION payables_semantic(p_org UUID, p_instance UUID) RETURNS TEXT LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT s.semantic FROM public.workflow_instance i JOIN public.workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE i.org_id = p_org AND i.id = p_instance$$;-- +goose StatementEnd
-- +goose StatementBeginCREATE FUNCTION purchase_invoice_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.payables_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'purchase invoice % is not a draft', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'purchase_invoice_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER purchase_invoice_draft_guard BEFORE UPDATE OF supplier_party_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, gross_amount, gross_base, document_id, document_basis, supplier_tin, einvoice_uuid, einvoice_validated_at, relaxation_reason ON purchase_invoice FOR EACH ROW EXECUTE FUNCTION purchase_invoice_draft_guard();
-- +goose StatementBeginCREATE FUNCTION purchase_invoice_line_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (SELECT public.payables_semantic(h.org_id, h.instance_id) FROM public.purchase_invoice h WHERE h.org_id = NEW.org_id AND h.id = NEW.invoice_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'purchase invoice % is not a draft: its lines cannot change', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'purchase_invoice_line_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER purchase_invoice_line_draft_guard BEFORE INSERT OR UPDATE ON purchase_invoice_line FOR EACH ROW EXECUTE FUNCTION purchase_invoice_line_draft_guard();
-- +goose DownDROP TABLE purchase_invoice_line;DROP TABLE purchase_invoice;DROP FUNCTION purchase_invoice_line_draft_guard();DROP FUNCTION purchase_invoice_draft_guard();DROP FUNCTION payables_semantic(UUID, UUID);sqlc.yaml, after the Plan 7 overrides:
# AP (Ruling P13): a document's FX rate is a decimal, not money. - column: "purchase_invoice.fx_rate" go_type: github.com/mcom/erp/internal/kernel/money.Rateretention_test.go amendable, after "party_supplier",:
// AP (spec D11): posted invoices and credit notes are never // deleted; a draft's replaced line is withdrawn (Ruling P16). "purchase_invoice", "purchase_invoice_line",-
Step 4: Run.
make generate && go test ./internal/kernel/db/... -count=1→ PASS. -
Step 5: Sabotage. Remove
purchase_invoice_linefromamendable, seeTestOnlyOperational…fail, restore. -
Step 6: Commit.
git add db/migrations/00302_purchase_invoice.sql sqlc.yaml internal/gen internal/kernel/db && git commit -m "AP Task 4: purchase invoice and credit note schema" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: Workflow kinds — the accrual-only rule, sign-off kind, three seeded graphs
Section titled “Task 5: Workflow kinds — the accrual-only rule, sign-off kind, three seeded graphs”Files:
- Modify:
internal/platform/workflow/model.go(AccrualOnlyKinds,SignoffKinds) - Modify:
internal/platform/workflow/validate.go(accrualOnly, called fromValidateGraph) - Test:
internal/platform/workflow/validate_test.go(append) - Create:
internal/platform/payables/graphs.go - Test:
internal/platform/payables/graphs_test.go - Test:
internal/platform/payables/invoice_schema_test.go(moved here from Task 4: it needs the seededpurchase_invoicegraph)
Interfaces:
-
Consumes:
workflow.RegisterSeededGraph,workflow.Graph,workflow.StateSpec,workflow.EdgeSpec,workflow.GrantSpec,workflowtest.RequireSeededGraphValid. -
Produces:
workflow.AccrualOnlyKinds []string; violation codesaccrual_only_count,payout_in_accrual_only(rule 3);payables.InvoiceGraph(ownerSeat uuid.UUID) workflow.Graph,payables.CreditNoteGraph(…),payables.PaymentGraph(…); state keysdraft,pending_review,approved,rejected,cancelled,voided(invoice, credit note) anddraft,pending_approval,paid,rejected,cancelled,voided(payment). -
Step 1: Failing tests. Append to
internal/platform/workflow/validate_test.go(packageworkflow_test;cashOnly()is the file’s existing helper):
func TestAnAccrualOnlyKindPostsExactlyOneAccrualAndNoPayout(t *testing.T) { g := accrualFirstNoPayout() // defined below g.Kind = "purchase_invoice" require.Empty(t, workflow.ValidateGraph(g.Normalized(), "MYR"), "PRESENCE: one accrual, no payout is valid")
withPayout := g withPayout.States = append(append([]workflow.StateSpec{}, g.States...), workflow.StateSpec{ Key: "paid", Semantic: posting.Payout, DisplayOrder: 9, IsTerminal: true, Labels: workflow.Labels{"en": "Paid"}}) withPayout.Edges = append(append([]workflow.EdgeSpec{}, g.Edges...), workflow.EdgeSpec{ From: "posted", To: "paid", Gate: workflow.GateTarget, ActionKey: "pay", Labels: workflow.Labels{"en": "Pay"}}) withPayout.Grants = append(append([]workflow.GrantSpec{}, g.Grants...), workflow.GrantSpec{ StateKey: "paid", GranteeKind: workflow.GranteeUnitHead, OnVacancy: workflow.VacancyEscalate, MaxEscalationLevels: 2}) requireViolation(t, workflow.ValidateGraph(withPayout.Normalized(), "MYR"), "payout_in_accrual_only")
// The same graph under another kind is not held to the rule. withPayout.Kind = "some_other_kind" for _, v := range workflow.ValidateGraph(withPayout.Normalized(), "MYR") { require.NotEqual(t, "payout_in_accrual_only", v.Code) }
none := g none.States = append([]workflow.StateSpec{}, g.States...) for i := range none.States { if none.States[i].Semantic == posting.Accrual { none.States[i].Semantic, none.States[i].ControlKind = posting.Custom, "" } } requireViolation(t, workflow.ValidateGraph(none.Normalized(), "MYR"), "accrual_only_count")}
// accrualFirstNoPayout: draft → pending → posted (accrual, ap_trade) → voided.func accrualFirstNoPayout() workflow.Graph { l := func(s string) workflow.Labels { return workflow.Labels{"en": s} } return workflow.Graph{ Kind: "purchase_invoice", PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ {Key: "draft", Semantic: posting.Draft, DisplayOrder: 1, Labels: l("Draft")}, {Key: "pending", Semantic: posting.Pending, DisplayOrder: 2, Labels: l("Pending")}, {Key: "posted", Semantic: posting.Accrual, ControlKind: "ap_trade", DisplayOrder: 3, Labels: l("Posted")}, {Key: "voided", Semantic: posting.Cancelled, DisplayOrder: 4, IsTerminal: true, Labels: l("Voided")}, }, Edges: []workflow.EdgeSpec{ {From: "draft", To: "pending", Gate: workflow.GateReporter, ActionKey: "submit", Labels: l("Submit")}, {From: "pending", To: "posted", Gate: workflow.GateTarget, ActionKey: "post", Labels: l("Post")}, {From: "posted", To: "voided", Gate: workflow.GateTarget, ActionKey: "void", RequiresReason: true, ReversesAccrual: true, Labels: l("Void")}, }, Grants: []workflow.GrantSpec{ {StateKey: "posted", GranteeKind: workflow.GranteeUnitHead, OnVacancy: workflow.VacancyEscalate, MaxEscalationLevels: 2}, {StateKey: "voided", GranteeKind: workflow.GranteeUnitHead, OnVacancy: workflow.VacancyEscalate, MaxEscalationLevels: 2}, }, }}If the file has no requireViolation(t, vs, code) helper, add it:
func requireViolation(t *testing.T, vs []workflow.Violation, code string) { t.Helper() for _, v := range vs { if v.Code == code { return } } require.Failf(t, "violation missing", "want %q in %+v", code, vs)}internal/platform/payables/graphs_test.go:
package payables_test
import ( "testing"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
func TestTheSeededGraphsAreValid(t *testing.T) { for _, k := range []string{payables.KindInvoice, payables.KindCreditNote, payables.KindPayment} { workflowtest.RequireSeededGraphValid(t, k) }}
func TestInvoiceGraphPostsOnceAndVoidReverses(t *testing.T) { g := payables.InvoiceGraph(uuid.New()) var accruals, payouts int for _, s := range g.States { switch s.Semantic { case posting.Accrual: accruals++ require.Equal(t, payables.ControlKind, s.ControlKind) require.False(t, s.IsTerminal, "a posted invoice can still be voided (Ruling P8)") case posting.Payout: payouts++ } } require.Equal(t, 1, accruals) require.Zero(t, payouts, "an invoice is paid by a supplier payment (D3)") e, ok := g.Edge("approved", "voided") require.True(t, ok) require.True(t, e.ReversesAccrual) require.Contains(t, workflow.SignoffKinds, payables.KindInvoice, "Ruling P11")}
func TestPaymentGraphIsCashOnly(t *testing.T) { g := payables.PaymentGraph(uuid.New()) require.Equal(t, workflow.CashOnly, g.PostingOrder) s, ok := g.State("paid") require.True(t, ok) require.Equal(t, posting.Payout, s.Semantic)}(Graph.Edge(from, to) and Graph.State(key) exist on workflow.Definition; if they are defined on Definition only, call workflow.Definition{Graph: g}.Edge(…).)
Also create internal/platform/payables/invoice_schema_test.go (Task 4’s schema, now that the graph exists):
package payables_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/workflow")
// rawInvoice inserts a header as erp_app against an instance started in its// own transaction (workflow.Start audits; lock order).func rawInvoice(t *testing.T, e *payablestest.Env, kind string, supplier uuid.UUID, no string) (uuid.UUID, uuid.UUID) { t.Helper() id := uuid.New() var inst workflow.Instance e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error inst, err = workflow.Start(ctx, tx, e.Clock, workflow.StartInput{Kind: kind, SourceID: id, Reporter: e.Staff, Amount: money.Zero()}) return err }) require.NoError(t, e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO purchase_invoice (id, org_id, kind, reference, supplier_party_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, instance_id, prepared_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, DATE '2026-09-21', DATE '2026-10-21', 'MYR', 1, $7, $8, now(), now())`, id, e.Org, kind, "T-"+id.String()[:8], supplier, no, inst.ID, e.Staff) return err })) return id, inst.ID}
func TestDuplicateSupplierInvoiceNumberIsRefusedPerKind(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") rawInvoice(t, e, payables.KindInvoice, s.PartyID, "INV-001") // PRESENCE: a different number, and the same number as a CREDIT NOTE, are accepted. rawInvoice(t, e, payables.KindInvoice, s.PartyID, "INV-002") rawInvoice(t, e, payables.KindCreditNote, s.PartyID, "INV-001") // ABSENCE: the same number again (case-insensitive) is refused by name. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { inst, err := workflow.Start(ctx, tx, e.Clock, workflow.StartInput{Kind: payables.KindInvoice, SourceID: uuid.New(), Reporter: e.Staff, Amount: money.Zero()}) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO purchase_invoice (id, org_id, kind, reference, supplier_party_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, instance_id, prepared_by, created_at, updated_at) VALUES ($1, $2, 'purchase_invoice', 'T-dup', $3, 'inv-001', DATE '2026-09-21', DATE '2026-10-21', 'MYR', 1, $4, $5, now(), now())`, inst.SourceID, e.Org, s.PartyID, inst.ID, e.Staff) return err }) requirePgError(t, err, "23505", "purchase_invoice_supplier_no_once")}
func TestAnInvoiceIsEditableOnlyWhileDraft(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") id, inst := rawInvoice(t, e, payables.KindInvoice, s.PartyID, "INV-001") edit := func() error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE purchase_invoice SET supplier_invoice_no = 'INV-001A' WHERE org_id = $1 AND id = $2`, e.Org, id) return err }) } require.NoError(t, edit(), "PRESENCE: a draft takes an edit") // Leave draft: the edge exists in the seeded graph (Task 5). e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := workflow.Transition(ctx, tx, e.Clock, workflow.TransitionInput{InstanceID: inst, From: "draft", To: "cancelled", Actor: e.Staff, Reason: "entered twice"}) return err }) requirePgError(t, edit(), "23514", "purchase_invoice_is_draft")}
func TestEmployeeNamedEInvoiceIsNotAPurchaseBasis(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") id, _ := rawInvoice(t, e, payables.KindInvoice, s.PartyID, "INV-001") set := func(basis string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE purchase_invoice SET document_basis = $3, supplier_tin = 'C20880050010', einvoice_uuid = 'ABCDEFGHIJ12' WHERE org_id = $1 AND id = $2`, e.Org, id, basis) return err }) } require.NoError(t, set("einvoice_company"), "PRESENCE") requirePgError(t, set("einvoice_employee"), "23514", "purchase_invoice_basis_known")}-
Step 2: Run — expect failure.
go test ./internal/platform/workflow/ -run AccrualOnly -count=1→ FAIL:violation missing: want "payout_in_accrual_only".go test ./internal/platform/payables/ -run Graph -count=1→ FAIL:undefined: payables.InvoiceGraph. -
Step 3: Implement.
internal/platform/workflow/model.go, below SignoffKinds:
// SignoffKinds are the definition kinds whose module reports per-account// shares to the engine (workflow.SetShares). P12: expense claims; AP// Ruling P11 adds purchase invoices.var SignoffKinds = []string{"expense_claim", "purchase_invoice"}
// AccrualOnlyKinds post exactly one accrual and are never paid by their own// flow (AP spec D3): a supplier_payment pays them.var AccrualOnlyKinds = []string{"purchase_invoice", "supplier_credit_note"}internal/platform/workflow/validate.go — in ValidateGraph, after c.rule3() add c.accrualOnly(); and the method:
// Rule 3, for an accrual-only kind (AP spec D3): the document's one posting// is its accrual, and a payout state would let it pay itself.func (c *checker) accrualOnly() { if !slices.Contains(AccrualOnlyKinds, c.g.Kind) { return } accruals := 0 for _, s := range c.g.States { switch s.Semantic { case posting.Accrual: accruals++ case posting.Payout: c.add(3, "payout_in_accrual_only", s.Key, "%s is paid by a supplier payment, never by its own flow; %s is a payout", c.g.Kind, s.Key) } } if accruals != 1 { c.add(3, "accrual_only_count", "", "%s posts exactly one accrual; found %d accrual states", c.g.Kind, accruals) }}internal/platform/payables/graphs.go:
package payables
import ( "github.com/google/uuid"
"github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
func init() { workflow.RegisterSeededGraph(KindInvoice, InvoiceGraph) workflow.RegisterSeededGraph(KindCreditNote, CreditNoteGraph) workflow.RegisterSeededGraph(KindPayment, PaymentGraph)}
func ls(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
func st(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { s := workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l} if sem == posting.Accrual { s.ControlKind = ControlKind // Trade payables (D2) } return s}
func ed(order int, from, to string, gate workflow.Gate, action string, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, DisplayOrder: order, Labels: l}}
func why(e workflow.EdgeSpec, reverses bool) workflow.EdgeSpec { e.RequiresReason, e.ReversesAccrual = true, reverses return e}
func seat(state string, ownerSeat uuid.UUID) workflow.GrantSpec { return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}
// documentGraph is D3's flow, shared by invoices and credit notes (D5)://// draft → pending_review → approved ("Posted", accrual) → voided (reverses)// │ └─→ rejected → draft// └─→ cancelled ("Withdrawn")func documentGraph(kind string, ownerSeat uuid.UUID, posted workflow.Labels) workflow.Graph { return workflow.Graph{ Kind: kind, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿", "Draf")), st(2, "pending_review", posting.Pending, false, ls("Pending review", "待审核", "Menunggu semakan")), st(3, "approved", posting.Accrual, false, posted), st(4, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝", "Ditolak")), st(5, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回", "Ditarik balik")), st(6, "voided", posting.Cancelled, true, ls("Voided", "已作废", "Dibatalkan")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_review", workflow.GateReporter, "submit", ls("Submit", "提交", "Hantar")), ed(2, "pending_review", "approved", workflow.GateTarget, "post", ls("Approve and post", "批准并过账", "Luluskan dan pos")), why(ed(3, "pending_review", "rejected", workflow.GateAdvance, "reject", ls("Reject", "拒绝", "Tolak")), false), ed(4, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改", "Semak semula")), why(ed(5, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回", "Tarik balik")), false), why(ed(6, "approved", "voided", workflow.GateTarget, "void", ls("Void", "作废", "Batalkan")), true), }, Grants: []workflow.GrantSpec{seat("approved", ownerSeat), seat("voided", ownerSeat)}, }.Normalized()}
func InvoiceGraph(ownerSeat uuid.UUID) workflow.Graph { return documentGraph(KindInvoice, ownerSeat, ls("Posted", "已过账", "Telah dipos"))}
func CreditNoteGraph(ownerSeat uuid.UUID) workflow.Graph { return documentGraph(KindCreditNote, ownerSeat, ls("Posted", "已过账", "Telah dipos"))}
// PaymentGraph is D4's flow, cash_only (no accrual of its own)://// draft → pending_approval → paid (payout) → voided (reverses)// │ └─→ rejected → draft// └─→ cancelledfunc PaymentGraph(ownerSeat uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: KindPayment, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿", "Draf")), st(2, "pending_approval", posting.Pending, false, ls("Pending approval", "待批准", "Menunggu kelulusan")), st(3, "paid", posting.Payout, false, ls("Paid", "已付款", "Telah dibayar")), st(4, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝", "Ditolak")), st(5, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回", "Ditarik balik")), st(6, "voided", posting.Cancelled, true, ls("Voided", "已作废", "Dibatalkan")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_approval", workflow.GateReporter, "submit", ls("Submit", "提交", "Hantar")), ed(2, "pending_approval", "paid", workflow.GateTarget, "approve_payment", ls("Approve and record payment", "批准并记录付款", "Luluskan dan rekod bayaran")), why(ed(3, "pending_approval", "rejected", workflow.GateAdvance, "reject", ls("Reject", "拒绝", "Tolak")), false), ed(4, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改", "Semak semula")), why(ed(5, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回", "Tarik balik")), false), why(ed(6, "paid", "voided", workflow.GateTarget, "void", ls("Void", "作废", "Batalkan")), true), }, Grants: []workflow.GrantSpec{seat("paid", ownerSeat), seat("voided", ownerSeat)}, }.Normalized()}-
Step 4: Run passing.
go test ./internal/platform/workflow/... ./internal/platform/payables/... ./internal/app/ -run 'Seeded|AccrualOnly|Graph|Duplicate|Draft|EInvoice' -count=1→ PASS (includes Task 4’s three schema tests andinternal/app.TestEverySeededGraphPassesTheValidatoronce Task 12 links the module; before that the payables test binary’sRequireSeededGraphValidcovers it). -
Step 5: Sabotage. Remove
c.accrualOnly()fromValidateGraph;TestAnAccrualOnlyKind…failswant "payout_in_accrual_only"; restore. Markapprovedterminal indocumentGraph;TestTheSeededGraphsAreValidfails withterminal_exit_not_dispute; restore. -
Step 6: Commit.
git add internal/platform/workflow internal/platform/payables && git commit -m "AP Task 5: accrual-only validator rule and the three AP workflow graphs" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5A: Tax code usage and the MY purchase-side SST codes
Section titled “Task 5A: Tax code usage and the MY purchase-side SST codes”Files:
- Create:
db/migrations/00303_tax_code_usage.sql - Modify:
internal/platform/accounting/chart/taxcode.go(UsageonTaxCode/TaxCodeInput, the three constants, validation, insert, select) - Modify:
internal/platform/countrypack/countrypack.go(TaxCodeSeed.Usage),internal/platform/countrypack/seed.go(pass it;SeedTaxCodesWhere),internal/platform/countrypack/my/my.go(five purchase codes) - Modify:
internal/platform/payables/step.go(steppayables_purchase_tax_codes, order 76) - Modify:
internal/modules/accounting/chart_handlers.go(tax code DTOusage; create body optionalusage),api/openapi.yaml(TaxCode.usage,CreateTaxCodeRequest.usage) - Modify:
internal/platform/countrypack/my/my_test.go(require.Len(t, p.TaxCodes(), 6)→11, and its comment “6 SST tax codes” → “11 SST tax codes (6 sales-side, 5 purchase-side)”) - Modify:
internal/platform/countrypack/validate.go(a seed’sUsagemust be “”,sales,purchaseorboth) - Test:
internal/platform/accounting/chart/taxcode_test.go(append),internal/platform/payables/step_test.go(append),internal/platform/countrypack/my/my_test.go
Interfaces:
- Consumes:
chart.CreateTaxCode,chart.ListTaxCodes,countrypack.Lookup,company.Get,provisioning.Register. - Produces: column
tax_code.usage TEXT NOT NULLwithCONSTRAINT tax_code_usage_known CHECK (usage IN ('sales','purchase','both'));chart.TaxUsageSales = "sales",chart.TaxUsagePurchase = "purchase",chart.TaxUsageBoth = "both";chart.TaxCode.Usage string;chart.TaxCodeInput.Usage string(""meanssales);countrypack.TaxCodeSeed.Usage string;countrypack.SeedTaxCodesWhere(ctx, tx, c, p Pack, recordLocale string, keep func(TaxCodeSeed) bool) error; MY seed codesPST5,PST10,PSVT6,PSVT8,PNT;payables.StepKeyPurchaseTaxCodes = "payables_purchase_tax_codes",payables.StepOrderPurchaseTaxCodes = 76.
Expense claims are unchanged: nothing in internal/platform/expenses reads usage, and every existing code is backfilled to sales, which is what a claim line already chooses from today.
- Step 1: Failing tests.
Append to internal/platform/accounting/chart/taxcode_test.go (package chart_test, accountingtest world; add github.com/jackc/pgx/v5/pgconn to its imports):
func TestATaxCodeCarriesItsUsage(t *testing.T) { e := accountingtest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { sales, err := chart.CreateTaxCode(ctx, tx, e.Clock, chart.TaxCodeInput{Code: "U1", Name: "U1", PackKind: "x", Rate: money.MustParseRate("6"), ValidFrom: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}) require.NoError(t, err) require.Equal(t, chart.TaxUsageSales, sales.Usage, "an unspecified usage is sales, as every existing code is") p, err := chart.CreateTaxCode(ctx, tx, e.Clock, chart.TaxCodeInput{Code: "U2", Name: "U2", PackKind: "x", Rate: money.MustParseRate("6"), Usage: chart.TaxUsagePurchase, ValidFrom: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}) require.NoError(t, err, "PRESENCE") require.Equal(t, chart.TaxUsagePurchase, p.Usage) _, err = chart.CreateTaxCode(ctx, tx, e.Clock, chart.TaxCodeInput{Code: "U3", Name: "U3", PackKind: "x", Rate: money.MustParseRate("6"), Usage: "import", ValidFrom: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}) require.ErrorIs(t, err, chart.ErrInvalidTaxCode) return nil }) // The database refuses it too. _, err := e.H.Admin.Exec(context.Background(), `UPDATE tax_code SET usage = 'import' WHERE org_id = $1 AND code = 'U1'`, e.Org) var pg *pgconn.PgError require.ErrorAs(t, err, &pg) require.Equal(t, "tax_code_usage_known", pg.ConstraintName)}Append to internal/platform/payables/step_test.go:
func TestMalaysianOrgsHavePurchaseSideSSTCodesThatAreCost(t *testing.T) { e := expensestest.New(t) codes := map[string]chart.TaxCode{} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { all, err := chart.ListTaxCodes(ctx, tx) for _, c := range all { codes[c.Code] = c } return err }) for _, code := range []string{"PST5", "PST10", "PSVT6", "PSVT8", "PNT"} { c, ok := codes[code] require.True(t, ok, "%s seeded", code) require.Equal(t, chart.TaxUsagePurchase, c.Usage) require.Nil(t, c.AccountID, "%s: no account, so the tax is cost (D8)", code) } require.Equal(t, chart.TaxUsageSales, codes["SVT8"].Usage, "the sales-side codes are untouched")}
func TestTheStepGivesAnExistingOrgThePurchaseCodes(t *testing.T) { e := expensestest.New(t) // Simulate an organization provisioned before this task: no purchase codes. _, err := e.H.Admin.Exec(context.Background(), `UPDATE tax_code SET code = code || '-OLD', usage = 'sales', is_active = false WHERE org_id = $1 AND usage = 'purchase'`, e.Org) require.NoError(t, err) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := provisioning.Run(ctx, tx, e.Clock, provisioning.Input{}) require.NoError(t, err) all, err := chart.ListTaxCodes(ctx, tx) n := 0 for _, c := range all { if c.Usage == chart.TaxUsagePurchase && c.IsActive { n++ } } require.Equal(t, 5, n, "the step seeded the five purchase codes") return err })}-
Step 2: Run — expect failure.
go test ./internal/platform/accounting/chart/ ./internal/platform/payables/ -run 'Usage|PurchaseSide|PurchaseCodes' -count=1→ FAIL:unknown field Usage in struct literal of type chart.TaxCodeInput. -
Step 3: Implement.
db/migrations/00303_tax_code_usage.sql:
-- +goose Up-- AP amendment 1: which side of the ledger a tax code is for. Every existing-- code is a sales-side code (the MY pack's all name 2140, SST payable), so-- the DEFAULT backfills them to 'sales' and keeps every existing writer-- (expense claims included) unchanged.ALTER TABLE tax_code ADD COLUMN usage TEXT NOT NULL DEFAULT 'sales' CONSTRAINT tax_code_usage_known CHECK (usage IN ('sales','purchase','both'));-- +goose DownALTER TABLE tax_code DROP COLUMN usage;chart/taxcode.go:
// Which side of the ledger a code is for (AP amendment 1). A purchase// invoice line takes purchase or both; nothing else reads it.const ( TaxUsageSales = "sales" TaxUsagePurchase = "purchase" TaxUsageBoth = "both")TaxCode gains Usage string \json:“usage”`; TaxCodeInputgainsUsage string; taxCodeColumnsgains, usageandscanTaxCodescans&tc.Usagelast;validateTaxCode` gains
switch in.Usage { case "", TaxUsageSales, TaxUsagePurchase, TaxUsageBoth: default: return fmt.Errorf("%w: usage must be sales, purchase or both", ErrInvalidTaxCode) }and CreateTaxCode sets usage := in.Usage; if usage == "" { usage = TaxUsageSales }, puts it on tc.Usage, and inserts it as a tenth column (INSERT INTO tax_code (…, valid_to, usage) VALUES (…,$9,$10)).
countrypack.TaxCodeSeed gains Usage string // "" is sales; SeedTaxCodes passes Usage: s.Usage and becomes return SeedTaxCodesWhere(ctx, tx, c, p, recordLocale, func(TaxCodeSeed) bool { return true }), its old body moved into:
// SeedTaxCodesWhere creates the pack's tax codes keep selects, in seed order// (the body SeedTaxCodes had, with `if !keep(s) { continue }` first in the loop).func SeedTaxCodesWhere(ctx context.Context, tx pgx.Tx, c clock.Clock, p Pack, recordLocale string, keep func(TaxCodeSeed) bool) errorcountrypack/validate.go — in the tax-code loop of Validate, beside the existing rate/date checks:
switch s.Usage { case "", chart.TaxUsageSales, chart.TaxUsagePurchase, chart.TaxUsageBoth: default: return fmt.Errorf("%w: tax code %q has unknown usage %q", ErrInvalidPack, s.Code, s.Usage) }countrypack/my/my.go — append to taxCodes (no AccountCode: D8 makes the tax cost):
// AP amendment 1: the purchase side. No account, so SST on a purchase // is part of its cost (D8). {Code: "PST10", PackKind: "sales_tax", Usage: "purchase", Rate: "10", ValidFrom: "2018-09-01", Names: names("Sales tax 10% on purchases", "Cukai jualan 10% atas belian", "采购销售税 10%")}, {Code: "PST5", PackKind: "sales_tax", Usage: "purchase", Rate: "5", ValidFrom: "2018-09-01", Names: names("Sales tax 5% on purchases", "Cukai jualan 5% atas belian", "采购销售税 5%")}, {Code: "PSVT6", PackKind: "service_tax", Usage: "purchase", Rate: "6", ValidFrom: "2018-09-01", Names: names("Service tax 6% on purchases", "Cukai perkhidmatan 6% atas belian", "采购服务税 6%")}, {Code: "PSVT8", PackKind: "service_tax", Usage: "purchase", Rate: "8", ValidFrom: "2024-03-01", Names: names("Service tax 8% on purchases", "Cukai perkhidmatan 8% atas belian", "采购服务税 8%")}, {Code: "PNT", PackKind: "out_of_scope", Usage: "purchase", Rate: "0", ValidFrom: "2018-09-01", Names: names("No SST on purchase (exempt or out of scope)", "Tiada SST atas belian (dikecualikan atau di luar skop)", "采购无销售与服务税(豁免或不适用)")},payables/step.go — a second step:
const ( StepKeyPurchaseTaxCodes = "payables_purchase_tax_codes" StepOrderPurchaseTaxCodes = 76)
func init() { provisioning.Register(purchaseTaxCodesStep{}) }
type purchaseTaxCodesStep struct{}
func (purchaseTaxCodesStep) Key() string { return StepKeyPurchaseTaxCodes }func (purchaseTaxCodesStep) Order() int { return StepOrderPurchaseTaxCodes }
// Check is done when every purchase-usage code of the org's pack exists by// code (active or not: never seed over what an administrator retired), or// when no pack is registered for the org's country.func (purchaseTaxCodesStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) { missing, err := missingPurchaseCodes(ctx, tx) return len(missing) == 0, nil, err}
func (purchaseTaxCodesStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error { missing, err := missingPurchaseCodes(ctx, tx) if err != nil || len(missing) == 0 { return err } profile, err := company.Get(ctx, tx) if err != nil { return err } pack, _ := countrypack.Lookup(profile.CountryCode) return countrypack.SeedTaxCodesWhere(ctx, tx, c, pack, profile.DefaultLocale, func(s countrypack.TaxCodeSeed) bool { return missing[s.Code] })}
func missingPurchaseCodes(ctx context.Context, tx pgx.Tx) (map[string]bool, error) { profile, err := company.Get(ctx, tx) if err != nil { return nil, err } pack, ok := countrypack.Lookup(profile.CountryCode) if !ok { return nil, nil } have, err := chart.ListTaxCodes(ctx, tx) if err != nil { return nil, err } exists := map[string]bool{} for _, c := range have { exists[c.Code] = true } out := map[string]bool{} for _, s := range pack.TaxCodes() { if s.Usage == chart.TaxUsagePurchase && !exists[s.Code] { out[s.Code] = true } } return out, nil}(chart.ListTaxCodes must return retired codes too; it does — the step test’s -OLD rows are inactive and renamed, so the five codes are missing by code and get seeded.)
internal/modules/accounting/chart_handlers.go: the tax code DTO (line ~101) gains Usage string \json:“usage”`filled fromtc.Usage; the create body (line ~527) gains Usage string `json:“usage”`passed asUsage: body.Usage. api/openapi.yaml: TaxCodegains requiredusage: { type: string, enum: [sales, purchase, both] }; CreateTaxCodeRequestgains optionalusage(same enum, "Absent means sales.").make generate`.
-
Step 4: Run passing.
go test ./internal/platform/accounting/... ./internal/platform/countrypack/... ./internal/platform/payables/... ./internal/platform/expenses/... ./internal/modules/accounting/... -count=1→ PASS (the expenses packages prove claim behaviour is unchanged). -
Step 5: Sabotage. Remove
CONSTRAINT tax_code_usage_known CHECK …from the migration;TestATaxCodeCarriesItsUsagefails at theErrorAson the raw UPDATE; restore. MakepurchaseTaxCodesStep.Checkreturntrue;TestTheStepGivesAnExistingOrgThePurchaseCodesfails (0purchase codes); restore. -
Step 6: Commit.
git add -A && git commit -m "AP Task 5A: tax code usage and MY purchase-side SST codes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Invoice and credit note service, binder, transition wrapper
Section titled “Task 6: Invoice and credit note service, binder, transition wrapper”Files:
- Create:
internal/platform/payables/invoice.go - Create:
internal/platform/payables/binder.go - Create:
internal/platform/payables/cursor.go - Create:
internal/platform/payables/transition.go - Modify:
internal/platform/payables/payablestest/env.go(invoice helpers) - Test:
internal/platform/payables/invoice_test.go
Interfaces:
- Consumes:
numbering.Allocate/Format,fiscal.YearContaining,company.Get,workflow.Start/SetAmount/SetShares/Transition/Load/Get/AvailableTransitions,analytic.Plan/Allocate/ListAxes,chart.GetTaxCode,documents.Get/Lock/BoundElsewhere/RegisterBinder,taxrecord.Normalize,member.Can,audit.Write. - Produces:
type Line struct { ID uuid.UUID LineNo int Description string AccountID uuid.UUID TaxCodeID *uuid.UUID NetAmount, TaxAmount, NetBase, TaxBase money.Amount Shares []analytic.Share}type Invoice struct { ID uuid.UUID Kind, Reference string SupplierID uuid.UUID SupplierInvoiceNo string InvoiceDate, DueOn time.Time Currency string FXRate money.Rate GrossAmount, GrossBase money.Amount DocumentID *uuid.UUID DocumentBasis, SupplierTIN, EInvoiceUUID, RelaxationReason *string EInvoiceValidatedAt *time.Time InstanceID, PreparedBy uuid.UUID CancelledAt *time.Time Revision int CreatedAt, UpdatedAt time.Time State string Semantic posting.Semantic Lines []Line}type LineInput struct { Description string AccountID uuid.UUID TaxCodeID *uuid.UUID NetAmount, TaxAmount money.Amount Shares []analytic.Share}type InvoiceInput struct { Kind string // create only; ignored on update SupplierID uuid.UUID SupplierInvoiceNo string InvoiceDate time.Time DueOn *time.Time // nil: InvoiceDate + supplier terms (invoice); InvoiceDate (credit note) Currency string FXRate *money.Rate DocumentID *uuid.UUID DocumentBasis, SupplierTIN, EInvoiceUUID, RelaxationReason *string EInvoiceValidatedAt *time.Time Lines []LineInput}type InvoiceFilter struct { Kind string SupplierID *uuid.UUID State string DueBefore *time.Time Currency string Limit int After *Cursor}type Cursor struct{ CreatedAt time.Time; ID uuid.UUID }func (c Cursor) Encode() stringfunc DecodeCursor(s string) (Cursor, error)var ErrBadCursor errorfunc CreateInvoice(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in InvoiceInput) (Invoice, error)func GetInvoice(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Invoice, error)func ListInvoices(ctx context.Context, tx pgx.Tx, f InvoiceFilter) ([]Invoice, *Cursor, error)func UpdateInvoice(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in InvoiceInput) (Invoice, error)type DuplicateInvoiceError struct{ ExistingID uuid.UUID; Reference string }type SubmissionError struct{ Problems []FieldError }type TransitionInput struct{ ID uuid.UUID; From, To string; Actor uuid.UUID; Reason string }func TransitionInvoice(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Invoice, workflow.Decision, error)func Available(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, actor uuid.UUID) ([]workflow.Available, error)var ErrNotDraft, ErrHasAllocations error // ErrHasAllocations is raised from Task 9 on// payablestest (this task)func (e *Env) TaxCode(t *testing.T, code string) uuid.UUIDfunc (e *Env) Submitted(t *testing.T, inv payables.Invoice) payables.Invoicefunc (e *Env) Approve(t *testing.T, inv payables.Invoice) payables.Invoicefunc (e *Env) Void(t *testing.T, inv payables.Invoice) payables.Invoicefunc (e *Env) GrantOwnerSeatTo(t *testing.T, member uuid.UUID)func (e *Env) NopPoster(t *testing.T)// payablestestfunc (e *Env) Doc(t *testing.T) uuid.UUID // a verified PDF uploaded by Stafffunc (e *Env) Invoice(t *testing.T, kind string, s payables.Supplier, no string, lines ...payables.LineInput) payables.Invoice // draft by Stafffunc (e *Env) Post(t *testing.T, inv payables.Invoice) payables.Invoice // submit (Staff), post (Owner)func Line(account uuid.UUID, net string) payables.LineInput- Step 1: Failing test.
internal/platform/payables/invoice_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/workflow")
func TestCreateInvoiceTotalsFreezesBaseAndDefaultsTheDueDate(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") inv := e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "100.00"), payablestest.Line(e.Account(t, "6160"), "50.00")) require.Regexp(t, `^PI-2026-\d{4}$`, inv.Reference) require.Equal(t, "150.0000", inv.GrossAmount.String()) require.Equal(t, "150.0000", inv.GrossBase.String()) require.Equal(t, expensestest.Today.AddDate(0, 0, 30), inv.DueOn, "invoice date + supplier terms") require.Equal(t, "draft", inv.State) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { i, err := workflow.Get(ctx, tx, inv.InstanceID) require.Equal(t, "150.0000", i.Amount.String(), "the engine is told the base gross (thresholds)") return err })}
func TestForeignCurrencyNeedsARateAndFreezesLineBases(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "USSUP") in := payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: "U-1", InvoiceDate: expensestest.Today, Currency: "USD", Lines: []payables.LineInput{payablestest.Line(e.Account(t, "6170"), "100.00")}} var fe *payables.FieldError require.ErrorAs(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, in) return err }), &fe) require.Equal(t, "fx_rate_required", fe.Rule) rate := money.MustParseRate("4.4567") in.FXRate = &rate require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { // PRESENCE inv, err := payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, in) require.Equal(t, "445.6700", inv.GrossBase.String()) require.Equal(t, "445.6700", inv.Lines[0].NetBase.String()) return err }))}
func TestTaxMustMatchTheCodeWithinRounding(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") psvt8 := e.TaxCode(t, "PSVT8") // MY pack: 8% service tax on purchases (Task 5A) tax8 := payables.LineInput{Description: "Cleaning", AccountID: e.Account(t, "6160"), TaxCodeID: &psvt8, NetAmount: money.MustParse("100.00"), TaxAmount: money.MustParse("8.04")} e.Invoice(t, payables.KindInvoice, s, "INV-OK", tax8) // PRESENCE: a purchase code, 0.04 off, is accepted tax8.TaxAmount = money.MustParse("8.10") var fe *payables.FieldError require.ErrorAs(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: "INV-BAD", InvoiceDate: expensestest.Today, Currency: "MYR", Lines: []payables.LineInput{tax8}}) return err }), &fe) require.Equal(t, "tax_amount", fe.Field)
// A sales-side code is refused on a purchase line (P40), by name. svt8 := e.TaxCode(t, "SVT8") sales := payables.LineInput{Description: "Cleaning", AccountID: e.Account(t, "6160"), TaxCodeID: &svt8, NetAmount: money.MustParse("100.00"), TaxAmount: money.MustParse("8.00")} require.ErrorAs(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: "INV-SALES", InvoiceDate: expensestest.Today, Currency: "MYR", Lines: []payables.LineInput{sales}}) return err }), &fe) require.Equal(t, "tax_code_usage", fe.Rule)}
func TestDuplicateSupplierNumberNamesTheExistingDocument(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") first := e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "10.00")) e.Invoice(t, payables.KindInvoice, s, "INV-002", payablestest.Line(e.Account(t, "6140"), "10.00")) // PRESENCE var dup *payables.DuplicateInvoiceError require.ErrorAs(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: " inv-001 ", InvoiceDate: expensestest.Today, Currency: "MYR", Lines: []payables.LineInput{payablestest.Line(e.Account(t, "6140"), "10.00")}}) return err }), &dup) require.Equal(t, first.Reference, dup.Reference)}
func TestAWithdrawnInvoiceFreesItsNumber(t *testing.T) { e := payablestest.New(t) s := e.Supplier(t, "BEKAL") inv := e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "10.00")) require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: "draft", To: "cancelled", Actor: e.Staff, Reason: "entered twice"}) return err })) e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "10.00"))}
func TestSubmissionNeedsAVerifiedDocumentAndABasis(t *testing.T) { e := payablestest.New(t) e.NopPoster(t) s := e.Supplier(t, "BEKAL") // B1: no document and no basis — e.Invoice would attach both. line := payablestest.Line(e.Account(t, "6140"), "10.00") var inv payables.Invoice require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: "INV-001", InvoiceDate: expensestest.Today, Currency: "MYR", Lines: []payables.LineInput{line}}) return err })) var sub *payables.SubmissionError require.ErrorAs(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: "draft", To: "pending_review", Actor: e.Staff}) return err }), &sub) fields := []string{} for _, p := range sub.Problems { fields = append(fields, p.Field) } require.ElementsMatch(t, []string{"document_id", "document_basis"}, fields) posted := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-002", payablestest.Line(e.Account(t, "6140"), "10.00"))) require.Equal(t, "approved", posted.State, "PRESENCE: with both, it submits and posts")}
func TestThePreparerCannotApproveTheirOwnInvoice(t *testing.T) { e := payablestest.New(t) e.NopPoster(t) s := e.Supplier(t, "BEKAL") inv := e.Submitted(t, e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "10.00"))) e.GrantOwnerSeatTo(t, e.Staff) // Staff now holds the approving grant too var refused *workflow.RefusedError require.ErrorAs(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: "pending_review", To: "approved", Actor: e.Staff}) return err }), &refused) require.Equal(t, workflow.RefuseSelfApproval, refused.Refusal.Code) // PRESENCE (S4): the same edge, taken by someone else, is allowed. require.Equal(t, "approved", e.Approve(t, inv).State)}-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run 'Invoice|Tax|Duplicate|Withdrawn|Submission|Preparer' -count=1→ FAIL:undefined: payables.CreateInvoice. -
Step 3: Implement.
internal/platform/payables/cursor.go:
package payables
import ( "encoding/base64" "errors" "strings" "time"
"github.com/google/uuid")
var ErrBadCursor = errors.New("payables: malformed cursor")
// Cursor is a keyset position on (created_at DESC, id DESC) (Ruling P30).type Cursor struct { CreatedAt time.Time ID uuid.UUID}
func (c Cursor) Encode() string { return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt.UTC().Format(time.RFC3339Nano) + "|" + c.ID.String()))}
func DecodeCursor(s string) (Cursor, error) { raw, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return Cursor{}, ErrBadCursor } at, id, ok := strings.Cut(string(raw), "|") if !ok { return Cursor{}, ErrBadCursor } t, err1 := time.Parse(time.RFC3339Nano, at) u, err2 := uuid.Parse(id) if err1 != nil || err2 != nil { return Cursor{}, ErrBadCursor } return Cursor{CreatedAt: t, ID: u}, nil}internal/platform/payables/invoice.go — the full service:
package payables
import ( "context" "encoding/json" "errors" "fmt" "slices" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/platform/accounting/analytic" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/numbering" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/taxrecord" "github.com/mcom/erp/internal/platform/workflow")
var ( ErrNotDraft = errors.New("payables: the document is not a draft") ErrHasAllocations = errors.New("payables: the document has allocations; reverse them or void the payments first"))
// taxTolerance is Ruling P14's rounding allowance per line.var taxTolerance = money.MustParse("0.05")
// (types Line, Invoice, LineInput, InvoiceInput, InvoiceFilter exactly as in Interfaces)
type DuplicateInvoiceError struct { ExistingID uuid.UUID Reference string}
func (e *DuplicateInvoiceError) Error() string { return "payables: this supplier's document number is already recorded as " + e.Reference}
type SubmissionError struct{ Problems []FieldError }
func (e *SubmissionError) Error() string { parts := make([]string, len(e.Problems)) for i, p := range e.Problems { parts[i] = p.Error() } return strings.Join(parts, "; ")}
type shareJSON struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"`}
var prefixes = map[string]string{KindInvoice: "PI", KindCreditNote: "CN"}
const invoiceSelect = ` SELECT h.id, h.kind, h.reference, h.supplier_party_id, h.supplier_invoice_no, h.invoice_date, h.due_on, h.currency, h.fx_rate, h.gross_amount, h.gross_base, h.document_id, h.document_basis, h.supplier_tin, h.einvoice_uuid, h.relaxation_reason, h.einvoice_validated_at, h.instance_id, h.prepared_by, h.cancelled_at, h.revision, h.created_at, h.updated_at, i.state_key, s.semantic FROM purchase_invoice h JOIN workflow_instance i ON i.org_id = h.org_id AND i.id = h.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE h.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 `
func scanInvoice(row pgx.Row) (Invoice, error) { var v Invoice err := row.Scan(&v.ID, &v.Kind, &v.Reference, &v.SupplierID, &v.SupplierInvoiceNo, &v.InvoiceDate, &v.DueOn, &v.Currency, &v.FXRate, &v.GrossAmount, &v.GrossBase, &v.DocumentID, &v.DocumentBasis, &v.SupplierTIN, &v.EInvoiceUUID, &v.RelaxationReason, &v.EInvoiceValidatedAt, &v.InstanceID, &v.PreparedBy, &v.CancelledAt, &v.Revision, &v.CreatedAt, &v.UpdatedAt, &v.State, &v.Semantic) if errors.Is(err, pgx.ErrNoRows) { return Invoice{}, ErrNotFound } v.InvoiceDate, v.DueOn = v.InvoiceDate.UTC(), v.DueOn.UTC() return v, err}
func loadLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) ([]Line, error) { rows, err := tx.Query(ctx, ` SELECT id, line_no, description, account_id, tax_code_id, net_amount, tax_amount, net_base, tax_base, shares FROM purchase_invoice_line WHERE org_id = $1 AND invoice_id = $2 AND withdrawn_at IS NULL ORDER BY line_no`, org, id) if err != nil { return nil, fmt.Errorf("payables: lines: %w", err) } defer rows.Close() var out []Line for rows.Next() { var l Line var raw []byte if err := rows.Scan(&l.ID, &l.LineNo, &l.Description, &l.AccountID, &l.TaxCodeID, &l.NetAmount, &l.TaxAmount, &l.NetBase, &l.TaxBase, &raw); err != nil { return nil, err } var stored []shareJSON if err := json.Unmarshal(raw, &stored); err != nil { return nil, fmt.Errorf("payables: line shares: %w", err) } for _, s := range stored { l.Shares = append(l.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } out = append(out, l) } return out, rows.Err()}
func getInvoice(ctx context.Context, tx pgx.Tx, id uuid.UUID, suffix string) (Invoice, error) { org, err := orgOf(ctx) if err != nil { return Invoice{}, err } v, err := scanInvoice(tx.QueryRow(ctx, invoiceSelect+`AND h.id = $2`+suffix, org, id)) if err != nil { return Invoice{}, err } v.Lines, err = loadLines(ctx, tx, org, id) return v, err}
func GetInvoice(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Invoice, error) { return getInvoice(ctx, tx, id, "") }
func lockInvoice(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Invoice, error) { return getInvoice(ctx, tx, id, " FOR UPDATE OF h")}
func ListInvoices(ctx context.Context, tx pgx.Tx, f InvoiceFilter) ([]Invoice, *Cursor, error) { org, err := orgOf(ctx) if err != nil { return nil, nil, err } if f.Limit <= 0 || f.Limit > 200 { f.Limit = 50 } var afterAt *time.Time var afterID *uuid.UUID if f.After != nil { afterAt, afterID = &f.After.CreatedAt, &f.After.ID } rows, err := tx.Query(ctx, invoiceSelect+` AND ($2 = '' OR h.kind = $2) AND ($3::uuid IS NULL OR h.supplier_party_id = $3) AND ($4 = '' OR i.state_key = $4) AND ($5::date IS NULL OR h.due_on < $5) AND ($6 = '' OR h.currency = $6) AND ($7::timestamptz IS NULL OR (h.created_at, h.id) < ($7, $8::uuid)) ORDER BY h.created_at DESC, h.id DESC LIMIT $9`, org, f.Kind, f.SupplierID, f.State, f.DueBefore, f.Currency, afterAt, afterID, f.Limit+1) if err != nil { return nil, nil, fmt.Errorf("payables: list invoices: %w", err) } defer rows.Close() var out []Invoice for rows.Next() { v, err := scanInvoice(rows) if err != nil { return nil, nil, err } out = append(out, v) } if err := rows.Err(); err != nil { return nil, nil, err } var next *Cursor if len(out) > f.Limit { out = out[:f.Limit] last := out[len(out)-1] next = &Cursor{CreatedAt: last.CreatedAt, ID: last.ID} } return out, next, nil}
// priced is one validated line with its frozen bases.type priced struct { LineInput netBase, taxBase money.Amount shares []byte}
// checkInput normalizes in and prices its lines at the header's rate.func checkInput(ctx context.Context, tx pgx.Tx, in *InvoiceInput, base string) ([]priced, error) { in.SupplierInvoiceNo = strings.TrimSpace(in.SupplierInvoiceNo) in.Currency = strings.ToUpper(strings.TrimSpace(in.Currency)) switch { case in.SupplierInvoiceNo == "" || len(in.SupplierInvoiceNo) > 64: return nil, fieldErr("supplier_invoice_no", "required", "the supplier's document number is required") case recordtext.Check("supplier_invoice_no", in.SupplierInvoiceNo) != nil: return nil, fieldErr("supplier_invoice_no", "record_language", "record text is English or Bahasa Malaysia") case !currencyCode.MatchString(in.Currency): return nil, fieldErr("currency", "currency_code", "a three-letter currency code, such as MYR") case in.Currency == base && in.FXRate != nil && in.FXRate.Cmp(money.OneRate()) != 0: return nil, fieldErr("fx_rate", "fx_rate_identity", "a document in the base currency has a rate of 1") case in.Currency != base && in.FXRate == nil: return nil, fieldErr("fx_rate", "fx_rate_required", "a foreign-currency document needs its rate to "+base) case in.FXRate != nil && !in.FXRate.IsPositive(): return nil, fieldErr("fx_rate", "fx_rate_positive", "the rate must be positive") case len(in.Lines) == 0 || len(in.Lines) > 200: return nil, fieldErr("lines", "lines_count", "a document has 1 to 200 lines") } if in.Currency == base { one := money.OneRate() in.FXRate = &one } rec, err := taxrecord.Normalize(taxrecord.Record{SupplierTIN: in.SupplierTIN, EInvoiceUUID: in.EInvoiceUUID, DocumentBasis: in.DocumentBasis, RelaxationReason: in.RelaxationReason}) if err != nil { var te *taxrecord.Error if errors.As(err, &te) { return nil, fieldErr(te.Field, te.Rule, te.Detail) } return nil, err } if rec.DocumentBasis != nil && *rec.DocumentBasis == taxrecord.BasisEInvoiceEmployee { return nil, fieldErr("document_basis", "employee_named_not_allowed", "a supplier invoice is issued to the company") } in.SupplierTIN, in.EInvoiceUUID, in.DocumentBasis = rec.SupplierTIN, rec.EInvoiceUUID, rec.DocumentBasis // S15: taxrecord.Normalize leaves the reason's text to the caller. if in.RelaxationReason = rec.RelaxationReason; in.RelaxationReason != nil { if r := strings.TrimSpace(*in.RelaxationReason); r == "" || len(r) > 500 || recordtext.Check("relaxation_reason", r) != nil { return nil, fieldErr("relaxation_reason", "record_language", "the reason, up to 500 characters, in English or Bahasa Malaysia") } } if in.EInvoiceValidatedAt != nil && in.EInvoiceUUID == nil { return nil, fieldErr("einvoice_validated_at", "validated_needs_uuid", "a validation date needs the e-Invoice identifier") } out := make([]priced, 0, len(in.Lines)) for i, l := range in.Lines { f := func(field string) string { return fmt.Sprintf("lines[%d].%s", i, field) } l.Description = strings.TrimSpace(l.Description) if l.Description == "" || len(l.Description) > 500 || recordtext.Check("description", l.Description) != nil { return nil, fieldErr(f("description"), "required", "a description in English or Bahasa Malaysia is required") } if !l.NetAmount.IsPositive() { return nil, fieldErr(f("net_amount"), "positive", "the net amount must be positive") } if l.TaxAmount.IsNegative() { return nil, fieldErr(f("tax_amount"), "nonnegative", "the tax amount cannot be negative") } if err := checkLineAccount(ctx, tx, l.AccountID); err != nil { return nil, fieldErr(f("account_id"), "account_usable", err.Error()) } switch { case l.TaxCodeID == nil && !l.TaxAmount.IsZero(): return nil, fieldErr(f("tax_amount"), "tax_needs_code", "tax needs a tax code") case l.TaxCodeID != nil: tc, err := chart.GetTaxCode(ctx, tx, *l.TaxCodeID) if err != nil || !tc.IsActive || in.InvoiceDate.Before(tc.ValidFrom) || (tc.ValidTo != nil && !in.InvoiceDate.Before(*tc.ValidTo)) { return nil, fieldErr(f("tax_code_id"), "tax_code_valid", "the tax code must be active on the document date") } if tc.Usage != chart.TaxUsagePurchase && tc.Usage != chart.TaxUsageBoth { // P40 return nil, fieldErr(f("tax_code_id"), "tax_code_usage", "this is a sales tax code; choose a purchase tax code, such as "+"PSVT8") } if diff := l.TaxAmount.Sub(l.NetAmount.Percent(tc.Rate)); diff.Cmp(taxTolerance) > 0 || diff.Neg().Cmp(taxTolerance) > 0 { return nil, fieldErr(f("tax_amount"), "tax_matches_rate", fmt.Sprintf("the tax is %s at %s%%; the amount entered is more than 0.05 away", l.NetAmount.Percent(tc.Rate), tc.Rate)) } } acct, _ := chart.GetAccount(ctx, tx, l.AccountID) netBase := l.NetAmount.MulRate(*in.FXRate) if _, err := analytic.Plan(ctx, tx, acct.Type, netBase, l.Shares, true); err != nil { return nil, fieldErr(f("shares"), "shares_valid", err.Error()) } stored := make([]shareJSON, 0, len(l.Shares)) for _, s := range l.Shares { stored = append(stored, shareJSON{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } raw, _ := json.Marshal(stored) out = append(out, priced{LineInput: l, netBase: netBase, taxBase: l.TaxAmount.MulRate(*in.FXRate), shares: raw}) } return out, nil}
// writeLines withdraws the current lines and inserts lines (Ruling P16),// then writes the totals onto the header. Returns gross and gross base.func writeLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, now time.Time, lines []priced) (money.Amount, money.Amount, error) { if _, err := tx.Exec(ctx, `UPDATE purchase_invoice_line SET withdrawn_at = $3 WHERE org_id = $1 AND invoice_id = $2 AND withdrawn_at IS NULL`, org, id, now); err != nil { return money.Zero(), money.Zero(), fmt.Errorf("payables: withdraw lines: %w", err) } gross, grossBase := money.Zero(), money.Zero() for i, l := range lines { if _, err := tx.Exec(ctx, ` INSERT INTO purchase_invoice_line (org_id, invoice_id, line_no, description, account_id, tax_code_id, net_amount, tax_amount, net_base, tax_base, shares, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, org, id, i+1, l.Description, l.AccountID, l.TaxCodeID, l.NetAmount, l.TaxAmount, l.netBase, l.taxBase, l.shares, now); err != nil { return money.Zero(), money.Zero(), fmt.Errorf("payables: insert line: %w", err) } gross = gross.Add(l.NetAmount).Add(l.TaxAmount) grossBase = grossBase.Add(l.netBase).Add(l.taxBase) } return gross, grossBase, nil}
func duplicateOf(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, supplier uuid.UUID, no string, err error) error { var pg *pgconn.PgError if !errors.As(err, &pg) || pg.ConstraintName != "purchase_invoice_supplier_no_once" { return err } var d DuplicateInvoiceError if qerr := tx.QueryRow(ctx, ` SELECT id, reference FROM purchase_invoice WHERE org_id = $1 AND kind = $2 AND supplier_party_id = $3 AND lower(supplier_invoice_no) = lower($4) AND cancelled_at IS NULL`, org, kind, supplier, no).Scan(&d.ExistingID, &d.Reference); qerr != nil { return err } return &d}
// CreateInvoice files a draft invoice or credit note. The supplier's number// is checked against the live documents FIRST, in a savepoint-free way: a// failed INSERT would abort the transaction, so the duplicate is looked up// before it is attempted, and the unique index stays the backstop.func CreateInvoice(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in InvoiceInput) (Invoice, error) { org, err := orgOf(ctx) if err != nil { return Invoice{}, err } prefix, ok := prefixes[in.Kind] if !ok { return Invoice{}, fieldErr("kind", "kind_known", "a purchase invoice or a supplier credit note") } sup, err := GetSupplier(ctx, tx, in.SupplierID) if err != nil || !sup.IsActive { return Invoice{}, fieldErr("supplier_id", "supplier_active", "choose an active supplier") } profile, err := company.Get(ctx, tx) if err != nil { return Invoice{}, err } lines, err := checkInput(ctx, tx, &in, profile.BaseCurrency) if err != nil { return Invoice{}, err } due := in.InvoiceDate if in.Kind == KindInvoice { due = in.InvoiceDate.AddDate(0, 0, sup.PaymentTermsDays) } if in.DueOn != nil { due = *in.DueOn } if due.Before(in.InvoiceDate) { return Invoice{}, fieldErr("due_on", "due_after_date", "the due date cannot be before the document date") } var existing DuplicateInvoiceError if err := tx.QueryRow(ctx, ` SELECT id, reference FROM purchase_invoice WHERE org_id = $1 AND kind = $2 AND supplier_party_id = $3 AND lower(supplier_invoice_no) = lower($4) AND cancelled_at IS NULL`, org, in.Kind, in.SupplierID, in.SupplierInvoiceNo).Scan(&existing.ExistingID, &existing.Reference); err == nil { return Invoice{}, &existing } else if !errors.Is(err, pgx.ErrNoRows) { return Invoice{}, err } today := clock.TodayUTC(c) year, err := fiscal.YearContaining(ctx, tx, today) if err != nil { return Invoice{}, err } n, err := numbering.Allocate(ctx, tx, in.Kind, year.EndYear) if err != nil { return Invoice{}, err } id := uuid.New() inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: in.Kind, SourceID: id, Reporter: actor, Amount: money.Zero()}) if err != nil { return Invoice{}, err } now := c.Now() if _, err := tx.Exec(ctx, ` INSERT INTO purchase_invoice (id, org_id, kind, reference, supplier_party_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, document_id, document_basis, supplier_tin, einvoice_uuid, relaxation_reason, einvoice_validated_at, instance_id, prepared_by, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)`, id, org, in.Kind, numbering.Format(prefix, year.EndYear, n), in.SupplierID, in.SupplierInvoiceNo, in.InvoiceDate, due, in.Currency, *in.FXRate, in.DocumentID, in.DocumentBasis, in.SupplierTIN, in.EInvoiceUUID, in.RelaxationReason, in.EInvoiceValidatedAt, inst.ID, actor, now); err != nil { return Invoice{}, duplicateOf(ctx, tx, org, in.Kind, in.SupplierID, in.SupplierInvoiceNo, err) } if err := bindDocument(ctx, tx, id, in.DocumentID); err != nil { return Invoice{}, err } inv, err := reprice(ctx, tx, c, org, id, lines, now) if err != nil { return Invoice{}, err } return inv, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityInvoice, EntityID: id, Action: "create", After: inv})}
// reprice writes the lines and totals and tells the engine the base gross// and, for an invoice, the analytic shares (Ruling P11).func reprice(ctx context.Context, tx pgx.Tx, c clock.Clock, org, id uuid.UUID, lines []priced, now time.Time) (Invoice, error) { gross, grossBase, err := writeLines(ctx, tx, org, id, now, lines) if err != nil { return Invoice{}, err } if _, err := tx.Exec(ctx, `UPDATE purchase_invoice SET gross_amount = $3, gross_base = $4, updated_at = $5 WHERE org_id = $1 AND id = $2`, org, id, gross, grossBase, now); err != nil { return Invoice{}, fmt.Errorf("payables: totals: %w", err) } inv, err := GetInvoice(ctx, tx, id) if err != nil { return Invoice{}, err } if err := workflow.SetAmount(ctx, tx, c, inv.InstanceID, grossBase); err != nil { return Invoice{}, err } if inv.Kind == KindInvoice { shares, err := invoiceShares(ctx, tx, inv) if err != nil { return Invoice{}, err } if err := workflow.SetShares(ctx, tx, c, inv.InstanceID, shares); err != nil { return Invoice{}, err } } return inv, nil}
// invoiceShares is expenses.claimShares over an invoice's lines: per axis// that applies to expenses, each line's base (net + tax) split by its shares,// the uncovered part on the axis's unallocated total.func invoiceShares(ctx context.Context, tx pgx.Tx, inv Invoice) ([]workflow.Share, error) { axes, err := analytic.ListAxes(ctx, tx) if err != nil { return nil, err } type key struct{ axis, account uuid.UUID } totals := map[key]money.Amount{} var order []key add := func(k key, a money.Amount) { if _, ok := totals[k]; !ok { order, totals[k] = append(order, k), money.Zero() } totals[k] = totals[k].Add(a) } for _, l := range inv.Lines { base := l.NetBase.Add(l.TaxBase) planned, err := analytic.Allocate(ctx, tx, base, l.Shares) if err != nil { return nil, err } covered := map[uuid.UUID]bool{} for _, p := range planned { add(key{p.AxisID, p.AnalyticAccountID}, p.BaseAmount) covered[p.AxisID] = true } for _, a := range axes { if a.IsActive && slices.Contains(a.AppliesTo, chart.TypeExpense) && !covered[a.ID] { add(key{axis: a.ID}, base) } } } out := make([]workflow.Share, 0, len(order)) for _, k := range order { s := workflow.Share{AxisID: k.axis, Amount: totals[k]} if k.account != uuid.Nil { acc := k.account s.AccountID = &acc } out = append(out, s) } return out, nil}
func UpdateInvoice(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in InvoiceInput) (Invoice, error) { org, err := orgOf(ctx) if err != nil { return Invoice{}, err } before, err := lockInvoice(ctx, tx, id) if err != nil { return Invoice{}, err } if before.Revision != expectedRevision { return Invoice{}, &RevisionMismatchError{Current: before.Revision} } if before.Semantic != posting.Draft { return Invoice{}, ErrNotDraft } in.Kind = before.Kind sup, err := GetSupplier(ctx, tx, in.SupplierID) if err != nil || !sup.IsActive { return Invoice{}, fieldErr("supplier_id", "supplier_active", "choose an active supplier") } profile, err := company.Get(ctx, tx) if err != nil { return Invoice{}, err } lines, err := checkInput(ctx, tx, &in, profile.BaseCurrency) if err != nil { return Invoice{}, err } due := in.InvoiceDate if in.Kind == KindInvoice { due = in.InvoiceDate.AddDate(0, 0, sup.PaymentTermsDays) } if in.DueOn != nil { due = *in.DueOn } if due.Before(in.InvoiceDate) { return Invoice{}, fieldErr("due_on", "due_after_date", "the due date cannot be before the document date") } var existing DuplicateInvoiceError if err := tx.QueryRow(ctx, ` SELECT id, reference FROM purchase_invoice WHERE org_id = $1 AND kind = $2 AND supplier_party_id = $3 AND lower(supplier_invoice_no) = lower($4) AND cancelled_at IS NULL AND id <> $5`, org, in.Kind, in.SupplierID, in.SupplierInvoiceNo, id).Scan(&existing.ExistingID, &existing.Reference); err == nil { return Invoice{}, &existing } else if !errors.Is(err, pgx.ErrNoRows) { return Invoice{}, err } now := c.Now() if _, err := tx.Exec(ctx, ` UPDATE purchase_invoice SET supplier_party_id = $3, supplier_invoice_no = $4, invoice_date = $5, due_on = $6, currency = $7, fx_rate = $8, document_id = $9, document_basis = $10, supplier_tin = $11, einvoice_uuid = $12, relaxation_reason = $13, einvoice_validated_at = $14, revision = revision + 1, updated_at = $15 WHERE org_id = $1 AND id = $2`, org, id, in.SupplierID, in.SupplierInvoiceNo, in.InvoiceDate, due, in.Currency, *in.FXRate, in.DocumentID, in.DocumentBasis, in.SupplierTIN, in.EInvoiceUUID, in.RelaxationReason, in.EInvoiceValidatedAt, now); err != nil { return Invoice{}, duplicateOf(ctx, tx, org, in.Kind, in.SupplierID, in.SupplierInvoiceNo, err) } if err := bindDocument(ctx, tx, id, in.DocumentID); err != nil { return Invoice{}, err } after, err := reprice(ctx, tx, c, org, id, lines, now) if err != nil { return Invoice{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityInvoice, EntityID: id, Action: "update", Before: before, After: after})}
// checkSubmission is Ruling P18.func checkSubmission(ctx context.Context, tx pgx.Tx, inv Invoice) error { var probs []FieldError if len(inv.Lines) == 0 { probs = append(probs, FieldError{"lines", "lines_count", "add at least one line"}) } if inv.DocumentID == nil { probs = append(probs, FieldError{"document_id", "document_required", "attach the supplier's document"}) } else if d, err := documents.Get(ctx, tx, *inv.DocumentID); err != nil || d.Status != documents.StatusVerified { probs = append(probs, FieldError{"document_id", "document_verified", "the attached document has not finished verifying"}) } if inv.DocumentBasis == nil { probs = append(probs, FieldError{"document_basis", "basis_required", "say what the document is: an e-Invoice, a foreign supplier's document or a receipt without e-Invoice"}) } if sup, err := GetSupplier(ctx, tx, inv.SupplierID); err != nil || !sup.IsActive { probs = append(probs, FieldError{"supplier_id", "supplier_active", "the supplier is inactive"}) } if len(probs) > 0 { return &SubmissionError{Problems: probs} } return nil}(Add "github.com/mcom/erp/internal/platform/documents" to the imports.) The subtle point: CreateInvoice and UpdateInvoice look the duplicate up before the write, because a failed statement aborts the transaction and duplicateOf could not then query; the index remains the race backstop (duplicateOf answers from it when the lookup can still run, else the raw 23505 maps to 409 in the handler).
internal/platform/payables/binder.go:
package payables
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/documents" "github.com/mcom/erp/internal/platform/identity/member")
func init() { documents.RegisterBinder(documentBinder{}) }
type documentBinder struct{}
func (documentBinder) Kind() string { return BinderKind }
// BoundTo is every live invoice or credit note the document backs.func (documentBinder) BoundTo(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]documents.Binding, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, `SELECT id, reference FROM purchase_invoice WHERE org_id = $1 AND document_id = $2 AND cancelled_at IS NULL`, org, documentID) if err != nil { return nil, fmt.Errorf("payables: bound to: %w", err) } defer rows.Close() var out []documents.Binding for rows.Next() { b := documents.Binding{Kind: BinderKind} if err := rows.Scan(&b.RecordID, &b.Reference); err != nil { return nil, err } out = append(out, b) } return out, rows.Err()}
// MayRead: anyone who may read AP documents (Ruling P29), for a document// that backs one.func (b documentBinder) MayRead(ctx context.Context, tx pgx.Tx, _ clock.Clock, documentID, memberID uuid.UUID) (bool, error) { bs, err := b.BoundTo(ctx, tx, documentID) if err != nil || len(bs) == 0 { return false, err } return member.Can(ctx, tx, memberID, PermissionInvoiceRead)}
// DocumentBoundElsewhereError: the document backs another record.type DocumentBoundElsewhereError struct{ Bindings []documents.Binding }
func (e *DocumentBoundElsewhereError) Error() string { return fmt.Sprintf("payables: the document already backs %s %s", e.Bindings[0].Kind, e.Bindings[0].Reference)}
// bindDocument checks, under the document's lock, that it backs nothing// else (documents contract §1.4). The header row is already locked or new.func bindDocument(ctx context.Context, tx pgx.Tx, invoiceID uuid.UUID, documentID *uuid.UUID) error { if documentID == nil { return nil } if _, err := documents.Lock(ctx, tx, *documentID); err != nil { return fieldErr("document_id", "document_known", "no such document") } bs, err := documents.BoundElsewhere(ctx, tx, *documentID, documents.Binding{Kind: BinderKind, RecordID: invoiceID}) if err != nil { return err } if len(bs) > 0 { return &DocumentBoundElsewhereError{Bindings: bs} } return nil}internal/platform/payables/transition.go (the invoice half; Task 9 adds TransitionPayment to this file):
package payables
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
type TransitionInput struct { ID uuid.UUID From, To string Actor uuid.UUID Reason string}
func Available(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, actor uuid.UUID) ([]workflow.Available, error) { return workflow.AvailableTransitions(ctx, tx, c, instanceID, actor)}
// destination reads the target state's semantic without locking the instance.func destination(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID, from, to string) (workflow.StateSpec, workflow.StateSpec, error) { inst, err := workflow.Get(ctx, tx, instanceID) if err != nil { return workflow.StateSpec{}, workflow.StateSpec{}, err } def, err := workflow.Load(ctx, tx, inst.DefinitionID) if err != nil { return workflow.StateSpec{}, workflow.StateSpec{}, err } f, _ := def.State(from) t, ok := def.State(to) if !ok { return workflow.StateSpec{}, workflow.StateSpec{}, workflow.ErrNoSuchEdge } return f, t, nil}
// TransitionInvoice moves an invoice or credit note. ORDER: header FOR// UPDATE → checks → cancelled_at → workflow.Transition (posts the accrual// or its reversal) → audit, last.func TransitionInvoice(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Invoice, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Invoice{}, workflow.Decision{}, err } inv, err := lockInvoice(ctx, tx, in.ID) if err != nil { return Invoice{}, workflow.Decision{}, err } from, to, err := destination(ctx, tx, inv.InstanceID, in.From, in.To) if err != nil { return Invoice{}, workflow.Decision{}, err } if from.Semantic == posting.Draft && to.Semantic != posting.Cancelled && to.Semantic != posting.Rejected { if err := checkSubmission(ctx, tx, inv); err != nil { return Invoice{}, workflow.Decision{}, err } } // Task 9 inserts the has-allocations refusal (Ruling P24) here, once // ap_allocation exists. if to.Semantic == posting.Cancelled { if _, err := tx.Exec(ctx, `UPDATE purchase_invoice SET cancelled_at = $3 WHERE org_id = $1 AND id = $2`, org, inv.ID, c.Now()); err != nil { return Invoice{}, workflow.Decision{}, fmt.Errorf("payables: cancel: %w", err) } } d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: inv.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Invoice{}, workflow.Decision{}, err } after, err := GetInvoice(ctx, tx, inv.ID) if err != nil { return Invoice{}, workflow.Decision{}, err } return after, d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityInvoice, EntityID: inv.ID, Action: "transition", Before: map[string]string{"state": inv.State}, After: map[string]string{"state": after.State}})}Note: purchase_invoice_draft_guard fires only on the listed columns, so the cancelled_at update on a posted invoice being voided is allowed.
payablestest/env.go additions:
func Line(account uuid.UUID, net string) payables.LineInput { return payables.LineInput{Description: "Office supplies", AccountID: account, NetAmount: money.MustParse(net), TaxAmount: money.Zero()}}
func (e *Env) TaxCode(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT id FROM tax_code WHERE org_id = $1 AND code = $2`, e.Org, code).Scan(&id)) return id}
func (e *Env) Doc(t *testing.T) uuid.UUID { t.Helper() return e.Document(t, e.Staff, "application/pdf", []byte("invoice "+uuid.NewString()))}
// Invoice files a draft with a verified document and a relaxation-receipt// basis, so it is submittable as it stands.func (e *Env) Invoice(t *testing.T, kind string, s payables.Supplier, no string, lines ...payables.LineInput) payables.Invoice { t.Helper() basis, reason := "relaxation_receipt", "Supplier issued a paper invoice" doc := e.Doc(t) var inv payables.Invoice require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: kind, SupplierID: s.PartyID, SupplierInvoiceNo: no, InvoiceDate: expensestest.Today, Currency: s.Currency, DocumentID: &doc, DocumentBasis: &basis, RelaxationReason: &reason, Lines: lines}) return err })) return inv}
func (e *Env) move(t *testing.T, inv payables.Invoice, by uuid.UUID, from, to string) payables.Invoice { t.Helper() require.NoError(t, e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error inv, _, err = payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: from, To: to, Actor: by, Reason: "test"}) return err })) return inv}
func (e *Env) Submitted(t *testing.T, inv payables.Invoice) payables.Invoice { return e.move(t, inv, e.Staff, "draft", "pending_review")}
// Approve posts a submitted invoice as Owner (the OWNER seat).func (e *Env) Approve(t *testing.T, inv payables.Invoice) payables.Invoice { return e.move(t, inv, e.Owner, "pending_review", "approved")}
// Post submits (Staff, the preparer) and approves (Owner, the OWNER seat).func (e *Env) Post(t *testing.T, inv payables.Invoice) payables.Invoice { return e.Approve(t, e.Submitted(t, inv))}
func (e *Env) Void(t *testing.T, inv payables.Invoice) payables.Invoice { return e.move(t, inv, e.Owner, "approved", "voided")}
// GrantOwnerSeatTo seats member in the OWNER position as a secondary seat.func (e *Env) GrantOwnerSeatTo(t *testing.T, member uuid.UUID) { t.Helper() e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := orgstruct.Assign(ctx, tx, e.Clock, orgstruct.AssignmentInput{MemberID: member, PositionID: e.OwnerSeat, IsPrimary: false, ValidFrom: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}) return err })}(Imports: money, expensestest, orgstruct, time.) PSVT8 is Task 5A’s MY purchase code; SVT8 the sales-side one.
- Step 4: Run passing.
go test ./internal/platform/payables/... -count=1→ PASS. The posting-dependent step (pending_review → approved) needs a Poster, and the accrual rule is Task 7’s;payablestest.Newregisters nothing, so the two tests that post calle.NopPoster(t)explicitly (already in Step 1). AddNopPostertopayablestest(theadvancestest.NopPosterbody):
type nopPoster struct{}
func (nopPoster) Post(context.Context, pgx.Tx, posting.Event) error { return nil }
// NopPoster lets transitions that would post commit without a ledger.func (e *Env) NopPoster(t *testing.T) { t.Helper() posting.ResetForTest() posting.Register(nopPoster{}) t.Cleanup(posting.ResetForTest)}Task 7 switches those two calls to e.UsePayablesPoster(t).
-
Step 5: Sabotage. (0) Delete the
tc.Usagecheck;TestTaxMustMatch…fails attax_code_usage; restore. (a) DropAND cancelled_at IS NULLfromCreateInvoice’s lookup andWHERE cancelled_at IS NULLfrom the index;TestAWithdrawnInvoiceFreesItsNumberfails withDuplicateInvoiceError; restore. (b) Delete thecheckSubmissioncall;TestSubmissionNeeds…fails withAn error is expected but got nil; restore. (c) RaisetaxToleranceto1.00;TestTaxMustMatch…fails; restore. -
Step 6: Commit.
git add internal/platform/payables && git commit -m "AP Task 6: purchase invoice and credit note service, binder, transitions" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Accrual posting rules — invoice and credit note (D3, D5, D8, D9)
Section titled “Task 7: Accrual posting rules — invoice and credit note (D3, D5, D8, D9)”Files:
- Create:
internal/platform/payables/posting.go - Modify:
internal/platform/payables/payablestest/env.go(UsePayablesPoster,ControlBalanceByParty,EntryFor) - Test:
internal/platform/payables/posting_test.go
Interfaces:
-
Consumes:
accounting.Source,accounting.Draft,accounting.PostingRule,ledger.Line,ledger.Unreversed(ctx, tx, instanceID)(headers),ledger.Get(ctx, tx, id)(with lines),chart.GetTaxCode,chart.CreateTaxCode,chart.JournalPurchase. -
Produces:
payables.AccrualRule{KindName string}(implementsaccounting.PostingRule);payables.Rules() []accounting.PostingRule(Task 9 appendsPaymentPayoutRule{});payables.ErrInstanceMismatch,payables.ErrTotalsDrift;payablestest.(*Env).UsePayablesPoster(t),TaxCodeWithAccount(t, code, rate string, account *uuid.UUID) uuid.UUID,ControlBalanceByParty(t) map[uuid.UUID]money.Amount,EntryFor(t, instanceID uuid.UUID, semantic string) ledger.Entry. -
Step 1: Failing test.
internal/platform/payables/posting_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
// sumSide totals base debit and credit of entry's lines on account.func sumSide(entry ledger.Entry, account uuid.UUID) (dr, cr money.Amount) { dr, cr = money.Zero(), money.Zero() for _, l := range entry.Lines { if l.AccountID == account { dr, cr = dr.Add(l.BaseDebit), cr.Add(l.BaseCredit) } } return dr, cr}
func requireBalancedWithPartyOnControl(t *testing.T, e *payablestest.Env, entry ledger.Entry, party uuid.UUID) { t.Helper() control := e.Account(t, "2105") dr, cr := money.Zero(), money.Zero() var onControl int for _, l := range entry.Lines { dr, cr = dr.Add(l.BaseDebit), cr.Add(l.BaseCredit) if l.AccountID == control { onControl++ require.NotNil(t, l.PartyID, "every ap_trade line carries the supplier (D2)") require.Equal(t, party, *l.PartyID) } } require.Positive(t, onControl, "PRESENCE: the entry touches Trade payables") require.Zero(t, dr.Cmp(cr), "balanced in base")}
func TestInvoiceAccrualSplitsTaxByTheCodesAccount(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") input := e.Account(t, "1570") withAccount := e.TaxCodeWithAccount(t, "TPIN6", "6", &input) // a 6% purchase code that names an account cost := e.TaxCode(t, "PSVT8") // MY purchase code, no account → cost (D8) inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-001", payables.LineInput{Description: "Printer toner", AccountID: e.Account(t, "6140"), TaxCodeID: &withAccount, NetAmount: money.MustParse("100.00"), TaxAmount: money.MustParse("6.00")}, payables.LineInput{Description: "Aircond service", AccountID: e.Account(t, "6160"), TaxCodeID: &cost, NetAmount: money.MustParse("200.00"), TaxAmount: money.MustParse("16.00")})) entry := e.EntryFor(t, inv.InstanceID, ledger.SemanticAccrual) requireBalancedWithPartyOnControl(t, e, entry, s.PartyID) dr, _ := sumSide(entry, e.Account(t, "6140")) require.Equal(t, "100.0000", dr.String(), "net only: the code names an account") dr, _ = sumSide(entry, input) require.Equal(t, "6.0000", dr.String(), "tax to the code's account") dr, _ = sumSide(entry, e.Account(t, "6160")) require.Equal(t, "216.0000", dr.String(), "no account on the code: SST is cost (D8)") _, cr := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "322.0000", cr.String()) require.Equal(t, "purchase_invoice", *entry.SourceKind)}
func TestCreditNoteMirrorsTheAccrual(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, s, "CN-001", payablestest.Line(e.Account(t, "6140"), "40.00"))) entry := e.EntryFor(t, cn.InstanceID, ledger.SemanticAccrual) requireBalancedWithPartyOnControl(t, e, entry, s.PartyID) dr, _ := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "40.0000", dr.String(), "Dr Trade payables") _, cr := sumSide(entry, e.Account(t, "6140")) require.Equal(t, "40.0000", cr.String(), "Cr the expense")}
func TestForeignInvoicePostsInItsCurrencyAtItsFrozenBase(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "USSUP") rate := money.MustParseRate("4.4567") doc, basis := e.Doc(t), "foreign_supplier" var inv payables.Invoice require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: "U-1", InvoiceDate: expensestest.Today, Currency: "USD", FXRate: &rate, DocumentID: &doc, DocumentBasis: &basis, Lines: []payables.LineInput{payablestest.Line(e.Account(t, "6170"), "100.00")}}) return err })) entry := e.EntryFor(t, e.Post(t, inv).InstanceID, ledger.SemanticAccrual) require.Equal(t, "USD", entry.Currency) for _, l := range entry.Lines { require.NotEqual(t, ledger.KindFXDifference, l.Kind, "frozen bases balance: no FX line on an accrual (Ruling P13)") } _, cr := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "445.6700", cr.String())}
func TestVoidReversesTheAccrual(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "10.00"))) require.Equal(t, "10.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "PRESENCE: the accrual is on the control") e.Void(t, inv) require.Equal(t, "0.0000", e.ControlBalanceByParty(t)[s.PartyID].String())}(Import github.com/google/uuid.)
-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run 'Accrual|CreditNote|Foreign|Void' -count=1→ FAIL:undefined: payablestest.(*Env).UsePayablesPoster. -
Step 3: Implement
posting.go.
// The AP posting rules (spec D3, D4, D5, D8, D9). A rule reads its document// by Source.Event.SourceID on the transition's own tx and returns a Draft;// it never writes a journal line. Reversal (void) is the engine's mirror.package payables
import ( "context" "errors" "fmt"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/posting")
var ( ErrInstanceMismatch = errors.New("payables: the event's instance is not this document's instance") ErrTotalsDrift = errors.New("payables: the document's lines and its totals disagree"))
// Rules is every posting rule this package owns.func Rules() []accounting.PostingRule { return []accounting.PostingRule{AccrualRule{KindName: KindInvoice}, AccrualRule{KindName: KindCreditNote}}}
// AccrualRule posts an invoice (Dr expense/tax, Cr Trade payables) or, for// a credit note, its mirror, when the document enters an accrual state.type AccrualRule struct{ KindName string }
var _ accounting.PostingRule = AccrualRule{}
func (r AccrualRule) Kind() string { return r.KindName }func (AccrualRule) Semantic() posting.Semantic { return posting.Accrual }
func (r AccrualRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { if src.Control == nil { return accounting.Draft{}, fmt.Errorf("payables: accrual: %w", chart.ErrNotFound) } inv, err := GetInvoice(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, fmt.Errorf("payables: accrual: %w", err) } if inv.InstanceID != src.Event.InstanceID || inv.Kind != r.KindName { return accounting.Draft{}, ErrInstanceMismatch } party := inv.SupplierID mirror := inv.Kind == KindCreditNote side := func(l ledger.Line, amt, base money.Amount, debit bool) ledger.Line { if debit != mirror { // invoice: debit; credit note: the mirror l.Debit = amt } else { l.Credit = amt } b := base l.Base = &b return l } gross, grossBase := money.Zero(), money.Zero() var out []ledger.Line for _, l := range inv.Lines { memo := fmt.Sprintf("%s L%d %s", inv.Reference, l.LineNo, l.Description) expense, expenseBase := l.NetAmount, l.NetBase var taxAccount *chart.Account if l.TaxCodeID != nil && l.TaxAmount.IsPositive() { tc, err := chart.GetTaxCode(ctx, tx, *l.TaxCodeID) if err != nil { return accounting.Draft{}, fmt.Errorf("payables: accrual: %w", err) } if tc.AccountID != nil { a, err := chart.GetAccount(ctx, tx, *tc.AccountID) if err != nil { return accounting.Draft{}, fmt.Errorf("payables: accrual: %w", err) } taxAccount = &a } else { // D8: SST is cost expense, expenseBase = expense.Add(l.TaxAmount), expenseBase.Add(l.TaxBase) } } out = append(out, side(ledger.Line{AccountID: l.AccountID, TaxCodeID: l.TaxCodeID, PartyID: &party, Memo: memo, Distribution: l.Shares}, expense, expenseBase, true)) if taxAccount != nil { out = append(out, side(ledger.Line{AccountID: taxAccount.ID, TaxCodeID: l.TaxCodeID, PartyID: &party, Memo: memo + " tax"}, l.TaxAmount, l.TaxBase, true)) } gross, grossBase = gross.Add(l.NetAmount).Add(l.TaxAmount), grossBase.Add(l.NetBase).Add(l.TaxBase) } if len(inv.Lines) == 0 || gross.Cmp(inv.GrossAmount) != 0 || grossBase.Cmp(inv.GrossBase) != 0 { return accounting.Draft{}, fmt.Errorf("%w: lines %s/%s, header %s/%s", ErrTotalsDrift, gross, grossBase, inv.GrossAmount, inv.GrossBase) } out = append(out, side(ledger.Line{AccountID: src.Control.ID, PartyID: &party, Memo: inv.Reference + " " + inv.SupplierInvoiceNo}, inv.GrossAmount, inv.GrossBase, false)) label := map[string]string{KindInvoice: "Purchase invoice ", KindCreditNote: "Supplier credit note "}[inv.Kind] return accounting.Draft{ JournalType: chart.JournalPurchase, Currency: inv.Currency, Description: label + inv.Reference + " " + inv.SupplierInvoiceNo, Lines: out, }, nil}payablestest/env.go additions:
func (e *Env) UsePayablesPoster(t *testing.T) { t.Helper(); e.UsePoster(t, payables.Rules()...) }
// TaxCodeWithAccount creates an active purchase-usage tax code at rate%// posting to account (nil: none, so D8 makes the tax cost).func (e *Env) TaxCodeWithAccount(t *testing.T, code, rate string, account *uuid.UUID) uuid.UUID { t.Helper() var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { tc, err := chart.CreateTaxCode(ctx, tx, e.Clock, chart.TaxCodeInput{Code: code, Name: code, PackKind: "test", Usage: chart.TaxUsagePurchase, Rate: money.MustParseRate(rate), AccountID: account, ValidFrom: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}) id = tc.ID return err }) return id}
// EntryFor is the instance's unreversed entry of semantic.func (e *Env) EntryFor(t *testing.T, instanceID uuid.UUID, semantic string) ledger.Entry { t.Helper() var out ledger.Entry e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { open, err := ledger.Unreversed(ctx, tx, instanceID) // headers only if err != nil { return err } for _, en := range open { if en.Semantic == semantic { out, err = ledger.Get(ctx, tx, en.ID) // with lines return err } } return nil }) require.NotEqual(t, uuid.Nil, out.ID, "no unreversed %s entry for instance %s", semantic, instanceID) return out}
// ControlBalanceByParty is the Trade payables base balance (credit − debit)// per party, over every posted line.func (e *Env) ControlBalanceByParty(t *testing.T) map[uuid.UUID]money.Amount { t.Helper() rows, err := e.H.Admin.Query(context.Background(), ` SELECT l.party_id, sum(l.base_credit - l.base_debit)::text FROM journal_line l JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND a.org_id = $1 AND a.control_kind = 'ap_trade' GROUP BY l.party_id`, e.Org) require.NoError(t, err) defer rows.Close() out := map[uuid.UUID]money.Amount{} for rows.Next() { var party *uuid.UUID var s string require.NoError(t, rows.Scan(&party, &s)) require.NotNil(t, party, "an ap_trade line without a party (D2)") out[*party] = money.MustParse(s) } require.NoError(t, rows.Err()) return out}Switch Task 6’s NopPoster calls in TestSubmissionNeeds… and TestThePreparerCannot… to UsePayablesPoster.
-
Step 4: Run passing.
go test ./internal/platform/payables/... -count=1→ PASS. -
Step 5: Sabotage. Remove
PartyID: &partyfrom the control line;requireBalancedWithPartyOnControlfailsevery ap_trade line carries the supplier; restore. Invertmirror;TestCreditNoteMirrors…fails; restore. -
Step 6: Commit.
git add internal/platform/payables && git commit -m "AP Task 7: invoice and credit note accrual rules (D3, D5, D8, D9)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Supplier payment and allocation schema
Section titled “Task 8: Supplier payment and allocation schema”Files:
- Create:
db/migrations/00304_supplier_payment_allocation.sql - Modify:
sqlc.yaml(supplier_payment.fx_rate→money.Rate) - Modify:
internal/kernel/db/retention_test.go(supplier_payment,supplier_payment_line→amendable;ap_allocation→appendOnly) - Test:
internal/platform/payables/allocation_schema_test.go
Interfaces:
-
Consumes:
purchase_invoice,party_supplier,account,workflow_instance,payables_semantic(). -
Produces: tables
supplier_payment,supplier_payment_line,ap_allocation; triggerap_allocation_guard; constraint namessupplier_payment_is_draft,supplier_payment_line_is_draft,ap_allocation_target_is_invoice,ap_allocation_invoice_posted,ap_allocation_same_supplier,ap_allocation_currency_matches,ap_allocation_rate_matches,ap_allocation_credit_note_posted,ap_allocation_not_over,ap_allocation_credit_not_over,ap_allocation_clears_base,ap_allocation_reversal_mirrors, indexap_allocation_reversed_once. -
Step 1: Failing test.
internal/platform/payables/allocation_schema_test.go:
package payables_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
// rawAllocate inserts a credit-note allocation as erp_app, bypassing Go.func rawAllocate(e *payablestest.Env, inv, cn uuid.UUID, amount, base string, reverses *uuid.UUID) (uuid.UUID, error) { var id uuid.UUID err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO ap_allocation (org_id, invoice_id, credit_note_id, amount, base_amount, currency, effective_on, reverses_id, created_by, created_at) VALUES ($1, $2, $3, $4, $5, 'MYR', DATE '2026-09-21', $6, $7, now()) RETURNING id`, e.Org, inv, cn, amount, base, reverses, e.Staff).Scan(&id) }) return id, err}
func TestTheAllocationGuardHoldsWhateverGoDoes(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "100.00"))) cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, s, "CN-001", payablestest.Line(e.Account(t, "6140"), "30.00")))
// PRESENCE: a partial offset is accepted. first, err := rawAllocate(e, inv.ID, cn.ID, "20.0000", "20.0000", nil) require.NoError(t, err) // ABSENCE: more than the credit note has left is refused by name. _, err = rawAllocate(e, inv.ID, cn.ID, "20.0000", "20.0000", nil) requirePgError(t, err, "23514", "ap_allocation_credit_not_over") // A reversal must mirror, and happens once. _, err = rawAllocate(e, inv.ID, cn.ID, "-10.0000", "-10.0000", &first) requirePgError(t, err, "23514", "ap_allocation_reversal_mirrors") _, err = rawAllocate(e, inv.ID, cn.ID, "-20.0000", "-20.0000", &first) require.NoError(t, err, "PRESENCE: an exact mirror reverses") _, err = rawAllocate(e, inv.ID, cn.ID, "-20.0000", "-20.0000", &first) requirePgError(t, err, "23505", "ap_allocation_reversed_once") // Rows are never changed or removed. err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE ap_allocation SET amount = 1 WHERE org_id = $1 AND id = $2`, e.Org, first) return err }) require.ErrorContains(t, err, "permission denied")}
func TestOnlyAPostedInvoiceTakesAnAllocation(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") draft := e.Invoice(t, payables.KindInvoice, s, "INV-001", payablestest.Line(e.Account(t, "6140"), "100.00")) posted := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-002", payablestest.Line(e.Account(t, "6140"), "100.00"))) cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, s, "CN-001", payablestest.Line(e.Account(t, "6140"), "30.00"))) _, err := rawAllocate(e, posted.ID, cn.ID, "10.0000", "10.0000", nil) require.NoError(t, err, "PRESENCE") _, err = rawAllocate(e, draft.ID, cn.ID, "10.0000", "10.0000", nil) requirePgError(t, err, "23514", "ap_allocation_invoice_posted")}-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run 'AllocationGuard|PostedInvoice' -count=1→ FAIL:relation "ap_allocation" does not exist. -
Step 3: Implement
db/migrations/00304_supplier_payment_allocation.sql:
-- +goose Up
-- SUPPLIER PAYMENTS (AP spec D4). Recorded manually; a bank file is the-- payment-batches item. Allocations are planned in supplier_payment_line-- while a draft and become ap_allocation rows at payout (Ruling P20).CREATE TABLE supplier_payment ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, supplier_party_id UUID NOT NULL, bank_account_id UUID NOT NULL, payment_date DATE NOT NULL, bank_reference TEXT NOT NULL DEFAULT '' CONSTRAINT supplier_payment_bank_reference_length CHECK (length(bank_reference) <= 64), currency TEXT NOT NULL CONSTRAINT supplier_payment_currency_code CHECK (currency ~ '^[A-Z]{3}$'), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT supplier_payment_fx_rate_positive CHECK (fx_rate > 0), amount NUMERIC(19,4) NOT NULL CONSTRAINT supplier_payment_amount_positive CHECK (amount > 0), base_amount NUMERIC(19,4) NOT NULL CONSTRAINT supplier_payment_base_positive CHECK (base_amount > 0), instance_id UUID NOT NULL, prepared_by UUID NOT NULL, revision INT NOT NULL DEFAULT 1 CONSTRAINT supplier_payment_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT supplier_payment_tenant_id UNIQUE (org_id, id), CONSTRAINT supplier_payment_reference_once UNIQUE (org_id, reference), CONSTRAINT supplier_payment_instance_once UNIQUE (org_id, instance_id), CONSTRAINT supplier_payment_bank_reference_language CHECK (bank_reference !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT supplier_payment_supplier_fk FOREIGN KEY (org_id, supplier_party_id) REFERENCES party_supplier (org_id, party_id), CONSTRAINT supplier_payment_bank_account_fk FOREIGN KEY (org_id, bank_account_id) REFERENCES account (org_id, id), CONSTRAINT supplier_payment_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT supplier_payment_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id));CREATE INDEX supplier_payment_by_created ON supplier_payment (org_id, created_at DESC, id DESC);
CREATE TABLE supplier_payment_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), payment_id UUID NOT NULL, invoice_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT supplier_payment_line_amount_positive CHECK (amount > 0), withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT supplier_payment_line_tenant_id UNIQUE (org_id, id), CONSTRAINT supplier_payment_line_payment_fk FOREIGN KEY (org_id, payment_id) REFERENCES supplier_payment (org_id, id), CONSTRAINT supplier_payment_line_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES purchase_invoice (org_id, id));CREATE UNIQUE INDEX supplier_payment_line_invoice_once ON supplier_payment_line (org_id, payment_id, invoice_id) WHERE withdrawn_at IS NULL;
-- ALLOCATION (D6; Rulings P20–P23). Append-only: a reversal is a new row.CREATE TABLE ap_allocation ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), invoice_id UUID NOT NULL, payment_id UUID, credit_note_id UUID, source_kind TEXT GENERATED ALWAYS AS (CASE WHEN payment_id IS NOT NULL THEN 'payment' ELSE 'credit_note' END) STORED, amount NUMERIC(19,4) NOT NULL, base_amount NUMERIC(19,4) NOT NULL, currency TEXT NOT NULL CONSTRAINT ap_allocation_currency_code CHECK (currency ~ '^[A-Z]{3}$'), effective_on DATE NOT NULL, reverses_id UUID, created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT ap_allocation_tenant_id UNIQUE (org_id, id), CONSTRAINT ap_allocation_one_source CHECK (num_nonnulls(payment_id, credit_note_id) = 1), CONSTRAINT ap_allocation_sign CHECK ((reverses_id IS NULL AND amount > 0 AND base_amount >= 0) OR (reverses_id IS NOT NULL AND amount < 0 AND base_amount <= 0)), CONSTRAINT ap_allocation_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES purchase_invoice (org_id, id), CONSTRAINT ap_allocation_payment_fk FOREIGN KEY (org_id, payment_id) REFERENCES supplier_payment (org_id, id), CONSTRAINT ap_allocation_credit_note_fk FOREIGN KEY (org_id, credit_note_id) REFERENCES purchase_invoice (org_id, id), CONSTRAINT ap_allocation_reverses_fk FOREIGN KEY (org_id, reverses_id) REFERENCES ap_allocation (org_id, id), CONSTRAINT ap_allocation_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id));-- D6: a row is reversed once.CREATE UNIQUE INDEX ap_allocation_reversed_once ON ap_allocation (org_id, reverses_id) WHERE reverses_id IS NOT NULL;CREATE INDEX ap_allocation_by_invoice ON ap_allocation (org_id, invoice_id);CREATE INDEX ap_allocation_by_credit_note ON ap_allocation (org_id, credit_note_id) WHERE credit_note_id IS NOT NULL;CREATE INDEX ap_allocation_by_payment ON ap_allocation (org_id, payment_id) WHERE payment_id IS NOT NULL;
ALTER TABLE supplier_payment ENABLE ROW LEVEL SECURITY;ALTER TABLE supplier_payment FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON supplier_payment USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE supplier_payment_line ENABLE ROW LEVEL SECURITY;ALTER TABLE supplier_payment_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON supplier_payment_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE ap_allocation ENABLE ROW LEVEL SECURITY;ALTER TABLE ap_allocation FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON ap_allocation USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON supplier_payment, supplier_payment_line FROM erp_app;REVOKE UPDATE, DELETE ON ap_allocation FROM erp_app;
-- +goose StatementBeginCREATE FUNCTION supplier_payment_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.payables_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'supplier payment % is not a draft', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'supplier_payment_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER supplier_payment_draft_guard BEFORE UPDATE OF supplier_party_id, bank_account_id, payment_date, bank_reference, currency, fx_rate, amount, base_amount ON supplier_payment FOR EACH ROW EXECUTE FUNCTION supplier_payment_draft_guard();
-- +goose StatementBeginCREATE FUNCTION supplier_payment_line_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (SELECT public.payables_semantic(p.org_id, p.instance_id) FROM public.supplier_payment p WHERE p.org_id = NEW.org_id AND p.id = NEW.payment_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'supplier payment % is not a draft: its allocations cannot change', NEW.payment_id USING ERRCODE = '23514', CONSTRAINT = 'supplier_payment_line_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER supplier_payment_line_draft_guard BEFORE INSERT OR UPDATE ON supplier_payment_line FOR EACH ROW EXECUTE FUNCTION supplier_payment_line_draft_guard();
-- THE ALLOCATION GUARD (D6): the backstop for everything allocation.go checks.-- It locks the invoice (and the credit note) row, so two allocations against-- one invoice serialize and outstanding can never go below zero.-- +goose StatementBeginCREATE FUNCTION ap_allocation_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_inv public.purchase_invoice%ROWTYPE; v_cn public.purchase_invoice%ROWTYPE; v_pay public.supplier_payment%ROWTYPE; v_orig public.ap_allocation%ROWTYPE; v_out NUMERIC; v_out_base NUMERIC; v_left NUMERIC;BEGIN SELECT * INTO v_inv FROM public.purchase_invoice WHERE org_id = NEW.org_id AND id = NEW.invoice_id FOR UPDATE; IF v_inv.kind <> 'purchase_invoice' THEN RAISE EXCEPTION 'allocation target % is not a purchase invoice', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_target_is_invoice'; END IF; IF NEW.currency <> v_inv.currency THEN RAISE EXCEPTION 'allocation currency % is not the invoice''s %', NEW.currency, v_inv.currency USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_currency_matches'; END IF;
IF NEW.reverses_id IS NOT NULL THEN SELECT * INTO v_orig FROM public.ap_allocation WHERE org_id = NEW.org_id AND id = NEW.reverses_id; IF v_orig.reverses_id IS NOT NULL OR v_orig.invoice_id <> NEW.invoice_id OR v_orig.payment_id IS DISTINCT FROM NEW.payment_id OR v_orig.credit_note_id IS DISTINCT FROM NEW.credit_note_id OR NEW.amount <> -v_orig.amount OR NEW.base_amount <> -v_orig.base_amount THEN RAISE EXCEPTION 'allocation % must mirror the allocation it reverses', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_reversal_mirrors'; END IF; RETURN NEW; END IF;
IF NOT (SELECT i.accrual_open FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = v_inv.instance_id) THEN RAISE EXCEPTION 'invoice % is not posted', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_invoice_posted'; END IF;
IF NEW.credit_note_id IS NOT NULL THEN SELECT * INTO v_cn FROM public.purchase_invoice WHERE org_id = NEW.org_id AND id = NEW.credit_note_id FOR UPDATE; IF v_cn.kind <> 'supplier_credit_note' OR NOT (SELECT i.accrual_open FROM public.workflow_instance i WHERE i.org_id = NEW.org_id AND i.id = v_cn.instance_id) THEN RAISE EXCEPTION 'credit note % is not a posted credit note', NEW.credit_note_id USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_credit_note_posted'; END IF; IF v_cn.supplier_party_id <> v_inv.supplier_party_id THEN RAISE EXCEPTION 'credit note and invoice belong to different suppliers' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_same_supplier'; END IF; IF v_cn.currency <> v_inv.currency THEN RAISE EXCEPTION 'credit note and invoice are in different currencies' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_currency_matches'; END IF; IF v_cn.fx_rate <> v_inv.fx_rate THEN -- Ruling P23 RAISE EXCEPTION 'credit note and invoice carry different rates' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_rate_matches'; END IF; SELECT v_cn.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount INTO v_left FROM public.ap_allocation a WHERE a.org_id = NEW.org_id AND a.credit_note_id = NEW.credit_note_id; IF v_left < 0 THEN RAISE EXCEPTION 'credit note % has less left than %', v_cn.reference, NEW.amount USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_credit_not_over'; END IF; ELSE SELECT * INTO v_pay FROM public.supplier_payment WHERE org_id = NEW.org_id AND id = NEW.payment_id; IF v_pay.supplier_party_id <> v_inv.supplier_party_id THEN RAISE EXCEPTION 'payment and invoice belong to different suppliers' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_same_supplier'; END IF; IF v_pay.currency <> v_inv.currency THEN RAISE EXCEPTION 'payment and invoice are in different currencies' USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_currency_matches'; END IF; END IF;
SELECT v_inv.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount, v_inv.gross_base - coalesce(sum(a.base_amount), 0) - NEW.base_amount INTO v_out, v_out_base FROM public.ap_allocation a WHERE a.org_id = NEW.org_id AND a.invoice_id = NEW.invoice_id; IF v_out < 0 THEN RAISE EXCEPTION 'invoice % would be over-allocated', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_not_over'; END IF; IF v_out = 0 AND v_out_base <> 0 THEN RAISE EXCEPTION 'an allocation that clears invoice % must clear its base', v_inv.reference USING ERRCODE = '23514', CONSTRAINT = 'ap_allocation_clears_base'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER ap_allocation_guard BEFORE INSERT ON ap_allocation FOR EACH ROW EXECUTE FUNCTION ap_allocation_guard();
-- +goose DownDROP TABLE ap_allocation;DROP TABLE supplier_payment_line;DROP TABLE supplier_payment;DROP FUNCTION ap_allocation_guard();DROP FUNCTION supplier_payment_line_draft_guard();DROP FUNCTION supplier_payment_draft_guard();sqlc.yaml: add - column: "supplier_payment.fx_rate" with go_type: github.com/mcom/erp/internal/kernel/money.Rate beside Task 4’s entry.
retention_test.go: amendable gains "supplier_payment", "supplier_payment_line", (comment: // AP (D4, D11): payments and their planned allocations; never deleted.); appendOnly gains, after "advance_settlement",:
// AP (D6): what was allocated to which invoice. A correction is a // reversing row (reverses_id), never an edit. "ap_allocation",-
Step 4: Run passing.
make generate && go test ./internal/platform/payables/... ./internal/kernel/db/... -count=1→ PASS. -
Step 5: Sabotage. Comment out the
v_left < 0block;TestTheAllocationGuard…fails atap_allocation_credit_not_overwith<nil>; restore. Remove theap_allocation_reversed_onceindex; the double reversal is accepted and the test fails; restore. -
Step 6: Commit.
git add db/migrations/00304_supplier_payment_allocation.sql sqlc.yaml internal/gen internal/kernel/db internal/platform/payables && git commit -m "AP Task 8: supplier payment and append-only allocation schema" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 9: Supplier payment service, payout rule with realised FX, void
Section titled “Task 9: Supplier payment service, payout rule with realised FX, void”Files:
- Create:
internal/platform/payables/payment.go - Create:
internal/platform/payables/allocation.go(the payment half:allocatePayment,reversePaymentAllocations,Allocation,Allocations) - Modify:
internal/platform/payables/transition.go(TransitionPayment) - Modify:
internal/platform/payables/posting.go(PaymentPayoutRule,Rules()) - Modify:
internal/platform/payables/payablestest/env.go(Payment,Pay) - Test:
internal/platform/payables/payment_test.go
Interfaces:
- Consumes: Task 6’s
lockInvoice,destination,TransitionInvoice; Task 3’slockSupplier;chart.GetAccount,chart.ResolveControl,chart.ListJournals,chart.JournalBank. - Produces:
type PaymentLine struct { InvoiceID uuid.UUID Amount money.Amount}type Payment struct { ID uuid.UUID Reference string SupplierID uuid.UUID BankAccountID uuid.UUID PaymentDate time.Time BankReference string Currency string FXRate money.Rate Amount, BaseAmount money.Amount InstanceID, PreparedBy uuid.UUID Revision int CreatedAt, UpdatedAt time.Time State string Semantic posting.Semantic Lines []PaymentLine}type PaymentInput struct { SupplierID, BankAccountID uuid.UUID PaymentDate time.Time BankReference string Currency string FXRate *money.Rate Amount money.Amount Lines []PaymentLine}type PaymentFilter struct{ SupplierID *uuid.UUID; State, Currency string; Limit int; After *Cursor }func CreatePayment(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in PaymentInput) (Payment, error)func GetPayment(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payment, error)func ListPayments(ctx context.Context, tx pgx.Tx, f PaymentFilter) ([]Payment, *Cursor, error)func UpdatePayment(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in PaymentInput) (Payment, error)func TransitionPayment(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Payment, workflow.Decision, error)type Allocation struct { ID, InvoiceID uuid.UUID SourceKind string // "payment" | "credit_note" PaymentID, CreditNoteID, ReversesID, ReversedByID *uuid.UUID Amount, BaseAmount money.Amount Currency string EffectiveOn time.Time CreatedBy uuid.UUID CreatedAt time.Time}func Allocations(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]Allocation, error)func InvoiceOutstanding(ctx context.Context, tx pgx.Tx, inv Invoice) (amount, base money.Amount, err error)type PaymentPayoutRule struct{}var ErrAllocationTotal, ErrBankUnverified, ErrBankAccount, ErrInvoiceNotPayable error// payablestestfunc (e *Env) Payment(t *testing.T, s payables.Supplier, amount string, rate *money.Rate, lines ...payables.PaymentLine) payables.Paymentfunc (e *Env) Pay(t *testing.T, p payables.Payment) payables.Payment // submit (Staff), approve (Owner)func (e *Env) SubmittedPayment(t *testing.T, p payables.Payment) payables.Paymentfunc (e *Env) ApprovePayment(t *testing.T, p payables.Payment) payables.Paymentfunc (e *Env) VoidPayment(t *testing.T, p payables.Payment) payables.Paymentfunc (e *Env) SetAndVerifyBank(t *testing.T, s payables.Supplier) payables.Supplierfunc (e *Env) ForeignInvoice(t *testing.T, s payables.Supplier, no, currency string, rate money.Rate, lines ...payables.LineInput) payables.Invoice- Step 1: Failing test.
internal/platform/payables/payment_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
func pl(inv payables.Invoice, amt string) payables.PaymentLine { return payables.PaymentLine{InvoiceID: inv.ID, Amount: money.MustParse(amt)}}
func TestAPaymentClearsItsInvoicesAtTheirRates(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") a := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-A", payablestest.Line(e.Account(t, "6140"), "100.00"))) b := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-B", payablestest.Line(e.Account(t, "6140"), "50.00"))) p := e.Pay(t, e.Payment(t, s, "130.00", nil, pl(a, "100.00"), pl(b, "30.00"))) require.Equal(t, "paid", p.State) entry := e.EntryFor(t, p.InstanceID, ledger.SemanticPayout) requireBalancedWithPartyOnControl(t, e, entry, s.PartyID) dr, _ := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "130.0000", dr.String()) _, cr := sumSide(entry, e.Account(t, "1520")) require.Equal(t, "130.0000", cr.String()) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { inv, _ := payables.GetInvoice(ctx, tx, b.ID) out, _, err := payables.InvoiceOutstanding(ctx, tx, inv) require.Equal(t, "20.0000", out.String()) return err }) require.Equal(t, "20.0000", e.ControlBalanceByParty(t)[s.PartyID].String())}
func TestAForeignPaymentPostsRealisedFX(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "USSUP") invRate, payRate := money.MustParseRate("4.40"), money.MustParseRate("4.50") inv := e.Post(t, e.ForeignInvoice(t, s, "U-1", "USD", invRate, payablestest.Line(e.Account(t, "6170"), "100.00"))) p := e.Pay(t, e.Payment(t, s, "100.00", &payRate, pl(inv, "100.00"))) entry := e.EntryFor(t, p.InstanceID, ledger.SemanticPayout) requireBalancedWithPartyOnControl(t, e, entry, s.PartyID) // S18: party_id on every ap_trade line, with FX dr, _ := sumSide(entry, e.Account(t, "2105")) require.Equal(t, "440.0000", dr.String(), "Dr Trade payables at the invoice's rate") _, cr := sumSide(entry, e.Account(t, "1520")) require.Equal(t, "450.0000", cr.String(), "Cr bank at the payment's rate") fxDr, _ := sumSide(entry, e.Account(t, "6900")) require.Equal(t, "10.0000", fxDr.String(), "D9: a 10.00 realised loss on fx_gain_loss") require.Equal(t, "0.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "the supplier is settled in base too")}
func TestPaymentApprovalNeedsVerifiedBankDetails(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) unverified := e.Supplier(t, "NOBANK") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, unverified, "INV-1", payablestest.Line(e.Account(t, "6140"), "10.00"))) p := e.SubmittedPayment(t, e.Payment(t, unverified, "10.00", nil, pl(inv, "10.00"))) err := e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionPayment(ctx, tx, e.Clock, payables.TransitionInput{ID: p.ID, From: "pending_approval", To: "paid", Actor: e.Owner}) return err }) require.ErrorIs(t, err, payables.ErrBankUnverified) e.SetAndVerifyBank(t, unverified) // Staff sets, Owner verifies paid := e.ApprovePayment(t, p) // PRESENCE: approved once verified require.Equal(t, "paid", paid.State)}
func TestAllocationsMustEqualThePaymentAndNotOverpay(t *testing.T) { e := payablestest.New(t) s := e.VerifiedSupplier(t, "BEKAL") e.UsePayablesPoster(t) inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "100.00"))) bank := e.Account(t, "1520") // outside the transaction below: e.Account opens its own create := func(amount string, lines ...payables.PaymentLine) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CreatePayment(ctx, tx, e.Clock, e.Staff, payables.PaymentInput{SupplierID: s.PartyID, BankAccountID: bank, PaymentDate: expensestest.Today, Currency: "MYR", Amount: money.MustParse(amount), Lines: lines}) return err }) } require.NoError(t, create("100.00", pl(inv, "100.00")), "PRESENCE") require.ErrorIs(t, create("90.00", pl(inv, "100.00")), payables.ErrAllocationTotal) var fe *payables.FieldError require.ErrorAs(t, create("150.00", pl(inv, "150.00")), &fe, "over-allocation refused at save") require.Equal(t, "over_allocated", fe.Rule)}
func TestVoidingAPaymentReversesItsAllocationsAndItsEntry(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "100.00"))) p := e.Pay(t, e.Payment(t, s, "100.00", nil, pl(inv, "100.00"))) require.Equal(t, "0.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "PRESENCE: settled") // The invoice cannot be voided while the payment stands (Ruling P24). err := e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: "approved", To: "voided", Actor: e.Owner, Reason: "wrong"}) return err }) require.ErrorIs(t, err, payables.ErrHasAllocations) e.VoidPayment(t, p) require.Equal(t, "100.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "owed again") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { cur, _ := payables.GetInvoice(ctx, tx, inv.ID) out, _, err := payables.InvoiceOutstanding(ctx, tx, cur) require.Equal(t, "100.0000", out.String()) return err }) e.Void(t, inv) // and now it can be voided}-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run 'Payment|ForeignPayment|Voiding' -count=1→ FAIL:undefined: payables.CreatePayment. -
Step 3: Implement.
internal/platform/payables/payment.go:
package payables
import ( "context" "errors" "fmt" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/numbering" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
var ( ErrAllocationTotal = errors.New("payables: the allocations must add up to the payment amount") ErrBankUnverified = errors.New("payables: the supplier's bank details are not verified") ErrBankAccount = errors.New("payables: pay from an active bank account") ErrInvoiceNotPayable = errors.New("payables: only this supplier's posted invoices in the payment's currency can be paid"))
// (types PaymentLine, Payment, PaymentInput, PaymentFilter exactly as in Interfaces)
const paymentSelect = ` SELECT p.id, p.reference, p.supplier_party_id, p.bank_account_id, p.payment_date, p.bank_reference, p.currency, p.fx_rate, p.amount, p.base_amount, p.instance_id, p.prepared_by, p.revision, p.created_at, p.updated_at, i.state_key, s.semantic FROM supplier_payment p JOIN workflow_instance i ON i.org_id = p.org_id AND i.id = p.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE p.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 `
func scanPayment(row pgx.Row) (Payment, error) { var p Payment err := row.Scan(&p.ID, &p.Reference, &p.SupplierID, &p.BankAccountID, &p.PaymentDate, &p.BankReference, &p.Currency, &p.FXRate, &p.Amount, &p.BaseAmount, &p.InstanceID, &p.PreparedBy, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &p.State, &p.Semantic) if errors.Is(err, pgx.ErrNoRows) { return Payment{}, ErrNotFound } p.PaymentDate = p.PaymentDate.UTC() return p, err}
func getPayment(ctx context.Context, tx pgx.Tx, id uuid.UUID, suffix string) (Payment, error) { org, err := orgOf(ctx) if err != nil { return Payment{}, err } p, err := scanPayment(tx.QueryRow(ctx, paymentSelect+`AND p.id = $2`+suffix, org, id)) if err != nil { return Payment{}, err } rows, err := tx.Query(ctx, `SELECT invoice_id, amount FROM supplier_payment_line WHERE org_id = $1 AND payment_id = $2 AND withdrawn_at IS NULL ORDER BY created_at, id`, org, id) if err != nil { return Payment{}, err } defer rows.Close() for rows.Next() { var l PaymentLine if err := rows.Scan(&l.InvoiceID, &l.Amount); err != nil { return Payment{}, err } p.Lines = append(p.Lines, l) } return p, rows.Err()}
func GetPayment(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payment, error) { return getPayment(ctx, tx, id, "") }
func lockPayment(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payment, error) { return getPayment(ctx, tx, id, " FOR UPDATE OF p")}
func ListPayments(ctx context.Context, tx pgx.Tx, f PaymentFilter) ([]Payment, *Cursor, error) { org, err := orgOf(ctx) if err != nil { return nil, nil, err } if f.Limit <= 0 || f.Limit > 200 { f.Limit = 50 } var afterAt *time.Time var afterID *uuid.UUID if f.After != nil { afterAt, afterID = &f.After.CreatedAt, &f.After.ID } rows, err := tx.Query(ctx, paymentSelect+` AND ($2::uuid IS NULL OR p.supplier_party_id = $2) AND ($3 = '' OR i.state_key = $3) AND ($4::timestamptz IS NULL OR (p.created_at, p.id) < ($4, $5::uuid)) AND ($7 = '' OR p.currency = $7) ORDER BY p.created_at DESC, p.id DESC LIMIT $6`, org, f.SupplierID, f.State, afterAt, afterID, f.Limit+1, f.Currency) if err != nil { return nil, nil, fmt.Errorf("payables: list payments: %w", err) } defer rows.Close() var out []Payment for rows.Next() { p, err := scanPayment(rows) if err != nil { return nil, nil, err } out = append(out, p) } if err := rows.Err(); err != nil { return nil, nil, err } var next *Cursor if len(out) > f.Limit { out = out[:f.Limit] next = &Cursor{CreatedAt: out[len(out)-1].CreatedAt, ID: out[len(out)-1].ID} } return out, next, nil}
// checkPayment validates in against the supplier, the bank account and each// invoice's current outstanding (soft: the guard re-checks at payout).func checkPayment(ctx context.Context, tx pgx.Tx, in *PaymentInput, base string) error { in.Currency = strings.ToUpper(strings.TrimSpace(in.Currency)) in.BankReference = strings.TrimSpace(in.BankReference) switch { case !currencyCode.MatchString(in.Currency): return fieldErr("currency", "currency_code", "a three-letter currency code, such as MYR") case in.Currency != base && in.FXRate == nil: return fieldErr("fx_rate", "fx_rate_required", "a foreign-currency payment needs its rate to "+base) case in.Currency == base && in.FXRate != nil && in.FXRate.Cmp(money.OneRate()) != 0: return fieldErr("fx_rate", "fx_rate_identity", "a payment in the base currency has a rate of 1") case !in.Amount.IsPositive(): return fieldErr("amount", "positive", "the payment amount must be positive") case len(in.BankReference) > 64 || recordtext.Check("bank_reference", in.BankReference) != nil: return fieldErr("bank_reference", "record_language", "a reference of up to 64 characters in English or Bahasa Malaysia") case len(in.Lines) == 0: return fieldErr("lines", "lines_count", "allocate the payment to at least one invoice") } if in.Currency == base { one := money.OneRate() in.FXRate = &one } sup, err := GetSupplier(ctx, tx, in.SupplierID) if err != nil || !sup.IsActive { return fieldErr("supplier_id", "supplier_active", "choose an active supplier") } a, err := chart.GetAccount(ctx, tx, in.BankAccountID) if err != nil || !a.IsActive || !a.IsPostable || a.ControlKind == nil || *a.ControlKind != "bank" || (a.Currency != nil && *a.Currency != in.Currency) { return ErrBankAccount } total, seen := money.Zero(), map[uuid.UUID]bool{} for i, l := range in.Lines { f := fmt.Sprintf("lines[%d].amount", i) if seen[l.InvoiceID] || !l.Amount.IsPositive() { return fieldErr(f, "positive_once", "each invoice once, with a positive amount") } seen[l.InvoiceID] = true inv, err := GetInvoice(ctx, tx, l.InvoiceID) if err != nil || inv.Kind != KindInvoice || inv.SupplierID != in.SupplierID || inv.Currency != in.Currency || inv.Semantic != posting.Accrual { return ErrInvoiceNotPayable } out, _, err := InvoiceOutstanding(ctx, tx, inv) if err != nil { return err } if l.Amount.Cmp(out) > 0 { return fieldErr(f, "over_allocated", fmt.Sprintf("%s has %s outstanding", inv.Reference, out)) } total = total.Add(l.Amount) } if total.Cmp(in.Amount) != 0 { return ErrAllocationTotal // D4: no unallocated prepayments in v1 } return nil}
func writePaymentLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, now time.Time, lines []PaymentLine) error { if _, err := tx.Exec(ctx, `UPDATE supplier_payment_line SET withdrawn_at = $3 WHERE org_id = $1 AND payment_id = $2 AND withdrawn_at IS NULL`, org, id, now); err != nil { return err } for _, l := range lines { if _, err := tx.Exec(ctx, `INSERT INTO supplier_payment_line (org_id, payment_id, invoice_id, amount, created_at) VALUES ($1,$2,$3,$4,$5)`, org, id, l.InvoiceID, l.Amount, now); err != nil { return fmt.Errorf("payables: payment line: %w", err) } } return nil}
func CreatePayment(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in PaymentInput) (Payment, error) { org, err := orgOf(ctx) if err != nil { return Payment{}, err } profile, err := company.Get(ctx, tx) if err != nil { return Payment{}, err } if err := checkPayment(ctx, tx, &in, profile.BaseCurrency); err != nil { return Payment{}, err } year, err := fiscal.YearContaining(ctx, tx, clock.TodayUTC(c)) if err != nil { return Payment{}, err } n, err := numbering.Allocate(ctx, tx, KindPayment, year.EndYear) if err != nil { return Payment{}, err } id, now := uuid.New(), c.Now() baseAmt := in.Amount.MulRate(*in.FXRate) inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: KindPayment, SourceID: id, Reporter: actor, Amount: baseAmt}) if err != nil { return Payment{}, err } if _, err := tx.Exec(ctx, ` INSERT INTO supplier_payment (id, org_id, reference, supplier_party_id, bank_account_id, payment_date, bank_reference, currency, fx_rate, amount, base_amount, instance_id, prepared_by, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14)`, id, org, numbering.Format("SP", year.EndYear, n), in.SupplierID, in.BankAccountID, in.PaymentDate, in.BankReference, in.Currency, *in.FXRate, in.Amount, baseAmt, inst.ID, actor, now); err != nil { return Payment{}, fmt.Errorf("payables: create payment: %w", err) } if err := writePaymentLines(ctx, tx, org, id, now, in.Lines); err != nil { return Payment{}, err } p, err := GetPayment(ctx, tx, id) if err != nil { return Payment{}, err } return p, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPayment, EntityID: id, Action: "create", After: p})}
func UpdatePayment(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in PaymentInput) (Payment, error) { org, err := orgOf(ctx) if err != nil { return Payment{}, err } before, err := lockPayment(ctx, tx, id) if err != nil { return Payment{}, err } if before.Revision != expectedRevision { return Payment{}, &RevisionMismatchError{Current: before.Revision} } if before.Semantic != posting.Draft { return Payment{}, ErrNotDraft } profile, err := company.Get(ctx, tx) if err != nil { return Payment{}, err } if err := checkPayment(ctx, tx, &in, profile.BaseCurrency); err != nil { return Payment{}, err } now, baseAmt := c.Now(), in.Amount.MulRate(*in.FXRate) if _, err := tx.Exec(ctx, ` UPDATE supplier_payment SET supplier_party_id = $3, bank_account_id = $4, payment_date = $5, bank_reference = $6, currency = $7, fx_rate = $8, amount = $9, base_amount = $10, revision = revision + 1, updated_at = $11 WHERE org_id = $1 AND id = $2`, org, id, in.SupplierID, in.BankAccountID, in.PaymentDate, in.BankReference, in.Currency, *in.FXRate, in.Amount, baseAmt, now); err != nil { return Payment{}, fmt.Errorf("payables: update payment: %w", err) } if err := writePaymentLines(ctx, tx, org, id, now, in.Lines); err != nil { return Payment{}, err } if err := workflow.SetAmount(ctx, tx, c, before.InstanceID, baseAmt); err != nil { return Payment{}, err } after, err := GetPayment(ctx, tx, id) if err != nil { return Payment{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPayment, EntityID: id, Action: "update", Before: before, After: after})}internal/platform/payables/allocation.go (payment half):
package payables
import ( "context" "errors" "fmt" "sort" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money")
// (type Allocation exactly as in Interfaces)
const allocationSelect = ` SELECT a.id, a.invoice_id, a.source_kind, a.payment_id, a.credit_note_id, a.reverses_id, (SELECT r.id FROM ap_allocation r WHERE r.org_id = $1 AND r.reverses_id = a.id), a.amount, a.base_amount, a.currency, a.effective_on, a.created_by, a.created_at FROM ap_allocation a WHERE a.org_id = $1 `
func scanAllocation(row pgx.Row) (Allocation, error) { var a Allocation err := row.Scan(&a.ID, &a.InvoiceID, &a.SourceKind, &a.PaymentID, &a.CreditNoteID, &a.ReversesID, &a.ReversedByID, &a.Amount, &a.BaseAmount, &a.Currency, &a.EffectiveOn, &a.CreatedBy, &a.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return Allocation{}, ErrNotFound } a.EffectiveOn = a.EffectiveOn.UTC() return a, err}
// Allocations is every allocation row touching documentID (an invoice, a// credit note or a payment), oldest first.func Allocations(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]Allocation, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, allocationSelect+` AND (a.invoice_id = $2 OR a.credit_note_id = $2 OR a.payment_id = $2) ORDER BY a.created_at, a.id`, org, documentID) if err != nil { return nil, fmt.Errorf("payables: allocations: %w", err) } defer rows.Close() var out []Allocation for rows.Next() { a, err := scanAllocation(rows) if err != nil { return nil, err } out = append(out, a) } return out, rows.Err()}
// InvoiceOutstanding is gross − Σ allocations of an invoice, or of a credit// note (its unallocated remainder), in document currency and in base.func InvoiceOutstanding(ctx context.Context, tx pgx.Tx, inv Invoice) (money.Amount, money.Amount, error) { org, err := orgOf(ctx) if err != nil { return money.Zero(), money.Zero(), err } col := "invoice_id" if inv.Kind == KindCreditNote { col = "credit_note_id" } var amt, base money.Amount err = tx.QueryRow(ctx, `SELECT coalesce(sum(amount), 0), coalesce(sum(base_amount), 0) FROM ap_allocation WHERE org_id = $1 AND `+col+` = $2`, org, inv.ID).Scan(&amt, &base) return inv.GrossAmount.Sub(amt), inv.GrossBase.Sub(base), err}
// allocationBase is Ruling P21: at the invoice's rate, or the invoice's// remaining base when the row clears it.func allocationBase(inv Invoice, amount, outAmt, outBase money.Amount) money.Amount { if amount.Cmp(outAmt) == 0 { return outBase } return amount.MulRate(inv.FXRate)}
func insertAllocation(ctx context.Context, tx pgx.Tx, org uuid.UUID, a Allocation) error { _, err := tx.Exec(ctx, ` INSERT INTO ap_allocation (org_id, invoice_id, payment_id, credit_note_id, amount, base_amount, currency, effective_on, reverses_id, created_by, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, org, a.InvoiceID, a.PaymentID, a.CreditNoteID, a.Amount, a.BaseAmount, a.Currency, a.EffectiveOn, a.ReversesID, a.CreatedBy, a.CreatedAt) return err}
// allocatePayment turns the payment's planned lines into effective rows// (Ruling P20), locking each invoice in id order first.func allocatePayment(ctx context.Context, tx pgx.Tx, c clock.Clock, p Payment, actor uuid.UUID) error { org, err := orgOf(ctx) if err != nil { return err } lines := append([]PaymentLine(nil), p.Lines...) sort.Slice(lines, func(i, j int) bool { return lines[i].InvoiceID.String() < lines[j].InvoiceID.String() }) total := money.Zero() for _, l := range lines { inv, err := lockInvoice(ctx, tx, l.InvoiceID) if err != nil { return err } outAmt, outBase, err := InvoiceOutstanding(ctx, tx, inv) if err != nil { return err } if l.Amount.Cmp(outAmt) > 0 { return fieldErr("lines", "over_allocated", fmt.Sprintf("%s has %s outstanding", inv.Reference, outAmt)) } pid := p.ID if err := insertAllocation(ctx, tx, org, Allocation{InvoiceID: inv.ID, PaymentID: &pid, Amount: l.Amount, BaseAmount: allocationBase(inv, l.Amount, outAmt, outBase), Currency: p.Currency, EffectiveOn: clock.TodayUTC(c), CreatedBy: actor, CreatedAt: c.Now()}); err != nil { return err } total = total.Add(l.Amount) } if total.Cmp(p.Amount) != 0 { return ErrAllocationTotal } return nil}
// reversePaymentAllocations mirrors every unreversed row of the payment.func reversePaymentAllocations(ctx context.Context, tx pgx.Tx, c clock.Clock, p Payment, actor uuid.UUID) error { org, err := orgOf(ctx) if err != nil { return err } rows, err := Allocations(ctx, tx, p.ID) if err != nil { return err } for _, a := range rows { if a.PaymentID == nil || *a.PaymentID != p.ID || a.ReversesID != nil || a.ReversedByID != nil { continue } orig := a.ID if err := insertAllocation(ctx, tx, org, Allocation{InvoiceID: a.InvoiceID, PaymentID: a.PaymentID, Amount: a.Amount.Neg(), BaseAmount: a.BaseAmount.Neg(), Currency: a.Currency, EffectiveOn: clock.TodayUTC(c), ReversesID: &orig, CreatedBy: actor, CreatedAt: c.Now()}); err != nil { return err } } return nil}(Allocation is declared at the top of this file, so time is used by its fields.)
transition.go — in TransitionInvoice, replace the comment // Task 9 inserts the has-allocations refusal … with (Ruling P24):
if to.Semantic == posting.Cancelled || to.Semantic == posting.Rejected { amt, _, err := InvoiceOutstanding(ctx, tx, inv) if err != nil { return Invoice{}, workflow.Decision{}, err } if amt.Cmp(inv.GrossAmount) != 0 { return Invoice{}, workflow.Decision{}, ErrHasAllocations } }and add:
// TransitionPayment moves a payment. ORDER: payment FOR UPDATE → supplier// FOR SHARE (bank check) → allocations (invoice locks, id order) →// workflow.Transition (posts the payout or its reversal) → audit, last.func TransitionPayment(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Payment, workflow.Decision, error) { p, err := lockPayment(ctx, tx, in.ID) if err != nil { return Payment{}, workflow.Decision{}, err } from, to, err := destination(ctx, tx, p.InstanceID, in.From, in.To) if err != nil { return Payment{}, workflow.Decision{}, err } switch { case to.Semantic == posting.Payout: sup, err := lockSupplier(ctx, tx, p.SupplierID, "SHARE") if err != nil { return Payment{}, workflow.Decision{}, err } if !sup.BankVerified() { return Payment{}, workflow.Decision{}, ErrBankUnverified // Ruling P27 } if err := allocatePayment(ctx, tx, c, p, in.Actor); err != nil { return Payment{}, workflow.Decision{}, err } case from.Semantic == posting.Payout && (to.Semantic == posting.Cancelled || to.Semantic == posting.Rejected): if err := reversePaymentAllocations(ctx, tx, c, p, in.Actor); err != nil { return Payment{}, workflow.Decision{}, 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 Payment{}, workflow.Decision{}, err } after, err := GetPayment(ctx, tx, p.ID) if err != nil { return Payment{}, workflow.Decision{}, err } return after, d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPayment, EntityID: p.ID, Action: "transition", Before: map[string]string{"state": p.State}, After: map[string]string{"state": after.State}})}Note the self-approval check happens inside workflow.Transition, after allocatePayment wrote rows — a refused approval rolls the whole transaction back, so no row survives. The ErrBankUnverified check runs before the engine is asked, so an unverified supplier is refused even for a caller the grants would refuse anyway (a 409 rather than a 403; both leave nothing behind).
posting.go — Rules() becomes:
func Rules() []accounting.PostingRule { return []accounting.PostingRule{AccrualRule{KindName: KindInvoice}, AccrualRule{KindName: KindCreditNote}, PaymentPayoutRule{}}}and add:
// PaymentPayoutRule (D4, D9): Dr Trade payables per allocation at the// invoice's frozen base, Cr the bank account at the payment's frozen base,// in the payment's currency. When those bases differ, ledger.Post adds the// fx_difference line on fx_gain_loss itself: that line IS the realised FX// of D9 (Σ allocated × invoice rate − amount × payment rate).type PaymentPayoutRule struct{}
var _ accounting.PostingRule = PaymentPayoutRule{}
func (PaymentPayoutRule) Kind() string { return KindPayment }func (PaymentPayoutRule) Semantic() posting.Semantic { return posting.Payout }
func (PaymentPayoutRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { p, err := GetPayment(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, fmt.Errorf("payables: payout: %w", err) } if p.InstanceID != src.Event.InstanceID { return accounting.Draft{}, ErrInstanceMismatch } // ponytail: resolved fresh, not read off each invoice's accrual; an org // that re-points ap_trade between accrual and payment needs a journal to // move the balance, like any control re-pointing. control, err := chart.ResolveControl(ctx, tx, ControlKind) if err != nil { return accounting.Draft{}, fmt.Errorf("payables: payout: %w", err) } allocs, err := Allocations(ctx, tx, p.ID) if err != nil { return accounting.Draft{}, err } party := p.SupplierID var out []ledger.Line total := money.Zero() for _, a := range allocs { if a.PaymentID == nil || *a.PaymentID != p.ID || a.ReversesID != nil || a.ReversedByID != nil { continue } base := a.BaseAmount out = append(out, ledger.Line{AccountID: control.ID, Debit: a.Amount, Base: &base, PartyID: &party, Memo: p.Reference}) total = total.Add(a.Amount) } if len(out) == 0 || total.Cmp(p.Amount) != 0 { return accounting.Draft{}, fmt.Errorf("%w: allocations %s, payment %s", ErrAllocationTotal, total, p.Amount) } bankBase := p.BaseAmount out = append(out, ledger.Line{AccountID: p.BankAccountID, Credit: p.Amount, Base: &bankBase, PartyID: &party, Memo: p.Reference + " " + p.BankReference}) jid, jtype, err := journalFor(ctx, tx, p.BankAccountID) if err != nil { return accounting.Draft{}, err } return accounting.Draft{JournalID: jid, JournalType: jtype, Currency: p.Currency, Description: "Supplier payment " + p.Reference, Lines: out}, nil}
// journalFor is advances.journalFor, copied (Ruling P25).func journalFor(ctx context.Context, tx pgx.Tx, accountID uuid.UUID) (*uuid.UUID, string, error) { journals, err := chart.ListJournals(ctx, tx) if err != nil { return nil, "", fmt.Errorf("payables: journal for: %w", err) } for _, j := range journals { if j.IsActive && j.DefaultAccountID != nil && *j.DefaultAccountID == accountID { id := j.ID return &id, "", nil } } return nil, chart.JournalBank, nil}(Add "github.com/google/uuid" to posting.go’s imports.)
payablestest/env.go additions:
func (e *Env) Payment(t *testing.T, s payables.Supplier, amount string, rate *money.Rate, lines ...payables.PaymentLine) payables.Payment { t.Helper() cur := s.Currency if len(lines) > 0 { e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { inv, err := payables.GetInvoice(ctx, tx, lines[0].InvoiceID) cur = inv.Currency return err }) } var p payables.Payment bank := e.Account(t, "1520") // hoisted: e.Account opens its own transaction require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error p, err = payables.CreatePayment(ctx, tx, e.Clock, e.Staff, payables.PaymentInput{SupplierID: s.PartyID, BankAccountID: bank, PaymentDate: expensestest.Today, BankReference: "DuitNow 12345", Currency: cur, FXRate: rate, Amount: money.MustParse(amount), Lines: lines}) return err })) return p}
func (e *Env) movePayment(t *testing.T, p payables.Payment, by uuid.UUID, from, to string) payables.Payment { t.Helper() require.NoError(t, e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error p, _, err = payables.TransitionPayment(ctx, tx, e.Clock, payables.TransitionInput{ID: p.ID, From: from, To: to, Actor: by, Reason: "test"}) return err })) return p}
func (e *Env) SubmittedPayment(t *testing.T, p payables.Payment) payables.Payment { return e.movePayment(t, p, e.Staff, "draft", "pending_approval")}func (e *Env) ApprovePayment(t *testing.T, p payables.Payment) payables.Payment { return e.movePayment(t, p, e.Owner, "pending_approval", "paid")}func (e *Env) Pay(t *testing.T, p payables.Payment) payables.Payment { return e.ApprovePayment(t, e.SubmittedPayment(t, p))}func (e *Env) VoidPayment(t *testing.T, p payables.Payment) payables.Payment { return e.movePayment(t, p, e.Owner, "paid", "voided")}
// SetAndVerifyBank: Staff sets the details, Owner verifies them.func (e *Env) SetAndVerifyBank(t *testing.T, s payables.Supplier) payables.Supplier { t.Helper() require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { cur, err := payables.GetSupplier(ctx, tx, s.PartyID) if err != nil { return err } s, err = payables.SetBank(ctx, tx, e.Clock, s.PartyID, e.Staff, cur.Revision, payables.Bank{EntryID: e.Bank(t), AccountNo: "5140123456", AccountName: s.LegalName}) return err })) require.NoError(t, e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { var err error s, err = payables.VerifyBank(ctx, tx, e.Clock, s.PartyID, e.Owner, s.Revision) return err })) return s}
// ForeignInvoice files a draft in currency at rate, foreign_supplier basis.func (e *Env) ForeignInvoice(t *testing.T, s payables.Supplier, no, currency string, rate money.Rate, lines ...payables.LineInput) payables.Invoice { t.Helper() doc, basis := e.Doc(t), "foreign_supplier" var inv payables.Invoice require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, e.Staff, payables.InvoiceInput{Kind: payables.KindInvoice, SupplierID: s.PartyID, SupplierInvoiceNo: no, InvoiceDate: expensestest.Today, Currency: currency, FXRate: &rate, DocumentID: &doc, DocumentBasis: &basis, Lines: lines}) return err })) return inv}Rewrite VerifiedSupplier as return e.SetAndVerifyBank(t, e.Supplier(t, code)).
-
Step 4: Run passing.
go test ./internal/platform/payables/... -count=1→ PASS. -
Step 5: Sabotage. (a) Delete the
!sup.BankVerified()check;TestPaymentApprovalNeeds…failsAn error is expected but got nil; restore. (b) Set the bank line’sBaseto&baseof the allocations’ sum instead ofp.BaseAmount;TestAForeignPaymentPostsRealisedFXfailsD9: a 10.00 realised loss(no FX line); restore. (c) SkipreversePaymentAllocations;TestVoiding…fails at100.0000outstanding (reads0.0000); restore. -
Step 6: Commit.
git add internal/platform/payables && git commit -m "AP Task 9: supplier payments, allocation at payout, realised FX, void" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 10: Credit-note allocation and reversal
Section titled “Task 10: Credit-note allocation and reversal”Files:
- Modify:
internal/platform/payables/allocation.go(AllocateCreditNote,ReverseAllocation) - Modify:
internal/platform/payables/payablestest/env.go(ForeignCreditNote,Offset,ReverseOffset) - Test:
internal/platform/payables/allocation_test.go
Interfaces:
- Consumes: Task 9’s
Allocation,insertAllocation,allocationBase,scanAllocation; Task 6’slockInvoice,InvoiceOutstanding;audit.Write. - Produces:
func AllocateCreditNote(ctx context.Context, tx pgx.Tx, c clock.Clock, creditNoteID, invoiceID, actor uuid.UUID, amount money.Amount) (Allocation, error)func ReverseAllocation(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Allocation, error)var ErrOverAllocation, ErrAlreadyReversed, ErrReversePaymentAllocation, ErrRateMismatch, ErrNotAllocatable errorfunc (e *Env) Offset(t *testing.T, cn, inv payables.Invoice, amount string) payables.Allocationfunc (e *Env) ReverseOffset(t *testing.T, a payables.Allocation)func (e *Env) ForeignCreditNote(t *testing.T, s payables.Supplier, no, currency string, rate money.Rate, lines ...payables.LineInput) payables.Invoice- Step 1: Failing test.
internal/platform/payables/allocation_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
func outstanding(t *testing.T, e *payablestest.Env, id uuid.UUID) string { t.Helper() var s string e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { inv, err := payables.GetInvoice(ctx, tx, id) if err != nil { return err } out, _, err := payables.InvoiceOutstanding(ctx, tx, inv) s = out.String() return err }) return s}
func allocate(e *payablestest.Env, cn, inv payables.Invoice, amount string) (payables.Allocation, error) { var a payables.Allocation err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error a, err = payables.AllocateCreditNote(ctx, tx, e.Clock, cn.ID, inv.ID, e.Staff, money.MustParse(amount)) return err }) return a, err}
func reverse(e *payablestest.Env, id uuid.UUID) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.ReverseAllocation(ctx, tx, e.Clock, id, e.Staff) return err })}
func TestOverAllocationIsRefusedAndReversalRestoresOutstanding(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.Supplier(t, "BEKAL") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "100.00"))) cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, s, "CN-1", payablestest.Line(e.Account(t, "6140"), "150.00"))) a, err := allocate(e, cn, inv, "60.00") require.NoError(t, err, "PRESENCE") require.Equal(t, "40.0000", outstanding(t, e, inv.ID)) _, err = allocate(e, cn, inv, "50.00") require.ErrorIs(t, err, payables.ErrOverAllocation, "only 40.00 is outstanding") require.NoError(t, reverse(e, a.ID)) require.Equal(t, "100.0000", outstanding(t, e, inv.ID), "reversal restores it") require.ErrorIs(t, reverse(e, a.ID), payables.ErrAlreadyReversed, "double reversal refused") require.Equal(t, "-50.0000", e.ControlBalanceByParty(t)[s.PartyID].String(), "offsets post nothing: 100 owed − 150 credited")}
func TestPaymentAllocationsAreUndoneOnlyByVoidingThePayment(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "10.00"))) p := e.Pay(t, e.Payment(t, s, "10.00", nil, payables.PaymentLine{InvoiceID: inv.ID, Amount: money.MustParse("10.00")})) var rows []payables.Allocation e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = payables.Allocations(ctx, tx, p.ID) return err }) require.Len(t, rows, 1, "PRESENCE: the payment's row exists") require.ErrorIs(t, reverse(e, rows[0].ID), payables.ErrReversePaymentAllocation)}
func TestACreditNoteOffsetsOnlyTheSameSupplierCurrencyAndRate(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s, other := e.Supplier(t, "BEKAL"), e.Supplier(t, "LAIN") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "100.00"))) cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, s, "CN-1", payablestest.Line(e.Account(t, "6140"), "10.00"))) foreign := e.Post(t, e.Invoice(t, payables.KindCreditNote, other, "CN-X", payablestest.Line(e.Account(t, "6140"), "10.00"))) _, err := allocate(e, cn, inv, "10.00") require.NoError(t, err, "PRESENCE") _, err = allocate(e, foreign, inv, "10.00") require.ErrorIs(t, err, payables.ErrNotAllocatable) usd := e.VerifiedSupplier(t, "USSUP") a := e.Post(t, e.ForeignInvoice(t, usd, "U-1", "USD", money.MustParseRate("4.40"), payablestest.Line(e.Account(t, "6170"), "100.00"))) c := e.Post(t, e.ForeignCreditNote(t, usd, "UC-1", "USD", money.MustParseRate("4.50"), payablestest.Line(e.Account(t, "6170"), "10.00"))) _, err = allocate(e, c, a, "10.00") require.ErrorIs(t, err, payables.ErrRateMismatch, "Ruling P23")}(Import github.com/google/uuid. ForeignCreditNote is ForeignInvoice with Kind: payables.KindCreditNote; add it to payablestest by giving ForeignInvoice an unexported foreign(kind, …) body both call.) payablestest also gains the two offset helpers Task 11 uses — defined here, once:
func (e *Env) Offset(t *testing.T, cn, inv payables.Invoice, amount string) payables.Allocation { t.Helper() var a payables.Allocation require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error a, err = payables.AllocateCreditNote(ctx, tx, e.Clock, cn.ID, inv.ID, e.Staff, money.MustParse(amount)) return err })) return a}
func (e *Env) ReverseOffset(t *testing.T, a payables.Allocation) { t.Helper() require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.ReverseAllocation(ctx, tx, e.Clock, a.ID, e.Staff) return err }))}-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run 'OverAllocation|UndoneOnly|CreditNoteOffsets' -count=1→ FAIL:undefined: payables.AllocateCreditNote. -
Step 3: Implement (append to
allocation.go):
var ( ErrOverAllocation = errors.New("payables: more than the invoice has outstanding, or than the credit note has left") ErrAlreadyReversed = errors.New("payables: this allocation has already been reversed") ErrReversePaymentAllocation = errors.New("payables: a payment's allocation is undone by voiding the payment") ErrRateMismatch = errors.New("payables: a credit note offsets only an invoice at the same rate") ErrNotAllocatable = errors.New("payables: a posted credit note offsets a posted invoice of the same supplier and currency"))
// AllocateCreditNote offsets part of a posted credit note against a posted// invoice (D5, D6). Posts nothing: both sides are the supplier's, on Trade// payables. Lock order: credit note, then invoice (both FOR UPDATE).func AllocateCreditNote(ctx context.Context, tx pgx.Tx, c clock.Clock, creditNoteID, invoiceID, actor uuid.UUID, amount money.Amount) (Allocation, error) { org, err := orgOf(ctx) if err != nil { return Allocation{}, err } if !amount.IsPositive() { return Allocation{}, fieldErr("amount", "positive", "the amount must be positive") } cn, err := lockInvoice(ctx, tx, creditNoteID) if err != nil { return Allocation{}, err } inv, err := lockInvoice(ctx, tx, invoiceID) if err != nil { return Allocation{}, err } switch { case cn.Kind != KindCreditNote || inv.Kind != KindInvoice || cn.Semantic != posting.Accrual || inv.Semantic != posting.Accrual, cn.SupplierID != inv.SupplierID, cn.Currency != inv.Currency: return Allocation{}, ErrNotAllocatable case cn.FXRate.Cmp(inv.FXRate) != 0: return Allocation{}, ErrRateMismatch } outAmt, outBase, err := InvoiceOutstanding(ctx, tx, inv) if err != nil { return Allocation{}, err } leftAmt, leftBase, err := InvoiceOutstanding(ctx, tx, cn) if err != nil { return Allocation{}, err } if amount.Cmp(outAmt) > 0 || amount.Cmp(leftAmt) > 0 { return Allocation{}, ErrOverAllocation } base := allocationBase(inv, amount, outAmt, outBase) if amount.Cmp(leftAmt) == 0 && amount.Cmp(outAmt) != 0 { base = leftBase // clears the credit note exactly } cid := cn.ID a := Allocation{InvoiceID: inv.ID, CreditNoteID: &cid, Amount: amount, BaseAmount: base, Currency: inv.Currency, EffectiveOn: clock.TodayUTC(c), CreatedBy: actor, CreatedAt: c.Now()} if err := tx.QueryRow(ctx, ` INSERT INTO ap_allocation (org_id, invoice_id, credit_note_id, amount, base_amount, currency, effective_on, created_by, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, org, a.InvoiceID, a.CreditNoteID, a.Amount, a.BaseAmount, a.Currency, a.EffectiveOn, a.CreatedBy, a.CreatedAt).Scan(&a.ID); err != nil { return Allocation{}, err } a.SourceKind = "credit_note" return a, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityAllocation, EntityID: a.ID, Action: "create", After: a})}
// ReverseAllocation undoes a credit-note allocation with a mirroring row// (D6; Ruling P22). Lock order: the credit note, then the invoice.func ReverseAllocation(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Allocation, error) { org, err := orgOf(ctx) if err != nil { return Allocation{}, err } a, err := scanAllocation(tx.QueryRow(ctx, allocationSelect+`AND a.id = $2`, org, id)) if err != nil { return Allocation{}, err } switch { case a.PaymentID != nil: return Allocation{}, ErrReversePaymentAllocation case a.ReversesID != nil || a.ReversedByID != nil: return Allocation{}, ErrAlreadyReversed } if _, err := lockInvoice(ctx, tx, *a.CreditNoteID); err != nil { return Allocation{}, err } if _, err := lockInvoice(ctx, tx, a.InvoiceID); err != nil { return Allocation{}, err } r := Allocation{InvoiceID: a.InvoiceID, CreditNoteID: a.CreditNoteID, Amount: a.Amount.Neg(), BaseAmount: a.BaseAmount.Neg(), Currency: a.Currency, EffectiveOn: clock.TodayUTC(c), ReversesID: &a.ID, CreatedBy: actor, CreatedAt: c.Now()} if err := tx.QueryRow(ctx, ` INSERT INTO ap_allocation (org_id, invoice_id, credit_note_id, amount, base_amount, currency, effective_on, reverses_id, created_by, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`, org, r.InvoiceID, r.CreditNoteID, r.Amount, r.BaseAmount, r.Currency, r.EffectiveOn, r.ReversesID, r.CreatedBy, r.CreatedAt).Scan(&r.ID); err != nil { if isConstraint(err, "ap_allocation_reversed_once") { return Allocation{}, ErrAlreadyReversed // a concurrent reversal won } return Allocation{}, err } r.SourceKind = "credit_note" return r, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityAllocation, EntityID: r.ID, Action: "reverse", Before: a, After: r})}
func isConstraint(err error, name string) bool { var pg *pgconn.PgError return errors.As(err, &pg) && pg.ConstraintName == name}(Imports: audit, posting, pgconn.)
-
Step 4: Run passing.
go test ./internal/platform/payables/... -count=1→ PASS. -
Step 5: Sabotage. Remove the
a.ReversesID != nil || a.ReversedByID != nilcase; the second reversal now reaches the index and still returnsErrAlreadyReversedviaisConstraint— delete that mapping too and seeTestOverAllocation…fail with a raw 23505; restore both. -
Step 6: Commit.
git add internal/platform/payables && git commit -m "AP Task 10: credit-note allocation and reversal" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 11: AP aging, supplier balance, sub-ledger = control drift test
Section titled “Task 11: AP aging, supplier balance, sub-ledger = control drift test”Files:
- Create:
internal/platform/payables/aging.go - Modify:
internal/platform/payables/payablestest/env.go(AdvanceClock) - Test:
internal/platform/payables/aging_test.go - Test:
internal/platform/payables/subledger_test.go
Interfaces:
- Consumes:
journal_entry(accrual entries ofsource_kindinKindInvoice/KindCreditNote, reversal viareversal_of_id),ap_allocation.effective_on,company.Get. - Produces:
const ( BucketCurrent = "current"; Bucket1To30 = "days_1_30"; Bucket31To60 = "days_31_60" Bucket61To90 = "days_61_90"; BucketOver90 = "over_90")var Buckets = []string{BucketCurrent, Bucket1To30, Bucket31To60, Bucket61To90, BucketOver90}func BucketFor(daysPastDue int) stringtype BucketAmounts struct { Current money.Amount `json:"current"` D1To30 money.Amount `json:"days_1_30"` D31To60 money.Amount `json:"days_31_60"` D61To90 money.Amount `json:"days_61_90"` Over90 money.Amount `json:"over_90"` Total money.Amount `json:"total"`}func (b *BucketAmounts) Add(bucket string, a money.Amount)type AgingRow struct { DocumentID uuid.UUID; Kind, Reference, SupplierDocumentNo string SupplierID uuid.UUID; SupplierCode, SupplierName string Currency string; DocumentDate, DueOn time.Time; DaysPastDue int; Bucket string Outstanding, OutstandingBase money.Amount}type AgingSupplier struct{ SupplierID uuid.UUID; Code, Name string; Buckets BucketAmounts }type Aging struct { AsOf time.Time; Currency string Rows []AgingRow; Suppliers []AgingSupplier; Totals BucketAmounts}func AgingAsOf(ctx context.Context, tx pgx.Tx, asOf time.Time, supplierID *uuid.UUID) (Aging, error)- Step 1: Failing tests.
internal/platform/payables/aging_test.go:
package payables_test
import ( "context" "testing" "time"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
func TestBucketBoundaries(t *testing.T) { for days, want := range map[int]string{ -5: payables.BucketCurrent, 0: payables.BucketCurrent, 1: payables.Bucket1To30, 30: payables.Bucket1To30, 31: payables.Bucket31To60, 60: payables.Bucket31To60, 61: payables.Bucket61To90, 90: payables.Bucket61To90, 91: payables.BucketOver90, 400: payables.BucketOver90, } { require.Equal(t, want, payables.BucketFor(days), "day %d", days) }}
func aging(t *testing.T, e *payablestest.Env, asOf time.Time) payables.Aging { t.Helper() var a payables.Aging e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error a, err = payables.AgingAsOf(ctx, tx, asOf, nil) return err }) return a}
func TestAgingPlacesInvoicesByDaysPastDueAndHonoursAsOf(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") due := expensestest.Today.AddDate(0, 0, 30) // Invoice() uses 30-day terms inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "100.00")))
for _, tc := range []struct { asOf time.Time bucket string }{ {due, payables.BucketCurrent}, // day 0 {due.AddDate(0, 0, 1), payables.Bucket1To30}, // day 1 {due.AddDate(0, 0, 30), payables.Bucket1To30}, // day 30 {due.AddDate(0, 0, 31), payables.Bucket31To60}, // day 31 {due.AddDate(0, 0, 90), payables.Bucket61To90}, // day 90 {due.AddDate(0, 0, 91), payables.BucketOver90}, // day 91 } { a := aging(t, e, tc.asOf) require.Len(t, a.Rows, 1, "as of %s", tc.asOf.Format("2006-01-02")) require.Equal(t, tc.bucket, a.Rows[0].Bucket, "as of %s", tc.asOf.Format("2006-01-02")) require.Equal(t, "100.0000", a.Totals.Total.String()) }
// A past as-of date, before the invoice posted, shows nothing (presence above). require.Empty(t, aging(t, e, expensestest.Today.AddDate(0, 0, -1)).Rows)
// Paid in full today: today's aging is empty. e.Pay(t, e.Payment(t, s, "100.00", nil, payables.PaymentLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) require.Empty(t, aging(t, e, expensestest.Today).Rows, "aging shows zero once paid (spec e2e)")}
func TestHistoricalAgingIgnoresLaterAllocations(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) s := e.VerifiedSupplier(t, "BEKAL") inv := e.Post(t, e.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.Account(t, "6140"), "100.00"))) e.AdvanceClock(t, 10) // payablestest: moves e.Clock forward 10 days e.Pay(t, e.Payment(t, s, "40.00", nil, payables.PaymentLine{InvoiceID: inv.ID, Amount: money.MustParse("40.00")})) require.Equal(t, "100.0000", aging(t, e, expensestest.Today.AddDate(0, 0, 5)).Totals.Total.String(), "before the payment") require.Equal(t, "60.0000", aging(t, e, expensestest.Today.AddDate(0, 0, 10)).Totals.Total.String(), "after it")}payablestest gains:
// AdvanceClock moves the business date forward and re-registers the Poster,// whose accounting.NewPoster captured the old clock.func (e *Env) AdvanceClock(t *testing.T, days int) { t.Helper() e.Clock = clock.Fixed(e.Clock.Now().AddDate(0, 0, days)) e.UsePayablesPoster(t)}The later business date is still in FY2026’s open periods.
internal/platform/payables/subledger_test.go:
package payables_test
import ( "testing"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
// TestSubledgerEqualsControlPerSupplier is spec D2's drift test: after a// mixed sequence — invoices, a credit note and its partial offset and// reversal, a foreign invoice paid at another rate, a void, a voided// payment — every supplier's open items (the aging, in base) equal the// Trade payables balance carrying that supplier's party_id.func TestSubledgerEqualsControlPerSupplier(t *testing.T) { e := payablestest.New(t) e.UsePayablesPoster(t) a, b := e.VerifiedSupplier(t, "ALPHA"), e.VerifiedSupplier(t, "USBETA") exp := e.Account(t, "6140")
i1 := e.Post(t, e.Invoice(t, payables.KindInvoice, a, "A-1", payablestest.Line(exp, "500.00"))) i2 := e.Post(t, e.Invoice(t, payables.KindInvoice, a, "A-2", payablestest.Line(exp, "120.00"))) cn := e.Post(t, e.Invoice(t, payables.KindCreditNote, a, "A-CN", payablestest.Line(exp, "80.00"))) off := e.Offset(t, cn, i1, "50.00") e.ReverseOffset(t, off) e.Offset(t, cn, i2, "30.00") e.Pay(t, e.Payment(t, a, "200.00", nil, payables.PaymentLine{InvoiceID: i1.ID, Amount: money.MustParse("200.00")})) bad := e.Post(t, e.Invoice(t, payables.KindInvoice, a, "A-3", payablestest.Line(exp, "999.00"))) e.Void(t, bad)
usd := e.Post(t, e.ForeignInvoice(t, b, "B-1", "USD", money.MustParseRate("4.40"), payablestest.Line(exp, "100.00"))) rate := money.MustParseRate("4.47") p := e.Pay(t, e.Payment(t, b, "60.00", &rate, payables.PaymentLine{InvoiceID: usd.ID, Amount: money.MustParse("60.00")})) e.VoidPayment(t, p) e.Pay(t, e.Payment(t, b, "100.00", &rate, payables.PaymentLine{InvoiceID: usd.ID, Amount: money.MustParse("100.00")}))
control := e.ControlBalanceByParty(t) ag := aging(t, e, expensestest.Today) sub := map[uuid.UUID]money.Amount{} for _, r := range ag.Rows { sub[r.SupplierID] = sub[r.SupplierID].Add(r.OutstandingBase) } // PRESENCE: the sequence left something open, so the equality is not 0 = 0. require.Equal(t, "340.0000", control[a.PartyID].String(), "500+120−80−200 (A-3 voided)") require.NotEmpty(t, ag.Rows) for _, party := range []uuid.UUID{a.PartyID, b.PartyID} { require.Zero(t, sub[party].Cmp(control[party]), "supplier %s: sub-ledger %s, control %s", party, sub[party], control[party]) } for party := range control { require.Contains(t, []uuid.UUID{a.PartyID, b.PartyID}, party, "a control balance for a party with no open items") }}(Offset and ReverseOffset are Task 10’s.)
-
Step 2: Run — expect failure.
go test ./internal/platform/payables/ -run 'Bucket|Aging|Subledger' -count=1→ FAIL:undefined: payables.BucketFor. -
Step 3: Implement
aging.go.
package payables
import ( "context" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/company")
// (Bucket constants, Buckets, BucketAmounts, AgingRow, AgingSupplier, Aging exactly as in Interfaces)
// BucketFor is Ruling P31: ≤ 0 current, then 1–30, 31–60, 61–90, over 90.func BucketFor(days int) string { switch { case days <= 0: return BucketCurrent case days <= 30: return Bucket1To30 case days <= 60: return Bucket31To60 case days <= 90: return Bucket61To90 } return BucketOver90}
func (b *BucketAmounts) Add(bucket string, a money.Amount) { switch bucket { case BucketCurrent: b.Current = b.Current.Add(a) case Bucket1To30: b.D1To30 = b.D1To30.Add(a) case Bucket31To60: b.D31To60 = b.D31To60.Add(a) case Bucket61To90: b.D61To90 = b.D61To90.Add(a) default: b.Over90 = b.Over90.Add(a) } b.Total = b.Total.Add(a)}
func zeroBuckets() BucketAmounts { z := money.Zero() return BucketAmounts{Current: z, D1To30: z, D31To60: z, D61To90: z, Over90: z, Total: z}}
// agingSQL: a document is open as of $2 when its accrual entry is dated on// or before $2 and no reversal of that entry is; allocations count when// effective_on ≤ $2. A credit note's remainder is negative.const agingSQL = ` WITH posted AS ( SELECT h.id, h.kind, h.reference, h.supplier_invoice_no, h.supplier_party_id, h.currency, h.invoice_date, h.due_on, h.gross_amount, h.gross_base FROM purchase_invoice h JOIN journal_entry e ON e.org_id = h.org_id AND e.source_id = h.id AND e.source_kind = h.kind AND e.semantic = 'accrual' WHERE h.org_id = $1 AND e.org_id = $1 AND e.entry_date <= $2 AND ($3::uuid IS NULL OR h.supplier_party_id = $3) AND NOT EXISTS (SELECT 1 FROM journal_entry r WHERE r.org_id = $1 AND r.reversal_of_id = e.id AND r.entry_date <= $2) ), applied AS ( SELECT p.id, coalesce(sum(a.amount), 0) AS amt, coalesce(sum(a.base_amount), 0) AS base FROM posted p LEFT JOIN ap_allocation a ON a.org_id = $1 AND a.effective_on <= $2 AND ((p.kind = 'purchase_invoice' AND a.invoice_id = p.id) OR (p.kind = 'supplier_credit_note' AND a.credit_note_id = p.id)) GROUP BY p.id ) SELECT p.id, p.kind, p.reference, p.supplier_invoice_no, p.supplier_party_id, s.code, pa.legal_name, p.currency, p.invoice_date, p.due_on, CASE WHEN p.kind = 'supplier_credit_note' THEN -(p.gross_amount - ap.amt) ELSE p.gross_amount - ap.amt END, CASE WHEN p.kind = 'supplier_credit_note' THEN -(p.gross_base - ap.base) ELSE p.gross_base - ap.base END FROM posted p JOIN applied ap ON ap.id = p.id JOIN party_supplier s ON s.org_id = $1 AND s.party_id = p.supplier_party_id JOIN party pa ON pa.org_id = $1 AND pa.id = p.supplier_party_id WHERE p.gross_amount - ap.amt <> 0 OR p.gross_base - ap.base <> 0 ORDER BY s.code, p.due_on, p.reference`
func AgingAsOf(ctx context.Context, tx pgx.Tx, asOf time.Time, supplierID *uuid.UUID) (Aging, error) { org, err := orgOf(ctx) if err != nil { return Aging{}, err } profile, err := company.Get(ctx, tx) if err != nil { return Aging{}, err } asOf = time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 0, 0, 0, 0, time.UTC) rows, err := tx.Query(ctx, agingSQL, org, asOf, supplierID) if err != nil { return Aging{}, fmt.Errorf("payables: aging: %w", err) } defer rows.Close() out := Aging{AsOf: asOf, Currency: profile.BaseCurrency, Totals: zeroBuckets()} index := map[uuid.UUID]int{} for rows.Next() { var r AgingRow if err := rows.Scan(&r.DocumentID, &r.Kind, &r.Reference, &r.SupplierDocumentNo, &r.SupplierID, &r.SupplierCode, &r.SupplierName, &r.Currency, &r.DocumentDate, &r.DueOn, &r.Outstanding, &r.OutstandingBase); err != nil { return Aging{}, err } r.DocumentDate, r.DueOn = r.DocumentDate.UTC(), r.DueOn.UTC() r.DaysPastDue = int(asOf.Sub(r.DueOn).Hours() / 24) r.Bucket = BucketFor(r.DaysPastDue) out.Rows = append(out.Rows, r) i, ok := index[r.SupplierID] if !ok { i = len(out.Suppliers) index[r.SupplierID] = i out.Suppliers = append(out.Suppliers, AgingSupplier{SupplierID: r.SupplierID, Code: r.SupplierCode, Name: r.SupplierName, Buckets: zeroBuckets()}) } out.Suppliers[i].Buckets.Add(r.Bucket, r.OutstandingBase) out.Totals.Add(r.Bucket, r.OutstandingBase) } return out, rows.Err()}-
Step 4: Run passing.
go test ./internal/platform/payables/... -count=1→ PASS. -
Step 5: Sabotage. (a) Change
days <= 30todays < 30;TestBucketBoundariesfails atday 30; restore. (b) DropAND a.effective_on <= $2;TestHistoricalAging…failsbefore the payment; restore. (c) Replace the twoCASE WHEN p.kind = 'supplier_credit_note' …expressions with the plainp.gross_amount - ap.amt/p.gross_base - ap.base;TestSubledgerEqualsControlPerSupplierfails naming ALPHA’s party (sub-ledger 440.0000, control 340.0000); restore. This is the drift guard watched failing. -
Step 6: Commit.
git add internal/platform/payables && git commit -m "AP Task 11: aging, supplier balance, sub-ledger = control drift test" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 12: The payables module — permissions, wiring, Owner grants, supplier routes
Section titled “Task 12: The payables module — permissions, wiring, Owner grants, supplier routes”Files:
- Create:
internal/modules/payables/{permissions.go,routes.go,boot.go,http.go,errors.go,supplier_handlers.go} - Create:
db/migrations/00305_payables_owner_grants.sql - Modify:
internal/platform/provisioning/steps.go(RegisterRolePermissions, applied byrolesStep.Run) - Modify:
constants.yaml(permissions:— nine keys), generated vocab - Modify:
api/openapi.yaml(supplier paths and schemas) - Modify:
internal/app/app.go,internal/app/routes_test.go(composedModules),cmd/api/main.go,cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go,cmd/migrate/wiring_test.go,cmd/orgadmin/wiring_test.go,cmd/worker/wiring_test.go - Test:
internal/modules/payables/env_test.go,internal/modules/payables/supplier_handlers_test.go,internal/modules/payables/boot_test.go,internal/modules/payables/grants_test.go,internal/platform/payables/migration00305_test.go,internal/platform/provisioning/provisioning_test.go(append)
Interfaces:
- Consumes: every
internal/platform/payablessupplier function;httpx.Protected,member.RegisterPermission,member.Permissions,member.Can,payout.ListRegistryEntries(ctx, tx, registry string, activeOnly bool). - Produces:
const ( PermissionSupplierRead = "payables.supplier.read" PermissionSupplierManage = "payables.supplier.manage" PermissionSupplierVerifyBank = platformpayables.PermissionVerifyBank PermissionInvoiceRead = platformpayables.PermissionInvoiceRead PermissionInvoiceCreate = "payables.invoice.create" PermissionInvoiceTransition = "payables.invoice.transition" PermissionPaymentRead = "payables.payment.read" PermissionPaymentCreate = "payables.payment.create" PermissionPaymentTransition = "payables.payment.transition")type Deps struct{ DB *db.DB; Clock clock.Clock }func Register(g *gin.RouterGroup, deps Deps)Routes (all httpx.Protected):
| Method | Path | Permission |
|---|---|---|
| GET | /payables/banks |
supplier.read |
| GET / POST | /payables/suppliers |
supplier.read / supplier.manage |
| GET / PUT | /payables/suppliers/:id |
supplier.read / supplier.manage |
| PUT | /payables/suppliers/:id/bank |
supplier.manage |
| POST | /payables/suppliers/:id/bank/verify |
supplier.verify_bank |
| GET | /payables/suppliers/:id/balance |
invoice.read |
| (Task 13) GET / POST | /payables/invoices |
invoice.read / invoice.create |
| (Task 13) GET / PUT | /payables/invoices/:id |
invoice.read / invoice.create |
| (Task 13) POST | /payables/invoices/:id/transitions |
invoice.transition |
| (Task 13) GET | /payables/invoices/:id/allocations |
invoice.read |
| (Task 13) POST | /payables/allocations |
invoice.create |
| (Task 13) POST | /payables/allocations/:id/reverse |
invoice.create |
| (Task 13) GET / POST | /payables/payments |
payment.read / payment.create |
| (Task 13) GET / PUT | /payables/payments/:id |
payment.read / payment.create |
| (Task 13) POST | /payables/payments/:id/transitions |
payment.transition |
- Step 1: Failing tests.
internal/modules/payables/env_test.go: copy alwaysLive, discard, env, newEnv, grant, mint, do from internal/modules/advances/advance_handlers_test.go (lines 33–150) with these substitutions: package payables_test; ax *advancestest.Env → px *payablestest.Env (payablestest.New(t), then px.UsePayablesPoster(t) instead of NopPoster); advances.Register(api, advances.Deps{…}) → payables.Register(api, payables.Deps{DB: px.DB, Clock: px.Clock}); members: clerk = px.Staff granted every key except PermissionSupplierVerifyBank, approver = px.Owner granted all nine, reader (a new member under the OWNER seat) granted PermissionSupplierRead, PermissionInvoiceRead, PermissionPaymentRead; tokens clerkTok, approverTok, readerTok. Add a helper:
// decode unmarshals rec's body into a fresh T, failing on a bad status.func decode[T any](t *testing.T, rec *httptest.ResponseRecorder, want int) T { t.Helper() require.Equal(t, want, rec.Code, "%s", rec.Body.String()) var v T require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &v)) return v}internal/modules/payables/supplier_handlers_test.go:
package payables_test
import ( "net/http" "strconv" "testing"
"github.com/stretchr/testify/require")
type supplierView struct { ID string `json:"id"` Code string `json:"code"` Revision int `json:"revision"` BankVerified bool `json:"bank_verified"` Bank *struct { AccountNo string `json:"account_no"` } `json:"bank"` PreviousBank *struct { AccountNo string `json:"account_no"` } `json:"previous_bank"`}
func etag(rev int) string { return strconv.Quote(strconv.Itoa(rev)) }
func TestSupplierLifecycleOverHTTP(t *testing.T) { e := newEnv(t) created := decode[supplierView](t, e.do(t, "POST", "/v1/payables/suppliers", e.clerkTok, map[string]any{ "code": "BEKAL", "party_kind": "organization", "legal_name": "Bekalan Sdn Bhd", "country_code": "MY", "payment_terms_days": 30, "currency": "MYR", "address": map[string]any{"city": "Shah Alam"}, }), http.StatusCreated) require.Equal(t, "BEKAL", created.Code)
// The reader may list but not create (presence of the list first). list := decode[struct { Suppliers []supplierView `json:"suppliers"` }](t, e.do(t, "GET", "/v1/payables/suppliers", e.readerTok, nil), http.StatusOK) require.Len(t, list.Suppliers, 1) require.Equal(t, http.StatusForbidden, e.do(t, "POST", "/v1/payables/suppliers", e.readerTok, map[string]any{ "code": "X", "party_kind": "organization", "legal_name": "X", "country_code": "MY", "payment_terms_days": 0, "currency": "MYR", }).Code)
banks := decode[struct { Banks []struct{ ID string `json:"id"` } `json:"banks"` }](t, e.do(t, "GET", "/v1/payables/banks", e.clerkTok, nil), http.StatusOK) require.NotEmpty(t, banks.Banks)
withBank := decode[supplierView](t, e.do(t, "PUT", "/v1/payables/suppliers/"+created.ID+"/bank", e.clerkTok, map[string]any{ "bank_entry_id": banks.Banks[0].ID, "account_no": "5140123456", "account_name": "Bekalan Sdn Bhd", }, "If-Match", etag(created.Revision)), http.StatusOK) require.Equal(t, "••••3456", withBank.Bank.AccountNo, "masked for a caller without verify_bank") require.False(t, withBank.BankVerified)
// The changer lacks verify_bank; the approver has it but is refused if they changed it. require.Equal(t, http.StatusForbidden, e.do(t, "POST", "/v1/payables/suppliers/"+created.ID+"/bank/verify", e.clerkTok, map[string]any{}, "If-Match", etag(withBank.Revision)).Code) full := decode[supplierView](t, e.do(t, "GET", "/v1/payables/suppliers/"+created.ID, e.approverTok, nil), http.StatusOK) require.Equal(t, "5140123456", full.Bank.AccountNo, "verify_bank holders see the full number") verified := decode[supplierView](t, e.do(t, "POST", "/v1/payables/suppliers/"+created.ID+"/bank/verify", e.approverTok, map[string]any{}, "If-Match", etag(withBank.Revision)), http.StatusOK) require.True(t, verified.BankVerified)
// Stale If-Match is 409. require.Equal(t, http.StatusConflict, e.do(t, "PUT", "/v1/payables/suppliers/"+created.ID+"/bank", e.clerkTok, map[string]any{ "bank_entry_id": banks.Banks[0].ID, "account_no": "5140999999", "account_name": "Bekalan Sdn Bhd", }, "If-Match", etag(withBank.Revision)).Code)}
func TestTheChangerIsRefusedAsVerifier(t *testing.T) { e := newEnv(t) s := e.px.Supplier(t, "BEKAL") // approver changes AND tries to verify: 403 with the refusal code. banks := decode[struct { Banks []struct{ ID string `json:"id"` } `json:"banks"` }](t, e.do(t, "GET", "/v1/payables/banks", e.approverTok, nil), http.StatusOK) changed := decode[supplierView](t, e.do(t, "PUT", "/v1/payables/suppliers/"+s.PartyID.String()+"/bank", e.approverTok, map[string]any{ "bank_entry_id": banks.Banks[0].ID, "account_no": "5140123456", "account_name": "Bekalan Sdn Bhd", }, "If-Match", etag(s.Revision)), http.StatusOK) rec := e.do(t, "POST", "/v1/payables/suppliers/"+s.PartyID.String()+"/bank/verify", e.approverTok, map[string]any{}, "If-Match", etag(changed.Revision)) require.Equal(t, http.StatusForbidden, rec.Code) require.Contains(t, rec.Body.String(), "bank_verifier_is_changer")}internal/modules/payables/boot_test.go:
package payables_test
import ( "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/identity/member" "github.com/mcom/erp/internal/platform/posting"
_ "github.com/mcom/erp/internal/modules/payables")
func TestBootRegistersTheRulesAndPermissions(t *testing.T) { for _, tc := range []struct { kind string sem posting.Semantic }{{"purchase_invoice", posting.Accrual}, {"supplier_credit_note", posting.Accrual}, {"supplier_payment", posting.Payout}} { _, ok := accounting.DefaultRules().Lookup(tc.kind, tc.sem) require.True(t, ok, "no %s rule for %s", tc.sem, tc.kind) } keys := member.RegisteredPermissionSet().Keys() for _, k := range []string{"payables.supplier.read", "payables.supplier.manage", "payables.supplier.verify_bank", "payables.invoice.read", "payables.invoice.create", "payables.invoice.transition", "payables.payment.read", "payables.payment.create", "payables.payment.transition"} { require.Contains(t, keys, k) }}internal/platform/payables/migration00305_test.go (S3; the internal/platform/identity/role/migration_test.go shape — testdb.StartAt boots its own container, which AGENTS.md reserves for migration tests):
package payables_test
import ( "context" "database/sql" "testing"
"github.com/google/uuid" _ "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
func TestMigration00305GrantsOwnerAndFinanceTheirAPSets(t *testing.T) { h := testdb.StartAt(t, 304) ctx := context.Background() org := uuid.New() _, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err) role := func(name string) uuid.UUID { var id uuid.UUID require.NoError(t, h.Admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, org, name).Scan(&id)) return id } owner, finance, employee := role("Owner"), role("Finance"), role("Employee") keys := func(id uuid.UUID) map[string]bool { rows, err := h.Admin.Query(ctx, `SELECT permission_key FROM role_permission WHERE role_id = $1`, id) require.NoError(t, err) defer rows.Close() out := map[string]bool{} for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) out[k] = true } return out } pv := func() int64 { var v int64 require.NoError(t, h.Admin.QueryRow(ctx, `SELECT perm_version FROM organization WHERE id = $1`, org).Scan(&v)) return v } require.Empty(t, keys(finance), "ABSENCE before: nothing granted yet") before := pv()
conn, err := sql.Open("pgx", h.AdminDSN) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) require.NoError(t, goose.SetDialect("postgres")) require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 305, goose.WithAllowMissing()))
o, fin := keys(owner), keys(finance) require.Len(t, o, 9, "Owner: all nine payables keys") require.True(t, o["payables.supplier.verify_bank"]) // PRESENCE then ABSENCE on Finance. require.True(t, fin["payables.invoice.transition"]) require.True(t, fin["accounting.ledger.read"]) require.Len(t, fin, 9, "Finance: eight payables keys and accounting.ledger.read") require.False(t, fin["payables.supplier.verify_bank"], "verify_bank stays Owner-only") require.Empty(t, keys(employee), "no other role is touched") require.Equal(t, before+1, pv())
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) require.Empty(t, keys(owner), "the Down removes Owner's payables keys") require.Equal(t, map[string]bool{"accounting.ledger.read": true}, keys(finance), "and Finance's, leaving the ledger read (indistinguishable from an administrator's grant)")}internal/modules/payables/grants_test.go (Ruling P3; this test binary links the module, so its init() registrations are in effect when payablestest.New provisions):
package payables_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables/payablestest")
func financeKeys(t *testing.T, px *payablestest.Env) map[string]bool { t.Helper() rows, err := px.H.Admin.Query(context.Background(), ` SELECT rp.permission_key FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE r.org_id = $1 AND r.system AND r.name = 'Finance'`, px.Org) require.NoError(t, err) defer rows.Close() out := map[string]bool{} for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) out[k] = true } return out}
func TestANewOrgsFinanceRoleRunsAPButCannotVerifyBankDetails(t *testing.T) { px := payablestest.New(t) keys := financeKeys(t, px) // PRESENCE: Finance holds the AP set and the ledger read the forms need. for _, k := range []string{"payables.supplier.read", "payables.supplier.manage", "payables.invoice.read", "payables.invoice.create", "payables.invoice.transition", "payables.payment.read", "payables.payment.create", "payables.payment.transition", "accounting.ledger.read"} { require.True(t, keys[k], "Finance lacks %s", k) } // ABSENCE: bank verification stays Owner-only. require.False(t, keys["payables.supplier.verify_bank"])}Append to internal/platform/provisioning/provisioning_test.go (package-level registry, so the test registers and cleans up its own role key):
func TestRegisteredRolePermissionsReachOnlyARoleTheStepCreates(t *testing.T) { provisioning.RegisterRolePermissions("Approver", "fixture.approve") t.Cleanup(func() { provisioning.UnregisterRolePermissionsForTest("Approver", "fixture.approve") }) d, h, ctx, org := provisioningSetup(t) owner := newMember(t, h, org, "Owner") require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { return company.Create(txCtx, tx, clock.Fixed(t0), company.Profile{LegalName: "Acme Sdn Bhd", CountryCode: "MY", BaseCurrency: "MYR", FiscalYearStartsOn: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), DefaultLocale: "en", Timezone: "Asia/Kuala_Lumpur"}) })) run := func() { require.NoError(t, d.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { _, err := provisioning.Run(txCtx, tx, clock.Fixed(t0), ownerInput(owner)) return err })) } count := func() int { var n int require.NoError(t, h.Admin.QueryRow(context.Background(), ` SELECT count(*) FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE r.org_id = $1 AND r.name = 'Approver' AND rp.permission_key = 'fixture.approve'`, org).Scan(&n)) return n } run() require.Equal(t, 1, count(), "PRESENCE: the role the step created holds the registered key") _, err := h.Admin.Exec(context.Background(), ` DELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.org_id = $1 AND r.name = 'Approver' AND rp.permission_key = 'fixture.approve'`, org) require.NoError(t, err) // S2: force a REAL repair. With the five roles present and Owner granted, // rolesStep.Check is done and Run never executes, which would make the // assertion below vacuous. Emptying Owner's grants makes Check false, so // Run executes with Approver already there (not inserted this run). _, err = h.Admin.Exec(context.Background(), ` DELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.org_id = $1 AND r.name = 'Owner'`, org) require.NoError(t, err) run() var ownerGrants int require.NoError(t, h.Admin.QueryRow(context.Background(), ` SELECT count(*) FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE r.org_id = $1 AND r.name = 'Owner'`, org).Scan(&ownerGrants)) require.Positive(t, ownerGrants, "PRESENCE: the repair really ran rolesStep.Run (it re-seeded Owner)") require.Equal(t, 0, count(), "an administrator's removal stands: a repair does not re-add it")}-
Step 2: Run — expect failure.
go test ./internal/modules/payables/... -count=1→ FAIL:no non-test Go files. -
Step 3: Implement.
constants.yaml permissions: — append after org.roles.manage:
- payables.supplier.read - payables.supplier.manage - payables.supplier.verify_bank - payables.invoice.read - payables.invoice.create - payables.invoice.transition - payables.payment.read - payables.payment.create - payables.payment.transitionpermissions.go:
package payables
import ( "github.com/mcom/erp/internal/platform/identity/member" platformpayables "github.com/mcom/erp/internal/platform/payables")
// (the nine constants exactly as in Interfaces)
func init() { member.RegisterPermission(PermissionSupplierRead, "Read suppliers and the banks they can be paid at") member.RegisterPermission(PermissionSupplierManage, "Create and edit suppliers and their bank details") member.RegisterPermission(PermissionSupplierVerifyBank, "Verify a supplier's bank details changed by someone else, and see account numbers in full") member.RegisterPermission(PermissionInvoiceRead, "Read purchase invoices, credit notes, allocations and supplier balances") member.RegisterPermission(PermissionInvoiceCreate, "Enter purchase invoices and credit notes, edit their drafts, and offset credit notes") member.RegisterPermission(PermissionInvoiceTransition, "Use the invoice transition endpoint; authority to approve still comes from the workflow's grants") member.RegisterPermission(PermissionPaymentRead, "Read supplier payments") member.RegisterPermission(PermissionPaymentCreate, "Prepare supplier payments and edit their drafts") member.RegisterPermission(PermissionPaymentTransition, "Use the payment transition endpoint; authority to approve still comes from the workflow's grants")}internal/platform/provisioning/steps.go — beside EveryRolePermissions:
var ( rolePermsMu sync.Mutex rolePerms = map[string][]string{})
// RegisterRolePermissions adds keys a seeded role receives when rolesStep// CREATES it — never on a repair that finds the role already there, so an// administrator's removal stands (EveryRolePermissions' rule). Modules call// it from init(); existing organizations get the same keys by migration.func RegisterRolePermissions(role string, keys ...string) { rolePermsMu.Lock() defer rolePermsMu.Unlock() rolePerms[role] = append(rolePerms[role], keys...)}
// UnregisterRolePermissionsForTest removes what a test registered.func UnregisterRolePermissionsForTest(role string, keys ...string) { rolePermsMu.Lock() defer rolePermsMu.Unlock() rolePerms[role] = slices.DeleteFunc(rolePerms[role], func(k string) bool { return slices.Contains(keys, k) })}In rolesStep.Run, the scan loop already sees (id, name, inserted); collect createdByName := map[string]uuid.UUID{} for inserted non-Owner rows, and after the EveryRolePermissions insert add:
rolePermsMu.Lock() for name, id := range createdByName { if keys := rolePerms[name]; len(keys) > 0 { if _, err := tx.Exec(ctx, ` INSERT INTO role_permission (org_id, role_id, permission_key) SELECT $1, $2, k FROM unnest($3::text[]) AS k ON CONFLICT (role_id, permission_key) DO NOTHING`, orgID, id, keys); err != nil { rolePermsMu.Unlock() return fmt.Errorf("provisioning: seed %s permissions: %w", name, err) } } } rolePermsMu.Unlock()(imports slices, sync). Update seededRoles’ doc comment: Finance may now be seeded with registered keys.
permissions.go’s init() gains, after the nine RegisterPermission calls:
// Ruling P3: Finance runs AP; verifying bank details stays Owner-only. provisioning.RegisterRolePermissions("Finance", PermissionSupplierRead, PermissionSupplierManage, PermissionInvoiceRead, PermissionInvoiceCreate, PermissionInvoiceTransition, PermissionPaymentRead, PermissionPaymentCreate, PermissionPaymentTransition, "accounting.ledger.read")(import internal/platform/provisioning).
boot.go:
package payables
import ( "github.com/mcom/erp/internal/platform/accounting" platformpayables "github.com/mcom/erp/internal/platform/payables")
// One loop over Rules(): registering only some of them is a transition that// commits a state change it cannot post.func init() { for _, r := range platformpayables.Rules() { accounting.RegisterPostingRule(r) }}http.go: copy internal/modules/advances/http.go byte for byte, change the package clause to package payables, and in ifMatch replace the two occurrences of “advance” in its messages with “record”.
routes.go: Deps, Register with the table above (Task 13’s rows are added by Task 13), handlers, and permissionLoader copied from internal/modules/advances/routes.go with advances → payables in the error text.
errors.go:
package payables
import ( "errors" "fmt" "strings"
"github.com/jackc/pgx/v5/pgconn"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/accounting/ledger" p "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/workflow")
// mapErr turns platform errors into RFC 7807 problems. 409: the same// request would succeed against the records in another state; 422: wrong// whatever the data holds.func mapErr(err error) error { var ( field *p.FieldError revision *p.RevisionMismatchError dup *p.DuplicateInvoiceError submission *p.SubmissionError elsewhere *p.DocumentBoundElsewhereError refused *workflow.RefusedError unroutable *workflow.SubmissionRefusedError period *fiscal.PeriodNotOpenError control *chart.NoControlAccountError noRule *accounting.NoPostingRuleError pg *pgconn.PgError ) switch { case errors.Is(err, p.ErrNotFound): return apperr.New(apperr.KindNotFound, "no such record in this organization") case errors.As(err, &field): return apperr.New(apperr.KindValidation, field.Error()) case errors.As(err, &submission): return apperr.New(apperr.KindValidation, submission.Error()) case errors.As(err, &revision): return apperr.New(apperr.KindConflict, fmt.Sprintf("the record is at revision %d; reload and retry", revision.Current)). With("code", "revision_mismatch") case errors.As(err, &dup): return apperr.New(apperr.KindConflict, dup.Error()).With("code", "duplicate_supplier_document").With("existing_id", dup.ExistingID) case errors.As(err, &elsewhere): return apperr.New(apperr.KindConflict, elsewhere.Error()).With("code", "document_bound_elsewhere") case errors.Is(err, p.ErrDuplicateSupplierCode): return apperr.New(apperr.KindConflict, "another supplier has this code").With("code", "duplicate_supplier_code") case errors.Is(err, p.ErrSameVerifier): return apperr.New(apperr.KindForbidden, "the member who changed the bank details cannot verify them"). With("code", "bank_verifier_is_changer") case errors.Is(err, p.ErrNoBank), errors.Is(err, p.ErrAlreadyVerified), errors.Is(err, p.ErrNotDraft): return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")) case errors.Is(err, p.ErrHasAllocations): return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "has_allocations") case errors.Is(err, p.ErrBankUnverified): return apperr.New(apperr.KindConflict, "the supplier's bank details must be verified before a payment is approved"). With("code", "bank_unverified") case errors.Is(err, p.ErrAllocationTotal), errors.Is(err, p.ErrBankAccount), errors.Is(err, p.ErrInvoiceNotPayable), errors.Is(err, p.ErrNotAllocatable), errors.Is(err, p.ErrBadCursor): return apperr.New(apperr.KindValidation, strings.TrimPrefix(err.Error(), "payables: ")) case errors.Is(err, p.ErrRateMismatch): return apperr.New(apperr.KindValidation, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "rate_mismatch") case errors.Is(err, p.ErrOverAllocation): // one status for every over-allocation: 422, like a payment's field refusal return apperr.New(apperr.KindValidation, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "over_allocated") case errors.Is(err, p.ErrAlreadyReversed), errors.Is(err, p.ErrReversePaymentAllocation): return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")) case errors.Is(err, workflow.ErrStaleState): return apperr.New(apperr.KindConflict, "the document is no longer in the state you sent as from; reload it and retry") case errors.Is(err, workflow.ErrNoSuchEdge): return apperr.New(apperr.KindValidation, "to names no transition out of the current state") case errors.Is(err, workflow.ErrReasonRequired): return apperr.New(apperr.KindValidation, "reason is required for this transition") case errors.As(err, &refused): return apperr.New(apperr.KindForbidden, "refused: "+refused.Refusal.Code+": "+refused.Refusal.Detail) case errors.As(err, &unroutable): return apperr.New(apperr.KindValidation, err.Error()) case errors.As(err, &period): return apperr.New(apperr.KindConflict, fmt.Sprintf("the fiscal period this would post into (%s) is %s, not open", period.PeriodID, period.Status)) case errors.As(err, &control): return apperr.New(apperr.KindConflict, "accounting is not set up: no active account for control kind "+control.Kind) case errors.As(err, &noRule), errors.Is(err, ledger.ErrUnbalanced), errors.Is(err, p.ErrInstanceMismatch), errors.Is(err, p.ErrTotalsDrift): return apperr.New(apperr.KindInternal, "this document cannot be posted; contact the operator") case errors.As(err, &pg) && pg.ConstraintName == "purchase_invoice_supplier_no_once": return apperr.New(apperr.KindConflict, "this supplier's document number is already recorded").With("code", "duplicate_supplier_document") case errors.As(err, &pg) && (pg.ConstraintName == "purchase_invoice_is_draft" || pg.ConstraintName == "supplier_payment_is_draft" || pg.ConstraintName == "purchase_invoice_line_is_draft" || pg.ConstraintName == "supplier_payment_line_is_draft"): return apperr.New(apperr.KindConflict, "the document is not a draft any more") // S14: the bank DB backstops, reached only on a race past the Go checks. case errors.As(err, &pg) && pg.ConstraintName == "party_supplier_bank_verifier_differs": return apperr.New(apperr.KindForbidden, "the member who changed the bank details cannot verify them"). With("code", "bank_verifier_is_changer") case errors.As(err, &pg) && pg.ConstraintName == "party_supplier_bank_change_clears_verification": return apperr.New(apperr.KindConflict, "the bank details changed while being verified; reload and retry") case errors.As(err, &pg) && strings.HasPrefix(pg.ConstraintName, "ap_allocation_"): return apperr.New(apperr.KindConflict, pg.Message).With("code", pg.ConstraintName) } return err}(Problem.With(key, v) exists in internal/kernel/errors/problem.go; NoControlAccountError.Kind and PeriodNotOpenError.{PeriodID,Status} are the fields advances’ transitionErr reads.)
supplier_handlers.go:
package payables
import ( "context" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/platform/identity/member" p "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payout")
type addressDTO struct { Line1 string `json:"line1"` Line2 string `json:"line2"` City string `json:"city"` Postcode string `json:"postcode"` State string `json:"state"`}
type bankDTO struct { BankEntryID uuid.UUID `json:"bank_entry_id"` BankName string `json:"bank_name"` AccountNo string `json:"account_no"` AccountName string `json:"account_name"`}
type supplierDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` PartyKind string `json:"party_kind"` LegalName string `json:"legal_name"` TIN *string `json:"tin"` RegistrationNo *string `json:"registration_no"` CountryCode string `json:"country_code"` Address addressDTO `json:"address"` Email *string `json:"email"` Phone *string `json:"phone"` PaymentTermsDays int `json:"payment_terms_days"` Currency string `json:"currency"` DefaultExpenseAccountID *uuid.UUID `json:"default_expense_account_id"` DefaultTaxCodeID *uuid.UUID `json:"default_tax_code_id"` Bank *bankDTO `json:"bank"` PreviousBank *bankDTO `json:"previous_bank"` BankChangedBy *uuid.UUID `json:"bank_changed_by"` BankChangedAt *time.Time `json:"bank_changed_at"` BankVerifiedBy *uuid.UUID `json:"bank_verified_by"` BankVerifiedAt *time.Time `json:"bank_verified_at"` BankVerified bool `json:"bank_verified"` IsActive bool `json:"is_active"` Revision int `json:"revision"`}
// toSupplierDTO masks account numbers unless full is set (Ruling P26).func toSupplierDTO(ctx context.Context, tx pgx.Tx, s p.Supplier, full bool) supplierDTO { bank := func(b *p.Bank) *bankDTO { if b == nil { return nil } no := b.AccountNo if !full { no = p.MaskAccountNo(no) } name := "" if e, err := payout.GetRegistryEntry(ctx, tx, b.EntryID); err == nil { name = e.Name } return &bankDTO{BankEntryID: b.EntryID, BankName: name, AccountNo: no, AccountName: b.AccountName} } return supplierDTO{ID: s.PartyID, Code: s.Code, PartyKind: s.PartyKind, LegalName: s.LegalName, TIN: s.TIN, RegistrationNo: s.RegistrationNo, CountryCode: s.CountryCode, Address: addressDTO{s.Address.Line1, s.Address.Line2, s.Address.City, s.Address.Postcode, s.Address.State}, Email: s.Email, Phone: s.Phone, PaymentTermsDays: s.PaymentTermsDays, Currency: s.Currency, DefaultExpenseAccountID: s.DefaultExpenseAccountID, DefaultTaxCodeID: s.DefaultTaxCodeID, Bank: bank(s.Bank), PreviousBank: bank(s.PreviousBank), BankChangedBy: s.BankChangedBy, BankChangedAt: s.BankChangedAt, BankVerifiedBy: s.BankVerifiedBy, BankVerifiedAt: s.BankVerifiedAt, BankVerified: s.BankVerified(), IsActive: s.IsActive, Revision: s.Revision}}
type supplierBody struct { Code string `json:"code"` PartyKind string `json:"party_kind"` LegalName string `json:"legal_name"` TIN *string `json:"tin"` RegistrationNo *string `json:"registration_no"` CountryCode string `json:"country_code"` Address addressDTO `json:"address"` Email *string `json:"email"` Phone *string `json:"phone"` PaymentTermsDays int `json:"payment_terms_days"` Currency string `json:"currency"` DefaultExpenseAccountID *uuid.UUID `json:"default_expense_account_id"` DefaultTaxCodeID *uuid.UUID `json:"default_tax_code_id"` IsActive *bool `json:"is_active"`}
func (b supplierBody) input() p.SupplierInput { active := b.IsActive == nil || *b.IsActive return p.SupplierInput{Code: b.Code, PartyKind: b.PartyKind, LegalName: b.LegalName, TIN: b.TIN, RegistrationNo: b.RegistrationNo, CountryCode: b.CountryCode, Address: p.Address{Line1: b.Address.Line1, Line2: b.Address.Line2, City: b.Address.City, Postcode: b.Address.Postcode, State: b.Address.State}, Email: b.Email, Phone: b.Phone, PaymentTermsDays: b.PaymentTermsDays, Currency: b.Currency, DefaultExpenseAccountID: b.DefaultExpenseAccountID, DefaultTaxCodeID: b.DefaultTaxCodeID, IsActive: active}}
// supplierTx runs fn in a transaction and answers the supplier it returns,// full account numbers only for verify_bank holders.func (h handlers) supplierTx(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Supplier, error)) { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } var out supplierDTO if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { s, err := fn(ctx, tx, who) if err != nil { return err } full, err := member.Can(ctx, tx, who, PermissionSupplierVerifyBank) if err != nil { return err } out = toSupplierDTO(ctx, tx, s, full) return nil }); err != nil { _ = c.Error(mapErr(err)) return } setETag(c, out.Revision) c.JSON(status, out)}
func (h handlers) listSuppliers(c *gin.Context) { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } out := []supplierDTO{} if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { ss, err := p.ListSuppliers(ctx, tx, p.SupplierFilter{Query: c.Query("q"), ActiveOnly: c.Query("active") == "true"}) if err != nil { return err } full, err := member.Can(ctx, tx, who, PermissionSupplierVerifyBank) for _, s := range ss { out = append(out, toSupplierDTO(ctx, tx, s, full)) } return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"suppliers": out})}
func (h handlers) createSupplier(c *gin.Context) { var body supplierBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } h.supplierTx(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Supplier, error) { return p.CreateSupplier(ctx, tx, h.deps.Clock, body.input()) })}
func (h handlers) getSupplier(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.supplierTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Supplier, error) { return p.GetSupplier(ctx, tx, id) })}
func (h handlers) updateSupplier(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body supplierBody if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.supplierTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Supplier, error) { return p.UpdateSupplier(ctx, tx, h.deps.Clock, id, rev, body.input()) })}
type bankBody struct { BankEntryID uuid.UUID `json:"bank_entry_id"` AccountNo string `json:"account_no"` AccountName string `json:"account_name"`}
func (h handlers) setSupplierBank(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body bankBody if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.supplierTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Supplier, error) { return p.SetBank(ctx, tx, h.deps.Clock, id, who, rev, p.Bank{EntryID: body.BankEntryID, AccountNo: body.AccountNo, AccountName: body.AccountName}) })}
func (h handlers) verifySupplierBank(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body struct{} if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.supplierTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Supplier, error) { return p.VerifyBank(ctx, tx, h.deps.Clock, id, who, rev) })}
func (h handlers) listBanks(c *gin.Context) { type bank struct { ID uuid.UUID `json:"id"` Key string `json:"key"` Name string `json:"name"` } out := []bank{} if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { es, err := payout.ListRegistryEntries(ctx, tx, p.BanksRegistry, true) for _, e := range es { out = append(out, bank{ID: e.ID, Key: e.Key, Name: e.Name}) } return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"banks": out})}
type balanceDTO struct { SupplierID uuid.UUID `json:"supplier_id"` AsOf string `json:"as_of"` Currency string `json:"currency"` Currencies []struct { Currency string `json:"currency"` Outstanding string `json:"outstanding"` OutstandingBase string `json:"outstanding_base"` } `json:"currencies"` Buckets p.BucketAmounts `json:"buckets"`}
// supplierBalance is Ruling P32: the aging for one supplier, by currency.func (h handlers) supplierBalance(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } asOf := clock.TodayUTC(h.deps.Clock) if s := c.Query("as_of"); s != "" { if asOf, err = parseDate(s); err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "as_of must be a date YYYY-MM-DD")) return } } var out balanceDTO if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { if _, err := p.GetSupplier(ctx, tx, id); err != nil { return err } a, err := p.AgingAsOf(ctx, tx, asOf, &id) if err != nil { return err } out = balanceDTO{SupplierID: id, AsOf: a.AsOf.Format(dateLayout), Currency: a.Currency, Buckets: a.Totals} sums := map[string][2]money.Amount{} var order []string for _, r := range a.Rows { v, ok := sums[r.Currency] if !ok { order, v = append(order, r.Currency), [2]money.Amount{money.Zero(), money.Zero()} } sums[r.Currency] = [2]money.Amount{v[0].Add(r.Outstanding), v[1].Add(r.OutstandingBase)} } for _, cur := range order { v := sums[cur] out.Currencies = append(out.Currencies, struct { Currency string `json:"currency"` Outstanding string `json:"outstanding"` OutstandingBase string `json:"outstanding_base"` }{cur, v[0].String(), v[1].String()}) } return nil }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out)}(supplier_handlers.go imports internal/kernel/clock and internal/kernel/money as well as those listed.)
routes.go Register (Task 12 part):
func Register(g *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} pl := permissionLoader{db: deps.DB} httpx.Protected(g, pl, "GET", "/payables/banks", PermissionSupplierRead, h.listBanks) httpx.Protected(g, pl, "GET", "/payables/suppliers", PermissionSupplierRead, h.listSuppliers) httpx.Protected(g, pl, "POST", "/payables/suppliers", PermissionSupplierManage, h.createSupplier) httpx.Protected(g, pl, "GET", "/payables/suppliers/:id", PermissionSupplierRead, h.getSupplier) httpx.Protected(g, pl, "PUT", "/payables/suppliers/:id", PermissionSupplierManage, h.updateSupplier) httpx.Protected(g, pl, "PUT", "/payables/suppliers/:id/bank", PermissionSupplierManage, h.setSupplierBank) httpx.Protected(g, pl, "POST", "/payables/suppliers/:id/bank/verify", PermissionSupplierVerifyBank, h.verifySupplierBank) httpx.Protected(g, pl, "GET", "/payables/suppliers/:id/balance", PermissionInvoiceRead, h.supplierBalance)}db/migrations/00305_payables_owner_grants.sql — the 00170 pattern (Ruling P3):
-- +goose Up-- AP (Ruling P3): a new Owner is seeded with every registered key, and a-- new Finance role with its registered set, by provisioning; an existing-- organization's roles never are (rolesStep seeds only what it creates).-- Runs as the migration role, which bypasses RLS.INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY['payables.supplier.read','payables.supplier.manage','payables.supplier.verify_bank', 'payables.invoice.read','payables.invoice.create','payables.invoice.transition', 'payables.payment.read','payables.payment.create','payables.payment.transition']) AS k WHERE r.system AND r.name = 'Owner'ON CONFLICT (role_id, permission_key) DO NOTHING;
-- Finance: the same set without verify_bank (Owner-only, D10's second-- person), plus the ledger read the AP forms need (Ruling P37).INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY['payables.supplier.read','payables.supplier.manage', 'payables.invoice.read','payables.invoice.create','payables.invoice.transition', 'payables.payment.read','payables.payment.create','payables.payment.transition', 'accounting.ledger.read']) AS k WHERE r.system AND r.name = 'Finance'ON CONFLICT (role_id, permission_key) DO NOTHING;
-- member.BumpPermVersion's contract (see 00170).UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner','Finance'));
-- +goose Down-- Removes the payables keys from both roles. accounting.ledger.read is left-- on Finance: it cannot be told apart from a grant an administrator made.DELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name IN ('Owner','Finance') AND rp.permission_key LIKE 'payables.%';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'));Wiring:
internal/app/app.go: import"github.com/mcom/erp/internal/modules/payables";DepsgainsPayables payables.Deps;Routercallspayables.Register(api, deps.Payables)afteradvances.Register.internal/app/routes_test.gocomposedModulesgains:"payables": {payables.PermissionSupplierRead, payables.PermissionSupplierManage, payables.PermissionSupplierVerifyBank,payables.PermissionInvoiceRead, payables.PermissionInvoiceCreate, payables.PermissionInvoiceTransition,payables.PermissionPaymentRead, payables.PermissionPaymentCreate, payables.PermissionPaymentTransition},cmd/api/main.go: import the module and setPayables: payables.Deps{DB: database, Clock: clk}besideAdvances:(same variable names theAdvancesentry uses).cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go:_ "github.com/mcom/erp/internal/modules/payables"beside the advances import.cmd/migrate/wiring_test.go,cmd/orgadmin/wiring_test.go: add the module path to the imports list and the nine keys (each with// modules/payables) to the expected-keys list, beside the advances ones.cmd/worker/wiring_test.go: addTestWorkerLinksThePayablesModule, the advances test with kinds{"purchase_invoice", Accrual}, {"supplier_credit_note", Accrual}, {"supplier_payment", Payout}and the nine keys.
api/openapi.yaml: add under paths: (after the /advances/summary block):
/payables/banks: get: operationId: listPayablesBanks summary: Banks a supplier can be paid at description: The active my_banks registry entries (Ruling P36). Requires payables.supplier.read. responses: '200': description: The banks content: application/json: schema: type: object required: [banks] properties: banks: type: array items: type: object required: [id, key, name] properties: id: { type: string, format: uuid } key: { type: string } name: { type: string } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } /payables/suppliers: get: operationId: listSuppliers summary: Suppliers, by code description: At most 1000, ordered by code (Ruling P30). Requires payables.supplier.read. parameters: - { name: q, in: query, required: false, schema: { type: string }, description: Matches code or legal name. } - { name: active, in: query, required: false, schema: { type: boolean }, description: true lists active suppliers only. } responses: '200': description: The suppliers content: application/json: schema: type: object required: [suppliers] properties: suppliers: { type: array, items: { $ref: '#/components/schemas/Supplier' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } post: operationId: createSupplier summary: Create a supplier description: A party (organization or individual) with the supplier role. Requires payables.supplier.manage. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SupplierInput' } responses: '201': description: The supplier headers: { ETag: { $ref: '#/components/headers/PayablesRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/Supplier' } } } '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/suppliers/{id}: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: The supplier's party id. } get: operationId: getSupplier summary: One supplier description: Account numbers are masked unless the caller holds payables.supplier.verify_bank. Requires payables.supplier.read. responses: '200': description: The supplier headers: { ETag: { $ref: '#/components/headers/PayablesRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/Supplier' } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '404': { $ref: '#/components/responses/PayablesNotFound' } put: operationId: updateSupplier summary: Replace a supplier's editable fields description: The whole editable shape (Ruling P38); If-Match is required. Requires payables.supplier.manage. parameters: - { $ref: '#/components/parameters/PayablesIfMatch' } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/SupplierInput' } } } responses: '200': description: The supplier headers: { ETag: { $ref: '#/components/headers/PayablesRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/Supplier' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '404': { $ref: '#/components/responses/PayablesNotFound' } '409': { $ref: '#/components/responses/PayablesConflict' } '422': { $ref: '#/components/responses/PayablesInvalid' } /payables/suppliers/{id}/bank: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } put: operationId: setSupplierBank summary: Change a supplier's bank details description: Clears any verification (D10); the previous details are kept for the verifier. Requires payables.supplier.manage. parameters: - { $ref: '#/components/parameters/PayablesIfMatch' } requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [bank_entry_id, account_no, account_name] properties: bank_entry_id: { type: string, format: uuid } account_no: { type: string, pattern: '^[0-9 ]{6,24}$' } account_name: { type: string, minLength: 1, maxLength: 200 } responses: '200': description: The supplier headers: { ETag: { $ref: '#/components/headers/PayablesRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/Supplier' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '404': { $ref: '#/components/responses/PayablesNotFound' } '409': { $ref: '#/components/responses/PayablesConflict' } '422': { $ref: '#/components/responses/PayablesInvalid' } /payables/suppliers/{id}/bank/verify: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } post: operationId: verifySupplierBank summary: Verify a supplier's bank details description: > A member other than the one who changed them (D10); the changer is 403 with code bank_verifier_is_changer. Requires payables.supplier.verify_bank. parameters: - { $ref: '#/components/parameters/PayablesIfMatch' } requestBody: required: true content: { application/json: { schema: { type: object, additionalProperties: false } } } responses: '200': description: The supplier headers: { ETag: { $ref: '#/components/headers/PayablesRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/Supplier' } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '404': { $ref: '#/components/responses/PayablesNotFound' } '409': { $ref: '#/components/responses/PayablesConflict' } /payables/suppliers/{id}/balance: parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } get: operationId: getSupplierBalance summary: What the organization owes one supplier description: The AP aging for one supplier, by currency (Ruling P32). Requires payables.invoice.read. parameters: - { name: as_of, in: query, required: false, schema: { $ref: '#/components/schemas/Date' }, description: Defaults to today. } responses: '200': description: The balance content: application/json: schema: type: object required: [supplier_id, as_of, currency, currencies, buckets] properties: supplier_id: { type: string, format: uuid } as_of: { $ref: '#/components/schemas/Date' } currency: { type: string, description: The base currency of buckets. } currencies: type: array items: type: object required: [currency, outstanding, outstanding_base] properties: currency: { type: string } outstanding: { $ref: '#/components/schemas/Money' } outstanding_base: { $ref: '#/components/schemas/Money' } buckets: { $ref: '#/components/schemas/AgingBuckets' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '404': { $ref: '#/components/responses/PayablesNotFound' } '422': { $ref: '#/components/responses/PayablesInvalid' }and under components: — responses: gains PayablesForbidden, PayablesNotFound, PayablesConflict, PayablesInvalid (each description + application/problem+json Problem, the ExpensesForbidden shape); parameters: gains
PayablesIfMatch: name: If-Match in: header required: true schema: { type: string } description: The ETag of the record being changed.headers: gains PayablesRevision: { description: The record's revision, quoted., schema: { type: string } }; schemas: gains:
SupplierAddress: type: object properties: line1: { type: string } line2: { type: string } city: { type: string } postcode: { type: string } state: { type: string } SupplierBank: type: object required: [bank_entry_id, bank_name, account_no, account_name] properties: bank_entry_id: { type: string, format: uuid } bank_name: { type: string } account_no: { type: string, description: Masked (••••1234) unless the caller holds payables.supplier.verify_bank. } account_name: { type: string } SupplierInput: type: object additionalProperties: false required: [code, party_kind, legal_name, country_code, payment_terms_days, currency] properties: code: { type: string, minLength: 1, maxLength: 32 } party_kind: { type: string, enum: [organization, individual] } legal_name: { type: string, minLength: 1, maxLength: 200 } tin: { type: string, nullable: true } registration_no: { type: string, nullable: true } country_code: { type: string, minLength: 2, maxLength: 2 } address: { $ref: '#/components/schemas/SupplierAddress' } email: { type: string, nullable: true } phone: { type: string, nullable: true } payment_terms_days: { type: integer, minimum: 0, maximum: 365 } currency: { type: string, minLength: 3, maxLength: 3 } default_expense_account_id: { type: string, format: uuid, nullable: true } default_tax_code_id: { type: string, format: uuid, nullable: true } is_active: { type: boolean, description: PUT only; absent means true. } Supplier: type: object required: [id, code, party_kind, legal_name, tin, registration_no, country_code, address, email, phone, payment_terms_days, currency, default_expense_account_id, default_tax_code_id, bank, previous_bank, bank_changed_by, bank_changed_at, bank_verified_by, bank_verified_at, bank_verified, is_active, revision] properties: id: { type: string, format: uuid } code: { type: string } party_kind: { type: string, enum: [organization, individual] } legal_name: { type: string } tin: { type: string, nullable: true } registration_no: { type: string, nullable: true } country_code: { type: string } address: { $ref: '#/components/schemas/SupplierAddress' } email: { type: string, nullable: true } phone: { type: string, nullable: true } payment_terms_days: { type: integer } currency: { type: string } default_expense_account_id: { type: string, format: uuid, nullable: true } default_tax_code_id: { type: string, format: uuid, nullable: true } bank: { allOf: [{ $ref: '#/components/schemas/SupplierBank' }], nullable: true } previous_bank: { allOf: [{ $ref: '#/components/schemas/SupplierBank' }], nullable: true } bank_changed_by: { type: string, format: uuid, nullable: true } bank_changed_at: { type: string, format: date-time, nullable: true } bank_verified_by: { type: string, format: uuid, nullable: true } bank_verified_at: { type: string, format: date-time, nullable: true } bank_verified: { type: boolean } is_active: { type: boolean } revision: { type: integer } AgingBuckets: type: object required: [current, days_1_30, days_31_60, days_61_90, over_90, total] properties: current: { $ref: '#/components/schemas/Money' } days_1_30: { $ref: '#/components/schemas/Money' } days_31_60: { $ref: '#/components/schemas/Money' } days_61_90: { $ref: '#/components/schemas/Money' } over_90: { $ref: '#/components/schemas/Money' } total: { $ref: '#/components/schemas/Money' }Run make generate.
-
Step 4: Run passing.
go test ./internal/modules/payables/... ./internal/app/... ./cmd/... ./internal/kernel/... -count=1→ PASS (TestEveryRouteIsAuthorized,TestEveryModuleContributesRoutesAndPermissions,TestTheOpenAPIDocumentSaysWhatTheRouterEnforces,TestEverySeededGraphPassesTheValidator,TestEverySeededGraphShipsAllThreeLocales, the wiring tests,TestEveryTenantQueryCarriesAnExplicitOrgPredicate). -
Step 5: Sabotage. In
rolesStep.Run, applyrolePermsto every seeded role rather than onlycreatedByName;TestRegisteredRolePermissionsReachOnlyARoleTheStepCreatesfails atan administrator's removal stands; restore. Delete the FinanceINSERTfrom00305;TestMigration00305…fails atpayables.invoice.transition; restore. AddPermissionSupplierVerifyBankto the Finance registration;TestANewOrgsFinanceRole…fails at the absence assertion; restore. Removepayables.Registerfromapp.Router;TestEveryModuleContributesRoutesAndPermissionsfails namingpayables, andTestTheOpenAPI…lists the eight routes as phantom; restore. Delete the payables import fromcmd/worker/main.go;TestWorkerLinksThePayablesModulefails; restore. -
Step 6: Commit.
git add -A && git commit -m "AP Task 12: payables module, permissions, wiring, supplier routes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 13: Invoice, credit note, allocation and payment routes
Section titled “Task 13: Invoice, credit note, allocation and payment routes”Files:
- Create:
internal/modules/payables/invoice_handlers.go,internal/modules/payables/payment_handlers.go - Modify:
internal/modules/payables/routes.go(Task 13 rows) - Modify:
api/openapi.yaml(paths and schemas below) - Test:
internal/modules/payables/invoice_handlers_test.go,internal/modules/payables/payment_handlers_test.go
Interfaces:
-
Consumes: Tasks 6, 9, 10’s platform functions;
workflow.Decisions(ctx, tx, instanceID);ledger.MapError. -
Produces: wire shapes
InvoiceView {invoice, lines, allocations, outstanding, outstanding_base, available_transitions, decisions},PaymentView {payment, lines, allocations, available_transitions, decisions},Allocation; request bodiesInvoiceInput,PaymentInput,DocumentTransition {from, to, reason},CreditNoteAllocationInput {credit_note_id, invoice_id, amount}. -
Step 1: Failing tests.
internal/modules/payables/invoice_handlers_test.go:
package payables_test
import ( "net/http" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
type invoiceView struct { Invoice struct { ID string `json:"id"` Reference string `json:"reference"` State string `json:"state"` GrossAmount string `json:"gross_amount"` Revision int `json:"revision"` } `json:"invoice"` Outstanding string `json:"outstanding"` AvailableTransitions []struct { To string `json:"to"` Allowed bool `json:"allowed"` } `json:"available_transitions"`}
func TestInvoiceRoundTripOverHTTP(t *testing.T) { e := newEnv(t) s := e.px.Supplier(t, "BEKAL") doc := e.px.Doc(t) body := map[string]any{ "kind": "purchase_invoice", "supplier_id": s.PartyID, "supplier_invoice_no": "INV-001", "invoice_date": "2026-09-21", "currency": "MYR", "document_id": doc, "document_basis": "relaxation_receipt", "relaxation_reason": "Paper invoice", "lines": []map[string]any{{"description": "Toner", "account_id": e.px.Account(t, "6140"), "net_amount": "100.00", "tax_amount": "0.00"}}, } v := decode[invoiceView](t, e.do(t, "POST", "/v1/payables/invoices", e.clerkTok, body), http.StatusCreated) require.Equal(t, "100.0000", v.Invoice.GrossAmount, "money is a string on the wire")
// A JSON number for money is refused (kernel/money). bad := map[string]any{} for k, val := range body { bad[k] = val } bad["supplier_invoice_no"] = "INV-002" bad["lines"] = []map[string]any{{"description": "Toner", "account_id": e.px.Account(t, "6140"), "net_amount": 100, "tax_amount": "0"}} require.Equal(t, http.StatusBadRequest, e.do(t, "POST", "/v1/payables/invoices", e.clerkTok, bad).Code)
// Duplicate number: 409 naming the existing document; a different number is accepted (presence first above). rec := e.do(t, "POST", "/v1/payables/invoices", e.clerkTok, body) require.Equal(t, http.StatusConflict, rec.Code) require.Contains(t, rec.Body.String(), v.Invoice.Reference)
sub := decode[invoiceView](t, e.do(t, "POST", "/v1/payables/invoices/"+v.Invoice.ID+"/transitions", e.clerkTok, map[string]any{"from": "draft", "to": "pending_review"}), http.StatusOK) require.Equal(t, "pending_review", sub.Invoice.State) // The preparer cannot post their own (they lack the grant); the approver can. require.Equal(t, http.StatusForbidden, e.do(t, "POST", "/v1/payables/invoices/"+v.Invoice.ID+"/transitions", e.clerkTok, map[string]any{"from": "pending_review", "to": "approved"}).Code) posted := decode[invoiceView](t, e.do(t, "POST", "/v1/payables/invoices/"+v.Invoice.ID+"/transitions", e.approverTok, map[string]any{"from": "pending_review", "to": "approved"}), http.StatusOK) require.Equal(t, "approved", posted.Invoice.State) require.Equal(t, "100.0000", posted.Outstanding) // Replay of the same move is 409 (Ruling P28), not a second posting. require.Equal(t, http.StatusConflict, e.do(t, "POST", "/v1/payables/invoices/"+v.Invoice.ID+"/transitions", e.approverTok, map[string]any{"from": "pending_review", "to": "approved"}).Code)}
func TestCreditNoteOffsetAndReverseOverHTTP(t *testing.T) { e := newEnv(t) s := e.px.Supplier(t, "BEKAL") inv := e.px.Post(t, e.px.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.px.Account(t, "6140"), "100.00"))) cn := e.px.Post(t, e.px.Invoice(t, payables.KindCreditNote, s, "CN-1", payablestest.Line(e.px.Account(t, "6140"), "30.00"))) a := decode[struct { ID string `json:"id"` }](t, e.do(t, "POST", "/v1/payables/allocations", e.clerkTok, map[string]any{"credit_note_id": cn.ID, "invoice_id": inv.ID, "amount": "30.00"}), http.StatusCreated) require.Equal(t, http.StatusUnprocessableEntity, e.do(t, "POST", "/v1/payables/allocations", e.clerkTok, map[string]any{"credit_note_id": cn.ID, "invoice_id": inv.ID, "amount": "1.00"}).Code, "nothing left on the credit note") decode[struct{}](t, e.do(t, "POST", "/v1/payables/allocations/"+a.ID+"/reverse", e.clerkTok, map[string]any{}), http.StatusCreated) require.Equal(t, http.StatusConflict, e.do(t, "POST", "/v1/payables/allocations/"+a.ID+"/reverse", e.clerkTok, map[string]any{}).Code) v := decode[invoiceView](t, e.do(t, "GET", "/v1/payables/invoices/"+inv.ID.String(), e.readerTok, nil), http.StatusOK) require.Equal(t, "100.0000", v.Outstanding)}internal/modules/payables/payment_handlers_test.go:
package payables_test
import ( "net/http" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
func TestPaymentOverHTTPIsRefusedUntilTheBankIsVerified(t *testing.T) { e := newEnv(t) s := e.px.Supplier(t, "BEKAL") inv := e.px.Post(t, e.px.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(e.px.Account(t, "6140"), "100.00"))) pv := decode[struct { Payment struct { ID string `json:"id"` State string `json:"state"` } `json:"payment"` }](t, e.do(t, "POST", "/v1/payables/payments", e.clerkTok, map[string]any{ "supplier_id": s.PartyID, "bank_account_id": e.px.Account(t, "1520"), "payment_date": "2026-09-21", "bank_reference": "DuitNow 1", "currency": "MYR", "amount": "100.00", "lines": []map[string]any{{"invoice_id": inv.ID, "amount": "100.00"}}, }), http.StatusCreated) path := "/v1/payables/payments/" + pv.Payment.ID + "/transitions" decode[struct{}](t, e.do(t, "POST", path, e.clerkTok, map[string]any{"from": "draft", "to": "pending_approval"}), http.StatusOK) rec := e.do(t, "POST", path, e.approverTok, map[string]any{"from": "pending_approval", "to": "paid"}) require.Equal(t, http.StatusConflict, rec.Code) require.Contains(t, rec.Body.String(), "bank_unverified") e.px.SetAndVerifyBank(t, s) paid := decode[struct { Payment struct { State string `json:"state"` } `json:"payment"` }](t, e.do(t, "POST", path, e.approverTok, map[string]any{"from": "pending_approval", "to": "paid"}), http.StatusOK) require.Equal(t, "paid", paid.Payment.State, "PRESENCE: approved once verified")}-
Step 2: Run — expect failure.
go test ./internal/modules/payables/ -run 'Invoice|CreditNote|Payment' -count=1→ FAIL: 404 on/v1/payables/invoices. -
Step 3: Implement.
invoice_handlers.go:
package payables
import ( "context" "net/http" "strconv" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/analytic" "github.com/mcom/erp/internal/platform/accounting/ledger" p "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
type shareDTO struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"`}
type lineDTO struct { LineNo int `json:"line_no"` Description string `json:"description"` AccountID uuid.UUID `json:"account_id"` TaxCodeID *uuid.UUID `json:"tax_code_id"` NetAmount money.Amount `json:"net_amount"` TaxAmount money.Amount `json:"tax_amount"` NetBase money.Amount `json:"net_base"` TaxBase money.Amount `json:"tax_base"` Shares []shareDTO `json:"shares"`}
type invoiceDTO struct { ID uuid.UUID `json:"id"` Kind string `json:"kind"` Reference string `json:"reference"` SupplierID uuid.UUID `json:"supplier_id"` SupplierInvoiceNo string `json:"supplier_invoice_no"` InvoiceDate string `json:"invoice_date"` DueOn string `json:"due_on"` Currency string `json:"currency"` FXRate money.Rate `json:"fx_rate"` GrossAmount money.Amount `json:"gross_amount"` GrossBase money.Amount `json:"gross_base"` DocumentID *uuid.UUID `json:"document_id"` DocumentBasis *string `json:"document_basis"` SupplierTIN *string `json:"supplier_tin"` EInvoiceUUID *string `json:"einvoice_uuid"` EInvoiceValidatedAt *time.Time `json:"einvoice_validated_at"` RelaxationReason *string `json:"relaxation_reason"` InstanceID uuid.UUID `json:"instance_id"` PreparedBy uuid.UUID `json:"prepared_by"` State string `json:"state"` Semantic posting.Semantic `json:"semantic"` Revision int `json:"revision"` CreatedAt time.Time `json:"created_at"` Outstanding money.Amount `json:"outstanding"`}
type allocationDTO struct { ID uuid.UUID `json:"id"` InvoiceID uuid.UUID `json:"invoice_id"` SourceKind string `json:"source_kind"` PaymentID *uuid.UUID `json:"payment_id"` CreditNoteID *uuid.UUID `json:"credit_note_id"` Amount money.Amount `json:"amount"` BaseAmount money.Amount `json:"base_amount"` Currency string `json:"currency"` EffectiveOn string `json:"effective_on"` ReversesID *uuid.UUID `json:"reverses_id"` ReversedByID *uuid.UUID `json:"reversed_by_id"` CreatedBy uuid.UUID `json:"created_by"` CreatedAt time.Time `json:"created_at"`}
// decisionDTO is the shape web/src/components/workflow/DecisionTimeline reads.type decisionDTO struct { From string `json:"from"` To string `json:"to"` ActionKey string `json:"action_key"` ActorMemberID uuid.UUID `json:"actor_member_id"` Reason string `json:"reason"` DecidedAt time.Time `json:"decided_at"`}
type invoiceViewDTO struct { Invoice invoiceDTO `json:"invoice"` Lines []lineDTO `json:"lines"` Allocations []allocationDTO `json:"allocations"` Outstanding money.Amount `json:"outstanding"` OutstandingBase money.Amount `json:"outstanding_base"` AvailableTransitions []workflow.Available `json:"available_transitions"` Decisions []decisionDTO `json:"decisions"`}
func toInvoiceDTO(v p.Invoice, outstanding money.Amount) invoiceDTO { return invoiceDTO{Outstanding: outstanding, ID: v.ID, Kind: v.Kind, Reference: v.Reference, SupplierID: v.SupplierID, SupplierInvoiceNo: v.SupplierInvoiceNo, InvoiceDate: v.InvoiceDate.Format(dateLayout), DueOn: v.DueOn.Format(dateLayout), Currency: v.Currency, FXRate: v.FXRate, GrossAmount: v.GrossAmount, GrossBase: v.GrossBase, DocumentID: v.DocumentID, DocumentBasis: v.DocumentBasis, SupplierTIN: v.SupplierTIN, EInvoiceUUID: v.EInvoiceUUID, EInvoiceValidatedAt: v.EInvoiceValidatedAt, RelaxationReason: v.RelaxationReason, InstanceID: v.InstanceID, PreparedBy: v.PreparedBy, State: v.State, Semantic: v.Semantic, Revision: v.Revision, CreatedAt: v.CreatedAt.UTC()}}
func toAllocationDTOs(as []p.Allocation) []allocationDTO { out := make([]allocationDTO, 0, len(as)) for _, a := range as { out = append(out, allocationDTO{ID: a.ID, InvoiceID: a.InvoiceID, SourceKind: a.SourceKind, PaymentID: a.PaymentID, CreditNoteID: a.CreditNoteID, Amount: a.Amount, BaseAmount: a.BaseAmount, Currency: a.Currency, EffectiveOn: a.EffectiveOn.Format(dateLayout), ReversesID: a.ReversesID, ReversedByID: a.ReversedByID, CreatedBy: a.CreatedBy, CreatedAt: a.CreatedAt.UTC()}) } return out}
func decisionsOf(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID) ([]decisionDTO, error) { ds, err := workflow.Decisions(ctx, tx, instanceID) out := make([]decisionDTO, 0, len(ds)) for _, d := range ds { out = append(out, decisionDTO{From: d.From, To: d.To, ActionKey: d.ActionKey, ActorMemberID: d.Actor, Reason: d.Reason, DecidedAt: d.DecidedAt.UTC()}) } return out, err}
func (h handlers) invoiceView(ctx context.Context, tx pgx.Tx, v p.Invoice, who uuid.UUID) (invoiceViewDTO, error) { out := invoiceViewDTO{Lines: []lineDTO{}} for _, l := range v.Lines { shares := make([]shareDTO, 0, len(l.Shares)) for _, s := range l.Shares { shares = append(shares, shareDTO{s.AnalyticAccountID, s.Percentage}) } out.Lines = append(out.Lines, lineDTO{l.LineNo, l.Description, l.AccountID, l.TaxCodeID, l.NetAmount, l.TaxAmount, l.NetBase, l.TaxBase, shares}) } as, err := p.Allocations(ctx, tx, v.ID) if err != nil { return out, err } out.Allocations = toAllocationDTOs(as) if out.Outstanding, out.OutstandingBase, err = p.InvoiceOutstanding(ctx, tx, v); err != nil { return out, err } out.Invoice = toInvoiceDTO(v, out.Outstanding) if out.AvailableTransitions, err = p.Available(ctx, tx, h.deps.Clock, v.InstanceID, who); err != nil { return out, err } out.Decisions, err = decisionsOf(ctx, tx, v.InstanceID) return out, err}
type lineBody struct { Description string `json:"description"` AccountID uuid.UUID `json:"account_id"` TaxCodeID *uuid.UUID `json:"tax_code_id"` NetAmount money.Amount `json:"net_amount"` TaxAmount money.Amount `json:"tax_amount"` Shares []shareDTO `json:"shares"`}
type invoiceBody struct { Kind string `json:"kind"` SupplierID uuid.UUID `json:"supplier_id"` SupplierInvoiceNo string `json:"supplier_invoice_no"` InvoiceDate string `json:"invoice_date"` DueOn *string `json:"due_on"` Currency string `json:"currency"` FXRate *money.Rate `json:"fx_rate"` DocumentID *uuid.UUID `json:"document_id"` DocumentBasis *string `json:"document_basis"` SupplierTIN *string `json:"supplier_tin"` EInvoiceUUID *string `json:"einvoice_uuid"` EInvoiceValidatedAt *time.Time `json:"einvoice_validated_at"` RelaxationReason *string `json:"relaxation_reason"` Lines []lineBody `json:"lines"`}
func (b invoiceBody) input() (p.InvoiceInput, error) { date, err := parseDate(b.InvoiceDate) if err != nil { return p.InvoiceInput{}, apperr.New(apperr.KindValidation, "field=invoice_date rule=date: a date YYYY-MM-DD") } in := p.InvoiceInput{Kind: b.Kind, SupplierID: b.SupplierID, SupplierInvoiceNo: b.SupplierInvoiceNo, InvoiceDate: date, Currency: b.Currency, FXRate: b.FXRate, DocumentID: b.DocumentID, DocumentBasis: b.DocumentBasis, SupplierTIN: b.SupplierTIN, EInvoiceUUID: b.EInvoiceUUID, EInvoiceValidatedAt: b.EInvoiceValidatedAt, RelaxationReason: b.RelaxationReason} if b.DueOn != nil { d, err := parseDate(*b.DueOn) if err != nil { return p.InvoiceInput{}, apperr.New(apperr.KindValidation, "field=due_on rule=date: a date YYYY-MM-DD") } in.DueOn = &d } for _, l := range b.Lines { li := p.LineInput{Description: l.Description, AccountID: l.AccountID, TaxCodeID: l.TaxCodeID, NetAmount: l.NetAmount, TaxAmount: l.TaxAmount} for _, s := range l.Shares { li.Shares = append(li.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } in.Lines = append(in.Lines, li) } return in, nil}
// invoiceTx runs fn, posting-aware (ledger.MapError on the commit error).func (h handlers) invoiceTx(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Invoice, error)) { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } var out invoiceViewDTO err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { v, err := fn(ctx, tx, who) if err != nil { return err } out, err = h.invoiceView(ctx, tx, v, who) return err }) if err = ledger.MapError(err); err != nil { _ = c.Error(mapErr(err)) return } setETag(c, out.Invoice.Revision) c.JSON(status, out)}
func (h handlers) createInvoice(c *gin.Context) { var body invoiceBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } in, err := body.input() if err != nil { _ = c.Error(err) return } h.invoiceTx(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Invoice, error) { return p.CreateInvoice(ctx, tx, h.deps.Clock, who, in) })}
func (h handlers) getInvoice(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.invoiceTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Invoice, error) { return p.GetInvoice(ctx, tx, id) })}
func (h handlers) updateInvoice(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body invoiceBody if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } in, err := body.input() if err != nil { _ = c.Error(err) return } h.invoiceTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Invoice, error) { return p.UpdateInvoice(ctx, tx, h.deps.Clock, id, rev, in) })}
type transitionBody struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"`}
func (h handlers) transitionInvoice(c *gin.Context) { id, err := parseID(c, "id") var body transitionBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.invoiceTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Invoice, error) { v, _, err := p.TransitionInvoice(ctx, tx, h.deps.Clock, p.TransitionInput{ID: id, From: body.From, To: body.To, Actor: who, Reason: body.Reason}) return v, err })}
func (h handlers) listInvoices(c *gin.Context) { f := p.InvoiceFilter{Kind: c.Query("kind"), State: c.Query("state"), Currency: c.Query("currency")} if s := c.Query("supplier_id"); s != "" { id, err := uuid.Parse(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "supplier_id must be a UUID")) return } f.SupplierID = &id } if s := c.Query("due_before"); s != "" { d, err := parseDate(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "due_before must be a date YYYY-MM-DD")) return } f.DueBefore = &d } if s := c.Query("limit"); s != "" { f.Limit, _ = strconv.Atoi(s) } if s := c.Query("cursor"); s != "" { cur, err := p.DecodeCursor(s) if err != nil { _ = c.Error(mapErr(err)) return } f.After = &cur } var out struct { Invoices []invoiceDTO `json:"invoices"` NextCursor *string `json:"next_cursor"` } out.Invoices = []invoiceDTO{} if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { vs, next, err := p.ListInvoices(ctx, tx, f) if err != nil { return err } // ponytail: one outstanding query per row (≤ 200 a page); a join in // ListInvoices when lists grow. for _, v := range vs { o, _, err := p.InvoiceOutstanding(ctx, tx, v) if err != nil { return err } out.Invoices = append(out.Invoices, toInvoiceDTO(v, o)) } if next != nil { s := next.Encode() out.NextCursor = &s } return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out)}
func (h handlers) listInvoiceAllocations(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out []allocationDTO if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { if _, err := p.GetInvoice(ctx, tx, id); err != nil { return err } as, err := p.Allocations(ctx, tx, id) out = toAllocationDTOs(as) return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"allocations": out})}
func (h handlers) allocationTx(c *gin.Context, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Allocation, error)) { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } var out []allocationDTO if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { a, err := fn(ctx, tx, who) out = toAllocationDTOs([]p.Allocation{a}) return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusCreated, out[0])}
func (h handlers) createAllocation(c *gin.Context) { var body struct { CreditNoteID uuid.UUID `json:"credit_note_id"` InvoiceID uuid.UUID `json:"invoice_id"` Amount money.Amount `json:"amount"` } if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } h.allocationTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Allocation, error) { return p.AllocateCreditNote(ctx, tx, h.deps.Clock, body.CreditNoteID, body.InvoiceID, who, body.Amount) })}
func (h handlers) reverseAllocation(c *gin.Context) { id, err := parseID(c, "id") var body struct{} if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.allocationTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Allocation, error) { return p.ReverseAllocation(ctx, tx, h.deps.Clock, id, who) })}payment_handlers.go:
package payables
import ( "context" "net/http" "strconv" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/ledger" p "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
type paymentLineDTO struct { InvoiceID uuid.UUID `json:"invoice_id"` Amount money.Amount `json:"amount"`}
type paymentDTO struct { ID uuid.UUID `json:"id"` Reference string `json:"reference"` SupplierID uuid.UUID `json:"supplier_id"` BankAccountID uuid.UUID `json:"bank_account_id"` PaymentDate string `json:"payment_date"` BankReference string `json:"bank_reference"` Currency string `json:"currency"` FXRate money.Rate `json:"fx_rate"` Amount money.Amount `json:"amount"` BaseAmount money.Amount `json:"base_amount"` InstanceID uuid.UUID `json:"instance_id"` PreparedBy uuid.UUID `json:"prepared_by"` State string `json:"state"` Semantic posting.Semantic `json:"semantic"` Revision int `json:"revision"` CreatedAt time.Time `json:"created_at"`}
type paymentViewDTO struct { Payment paymentDTO `json:"payment"` Lines []paymentLineDTO `json:"lines"` Allocations []allocationDTO `json:"allocations"` AvailableTransitions []workflow.Available `json:"available_transitions"` Decisions []decisionDTO `json:"decisions"`}
func toPaymentDTO(v p.Payment) paymentDTO { return paymentDTO{ID: v.ID, Reference: v.Reference, SupplierID: v.SupplierID, BankAccountID: v.BankAccountID, PaymentDate: v.PaymentDate.Format(dateLayout), BankReference: v.BankReference, Currency: v.Currency, FXRate: v.FXRate, Amount: v.Amount, BaseAmount: v.BaseAmount, InstanceID: v.InstanceID, PreparedBy: v.PreparedBy, State: v.State, Semantic: v.Semantic, Revision: v.Revision, CreatedAt: v.CreatedAt.UTC()}}
func (h handlers) paymentView(ctx context.Context, tx pgx.Tx, v p.Payment, who uuid.UUID) (paymentViewDTO, error) { out := paymentViewDTO{Payment: toPaymentDTO(v), Lines: []paymentLineDTO{}} for _, l := range v.Lines { out.Lines = append(out.Lines, paymentLineDTO{l.InvoiceID, l.Amount}) } as, err := p.Allocations(ctx, tx, v.ID) if err != nil { return out, err } out.Allocations = toAllocationDTOs(as) if out.AvailableTransitions, err = p.Available(ctx, tx, h.deps.Clock, v.InstanceID, who); err != nil { return out, err } out.Decisions, err = decisionsOf(ctx, tx, v.InstanceID) return out, err}
type paymentBody struct { SupplierID uuid.UUID `json:"supplier_id"` BankAccountID uuid.UUID `json:"bank_account_id"` PaymentDate string `json:"payment_date"` BankReference string `json:"bank_reference"` Currency string `json:"currency"` FXRate *money.Rate `json:"fx_rate"` Amount money.Amount `json:"amount"` Lines []paymentLineDTO `json:"lines"`}
func (b paymentBody) input() (p.PaymentInput, error) { d, err := parseDate(b.PaymentDate) if err != nil { return p.PaymentInput{}, apperr.New(apperr.KindValidation, "field=payment_date rule=date: a date YYYY-MM-DD") } in := p.PaymentInput{SupplierID: b.SupplierID, BankAccountID: b.BankAccountID, PaymentDate: d, BankReference: b.BankReference, Currency: b.Currency, FXRate: b.FXRate, Amount: b.Amount} for _, l := range b.Lines { in.Lines = append(in.Lines, p.PaymentLine{InvoiceID: l.InvoiceID, Amount: l.Amount}) } return in, nil}
func (h handlers) paymentTx(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Payment, error)) { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } var out paymentViewDTO err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { v, err := fn(ctx, tx, who) if err != nil { return err } out, err = h.paymentView(ctx, tx, v, who) return err }) if err = ledger.MapError(err); err != nil { _ = c.Error(mapErr(err)) return } setETag(c, out.Payment.Revision) c.JSON(status, out)}
func (h handlers) createPayment(c *gin.Context) { var body paymentBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } in, err := body.input() if err != nil { _ = c.Error(err) return } h.paymentTx(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Payment, error) { return p.CreatePayment(ctx, tx, h.deps.Clock, who, in) })}
func (h handlers) getPayment(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.paymentTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Payment, error) { return p.GetPayment(ctx, tx, id) })}
func (h handlers) updatePayment(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body paymentBody if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } in, err := body.input() if err != nil { _ = c.Error(err) return } h.paymentTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (p.Payment, error) { return p.UpdatePayment(ctx, tx, h.deps.Clock, id, rev, in) })}
func (h handlers) transitionPayment(c *gin.Context) { id, err := parseID(c, "id") var body transitionBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.paymentTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Payment, error) { v, _, err := p.TransitionPayment(ctx, tx, h.deps.Clock, p.TransitionInput{ID: id, From: body.From, To: body.To, Actor: who, Reason: body.Reason}) return v, err })}
func (h handlers) listPayments(c *gin.Context) { f := p.PaymentFilter{State: c.Query("state"), Currency: c.Query("currency")} if s := c.Query("supplier_id"); s != "" { id, err := uuid.Parse(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "supplier_id must be a UUID")) return } f.SupplierID = &id } if s := c.Query("limit"); s != "" { f.Limit, _ = strconv.Atoi(s) } if s := c.Query("cursor"); s != "" { cur, err := p.DecodeCursor(s) if err != nil { _ = c.Error(mapErr(err)) return } f.After = &cur } var out struct { Payments []paymentDTO `json:"payments"` NextCursor *string `json:"next_cursor"` } out.Payments = []paymentDTO{} if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { vs, next, err := p.ListPayments(ctx, tx, f) for _, v := range vs { out.Payments = append(out.Payments, toPaymentDTO(v)) } if next != nil { s := next.Encode() out.NextCursor = &s } return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out)}routes.go — append:
httpx.Protected(g, pl, "GET", "/payables/invoices", PermissionInvoiceRead, h.listInvoices) httpx.Protected(g, pl, "POST", "/payables/invoices", PermissionInvoiceCreate, h.createInvoice) httpx.Protected(g, pl, "GET", "/payables/invoices/:id", PermissionInvoiceRead, h.getInvoice) httpx.Protected(g, pl, "PUT", "/payables/invoices/:id", PermissionInvoiceCreate, h.updateInvoice) httpx.Protected(g, pl, "POST", "/payables/invoices/:id/transitions", PermissionInvoiceTransition, h.transitionInvoice) httpx.Protected(g, pl, "GET", "/payables/invoices/:id/allocations", PermissionInvoiceRead, h.listInvoiceAllocations) httpx.Protected(g, pl, "POST", "/payables/allocations", PermissionInvoiceCreate, h.createAllocation) httpx.Protected(g, pl, "POST", "/payables/allocations/:id/reverse", PermissionInvoiceCreate, h.reverseAllocation) httpx.Protected(g, pl, "GET", "/payables/payments", PermissionPaymentRead, h.listPayments) httpx.Protected(g, pl, "POST", "/payables/payments", PermissionPaymentCreate, h.createPayment) httpx.Protected(g, pl, "GET", "/payables/payments/:id", PermissionPaymentRead, h.getPayment) httpx.Protected(g, pl, "PUT", "/payables/payments/:id", PermissionPaymentCreate, h.updatePayment) httpx.Protected(g, pl, "POST", "/payables/payments/:id/transitions", PermissionPaymentTransition, h.transitionPayment)api/openapi.yaml — the paths in the same shape as Task 12’s (each operation: operationId, summary, a description ending “Requires requestBody with additionalProperties: false, and 200/201 + 400/401/403/404/409/422 using the Payables* responses):
| Path | Operations (operationId) | Body / response schemas |
|---|---|---|
/payables/invoices |
listPurchaseInvoices (query kind enum [purchase_invoice, supplier_credit_note], supplier_id, state, due_before Date, currency, limit 1–200, cursor), createPurchaseInvoice |
PurchaseInvoiceInput → PurchaseInvoiceView; list → {invoices: PurchaseInvoice[], next_cursor: string|null} |
/payables/invoices/{id} |
getPurchaseInvoice, updatePurchaseInvoice (If-Match) |
PurchaseInvoiceInput → PurchaseInvoiceView |
/payables/invoices/{id}/transitions |
transitionPurchaseInvoice |
DocumentTransition → PurchaseInvoiceView; 409 also “has_allocations”, 403 “self_approval” |
/payables/invoices/{id}/allocations |
listPurchaseInvoiceAllocations |
→ {allocations: ApAllocation[]} |
/payables/allocations |
createApAllocation |
CreditNoteAllocationInput → 201 ApAllocation |
/payables/allocations/{id}/reverse |
reverseApAllocation |
{} → 201 ApAllocation |
/payables/payments |
listSupplierPayments (query supplier_id, state, currency, limit, cursor; “due before” does not apply to a payment — say so in the description), createSupplierPayment |
SupplierPaymentInput → SupplierPaymentView; list → {payments: SupplierPayment[], next_cursor} |
/payables/payments/{id} |
getSupplierPayment, updateSupplierPayment (If-Match) |
SupplierPaymentInput → SupplierPaymentView |
/payables/payments/{id}/transitions |
transitionSupplierPayment |
DocumentTransition → SupplierPaymentView; 409 also “bank_unverified” |
Schemas (under components.schemas), property for property the DTOs above; money as $ref: Money, rates as $ref: Rate, dates as $ref: Date:
PurchaseInvoiceLineInput: type: object additionalProperties: false required: [description, account_id, net_amount, tax_amount] properties: description: { type: string, minLength: 1, maxLength: 500 } account_id: { type: string, format: uuid } tax_code_id: { type: string, format: uuid, nullable: true } net_amount: { $ref: '#/components/schemas/Money' } tax_amount: { $ref: '#/components/schemas/Money' } shares: type: array items: type: object additionalProperties: false required: [analytic_account_id, percentage] properties: analytic_account_id: { type: string, format: uuid } percentage: { $ref: '#/components/schemas/Rate' } PurchaseInvoiceInput: type: object additionalProperties: false required: [kind, supplier_id, supplier_invoice_no, invoice_date, currency, lines] properties: kind: { type: string, enum: [purchase_invoice, supplier_credit_note], description: Read on create; ignored on update. } supplier_id: { type: string, format: uuid } supplier_invoice_no: { type: string, minLength: 1, maxLength: 64 } invoice_date: { $ref: '#/components/schemas/Date' } due_on: { allOf: [{ $ref: '#/components/schemas/Date' }], nullable: true, description: Absent - invoice date plus the supplier's terms (invoices); the document date (credit notes). } currency: { type: string, minLength: 3, maxLength: 3 } fx_rate: { allOf: [{ $ref: '#/components/schemas/Rate' }], nullable: true, description: Required when currency is not the base currency. } document_id: { type: string, format: uuid, nullable: true } document_basis: { type: string, enum: [einvoice_company, foreign_supplier, relaxation_receipt], nullable: true } supplier_tin: { type: string, nullable: true } einvoice_uuid: { type: string, nullable: true } einvoice_validated_at: { type: string, format: date-time, nullable: true } relaxation_reason: { type: string, nullable: true } lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PurchaseInvoiceLineInput' } } PurchaseInvoice: type: object required: [id, kind, reference, supplier_id, supplier_invoice_no, invoice_date, due_on, currency, fx_rate, gross_amount, gross_base, document_id, document_basis, supplier_tin, einvoice_uuid, einvoice_validated_at, relaxation_reason, instance_id, prepared_by, state, semantic, revision, created_at, outstanding] properties: outstanding: { $ref: '#/components/schemas/Money', description: Gross less allocations (a credit note - what it has left). } id: { type: string, format: uuid } kind: { type: string, enum: [purchase_invoice, supplier_credit_note] } reference: { type: string } supplier_id: { type: string, format: uuid } supplier_invoice_no: { type: string } invoice_date: { $ref: '#/components/schemas/Date' } due_on: { $ref: '#/components/schemas/Date' } currency: { type: string } fx_rate: { $ref: '#/components/schemas/Rate' } gross_amount: { $ref: '#/components/schemas/Money' } gross_base: { $ref: '#/components/schemas/Money' } document_id: { type: string, format: uuid, nullable: true } document_basis: { type: string, nullable: true } supplier_tin: { type: string, nullable: true } einvoice_uuid: { type: string, nullable: true } einvoice_validated_at: { type: string, format: date-time, nullable: true } relaxation_reason: { type: string, nullable: true } instance_id: { type: string, format: uuid } prepared_by: { type: string, format: uuid } state: { type: string } semantic: { $ref: '#/components/schemas/WorkflowSemantic' } revision: { type: integer } created_at: { type: string, format: date-time } PurchaseInvoiceLine: type: object required: [line_no, description, account_id, tax_code_id, net_amount, tax_amount, net_base, tax_base, shares] properties: line_no: { type: integer } description: { type: string } account_id: { type: string, format: uuid } tax_code_id: { type: string, format: uuid, nullable: true } net_amount: { $ref: '#/components/schemas/Money' } tax_amount: { $ref: '#/components/schemas/Money' } net_base: { $ref: '#/components/schemas/Money' } tax_base: { $ref: '#/components/schemas/Money' } shares: type: array items: type: object required: [analytic_account_id, percentage] properties: analytic_account_id: { type: string, format: uuid } percentage: { $ref: '#/components/schemas/Rate' } ApAllocation: type: object required: [id, invoice_id, source_kind, payment_id, credit_note_id, amount, base_amount, currency, effective_on, reverses_id, reversed_by_id, created_by, created_at] properties: id: { type: string, format: uuid } invoice_id: { type: string, format: uuid } source_kind: { type: string, enum: [payment, credit_note] } payment_id: { type: string, format: uuid, nullable: true } credit_note_id: { type: string, format: uuid, nullable: true } amount: { $ref: '#/components/schemas/Money' } base_amount: { $ref: '#/components/schemas/Money' } currency: { type: string } effective_on: { $ref: '#/components/schemas/Date' } reverses_id: { type: string, format: uuid, nullable: true } reversed_by_id: { type: string, format: uuid, nullable: true } created_by: { type: string, format: uuid } created_at: { type: string, format: date-time } DocumentDecision: type: object required: [from, to, action_key, actor_member_id, reason, decided_at] properties: from: { type: string } to: { type: string } action_key: { type: string } actor_member_id: { type: string, format: uuid } reason: { type: string } decided_at: { type: string, format: date-time } PurchaseInvoiceView: type: object required: [invoice, lines, allocations, outstanding, outstanding_base, available_transitions, decisions] properties: invoice: { $ref: '#/components/schemas/PurchaseInvoice' } lines: { type: array, items: { $ref: '#/components/schemas/PurchaseInvoiceLine' } } allocations: { type: array, items: { $ref: '#/components/schemas/ApAllocation' } } outstanding: { $ref: '#/components/schemas/Money' } outstanding_base: { $ref: '#/components/schemas/Money' } available_transitions: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } } decisions: { type: array, items: { $ref: '#/components/schemas/DocumentDecision' } } DocumentTransition: type: object additionalProperties: false required: [from, to] properties: from: { type: string, description: The state the caller believes the document is in. } to: { type: string } reason: { type: string } CreditNoteAllocationInput: type: object additionalProperties: false required: [credit_note_id, invoice_id, amount] properties: credit_note_id: { type: string, format: uuid } invoice_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } SupplierPaymentLine: type: object additionalProperties: false required: [invoice_id, amount] properties: invoice_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } SupplierPaymentInput: type: object additionalProperties: false required: [supplier_id, bank_account_id, payment_date, currency, amount, lines] properties: supplier_id: { type: string, format: uuid } bank_account_id: { type: string, format: uuid } payment_date: { $ref: '#/components/schemas/Date' } bank_reference: { type: string, maxLength: 64 } currency: { type: string, minLength: 3, maxLength: 3 } fx_rate: { allOf: [{ $ref: '#/components/schemas/Rate' }], nullable: true } amount: { $ref: '#/components/schemas/Money' } lines: { type: array, minItems: 1, items: { $ref: '#/components/schemas/SupplierPaymentLine' } } SupplierPayment: type: object required: [id, reference, supplier_id, bank_account_id, payment_date, bank_reference, currency, fx_rate, amount, base_amount, instance_id, prepared_by, state, semantic, revision, created_at] properties: id: { type: string, format: uuid } reference: { type: string } supplier_id: { type: string, format: uuid } bank_account_id: { type: string, format: uuid } payment_date: { $ref: '#/components/schemas/Date' } bank_reference: { type: string } currency: { type: string } fx_rate: { $ref: '#/components/schemas/Rate' } amount: { $ref: '#/components/schemas/Money' } base_amount: { $ref: '#/components/schemas/Money' } instance_id: { type: string, format: uuid } prepared_by: { type: string, format: uuid } state: { type: string } semantic: { $ref: '#/components/schemas/WorkflowSemantic' } revision: { type: integer } created_at: { type: string, format: date-time } SupplierPaymentView: type: object required: [payment, lines, allocations, available_transitions, decisions] properties: payment: { $ref: '#/components/schemas/SupplierPayment' } lines: { type: array, items: { $ref: '#/components/schemas/SupplierPaymentLine' } } allocations: { type: array, items: { $ref: '#/components/schemas/ApAllocation' } } available_transitions: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } } decisions: { type: array, items: { $ref: '#/components/schemas/DocumentDecision' } }make generate.
-
Step 4: Run passing.
go test ./internal/modules/payables/... ./internal/app/... -count=1→ PASS. -
Step 5: Sabotage. Delete
mapErr’sworkflow.ErrStaleStatecase;TestInvoiceRoundTripOverHTTP’s replay answers 500 instead of 409 — read it, restore. DeletemapErr’sp.ErrBankUnverifiedcase;TestPaymentOverHTTP…answers 500 withoutbank_unverified; restore. -
Step 6: Commit.
git add -A && git commit -m "AP Task 13: invoice, credit note, allocation and payment routes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 14: AP aging report — JSON and export (CSV/XLSX/PDF)
Section titled “Task 14: AP aging report — JSON and export (CSV/XLSX/PDF)”Files:
- Create:
db/migrations/00306_report_export_ap_aging.sql - Create:
internal/modules/reports/aging_handlers.go - Modify:
internal/modules/reports/routes.go,internal/platform/reporting/export/{export.go,params.go,project.go} - Modify:
api/openapi.yaml(/reports/ap-aging;ReportExportRequest.reportenum gainsap_aging) - Test:
internal/modules/reports/aging_handlers_test.go,internal/platform/reporting/export/apaging_internal_test.go,internal/platform/reporting/export/params_test.go(append)
Interfaces:
-
Consumes:
payables.AgingAsOf,payables.Buckets,render.Writer, the reports module’sallowQuery,dateParam,db.InReadOnlyTx. -
Produces:
GET /reports/ap-aging?as_of=(reports.read) →ApAgingReport;export.ReportAPAging = "ap_aging"taking{as_of};projectAPAging. -
Step 1: Failing tests.
Append to internal/platform/reporting/export/params_test.go:
func TestAPAgingTakesAsOfOnly(t *testing.T) { require.NoError(t, export.Params{AsOf: "2026-09-30"}.Validate(export.ReportAPAging)) var bad *export.InvalidParamsError require.ErrorAs(t, export.Params{From: "2026-09-01", AsOf: "2026-09-30"}.Validate(export.ReportAPAging), &bad) require.Equal(t, "from", bad.Field) require.ErrorAs(t, export.Params{}.Validate(export.ReportAPAging), &bad) require.Equal(t, "as_of", bad.Field)}internal/platform/reporting/export/apaging_internal_test.go (package export, so it can drive the unexported project directly over a real AP world):
package export
import ( "context" "strings" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/reporting/render")
func TestTheAPAgingExportCarriesEveryOpenDocument(t *testing.T) { px := payablestest.New(t) px.UsePayablesPoster(t) s := px.Supplier(t, "BEKAL") px.Post(t, px.Invoice(t, payables.KindInvoice, s, "INV-1", payablestest.Line(px.Account(t, "6140"), "100.00"))) w, _, _, err := render.New(render.FormatCSV) require.NoError(t, err) var n int64 px.Tx(t, func(ctx context.Context, tx pgx.Tx) error { n, err = project(ctx, tx, px.Clock, Export{ID: uuid.New(), Report: ReportAPAging, Format: render.FormatCSV, Params: Params{AsOf: "2026-11-21"}}, w) return err }) require.Equal(t, int64(1), n, "PRESENCE: one open invoice") body, err := w.Finish() require.NoError(t, err) csv := string(body) require.Contains(t, csv, "AP aging") require.Contains(t, csv, "BEKAL") require.True(t, strings.Contains(csv, "100.00"), "%s", csv)}internal/modules/reports/aging_handlers_test.go (the module’s newEnv world holds no AP documents, so this pins the route, its permission and its empty shape; the figures are pinned by Task 11 and the export test above):
package reports_test
import ( "encoding/json" "net/http" "testing"
"github.com/stretchr/testify/require")
func TestAPAgingRouteAndPermission(t *testing.T) { e := newEnv(t) w := e.get(t, "/v1/reports/ap-aging?as_of=2026-11-21", e.readerTok) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) // PRESENCE var got struct { AsOf string `json:"as_of"` Currency string `json:"currency"` Rows []json.RawMessage `json:"rows"` Totals struct { Total string `json:"total"` } `json:"totals"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) require.Equal(t, "2026-11-21", got.AsOf) require.NotNil(t, got.Rows, "rows is [] not null") require.Equal(t, "0.0000", got.Totals.Total) require.Equal(t, http.StatusForbidden, e.get(t, "/v1/reports/ap-aging", e.strangerTok).Code) require.Equal(t, http.StatusUnprocessableEntity, e.get(t, "/v1/reports/ap-aging?as_of=soon", e.readerTok).Code)}-
Step 2: Run — expect failure.
go test ./internal/platform/reporting/export/ ./internal/modules/reports/ -run 'APAging' -count=1→ FAIL:undefined: export.ReportAPAging. -
Step 3: Implement.
db/migrations/00306_report_export_ap_aging.sql:
-- +goose UpALTER TABLE report_export DROP CONSTRAINT report_export_report_known, ADD CONSTRAINT report_export_report_known CHECK (report IN ('trial_balance','profit_and_loss','balance_sheet','general_ledger','ap_aging'));-- +goose DownALTER TABLE report_export DROP CONSTRAINT report_export_report_known, ADD CONSTRAINT report_export_report_known CHECK (report IN ('trial_balance','profit_and_loss','balance_sheet','general_ledger'));export.go: add ReportAPAging = "ap_aging" to the report constants. params.go: allowed gains ReportAPAging: {"as_of": true}, and Validate’s balance-sheet branch becomes if report == ReportBalanceSheet || report == ReportAPAging {. project.go: reportTitles gains ReportAPAging: "AP aging"; in meta, if x.Report == ReportBalanceSheet { becomes if x.Report == ReportBalanceSheet || x.Report == ReportAPAging {; the dispatch switch gains case ReportAPAging: return projectAPAging(ctx, tx, c, x, w); and:
func projectAPAging(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, w render.Writer) (int64, error) { a, err := payables.AgingAsOf(ctx, tx, x.Params.AsOfDate(), nil) if err != nil { return 0, err } m, err := meta(ctx, tx, c, x, a.Currency, false) if err != nil { return 0, err } m.Landscape = true money := func(t string) render.Column { return render.Column{Title: t, Numeric: true, Weight: 8} } if err := w.Begin(m, []render.Column{{Title: "Supplier", Weight: 12}, {Title: "Document", Weight: 7}, {Title: "Supplier's no.", Weight: 8}, {Title: "Due", Weight: 6}, {Title: "Days past due", Weight: 4}, {Title: "Currency", Weight: 3}, money("Outstanding"), money("Current"), money("1–30"), money("31–60"), money("61–90"), money("Over 90")}); err != nil { return 0, err } blank := render.Cell{} for _, r := range a.Rows { cells := []render.Cell{render.Text(r.SupplierCode + " " + r.SupplierName), render.Text(r.Reference), render.Text(r.SupplierDocumentNo), render.Text(r.DueOn.Format("2006-01-02")), render.Text(strconv.Itoa(r.DaysPastDue)), render.Text(r.Currency), render.Money(r.Outstanding)} for _, b := range payables.Buckets { if b == r.Bucket { cells = append(cells, render.Money(r.OutstandingBase)) } else { cells = append(cells, blank) } } if err := w.Row(cells); err != nil { return 0, err } } t := a.Totals bold := func(v money.Amount) render.Cell { return render.Bold(render.Money(v)) } return int64(len(a.Rows)), w.Row([]render.Cell{render.Bold(render.Text("Total (" + a.Currency + ")")), blank, blank, blank, blank, blank, bold(t.Total), bold(t.Current), bold(t.D1To30), bold(t.D31To60), bold(t.D61To90), bold(t.Over90)})}(Rename the local money column helper to moneyCol to avoid shadowing the money package; import internal/platform/payables and strconv.)
aging_handlers.go:
package reports
import ( "context" "net/http"
"github.com/gin-gonic/gin" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/payables")
type agingRowDTO struct { DocumentID string `json:"document_id"` Kind string `json:"kind"` Reference string `json:"reference"` SupplierDocumentNo string `json:"supplier_document_no"` SupplierID string `json:"supplier_id"` SupplierCode string `json:"supplier_code"` SupplierName string `json:"supplier_name"` Currency string `json:"currency"` DocumentDate string `json:"document_date"` DueOn string `json:"due_on"` DaysPastDue int `json:"days_past_due"` Bucket string `json:"bucket"` Outstanding string `json:"outstanding"` OutstandingBase string `json:"outstanding_base"`}
type agingSupplierDTO struct { SupplierID string `json:"supplier_id"` Code string `json:"code"` Name string `json:"name"` Buckets payables.BucketAmounts `json:"buckets"`}
func (h handlers) getAPAging(c *gin.Context) { if err := allowQuery(c, "AP aging", "as_of"); err != nil { _ = c.Error(err) return } asOf, err := dateParam(c, "as_of", false) if err != nil { _ = c.Error(err) return } day := clock.TodayUTC(h.deps.Clock) if asOf != nil { day = *asOf } var out struct { AsOf string `json:"as_of"` Currency string `json:"currency"` Rows []agingRowDTO `json:"rows"` Suppliers []agingSupplierDTO `json:"suppliers"` Totals payables.BucketAmounts `json:"totals"` } err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { a, err := payables.AgingAsOf(ctx, tx, day, nil) if err != nil { return err } out.AsOf, out.Currency, out.Totals = a.AsOf.Format("2006-01-02"), a.Currency, a.Totals out.Rows, out.Suppliers = []agingRowDTO{}, []agingSupplierDTO{} for _, r := range a.Rows { out.Rows = append(out.Rows, agingRowDTO{r.DocumentID.String(), r.Kind, r.Reference, r.SupplierDocumentNo, r.SupplierID.String(), r.SupplierCode, r.SupplierName, r.Currency, r.DocumentDate.Format("2006-01-02"), r.DueOn.Format("2006-01-02"), r.DaysPastDue, r.Bucket, r.Outstanding.String(), r.OutstandingBase.String()}) } for _, s := range a.Suppliers { out.Suppliers = append(out.Suppliers, agingSupplierDTO{s.SupplierID.String(), s.Code, s.Name, s.Buckets}) } return nil }) if err != nil { _ = c.Error(err) return } c.JSON(http.StatusOK, out)}routes.go: httpx.Protected(g, p, "GET", "/reports/ap-aging", read, h.getAPAging) after /reports/general-ledger. permissions.go’s reports.read description gains “, and AP aging”.
api/openapi.yaml: path /reports/ap-aging (getApAging, query as_of Date optional “Defaults to today”, 200 → ApAgingReport, 401, 403, 422), ReportExportRequest.report enum gains ap_aging and its description notes “ap_aging takes as_of only”; schemas:
ApAgingRow: type: object required: [document_id, kind, reference, supplier_document_no, supplier_id, supplier_code, supplier_name, currency, document_date, due_on, days_past_due, bucket, outstanding, outstanding_base] properties: document_id: { type: string, format: uuid } kind: { type: string, enum: [purchase_invoice, supplier_credit_note] } reference: { type: string } supplier_document_no: { type: string } supplier_id: { type: string, format: uuid } supplier_code: { type: string } supplier_name: { type: string } currency: { type: string } document_date: { $ref: '#/components/schemas/Date' } due_on: { $ref: '#/components/schemas/Date' } days_past_due: { type: integer } bucket: { type: string, enum: [current, days_1_30, days_31_60, days_61_90, over_90] } outstanding: { $ref: '#/components/schemas/Money' } outstanding_base: { $ref: '#/components/schemas/Money' } ApAgingReport: type: object required: [as_of, currency, rows, suppliers, totals] properties: as_of: { $ref: '#/components/schemas/Date' } currency: { type: string } rows: { type: array, items: { $ref: '#/components/schemas/ApAgingRow' } } suppliers: type: array items: type: object required: [supplier_id, code, name, buckets] properties: supplier_id: { type: string, format: uuid } code: { type: string } name: { type: string } buckets: { $ref: '#/components/schemas/AgingBuckets' } totals: { $ref: '#/components/schemas/AgingBuckets' }make generate.
-
Step 4: Run passing.
go test ./internal/platform/reporting/... ./internal/modules/reports/... ./internal/app/... -count=1→ PASS (TestReportingNeverWritesstill passes:AgingAsOflives inpayablesand only reads). -
Step 5: Sabotage. Drop the
case ReportAPAgingline fromproject’s switch;TestTheAPAgingExport…fails withErrUnknownReport; restore. Remove'ap_aging'from the migration’s CHECK;go test ./internal/modules/reports/ -run TestExportRequestsAreValidatedstays green, so add one line to that test — a request withreport: "ap_aging", format: "csv", params: {as_of: "2026-11-21"}expecting 202 — and watch it fail withreport_export_report_known; restore the CHECK. -
Step 6: Commit.
git add -A && git commit -m "AP Task 14: AP aging report, JSON and export" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 15: Web — payables API hooks, translations, suppliers screen, nav
Section titled “Task 15: Web — payables API hooks, translations, suppliers screen, nav”Files:
- Create:
web/src/features/payables/api.ts,web/src/features/payables/SuppliersPage.tsx,web/src/features/payables/SupplierDialog.tsx,web/src/features/payables/SupplierDrawer.tsx,web/src/features/payables/routes.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/payables.json(every key Tasks 15–17 use) - Modify:
web/src/app/routes.ts(one import, one spread),web/src/app/shell/nav.ts(Suppliers item) - Test:
web/src/features/payables/__tests__/SuppliersPage.test.tsx(the nav assertion over all three AP items is Task 17’s)
Interfaces:
- Consumes:
api(@/lib/api/client),unwrap,problemMessage,Schemas(generated by Tasks 12–14),DataTablewithtableId,Drawer,Dialog,Field,Input,Select,Tabs,PageHeader,EmptyState,MoneyText,useCan,RequirePermission,appRoute. - Produces (TS):
export type Supplier = Schemas['Supplier'];export type PurchaseInvoice = Schemas['PurchaseInvoice'];export type PurchaseInvoiceView = Schemas['PurchaseInvoiceView'];export type SupplierPayment = Schemas['SupplierPayment'];export type SupplierPaymentView = Schemas['SupplierPaymentView'];export type InvoiceKind = PurchaseInvoice['kind'];export const payablesKeys: { all, suppliers, supplier(id), banks, invoices(f), invoice(id), payments, payment(id), aging(asOf) };export function useSuppliers(): UseQueryResult<Supplier[]>;export function useSupplier(id: string): UseQueryResult<Supplier>;export function useBanks(): UseQueryResult<{ id: string; key: string; name: string }[]>;export function useSaveSupplier(): UseMutationResult<Supplier, unknown, { id?: string; revision?: number; body: Schemas['SupplierInput'] }>;export function useSetBank(id: string): UseMutationResult<Supplier, unknown, { revision: number; bank_entry_id: string; account_no: string; account_name: string }>;export function useVerifyBank(id: string): UseMutationResult<Supplier, unknown, { revision: number }>;export function useInvoices(f: { kind: InvoiceKind; supplier_id?: string; state?: string }): UseInfiniteQueryResult<PurchaseInvoice[]>;export function useInvoice(id: string): UseQueryResult<PurchaseInvoiceView>;export function useSaveInvoice(): UseMutationResult<PurchaseInvoiceView, unknown, { id?: string; revision?: number; body: Schemas['PurchaseInvoiceInput'] }>;export function useInvoiceTransition(id?: string): UseMutationResult<PurchaseInvoiceView, unknown, Schemas['DocumentTransition'] & { id?: string }>;export function useAllocate(): UseMutationResult<Schemas['ApAllocation'], unknown, Schemas['CreditNoteAllocationInput']>;export function useReverseAllocation(): UseMutationResult<Schemas['ApAllocation'], unknown, string>;export function usePayments(): UseInfiniteQueryResult<SupplierPayment[]>;export function usePayment(id: string): UseQueryResult<SupplierPaymentView>;export function useSavePayment(): UseMutationResult<SupplierPaymentView, unknown, { id?: string; revision?: number; body: Schemas['SupplierPaymentInput'] }>;export function usePaymentTransition(id?: string): UseMutationResult<SupplierPaymentView, unknown, Schemas['DocumentTransition'] & { id?: string }>;export function useAPAging(asOf: string): UseQueryResult<Schemas['ApAgingReport']>;- Step 1: Failing tests.
web/src/features/payables/__tests__/SuppliersPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const supplier = (over: Record<string, unknown> = {}) => ({ id: 's1', code: 'BEKAL', party_kind: 'organization', legal_name: 'Bekalan Sdn Bhd', tin: null, registration_no: null, country_code: 'MY', address: {}, email: null, phone: null, payment_terms_days: 30, currency: 'MYR', default_expense_account_id: null, default_tax_code_id: null, bank: { bank_entry_id: 'b1', bank_name: 'Maybank', account_no: '••••3456', account_name: 'Bekalan Sdn Bhd' }, previous_bank: null, bank_changed_by: 'm-clerk', bank_changed_at: '2026-09-21T01:00:00Z', bank_verified_by: null, bank_verified_at: null, bank_verified: false, is_active: true, revision: 2, ...over,});
function mockApi(calls: { path: string; body: unknown; ifMatch: string | null }[] = []) { server.use( http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [supplier()] })), http.get('*/v1/payables/suppliers/:id', () => HttpResponse.json(supplier())), http.get('*/v1/payables/banks', () => HttpResponse.json({ banks: [{ id: 'b1', key: 'mbb', name: 'Maybank' }] })), http.post('*/v1/payables/suppliers/:id/bank/verify', ({ request }) => { calls.push({ path: new URL(request.url).pathname, body: null, ifMatch: request.headers.get('If-Match') }); return HttpResponse.json(supplier({ bank_verified: true, revision: 3 })); }), );}
describe('SuppliersPage', () => { it('lists suppliers with an unverified-bank flag', async () => { mockApi(); await renderWithApp({ path: '/payables/suppliers', permissions: ['payables.supplier.read'] }); const row = (await screen.findByText('Bekalan Sdn Bhd')).closest('tr')!; expect(within(row).getByText('Not verified')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New supplier' })).not.toBeInTheDocument(); });
it('offers verification only with payables.supplier.verify_bank (presence first)', async () => { const calls: { path: string; body: unknown; ifMatch: string | null }[] = []; mockApi(calls); const { user } = await renderWithApp({ path: '/payables/suppliers/s1', permissions: ['payables.supplier.read', 'payables.supplier.verify_bank'], }); const verify = await screen.findByRole('button', { name: 'Verify bank details' }); await user.click(verify); await waitFor(() => expect(calls).toHaveLength(1)); expect(calls[0]!.ifMatch).toBe('"2"'); });
it('hides verification without the permission', async () => { mockApi(); await renderWithApp({ path: '/payables/suppliers/s1', permissions: ['payables.supplier.read'] }); await screen.findByText('Maybank'); expect(screen.queryByRole('button', { name: 'Verify bank details' })).not.toBeInTheDocument(); });});-
Step 2: Run — expect failure.
pnpm -C web exec vitest run src/features/payables→ FAIL:Cannot find module '../SuppliersPage'/ route/payables/suppliersnot found. -
Step 3: Implement.
web/src/features/payables/api.ts:
import { infiniteQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient,} from '@tanstack/react-query';import { api } from '@/lib/api/client';import { unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';
export type Supplier = Schemas['Supplier'];export type PurchaseInvoice = Schemas['PurchaseInvoice'];export type PurchaseInvoiceView = Schemas['PurchaseInvoiceView'];export type SupplierPayment = Schemas['SupplierPayment'];export type SupplierPaymentView = Schemas['SupplierPaymentView'];export type InvoiceKind = PurchaseInvoice['kind'];export const PAGE_SIZE = 20;const etag = (rev: number) => `"${rev}"`;
export const payablesKeys = { all: ['payables'] as const, suppliers: ['payables', 'suppliers'] as const, supplier: (id: string) => ['payables', 'supplier', id] as const, banks: ['payables', 'banks'] as const, invoices: (f: object) => ['payables', 'invoices', f] as const, invoice: (id: string) => ['payables', 'invoice', id] as const, payments: ['payables', 'payments'] as const, payment: (id: string) => ['payables', 'payment', id] as const, aging: (asOf: string) => ['payables', 'aging', asOf] as const,};
export function useSuppliers() { return useQuery({ queryKey: payablesKeys.suppliers, queryFn: async ({ signal }) => unwrap(await api.GET('/payables/suppliers', { signal })).suppliers, });}export function useSupplier(id: string) { return useQuery({ queryKey: payablesKeys.supplier(id), queryFn: async ({ signal }) => unwrap(await api.GET('/payables/suppliers/{id}', { params: { path: { id } }, signal })), });}export function useBanks() { return useQuery({ queryKey: payablesKeys.banks, queryFn: async ({ signal }) => unwrap(await api.GET('/payables/banks', { signal })).banks, });}
function useAfterSupplier() { const qc = useQueryClient(); return (s: Supplier) => { qc.setQueryData(payablesKeys.supplier(s.id), s); void qc.invalidateQueries({ queryKey: payablesKeys.suppliers }); };}export function useSaveSupplier() { const after = useAfterSupplier(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; body: Schemas['SupplierInput'] }) => v.id ? unwrap( await api.PUT('/payables/suppliers/{id}', { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.body, }), ) : unwrap(await api.POST('/payables/suppliers', { body: v.body })), onSuccess: after, });}export function useSetBank(id: string) { const after = useAfterSupplier(); return useMutation({ mutationFn: async ({ revision, ...body }: { revision: number; bank_entry_id: string; account_no: string; account_name: string }) => unwrap( await api.PUT('/payables/suppliers/{id}/bank', { params: { path: { id }, header: { 'If-Match': etag(revision) } }, body, }), ), onSuccess: after, });}export function useVerifyBank(id: string) { const after = useAfterSupplier(); return useMutation({ mutationFn: async ({ revision }: { revision: number }) => unwrap( await api.POST('/payables/suppliers/{id}/bank/verify', { params: { path: { id }, header: { 'If-Match': etag(revision) } }, body: {}, }), ), onSuccess: after, });}
export function invoiceListQueryOptions(f: { kind: InvoiceKind; supplier_id?: string; state?: string }) { return infiniteQueryOptions({ queryKey: payablesKeys.invoices(f), initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => unwrap( await api.GET('/payables/invoices', { params: { query: { ...f, limit: PAGE_SIZE, cursor: pageParam } }, signal, }), ), getNextPageParam: (last) => last.next_cursor ?? undefined, });}export function useInvoices(f: { kind: InvoiceKind; supplier_id?: string; state?: string }) { return useInfiniteQuery({ ...invoiceListQueryOptions(f), select: (d) => d.pages.flatMap((p) => p.invoices) });}export function useInvoice(id: string) { return useQuery({ queryKey: payablesKeys.invoice(id), queryFn: async ({ signal }) => unwrap(await api.GET('/payables/invoices/{id}', { params: { path: { id } }, signal })), });}function useAfterInvoice() { const qc = useQueryClient(); return (v: PurchaseInvoiceView) => { qc.setQueryData(payablesKeys.invoice(v.invoice.id), v); void qc.invalidateQueries({ queryKey: ['payables', 'invoices'] }); void qc.invalidateQueries({ queryKey: ['payables', 'aging'] }); };}export function useSaveInvoice() { const after = useAfterInvoice(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; body: Schemas['PurchaseInvoiceInput'] }) => v.id ? unwrap( await api.PUT('/payables/invoices/{id}', { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.body, }), ) : unwrap(await api.POST('/payables/invoices', { body: v.body })), onSuccess: after, });}// id is fixed for a drawer; a dialog that has just saved a draft passes it per call (B5), so// every submit goes through this mutation and its onSuccess replaces the cached draft view.export function useInvoiceTransition(id?: string) { const after = useAfterInvoice(); return useMutation({ mutationFn: async ({ id: callId, ...body }: Schemas['DocumentTransition'] & { id?: string }) => unwrap(await api.POST('/payables/invoices/{id}/transitions', { params: { path: { id: (callId ?? id)! } }, body })), onSuccess: after, });}export function useAllocate() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: Schemas['CreditNoteAllocationInput']) => unwrap(await api.POST('/payables/allocations', { body })), onSuccess: () => void qc.invalidateQueries({ queryKey: payablesKeys.all }), });}export function useReverseAllocation() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: string) => unwrap(await api.POST('/payables/allocations/{id}/reverse', { params: { path: { id } }, body: {} })), onSuccess: () => void qc.invalidateQueries({ queryKey: payablesKeys.all }), });}
export function paymentListQueryOptions() { return infiniteQueryOptions({ queryKey: payablesKeys.payments, initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => unwrap(await api.GET('/payables/payments', { params: { query: { limit: PAGE_SIZE, cursor: pageParam } }, signal })), getNextPageParam: (last) => last.next_cursor ?? undefined, });}export function usePayments() { return useInfiniteQuery({ ...paymentListQueryOptions(), select: (d) => d.pages.flatMap((p) => p.payments) });}export function usePayment(id: string) { return useQuery({ queryKey: payablesKeys.payment(id), queryFn: async ({ signal }) => unwrap(await api.GET('/payables/payments/{id}', { params: { path: { id } }, signal })), });}function useAfterPayment() { const qc = useQueryClient(); return (v: SupplierPaymentView) => { qc.setQueryData(payablesKeys.payment(v.payment.id), v); void qc.invalidateQueries({ queryKey: payablesKeys.payments }); void qc.invalidateQueries({ queryKey: ['payables', 'invoices'] }); void qc.invalidateQueries({ queryKey: ['payables', 'aging'] }); };}export function useSavePayment() { const after = useAfterPayment(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; body: Schemas['SupplierPaymentInput'] }) => v.id ? unwrap( await api.PUT('/payables/payments/{id}', { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.body, }), ) : unwrap(await api.POST('/payables/payments', { body: v.body })), onSuccess: after, });}export function usePaymentTransition(id?: string) { const after = useAfterPayment(); return useMutation({ mutationFn: async ({ id: callId, ...body }: Schemas['DocumentTransition'] & { id?: string }) => unwrap(await api.POST('/payables/payments/{id}/transitions', { params: { path: { id: (callId ?? id)! } }, body })), onSuccess: after, });}export function useAPAging(asOf: string) { return useQuery({ queryKey: payablesKeys.aging(asOf), queryFn: async ({ signal }) => unwrap(await api.GET('/reports/ap-aging', { params: { query: { as_of: asOf } }, signal })), });}SuppliersPage.tsx:
import { useMemo, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { Tag } from '@/components/ui/Tag';import { useCan } from '@/features/auth/session';import { useSuppliers, type Supplier } from './api';
export function SuppliersPage(p: { onOpen(id: string): void; onNew(): void; children?: ReactNode }) { const { t } = useTranslation('payables'); const canManage = useCan('payables.supplier.manage'); const list = useSuppliers(); const columns = useMemo<ColumnDef<Supplier>[]>( () => [ { id: 'code', meta: { required: true }, header: t('suppliers.columns.code'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.code}</span> }, { id: 'name', meta: { required: true }, header: t('suppliers.columns.name'), cell: ({ row }) => <span className="font-medium">{row.original.legal_name}</span> }, { id: 'tin', meta: { defaultHidden: true }, header: t('suppliers.columns.tin'), cell: ({ row }) => row.original.tin ?? '–' }, { id: 'terms', header: t('suppliers.columns.terms'), cell: ({ row }) => t('suppliers.termsDays', { count: row.original.payment_terms_days }) }, { id: 'currency', header: t('suppliers.columns.currency'), cell: ({ row }) => row.original.currency }, { id: 'bank', header: t('suppliers.columns.bank'), cell: ({ row }) => !row.original.bank ? '–' : row.original.bank_verified ? ( <Tag tone="green">{t('suppliers.bankVerified')}</Tag> ) : ( <Tag tone="amber">{t('suppliers.bankUnverified')}</Tag> ) }, { id: 'status', meta: { defaultHidden: true }, header: t('suppliers.columns.status'), cell: ({ row }) => (row.original.is_active ? t('suppliers.active') : t('suppliers.inactive')) }, ], [t], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('suppliers.title')} subtitle={t('suppliers.subtitle')} actions={canManage ? ( <Button onClick={p.onNew}><Plus className="size-4" aria-hidden /> {t('suppliers.new')}</Button> ) : undefined} /> <DataTable tableId="payables.suppliers" columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(s: Supplier) => s.id} onRowClick={(s: Supplier) => p.onOpen(s.id)} empty={<EmptyState title={t('suppliers.empty.title')} description={t('suppliers.empty.description')} />} /> {p.children} </div> );}(Tag takes a tone — check web/src/components/ui/Tag.tsx for the tone names and use the green/amber ones it defines.)
SupplierDialog.tsx — create/edit form over Schemas['SupplierInput']:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { problemMessage } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';import { useSaveSupplier, type Supplier } from './api';
export function SupplierDialog({ supplier, onClose, onDone }: { supplier?: Supplier; onClose(): void; onDone(id: string): void }) { const { t } = useTranslation('payables'); const { t: tc } = useTranslation(); const save = useSaveSupplier(); const [v, setV] = useState<Schemas['SupplierInput']>({ code: supplier?.code ?? '', party_kind: supplier?.party_kind ?? 'organization', legal_name: supplier?.legal_name ?? '', tin: supplier?.tin ?? null, registration_no: supplier?.registration_no ?? null, country_code: supplier?.country_code ?? 'MY', address: supplier?.address ?? {}, email: supplier?.email ?? null, phone: supplier?.phone ?? null, payment_terms_days: supplier?.payment_terms_days ?? 30, currency: supplier?.currency ?? 'MYR', default_expense_account_id: supplier?.default_expense_account_id ?? null, default_tax_code_id: supplier?.default_tax_code_id ?? null, ...(supplier ? { is_active: supplier.is_active } : {}), }); const [error, setError] = useState(''); const text = (k: 'code' | 'legal_name' | 'country_code' | 'currency') => ({ value: v[k], onChange: (e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, [k]: e.target.value })), }); const optional = (k: 'tin' | 'registration_no' | 'email' | 'phone') => ({ value: v[k] ?? '', onChange: (e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, [k]: e.target.value || null })), }); const addr = (k: 'line1' | 'line2' | 'city' | 'postcode' | 'state') => ({ value: v.address?.[k] ?? '', onChange: (e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, address: { ...s.address, [k]: e.target.value } })), }); async function submit() { try { const s = await save.mutateAsync({ id: supplier?.id, revision: supplier?.revision, body: v }); onDone(s.id); } catch (e) { setError(problemMessage(e, tc)); } } return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={supplier ? t('suppliers.edit') : t('suppliers.new')} footer={<Button onClick={() => void submit()} loading={save.isPending}>{t('save')}</Button>}> <div className="grid gap-4 md:grid-cols-2"> <Field label={t('suppliers.fields.code')} id="sp-code"><Input id="sp-code" {...text('code')} /></Field> <Field label={t('suppliers.fields.kind')} id="sp-kind"> <Select id="sp-kind" aria-label={t('suppliers.fields.kind')} value={v.party_kind} onValueChange={(x: string) => setV((s) => ({ ...s, party_kind: x as Schemas['SupplierInput']['party_kind'] }))} options={[{ value: 'organization', label: t('suppliers.kinds.organization') }, { value: 'individual', label: t('suppliers.kinds.individual') }]} /> </Field> <div className="md:col-span-2"> <Field label={t('suppliers.fields.legalName')} id="sp-name"><Input id="sp-name" {...text('legal_name')} /></Field> </div> <Field label={t('suppliers.fields.tin')} id="sp-tin"><Input id="sp-tin" {...optional('tin')} /></Field> <Field label={t('suppliers.fields.registrationNo')} id="sp-reg"><Input id="sp-reg" {...optional('registration_no')} /></Field> <Field label={t('suppliers.fields.address1')} id="sp-a1"><Input id="sp-a1" {...addr('line1')} /></Field> <Field label={t('suppliers.fields.address2')} id="sp-a2"><Input id="sp-a2" {...addr('line2')} /></Field> <Field label={t('suppliers.fields.city')} id="sp-city"><Input id="sp-city" {...addr('city')} /></Field> <Field label={t('suppliers.fields.postcode')} id="sp-pc"><Input id="sp-pc" {...addr('postcode')} /></Field> <Field label={t('suppliers.fields.state')} id="sp-st"><Input id="sp-st" {...addr('state')} /></Field> <Field label={t('suppliers.fields.country')} id="sp-cc"><Input id="sp-cc" maxLength={2} {...text('country_code')} /></Field> <Field label={t('suppliers.fields.email')} id="sp-em"><Input id="sp-em" {...optional('email')} /></Field> <Field label={t('suppliers.fields.phone')} id="sp-ph"><Input id="sp-ph" {...optional('phone')} /></Field> <Field label={t('suppliers.fields.terms')} id="sp-terms"> <Input id="sp-terms" inputMode="numeric" value={String(v.payment_terms_days)} onChange={(e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, payment_terms_days: parseInt(e.target.value.replace(/\D/g, '') || '0', 10) }))} /> </Field> <Field label={t('suppliers.fields.currency')} id="sp-cur"><Input id="sp-cur" maxLength={3} {...text('currency')} /></Field> </div> {error && <p role="alert" className="mt-3 text-caption text-tone-red-fg">{error}</p>} </Dialog> );}SupplierDrawer.tsx — facts, bank (masked or full as the server sent it), change form, verify button:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Drawer } from '@/components/ui/Drawer';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { Skeleton } from '@/components/ui/Skeleton';import { toast } from '@/components/ui/Toaster';import { useCan, useMe } from '@/features/auth/session';import { problemMessage } from '@/lib/api/problem';import { useBanks, useSetBank, useSupplier, useVerifyBank } from './api';
export function SupplierDrawer({ id, onClose, onEdit }: { id: string; onClose(): void; onEdit(): void }) { const { t } = useTranslation('payables'); const { t: tc } = useTranslation(); const q = useSupplier(id); const { data: me } = useMe(); const canManage = useCan('payables.supplier.manage'); const canVerify = useCan('payables.supplier.verify_bank'); const banks = useBanks(); const setBank = useSetBank(id); const verify = useVerifyBank(id); const [form, setForm] = useState<{ bank_entry_id: string; account_no: string; account_name: string } | null>(null); const s = q.data; const run = async (fn: () => Promise<unknown>, ok: string) => { try { await fn(); toast.success(ok); } catch (e) { toast.error(problemMessage(e, tc)); } }; return ( <Drawer open onOpenChange={(o: boolean) => !o && onClose()} title={s?.legal_name ?? t('suppliers.detailTitle')} footer={canManage ? <Button variant="outline" className="w-full" onClick={onEdit}>{t('suppliers.edit')}</Button> : undefined}> {!s ? <Skeleton className="h-64 w-full" /> : ( <div className="flex flex-col gap-4"> <p className="text-caption text-fg-muted">{s.code} · {t('suppliers.termsDays', { count: s.payment_terms_days })} · {s.currency}</p> <section aria-labelledby="sp-bank" className="rounded-card border border-border-subtle bg-surface p-4"> <h3 id="sp-bank" className="mb-2 font-bold text-fg">{t('suppliers.bank.title')}</h3> {s.bank ? ( <dl className="grid grid-cols-2 gap-1 text-body"> <dt>{t('suppliers.bank.bank')}</dt><dd>{s.bank.bank_name}</dd> <dt>{t('suppliers.bank.accountNo')}</dt><dd className="font-mono">{s.bank.account_no}</dd> <dt>{t('suppliers.bank.accountName')}</dt><dd>{s.bank.account_name}</dd> <dt>{t('suppliers.bank.status')}</dt> <dd>{s.bank_verified ? t('suppliers.bankVerified') : t('suppliers.bankUnverified')}</dd> </dl> ) : <p className="text-body text-fg-muted">{t('suppliers.bank.none')}</p>} {!s.bank_verified && s.previous_bank && ( <p className="mt-2 text-caption text-fg-muted"> {t('suppliers.bank.was', { bank: s.previous_bank.bank_name, account: s.previous_bank.account_no })} </p> )} <div className="mt-3 flex gap-2"> {canManage && !form && ( <Button variant="outline" onClick={() => setForm({ bank_entry_id: s.bank?.bank_entry_id ?? '', account_no: '', account_name: s.legal_name })}> {t('suppliers.bank.change')} </Button> )} {canVerify && s.bank && !s.bank_verified && ( <Button disabled={s.bank_changed_by === me?.member_id} title={s.bank_changed_by === me?.member_id ? t('suppliers.bank.notYourOwn') : undefined} onClick={() => void run(() => verify.mutateAsync({ revision: s.revision }), t('suppliers.bank.verified'))}> {t('suppliers.bank.verify')} </Button> )} </div> {form && ( <div className="mt-3 grid gap-3"> <Field label={t('suppliers.bank.bank')} id="sb-bank"> <Select id="sb-bank" aria-label={t('suppliers.bank.bank')} value={form.bank_entry_id || undefined} onValueChange={(x: string) => setForm({ ...form, bank_entry_id: x })} options={(banks.data ?? []).map((b) => ({ value: b.id, label: b.name }))} /> </Field> <Field label={t('suppliers.bank.accountNo')} id="sb-no"> <Input id="sb-no" inputMode="numeric" value={form.account_no} onChange={(e: ChangeEvent<HTMLInputElement>) => setForm({ ...form, account_no: e.target.value })} /> </Field> <Field label={t('suppliers.bank.accountName')} id="sb-name"> <Input id="sb-name" value={form.account_name} onChange={(e: ChangeEvent<HTMLInputElement>) => setForm({ ...form, account_name: e.target.value })} /> </Field> <p className="text-caption text-fg-muted">{t('suppliers.bank.secondPerson')}</p> <Button onClick={() => void run(async () => { await setBank.mutateAsync({ revision: s.revision, ...form }); setForm(null); }, t('suppliers.bank.saved'))}> {t('save')} </Button> </div> )} </section> </div> )} </Drawer> );}routes.tsx:
import { createRoute, Outlet } from '@tanstack/react-router';import { appRoute } from '@/app/layouts';import { RequirePermission } from '@/features/auth/session';import { useSupplier } from './api';import { SupplierDialog } from './SupplierDialog';import { SupplierDrawer } from './SupplierDrawer';import { SuppliersPage } from './SuppliersPage';
const suppliersRoute = createRoute({ getParentRoute: () => appRoute, path: 'payables/suppliers', component: function R() { const navigate = suppliersRoute.useNavigate(); return ( <RequirePermission permission="payables.supplier.read"> <SuppliersPage onOpen={(id) => void navigate({ to: '/payables/suppliers/$id', params: { id } })} onNew={() => void navigate({ to: '/payables/suppliers/new' })} > <Outlet /> </SuppliersPage> </RequirePermission> ); },});const newSupplierRoute = createRoute({ getParentRoute: () => suppliersRoute, path: 'new', component: function R() { const navigate = newSupplierRoute.useNavigate(); return <SupplierDialog onClose={() => void navigate({ to: '/payables/suppliers' })} onDone={(id) => void navigate({ to: '/payables/suppliers/$id', params: { id } })} />; },});const supplierRoute = createRoute({ getParentRoute: () => suppliersRoute, path: '$id', component: function R() { const { id } = supplierRoute.useParams(); const navigate = supplierRoute.useNavigate(); return <SupplierDrawer id={id} onClose={() => void navigate({ to: '/payables/suppliers' })} onEdit={() => void navigate({ to: '/payables/suppliers/$id/edit', params: { id } })} />; },});const editSupplierRoute = createRoute({ getParentRoute: () => suppliersRoute, path: '$id/edit', component: function R() { const { id } = editSupplierRoute.useParams(); const navigate = editSupplierRoute.useNavigate(); const s = useSupplier(id); return s.data ? <SupplierDialog supplier={s.data} onClose={() => void navigate({ to: '/payables/suppliers/$id', params: { id } })} onDone={() => void navigate({ to: '/payables/suppliers/$id', params: { id } })} /> : null; },});
// APPEND-ONLY: Tasks 16 and 17 add their route trees here.export const routes = [suppliersRoute.addChildren([newSupplierRoute, editSupplierRoute, supplierRoute])] as const;web/src/app/routes.ts: import { routes as payablesRoutes } from '@/features/payables/routes'; and ...payablesRoutes, after ...projectsRoutes,.
nav.ts: import Truck from lucide-react; append after the /accounting/approvers item:
{ group: 'accounting', to: '/payables/suppliers', labelKey: 'payables:nav.suppliers', icon: Truck, permission: 'payables.supplier.read', },web/src/lib/i18n/locales/en/payables.json:
{ "nav": { "suppliers": "Suppliers", "invoices": "Purchase invoices", "payments": "Supplier payments" }, "save": "Save", "close": "Close", "moved": "Done", "suppliers": { "title": "Suppliers", "subtitle": "Who the company buys from, their terms and where they are paid", "new": "New supplier", "edit": "Edit supplier", "detailTitle": "Supplier", "active": "Active", "inactive": "Inactive", "bankVerified": "Verified", "bankUnverified": "Not verified", "termsDays_one": "{{count}} day", "termsDays_other": "{{count}} days", "columns": { "code": "Code", "name": "Name", "tin": "TIN", "terms": "Terms", "currency": "Currency", "bank": "Bank details", "status": "Status" }, "empty": { "title": "No suppliers yet", "description": "Suppliers you add will appear here." }, "kinds": { "organization": "Organization", "individual": "Individual" }, "fields": { "code": "Code", "kind": "Type", "legalName": "Registered name", "tin": "Tax identification number (TIN)", "registrationNo": "Registration number (SSM)", "address1": "Address line 1", "address2": "Address line 2", "city": "City", "postcode": "Postcode", "state": "State", "country": "Country", "email": "Email", "phone": "Phone", "terms": "Payment terms (days)", "currency": "Currency" }, "bank": { "title": "Bank details", "bank": "Bank", "accountNo": "Account number", "accountName": "Account name", "status": "Status", "none": "No bank details yet.", "change": "Change bank details", "verify": "Verify bank details", "verified": "Bank details verified", "saved": "Bank details saved — another person must verify them", "secondPerson": "Someone other than you must verify these before a payment can be approved.", "notYourOwn": "You changed these details, so someone else must verify them.", "was": "Previously {{bank}} {{account}}" } }, "invoices": { "title": "Purchase invoices", "subtitle": "Supplier invoices and credit notes, approved into the ledger", "tabs": { "purchase_invoice": "Invoices", "supplier_credit_note": "Credit notes" }, "new": { "purchase_invoice": "New invoice", "supplier_credit_note": "New credit note" }, "detailTitle": { "purchase_invoice": "Purchase invoice", "supplier_credit_note": "Supplier credit note" }, "columns": { "reference": "Reference", "supplier": "Supplier", "number": "Supplier's number", "date": "Date", "due": "Due", "amount": "Amount", "outstanding": "Outstanding", "status": "Status" }, "empty": { "title": "Nothing here yet", "description": "Documents you enter will appear here." }, "fields": { "supplier": "Supplier", "number": "Supplier's document number", "date": "Document date", "due": "Due date", "currency": "Currency", "rate": "Exchange rate to {{base}}", "document": "Supplier's document", "basis": "What the document is", "tin": "Supplier's TIN", "uuid": "e-Invoice unique identifier", "validatedAt": "Validated on MyInvois", "reason": "Why there is no e-Invoice" }, "basis": { "einvoice_company": "e-Invoice to the company", "foreign_supplier": "Foreign supplier's document", "relaxation_receipt": "Receipt without e-Invoice" }, "lines": { "title": "Lines", "add": "Add line", "remove": "Remove line", "description": "Description", "account": "Account", "taxCode": "Tax code", "noTax": "No tax", "net": "Net", "tax": "Tax", "total": "Total" }, "saveDraft": "Save draft", "submit": "Submit", "allocations": { "title": "Allocations", "none": "Nothing allocated yet.", "payment": "Payment", "credit_note": "Credit note", "reversed": "Reversed", "reverse": "Reverse" }, "offset": { "title": "Offset against an invoice", "invoice": "Invoice", "amount": "Amount", "apply": "Offset", "done": "Offset recorded" }, "outstanding": "Outstanding {{amount}}" }, "payments": { "title": "Supplier payments", "subtitle": "Payments recorded against posted invoices", "new": "New payment", "detailTitle": "Supplier payment", "columns": { "reference": "Reference", "supplier": "Supplier", "date": "Date", "amount": "Amount", "status": "Status" }, "empty": { "title": "No payments yet", "description": "Payments you record will appear here." }, "fields": { "supplier": "Supplier", "bankAccount": "Pay from", "date": "Payment date", "reference": "Bank reference", "currency": "Currency", "rate": "Exchange rate to {{base}}", "amount": "Amount" }, "allocate": { "title": "Invoices paid", "none": "This supplier has no posted invoices outstanding in {{currency}}.", "pay": "Pay", "remaining": "Left to allocate {{amount}}" }, "unverified": "The supplier's bank details are not verified; the payment cannot be approved until they are." }, "aging": { "tab": "AP aging", "supplier": "Supplier", "document": "Document", "due": "Due", "daysPastDue": "Days past due", "buckets": { "current": "Current", "days_1_30": "1–30", "days_31_60": "31–60", "days_61_90": "61–90", "over_90": "Over 90", "total": "Total" }, "empty": "Nothing is owed to suppliers as of this date." }}ms/payables.json (same keys):
{ "nav": { "suppliers": "Pembekal", "invoices": "Invois belian", "payments": "Bayaran pembekal" }, "save": "Simpan", "close": "Tutup", "moved": "Selesai", "suppliers": { "title": "Pembekal", "subtitle": "Pihak yang syarikat beli daripadanya, terma mereka dan tempat mereka dibayar", "new": "Pembekal baharu", "edit": "Sunting pembekal", "detailTitle": "Pembekal", "active": "Aktif", "inactive": "Tidak aktif", "bankVerified": "Disahkan", "bankUnverified": "Belum disahkan", "termsDays_one": "{{count}} hari", "termsDays_other": "{{count}} hari", "columns": { "code": "Kod", "name": "Nama", "tin": "TIN", "terms": "Terma", "currency": "Mata wang", "bank": "Butiran bank", "status": "Status" }, "empty": { "title": "Belum ada pembekal", "description": "Pembekal yang anda tambah akan dipaparkan di sini." }, "kinds": { "organization": "Organisasi", "individual": "Individu" }, "fields": { "code": "Kod", "kind": "Jenis", "legalName": "Nama berdaftar", "tin": "Nombor pengenalan cukai (TIN)", "registrationNo": "Nombor pendaftaran (SSM)", "address1": "Alamat baris 1", "address2": "Alamat baris 2", "city": "Bandar", "postcode": "Poskod", "state": "Negeri", "country": "Negara", "email": "E-mel", "phone": "Telefon", "terms": "Terma bayaran (hari)", "currency": "Mata wang" }, "bank": { "title": "Butiran bank", "bank": "Bank", "accountNo": "Nombor akaun", "accountName": "Nama akaun", "status": "Status", "none": "Belum ada butiran bank.", "change": "Tukar butiran bank", "verify": "Sahkan butiran bank", "verified": "Butiran bank disahkan", "saved": "Butiran bank disimpan — orang lain mesti mengesahkannya", "secondPerson": "Orang selain anda mesti mengesahkan butiran ini sebelum bayaran boleh diluluskan.", "notYourOwn": "Anda yang menukar butiran ini, jadi orang lain mesti mengesahkannya.", "was": "Sebelum ini {{bank}} {{account}}" } }, "invoices": { "title": "Invois belian", "subtitle": "Invois dan nota kredit pembekal, diluluskan ke dalam lejar", "tabs": { "purchase_invoice": "Invois", "supplier_credit_note": "Nota kredit" }, "new": { "purchase_invoice": "Invois baharu", "supplier_credit_note": "Nota kredit baharu" }, "detailTitle": { "purchase_invoice": "Invois belian", "supplier_credit_note": "Nota kredit pembekal" }, "columns": { "reference": "Rujukan", "supplier": "Pembekal", "number": "Nombor pembekal", "date": "Tarikh", "due": "Tarikh akhir", "amount": "Amaun", "outstanding": "Baki tertunggak", "status": "Status" }, "empty": { "title": "Belum ada apa-apa di sini", "description": "Dokumen yang anda masukkan akan dipaparkan di sini." }, "fields": { "supplier": "Pembekal", "number": "Nombor dokumen pembekal", "date": "Tarikh dokumen", "due": "Tarikh akhir bayaran", "currency": "Mata wang", "rate": "Kadar pertukaran kepada {{base}}", "document": "Dokumen pembekal", "basis": "Jenis dokumen", "tin": "TIN pembekal", "uuid": "Pengecam unik e-Invois", "validatedAt": "Disahkan di MyInvois", "reason": "Sebab tiada e-Invois" }, "basis": { "einvoice_company": "e-Invois kepada syarikat", "foreign_supplier": "Dokumen pembekal asing", "relaxation_receipt": "Resit tanpa e-Invois" }, "lines": { "title": "Baris", "add": "Tambah baris", "remove": "Buang baris", "description": "Keterangan", "account": "Akaun", "taxCode": "Kod cukai", "noTax": "Tiada cukai", "net": "Bersih", "tax": "Cukai", "total": "Jumlah" }, "saveDraft": "Simpan draf", "submit": "Hantar", "allocations": { "title": "Peruntukan", "none": "Belum ada peruntukan.", "payment": "Bayaran", "credit_note": "Nota kredit", "reversed": "Dibatalkan", "reverse": "Batalkan" }, "offset": { "title": "Tolak terhadap invois", "invoice": "Invois", "amount": "Amaun", "apply": "Tolak", "done": "Tolakan direkodkan" }, "outstanding": "Baki tertunggak {{amount}}" }, "payments": { "title": "Bayaran pembekal", "subtitle": "Bayaran yang direkodkan terhadap invois yang telah dipos", "new": "Bayaran baharu", "detailTitle": "Bayaran pembekal", "columns": { "reference": "Rujukan", "supplier": "Pembekal", "date": "Tarikh", "amount": "Amaun", "status": "Status" }, "empty": { "title": "Belum ada bayaran", "description": "Bayaran yang anda rekodkan akan dipaparkan di sini." }, "fields": { "supplier": "Pembekal", "bankAccount": "Bayar daripada", "date": "Tarikh bayaran", "reference": "Rujukan bank", "currency": "Mata wang", "rate": "Kadar pertukaran kepada {{base}}", "amount": "Amaun" }, "allocate": { "title": "Invois yang dibayar", "none": "Pembekal ini tiada invois yang telah dipos dan tertunggak dalam {{currency}}.", "pay": "Bayar", "remaining": "Baki untuk diperuntukkan {{amount}}" }, "unverified": "Butiran bank pembekal belum disahkan; bayaran tidak boleh diluluskan sehingga ia disahkan." }, "aging": { "tab": "Penuaan AP", "supplier": "Pembekal", "document": "Dokumen", "due": "Tarikh akhir", "daysPastDue": "Hari lewat", "buckets": { "current": "Semasa", "days_1_30": "1–30", "days_31_60": "31–60", "days_61_90": "61–90", "over_90": "Lebih 90", "total": "Jumlah" }, "empty": "Tiada hutang kepada pembekal pada tarikh ini." }}zh/payables.json (same keys):
{ "nav": { "suppliers": "供应商", "invoices": "采购发票", "payments": "供应商付款" }, "save": "保存", "close": "关闭", "moved": "完成", "suppliers": { "title": "供应商", "subtitle": "公司向谁采购、付款条件以及付款账户", "new": "新建供应商", "edit": "编辑供应商", "detailTitle": "供应商", "active": "启用", "inactive": "停用", "bankVerified": "已核实", "bankUnverified": "未核实", "termsDays_one": "{{count}} 天", "termsDays_other": "{{count}} 天", "columns": { "code": "代码", "name": "名称", "tin": "税号", "terms": "付款条件", "currency": "币种", "bank": "银行资料", "status": "状态" }, "empty": { "title": "暂无供应商", "description": "您添加的供应商将显示在这里。" }, "kinds": { "organization": "机构", "individual": "个人" }, "fields": { "code": "代码", "kind": "类型", "legalName": "注册名称", "tin": "税务识别号(TIN)", "registrationNo": "注册号(SSM)", "address1": "地址第一行", "address2": "地址第二行", "city": "城市", "postcode": "邮编", "state": "州属", "country": "国家", "email": "电子邮件", "phone": "电话", "terms": "付款期限(天)", "currency": "币种" }, "bank": { "title": "银行资料", "bank": "银行", "accountNo": "账号", "accountName": "户名", "status": "状态", "none": "尚无银行资料。", "change": "更改银行资料", "verify": "核实银行资料", "verified": "银行资料已核实", "saved": "银行资料已保存——须由另一人核实", "secondPerson": "须由您以外的人核实后,付款才能获批。", "notYourOwn": "这些资料由您更改,须由他人核实。", "was": "原为 {{bank}} {{account}}" } }, "invoices": { "title": "采购发票", "subtitle": "供应商发票和贷项通知单,经审批后入账", "tabs": { "purchase_invoice": "发票", "supplier_credit_note": "贷项通知单" }, "new": { "purchase_invoice": "新建发票", "supplier_credit_note": "新建贷项通知单" }, "detailTitle": { "purchase_invoice": "采购发票", "supplier_credit_note": "供应商贷项通知单" }, "columns": { "reference": "编号", "supplier": "供应商", "number": "供应商单号", "date": "日期", "due": "到期日", "amount": "金额", "outstanding": "未结金额", "status": "状态" }, "empty": { "title": "暂无记录", "description": "您录入的单据将显示在这里。" }, "fields": { "supplier": "供应商", "number": "供应商单据号", "date": "单据日期", "due": "到期日", "currency": "币种", "rate": "兑 {{base}} 汇率", "document": "供应商单据", "basis": "单据类型", "tin": "供应商税号", "uuid": "电子发票唯一识别码", "validatedAt": "MyInvois 核验日期", "reason": "无电子发票的原因" }, "basis": { "einvoice_company": "开给公司的电子发票", "foreign_supplier": "境外供应商单据", "relaxation_receipt": "无电子发票的收据" }, "lines": { "title": "明细", "add": "添加明细", "remove": "删除明细", "description": "说明", "account": "科目", "taxCode": "税码", "noTax": "无税", "net": "净额", "tax": "税额", "total": "合计" }, "saveDraft": "保存草稿", "submit": "提交", "allocations": { "title": "核销", "none": "尚无核销。", "payment": "付款", "credit_note": "贷项通知单", "reversed": "已撤销", "reverse": "撤销" }, "offset": { "title": "冲抵发票", "invoice": "发票", "amount": "金额", "apply": "冲抵", "done": "冲抵已记录" }, "outstanding": "未结 {{amount}}" }, "payments": { "title": "供应商付款", "subtitle": "针对已入账发票记录的付款", "new": "新建付款", "detailTitle": "供应商付款", "columns": { "reference": "编号", "supplier": "供应商", "date": "日期", "amount": "金额", "status": "状态" }, "empty": { "title": "暂无付款", "description": "您记录的付款将显示在这里。" }, "fields": { "supplier": "供应商", "bankAccount": "付款账户", "date": "付款日期", "reference": "银行参考号", "currency": "币种", "rate": "兑 {{base}} 汇率", "amount": "金额" }, "allocate": { "title": "所付发票", "none": "该供应商没有 {{currency}} 的已入账未结发票。", "pay": "支付", "remaining": "待分配 {{amount}}" }, "unverified": "供应商银行资料尚未核实;核实前付款无法获批。" }, "aging": { "tab": "应付账龄", "supplier": "供应商", "document": "单据", "due": "到期日", "daysPastDue": "逾期天数", "buckets": { "current": "未到期", "days_1_30": "1–30", "days_31_60": "31–60", "days_61_90": "61–90", "over_90": "90 以上", "total": "合计" }, "empty": "截至该日,没有应付供应商的款项。" }}-
Step 4: Run passing.
pnpm -C web exec vitest run src/features/payables src/drift && pnpm -C web typecheck && pnpm -C web lint→ PASS . -
Step 5: Sabotage. Remove
canVerify &&from the verify button’s condition; the “hides verification” test fails; restore. Delete one key fromms/payables.json;i18nparity.test.tsfails naming it; restore. -
Step 6: Commit.
git add web && git commit -m "AP Task 15: web payables API, translations, suppliers screen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 16: Web — purchase invoices and credit notes
Section titled “Task 16: Web — purchase invoices and credit notes”Files:
- Create:
web/src/features/payables/InvoicesPage.tsx,InvoiceDialog.tsx,InvoiceDrawer.tsx - Modify:
web/src/features/payables/routes.tsx(invoice routes),web/src/app/shell/nav.ts(Purchase invoices item) - Modify:
web/src/lib/money.ts(percentOfMoney),web/src/lib/money.test.ts,web/src/components/money/MoneyInput.tsx(optional'aria-label'passed to its<input>) - Test:
web/src/features/payables/__tests__/InvoicesPage.test.tsx,web/src/lib/money.test.ts
G10: the web has no claim-line tax-code picker today — grep -rn "tax-codes" web/src finds nothing outside web/src/lib/api/schema.d.ts, and web/src/features/claims/wizard sends no tax_code_id (only its test fixtures carry tax_code_id: null). So there is nothing to filter now; Ruling P48 binds the day one is added (usage === 'sales' || usage === 'both'), and the server’s claim-line acceptance is unchanged.
Interfaces:
-
Consumes: Task 15’s hooks;
useAccounts,accountOptions(@/features/accounting/queries);/accounting/tax-codes;DocumentUploader,TransitionBar,DecisionTimeline,StatusChip,MoneyInput,MoneyText,DatePicker. -
Produces: routes
/payables/invoices?kind=,/payables/invoices/new?kind=,/payables/invoices/$id,/payables/invoices/$id/edit. -
Step 1: Failing test.
web/src/features/payables/__tests__/InvoicesPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const invoice = (over: Record<string, unknown> = {}) => ({ id: 'i1', kind: 'purchase_invoice', reference: 'PI-2026-0001', supplier_id: 's1', supplier_invoice_no: 'INV-001', invoice_date: '2026-09-21', due_on: '2026-10-21', currency: 'USD', fx_rate: '4.4000000000', gross_amount: '100.0000', gross_base: '440.0000', outstanding: '60.0000', document_id: 'd1', document_basis: 'foreign_supplier', supplier_tin: null, einvoice_uuid: null, einvoice_validated_at: null, relaxation_reason: null, instance_id: 'n1', prepared_by: 'm1', state: 'approved', semantic: 'accrual', revision: 3, created_at: '2026-09-21T01:00:00Z', ...over,});const definition = { kind: 'purchase_invoice', version: 1, graph: { kind: 'purchase_invoice', posting_order: 'accrual_first', edges: [], grants: [], states: [ { key: 'draft', semantic: 'draft', display_order: 1, is_terminal: false, labels: { en: 'Draft' } }, { key: 'pending_review', semantic: 'pending', display_order: 2, is_terminal: false, labels: { en: 'Pending review' } }, { key: 'approved', semantic: 'accrual', control_kind: 'ap_trade', display_order: 3, is_terminal: false, labels: { en: 'Posted' } }, ] },};
function mockApi(transitions: unknown[] = []) { server.use( http.get('*/v1/workflow/definitions/:kind', () => HttpResponse.json(definition)), http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [{ id: 's1', code: 'USSUP', legal_name: 'US Supplier Inc' }] })), http.get('*/v1/payables/invoices', ({ request }) => HttpResponse.json({ invoices: new URL(request.url).searchParams.get('kind') === 'supplier_credit_note' ? [] : [invoice()], next_cursor: null, })), http.get('*/v1/payables/invoices/:id', () => HttpResponse.json({ invoice: invoice({ state: 'pending_review', semantic: 'pending' }), lines: [], allocations: [], outstanding: '100.0000', outstanding_base: '440.0000', decisions: [], available_transitions: [{ to: 'approved', action_key: 'post', gate: 'target', requires_reason: false, allowed: true, labels: { en: 'Approve and post' } }] })), http.post('*/v1/payables/invoices/:id/transitions', async ({ request }) => { transitions.push(await request.json()); return HttpResponse.json({ invoice: invoice(), lines: [], allocations: [], outstanding: '100.0000', outstanding_base: '440.0000', decisions: [], available_transitions: [] }); }), );}
describe('InvoiceDialog', () => { it('offers only purchase and both tax codes on a line (presence first)', async () => { mockApi(); server.use( http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), http.get('*/v1/accounting/tax-codes', () => HttpResponse.json({ tax_codes: [ { id: 't1', code: 'PSVT8', label: 'Service tax 8% on purchases', usage: 'purchase', active: true }, { id: 't2', code: 'SVT8', label: 'Service tax 8%', usage: 'sales', active: true }, { id: 't3', code: 'GEN', label: 'General', usage: 'both', active: true } ] })), ); const { user } = await renderWithApp({ path: '/payables/invoices/new', permissions: ['payables.invoice.read', 'payables.invoice.create', 'accounting.ledger.read'] }); await user.click(await screen.findByRole('combobox', { name: 'Tax code' })); expect(await screen.findByRole('option', { name: /PSVT8/ })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /GEN/ })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: /^SVT8/ })).not.toBeInTheDocument(); });
it('defaults the tax from net × rate once a code is picked, and keeps it editable (S6)', async () => { mockApi(); server.use( http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), http.get('*/v1/accounting/tax-codes', () => HttpResponse.json({ tax_codes: [ { id: 't1', code: 'PSVT8', label: 'Service tax 8% on purchases', usage: 'purchase', active: true, rate: '8.0000000000' } ] })), ); const { user } = await renderWithApp({ path: '/payables/invoices/new', permissions: ['payables.invoice.read', 'payables.invoice.create', 'accounting.ledger.read'] }); await user.type(await screen.findByRole('textbox', { name: 'Net' }), '100.00'); await user.click(screen.getByRole('combobox', { name: 'Tax code' })); await user.click(await screen.findByRole('option', { name: /PSVT8/ })); const tax = screen.getByLabelText('Tax') as HTMLInputElement; expect(tax.value).toBe('8.0000'); await user.clear(tax); await user.type(tax, '8.04'); expect(tax.value).toBe('8.04'); // still editable });});
describe('InvoicesPage', () => { it('shows amount in currency, base underneath, and the outstanding', async () => { mockApi(); await renderWithApp({ path: '/payables/invoices', permissions: ['payables.invoice.read'] }); const row = (await screen.findByText('PI-2026-0001')).closest('tr')!; expect(within(row).getByText('USD 100.00')).toBeInTheDocument(); expect(within(row).getByText('RM 440.00')).toBeInTheDocument(); expect(within(row).getByText('USD 60.00')).toBeInTheDocument(); });
it('switches to credit notes by tab', async () => { mockApi(); const { user } = await renderWithApp({ path: '/payables/invoices', permissions: ['payables.invoice.read'] }); await screen.findByText('PI-2026-0001'); await user.click(screen.getByRole('tab', { name: 'Credit notes' })); expect(await screen.findByText('Nothing here yet')).toBeInTheDocument(); });
it('posts through the transition bar with from set to the current state', async () => { const transitions: unknown[] = []; mockApi(transitions); const { user } = await renderWithApp({ path: '/payables/invoices/i1', permissions: ['payables.invoice.read', 'payables.invoice.transition'], }); await user.click(await screen.findByRole('button', { name: 'Approve and post' })); await waitFor(() => expect(transitions).toEqual([{ from: 'pending_review', to: 'approved' }])); });});-
Step 2: Run — expect failure.
pnpm -C web exec vitest run src/features/payables/__tests__/InvoicesPage.test.tsx→ FAIL (route not found). -
Step 3: Implement.
web/src/lib/money.ts gains (BigInt only; nofloat.test.ts):
/** net × rate% (rate a percentage Rate string), rounded half-up to cents, in the wire's * 4-decimal form — the D8 default for a purchase line's tax. */export function percentOfMoney(net: string, rate: string): string { const units = toWireUnits(net) * toRateUnits(rate); // wire units × rate units const den = RATE_FACTOR * 100n * 100n; // ÷100 for percent, then to cents (1 cent = 100 wire units) const negative = units < 0n; const abs = negative ? -units : units; const cents = (abs + den / 2n) / den; return fromWireUnits((negative ? -cents : cents) * 100n);}with web/src/lib/money.test.ts: expect(percentOfMoney('100.00', '8')).toBe('8.0000'), expect(percentOfMoney('33.33', '6')).toBe('2.0000') (1.9998 → 2.00), expect(percentOfMoney('0.05', '6')).toBe('0.0000'). MoneyInput gains an optional 'aria-label'?: string prop passed through to its <input>.
InvoicesPage.tsx:
import { useMemo, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { LoadMore } from '@/components/ui/LoadMore';import { PageHeader } from '@/components/ui/PageHeader';import { Tabs } from '@/components/ui/Tabs';import { MoneyText } from '@/components/money/MoneyText';import { StatusChip } from '@/components/workflow/StatusChip';import { useCan, useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useInvoices, useSuppliers, type InvoiceKind, type PurchaseInvoice } from './api';
export function InvoicesPage(p: { kind: InvoiceKind; onKind(k: InvoiceKind): void; onOpen(id: string): void; onNew(): void; children?: ReactNode }) { const { t } = useTranslation('payables'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const canCreate = useCan('payables.invoice.create'); const list = useInvoices({ kind: p.kind }); const suppliers = useSuppliers(); const nameOf = (id: string) => suppliers.data?.find((s) => s.id === id)?.legal_name ?? ''; const columns = useMemo<ColumnDef<PurchaseInvoice>[]>( () => [ { id: 'reference', meta: { required: true }, header: t('invoices.columns.reference'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.reference}</span> }, { id: 'supplier', header: t('invoices.columns.supplier'), cell: ({ row }) => nameOf(row.original.supplier_id) }, { id: 'number', header: t('invoices.columns.number'), cell: ({ row }) => row.original.supplier_invoice_no }, { id: 'date', meta: { defaultHidden: true }, header: t('invoices.columns.date'), cell: ({ row }) => formatDate(row.original.invoice_date, locale) }, { id: 'due', header: t('invoices.columns.due'), cell: ({ row }) => formatDate(row.original.due_on, locale) }, { id: 'amount', meta: { required: true }, header: t('invoices.columns.amount'), cell: ({ row }) => ( <span className="font-bold"> <MoneyText amount={row.original.gross_amount} currency={row.original.currency} /> {row.original.currency !== base && ( <span className="block text-caption font-normal text-fg-muted"> <MoneyText amount={row.original.gross_base} currency={base} /> </span> )} </span> ) }, { id: 'outstanding', header: t('invoices.columns.outstanding'), cell: ({ row }) => <MoneyText amount={row.original.outstanding} currency={row.original.currency} /> }, { id: 'status', header: t('invoices.columns.status'), cell: ({ row }) => <StatusChip kind={row.original.kind} state={row.original.state} semantic={row.original.semantic} /> }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, locale, base, suppliers.data], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('invoices.title')} subtitle={t('invoices.subtitle')} actions={canCreate ? <Button onClick={p.onNew}><Plus className="size-4" aria-hidden /> {t(`invoices.new.${p.kind}`)}</Button> : undefined} /> <Tabs value={p.kind} onValueChange={(v: string) => p.onKind(v as InvoiceKind)} items={(['purchase_invoice', 'supplier_credit_note'] as const).map((k) => ({ value: k, label: t(`invoices.tabs.${k}`) }))} /> <DataTable tableId={`payables.${p.kind}`} columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(v: PurchaseInvoice) => v.id} onRowClick={(v: PurchaseInvoice) => p.onOpen(v.id)} empty={<EmptyState title={t('invoices.empty.title')} description={t('invoices.empty.description')} />} /> <LoadMore hasMore={Boolean(list.hasNextPage)} loading={list.isFetchingNextPage} onLoadMore={() => void list.fetchNextPage()} /> {p.children} </div> );}InvoiceDialog.tsx — header fields, lines editor, the supplier’s document (one), basis fields; Save draft and Submit (submit = save then the draft’s draft → pending_review edge from available_transitions):
import { useState, type ChangeEvent } from 'react';import { useQuery } from '@tanstack/react-query';import { useTranslation } from 'react-i18next';import { X } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { IconButton } from '@/components/ui/IconButton';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { MoneyInput } from '@/components/money/MoneyInput';import { DocumentUploader } from '@/components/upload/DocumentUploader';import type { UploadedDoc } from '@/components/upload/uploadDocument';import { accountOptions, useAccounts } from '@/features/accounting/queries';import { useMe } from '@/features/auth/session';import { api } from '@/lib/api/client';import { problemMessage, unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';import { todayISO } from '@/lib/date';import { isMoneyInput, percentOfMoney } from '@/lib/money';import { useInvoiceTransition, useSaveInvoice, useSuppliers, type InvoiceKind, type PurchaseInvoiceView } from './api';
type LineIn = Schemas['PurchaseInvoiceLineInput'];const NO_TAX = '__none';const blankLine = (): LineIn => ({ description: '', account_id: '', tax_code_id: null, net_amount: '', tax_amount: '0' });
export function InvoiceDialog({ kind, view, onClose, onDone }: { kind: InvoiceKind; view?: PurchaseInvoiceView; onClose(): void; onDone(id: string): void }) { const { t } = useTranslation('payables'); const { t: tc } = useTranslation(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const suppliers = useSuppliers(); const accounts = useAccounts(); const taxCodes = useQuery({ queryKey: ['accounting', 'tax-codes'], queryFn: async ({ signal }) => unwrap(await api.GET('/accounting/tax-codes', { signal })).tax_codes }); const save = useSaveInvoice(); const move = useInvoiceTransition(); // B5: the submit goes through the mutation, so the cache updates const inv = view?.invoice; const [h, setH] = useState<Omit<Schemas['PurchaseInvoiceInput'], 'lines'>>({ kind, supplier_id: inv?.supplier_id ?? '', supplier_invoice_no: inv?.supplier_invoice_no ?? '', invoice_date: inv?.invoice_date ?? todayISO(), due_on: inv?.due_on ?? null, currency: inv?.currency ?? base, fx_rate: inv?.currency && inv.currency !== base ? inv.fx_rate : null, document_id: inv?.document_id ?? null, document_basis: (inv?.document_basis as Schemas['PurchaseInvoiceInput']['document_basis']) ?? null, supplier_tin: inv?.supplier_tin ?? null, einvoice_uuid: inv?.einvoice_uuid ?? null, einvoice_validated_at: inv?.einvoice_validated_at ?? null, relaxation_reason: inv?.relaxation_reason ?? null, }); const [lines, setLines] = useState<LineIn[]>( view?.lines.map((l) => ({ description: l.description, account_id: l.account_id, tax_code_id: l.tax_code_id, net_amount: l.net_amount, tax_amount: l.tax_amount, shares: l.shares })) ?? [blankLine()], ); const [docs, setDocs] = useState<UploadedDoc[]>([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const setLine = (i: number, patch: Partial<LineIn>) => setLines((ls) => ls.map((l, j) => (j === i ? { ...l, ...patch } : l))); // S6 (D8): picking a code, or changing the net under one, defaults the tax to net × rate%; // the clerk may still edit it (the server allows 0.05 either way). const rateOf = (id: string | null | undefined) => taxCodes.data?.find((c) => c.id === id)?.rate; const withTax = (l: LineIn): LineIn => { const rate = rateOf(l.tax_code_id); return rate && isMoneyInput(l.net_amount) && l.net_amount !== '' ? { ...l, tax_amount: percentOfMoney(l.net_amount, rate) } : l; }; const setTaxed = (i: number, patch: Partial<LineIn>) => setLines((ls) => ls.map((l, j) => (j === i ? withTax({ ...l, ...patch }) : l)));
async function run(submit: boolean) { setBusy(true); setError(''); try { const saved = await save.mutateAsync({ id: inv?.id, revision: inv?.revision, body: { ...h, lines } }); if (submit) { const edge = saved.available_transitions.find((tr) => tr.allowed && tr.gate === 'reporter' && !tr.requires_reason); if (edge) await move.mutateAsync({ id: saved.invoice.id, from: saved.invoice.state, to: edge.to }); } onDone(saved.invoice.id); } catch (e) { setError(problemMessage(e, tc)); } finally { setBusy(false); } }
const expenseAccounts = accountOptions(accounts.data, (a) => a.postable && a.active && !a.control_kind && (a.type === 'expense' || a.type === 'asset')); return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t(`invoices.new.${kind}`)} footer={<> <Button variant="outline" onClick={() => void run(false)} disabled={busy}>{t('invoices.saveDraft')}</Button> <Button onClick={() => void run(true)} loading={busy}>{t('invoices.submit')}</Button> </>}> <div className="grid gap-4 md:grid-cols-2"> <Field label={t('invoices.fields.supplier')} id="pi-sup"> <Select id="pi-sup" aria-label={t('invoices.fields.supplier')} value={h.supplier_id || undefined} onValueChange={(x: string) => setH((s) => ({ ...s, supplier_id: x, currency: suppliers.data?.find((y) => y.id === x)?.currency ?? s.currency }))} options={(suppliers.data ?? []).filter((s) => s.is_active).map((s) => ({ value: s.id, label: `${s.code} · ${s.legal_name}` }))} /> </Field> <Field label={t('invoices.fields.number')} id="pi-no"> <Input id="pi-no" value={h.supplier_invoice_no} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, supplier_invoice_no: e.target.value }))} /> </Field> <Field label={t('invoices.fields.date')} id="pi-date"> <DatePicker id="pi-date" value={h.invoice_date} onChange={(x?: string) => setH((s) => ({ ...s, invoice_date: x ?? todayISO() }))} /> </Field> <Field label={t('invoices.fields.due')} id="pi-due"> <DatePicker id="pi-due" value={h.due_on ?? undefined} onChange={(x?: string) => setH((s) => ({ ...s, due_on: x ?? null }))} /> </Field> <Field label={t('invoices.fields.currency')} id="pi-cur"> <Input id="pi-cur" maxLength={3} className="uppercase" value={h.currency} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, currency: e.target.value.toUpperCase() }))} /> </Field> {h.currency !== base && ( <Field label={t('invoices.fields.rate', { base })} id="pi-rate"> <Input id="pi-rate" inputMode="decimal" value={h.fx_rate ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, fx_rate: e.target.value || null }))} /> </Field> )} <div className="md:col-span-2"> <Field label={t('invoices.fields.document')} id="pi-doc"> <DocumentUploader value={docs} onBusyChange={setBusy} onUploaded={(d) => { setDocs([d]); setH((s) => ({ ...s, document_id: d.id })); }} onRemoved={() => { setDocs([]); setH((s) => ({ ...s, document_id: null })); }} /> </Field> </div> <Field label={t('invoices.fields.basis')} id="pi-basis"> <Select id="pi-basis" aria-label={t('invoices.fields.basis')} value={h.document_basis ?? undefined} onValueChange={(x: string) => setH((s) => ({ ...s, document_basis: x as Schemas['PurchaseInvoiceInput']['document_basis'] }))} options={(['einvoice_company', 'foreign_supplier', 'relaxation_receipt'] as const).map((b) => ({ value: b, label: t(`invoices.basis.${b}`) }))} /> </Field> {h.document_basis === 'einvoice_company' && (<> <Field label={t('invoices.fields.tin')} id="pi-tin"> <Input id="pi-tin" value={h.supplier_tin ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, supplier_tin: e.target.value || null }))} /> </Field> <Field label={t('invoices.fields.uuid')} id="pi-uuid"> <Input id="pi-uuid" value={h.einvoice_uuid ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, einvoice_uuid: e.target.value || null }))} /> </Field> </>)} {h.document_basis === 'relaxation_receipt' && ( <div className="md:col-span-2"> <Field label={t('invoices.fields.reason')} id="pi-reason"> <Input id="pi-reason" value={h.relaxation_reason ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, relaxation_reason: e.target.value || null }))} /> </Field> </div> )} </div> <section aria-label={t('invoices.lines.title')} className="mt-4 flex flex-col gap-3"> <h3 className="font-bold text-fg">{t('invoices.lines.title')}</h3> {lines.map((l, i) => ( <div key={i} className="grid gap-2 rounded-card border border-border-subtle p-3 md:grid-cols-6"> <div className="md:col-span-2"> <Field label={t('invoices.lines.description')} id={`pl-d-${i}`}> <Input id={`pl-d-${i}`} value={l.description} onChange={(e: ChangeEvent<HTMLInputElement>) => setLine(i, { description: e.target.value })} /> </Field> </div> <Field label={t('invoices.lines.account')} id={`pl-a-${i}`}> <Select id={`pl-a-${i}`} aria-label={t('invoices.lines.account')} value={l.account_id || undefined} onValueChange={(x: string) => setLine(i, { account_id: x })} options={expenseAccounts} /> </Field> <Field label={t('invoices.lines.taxCode')} id={`pl-t-${i}`}> <Select id={`pl-t-${i}`} aria-label={t('invoices.lines.taxCode')} value={l.tax_code_id ?? NO_TAX} onValueChange={(x: string) => (x === NO_TAX ? setLine(i, { tax_code_id: null, tax_amount: '0' }) : setTaxed(i, { tax_code_id: x }))} options={[{ value: NO_TAX, label: t('invoices.lines.noTax') }, ...(taxCodes.data ?? []).filter((c) => c.active && c.usage !== 'sales') // P40: purchase or both .map((c) => ({ value: c.id, label: `${c.code} · ${c.label}` }))]} /> </Field> <Field label={t('invoices.lines.net')} id={`pl-n-${i}`}> <MoneyInput id={`pl-n-${i}`} aria-label={t('invoices.lines.net')} value={l.net_amount} currency={h.currency} onChange={(x) => setTaxed(i, { net_amount: x })} /> </Field> <Field label={t('invoices.lines.tax')} id={`pl-x-${i}`}> <MoneyInput id={`pl-x-${i}`} value={l.tax_amount} currency={h.currency} disabled={!l.tax_code_id} onChange={(x) => setLine(i, { tax_amount: x })} /> </Field> {lines.length > 1 && ( <IconButton icon={X} label={t('invoices.lines.remove')} onClick={() => setLines((ls) => ls.filter((_, j) => j !== i))} /> )} </div> ))} <Button variant="outline" onClick={() => setLines((ls) => [...ls, blankLine()])}>{t('invoices.lines.add')}</Button> </section> {error && <p role="alert" className="mt-3 text-caption text-tone-red-fg">{error}</p>} </Dialog> );}(IconButton takes { icon, label } — import { X } from 'lucide-react'.)
InvoiceDrawer.tsx:
import { useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Drawer } from '@/components/ui/Drawer';import { Field } from '@/components/ui/Field';import { Select } from '@/components/ui/Select';import { Skeleton } from '@/components/ui/Skeleton';import { toast } from '@/components/ui/Toaster';import { MoneyInput } from '@/components/money/MoneyInput';import { MoneyText } from '@/components/money/MoneyText';import { DecisionTimeline } from '@/components/workflow/DecisionTimeline';import { StatusChip } from '@/components/workflow/StatusChip';import { TransitionBar } from '@/components/workflow/TransitionBar';import { useCan, useMe } from '@/features/auth/session';import { problemMessage } from '@/lib/api/problem';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useAllocate, useInvoice, useInvoices, useInvoiceTransition, useReverseAllocation } from './api';
export function InvoiceDrawer({ id, onClose, onEdit }: { id: string; onClose(): void; onEdit(): void }) { const { t } = useTranslation('payables'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const q = useInvoice(id); const move = useInvoiceTransition(id); const allocate = useAllocate(); const reverse = useReverseAllocation(); const canCreate = useCan('payables.invoice.create'); const v = q.data; const inv = v?.invoice; const isCN = inv?.kind === 'supplier_credit_note'; const targets = useInvoices({ kind: 'purchase_invoice', supplier_id: inv?.supplier_id, state: 'approved' }); const [offset, setOffset] = useState({ invoice_id: '', amount: '' }); const act = async (fn: () => Promise<unknown>, ok: string) => { try { await fn(); toast.success(ok); } catch (e) { toast.error(problemMessage(e, tc)); } }; return ( <Drawer open onOpenChange={(o: boolean) => !o && onClose()} title={inv ? t(`invoices.detailTitle.${inv.kind}`) : ''} footer={inv?.semantic === 'draft' && canCreate ? <Button variant="outline" className="w-full" onClick={onEdit}>{t('invoices.saveDraft')}</Button> : undefined}> {!v || !inv ? <Skeleton className="h-96 w-full" /> : ( <div className="flex flex-col gap-4"> <div className="flex items-start justify-between gap-2"> <h3 className="text-subtitle font-bold text-fg">{inv.reference} · {inv.supplier_invoice_no}</h3> <StatusChip kind={inv.kind} state={inv.state} semantic={inv.semantic} /> </div> <p className="text-caption text-fg-muted"> {formatDate(inv.invoice_date, locale)} · {t('invoices.columns.due')} {formatDate(inv.due_on, locale)} </p> <div className="rounded-card bg-bg-subtle p-4"> <MoneyText className="text-subtitle font-bold" amount={inv.gross_amount} currency={inv.currency} /> {inv.currency !== base && <p className="text-caption text-fg-muted"><MoneyText amount={inv.gross_base} currency={base} /></p>} <p className="text-caption text-fg-muted">{t('invoices.outstanding', { amount: `${inv.currency} ${v.outstanding}` })}</p> </div> <TransitionBar kind={inv.kind} transitions={v.available_transitions} onTake={(tr, reason) => move.mutateAsync({ from: inv.state, to: tr.to, ...(reason ? { reason } : {}) })} /> <section aria-labelledby="pi-alloc" className="rounded-card border border-border-subtle bg-surface p-4"> <h3 id="pi-alloc" className="mb-2 font-bold text-fg">{t('invoices.allocations.title')}</h3> {v.allocations.length === 0 ? <p className="text-body text-fg-muted">{t('invoices.allocations.none')}</p> : ( <ul className="flex flex-col gap-2 text-body"> {v.allocations.map((a) => ( <li key={a.id} className="flex items-center justify-between gap-2"> <span>{t(`invoices.allocations.${a.source_kind}`)} · {formatDate(a.effective_on, locale)} {a.reversed_by_id && <span className="ml-1 text-fg-muted">({t('invoices.allocations.reversed')})</span>}</span> <MoneyText amount={a.amount} currency={a.currency} /> {canCreate && a.source_kind === 'credit_note' && !a.reverses_id && !a.reversed_by_id && ( <Button variant="outline" onClick={() => void act(() => reverse.mutateAsync(a.id), t('moved'))}> {t('invoices.allocations.reverse')} </Button> )} </li> ))} </ul> )} {isCN && inv.semantic === 'accrual' && canCreate && ( <div className="mt-3 grid gap-2 md:grid-cols-3"> <Field label={t('invoices.offset.invoice')} id="cn-inv"> <Select id="cn-inv" aria-label={t('invoices.offset.invoice')} value={offset.invoice_id || undefined} onValueChange={(x: string) => setOffset((s) => ({ ...s, invoice_id: x }))} options={(targets.data ?? []).filter((x) => x.currency === inv.currency && x.fx_rate === inv.fx_rate) .map((x) => ({ value: x.id, label: `${x.reference} · ${x.currency} ${x.outstanding}` }))} /> </Field> <Field label={t('invoices.offset.amount')} id="cn-amt"> <MoneyInput id="cn-amt" value={offset.amount} currency={inv.currency} onChange={(x) => setOffset((s) => ({ ...s, amount: x }))} /> </Field> <Button className="self-end" onClick={() => void act(() => allocate.mutateAsync({ credit_note_id: inv.id, ...offset }), t('invoices.offset.done'))}> {t('invoices.offset.apply')} </Button> </div> )} </section> <DecisionTimeline kind={inv.kind} currentState={inv.state} decisions={v.decisions} /> </div> )} </Drawer> );}routes.tsx additions (before the routes export; routes gains invoicesRoute.addChildren([newInvoiceRoute, editInvoiceRoute, invoiceRoute])):
import { z } from 'zod';import { InvoiceDialog } from './InvoiceDialog';import { InvoiceDrawer } from './InvoiceDrawer';import { InvoicesPage } from './InvoicesPage';import { useInvoice, type InvoiceKind } from './api';
const invoiceSearch = z.object({ kind: z.enum(['purchase_invoice', 'supplier_credit_note']).catch('purchase_invoice') });type InvoiceSearch = z.infer<typeof invoiceSearch>;
const invoicesRoute = createRoute({ getParentRoute: () => appRoute, path: 'payables/invoices', validateSearch: (s: Record<string, unknown>) => invoiceSearch.parse(s), component: function R() { const { kind } = invoicesRoute.useSearch(); const navigate = invoicesRoute.useNavigate(); return ( <RequirePermission permission="payables.invoice.read"> <InvoicesPage kind={kind} onKind={(k: InvoiceKind) => void navigate({ search: { kind: k }, replace: true })} onOpen={(id) => void navigate({ to: '/payables/invoices/$id', params: { id }, search: (p: InvoiceSearch) => p })} onNew={() => void navigate({ to: '/payables/invoices/new', search: (p: InvoiceSearch) => p })}> <Outlet /> </InvoicesPage> </RequirePermission> ); },});const newInvoiceRoute = createRoute({ getParentRoute: () => invoicesRoute, path: 'new', component: function R() { const { kind } = newInvoiceRoute.useSearch(); const navigate = newInvoiceRoute.useNavigate(); return <InvoiceDialog kind={kind} onClose={() => void navigate({ to: '/payables/invoices', search: (p: InvoiceSearch) => p })} onDone={(id) => void navigate({ to: '/payables/invoices/$id', params: { id }, search: (p: InvoiceSearch) => p })} />; },});const invoiceRoute = createRoute({ getParentRoute: () => invoicesRoute, path: '$id', component: function R() { const { id } = invoiceRoute.useParams(); const navigate = invoiceRoute.useNavigate(); return <InvoiceDrawer id={id} onClose={() => void navigate({ to: '/payables/invoices', search: (p: InvoiceSearch) => p })} onEdit={() => void navigate({ to: '/payables/invoices/$id/edit', params: { id }, search: (p: InvoiceSearch) => p })} />; },});const editInvoiceRoute = createRoute({ getParentRoute: () => invoicesRoute, path: '$id/edit', component: function R() { const { id } = editInvoiceRoute.useParams(); const navigate = editInvoiceRoute.useNavigate(); const v = useInvoice(id); return v.data ? <InvoiceDialog kind={v.data.invoice.kind} view={v.data} onClose={() => void navigate({ to: '/payables/invoices/$id', params: { id }, search: (p: InvoiceSearch) => p })} onDone={() => void navigate({ to: '/payables/invoices/$id', params: { id }, search: (p: InvoiceSearch) => p })} /> : null; },});nav.ts: import FileInput; append after the Suppliers item:
{ group: 'accounting', to: '/payables/invoices', labelKey: 'payables:nav.invoices', icon: FileInput, permission: 'payables.invoice.read', },-
Step 4: Run passing.
pnpm -C web exec vitest run src/features/payables && pnpm -C web typecheck && pnpm -C web lint→ PASS. -
Step 5: Sabotage. Send
from: 'draft'instead ofinv.stateinonTake; the third test fails (pending_reviewexpected); restore. -
Step 6: Commit.
git add web && git commit -m "AP Task 16: web purchase invoices and credit notes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 17: Web — supplier payments and the AP aging tab
Section titled “Task 17: Web — supplier payments and the AP aging tab”Files:
- Create:
web/src/features/payables/PaymentsPage.tsx,PaymentDialog.tsx,PaymentDrawer.tsx - Create:
web/src/features/reports/APAgingView.tsx - Modify:
web/src/features/payables/routes.tsx,web/src/app/shell/nav.ts(Supplier payments item),web/src/features/reports/ReportsPage.tsx,web/src/features/reports/routes.tsx,web/src/lib/i18n/locales/{en,ms,zh}/reports.json(page.tabs.ap_aging),web/src/lib/money.ts(addMoney,sumMoney) - Test:
web/src/features/payables/__tests__/PaymentsPage.test.tsx,web/src/features/reports/APAgingView.test.tsx(beside its file, asReportsPage.test.tsxis),web/src/lib/money.test.ts,web/src/app/shell/nav.test.ts(append — moved here from Task 15 so no commit carries a red test)
Interfaces:
-
Consumes: Task 15’s hooks;
ExportMenu(report="ap_aging",params={{ as_of }}). -
Produces: routes
/payables/payments,/payables/payments/new,/payables/payments/$id; Reports tabap_aging. -
Step 1: Failing tests.
web/src/features/payables/__tests__/PaymentsPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const sup = { id: 's1', code: 'BEKAL', legal_name: 'Bekalan Sdn Bhd', currency: 'MYR', is_active: true, bank_verified: false };
describe('PaymentDialog', () => { it('allocates the amount across the supplier’s outstanding invoices and warns on unverified bank details', async () => { const posted: unknown[] = []; server.use( http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [sup] })), http.get('*/v1/payables/payments', () => HttpResponse.json({ payments: [], next_cursor: null })), http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [ { id: 'bank1', code: '1520', label: 'Bank – current account', control_kind: 'bank', postable: true, active: true, type: 'asset', currency: null } ] })), http.get('*/v1/payables/invoices', () => HttpResponse.json({ invoices: [ { id: 'i1', reference: 'PI-2026-0001', supplier_invoice_no: 'INV-1', currency: 'MYR', outstanding: '100.0000', fx_rate: '1.0000000000', kind: 'purchase_invoice', state: 'approved', semantic: 'accrual' }, { id: 'i2', reference: 'PI-2026-0002', supplier_invoice_no: 'INV-2', currency: 'MYR', outstanding: '50.0000', fx_rate: '1.0000000000', kind: 'purchase_invoice', state: 'approved', semantic: 'accrual' } ], next_cursor: null })), http.post('*/v1/payables/payments', async ({ request }) => { posted.push(await request.json()); return HttpResponse.json({ payment: { id: 'p1', state: 'draft', revision: 1 }, lines: [], allocations: [], available_transitions: [], decisions: [] }, { status: 201 }); }), ); const { user } = await renderWithApp({ path: '/payables/payments/new', permissions: ['payables.payment.read', 'payables.payment.create', 'accounting.ledger.read'] }); const dialog = await screen.findByRole('dialog', { name: 'New payment' }); await user.click(within(dialog).getByRole('combobox', { name: 'Supplier' })); await user.click(await screen.findByRole('option', { name: /BEKAL/ })); expect(within(dialog).getByText(/not verified/)).toBeInTheDocument(); await user.type(within(dialog).getByLabelText('Pay PI-2026-0001'), '100.00'); await user.type(within(dialog).getByLabelText('Pay PI-2026-0002'), '20.00'); expect(within(dialog).getByText('RM 120.00')).toBeInTheDocument(); // the amount is the sum await user.click(within(dialog).getByRole('button', { name: 'Save draft' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0]).toMatchObject({ supplier_id: 's1', amount: '120.0000', currency: 'MYR', // B4: sumMoney's wire form lines: [{ invoice_id: 'i1', amount: '100.00' }, { invoice_id: 'i2', amount: '20.00' }] }); });});web/src/features/reports/APAgingView.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const buckets = (over: Record<string, string> = {}) => ({ current: '0.0000', days_1_30: '0.0000', days_31_60: '0.0000', days_61_90: '0.0000', over_90: '0.0000', total: '0.0000', ...over });
describe('AP aging tab', () => { it('shows each supplier’s buckets and the totals as of the chosen date', async () => { server.use(http.get('*/v1/reports/ap-aging', () => HttpResponse.json({ as_of: '2026-11-21', currency: 'MYR', rows: [], suppliers: [{ supplier_id: 's1', code: 'BEKAL', name: 'Bekalan Sdn Bhd', buckets: buckets({ days_31_60: '100.0000', total: '100.0000' }) }], totals: buckets({ days_31_60: '100.0000', total: '100.0000' }), }))); await renderWithApp({ path: '/reports?report=ap_aging&as_of=2026-11-21', permissions: ['reports.read'] }); const row = (await screen.findByText('Bekalan Sdn Bhd')).closest('tr')!; expect(within(row).getAllByText('RM 100.00')).toHaveLength(2); // the bucket and the total });});Append to web/src/app/shell/nav.test.ts (all three AP items exist once this task lands):
it('puts the AP screens in the accounting group behind their read permissions', () => { const ap = navItems.filter((i) => i.to.startsWith('/payables/')); expect(ap.map((i) => i.to)).toEqual(['/payables/suppliers', '/payables/invoices', '/payables/payments']); expect(ap.every((i) => i.group === 'accounting')).toBe(true); expect(hasPermission([], ap[0]!.permission)).toBe(false); expect(hasPermission(['payables.supplier.read'], ap[0]!.permission)).toBe(true);});-
Step 2: Run — expect failure.
pnpm -C web exec vitest run src/features/payables src/features/reports/APAgingView.test.tsx src/app/shell→ FAIL. -
Step 3: Implement.
PaymentsPage.tsx — the SuppliersPage shape over usePayments() with tableId="payables.payments", columns reference (required, mono), supplier (name from useSuppliers), date (formatDate(payment_date)), amount (required, MoneyText amount currency + base underneath when foreign), status (StatusChip kind="supplier_payment"), a New payment button behind payables.payment.create, LoadMore:
import { useMemo, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { LoadMore } from '@/components/ui/LoadMore';import { PageHeader } from '@/components/ui/PageHeader';import { MoneyText } from '@/components/money/MoneyText';import { StatusChip } from '@/components/workflow/StatusChip';import { useCan, useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { usePayments, useSuppliers, type SupplierPayment } from './api';
export function PaymentsPage(p: { onOpen(id: string): void; onNew(): void; children?: ReactNode }) { const { t } = useTranslation('payables'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const canCreate = useCan('payables.payment.create'); const list = usePayments(); const suppliers = useSuppliers(); const columns = useMemo<ColumnDef<SupplierPayment>[]>( () => [ { id: 'reference', meta: { required: true }, header: t('payments.columns.reference'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.reference}</span> }, { id: 'supplier', header: t('payments.columns.supplier'), cell: ({ row }) => suppliers.data?.find((s) => s.id === row.original.supplier_id)?.legal_name ?? '' }, { id: 'date', header: t('payments.columns.date'), cell: ({ row }) => formatDate(row.original.payment_date, locale) }, { id: 'amount', meta: { required: true }, header: t('payments.columns.amount'), cell: ({ row }) => ( <span className="font-bold"> <MoneyText amount={row.original.amount} currency={row.original.currency} /> {row.original.currency !== base && ( <span className="block text-caption font-normal text-fg-muted"><MoneyText amount={row.original.base_amount} currency={base} /></span> )} </span> ) }, { id: 'status', header: t('payments.columns.status'), cell: ({ row }) => <StatusChip kind="supplier_payment" state={row.original.state} semantic={row.original.semantic} /> }, ], [t, locale, base, suppliers.data], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('payments.title')} subtitle={t('payments.subtitle')} actions={canCreate ? <Button onClick={p.onNew}><Plus className="size-4" aria-hidden /> {t('payments.new')}</Button> : undefined} /> <DataTable tableId="payables.payments" columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(v: SupplierPayment) => v.id} onRowClick={(v: SupplierPayment) => p.onOpen(v.id)} empty={<EmptyState title={t('payments.empty.title')} description={t('payments.empty.description')} />} /> <LoadMore hasMore={Boolean(list.hasNextPage)} loading={list.isFetchingNextPage} onLoadMore={() => void list.fetchNextPage()} /> {p.children} </div> );}PaymentDialog.tsx:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { MoneyInput } from '@/components/money/MoneyInput';import { MoneyText } from '@/components/money/MoneyText';import { accountOptions, useAccounts } from '@/features/accounting/queries';import { useMe } from '@/features/auth/session';import { problemMessage } from '@/lib/api/problem';import { todayISO } from '@/lib/date';import { isMoneyInput, isZeroMoney, sumMoney } from '@/lib/money';import { useInvoices, usePaymentTransition, useSavePayment, useSuppliers } from './api';
export function PaymentDialog({ onClose, onDone }: { onClose(): void; onDone(id: string): void }) { const { t } = useTranslation('payables'); const { t: tc } = useTranslation(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const suppliers = useSuppliers(); const accounts = useAccounts(); const save = useSavePayment(); const move = usePaymentTransition(); // B5: submit through the mutation so the cache updates const [h, setH] = useState({ supplier_id: '', bank_account_id: '', payment_date: todayISO(), bank_reference: '', currency: base, fx_rate: '' }); const [pay, setPay] = useState<Record<string, string>>({}); const [error, setError] = useState(''); const supplier = suppliers.data?.find((s) => s.id === h.supplier_id); const invoices = useInvoices({ kind: 'purchase_invoice', supplier_id: h.supplier_id || undefined, state: 'approved' }); const open = (invoices.data ?? []).filter((i) => i.currency === h.currency && i.outstanding !== '0.0000'); const lines = open .filter((i) => (pay[i.id] ?? '') !== '' && isMoneyInput(pay[i.id]!) && !isZeroMoney(pay[i.id]!)) .map((i) => ({ invoice_id: i.id, amount: pay[i.id]! })); const amount = sumMoney(lines.map((l) => l.amount));
async function run(submit: boolean) { setError(''); try { const saved = await save.mutateAsync({ body: { supplier_id: h.supplier_id, bank_account_id: h.bank_account_id, payment_date: h.payment_date, bank_reference: h.bank_reference, currency: h.currency, fx_rate: h.currency !== base ? h.fx_rate || null : null, amount, lines } }); if (submit) await move.mutateAsync({ id: saved.payment.id, from: saved.payment.state, to: 'pending_approval' }); onDone(saved.payment.id); } catch (e) { setError(problemMessage(e, tc)); } } return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('payments.new')} footer={<> <Button variant="outline" onClick={() => void run(false)} disabled={save.isPending}>{t('invoices.saveDraft')}</Button> <Button onClick={() => void run(true)} loading={save.isPending}>{t('invoices.submit')}</Button> </>}> <div className="grid gap-4 md:grid-cols-2"> <Field label={t('payments.fields.supplier')} id="sp-sup"> <Select id="sp-sup" aria-label={t('payments.fields.supplier')} value={h.supplier_id || undefined} onValueChange={(x: string) => { setH((s) => ({ ...s, supplier_id: x })); setPay({}); }} options={(suppliers.data ?? []).filter((s) => s.is_active).map((s) => ({ value: s.id, label: `${s.code} · ${s.legal_name}` }))} /> </Field> <Field label={t('payments.fields.bankAccount')} id="sp-bank"> <Select id="sp-bank" aria-label={t('payments.fields.bankAccount')} value={h.bank_account_id || undefined} onValueChange={(x: string) => setH((s) => ({ ...s, bank_account_id: x }))} options={accountOptions(accounts.data, (a) => a.control_kind === 'bank' && a.active && a.postable)} /> </Field> <Field label={t('payments.fields.date')} id="sp-date"> <DatePicker id="sp-date" value={h.payment_date} onChange={(x?: string) => setH((s) => ({ ...s, payment_date: x ?? todayISO() }))} /> </Field> <Field label={t('payments.fields.reference')} id="sp-ref"> <Input id="sp-ref" maxLength={64} value={h.bank_reference} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, bank_reference: e.target.value }))} /> </Field> <Field label={t('payments.fields.currency')} id="sp-cur"> <Input id="sp-cur" maxLength={3} className="uppercase" value={h.currency} onChange={(e: ChangeEvent<HTMLInputElement>) => { setH((s) => ({ ...s, currency: e.target.value.toUpperCase() })); setPay({}); }} /> </Field> {h.currency !== base && ( <Field label={t('payments.fields.rate', { base })} id="sp-rate"> <Input id="sp-rate" inputMode="decimal" value={h.fx_rate} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, fx_rate: e.target.value }))} /> </Field> )} </div> {supplier && !supplier.bank_verified && ( <p role="status" className="mt-3 rounded-card border border-tone-amber-accent bg-tone-amber-soft p-3 text-body">{t('payments.unverified')}</p> )} {h.supplier_id && ( <section aria-label={t('payments.allocate.title')} className="mt-4 flex flex-col gap-2"> <h3 className="font-bold text-fg">{t('payments.allocate.title')}</h3> {open.length === 0 ? <p className="text-body text-fg-muted">{t('payments.allocate.none', { currency: h.currency })}</p> : open.map((i) => ( <div key={i.id} className="grid grid-cols-3 items-center gap-2"> <span className="font-mono text-caption">{i.reference} · {i.supplier_invoice_no}</span> <MoneyText amount={i.outstanding} currency={i.currency} /> <MoneyInput id={`pay-${i.id}`} aria-label={`${t('payments.allocate.pay')} ${i.reference}`} value={pay[i.id] ?? ''} currency={i.currency} onChange={(x) => setPay((s) => ({ ...s, [i.id]: x }))} /> </div> ))} <p className="text-right font-bold"><MoneyText amount={amount} currency={h.currency} /></p> </section> )} {error && <p role="alert" className="mt-3 text-caption text-tone-red-fg">{error}</p>} </Dialog> );}MoneyInput gains an optional 'aria-label'?: string prop passed through to its <input> (it has id only today). @/lib/money gains:
/** Sums decimal strings exactly (the same scaled-integer arithmetic subtractMoney uses). */export function sumMoney(values: string[]): string { return values.reduce((acc, v) => addMoney(acc, v), '0.0000');}— implement addMoney beside subtractMoney by the same method subtractMoney uses (read it: it scales to integer minor units with BigInt, never Number), and add a unit test expect(sumMoney(['100.00', '20.00'])).toBe('120.0000') to web/src/lib/money.test.ts (B4: the wire’s 4-decimal form, as subtractMoney returns; MoneyText still renders “RM 120.00”). No float touches money (nofloat.test.ts).
PaymentDrawer.tsx — InvoiceDrawer’s layout over usePayment(id) / usePaymentTransition(id): header reference · bank_reference, StatusChip kind="supplier_payment", amount (+ base), TransitionBar kind="supplier_payment" with onTake={(tr, reason) => move.mutateAsync({ from: v.payment.state, to: tr.to, ...(reason ? { reason } : {}) })}, the allocations list (read-only: source_kind, effective_on, amount, reversed marker), DecisionTimeline kind="supplier_payment":
import { useTranslation } from 'react-i18next';import { Drawer } from '@/components/ui/Drawer';import { Skeleton } from '@/components/ui/Skeleton';import { MoneyText } from '@/components/money/MoneyText';import { DecisionTimeline } from '@/components/workflow/DecisionTimeline';import { StatusChip } from '@/components/workflow/StatusChip';import { TransitionBar } from '@/components/workflow/TransitionBar';import { useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { usePayment, usePaymentTransition } from './api';
export function PaymentDrawer({ id, onClose }: { id: string; onClose(): void }) { const { t } = useTranslation('payables'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const q = usePayment(id); const move = usePaymentTransition(id); const v = q.data; return ( <Drawer open onOpenChange={(o: boolean) => !o && onClose()} title={t('payments.detailTitle')}> {!v ? <Skeleton className="h-96 w-full" /> : ( <div className="flex flex-col gap-4"> <div className="flex items-start justify-between gap-2"> <h3 className="text-subtitle font-bold text-fg">{v.payment.reference} · {v.payment.bank_reference}</h3> <StatusChip kind="supplier_payment" state={v.payment.state} semantic={v.payment.semantic} /> </div> <p className="text-caption text-fg-muted">{formatDate(v.payment.payment_date, locale)}</p> <div className="rounded-card bg-bg-subtle p-4"> <MoneyText className="text-subtitle font-bold" amount={v.payment.amount} currency={v.payment.currency} /> {v.payment.currency !== base && <p className="text-caption text-fg-muted"><MoneyText amount={v.payment.base_amount} currency={base} /></p>} </div> <TransitionBar kind="supplier_payment" transitions={v.available_transitions} onTake={(tr, reason) => move.mutateAsync({ from: v.payment.state, to: tr.to, ...(reason ? { reason } : {}) })} /> <section aria-labelledby="sp-alloc" className="rounded-card border border-border-subtle bg-surface p-4"> <h3 id="sp-alloc" className="mb-2 font-bold text-fg">{t('payments.allocate.title')}</h3> <ul className="flex flex-col gap-2 text-body"> {v.allocations.map((a) => ( <li key={a.id} className="flex justify-between"> <span>{formatDate(a.effective_on, locale)}{a.reverses_id && ` (${t('invoices.allocations.reversed')})`}</span> <MoneyText amount={a.amount} currency={a.currency} /> </li> ))} {v.allocations.length === 0 && v.lines.map((l) => ( <li key={l.invoice_id} className="flex justify-between text-fg-muted"> <span className="font-mono">{l.invoice_id.slice(0, 8)}</span> <MoneyText amount={l.amount} currency={v.payment.currency} /> </li> ))} </ul> </section> <DecisionTimeline kind="supplier_payment" currentState={v.payment.state} decisions={v.decisions} /> </div> )} </Drawer> );}routes.tsx — paymentsRoute (path: 'payables/payments', RequirePermission permission="payables.payment.read", children new → PaymentDialog, $id → PaymentDrawer), in the exact shape of suppliersRoute; routes gains paymentsRoute.addChildren([newPaymentRoute, paymentRoute]).
nav.ts: import Banknote; append after Purchase invoices:
{ group: 'accounting', to: '/payables/payments', labelKey: 'payables:nav.payments', icon: Banknote, permission: 'payables.payment.read', },APAgingView.tsx:
import { useTranslation } from 'react-i18next';import { Skeleton } from '@/components/ui/Skeleton';import { MoneyText } from '@/components/money/MoneyText';import { useAPAging } from '@/features/payables/api';
const KEYS = ['current', 'days_1_30', 'days_31_60', 'days_61_90', 'over_90', 'total'] as const;
export function APAgingView({ asOf }: { asOf: string }) { const { t } = useTranslation('payables'); const q = useAPAging(asOf); if (!q.data) return <Skeleton className="h-64 w-full" />; const { suppliers, totals, currency } = q.data; if (suppliers.length === 0) return <p className="text-body text-fg-muted">{t('aging.empty')}</p>; return ( <table className="w-full text-body"> <thead> <tr> <th className="text-left">{t('aging.supplier')}</th> {KEYS.map((k) => <th key={k} className="text-right">{t(`aging.buckets.${k}`)}</th>)} </tr> </thead> <tbody> {suppliers.map((s) => ( <tr key={s.supplier_id}> <td>{s.name}</td> {KEYS.map((k) => <td key={k} className="text-right">{s.buckets[k] === '0.0000' ? '–' : <MoneyText amount={s.buckets[k]} currency={currency} />}</td>)} </tr> ))} </tbody> <tfoot> <tr className="font-bold"> <td>{t('aging.buckets.total')}</td> {KEYS.map((k) => <td key={k} className="text-right"><MoneyText amount={totals[k]} currency={currency} /></td>)} </tr> </tfoot> </table> );}reports/routes.tsx: the report enum gains 'ap_aging'. ReportsPage.tsx: the tabs list gains 'ap_aging'; exportParams treats ap_aging like balance_sheet ({ as_of: asOf }); the as-of date picker shows for ap_aging too; the body renders <APAgingView asOf={asOf} /> for search.report === 'ap_aging'. reports.json page.tabs.ap_aging: en “AP aging”, ms “Penuaan AP”, zh “应付账龄”.
-
Step 4: Run passing.
pnpm -C web test && pnpm -C web typecheck && pnpm -C web lint→ PASS (including the nav test above andnofloat.test.ts). -
Step 5: Sabotage. Make
sumMoneyuseNumberaddition;nofloat.test.tsfails naminglib/money.ts; restore. -
Step 6: Commit.
git add web && git commit -m "AP Task 17: web supplier payments and AP aging tab" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 18: End to end, dev seed, roadmap, deploy notes, full suite
Section titled “Task 18: End to end, dev seed, roadmap, deploy notes, full suite”Files:
- Modify:
web/e2e/seed.ts(Finance gains the AP set),web/e2e/support.ts(ensureFinanceAP) - Create:
web/e2e/payables.spec.ts - Modify:
docs/ROADMAP.md,docs/DEPLOY_NOTES.md
Interfaces:
-
Consumes: the running dev stack (
make dev-stack,api+weblaunch configurations);as,api,runTag,uniqueReceiptfromweb/e2e/support.ts. -
Produces:
ensureFinanceAP(): Promise<void>; the e2e flow. -
Step 1: Write the e2e (it fails until the seed grants Finance).
web/e2e/seed.ts — FINANCE gains:
'payables.supplier.read', 'payables.supplier.manage', 'payables.invoice.read', 'payables.invoice.create', 'payables.invoice.transition', 'payables.payment.read', 'payables.payment.create', 'payables.payment.transition',(Not payables.supplier.verify_bank: the Owner verifies, which is the second person. A freshly provisioned org’s Finance already holds these through Ruling P3; the list keeps the dev seed explicit.)
web/e2e/support.ts:
/** Arrange, not under test: a dev org seeded before AP has a Finance role without the AP keys. */export async function ensureFinanceAP() { const owner = await api('owner'); const { data: roles } = await owner.GET('/org/roles'); const fin = roles?.roles.find((r) => r.name === 'Finance'); if (!fin) throw new Error('Finance role not seeded'); const keys = new Set(fin.permissions); for (const k of [ 'payables.supplier.read', 'payables.supplier.manage', 'payables.invoice.read', 'payables.invoice.create', 'payables.invoice.transition', 'payables.payment.read', 'payables.payment.create', 'payables.payment.transition', 'accounting.ledger.read', 'documents.document.upload', 'reports.read', ]) keys.add(k); await owner.PUT('/org/roles/{id}/permissions', { params: { path: { id: fin.id } }, body: { permissions: [...keys] } });}(roles[].permissions is the field GET /org/roles returns; confirm in schema.d.ts and use its name.)
web/e2e/payables.spec.ts:
import { expect, test, type Page } from '@playwright/test';import { as, ensureFinanceAP, runTag, uniqueReceipt } from './support';
const chip = (page: Page) => page.getByRole('dialog').getByRole('heading', { level: 3 }).locator('xpath=following-sibling::*[1]');
test.beforeAll(async () => { await ensureFinanceAP();});
test('supplier → invoice → approve → pay → aging shows zero', async ({ browser }) => { const tag = runTag(); const code = `E2E${tag.replace(/\D/g, '').slice(-6)}`;
// 1. Finance creates the supplier and enters bank details. const fin = await as(browser, 'finance'); await fin.goto('/payables/suppliers'); await fin.getByRole('button', { name: 'New supplier' }).first().click(); const sd = fin.getByRole('dialog'); await sd.getByLabel('Code').fill(code); await sd.getByLabel('Registered name').fill(`${tag} Bekalan Sdn Bhd`); await sd.getByRole('button', { name: 'Save' }).click(); await fin.getByRole('button', { name: 'Change bank details' }).click(); await fin.getByRole('combobox', { name: 'Bank' }).click(); await fin.getByRole('option').first().click(); await fin.getByLabel('Account number').fill('5140123456'); await fin.getByRole('button', { name: 'Save' }).click(); await expect(fin.getByText('Not verified')).toBeVisible();
// 2. The owner (a second person) verifies them. const own = await as(browser, 'owner'); await own.goto('/payables/suppliers'); await own.getByRole('row', { name: new RegExp(code) }).click(); await own.getByRole('button', { name: 'Verify bank details' }).click(); await expect(own.getByRole('dialog').getByText('Verified')).toBeVisible();
// 3. Finance enters and submits an invoice. await fin.goto('/payables/invoices'); await fin.getByRole('button', { name: 'New invoice' }).first().click(); const inv = fin.getByRole('dialog'); await inv.getByRole('combobox', { name: 'Supplier' }).click(); await fin.getByRole('option', { name: new RegExp(code) }).click(); await inv.getByLabel("Supplier's document number").fill(`${tag}-INV`); await inv.locator('input[type=file]').setInputFiles(uniqueReceipt()); await expect(inv.getByRole('button', { name: 'Remove receipt.png' })).toBeVisible(); await inv.getByRole('combobox', { name: 'What the document is' }).click(); await fin.getByRole('option', { name: 'Receipt without e-Invoice' }).click(); await inv.getByLabel('Why there is no e-Invoice').fill('Supplier issued a paper invoice'); await inv.getByLabel('Description').fill('Printer toner'); await inv.getByRole('combobox', { name: 'Account' }).click(); await fin.getByRole('option', { name: /6140/ }).click(); await inv.getByLabel('Net').fill('250.00'); await inv.getByRole('button', { name: 'Submit' }).click(); await expect(chip(fin)).toHaveText('Pending review');
// 4. The owner approves and posts it. await own.goto('/payables/invoices'); await own.getByRole('row', { name: new RegExp(`${tag}-INV`) }).click(); await own.getByRole('dialog').getByRole('button', { name: 'Approve and post', exact: true }).click(); await expect(chip(own)).toHaveText('Posted');
// PRESENCE (S5): posted and unpaid, the supplier is in AP aging. await own.goto('/reports?report=ap_aging'); await expect(own.getByText(`${tag} Bekalan Sdn Bhd`)).toBeVisible();
// 5. Finance prepares the payment; the owner approves it. await fin.goto('/payables/payments'); await fin.getByRole('button', { name: 'New payment' }).first().click(); const pd = fin.getByRole('dialog'); await pd.getByRole('combobox', { name: 'Supplier' }).click(); await fin.getByRole('option', { name: new RegExp(code) }).click(); await pd.getByRole('combobox', { name: 'Pay from' }).click(); await fin.getByRole('option', { name: /1520/ }).click(); await pd.getByLabel(/^Pay PI-/).fill('250.00'); await pd.getByRole('button', { name: 'Submit' }).click(); await expect(chip(fin)).toHaveText('Pending approval'); await own.goto('/payables/payments'); await own.getByRole('row').filter({ hasText: 'SP-' }).first().click(); await own.getByRole('dialog').getByRole('button', { name: 'Approve and record payment', exact: true }).click(); await expect(chip(own)).toHaveText('Paid');
// 6. ABSENCE: paid in full, the same supplier is gone from AP aging. await own.goto('/reports?report=ap_aging'); await expect(own.getByText(`${tag} Bekalan Sdn Bhd`)).toHaveCount(0);});Run: pnpm -C web exec playwright test e2e/payables.spec.ts against the dev stack → first run FAILS at step 1 if the org predates AP (Finance lacks the keys and ensureFinanceAP has not run) — confirm beforeAll fixes it; also re-provision the dev org once (POST /v1/org/provision as owner) so it gains the three workflow kinds and account 2105. Expected after that: PASS.
- Step 2: Roadmap and deploy notes.
docs/ROADMAP.md, Phase 2 — replace the AP line with:
- [x] AP: supplier role, purchase invoices, credit notes, aging, payment allocation Design: [`2026-09-26-accounts-payable-design.md`](superpowers/specs/2026-09-26-accounts-payable-design.md) (D1–D12), migrations `00300`–`00306` Plan: [`2026-09-26-phase-2-ap.md`](superpowers/plans/2026-09-26-phase-2-ap.md) (19 tasks, Rulings P1–P48) Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — re-provision every organization (Trade payables 2105, MY purchase tax codes, three workflow kinds) - Sub-ledger = control per supplier is a drift test (`internal/platform/payables/subledger_test.go`) - End to end: supplier → invoice → approve → pay → aging shows zero (`web/e2e/payables.spec.ts`)docs/DEPLOY_NOTES.md — a new top section “Phase 2 AP”: migrations 00300–00306 (party columns, tax_code.usage with existing codes sales, party_supplier, ap_trade, purchase_invoice*, supplier_payment*, ap_allocation, report_export gains ap_aging, existing Owners gain payables.*, existing Finance roles gain it without verify_bank plus accounting.ledger.read, and perm_version bumps so live sessions refresh once); after migrating, POST /v1/org/provision per organization creates account 2105 Trade payables (step payables_trade_payables) the MY purchase tax codes PST10, PST5, PSVT6, PSVT8, PNT (step payables_purchase_tax_codes), and seeds purchase_invoice, supplier_credit_note, supplier_payment (step workflow_definitions); nothing else to configure. Purchase lines accept only purchase/both codes; expense claims are unchanged.
- Step 3: Full suite, once.
Run: make generate && git diff --exit-code (generated files committed), then go test -p 2 -count=1 ./... and make web.
Expected: PASS. Any failure outside payables/reports/workflow/accounting is investigated, not skipped.
- Step 4: Commit.
git add -A && git commit -m "AP Task 18: e2e, dev seed, roadmap, deploy notes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Self-Review
Section titled “Self-Review”Spec coverage
Section titled “Spec coverage”| Spec item | Where |
|---|---|
D1 supplier as a role on party (shared fields, kind widened, party_supplier, code unique, TIN via taxrecord) |
Task 1 (schema), Task 3 (service), P5, P6 |
D2 ap_trade control, Trade payables seed, constants + chart tests, party_id on every line, sub-ledger drift test |
Task 2, Task 7 (requireBalancedWithPartyOnControl), Task 11 (TestSubledgerEqualsControlPerSupplier), P7 |
D3 purchase invoice kind in payables, header/lines/analytics, numbering, graph, accrual-only validator rule, grants, accrual rule |
Tasks 4, 5, 6, 7; P1, P8–P12, P17, P18 |
| D4 supplier payment kind, graph, preparer ≠ approver, payout rule with FX, allocation = amount | Tasks 5, 8, 9; P9, P20, P25, P27 |
| D5 supplier credit note (same header/lines, mirror posting, allocation posts nothing) | Tasks 5, 6 (P2), 7, 10 |
| D6 append-only allocation, reversal once, effective timing, outstanding guard, derived aging with as-of | Tasks 8, 9, 10, 11, 14; P20–P23, P31 |
D7 duplicate supplier number per kind, 409 naming the existing doc; e-Invoice identifiers without einvoice_employee |
Task 4 (index, CHECKs), Task 6 (DuplicateInvoiceError), Task 13 (HTTP 409); P19, P39 |
| D8 SST is cost unless the tax code names an account; tax code kept; tax within rounding; purchase-usage codes only (amendment 1) | Task 5A (usage, MY purchase codes, step), Task 6 (P14/P40 checks), Task 7 (posting split test), Task 16 (picker); P40 |
D9 currency + rate per document; same-currency allocation; realised FX on fx_gain_loss; no revaluation |
Tasks 6, 7, 9 (TestAForeignPaymentPostsRealisedFX), 10 (P23); P13 |
| D10 bank details verified by a second person (DB CHECK), unverified blocks approval, audit old → new | Task 1 (CHECK + guard), Task 3, Task 9 (P27), Task 12 (HTTP), Task 15 (UI); P26 |
| D11 never deleted; corrections by credit note or void (engine reversal); allocations first; retention lists | Tasks 1, 4, 8 (retention), 6/9 (void + P24), 7 (TestVoidReversesTheAccrual) |
D12 purchase requests not linked; no conflicting names (purchase_invoice table kept for Phase 4’s purchase_order_id) |
P2 (table named purchase_invoice) — nothing to build |
| Permissions (nine keys; aging permission decided; Owner + Finance as the Accounts role, verify_bank Owner-only — amendment 2) | Task 12 (permissions.go, RegisterRolePermissions, 00305, grants_test.go, wiring tests); P3, P4, P37 |
| API list (suppliers CRUD + verify, invoices/credit notes/payments list/get/create/update/transition, allocations list/create/reverse, supplier balance, aging JSON + CSV) | Tasks 12, 13, 14 |
Web (Accounting sidebar items, DataTable tableId, drawers with chip/timeline/transition bar, aging under Reports, en/ms/zh) |
Tasks 15, 16, 17; P35–P37 |
| Test: RLS/tenant isolation for every new table | Tasks 1, 4, 8 (existing drift tests run in each Step 4) |
Test: posting invoice, credit note, payment with and without FX, balanced, party_id on ap_trade |
Tasks 7, 9 |
| Test: sub-ledger = control per supplier after a mixed sequence | Task 11 |
| Test: over-allocation refused; reversal restores; double reversal refused | Tasks 8 (DB), 10 (service), 13 (HTTP) |
| Test: duplicate supplier invoice number refused, presence first | Tasks 4, 6, 13 |
| Test: bank verification same person refused; payment approval refused while unverified, presence first | Tasks 1, 3, 9, 12, 13 |
| Test: aging buckets at days 0, 1, 30, 31, 90, 91 and as of a past date | Task 11 |
| Test: retention — no DELETE on the new tables | Tasks 1, 4, 8 |
| Test: e2e supplier → invoice → approve → pay → aging shows zero | Task 18 (with the supplier shown in aging first, S5) |
| Pre-flight rulings (B1–B5, S1–S19, G10, cosmetics) | Tasks 2, 4, 5, 5A, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 (Task 1 untouched); P41–P48 |
Placeholder scan
Section titled “Placeholder scan”Searched the plan for “TBD”, “TODO”, “add validation”, “similar to Task”, “handle errors”, “implement later”: none remain as instructions. Three places deliberately tell the implementer to confirm a name against the tree before relying on it, with the command to do so, because the name is generated by Postgres or by codegen rather than written in source: party_kind_check (Task 1), workflow_state_control_kind_check (Task 2), and one generated TS field name (roles[].permissions — Task 18); TaxCode.active, Tag tones and IconButton‘s {icon, label} were confirmed against the tree in pre-flight. Two files are specified as byte-for-byte copies of named existing files with named substitutions (internal/modules/payables/http.go from advances; env_test.go from advances’ handler test harness).
Type and name consistency
Section titled “Type and name consistency”Checked across tasks: payables.Kind{Invoice,CreditNote,Payment} ↔ workflow kinds ↔ journal_entry.source_kind ↔ purchase_invoice.kind CHECK ↔ OpenAPI enums ↔ web InvoiceKind; ControlKind = "ap_trade" ↔ constants.yaml ↔ both CHECKs ↔ IsSubledgerKind ↔ web lists; state keys draft/pending_review/approved/rejected/cancelled/voided and draft/pending_approval/paid/… ↔ Task 6/9 test helpers ↔ Task 13 HTTP tests ↔ e2e labels (“Approve and post”, “Posted”, “Approve and record payment”, “Paid”); Allocation fields ↔ allocationDTO ↔ ApAllocation schema; decisionDTO ↔ DocumentDecision ↔ TimelineDecision (actor_member_id, action_key); BucketAmounts JSON keys ↔ AgingBuckets schema ↔ APAgingView KEYS ↔ locale aging.buckets.*; permission keys identical in constants.yaml, permissions.go, 00305, routes_test.go, wiring tests, seed.ts, support.ts, nav items. InvoiceOutstanding is introduced in Task 9 (after ap_allocation exists in Task 8) and only used from Task 9 on; Task 6 leaves a marked insertion point for its has-allocations check. After pre-flight: migration numbers follow task order (00300 T1, 00301 T2, 00302 T4, 00303 T5A, 00304 T8, 00305 T12, 00306 T14) in the File Structure table, each task’s Files/steps and P40; payablestest helpers are each defined once (NopPoster, TaxCode, Submitted, Approve, Void, GrantOwnerSeatTo in T6; Offset, ReverseOffset, ForeignCreditNote in T10; AdvanceClock in T11); useInvoiceTransition(id?) / usePaymentTransition(id?) take { id?, from, to, reason? } in the drawers (T16, T17) and dialogs alike; checkPayment(ctx, tx, &in, base) has one signature at both call sites; PaymentFilter.Currency ↔ listPayments currency query ↔ OpenAPI; ErrOverAllocation is 422 over_allocated in mapErr and in the Task 13 test; percentOfMoney/sumMoney return the 4-decimal wire form and their tests expect it.