Accounts Receivable 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 keeps customers, issues MyInvois-shaped sales invoices (Dr Trade receivables with the customer’s party_id / Cr income / Cr SST payable, each tax line carrying its tax code, the code also snapshotted on the invoice line), has a second person approve customer credit notes against an issued invoice, records customer receipts into a bank or cash account (allocated to invoices at the invoices’ frozen bases, realised FX on fx_gain_loss, a base-currency remainder held on account), and reads each customer’s balance, a statement of account and an AR aging report (JSON, CSV/XLSX/PDF, any “as of” date) whose per-customer total is provably equal to the Trade receivables control account per party. Issued documents are frozen; a clean data contract and an “issued” event are handed to the MyInvois item.
Architecture: One platform package and one module, AP’s shape. internal/platform/receivables owns every AR rule: customers on party (party_customer role row), the three seeded workflow graphs (sales_invoice, customer_credit_note, customer_receipt), the sales document (one table, kind column) with its frozen issue snapshot, receipts, the append-only ar_allocation, three posting rules, aging and the ledger-based statement. internal/modules/receivables is the HTTP shell (routes, DTOs, error mapping, permissions, boot, the invoice PDF). internal/modules/reports serves AR aging as JSON; internal/platform/reporting/export gains ar_aging and customer_statement. The web gains web/src/features/receivables, an AR aging tab and a statement section. accounting/ still reacts only to semantics; nothing here 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-27-accounts-receivable-design.md (D1–D20). Template: docs/superpowers/plans/2026-09-26-phase-2-ap.md — where a step below says “mirror AP”, open the named AP file and follow it line for line, changing only what the step names.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00310–00329only. This plan uses00310–00314. Task 1 narrows AP’s declared range to{300, 309, "Phase 2 AP"}and adds{310, 329, "Phase 2 AR"}ininternal/kernel/db/migrationranges_test.goin the same commit as00310(AP declared300–339, which would silently “own” AR’s files; the payment-batches lane will add its own330–339). - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (OpenAPIMoney). Rates and quantities aremoney.Rate/NUMERIC(19,10)/ OpenAPIRate(theexpense_claim_line.quantityprecedent). Compare withCmp, never==. Nofloat64ininternal/platform/receivables,internal/modules/receivables,web/src/features/receivables(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,CONSTRAINT <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/UNIQUE/FOREIGN KEYisCONSTRAINT <table>_<rule>; no name ends in_check,_key,_excl(TestEveryConstraintIsNamed). A trigger refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name. - Pinned functions.
SET search_path = pg_catalog, public, pg_temp, schema-qualified tables, noSECURITY DEFINER(TestEveryFunctionPinsItsSearchPath). The AR triggers reusepublic.payables_semantic(org, instance)from00302— despite its name it only reads an instance’s current semantic; it is not renamed (surgical). - Every SQL statement names
org_id = $nfor each org-scoped table it touches, joins included, and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org fromtenancy.OrgFromContext; nothing takes an org argument. - Retention. In
internal/kernel/db/retention_test.go:party_customer,sales_invoice,sales_invoice_line,customer_receipt,customer_receipt_lineareamendable(REVOKE DELETE);ar_allocationisappendOnly(REVOKE UPDATE, DELETE). - Accounting reacts to semantics. Rules are keyed
(kind, semantic); no Go or SQL here compares astate_keyto a literal except the seeded graphs and tests. - Modules never write journal lines.
receivables.Rules()is registered frominternal/modules/receivables/boot.go’sinit(). - Lock order, one for the whole plan: document header
FOR UPDATE(sales document or receipt) → a credit note’s original invoiceFOR UPDATE→ other invoice rowsFOR UPDATEinidorder (receipt allocation) →numbering.Allocate→workflow.Transition(instance lock, posting) → allocation inserts that need the posted state (a credit note’s self-allocation) → this plan’saudit.Write, last. Nothing writes an audit row beforeworkflow.Transitionin a transaction that transitions. - 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 (AP’s handlers do). - Business date is
clock.TodayUTC(c); everyDATEin Go is midnight UTC; nevertime.Now()in code under test. - Record-bearing text (customer legal name and address, line description, credit-note reason, customer reference, exemption reason, bank reference, the seller/buyer snapshot) is English or Bahasa Malaysia:
recordtext.Checkin Go and a namedCHECK (… !~ '[-ヿ㐀-䶿一-鿿가-]')in SQL, written out at every use. - Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. Platform code returns sentinel or typed errors. A field refusal is 422field=<name> rule=<code>: <sentence>(receivables.FieldError, AP’s shape). - Every route goes through
httpx.Protectedwith areceivables.*(orreports.*) permission and is inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces).api/public_routes.txtgains nothing.make generateafter every migration, OpenAPI orconstants.yamlchange; generated files are committed;git statusclean after it. - i18n. Every UI string is a key in
web/src/lib/i18n/locales/{en,ms,zh}/receivables.json(or the named existing namespace);i18nparity.test.tsrequires identical keys and placeholders. Malay is genuine Malay — the strings in this plan are the ones to use. 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, write the message into the commit body, restore. - Posting is process-global. A test that needs a Poster calls
receivablestest.Env.UseReceivablesPoster(t), nevert.Parallel(). - Tests:
testdb.Start(throughexpensestest.New) per test; run only the touched packages while iterating (go test ./internal/platform/receivables/... -count=1); one fullgo test -p 2 -count=1 ./...in Task 14 only. - E2E on lane 1 only: pg
56412, minio59110, api3110, vite5192. Never the user’s dev stack (55432/59000/3000/5173). - Never bare
git stash. Set work aside with a WIP commit. - Git commits end with:
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”Plan-level choices the spec leaves open. Format: Ruling: <decision> — <why>.
- R1 Ruling: Kinds and entities are
sales_invoice,customer_credit_note,customer_receipt; tablesparty_customer,sales_invoice,sales_invoice_line,customer_receipt,customer_receipt_line,ar_allocation; audit entity kinds are the table names;receivables.ControlKind = "ar"— one vocabulary, used everywhere (workflow kind =sales_invoice.kind=journal_entry.source_kind). - R2 Ruling: Reference prefixes
INV,CRN,RCTfromnumbering.Allocate(kind, fiscal EndYear), allocated at issue / at recording (spec D5, D8), sosales_invoice.referenceandcustomer_receipt.referenceare NULL while a draft. (CNis AP’s supplier credit note;CRNavoids two “CN-2026-0001”s.) - R3 Ruling: A customer’s id on the wire is its
party_id(AP P6). Supplier and customer are separate parties in v1 (spec D1). - R4 Ruling: State keys — invoice
draft, issued, cancelled; credit notedraft, pending_approval, issued, rejected, cancelled; receiptdraft, received, voided, cancelled. Action keysissue,withdraw,submit,issue(credit note, target-gated),reject,revise,record,void.issuedis terminal on both document kinds (spec D5). - R5 Ruling: Seeded grants: credit note
issued→ OWNER seat; receiptvoided→ OWNER seat. The invoice graph has no grant (only reporter-gated edges). - R6 Ruling:
workflow.AccrualOnlyKindsgainssales_invoice,customer_credit_note.SignoffKindsis unchanged: no seeded step to sign, and revenue lines’ analytic shares go to the ledger (Distribution), not to the engine. Every draft save callsworkflow.SetAmount(gross_base)(spec D5). - R7 Ruling: A line’s income account is active, postable, carries no control kind and is of type
income(checkRevenueAccount). - R8 Ruling: A draft save (
PUT) replaces lines wholesale; old lines are withdrawn (withdrawn_at), never deleted (AP P16). Only the preparer edits a draft (APErrNotPreparer). - R9 Ruling: The tax code on a line must be active, of usage
sales/both, and valid on today’s date at every save and again at issue (the invoice date is the issue date, spec D5). Positive rate ⇒account_idrequired (422tax_code_account). - R10 Ruling:
net_amount = unit_price.MulRateCents(quantity) − discount_amount; must be > 0; discount ≥ 0 and in cents;unit_price≥ 0 with at most 4 decimals (the column’s scale);tax_amount = net_amount.PercentCents(tax_rate). Basesnet.MulRate(fx),tax.MulRate(fx)(AP P13). - R11 Ruling: Submission (credit note
draft → pending_approval) and issue both runcheckIssuable: ≥ 1 line, an active customer, every line’s tax code still valid today, and for a credit note an issued original of the same customer and the cap (spec D6). Issue additionally runs the snapshot. - R12 Ruling:
cancelled_atis set by the transition service on entering acancelled-semantic state (withdraw). An issued document is never cancelled in v1. - R13 Ruling: A receipt’s planned lines become
ar_allocationrows on entering its payout state, in that transaction, beforeworkflow.Transition(AP P20); voiding inserts the reversals first. A credit note’s self-allocation is inserted afterworkflow.Transition, because the allocation guard requires the credit note’saccrual_open. - R14 Ruling:
POST /receivables/allocationstakes{invoice_id, amount}and exactly one ofcredit_note_id/receipt_id; a receipt source must bereceived, in the base currency (spec D9); a credit-note source must share the invoice’s rate (AP P23).POST /receivables/allocations/{id}/reverserefuses (409void_the_receipt) a row whose receipt is in a foreign currency. - R15 Ruling: Aging reuses
payables.BucketFor,payables.Buckets,payables.BucketAmounts(spec D13). Receipt remainders are negative rows datedreceipt_date; their base isremainder × fx_rate(0 for a foreign receipt, which is fully allocated). - R16 Ruling: Statement lines are journal lines of
kind = 'normal'on accounts withcontrol_kind = 'ar',party_id= the customer,currency= the chosen currency (default: the customer’s currency),entry_datein[from, to], ordered byentry_date, posted_seq, line_no(the general ledger’s order;entry_nois text, numbered per journal); opening = Σ(debit − credit) beforefrom. - R17 Ruling: Lists are keyset-paginated on
(created_at DESC, id DESC)(AP P30,cursor.gocopied); customers ordered by code, capped at 1000. - R18 Ruling:
GET /receivables/invoices/{id}/pdfis synchronous (a document is ≤ 200 lines), rendered withrender.New(render.FormatPDF),Content-Disposition: attachment; filename="<reference>.pdf", underreceivables.invoice.read; a draft is 409not_issued. - R19 Ruling:
GET /receivables/deposit-accountsunderreceivables.receipt.createlists active postable accounts of control kindbankplus the default accounts of activecashjournals (spec D8), never an account with anowner_module(a petty-cash float), in code order. - R20 Ruling: The export
ParamsgainsCustomerID *uuid.UUID(customer_id) andCurrency string(currency);ar_agingtakesas_of;customer_statementtakesfrom,to,customer_id(required),currency(optional).Createrefuses an unknown customer asInvalidParamsError{Field: "customer_id"}; the worker treatsreceivables.ErrNotFoundasinvalid_parameters(permanent). - R21 Ruling: No reconciliation check is registered in v1; “sub-ledger = control” is the drift test of Task 7 (AP P34).
- R22 Ruling (web): three nav items appended to the
accountinggroup (/receivables/customers,/receivables/invoices,/receivables/receipts); credit notes are a tab of the invoices page; AR aging a tab of/reports; the statement a section of the customer drawer withExportMenu. - R23 Ruling:
EventDocumentIssued = "receivables.document_issued", payloadDocumentIssued{OrgID, DocumentID uuid.UUID; Kind, Reference string}, published witheventbus.Publishafter the audit write (spec D20).
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00310_party_customer.sql |
1 | party.registration_type, party.sst_registration_no; party_customer. |
00311_sales_invoice.sql |
3 | sales_invoice, sales_invoice_line, freeze triggers. |
00312_customer_receipt_allocation.sql |
5 | customer_receipt, customer_receipt_line, ar_allocation and its guard. |
00313_receivables_owner_grants.sql |
8 | Existing Owner and Finance roles get the eight receivables.* keys. |
00314_report_export_ar.sql |
10 | report_export_report_known gains ar_aging, customer_statement. |
New Go packages
Section titled “New Go packages”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/receivables/ |
1–7 | doc.go, customer.go, graphs.go, document.go, transition.go, posting.go, receipt.go, allocation.go, aging.go, statement.go, cursor.go |
internal/platform/receivables/receivablestest/ |
1 (grown in 3–7) | Test world on expensestest.Env. |
internal/modules/receivables/ |
8, 9 | permissions.go, routes.go, boot.go, http.go, errors.go, customer_handlers.go, document_handlers.go, receipt_handlers.go, pdf.go |
web/src/features/receivables/ |
11–13 | API hooks, pages, drawers, dialogs. |
Modified (shared)
Section titled “Modified (shared)”| Path | Task | Change |
|---|---|---|
internal/kernel/db/migrationranges_test.go |
1 | AP {300,309}, AR {310,329}. |
internal/kernel/db/retention_test.go |
1, 3, 5 | New tables into their lists. |
internal/kernel/money/rate.go (+ test) |
3 | MulRateCents, PercentCents. |
internal/platform/workflow/model.go |
2 | AccrualOnlyKinds += two kinds. |
constants.yaml (+ generated vocab) |
8 | Eight permissions. |
internal/platform/reporting/export/{export.go,params.go,project.go,run.go} |
10 | ar_aging, customer_statement. |
internal/modules/reports/{routes.go,aging_handlers.go} |
10 | GET /reports/ar-aging. |
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) |
8, 9, 10 | /receivables/…, /reports/ar-aging, export enums. |
sqlc.yaml |
3, 5 | money.Rate overrides: sales_invoice.fx_rate, sales_invoice_line.quantity, sales_invoice_line.tax_rate, customer_receipt.fx_rate. |
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go |
8 | Compose the module. |
web/src/app/shell/nav.ts, web/src/app/routes.ts |
11 | Three nav items; routes. |
web/src/lib/money.ts (+ test) |
12 | lineNet(unitPrice, quantity, discount). |
web/src/features/reports/{ReportsPage.tsx} + ARAgingView.tsx |
13 | AR aging tab. |
scripts/dev-stack.sh, web/playwright.config.ts |
14 | Lane-port overrides (if not already on the integration branch). |
web/e2e/seed.ts, web/e2e/support.ts, web/e2e/receivables.spec.ts |
14 | Finance’s AR keys; the e2e. |
docs/ROADMAP.md, docs/DEPLOY_NOTES.md |
14 | Tick AR; deploy notes. |
Tasks in one wave touch disjoint files and may run in parallel (each on its own worktree, merged in task order). api/openapi.yaml and the generated clients are one serial lane (8 → 9 → 10).
| Wave | Tasks | Why this grouping |
|---|---|---|
| 1 | 1 | Range, party_customer, customer service, test world — everything builds on it. |
| 2 | 2 | Seeded graphs; the test world’s provisioning needs them before any document exists. |
| 3 | 3 ∥ 8 | 3: sales document schema/service (platform only). 8: module, permissions, customer routes, OpenAPI (module + app + cmd + 00313). Disjoint files. |
| 4 | 4 ∥ 11 | 4: issue + accrual posting (platform). 11: web customers (needs 8’s generated client). |
| 5 | 5 | Receipt + allocation schema and receipt drafts. |
| 6 | 6 | Receipt posting, allocation service, credit-note self-allocation. |
| 7 | 7 | Aging, statement, sub-ledger drift test. |
| 8 | 9 | Document, allocation, receipt routes + PDF (OpenAPI lane). |
| 9 | 10 ∥ 12 | 10: reports/export (OpenAPI lane). 12: web invoices and credit notes (needs 9’s client). |
| 10 | 13 | Web receipts, AR aging tab, statement (needs 10’s client). |
| 11 | 14 | E2E, seed, roadmap, deploy notes, full suite. |
Task 1: Migration range, party_customer, customer service, test world
Section titled “Task 1: Migration range, party_customer, customer service, test world”Files:
- Create:
db/migrations/00310_party_customer.sql - Create:
internal/platform/receivables/doc.go,internal/platform/receivables/customer.go - Create:
internal/platform/receivables/receivablestest/env.go - Modify:
internal/kernel/db/migrationranges_test.go,internal/kernel/db/retention_test.go - Test:
internal/platform/receivables/schema_test.go,internal/platform/receivables/customer_test.go
Interfaces:
-
Consumes:
expensestest.New/Another/Tx/TxErr,payables-style helpers (copied, not imported: a package’s unexported helpers are not reachable),taxrecord.Normalize,recordtext.Check,chart.GetAccount,chart.GetTaxCode,audit.Write. -
Produces: tables
party(+2 columns),party_customer; constantsreceivables.KindInvoice = "sales_invoice",KindCreditNote = "customer_credit_note",KindReceipt = "customer_receipt",ControlKind = "ar",EntityCustomer = "party_customer",EntityDocument = "sales_invoice",EntityReceipt = "customer_receipt",EntityAllocation = "ar_allocation",SourceKinds; typesFieldError,RevisionMismatchError,ErrNotFound,ErrDuplicateCustomerCode;Customer,CustomerInput,CustomerFilter,Address; funcsGetCustomer,ListCustomers,CreateCustomer,UpdateCustomer;receivablestest.Env{*expensestest.Env}withNew,Another,As,Account,TaxCode,Customer(t, code). -
Step 1: Register the range and write the failing schema test.
internal/kernel/db/migrationranges_test.go — replace {300, 339, "Phase 2 AP"}, with:
{300, 309, "Phase 2 AP"}, {310, 329, "Phase 2 AR"},internal/platform/receivables/schema_test.go (package receivables_test) — copy AP’s requirePgError from internal/platform/payables/schema_test.go, then:
// rawCustomer inserts a party and its customer role as erp_app.func rawCustomer(ctx context.Context, tx pgx.Tx, org uuid.UUID, code 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, code+" Sdn Bhd").Scan(&party); err != nil { return uuid.Nil, err } _, err := tx.Exec(ctx, ` INSERT INTO party_customer (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 TestPartyTakesTheBuyerIdentifiers(t *testing.T) { e := expensestest.New(t) // PRESENCE: an NRIC-identified individual with an SST number 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_type, registration_no, sst_registration_no, country_code) VALUES ($1, 'individual', 'Tan Wei Ming', 'IG12345678090', 'nric', '900101-14-5678', 'W10-1808-31000123', 'MY')`, e.Org) return err }) // ABSENCE: an unknown type, a type without a number, a malformed SST number. for _, c := range []struct{ sql, constraint string }{ {`INSERT INTO party (org_id, kind, legal_name, registration_type, registration_no) VALUES ($1, 'organization', 'X', 'licence', 'A1234')`, "party_registration_type_known"}, {`INSERT INTO party (org_id, kind, legal_name, registration_type) VALUES ($1, 'organization', 'X', 'brn')`, "party_registration_type_needs_no"}, {`INSERT INTO party (org_id, kind, legal_name, sst_registration_no) VALUES ($1, 'organization', 'X', 'w1')`, "party_sst_registration_no_shape"}, } { err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, c.sql, e.Org); return err }) requirePgError(t, err, "23514", c.constraint) }}
func TestCustomerCodeIsUniquePerOrganization(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 := rawCustomer(ctx, tx, e.Org, "KEDAI"); return err }) other.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := rawCustomer(ctx, tx, other.Org, "KEDAI"); return err }) // ABSENCE: twice in one is refused. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := rawCustomer(ctx, tx, e.Org, "KEDAI"); return err }) requirePgError(t, err, "23505", "party_customer_code_once")}In retention_test.go, add "party_customer" to amendable (alphabetical placement as the list uses).
- Step 2: Run it — expect failure.
Run: go test ./internal/platform/receivables/... ./internal/kernel/db/... -count=1
Expected: FAIL — no Go files in receivables, then column "registration_type" of relation "party" does not exist; the retention test fails on party_customer missing.
- Step 3: Write the migration.
db/migrations/00310_party_customer.sql:
-- +goose Up
-- AR spec D1: customer is a ROLE on party (AP D1's promise). party gains the-- two identifiers a MyInvois buyer needs that AP did not: what kind of-- document registration_no is, and the SST registration number (NULL = not-- registered; the MyInvois item maps it to "NA").ALTER TABLE party ADD COLUMN registration_type TEXT, ADD COLUMN sst_registration_no TEXT;ALTER TABLE party ADD CONSTRAINT party_registration_type_known CHECK (registration_type IS NULL OR registration_type IN ('brn','nric','passport','army'));-- Existing suppliers carry a registration_no with no type, so the rule is one-way.ALTER TABLE party ADD CONSTRAINT party_registration_type_needs_no CHECK (registration_type IS NULL OR registration_no IS NOT NULL);ALTER TABLE party ADD CONSTRAINT party_sst_registration_no_shape CHECK (sst_registration_no IS NULL OR sst_registration_no ~ '^[A-Z0-9][A-Z0-9;-]{3,34}$');
CREATE TABLE party_customer ( party_id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT party_customer_code_format CHECK (code ~ '^[A-Z0-9][A-Z0-9_-]{0,31}$'), payment_terms_days INT NOT NULL CONSTRAINT party_customer_terms_range CHECK (payment_terms_days BETWEEN 0 AND 365), currency TEXT NOT NULL CONSTRAINT party_customer_currency_code CHECK (currency ~ '^[A-Z]{3}$'), default_revenue_account_id UUID, default_tax_code_id UUID, is_active BOOLEAN NOT NULL DEFAULT true, revision INT NOT NULL DEFAULT 1 CONSTRAINT party_customer_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT party_customer_tenant_id UNIQUE (org_id, party_id), CONSTRAINT party_customer_code_once UNIQUE (org_id, code), CONSTRAINT party_customer_party_fk FOREIGN KEY (org_id, party_id) REFERENCES party (org_id, id), CONSTRAINT party_customer_revenue_account_fk FOREIGN KEY (org_id, default_revenue_account_id) REFERENCES account (org_id, id), CONSTRAINT party_customer_tax_code_fk FOREIGN KEY (org_id, default_tax_code_id) REFERENCES tax_code (org_id, id));ALTER TABLE party_customer ENABLE ROW LEVEL SECURITY;ALTER TABLE party_customer FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON party_customer 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_customer FROM erp_app;
-- +goose DownDROP TABLE party_customer;ALTER TABLE party DROP CONSTRAINT party_sst_registration_no_shape, DROP CONSTRAINT party_registration_type_needs_no, DROP CONSTRAINT party_registration_type_known;ALTER TABLE party DROP COLUMN sst_registration_no, DROP COLUMN registration_type;- Step 4: Write
doc.go.
Mirror internal/platform/payables/doc.go with the constants listed under Produces, package doc:
// Package receivables is accounts receivable (spec 2026-09-27 AR design):// the customer role on party, sales invoices and customer credit notes (one// table, kind = the workflow kind) shaped for MyInvois and frozen once// issued, customer receipts, the append-only allocation of receipts and// credit notes to invoices, and the aging and statements derived from them.// Every posting goes through accounting's posting engine: this package// registers rules keyed (kind, semantic) and never writes a journal line.package receivablesplus var SourceKinds = []string{KindInvoice, KindCreditNote, KindReceipt}, FieldError/fieldErr, RevisionMismatchError, ErrNotFound = errors.New("receivables: no such record"), and orgOf(ctx) (AP supplier.go’s, copied).
- Step 5: Write the failing service test.
internal/platform/receivables/customer_test.go:
func TestACustomerIsAPartyWithTheCustomerRole(t *testing.T) { e := receivablestest.New(t) c := e.Customer(t, "KEDAI") require.Equal(t, "KEDAI Sdn Bhd", c.LegalName) require.Equal(t, "MYR", c.Currency) var kind string require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT kind FROM party WHERE org_id = $1 AND id = $2`, e.Org, c.PartyID).Scan(&kind)) require.Equal(t, "organization", kind)}
func TestCustomerFieldsAreRefusedByName(t *testing.T) { e := receivablestest.New(t) ok := receivables.CustomerInput{Code: "KEDAI", PartyKind: "organization", LegalName: "Kedai Runcit Sdn Bhd", CountryCode: "MY", PaymentTermsDays: 30, Currency: "MYR"} // PRESENCE: the valid input is accepted. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateCustomer(ctx, tx, e.Clock, ok); return err }) for _, c := range []struct { mut func(*receivables.CustomerInput) rule string }{ {func(in *receivables.CustomerInput) { in.Code = "bad code" }, "code_format"}, {func(in *receivables.CustomerInput) { in.LegalName = "吉隆坡贸易" }, "record_language"}, {func(in *receivables.CustomerInput) { tin := "nope"; in.TIN = &tin }, "tin_shape"}, {func(in *receivables.CustomerInput) { ty := "brn"; in.RegistrationType = &ty }, "registration_needs_number"}, {func(in *receivables.CustomerInput) { s := "w1"; in.SSTRegistrationNo = &s }, "sst_registration_no_shape"}, {func(in *receivables.CustomerInput) { a := e.Account(t, "6140"); in.DefaultRevenueAccountID = &a }, "account_usable"}, {func(in *receivables.CustomerInput) { tc := e.TaxCode(t, "PSVT8"); in.DefaultTaxCodeID = &tc }, "tax_code_usage"}, } { in := ok in.Code = "KEDAI2" c.mut(&in) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateCustomer(ctx, tx, e.Clock, in); return err }) var fe *receivables.FieldError require.ErrorAs(t, err, &fe, c.rule) require.Equal(t, c.rule, fe.Rule) } // A duplicate code is its own sentinel. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateCustomer(ctx, tx, e.Clock, ok); return err }) require.ErrorIs(t, err, receivables.ErrDuplicateCustomerCode)}
func TestUpdateCustomerChecksTheRevisionAndAudits(t *testing.T) { e := receivablestest.New(t) c := e.Customer(t, "KEDAI") in := receivables.CustomerInput{Code: c.Code, PartyKind: "organization", LegalName: "Kedai Baharu Sdn Bhd", CountryCode: "MY", PaymentTermsDays: 14, Currency: "MYR", IsActive: true} // PRESENCE: at the current revision the update lands. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.UpdateCustomer(ctx, tx, e.Clock, c.PartyID, c.Revision, in) return err }) // ABSENCE: the stale revision is refused. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.UpdateCustomer(ctx, tx, e.Clock, c.PartyID, c.Revision, in) return err }) var rm *receivables.RevisionMismatchError require.ErrorAs(t, err, &rm) require.Equal(t, 2, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'party_customer'`, e.Org))}-
Step 6: Run — expect failure (
undefined: receivablestest.New). -
Step 7: Write
customer.goand the test world.
customer.go mirrors internal/platform/payables/supplier.go without any bank code (Bank, SetBank, VerifyBank, lockSupplier’s SHARE use, masking): supplierSelect → customerSelect over party_customer c JOIN party p adding p.registration_type, p.sst_registration_no and c.default_revenue_account_id; Customer has the supplier’s shared fields plus RegistrationType, SSTRegistrationNo *string, DefaultRevenueAccountID. validate is AP’s, with these changes:
// D1: the identification type needs its number, and the number its type, // for a customer (a supplier's untyped number predates the column). switch { case in.RegistrationType != nil && (in.RegistrationNo == nil || strings.TrimSpace(*in.RegistrationNo) == ""): return in, fieldErr("registration_type", "registration_needs_number", "give the number for this identification type") case in.RegistrationType != nil && !slices.Contains([]string{"brn", "nric", "passport", "army"}, *in.RegistrationType): return in, fieldErr("registration_type", "registration_type_known", "a business registration, NRIC, passport or armed forces number") case in.RegistrationNo != nil && strings.TrimSpace(*in.RegistrationNo) != "" && in.RegistrationType == nil: return in, fieldErr("registration_type", "registration_needs_type", "say what kind of number this is") case in.SSTRegistrationNo != nil && *in.SSTRegistrationNo != "" && !sstShape.MatchString(strings.ToUpper(*in.SSTRegistrationNo)): return in, fieldErr("sst_registration_no", "sst_registration_no_shape", "an SST registration number such as W10-1808-31000123") }with sstShape = regexp.MustCompile(^[A-Z0-9][A-Z0-9;-]{3,34}$) (the Go twin of party_sst_registration_no_shape); the default revenue account is checked with checkRevenueAccount (R7):
// checkRevenueAccount is Ruling R7: postable, active, no control kind, income.func checkRevenueAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { a, err := chart.GetAccount(ctx, tx, id) switch { case errors.Is(err, chart.ErrNotFound): return errors.New("no such account") case err != nil: return err 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 sales line") case a.Type != chart.TypeIncome: return errors.New("a sales line is credited to an income account") } return nil}(validate returns a real DB error from it unchanged and wraps only these sentences as fieldErr(..., "account_usable", ...)). The default tax code must be active and of usage sales/both (tax_code_usage otherwise). CreateCustomer inserts party (with the two new columns) then party_customer, audits create; UpdateCustomer locks FOR UPDATE OF c, checks the revision, updates both rows, audits update with before/after; ErrDuplicateCustomerCode maps party_customer_code_once.
receivablestest/env.go (package doc as AP’s):
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)} }func (e *Env) As(member uuid.UUID) context.Context { return audit.WithActor(e.Ctx, e.Org, member) }
// Account and TaxCode: payablestest's, copied.
// Customer is created by e.Staff, MYR, 30 days, revenue 4100, tax OS.func (e *Env) Customer(t *testing.T, code string) receivables.Customer { t.Helper() rev, os := e.Account(t, "4100"), e.TaxCode(t, "OS") var c receivables.Customer require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error c, err = receivables.CreateCustomer(ctx, tx, e.Clock, receivables.CustomerInput{ Code: code, PartyKind: "organization", LegalName: code + " Sdn Bhd", CountryCode: "MY", Address: receivables.Address{Line1: "Lot 5, Jalan Niaga", City: "Shah Alam", Postcode: "40000", State: "Selangor"}, PaymentTermsDays: 30, Currency: "MYR", DefaultRevenueAccountID: &rev, DefaultTaxCodeID: &os}) return err })) return c}-
Step 8: Run — expect PASS.
go test ./internal/platform/receivables/... ./internal/kernel/db/... -count=1 -
Step 9: Sabotage. Delete
party_registration_type_needs_nofrom the migration, reset the test DB, runTestPartyTakesTheBuyerIdentifiers: red onparty_registration_type_needs_no. Restore. Then comment out theregistration_needs_numbercase invalidate:TestCustomerFieldsAreRefusedByNamered. Restore. Record both messages in the commit body. -
Step 10: Commit.
git add -A && git commit -m "AR Task 1: migration range, party_customer, customer service" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 2: Workflow kinds and the three seeded graphs
Section titled “Task 2: Workflow kinds and the three seeded graphs”Files:
- Create:
internal/platform/receivables/graphs.go - Modify:
internal/platform/workflow/model.go - Test:
internal/platform/receivables/graphs_test.go
Interfaces:
-
Consumes:
workflow.RegisterSeededGraph,workflow.ValidateGraph,workflow.Graph.Normalized. -
Produces:
receivables.InvoiceGraph,CreditNoteGraph,ReceiptGraph(func(ownerSeat uuid.UUID) workflow.Graph), registered ininit();workflow.AccrualOnlyKindsincludes both document kinds. -
Step 1: Failing test.
graphs_test.go (package receivables_test):
func TestTheSeededGraphsPassTheValidator(t *testing.T) { seat := uuid.New() for _, g := range []workflow.Graph{receivables.InvoiceGraph(seat), receivables.CreditNoteGraph(seat), receivables.ReceiptGraph(seat)} { require.Empty(t, workflow.ValidateGraph(g, "MYR"), g.Kind) }}
// The accrual-only rule now covers the sales side: a payout state on a sales// invoice would let it pay itself (presence first: the seeded graph passes).func TestASalesInvoiceCannotCarryAPayout(t *testing.T) { g := receivables.InvoiceGraph(uuid.New()) require.Empty(t, workflow.ValidateGraph(g, "MYR")) g.States = append(g.States, workflow.StateSpec{Key: "paid", Semantic: posting.Payout, DisplayOrder: 9, IsTerminal: true, Labels: workflow.Labels{"en": "Paid", "ms": "Dibayar", "zh": "已付"}}) g.Edges = append(g.Edges, workflow.EdgeSpec{From: "draft", To: "paid", Gate: workflow.GateReporter, ActionKey: "pay", DisplayOrder: 9, Labels: workflow.Labels{"en": "Pay", "ms": "Bayar", "zh": "付款"}}) codes := []string{} for _, v := range workflow.ValidateGraph(g, "MYR") { codes = append(codes, v.Code) } require.Contains(t, codes, "payout_in_accrual_only")}
// Spec D5: the issued invoice is final in v1 — no edge leaves it.func TestAnIssuedInvoiceHasNoExit(t *testing.T) { for _, g := range []workflow.Graph{receivables.InvoiceGraph(uuid.New()), receivables.CreditNoteGraph(uuid.New())} { s, ok := g.State("issued") require.True(t, ok) require.True(t, s.IsTerminal) require.Equal(t, posting.Accrual, s.Semantic) require.Equal(t, "ar", s.ControlKind) for _, e := range g.Edges { require.NotEqual(t, "issued", e.From, "%s: %s->%s", g.Kind, e.From, e.To) } }}-
Step 2: Run — expect failure (
undefined: receivables.InvoiceGraph). -
Step 3: Write the graphs.
internal/platform/workflow/model.go:
// AccrualOnlyKinds post exactly one accrual and are never paid by their own// flow (AP spec D3): a supplier_payment or a customer_receipt settles them// (AR spec D5).var AccrualOnlyKinds = []string{"purchase_invoice", "supplier_credit_note", "sales_invoice", "customer_credit_note"}internal/platform/receivables/graphs.go:
package receivables
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(KindReceipt, ReceiptGraph)}
// ls, st, ed, why, seat: payables/graphs.go's helpers, copied; st names// ControlKind (ar, spec D2) on an accrual state.
// InvoiceGraph is spec D5: issued by its preparer, final.//// draft ──issue──▶ issued (accrual, terminal)// └──withdraw──▶ cancelledfunc InvoiceGraph(uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: KindInvoice, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿", "Draf")), st(2, "issued", posting.Accrual, true, ls("Issued", "已开具", "Telah dikeluarkan")), st(3, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回", "Ditarik balik")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "issued", workflow.GateReporter, "issue", ls("Issue invoice", "开具发票", "Keluarkan invois")), why(ed(2, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回", "Tarik balik")), false), }, }.Normalized()}
// CreditNoteGraph is spec D6: a second person issues it.//// draft ──submit──▶ pending_approval ──issue──▶ issued (accrual, terminal)// │ └──reject──▶ rejected ──revise──▶ draft// └──withdraw──▶ cancelledfunc CreditNoteGraph(ownerSeat uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: KindCreditNote, PostingOrder: workflow.AccrualFirst, 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, "issued", posting.Accrual, true, ls("Issued", "已开具", "Telah dikeluarkan")), st(4, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝", "Ditolak")), st(5, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回", "Ditarik balik")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_approval", workflow.GateReporter, "submit", ls("Submit", "提交", "Hantar")), ed(2, "pending_approval", "issued", workflow.GateTarget, "issue", ls("Approve and issue", "批准并开具", "Luluskan dan keluarkan")), 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), }, Grants: []workflow.GrantSpec{seat("issued", ownerSeat)}, }.Normalized()}
// ReceiptGraph is spec D8, cash_only: recorded by its preparer, voided by a// second person.//// draft ──record──▶ received (payout) ──void──▶ voided (reverses)// └──withdraw──▶ cancelledfunc ReceiptGraph(ownerSeat uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: KindReceipt, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿", "Draf")), st(2, "received", posting.Payout, false, ls("Received", "已收款", "Telah diterima")), st(3, "voided", posting.Cancelled, true, ls("Voided", "已作废", "Dibatalkan")), st(4, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回", "Ditarik balik")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "received", workflow.GateReporter, "record", ls("Record receipt", "记录收款", "Rekod penerimaan")), why(ed(2, "received", "voided", workflow.GateTarget, "void", ls("Void", "作废", "Batalkan")), true), why(ed(3, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回", "Tarik balik")), false), }, Grants: []workflow.GrantSpec{seat("voided", ownerSeat)}, }.Normalized()}-
Step 4: Run — expect PASS, plus the cross-module guard:
go test ./internal/platform/receivables/... ./internal/platform/workflow/... ./internal/app/... -count=1(internal/app.TestEverySeededGraphPassesTheValidatorandTestEverySeededGraphShipsAllThreeLocalesonly see the graphs once Task 8 links the module intointernal/app; Task 8 re-runs them). -
Step 5: Sabotage. Remove
"sales_invoice"fromAccrualOnlyKinds:TestASalesInvoiceCannotCarryAPayoutred (codeslackspayout_in_accrual_only). Restore. -
Step 6: Commit.
AR Task 2: seeded sales invoice, credit note and receipt graphs
Task 3: Money helpers, sales document schema, draft service
Section titled “Task 3: Money helpers, sales document schema, draft service”Files:
- Modify:
internal/kernel/money/rate.go,internal/kernel/money/rate_test.go - Create:
db/migrations/00311_sales_invoice.sql - Create:
internal/platform/receivables/document.go,internal/platform/receivables/cursor.go - Modify:
internal/kernel/db/retention_test.go,sqlc.yaml,internal/platform/receivables/receivablestest/env.go - Test:
internal/platform/receivables/document_schema_test.go,internal/platform/receivables/document_test.go
Interfaces:
-
Consumes:
workflow.Start,workflow.SetAmount,company.Get,chart.GetTaxCode,chart.GetAccount,analytic.Plan,checkRevenueAccount(Task 1). -
Produces:
money.Amount.MulRateCents(money.Rate) Amount,money.Amount.PercentCents(money.Rate) Amount; tablessales_invoice,sales_invoice_line; typesDocument,Line,LineInput,DocumentInput,DocumentFilter,Cursor; funcsGetDocument,ListDocuments,CreateDocument,UpdateDocument,lockDocument; errorsErrNotDraft,ErrNotPreparer,ErrBadCursor; test helpersreceivablestest.Line(account, tax uuid.UUID, qty, price string) LineInput,e.Invoice(t, c, lines...),e.CreditNote(t, original, reason, lines...). -
Step 1: The rounding helpers, test first.
internal/kernel/money/rate_test.go:
// Both round ONCE from the exact product to cents, half away from zero —// never to 4 places first (a double rounding turns 0.004999... into 0.01).func TestCentsHelpersRoundOnce(t *testing.T) { require.Equal(t, "0.0000", money.MustParse("0.0001").MulRateCents(money.MustParseRate("49.99")).String()) // 0.004999 → 0.00 require.Equal(t, "0.0100", money.MustParse("0.0001").MulRateCents(money.MustParseRate("50")).String()) // 0.005 → 0.01 require.Equal(t, "25.0000", money.MustParse("12.5000").MulRateCents(money.MustParseRate("2")).String()) require.Equal(t, "0.0600", money.MustParse("1.0000").PercentCents(money.MustParseRate("6")).String()) require.Equal(t, "0.0000", money.MustParse("0.0833").PercentCents(money.MustParseRate("6")).String()) // 0.004998 → 0.00 require.Equal(t, "-0.0100", money.MustParse("-0.1667").PercentCents(money.MustParseRate("6")).String()) // half away from zero}internal/kernel/money/rate.go (beside Percent):
// MulRateCents is a × r rounded ONCE, half away from zero, to cents: a// sales line's quantity × unit price (AR spec D4). Not MulRate(...) then a// second rounding — that rounds twice.// ponytail: cents only; a zero- or three-decimal currency needs its minor unit here.func (a Amount) MulRateCents(r Rate) Amount { return Amount{d: a.d.Mul(r.d).Round(2)} }
// PercentCents is a × p / 100 rounded ONCE, half away from zero, to cents:// the tax on a sales line (AR spec D4, D7).func (a Amount) PercentCents(p Rate) Amount { return Amount{d: a.d.Mul(p.d).DivRound(decimal.New(100, 0), 2)}}Run go test ./internal/kernel/money/... -count=1 → PASS (it fails first with undefined: MulRateCents).
- Step 2: Failing schema test.
document_schema_test.go — raw SQL as erp_app on a draft created through the service (the instance must exist):
func TestAnIssuedDocumentIsFrozen(t *testing.T) { e := receivablestest.New(t) e.NopPoster(t) d := e.Invoice(t, e.Customer(t, "KEDAI"), e.StdLine(t, "1", "100.00")) edit := func() error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE sales_invoice SET customer_reference = 'PO-9' WHERE org_id = $1 AND id = $2`, e.Org, d.ID) return err }) } editLine := func() error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE sales_invoice_line SET description = 'Changed' WHERE org_id = $1 AND invoice_id = $2`, e.Org, d.ID) return err }) } // PRESENCE: a draft takes both edits. require.NoError(t, edit()) require.NoError(t, editLine()) e.Issue(t, d) // Task 4 adds Issue; until then this test is written but skipped with t.Skip("Task 4") // ABSENCE: issued, both are refused by name. requirePgError(t, edit(), "23514", "sales_invoice_is_draft") requirePgError(t, editLine(), "23514", "sales_invoice_line_is_draft")}
func TestTheSchemaKeepsTheDocumentConsistent(t *testing.T) { e := receivablestest.New(t) d := e.Invoice(t, e.Customer(t, "KEDAI"), e.StdLine(t, "1", "100.00")) for _, c := range []struct{ sql, constraint string }{ // gross = net + tax {`UPDATE sales_invoice SET gross_amount = gross_amount + 1 WHERE org_id = $1 AND id = $2`, "sales_invoice_gross_sum"}, // the issue columns move together {`UPDATE sales_invoice SET issued_at = now() WHERE org_id = $1 AND id = $2`, "sales_invoice_issued_complete"}, // a line is to the sen {`UPDATE sales_invoice_line SET net_amount = 1.005 WHERE org_id = $1 AND invoice_id = $2`, "sales_invoice_line_cents"}, // the tax snapshot is whole {`UPDATE sales_invoice_line SET tax_code = '' WHERE org_id = $1 AND invoice_id = $2`, "sales_invoice_line_tax_snapshot"}, } { // PRESENCE for each: the untouched row is valid (it was just written by the service). err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, c.sql, e.Org, d.ID); return err }) requirePgError(t, err, "23514", c.constraint) }}(The frozen test’s e.Issue call is added in Task 4; in this task the test body stops after the presence half with t.Skip("issued half: Task 4") placed where e.Issue goes. Task 4 removes the skip.)
Add "sales_invoice", "sales_invoice_line" to amendable in retention_test.go.
-
Step 3: Run — expect failure (
relation "sales_invoice" does not exist). -
Step 4: Write the migration.
db/migrations/00311_sales_invoice.sql:
-- +goose Up
-- SALES INVOICES AND CUSTOMER CREDIT NOTES (AR spec D3–D7). One table: kind is-- the workflow kind and the journal source_kind. Shaped for MyInvois; the-- issue columns are NULL on a draft and written together in the issuing-- transaction (while still a draft), then frozen by the guards below.CREATE TABLE sales_invoice ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), kind TEXT NOT NULL CONSTRAINT sales_invoice_kind_known CHECK (kind IN ('sales_invoice','customer_credit_note')), reference TEXT, customer_party_id UUID NOT NULL, original_invoice_id UUID, reason TEXT, customer_reference TEXT NOT NULL DEFAULT '' CONSTRAINT sales_invoice_customer_reference_length CHECK (length(customer_reference) <= 64), currency TEXT NOT NULL CONSTRAINT sales_invoice_currency_code CHECK (currency ~ '^[A-Z]{3}$'), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT sales_invoice_fx_rate_positive CHECK (fx_rate > 0), payment_terms_days INT NOT NULL CONSTRAINT sales_invoice_terms_range CHECK (payment_terms_days BETWEEN 0 AND 365), net_total NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_net_nonnegative CHECK (net_total >= 0), tax_total NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_tax_nonnegative CHECK (tax_total >= 0), gross_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_gross_nonnegative CHECK (gross_amount >= 0), gross_base NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_gross_base_nonnegative CHECK (gross_base >= 0), issued_at TIMESTAMPTZ, invoice_date DATE, due_on DATE, seller_name TEXT, seller_tin TEXT, seller_registration_no TEXT, seller_sst_no TEXT, seller_msic TEXT, seller_address TEXT, buyer_name TEXT, buyer_tin TEXT, buyer_registration_type TEXT, buyer_registration_no TEXT, buyer_sst_no TEXT, buyer_email TEXT, buyer_phone TEXT, buyer_address_line1 TEXT, buyer_address_line2 TEXT, buyer_address_city TEXT, buyer_address_postcode TEXT, buyer_address_state TEXT, buyer_country_code TEXT, instance_id UUID NOT NULL, prepared_by UUID NOT NULL, cancelled_at TIMESTAMPTZ, revision INT NOT NULL DEFAULT 1 CONSTRAINT sales_invoice_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT sales_invoice_tenant_id UNIQUE (org_id, id), -- R2: NULL while a draft; UNIQUE lets any number of NULLs through. CONSTRAINT sales_invoice_reference_once UNIQUE (org_id, reference), CONSTRAINT sales_invoice_instance_once UNIQUE (org_id, instance_id), CONSTRAINT sales_invoice_gross_sum CHECK (gross_amount = net_total + tax_total), -- D4: the issue columns are all NULL (a draft) or all set (issued). CONSTRAINT sales_invoice_issued_complete CHECK ( (issued_at IS NULL) = (reference IS NULL) AND (issued_at IS NULL) = (invoice_date IS NULL) AND (issued_at IS NULL) = (due_on IS NULL) AND (issued_at IS NULL) = (buyer_name IS NULL) AND (issued_at IS NULL) = (seller_name IS NULL)), CONSTRAINT sales_invoice_due_after_date CHECK (due_on IS NULL OR due_on >= invoice_date), -- D6: a credit note names its invoice and its reason; an invoice neither. CONSTRAINT sales_invoice_credit_note_shape CHECK ( (kind = 'customer_credit_note') = (original_invoice_id IS NOT NULL) AND (kind = 'customer_credit_note') = (reason IS NOT NULL)), CONSTRAINT sales_invoice_reason_present CHECK (reason IS NULL OR (btrim(reason) <> '' AND length(reason) <= 500)), CONSTRAINT sales_invoice_buyer_tin_shape CHECK (buyer_tin IS NULL OR buyer_tin ~ '^[A-Z]{1,2}[0-9]{8,13}$'), CONSTRAINT sales_invoice_buyer_registration_type_known CHECK (buyer_registration_type IS NULL OR buyer_registration_type IN ('brn','nric','passport','army')), CONSTRAINT sales_invoice_text_language CHECK ( coalesce(reason, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND customer_reference !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(seller_name, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(seller_address, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(buyer_name, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(buyer_address_line1, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(buyer_address_line2, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(buyer_address_city, '') !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(buyer_address_state, '') !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT sales_invoice_customer_fk FOREIGN KEY (org_id, customer_party_id) REFERENCES party_customer (org_id, party_id), CONSTRAINT sales_invoice_original_fk FOREIGN KEY (org_id, original_invoice_id) REFERENCES sales_invoice (org_id, id), CONSTRAINT sales_invoice_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT sales_invoice_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id));CREATE INDEX sales_invoice_by_created ON sales_invoice (org_id, kind, created_at DESC, id DESC);CREATE INDEX sales_invoice_by_customer ON sales_invoice (org_id, customer_party_id, due_on);CREATE INDEX sales_invoice_by_original ON sales_invoice (org_id, original_invoice_id) WHERE original_invoice_id IS NOT NULL;
CREATE TABLE sales_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 sales_invoice_line_no_range CHECK (line_no BETWEEN 1 AND 200), description TEXT NOT NULL CONSTRAINT sales_invoice_line_description_present CHECK (btrim(description) <> '' AND length(description) <= 500), classification_code TEXT NOT NULL CONSTRAINT sales_invoice_line_classification_shape CHECK (classification_code ~ '^[0-9]{3}$'), quantity NUMERIC(19,10) NOT NULL CONSTRAINT sales_invoice_line_quantity_positive CHECK (quantity > 0), unit_code TEXT CONSTRAINT sales_invoice_line_unit_shape CHECK (unit_code IS NULL OR unit_code ~ '^[A-Z0-9]{2,3}$'), unit_price NUMERIC(19,4) NOT NULL CONSTRAINT sales_invoice_line_price_nonnegative CHECK (unit_price >= 0), discount_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_line_discount_nonnegative CHECK (discount_amount >= 0), net_amount NUMERIC(19,4) NOT NULL CONSTRAINT sales_invoice_line_net_positive CHECK (net_amount > 0), account_id UUID NOT NULL, -- D7: the tax code AND what it said, frozen with the line. tax_code_id UUID NOT NULL, tax_code TEXT NOT NULL, tax_pack_kind TEXT NOT NULL, tax_rate NUMERIC(19,10) NOT NULL CONSTRAINT sales_invoice_line_tax_rate_range CHECK (tax_rate >= 0 AND tax_rate <= 100), tax_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_line_tax_nonnegative CHECK (tax_amount >= 0), tax_exemption_reason TEXT CONSTRAINT sales_invoice_line_exemption_length CHECK (tax_exemption_reason IS NULL OR length(tax_exemption_reason) <= 300), net_base NUMERIC(19,4) NOT NULL CONSTRAINT sales_invoice_line_net_base_positive CHECK (net_base > 0), tax_base NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT sales_invoice_line_tax_base_nonnegative CHECK (tax_base >= 0), shares JSONB NOT NULL DEFAULT '[]'::jsonb CONSTRAINT sales_invoice_line_shares_array CHECK (jsonb_typeof(shares) = 'array'), withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT sales_invoice_line_tenant_id UNIQUE (org_id, id), -- D4: to the sen. CONSTRAINT sales_invoice_line_cents CHECK (net_amount = round(net_amount, 2) AND tax_amount = round(tax_amount, 2) AND discount_amount = round(discount_amount, 2)), CONSTRAINT sales_invoice_line_tax_snapshot CHECK (btrim(tax_code) <> '' AND btrim(tax_pack_kind) <> ''), CONSTRAINT sales_invoice_line_text_language CHECK (description !~ '[-ヿ㐀-䶿一-鿿가-]' AND coalesce(tax_exemption_reason, '') !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT sales_invoice_line_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES sales_invoice (org_id, id), CONSTRAINT sales_invoice_line_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id), CONSTRAINT sales_invoice_line_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id));CREATE UNIQUE INDEX sales_invoice_line_no_once ON sales_invoice_line (org_id, invoice_id, line_no) WHERE withdrawn_at IS NULL;
ALTER TABLE sales_invoice ENABLE ROW LEVEL SECURITY;ALTER TABLE sales_invoice FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON sales_invoice USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE sales_invoice_line ENABLE ROW LEVEL SECURITY;ALTER TABLE sales_invoice_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON sales_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 sales_invoice, sales_invoice_line FROM erp_app;
-- THE FREEZE (D4). Every content column; cancelled_at and updated_at are not-- content. public.payables_semantic (00302) reads the instance's semantic.-- +goose StatementBeginCREATE FUNCTION sales_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 'sales document % is issued: correct it with a credit note', coalesce(NEW.reference, NEW.id::text) USING ERRCODE = '23514', CONSTRAINT = 'sales_invoice_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER sales_invoice_draft_guard BEFORE UPDATE OF kind, reference, customer_party_id, original_invoice_id, reason, customer_reference, currency, fx_rate, payment_terms_days, net_total, tax_total, gross_amount, gross_base, issued_at, invoice_date, due_on, seller_name, seller_tin, seller_registration_no, seller_sst_no, seller_msic, seller_address, buyer_name, buyer_tin, buyer_registration_type, buyer_registration_no, buyer_sst_no, buyer_email, buyer_phone, buyer_address_line1, buyer_address_line2, buyer_address_city, buyer_address_postcode, buyer_address_state, buyer_country_code, instance_id, prepared_by ON sales_invoice FOR EACH ROW EXECUTE FUNCTION sales_invoice_draft_guard();
-- +goose StatementBeginCREATE FUNCTION sales_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.sales_invoice h WHERE h.org_id = NEW.org_id AND h.id = NEW.invoice_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'sales document % is issued: its lines cannot change', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'sales_invoice_line_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER sales_invoice_line_draft_guard BEFORE INSERT OR UPDATE ON sales_invoice_line FOR EACH ROW EXECUTE FUNCTION sales_invoice_line_draft_guard();
-- +goose DownDROP TABLE sales_invoice_line;DROP TABLE sales_invoice;DROP FUNCTION sales_invoice_line_draft_guard();DROP FUNCTION sales_invoice_draft_guard();sqlc.yaml — after the AP entries, add money.Rate overrides for sales_invoice.fx_rate, sales_invoice_line.quantity, sales_invoice_line.tax_rate (the non-pointer form; all three are NOT NULL). Run make generate.
- Step 5: Failing service test.
document_test.go:
func TestADraftPricesItsLinesToTheSen(t *testing.T) { e := receivablestest.New(t) c := e.Customer(t, "KEDAI") sv8 := e.TaxCode(t, "SVT8") d := e.Invoice(t, c, receivablestest.Line(e.Account(t, "4100"), sv8, "3", "33.3333"), receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "OS"), "1", "50.00")) l := d.Lines[0] require.Equal(t, "100.0000", l.NetAmount.String(), "3 × 33.3333 = 99.9999 rounds once to 100.00") require.Equal(t, "8.0000", l.TaxAmount.String()) require.Equal(t, "SVT8", l.TaxCode) require.Equal(t, "service_tax", l.TaxPackKind) require.Equal(t, "150.0000", d.NetTotal.String()) require.Equal(t, "8.0000", d.TaxTotal.String()) require.Equal(t, "158.0000", d.GrossAmount.String()) require.Nil(t, d.Reference, "R2: no number until issue") require.Nil(t, d.IssuedAt)}
func TestALineIsRefusedByName(t *testing.T) { e := receivablestest.New(t) c := e.Customer(t, "KEDAI") rev, os := e.Account(t, "4100"), e.TaxCode(t, "OS") // PRESENCE: the base line is accepted. e.Invoice(t, c, receivablestest.Line(rev, os, "1", "10.00")) for _, tc := range []struct { line receivables.LineInput rule string }{ {receivablestest.Line(e.Account(t, "6140"), os, "1", "10.00"), "account_usable"}, // an expense account {receivablestest.Line(rev, e.TaxCode(t, "PSVT8"), "1", "10.00"), "tax_code_usage"}, // a purchase code {receivablestest.Line(rev, os, "0", "10.00"), "quantity_positive"}, {func() receivables.LineInput { l := receivablestest.Line(rev, os, "1", "10.00"); l.ClassificationCode = "22"; return l }(), "classification_shape"}, {func() receivables.LineInput { l := receivablestest.Line(rev, os, "1", "10.00"); l.DiscountAmount = money.MustParse("10.00"); return l }(), "net_positive"}, {func() receivables.LineInput { l := receivablestest.Line(rev, os, "1", "10.00"); l.Description = "咖啡"; return l }(), "required"}, } { err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{Kind: receivables.KindInvoice, CustomerID: c.PartyID, Currency: "MYR", Lines: []receivables.LineInput{tc.line}}) return err }) var fe *receivables.FieldError require.ErrorAs(t, err, &fe, tc.rule) require.Equal(t, tc.rule, fe.Rule) }}
// D7: a positive-rate code with no account would absorb output tax.func TestAPositiveRateCodeMustNameAnAccount(t *testing.T) { e := receivablestest.New(t) c := e.Customer(t, "KEDAI") // PRESENCE: SVT8 names 2140 and is accepted (TestADraftPricesItsLinesToTheSen). noAcct := e.SalesTaxCode(t, "XST6", "6", nil) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{Kind: receivables.KindInvoice, CustomerID: c.PartyID, Currency: "MYR", Lines: []receivables.LineInput{receivablestest.Line(e.Account(t, "4100"), noAcct, "1", "10.00")}}) return err }) var fe *receivables.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "tax_code_account", fe.Rule)}
func TestOnlyThePreparerEditsADraftAndTheRevisionIsChecked(t *testing.T) { e := receivablestest.New(t) d := e.Invoice(t, e.Customer(t, "KEDAI"), e.StdLine(t, "1", "10.00")) in := e.InputOf(d) in.CustomerReference = "PO-1" // PRESENCE: the preparer at the current revision. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.UpdateDocument(ctx, tx, e.Clock, d.ID, e.Staff, d.Revision, in) return err }) // ABSENCE: someone else; a stale revision. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.UpdateDocument(ctx, tx, e.Clock, d.ID, e.Owner, d.Revision+1, in) return err }) require.ErrorIs(t, err, receivables.ErrNotPreparer) err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.UpdateDocument(ctx, tx, e.Clock, d.ID, e.Staff, d.Revision, in) return err }) var rm *receivables.RevisionMismatchError require.ErrorAs(t, err, &rm)}
// D6: a credit note takes its invoice's currency and rate, whatever the caller sends.func TestACreditNoteInheritsItsInvoicesCurrencyAndRate(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) // Task 4 — until then t.Skip("Task 4") after the next line c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.ForeignInvoice(t, c, "USD", money.MustParseRate("4.2000"), e.StdLine(t, "1", "100.00"))) cn := e.CreditNote(t, inv, "Returned goods", e.StdLine(t, "1", "40.00")) require.Equal(t, "USD", cn.Currency) require.Equal(t, 0, cn.FXRate.Cmp(inv.FXRate))}(The last test is written now with t.Skip("Task 4") at its top; Task 4 removes the skip.)
-
Step 6: Run — expect failure (
undefined: receivables.CreateDocument). -
Step 7: Write
document.go.
Mirror internal/platform/payables/invoice.go (types, invoiceSelect → documentSelect, scanInvoice → scanDocument, loadLines, getDocument, GetDocument, lockDocument, ListDocuments, writeLines, reprice, UpdateDocument) with these differences:
- No supplier-number duplicate check, no
document_id/tax-record fields, nobindDocument, noinvoiceShares/SetShares(R6).repricestill callsworkflow.SetAmount(ctx, tx, c, instanceID, grossBase). Documentcarries every column ofsales_invoice(the issue snapshot as pointers), plusState,Semantic,AccrualOpenfrom the instance join;Linecarries every column ofsales_invoice_line(Quantity money.Rate,TaxRate money.Rate,UnitCode, TaxExemptionReason *string).DocumentInput{Kind, CustomerID, OriginalInvoiceID *uuid.UUID, Reason *string, CustomerReference, Currency string, FXRate *money.Rate, PaymentTermsDays *int, Lines []LineInput};LineInput{Description, ClassificationCode string, Quantity money.Rate, UnitCode *string, UnitPrice, DiscountAmount money.Amount, AccountID, TaxCodeID uuid.UUID, TaxExemptionReason *string, Shares []analytic.Share}.CreateDocumentdoes not allocate a number (R2); it inserts withreferenceNULL andpayment_terms_days= the input’s or the customer’s.- For a credit note,
CreateDocument/UpdateDocumentload the original (GetDocument), requireKind == KindInvoice,IssuedAt != nil, the same customer (fieldErr("original_invoice_id", "original_invoice", "a credit note corrects an issued invoice of the same customer")), require a non-blank record-languageReason, and overwritein.Currency,in.FXRatewith the original’s (D6), beforecheckInput.
checkInput is AP’s header part (currency code, rate required/identity/positive, 1–200 lines, rate forced to 1 at base) and then, per line:
today := clock.TodayUTC(c) // R9: the document will be dated the day it is issued 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) switch { case 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") case !classificationShape.MatchString(l.ClassificationCode): return nil, fieldErr(f("classification_code"), "classification_shape", "a three-digit e-Invoice classification code, such as 022") case !l.Quantity.IsPositive(): return nil, fieldErr(f("quantity"), "quantity_positive", "the quantity must be positive") case l.UnitCode != nil && !unitShape.MatchString(*l.UnitCode): return nil, fieldErr(f("unit_code"), "unit_shape", "a unit code such as C62 or KGM") case l.UnitPrice.IsNegative() || l.DiscountAmount.IsNegative(): return nil, fieldErr(f("unit_price"), "nonnegative", "the price and the discount cannot be negative") case l.DiscountAmount.Cmp(l.DiscountAmount.MulRateCents(money.OneRate())) != 0: return nil, fieldErr(f("discount_amount"), "cents", "the discount is in whole sen") case l.TaxExemptionReason != nil && (len(*l.TaxExemptionReason) > 300 || recordtext.Check("tax_exemption_reason", *l.TaxExemptionReason) != nil): return nil, fieldErr(f("tax_exemption_reason"), "record_language", "up to 300 characters in English or Bahasa Malaysia") } net := l.UnitPrice.MulRateCents(l.Quantity).Sub(l.DiscountAmount) // R10 if !net.IsPositive() { return nil, fieldErr(f("discount_amount"), "net_positive", "the line's amount after discount must be positive") } if err := checkRevenueAccount(ctx, tx, l.AccountID); err != nil { return nil, fieldErr(f("account_id"), "account_usable", err.Error()) } tc, err := chart.GetTaxCode(ctx, tx, l.TaxCodeID) switch { case errors.Is(err, chart.ErrNotFound): return nil, fieldErr(f("tax_code_id"), "tax_code_valid", "choose a tax code") case err != nil: return nil, err case !tc.IsActive || today.Before(tc.ValidFrom) || (tc.ValidTo != nil && !today.Before(*tc.ValidTo)): return nil, fieldErr(f("tax_code_id"), "tax_code_valid", "the tax code must be active today") case tc.Usage != chart.TaxUsageSales && tc.Usage != chart.TaxUsageBoth: return nil, fieldErr(f("tax_code_id"), "tax_code_usage", "this is a purchase tax code; choose a sales tax code, such as SVT8 or OS") case tc.Rate.IsPositive() && tc.AccountID == nil: return nil, fieldErr(f("tax_code_id"), "tax_code_account", "this tax code names no account for the tax collected; choose another or ask an administrator") } tax := net.PercentCents(tc.Rate) acct, err := chart.GetAccount(ctx, tx, l.AccountID) if err != nil { return nil, err } netBase := net.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()) } out = append(out, priced{LineInput: l, net: net, tax: tax, netBase: netBase, taxBase: tax.MulRate(*in.FXRate), code: tc.Code, packKind: tc.PackKind, rate: tc.Rate, shares: sharesJSON(l.Shares)}) }with classificationShape = regexp.MustCompile(^[0-9]{3}$), unitShape = regexp.MustCompile(^[A-Z0-9]{2,3}$). (Check the chart.ErrNotFound name the chart package exports for a missing tax code — AP’s checkInput treats any error as invalid; here a real DB error must propagate.) writeLines inserts the snapshot columns tax_code, tax_pack_kind, tax_rate from priced. reprice writes net_total, tax_total, gross_amount = net_total + tax_total, gross_base.
cursor.go: internal/platform/payables/cursor.go, copied (ErrBadCursor, Cursor, EncodeCursor, DecodeCursor).
ListDocuments(ctx, tx, DocumentFilter{Kind, CustomerID *uuid.UUID, State, Currency string, Open bool, Limit int, After *Cursor}) — AP’s ListInvoices query with supplier_party_id → customer_party_id, the DueBefore filter dropped, and Open reading ar_allocation — which does not exist until Task 5: in this task Open is accepted and ignored with a // Task 6 wires Open (ar_allocation exists from Task 5) marker; Task 6 replaces it.
- Step 8: Grow the test world.
receivablestest/env.go:
// Line is one sales line: qty × price on account, taxed with tax, classification 022.func Line(account, tax uuid.UUID, qty, price string) receivables.LineInput { return receivables.LineInput{Description: "Consulting services", ClassificationCode: "022", Quantity: money.MustParseRate(qty), UnitPrice: money.MustParse(price), DiscountAmount: money.Zero(), AccountID: account, TaxCodeID: tax}}
// StdLine is Line on 4100 with OS (no tax).func (e *Env) StdLine(t *testing.T, qty, price string) receivables.LineInput { return Line(e.Account(t, "4100"), e.TaxCode(t, "OS"), qty, price)}
// Invoice files a draft sales invoice by e.Staff in the customer's currency.func (e *Env) Invoice(t *testing.T, c receivables.Customer, lines ...receivables.LineInput) receivables.Document { … }
// ForeignInvoice: in currency at rate.func (e *Env) ForeignInvoice(t *testing.T, c receivables.Customer, currency string, rate money.Rate, lines ...receivables.LineInput) receivables.Document { … }
// CreditNote files a draft credit note against an issued invoice.func (e *Env) CreditNote(t *testing.T, original receivables.Document, reason string, lines ...receivables.LineInput) receivables.Document { … }
// InputOf is d's editable shape, for an UpdateDocument.func (e *Env) InputOf(d receivables.Document) receivables.DocumentInput { … }
// SalesTaxCode creates an active sales-usage code at rate% posting to account.func (e *Env) SalesTaxCode(t *testing.T, code, rate string, account *uuid.UUID) uuid.UUID { … } // payablestest.TaxCodeWithAccount with Usage: chart.TaxUsageSales
// NopPoster: payablestest's, copied.-
Step 9: Run — expect PASS (the two Task-4 halves skipped).
go test ./internal/platform/receivables/... ./internal/kernel/... -count=1 -
Step 10: Sabotage. (a) Drop
sales_invoice_line_centsfrom the migration:TestTheSchemaKeepsTheDocumentConsistentred. (b) ReplaceMulRateCentsincheckInputwithMulRate(l.Quantity):TestADraftPricesItsLinesToTheSenred (99.9999), then fails the cents CHECK. (c) Remove thetax_code_accountcase:TestAPositiveRateCodeMustNameAnAccountred. Restore each; record the messages. -
Step 11: Commit.
AR Task 3: cents helpers, sales document schema and drafts
Task 4: Issuing — number, snapshot, freeze, accrual posting
Section titled “Task 4: Issuing — number, snapshot, freeze, accrual posting”Files:
- Create:
internal/platform/receivables/transition.go,internal/platform/receivables/posting.go - Modify:
internal/platform/receivables/receivablestest/env.go,internal/platform/receivables/document_schema_test.go(remove the skip),document_test.go(remove the skip) - Test:
internal/platform/receivables/issue_test.go,internal/platform/receivables/posting_test.go
Interfaces:
-
Consumes:
numbering.Allocate/Format,fiscal.YearContaining,company.Get,workflow.Transition/Get/Load/AvailableTransitions,accounting.PostingRule,chart.GetTaxCode,eventbus.Publish. -
Produces:
TransitionInput{ID; From, To string; Actor uuid.UUID; Reason string};TransitionDocument(ctx, tx, c, in) (Document, workflow.Decision, error);Available(ctx, tx, c, instanceID, actor);Rules() []accounting.PostingRule(this task: the twoAccrualRules; Task 6 addsReceiptRule);EventDocumentIssued,DocumentIssued; errorsErrCreditExceedsInvoice,ErrOriginalNotIssued,ErrInstanceMismatch,ErrTotalsDrift,ErrTaxAccount,SubmissionError; test helperse.Issue,e.Submit,e.ApproveCreditNote,e.IssueCreditNote,e.UseReceivablesPoster,e.EntryFor,e.ControlBalanceByParty(oncontrol_kind = 'ar', debit − credit). -
Step 1: Failing tests.
issue_test.go:
func TestIssuingNumbersDatesAndSnapshotsTheInvoice(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") withdrawn := e.Invoice(t, c, e.StdLine(t, "1", "10.00")) e.Withdraw(t, withdrawn) d := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "2", "50.00"))) require.Equal(t, "INV-2026-0001", *d.Reference, "R2: the withdrawn draft took no number") require.Equal(t, expensestest.Today, *d.InvoiceDate) require.Equal(t, expensestest.Today.AddDate(0, 0, 30), *d.DueOn) require.Equal(t, "KEDAI Sdn Bhd", *d.BuyerName) require.Equal(t, "Selangor", *d.BuyerAddressState) require.NotNil(t, d.SellerName) entry := e.EntryFor(t, d.InstanceID, "accrual") require.Equal(t, *d.InvoiceDate, entry.EntryDate.UTC(), "D5: invoice date = posting date")
// The snapshot is a snapshot: renaming the customer changes nothing issued. e.RenameCustomer(t, c, "KEDAI Baharu Sdn Bhd") again := e.Get(t, d.ID) require.Equal(t, "KEDAI Sdn Bhd", *again.BuyerName)}
func TestTheLineTaxSnapshotSurvivesAnEditOfTheCode(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) d := e.Issue(t, e.Invoice(t, e.Customer(t, "KEDAI"), receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "1", "100.00"))) _, err := e.H.Admin.Exec(context.Background(), `UPDATE tax_code SET code = 'SVT8X', pack_kind = 'renamed' WHERE org_id = $1 AND code = 'SVT8'`, e.Org) require.NoError(t, err) l := e.Get(t, d.ID).Lines[0] require.Equal(t, "SVT8", l.TaxCode) require.Equal(t, "service_tax", l.TaxPackKind)}
// D6: the preparer cannot issue their own credit note; the OWNER seat can.func TestACreditNoteNeedsASecondPerson(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) inv := e.Issue(t, e.Invoice(t, e.Customer(t, "KEDAI"), e.StdLine(t, "1", "100.00"))) cn := e.Submit(t, e.CreditNote(t, inv, "Returned goods", e.StdLine(t, "1", "40.00"))) e.GrantOwnerSeatTo(t, e.Staff) // so the ONLY thing refusing Staff is self-approval err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := receivables.TransitionDocument(ctx, tx, e.Clock, receivables.TransitionInput{ ID: cn.ID, From: "pending_approval", To: "issued", Actor: e.Staff}) return err }) var refused *workflow.RefusedError require.ErrorAs(t, err, &refused) require.Equal(t, workflow.RefuseSelfApproval, refused.Refusal.Code) cn = e.ApproveCreditNote(t, cn) // Owner require.Equal(t, "CRN-2026-0001", *cn.Reference)}
// D6: Σ issued credit notes ≤ the invoice.func TestCreditNotesCannotExceedTheirInvoice(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) inv := e.Issue(t, e.Invoice(t, e.Customer(t, "KEDAI"), e.StdLine(t, "1", "100.00"))) e.IssueCreditNote(t, e.CreditNote(t, inv, "Partial return", e.StdLine(t, "1", "60.00"))) // PRESENCE second := e.CreditNote(t, inv, "Second return", e.StdLine(t, "1", "40.01")) err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := receivables.TransitionDocument(ctx, tx, e.Clock, receivables.TransitionInput{ ID: second.ID, From: "draft", To: "pending_approval", Actor: e.Staff}) return err }) require.ErrorIs(t, err, receivables.ErrCreditExceedsInvoice)}posting_test.go:
func TestAnIssuedInvoicePostsReceivableIncomeAndTax(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") d := e.Issue(t, e.Invoice(t, c, receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "1", "100.00"))) entry := e.EntryFor(t, d.InstanceID, "accrual") byCode := e.LinesByAccountCode(t, entry) // code → (debit, credit, party, tax code) require.Equal(t, "108.0000", byCode["1540"].Debit.String()) require.Equal(t, c.PartyID, *byCode["1540"].PartyID, "D2: party on every ar line") require.Equal(t, "100.0000", byCode["4100"].Credit.String()) require.Equal(t, "8.0000", byCode["2140"].Credit.String()) require.NotNil(t, byCode["2140"].TaxCodeID, "D7: the tax line carries its code") require.Equal(t, "sales", e.JournalTypeOf(t, entry))}
func TestACreditNotePostsTheMirror(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) inv := e.Issue(t, e.Invoice(t, e.Customer(t, "KEDAI"), receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "1", "100.00"))) cn := e.IssueCreditNote(t, e.CreditNote(t, inv, "Returned goods", receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "1", "50.00"))) byCode := e.LinesByAccountCode(t, e.EntryFor(t, cn.InstanceID, "accrual")) require.Equal(t, "54.0000", byCode["1540"].Credit.String()) require.Equal(t, "50.0000", byCode["4100"].Debit.String()) require.Equal(t, "4.0000", byCode["2140"].Debit.String())}
func TestAForeignInvoicePostsFrozenBasesWithoutAnFXLine(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) d := e.Issue(t, e.ForeignInvoice(t, e.Customer(t, "KEDAI"), "USD", money.MustParseRate("4.2000"), e.StdLine(t, "1", "100.00"))) entry := e.EntryFor(t, d.InstanceID, "accrual") for _, l := range entry.Lines { require.NotEqual(t, "fx_difference", l.Kind) } require.Equal(t, "420.0000", d.GrossBase.String())}(e.LinesByAccountCode, e.JournalTypeOf, e.Get, e.Withdraw, e.RenameCustomer are small helpers in receivablestest; LinesByAccountCode joins account for the code.)
-
Step 2: Run — expect failure (
undefined: receivables.TransitionDocument). -
Step 3: Write
transition.go.
destination and Available: payables/transition.go’s, copied. Then:
var ( ErrOriginalNotIssued = errors.New("receivables: a credit note corrects an issued invoice of the same customer") ErrCreditExceedsInvoice = errors.New("receivables: the invoice's credit notes would exceed the invoice"))
// EventDocumentIssued is published when a sales document enters its issued// state (spec D20); dispatched only after the transaction commits.const EventDocumentIssued = "receivables.document_issued"
type DocumentIssued struct { OrgID, DocumentID uuid.UUID Kind, Reference string}
// prefixes are Ruling R2's.var prefixes = map[string]string{KindInvoice: "INV", KindCreditNote: "CRN", KindReceipt: "RCT"}
// TransitionDocument moves an invoice or credit note. ORDER (Global// Constraints): header FOR UPDATE → a credit note's original FOR UPDATE →// checks → issue snapshot and number → workflow.Transition (posts the// accrual) → a credit note's self-allocation → audit, last → event.func TransitionDocument(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Document, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Document{}, workflow.Decision{}, err } d, err := lockDocument(ctx, tx, in.ID) if err != nil { return Document{}, workflow.Decision{}, err } from, to, err := destination(ctx, tx, d.InstanceID, in.From, in.To) if err != nil { return Document{}, workflow.Decision{}, err } forward := to.Semantic != posting.Cancelled && to.Semantic != posting.Rejected var original *Document if forward && (from.Semantic == posting.Draft || to.Semantic == posting.Accrual) { if original, err = checkIssuable(ctx, tx, c, d); err != nil { // R11 return Document{}, workflow.Decision{}, err } } if to.Semantic == posting.Accrual { if err := issue(ctx, tx, c, org, &d); err != nil { return Document{}, workflow.Decision{}, err } } if to.Semantic == posting.Cancelled { if _, err := tx.Exec(ctx, `UPDATE sales_invoice SET cancelled_at = $3 WHERE org_id = $1 AND id = $2`, org, d.ID, c.Now()); err != nil { return Document{}, workflow.Decision{}, fmt.Errorf("receivables: withdraw: %w", err) } } dec, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: d.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Document{}, workflow.Decision{}, err } // Task 6: a credit note's self-allocation goes here (R13) — it needs the // credit note's accrual_open, which Transition just set. _ = original after, err := GetDocument(ctx, tx, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityDocument, EntityID: d.ID, Action: "transition", Before: map[string]string{"state": d.State}, After: map[string]string{"state": after.State}}); err != nil { return Document{}, workflow.Decision{}, err } if to.Semantic == posting.Accrual { eventbus.Publish(ctx, EventDocumentIssued, DocumentIssued{OrgID: org, DocumentID: d.ID, Kind: d.Kind, Reference: *after.Reference}) } return after, dec, nil}
// checkIssuable is Ruling R11. For a credit note it locks and returns the// original invoice.func checkIssuable(ctx context.Context, tx pgx.Tx, c clock.Clock, d Document) (*Document, error) { var probs []FieldError if len(d.Lines) == 0 { probs = append(probs, FieldError{"lines", "lines_count", "add at least one line"}) } switch cu, err := GetCustomer(ctx, tx, d.CustomerID); { case errors.Is(err, ErrNotFound) || (err == nil && !cu.IsActive): probs = append(probs, FieldError{"customer_id", "customer_active", "the customer is inactive"}) case err != nil: return nil, err } today := clock.TodayUTC(c) for i, l := range d.Lines { tc, err := chart.GetTaxCode(ctx, tx, l.TaxCodeID) if err != nil { return nil, err } if !tc.IsActive || today.Before(tc.ValidFrom) || (tc.ValidTo != nil && !today.Before(*tc.ValidTo)) { probs = append(probs, FieldError{fmt.Sprintf("lines[%d].tax_code_id", i), "tax_code_valid", "the tax code is not active today; edit the line"}) } } if len(probs) > 0 { return nil, &SubmissionError{Problems: probs} } if d.Kind != KindCreditNote { return nil, nil } o, err := lockDocument(ctx, tx, *d.OriginalInvoiceID) if err != nil { return nil, err } if o.Kind != KindInvoice || o.IssuedAt == nil || o.CustomerID != d.CustomerID { return nil, ErrOriginalNotIssued } org, _ := orgOf(ctx) var credited money.Amount if err := tx.QueryRow(ctx, ` SELECT coalesce(sum(gross_amount), 0) FROM sales_invoice WHERE org_id = $1 AND original_invoice_id = $2 AND issued_at IS NOT NULL AND id <> $3`, org, o.ID, d.ID).Scan(&credited); err != nil { return nil, err } if credited.Add(d.GrossAmount).Cmp(o.GrossAmount) > 0 { return nil, ErrCreditExceedsInvoice } return &o, nil}
// issue is D5: the number, the dates and the two snapshots, written while// the document is still a draft (the freeze guard allows it), in the// transaction that posts it.func issue(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, d *Document) error { today := clock.TodayUTC(c) year, err := fiscal.YearContaining(ctx, tx, today) if err != nil { return err } n, err := numbering.Allocate(ctx, tx, d.Kind, year.EndYear) if err != nil { return err } p, err := company.Get(ctx, tx) if err != nil { return err } cu, err := GetCustomer(ctx, tx, d.CustomerID) if err != nil { return err } due := today if d.Kind == KindInvoice { due = today.AddDate(0, 0, d.PaymentTermsDays) } _, err = tx.Exec(ctx, ` UPDATE sales_invoice SET reference = $3, issued_at = $4, invoice_date = $5, due_on = $6, seller_name = $7, seller_tin = $8, seller_registration_no = $9, seller_sst_no = $10, seller_msic = $11, seller_address = $12, buyer_name = $13, buyer_tin = $14, buyer_registration_type = $15, buyer_registration_no = $16, buyer_sst_no = $17, buyer_email = $18, buyer_phone = $19, buyer_address_line1 = $20, buyer_address_line2 = $21, buyer_address_city = $22, buyer_address_postcode = $23, buyer_address_state = $24, buyer_country_code = $25, updated_at = $4 WHERE org_id = $1 AND id = $2`, org, d.ID, numbering.Format(prefixes[d.Kind], year.EndYear, n), c.Now(), today, due, p.LegalName, nullable(p.TIN), nullable(p.RegistrationNo), nullable(p.SSTRegistrationNo), nullable(p.MSICCode), nullable(p.RegisteredAddress), cu.LegalName, cu.TIN, cu.RegistrationType, cu.RegistrationNo, cu.SSTRegistrationNo, cu.Email, cu.Phone, nullable(cu.Address.Line1), nullable(cu.Address.Line2), nullable(cu.Address.City), nullable(cu.Address.Postcode), nullable(cu.Address.State), nullable(cu.CountryCode)) if err != nil { return fmt.Errorf("receivables: issue: %w", err) } *d, err = GetDocument(ctx, tx, d.ID) // the posting rule reads the reference return err}(nullable is customer.go’s, from AP’s supplier.go. SubmissionError is AP’s type, copied into doc.go.)
- Step 4: Write
posting.go.
var ( ErrInstanceMismatch = errors.New("receivables: the event's instance is not this document's instance") ErrTotalsDrift = errors.New("receivables: the document's lines and its totals disagree") ErrTaxAccount = errors.New("receivables: a line's tax code names no account for the tax collected"))
// Rules is every posting rule this package owns (Task 6 appends ReceiptRule).func Rules() []accounting.PostingRule { return []accounting.PostingRule{AccrualRule{KindName: KindInvoice}, AccrualRule{KindName: KindCreditNote}}}
// AccrualRule posts an issued invoice (Dr Trade receivables gross; Cr income// net and Cr the tax code's account tax, per line) or, for a credit note,// its mirror (spec D7, D11). Frozen bases, document currency: no FX line.type AccrualRule struct{ KindName string }
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("receivables: accrual: %w", chart.ErrNotFound) } d, err := GetDocument(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, fmt.Errorf("receivables: accrual: %w", err) } if d.InstanceID != src.Event.InstanceID || d.Kind != r.KindName || d.Reference == nil { return accounting.Draft{}, ErrInstanceMismatch } party, ref := d.CustomerID, *d.Reference mirror := d.Kind == KindCreditNote side := func(l ledger.Line, amt, base money.Amount, debit bool) ledger.Line { if debit != mirror { l.Debit = amt } else { l.Credit = amt } b := base l.Base = &b return l } var out []ledger.Line gross, grossBase := money.Zero(), money.Zero() for _, l := range d.Lines { memo := fmt.Sprintf("%s L%d %s", ref, l.LineNo, l.Description) code := l.TaxCodeID out = append(out, side(ledger.Line{AccountID: l.AccountID, TaxCodeID: &code, PartyID: &party, Memo: memo, Distribution: l.Shares}, l.NetAmount, l.NetBase, false)) if l.TaxAmount.IsPositive() { tc, err := chart.GetTaxCode(ctx, tx, l.TaxCodeID) if err != nil { return accounting.Draft{}, fmt.Errorf("receivables: accrual: %w", err) } if tc.AccountID == nil { return accounting.Draft{}, ErrTaxAccount // refused at save (R9); reached only if the code was edited since } out = append(out, side(ledger.Line{AccountID: *tc.AccountID, TaxCodeID: &code, PartyID: &party, Memo: memo + " tax"}, l.TaxAmount, l.TaxBase, false)) } gross, grossBase = gross.Add(l.NetAmount).Add(l.TaxAmount), grossBase.Add(l.NetBase).Add(l.TaxBase) } if len(d.Lines) == 0 || gross.Cmp(d.GrossAmount) != 0 || grossBase.Cmp(d.GrossBase) != 0 { return accounting.Draft{}, fmt.Errorf("%w: lines %s/%s, header %s/%s", ErrTotalsDrift, gross, grossBase, d.GrossAmount, d.GrossBase) } out = append(out, side(ledger.Line{AccountID: src.Control.ID, PartyID: &party, Memo: ref}, d.GrossAmount, d.GrossBase, true)) label := map[string]string{KindInvoice: "Sales invoice ", KindCreditNote: "Customer credit note "}[d.Kind] return accounting.Draft{JournalType: chart.JournalSales, Currency: d.Currency, Description: label + ref, Lines: out}, nil}- Step 5: Test-world helpers —
UseReceivablesPoster(e.UsePoster(t, receivables.Rules()...)),Issue(Staff:draft → issued),Withdraw,Submit(Staff:draft → pending_approval),ApproveCreditNote(Owner:pending_approval → issued),IssueCreditNote(= Approve ∘ Submit),GrantOwnerSeatTo,EntryFor,ControlBalanceByParty(payablestest’s witha.control_kind = 'ar'andsum(l.base_debit - l.base_credit)),AdvanceClock(re-registers the receivables Poster).
Remove the two t.Skip("Task 4") lines from Task 3’s tests.
-
Step 6: Run — expect PASS.
go test ./internal/platform/receivables/... -count=1 -
Step 7: Sabotage. (a) Comment out the
RAISEinsales_invoice_draft_guard:TestAnIssuedDocumentIsFrozen’s absence half is red (the UPDATE of an issued header succeeds). Restore. (b) Movenumbering.AllocatetoCreateDocument:TestIssuingNumbersDatesAndSnapshotsTheInvoicered (INV-2026-0002). Restore. (c) Remove the cap check:TestCreditNotesCannotExceedTheirInvoicered. Restore. -
Step 8: Commit.
AR Task 4: issue with number and snapshot, accrual posting
Task 5: Receipt and allocation schema, receipt drafts
Section titled “Task 5: Receipt and allocation schema, receipt drafts”Files:
- Create:
db/migrations/00312_customer_receipt_allocation.sql - Create:
internal/platform/receivables/receipt.go - Modify:
internal/kernel/db/retention_test.go,sqlc.yaml,internal/platform/receivables/receivablestest/env.go - Test:
internal/platform/receivables/allocation_schema_test.go,internal/platform/receivables/receipt_test.go
Interfaces:
-
Consumes:
chart.GetAccount,chart.ListJournals,company.Get,workflow.Start/SetAmount,GetDocument. -
Produces: tables
customer_receipt,customer_receipt_line,ar_allocation; typesReceipt,ReceiptLine{InvoiceID; Amount},ReceiptInput,ReceiptFilter; funcsGetReceipt,lockReceipt,ListReceipts,CreateReceipt,UpdateReceipt,DepositAccounts(ctx, tx) ([]chart.Account, error),checkDepositAccount; errorsErrDepositAccount,ErrInvoiceNotReceivable,ErrForeignUnallocated; helpere.Receipt(t, c, amount string, rate *money.Rate, lines ...ReceiptLine). -
Step 1: Failing schema test —
allocation_schema_test.go, raw SQL as erp_app on documents made through the services, mirroringinternal/platform/payables/allocation_schema_test.go:
func TestTheAllocationGuardRefusesByName(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c, other := e.Customer(t, "KEDAI"), e.Customer(t, "LAIN") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) rct := e.Record(t, e.Receipt(t, c, "150.00", nil)) // 150 on account (base currency, D9) ins := func(invoice, receipt uuid.UUID, amount string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO ar_allocation (org_id, invoice_id, receipt_id, amount, base_amount, currency, effective_on, created_by, created_at) VALUES ($1, $2, $3, $4, $4, 'MYR', $5, $6, now())`, e.Org, invoice, receipt, amount, expensestest.Today, e.Staff) return err }) } // PRESENCE: 60 of the receipt against the invoice is accepted. require.NoError(t, ins(inv.ID, rct.ID, "60.00")) // ABSENCE: more than the invoice has left; a draft invoice; another customer's invoice. requirePgError(t, ins(inv.ID, rct.ID, "40.01"), "23514", "ar_allocation_not_over") draft := e.Invoice(t, c, e.StdLine(t, "1", "10.00")) requirePgError(t, ins(draft.ID, rct.ID, "1.00"), "23514", "ar_allocation_invoice_posted") theirs := e.Issue(t, e.Invoice(t, other, e.StdLine(t, "1", "10.00"))) requirePgError(t, ins(theirs.ID, rct.ID, "1.00"), "23514", "ar_allocation_same_customer") // The receipt has 90 left: 90.01 over two invoices is refused. inv2 := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "500.00"))) requirePgError(t, ins(inv2.ID, rct.ID, "90.01"), "23514", "ar_allocation_receipt_not_over") // Append-only: no UPDATE, no DELETE for erp_app. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE ar_allocation SET amount = 1 WHERE org_id = $1`, e.Org) return err }) require.ErrorContains(t, err, "permission denied")}(This test uses e.Record from Task 6: write it now with t.Skip("Task 6") after the first two lines of setup; Task 6 removes the skip. The retention test covers the grants in this task.)
Retention: customer_receipt, customer_receipt_line → amendable; ar_allocation → appendOnly.
-
Step 2: Run — expect failure (
relation "customer_receipt" does not existin the retention test). -
Step 3: Write the migration.
db/migrations/00312_customer_receipt_allocation.sql:
-- +goose Up
-- CUSTOMER RECEIPTS (AR spec D8, D9). Allocations are planned in-- customer_receipt_line while a draft and become ar_allocation rows when it-- is recorded (Ruling R13). reference is NULL until recorded (R2).CREATE TABLE customer_receipt ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT, customer_party_id UUID NOT NULL, deposit_account_id UUID NOT NULL, receipt_date DATE NOT NULL, bank_reference TEXT NOT NULL DEFAULT '' CONSTRAINT customer_receipt_bank_reference_length CHECK (length(bank_reference) <= 64), currency TEXT NOT NULL CONSTRAINT customer_receipt_currency_code CHECK (currency ~ '^[A-Z]{3}$'), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT customer_receipt_fx_rate_positive CHECK (fx_rate > 0), amount NUMERIC(19,4) NOT NULL CONSTRAINT customer_receipt_amount_positive CHECK (amount > 0), base_amount NUMERIC(19,4) NOT NULL CONSTRAINT customer_receipt_base_positive CHECK (base_amount > 0), instance_id UUID NOT NULL, prepared_by UUID NOT NULL, revision INT NOT NULL DEFAULT 1 CONSTRAINT customer_receipt_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT customer_receipt_tenant_id UNIQUE (org_id, id), CONSTRAINT customer_receipt_reference_once UNIQUE (org_id, reference), CONSTRAINT customer_receipt_instance_once UNIQUE (org_id, instance_id), CONSTRAINT customer_receipt_bank_reference_language CHECK (bank_reference !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT customer_receipt_customer_fk FOREIGN KEY (org_id, customer_party_id) REFERENCES party_customer (org_id, party_id), CONSTRAINT customer_receipt_deposit_account_fk FOREIGN KEY (org_id, deposit_account_id) REFERENCES account (org_id, id), CONSTRAINT customer_receipt_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT customer_receipt_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id));CREATE INDEX customer_receipt_by_created ON customer_receipt (org_id, created_at DESC, id DESC);
CREATE TABLE customer_receipt_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), receipt_id UUID NOT NULL, invoice_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT customer_receipt_line_amount_positive CHECK (amount > 0), withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT customer_receipt_line_tenant_id UNIQUE (org_id, id), CONSTRAINT customer_receipt_line_receipt_fk FOREIGN KEY (org_id, receipt_id) REFERENCES customer_receipt (org_id, id), CONSTRAINT customer_receipt_line_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES sales_invoice (org_id, id));CREATE UNIQUE INDEX customer_receipt_line_invoice_once ON customer_receipt_line (org_id, receipt_id, invoice_id) WHERE withdrawn_at IS NULL;
-- ALLOCATION (D10; AP Ruling P21's shape). Append-only: a reversal is a new row.CREATE TABLE ar_allocation ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), invoice_id UUID NOT NULL, receipt_id UUID, credit_note_id UUID, source_kind TEXT GENERATED ALWAYS AS (CASE WHEN receipt_id IS NOT NULL THEN 'receipt' ELSE 'credit_note' END) STORED, amount NUMERIC(19,4) NOT NULL, base_amount NUMERIC(19,4) NOT NULL, currency TEXT NOT NULL CONSTRAINT ar_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 ar_allocation_tenant_id UNIQUE (org_id, id), CONSTRAINT ar_allocation_one_source CHECK (num_nonnulls(receipt_id, credit_note_id) = 1), CONSTRAINT ar_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 ar_allocation_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES sales_invoice (org_id, id), CONSTRAINT ar_allocation_receipt_fk FOREIGN KEY (org_id, receipt_id) REFERENCES customer_receipt (org_id, id), CONSTRAINT ar_allocation_credit_note_fk FOREIGN KEY (org_id, credit_note_id) REFERENCES sales_invoice (org_id, id), CONSTRAINT ar_allocation_reverses_fk FOREIGN KEY (org_id, reverses_id) REFERENCES ar_allocation (org_id, id), CONSTRAINT ar_allocation_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id));CREATE UNIQUE INDEX ar_allocation_reversed_once ON ar_allocation (org_id, reverses_id) WHERE reverses_id IS NOT NULL;CREATE INDEX ar_allocation_by_invoice ON ar_allocation (org_id, invoice_id);CREATE INDEX ar_allocation_by_credit_note ON ar_allocation (org_id, credit_note_id) WHERE credit_note_id IS NOT NULL;CREATE INDEX ar_allocation_by_receipt ON ar_allocation (org_id, receipt_id) WHERE receipt_id IS NOT NULL;
ALTER TABLE customer_receipt ENABLE ROW LEVEL SECURITY;ALTER TABLE customer_receipt FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON customer_receipt USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE customer_receipt_line ENABLE ROW LEVEL SECURITY;ALTER TABLE customer_receipt_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON customer_receipt_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE ar_allocation ENABLE ROW LEVEL SECURITY;ALTER TABLE ar_allocation FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON ar_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 customer_receipt, customer_receipt_line FROM erp_app;REVOKE UPDATE, DELETE ON ar_allocation FROM erp_app;
-- The receipt freeze: 00304's shape. reference is written in the recording-- transaction before workflow.Transition, while still a draft.-- +goose StatementBeginCREATE FUNCTION customer_receipt_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 'customer receipt % is not a draft', coalesce(NEW.reference, NEW.id::text) USING ERRCODE = '23514', CONSTRAINT = 'customer_receipt_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER customer_receipt_draft_guard BEFORE UPDATE OF reference, customer_party_id, deposit_account_id, receipt_date, bank_reference, currency, fx_rate, amount, base_amount ON customer_receipt FOR EACH ROW EXECUTE FUNCTION customer_receipt_draft_guard();
-- +goose StatementBeginCREATE FUNCTION customer_receipt_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.customer_receipt p WHERE p.org_id = NEW.org_id AND p.id = NEW.receipt_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'customer receipt % is not a draft: its allocations cannot change', NEW.receipt_id USING ERRCODE = '23514', CONSTRAINT = 'customer_receipt_line_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER customer_receipt_line_draft_guard BEFORE INSERT OR UPDATE ON customer_receipt_line FOR EACH ROW EXECUTE FUNCTION customer_receipt_line_draft_guard();
-- THE ALLOCATION GUARD (D10): the backstop for everything allocation.go-- checks. It locks the invoice (and the source) row, so two allocations-- against one invoice or from one source serialize.-- +goose StatementBeginCREATE FUNCTION ar_allocation_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v_inv public.sales_invoice%ROWTYPE; v_cn public.sales_invoice%ROWTYPE; v_rct public.customer_receipt%ROWTYPE; v_orig public.ar_allocation%ROWTYPE; v_out NUMERIC; v_out_base NUMERIC; v_left NUMERIC;BEGIN SELECT * INTO v_inv FROM public.sales_invoice WHERE org_id = NEW.org_id AND id = NEW.invoice_id FOR UPDATE; IF v_inv.kind <> 'sales_invoice' THEN RAISE EXCEPTION 'allocation target % is not a sales invoice', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'ar_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 = 'ar_allocation_currency_matches'; END IF;
IF NEW.reverses_id IS NOT NULL THEN SELECT * INTO v_orig FROM public.ar_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.receipt_id IS DISTINCT FROM NEW.receipt_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 = 'ar_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 issued', coalesce(v_inv.reference, v_inv.id::text) USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_invoice_posted'; END IF;
IF NEW.credit_note_id IS NOT NULL THEN SELECT * INTO v_cn FROM public.sales_invoice WHERE org_id = NEW.org_id AND id = NEW.credit_note_id FOR UPDATE; IF v_cn.kind <> 'customer_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 an issued credit note', NEW.credit_note_id USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_credit_note_posted'; END IF; IF v_cn.customer_party_id <> v_inv.customer_party_id THEN RAISE EXCEPTION 'credit note and invoice belong to different customers' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_same_customer'; END IF; IF v_cn.currency <> v_inv.currency OR v_cn.fx_rate <> v_inv.fx_rate THEN -- D6 / AP Ruling P23 RAISE EXCEPTION 'credit note and invoice carry different currencies or rates' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_rate_matches'; END IF; SELECT v_cn.gross_amount - coalesce(sum(a.amount), 0) - NEW.amount INTO v_left FROM public.ar_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 = 'ar_allocation_credit_not_over'; END IF; ELSE SELECT * INTO v_rct FROM public.customer_receipt WHERE org_id = NEW.org_id AND id = NEW.receipt_id FOR UPDATE; IF v_rct.customer_party_id <> v_inv.customer_party_id THEN RAISE EXCEPTION 'receipt and invoice belong to different customers' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_same_customer'; END IF; IF v_rct.currency <> v_inv.currency THEN RAISE EXCEPTION 'receipt and invoice are in different currencies' USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_currency_matches'; END IF; SELECT v_rct.amount - coalesce(sum(a.amount), 0) - NEW.amount INTO v_left FROM public.ar_allocation a WHERE a.org_id = NEW.org_id AND a.receipt_id = NEW.receipt_id; IF v_left < 0 THEN RAISE EXCEPTION 'receipt % has less left than %', coalesce(v_rct.reference, v_rct.id::text), NEW.amount USING ERRCODE = '23514', CONSTRAINT = 'ar_allocation_receipt_not_over'; 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.ar_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 = 'ar_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 = 'ar_allocation_clears_base'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER ar_allocation_guard BEFORE INSERT ON ar_allocation FOR EACH ROW EXECUTE FUNCTION ar_allocation_guard();
-- +goose DownDROP TABLE ar_allocation;DROP TABLE customer_receipt_line;DROP TABLE customer_receipt;DROP FUNCTION ar_allocation_guard();DROP FUNCTION customer_receipt_line_draft_guard();DROP FUNCTION customer_receipt_draft_guard();sqlc.yaml: customer_receipt.fx_rate → money.Rate. make generate.
- Step 4: Failing service test —
receipt_test.go:
func TestAReceiptDraftChecksItsLines(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) // PRESENCE: fully allocated, and part on account (base currency), both accepted. e.Receipt(t, c, "100.00", nil, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")}) e.Receipt(t, c, "150.00", nil, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")}) create := func(in receivables.ReceiptInput) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateReceipt(ctx, tx, e.Clock, e.Staff, in) return err }) } base := receivables.ReceiptInput{CustomerID: c.PartyID, DepositAccountID: e.Account(t, "1520"), ReceiptDate: expensestest.Today, Currency: "MYR", Amount: money.MustParse("100.00")} // Over the invoice's outstanding. over := base over.Lines = []receivables.ReceiptLine{{InvoiceID: inv.ID, Amount: money.MustParse("100.01")}} over.Amount = money.MustParse("100.01") var fe *receivables.FieldError require.ErrorAs(t, create(over), &fe) require.Equal(t, "over_allocated", fe.Rule) // Lines above the receipt's amount. above := base above.Lines = []receivables.ReceiptLine{{InvoiceID: inv.ID, Amount: money.MustParse("100.00")}} above.Amount = money.MustParse("99.00") require.ErrorIs(t, create(above), receivables.ErrAllocationTotal) // An expense account is not a deposit account; 1510 Cash in hand is (the CSH journal's default). exp := base exp.DepositAccountID = e.Account(t, "6140") require.ErrorIs(t, create(exp), receivables.ErrDepositAccount) cash := base cash.DepositAccountID = e.Account(t, "1510") require.NoError(t, create(cash)) // A receipt dated after today. future := base future.ReceiptDate = expensestest.Today.AddDate(0, 0, 1) require.ErrorAs(t, create(future), &fe) require.Equal(t, "receipt_date_future", fe.Rule)}
// D9: a foreign-currency receipt is allocated in full.func TestAForeignReceiptCannotLeaveMoneyOnAccount(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") rate := money.MustParseRate("4.2000") inv := e.Issue(t, e.ForeignInvoice(t, c, "USD", rate, e.StdLine(t, "1", "100.00"))) pay := money.MustParseRate("4.3000") // PRESENCE: in full. e.Receipt(t, c, "100.00", &pay, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")}) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.CreateReceipt(ctx, tx, e.Clock, e.Staff, receivables.ReceiptInput{CustomerID: c.PartyID, DepositAccountID: e.Account(t, "1520"), ReceiptDate: expensestest.Today, Currency: "USD", FXRate: &pay, Amount: money.MustParse("100.00"), Lines: []receivables.ReceiptLine{{InvoiceID: inv.ID, Amount: money.MustParse("60.00")}}}) return err }) require.ErrorIs(t, err, receivables.ErrForeignUnallocated)}
func TestDepositAccountsAreBankAndCashJournalAccounts(t *testing.T) { e := receivablestest.New(t) var codes []string e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { as, err := receivables.DepositAccounts(ctx, tx) for _, a := range as { codes = append(codes, a.Code) } return err }) require.Equal(t, []string{"1510", "1520"}, codes)}-
Step 5: Run — expect failure (
undefined: receivables.CreateReceipt). -
Step 6: Write
receipt.go.
Mirror internal/platform/payables/payment.go (types, paymentSelect → receiptSelect, scanReceipt, getReceipt with its planned lines, GetReceipt, lockReceipt, ListReceipts, writeReceiptLines in invoice-id order, CreateReceipt, UpdateReceipt with the preparer and revision checks and workflow.SetAmount(base_amount)), with these differences:
- No supplier bank checks; no number at creation (R2):
referenceis inserted NULL. ErrAllocationTotal = errors.New("receivables: the allocations cannot exceed the receipt"),ErrForeignUnallocated = errors.New("receivables: a foreign-currency receipt must be allocated in full"),ErrDepositAccount = errors.New("receivables: receive into an active bank account or the cash journal's account"),ErrInvoiceNotReceivable = errors.New("receivables: only this customer's issued invoices in the receipt's currency can be paid").checkReceipt(ctx, tx, c, in *ReceiptInput, base string) error:
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 receipt 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 receipt in the base currency has a rate of 1") case !in.Amount.IsPositive(): return fieldErr("amount", "positive", "the amount received must be positive") case in.ReceiptDate.After(clock.TodayUTC(c)): return fieldErr("receipt_date", "receipt_date_future", "money cannot have been received after today") 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") } // … rate forced to 1 at base; active customer (customer_active); // checkDepositAccount; each line: once, positive, an issued invoice of // this customer and currency with AccrualOpen, not above its outstanding // (receivables.InvoiceOutstanding — Task 6; in this task compute it // inline as gross − Σ ar_allocation.amount, which Task 6 replaces with the // shared function). Then: if total.Cmp(in.Amount) > 0 { return ErrAllocationTotal } if in.Currency != base && total.Cmp(in.Amount) != 0 { return ErrForeignUnallocated // D9 }checkDepositAccountandDepositAccounts(R19):
// checkDepositAccount is spec D8: an active postable account that is either// of control kind bank or the default account of an active cash journal.func checkDepositAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID, currency string) error { a, err := chart.GetAccount(ctx, tx, id) switch { case errors.Is(err, chart.ErrNotFound): return ErrDepositAccount case err != nil: return err case !a.IsActive || !a.IsPostable || (a.Currency != nil && *a.Currency != currency): return ErrDepositAccount case a.OwnerModule != nil: // a petty-cash float moves only through its own module (wave-3 §1.5) return ErrDepositAccount case a.ControlKind != nil && *a.ControlKind == "bank": return nil } ok, err := isCashJournalAccount(ctx, tx, id) if err != nil { return err } if !ok { return ErrDepositAccount } return nil}
func isCashJournalAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) (bool, error) { js, err := chart.ListJournals(ctx, tx) if err != nil { return false, err } for _, j := range js { if j.IsActive && j.Type == chart.JournalCash && j.DefaultAccountID != nil && *j.DefaultAccountID == id { return true, nil } } return false, nil}DepositAccounts lists chart.ListAccounts (or the chart package’s equivalent list call — use the one internal/modules/accounting’s accounts handler uses) filtered by the same rule, sorted by code.
-
Step 7: Test-world helper
e.Receipt(t, c, amount, rate, lines...)— by Staff into1520, datedexpensestest.Today, currency = the first line’s invoice’s (else the customer’s), bank reference"DuitNow 12345". -
Step 8: Run — expect PASS (the guard test skipped until Task 6).
-
Step 9: Sabotage. Remove the
ErrForeignUnallocatedbranch:TestAForeignReceiptCannotLeaveMoneyOnAccountred. Restore. -
Step 10: Commit.
AR Task 5: receipt and allocation schema, receipt drafts
Task 6: Recording and voiding receipts, the receipt posting rule, allocations
Section titled “Task 6: Recording and voiding receipts, the receipt posting rule, allocations”Files:
- Create:
internal/platform/receivables/allocation.go - Modify:
internal/platform/receivables/transition.go,posting.go,receipt.go,document.go(ListDocumentsOpen),receivablestest/env.go,allocation_schema_test.go(remove the skip) - Test:
internal/platform/receivables/allocation_test.go,internal/platform/receivables/receipt_posting_test.go
Interfaces:
-
Consumes: Tasks 4–5;
ledger.Post’sfx_differencebehaviour;chart.ResolveControl. -
Produces:
Allocation(AP’s fields withReceiptIDforPaymentID);Allocations(ctx, tx, documentID);InvoiceOutstanding(ctx, tx, Document) (amt, base money.Amount, err);ReceiptUnallocated(ctx, tx, Receipt) (money.Amount, error);TransitionReceipt(ctx, tx, c, in) (Receipt, workflow.Decision, error);Allocate(ctx, tx, c, AllocationInput{InvoiceID; CreditNoteID, ReceiptID *uuid.UUID; Amount money.Amount}, actor) (Allocation, error);ReverseAllocation(ctx, tx, c, id, actor) (Allocation, error);ReceiptRule; errorsErrOverAllocation,ErrAlreadyReversed,ErrVoidTheReceipt,ErrRateMismatch,ErrNotAllocatable; helperse.Record,e.VoidReceipt,e.Allocate,e.Reverse. -
Step 1: Failing tests.
receipt_posting_test.go:
func TestARecordedReceiptPostsBankAgainstReceivables(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) r := e.Record(t, e.Receipt(t, c, "150.00", nil, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) require.Equal(t, "RCT-2026-0001", *r.Reference) byCode := e.LinesByAccountCode(t, e.EntryFor(t, r.InstanceID, "payout")) require.Equal(t, "150.0000", byCode["1520"].Debit.String()) require.Equal(t, "150.0000", e.CreditTotal(t, e.EntryFor(t, r.InstanceID, "payout"), "1540"), "100 against the invoice + 50 on account") out, _ := e.Outstanding(t, inv) require.True(t, out.IsZero()) require.Equal(t, "50.0000", e.Unallocated(t, r).String())}
// D11 / AP D9: realised FX is the ledger's fx_difference line on the receipt.func TestAForeignReceiptPostsItsRealisedFX(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.ForeignInvoice(t, c, "USD", money.MustParseRate("4.2000"), e.StdLine(t, "1", "100.00"))) pay := money.MustParseRate("4.3000") r := e.Record(t, e.Receipt(t, c, "100.00", &pay, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) entry := e.EntryFor(t, r.InstanceID, "payout") fx := e.FXLine(t, entry) // the fx_difference line require.Equal(t, "6900", fx.AccountCode) require.Equal(t, "10.0000", fx.BaseCredit.String(), "received 430 for a 420 receivable: a 10 gain")}
func TestVoidingAReceiptReversesItsEntryAndAllocations(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) r := e.Record(t, e.Receipt(t, c, "100.00", nil, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) out, _ := e.Outstanding(t, inv) require.True(t, out.IsZero(), "PRESENCE: paid") // The preparer cannot void (self_approval); the OWNER seat can. err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := receivables.TransitionReceipt(ctx, tx, e.Clock, receivables.TransitionInput{ID: r.ID, From: "received", To: "voided", Actor: e.Staff, Reason: "Cheque bounced"}) return err }) var refused *workflow.RefusedError require.ErrorAs(t, err, &refused) e.VoidReceipt(t, r) out, _ = e.Outstanding(t, inv) require.Equal(t, "100.0000", out.String()) require.Equal(t, 0, e.ControlBalanceByParty(t)[c.PartyID].Cmp(money.MustParse("100.00")))}allocation_test.go:
// D6: at issue a credit note allocates itself to its invoice.func TestACreditNoteAllocatesItselfAtIssue(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) e.IssueCreditNote(t, e.CreditNote(t, inv, "Returned goods", e.StdLine(t, "1", "40.00"))) out, _ := e.Outstanding(t, inv) require.Equal(t, "60.0000", out.String())}
func TestACreditNoteOnAPaidInvoiceLeavesACredit(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) e.Record(t, e.Receipt(t, c, "100.00", nil, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) cn := e.IssueCreditNote(t, e.CreditNote(t, inv, "Overcharged", e.StdLine(t, "1", "30.00"))) left, _ := e.Outstanding(t, cn) require.Equal(t, "30.0000", left.String(), "nothing to offset: the credit stays") next := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "50.00"))) e.Allocate(t, receivables.AllocationInput{InvoiceID: next.ID, CreditNoteID: &cn.ID, Amount: money.MustParse("30.00")}) out, _ := e.Outstanding(t, next) require.Equal(t, "20.0000", out.String())}
func TestMoneyOnAccountIsAllocatedLaterAndReversible(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") r := e.Record(t, e.Receipt(t, c, "80.00", nil)) // all on account inv := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) a := e.Allocate(t, receivables.AllocationInput{InvoiceID: inv.ID, ReceiptID: &r.ID, Amount: money.MustParse("80.00")}) out, _ := e.Outstanding(t, inv) require.Equal(t, "20.0000", out.String()) e.Reverse(t, a) out, _ = e.Outstanding(t, inv) require.Equal(t, "100.0000", out.String()) // Twice is refused. err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.ReverseAllocation(ctx, tx, e.Clock, a.ID, e.Staff) return err }) require.ErrorIs(t, err, receivables.ErrAlreadyReversed)}
// R14: a foreign receipt's allocation is mirrored in its entry; only a void undoes it.func TestAForeignReceiptsAllocationIsUndoneOnlyByVoiding(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") rate := money.MustParseRate("4.2000") inv := e.Issue(t, e.ForeignInvoice(t, c, "USD", rate, e.StdLine(t, "1", "100.00"))) r := e.Record(t, e.Receipt(t, c, "100.00", &rate, receivables.ReceiptLine{InvoiceID: inv.ID, Amount: money.MustParse("100.00")})) allocs := e.AllocationsOf(t, r.ID) require.Len(t, allocs, 1) // PRESENCE: there is a row to try err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.ReverseAllocation(ctx, tx, e.Clock, allocs[0].ID, e.Staff) return err }) require.ErrorIs(t, err, receivables.ErrVoidTheReceipt)}
// D9: a foreign remainder cannot exist, so a later allocation from a foreign// receipt is refused as not allocatable — and a credit note needs the same rate.func TestACreditNoteAllocatesOnlyAtItsOwnRate(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") a, b := money.MustParseRate("4.2000"), money.MustParseRate("4.3000") first := e.Issue(t, e.ForeignInvoice(t, c, "USD", a, e.StdLine(t, "1", "100.00"))) e.Record(t, e.Receipt(t, c, "100.00", &a, receivables.ReceiptLine{InvoiceID: first.ID, Amount: money.MustParse("100.00")})) cn := e.IssueCreditNote(t, e.CreditNote(t, first, "Overcharged", e.StdLine(t, "1", "10.00"))) same := e.Issue(t, e.ForeignInvoice(t, c, "USD", a, e.StdLine(t, "1", "50.00"))) other := e.Issue(t, e.ForeignInvoice(t, c, "USD", b, e.StdLine(t, "1", "50.00"))) e.Allocate(t, receivables.AllocationInput{InvoiceID: same.ID, CreditNoteID: &cn.ID, Amount: money.MustParse("5.00")}) // PRESENCE err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := receivables.Allocate(ctx, tx, e.Clock, receivables.AllocationInput{InvoiceID: other.ID, CreditNoteID: &cn.ID, Amount: money.MustParse("5.00")}, e.Staff) return err }) require.ErrorIs(t, err, receivables.ErrRateMismatch)}-
Step 2: Run — expect failure (
undefined: receivables.TransitionReceipt). -
Step 3: Write
allocation.go.
Mirror internal/platform/payables/allocation.go (Allocation, allocationSelect, scanAllocation, Allocations, InvoiceOutstanding, allocationBase, insertAllocation, isConstraint), payment_id → receipt_id, ap_allocation → ar_allocation, then:
// allocateReceipt turns the receipt's planned lines into effective rows// (Ruling R13), locking each invoice in id order first.func allocateReceipt(ctx context.Context, tx pgx.Tx, c clock.Clock, r Receipt, actor uuid.UUID) error // AP allocatePayment, minus the "total == amount" check (D9)
// reverseReceiptAllocations mirrors every unreversed row of the receipt,// planned or later, in invoice-id order.func reverseReceiptAllocations(ctx context.Context, tx pgx.Tx, c clock.Clock, r Receipt, actor uuid.UUID) error // AP reversePaymentAllocations
// ReceiptUnallocated is amount − Σ ar_allocation.amount for the receipt.func ReceiptUnallocated(ctx context.Context, tx pgx.Tx, r Receipt) (money.Amount, error)
// selfAllocate is D6: at issue, min(credit note gross, invoice outstanding)// against the original. Called after workflow.Transition (R13). Posts nothing.func selfAllocate(ctx context.Context, tx pgx.Tx, c clock.Clock, cn Document, inv Document, actor uuid.UUID) error { outAmt, outBase, err := InvoiceOutstanding(ctx, tx, inv) if err != nil || !outAmt.IsPositive() { return err } amt := cn.GrossAmount if outAmt.Cmp(amt) < 0 { amt = outAmt } org, err := orgOf(ctx) if err != nil { return err } cid := cn.ID return insertAllocation(ctx, tx, org, Allocation{InvoiceID: inv.ID, CreditNoteID: &cid, Amount: amt, BaseAmount: allocationBase(inv, amt, outAmt, outBase), Currency: inv.Currency, EffectiveOn: clock.TodayUTC(c), CreatedBy: actor, CreatedAt: c.Now()})}
type AllocationInput struct { InvoiceID uuid.UUID CreditNoteID, ReceiptID *uuid.UUID Amount money.Amount}
// Allocate applies a credit note's or a base-currency receipt's remainder to// an issued invoice (R14). Posts nothing. Lock order: the source, then the// invoice (both FOR UPDATE), validated source-first as AP's// AllocateCreditNote does.func Allocate(ctx context.Context, tx pgx.Tx, c clock.Clock, in AllocationInput, actor uuid.UUID) (Allocation, error) { // exactly one source (422 field source); positive amount; // credit note: lockDocument, Kind == KindCreditNote && AccrualOpen, else ErrNotAllocatable; // receipt: lockReceipt, Semantic == posting.Payout (received, not voided) and Currency == base, else ErrNotAllocatable; // invoice: lockDocument, Kind == KindInvoice && AccrualOpen, same customer, same currency, else ErrNotAllocatable; // credit note && cn.FXRate != inv.FXRate → ErrRateMismatch; // amount ≤ invoice outstanding and ≤ source remainder, else ErrOverAllocation; // base: allocationBase(inv, …), or the credit note's remaining base when it clears the credit note (AP's rule); // INSERT … RETURNING id; audit "create", last.}
// ReverseAllocation undoes an allocation with a mirroring row (D10, R14).// A foreign receipt's rows are undone only by voiding it (ErrVoidTheReceipt).func ReverseAllocation(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Allocation, error)// AP's, with: a.ReceiptID != nil → lockReceipt; its Currency != base → ErrVoidTheReceipt;// lock order source → invoice; ar_allocation_reversed_once → ErrAlreadyReversed; audit "reverse".InvoiceOutstanding reads receipt_id/credit_note_id per the document’s kind exactly as AP’s reads invoice_id / credit_note_id.
- Step 4:
TransitionReceiptand the receipt rule.
transition.go — AP’s TransitionPayment shape:
// TransitionReceipt moves a receipt. ORDER: receipt FOR UPDATE → (record:// checks, number, planned lines to allocations with invoice locks in id// order) or (void/withdraw: reversals) → workflow.Transition (posts the// receipt or its reversal) → audit, last.func TransitionReceipt(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Receipt, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Receipt{}, workflow.Decision{}, err } r, err := lockReceipt(ctx, tx, in.ID) if err != nil { return Receipt{}, workflow.Decision{}, err } _, to, err := destination(ctx, tx, r.InstanceID, in.From, in.To) if err != nil { return Receipt{}, workflow.Decision{}, err } switch { case to.Semantic == posting.Payout: p, err := company.Get(ctx, tx) if err != nil { return Receipt{}, workflow.Decision{}, err } in2 := r.Input() // the stored draft as a ReceiptInput if err := checkReceipt(ctx, tx, c, &in2, p.BaseCurrency); err != nil { // the invoices may have moved since the draft return Receipt{}, workflow.Decision{}, err } year, err := fiscal.YearContaining(ctx, tx, clock.TodayUTC(c)) if err != nil { return Receipt{}, workflow.Decision{}, err } n, err := numbering.Allocate(ctx, tx, KindReceipt, year.EndYear) if err != nil { return Receipt{}, workflow.Decision{}, err } if _, err := tx.Exec(ctx, `UPDATE customer_receipt SET reference = $3, updated_at = $4 WHERE org_id = $1 AND id = $2`, org, r.ID, numbering.Format(prefixes[KindReceipt], year.EndYear, n), c.Now()); err != nil { return Receipt{}, workflow.Decision{}, fmt.Errorf("receivables: number receipt: %w", err) } if err := allocateReceipt(ctx, tx, c, r, in.Actor); err != nil { return Receipt{}, workflow.Decision{}, err } case to.Semantic == posting.Cancelled || to.Semantic == posting.Rejected: if err := reverseReceiptAllocations(ctx, tx, c, r, in.Actor); err != nil { // a no-op on a draft return Receipt{}, workflow.Decision{}, err } } dec, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: r.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Receipt{}, workflow.Decision{}, err } after, err := GetReceipt(ctx, tx, r.ID) if err != nil { return Receipt{}, workflow.Decision{}, err } return after, dec, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityReceipt, EntityID: r.ID, Action: "transition", Before: map[string]string{"state": r.State}, After: map[string]string{"state": after.State}})}In TransitionDocument, replace the Task-4 marker with:
if to.Semantic == posting.Accrual && original != nil { cn, err := GetDocument(ctx, tx, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } if err := selfAllocate(ctx, tx, c, cn, *original, in.Actor); err != nil { return Document{}, workflow.Decision{}, err } }posting.go — Rules() appends ReceiptRule{}:
// ReceiptRule (D8, D9, D11): Dr the deposit account at the receipt's frozen// base; Cr Trade receivables per allocation at the invoice's frozen base;// Cr Trade receivables the remainder (base currency only). ledger.Post adds// the fx_difference line on fx_gain_loss when the bases differ.type ReceiptRule struct{}
func (ReceiptRule) Kind() string { return KindReceipt }func (ReceiptRule) Semantic() posting.Semantic { return posting.Payout }
func (ReceiptRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { r, err := GetReceipt(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, fmt.Errorf("receivables: receipt: %w", err) } if r.InstanceID != src.Event.InstanceID || r.Reference == nil { return accounting.Draft{}, ErrInstanceMismatch } control, err := chart.ResolveControl(ctx, tx, ControlKind) if err != nil { return accounting.Draft{}, fmt.Errorf("receivables: receipt: %w", err) } allocs, err := Allocations(ctx, tx, r.ID) if err != nil { return accounting.Draft{}, err } party, ref := r.CustomerID, *r.Reference var out []ledger.Line total := money.Zero() for _, a := range allocs { if a.ReceiptID == nil || *a.ReceiptID != r.ID || a.ReversesID != nil || a.ReversedByID != nil { continue } base := a.BaseAmount out = append(out, ledger.Line{AccountID: control.ID, Credit: a.Amount, Base: &base, PartyID: &party, Memo: ref}) total = total.Add(a.Amount) } if rest := r.Amount.Sub(total); rest.IsPositive() { if r.Currency != src.BaseCurrency { return accounting.Draft{}, ErrForeignUnallocated // refused at save and at record; the backstop } base := rest // rate 1 out = append(out, ledger.Line{AccountID: control.ID, Credit: rest, Base: &base, PartyID: &party, Memo: ref + " on account"}) } else if rest.IsNegative() { return accounting.Draft{}, ErrAllocationTotal } bankBase := r.BaseAmount out = append(out, ledger.Line{AccountID: r.DepositAccountID, Debit: r.Amount, Base: &bankBase, PartyID: &party, Memo: ref + " " + r.BankReference}) jid, jtype, err := journalFor(ctx, tx, r.DepositAccountID) if err != nil { return accounting.Draft{}, err } return accounting.Draft{JournalID: jid, JournalType: jtype, Currency: r.Currency, Description: "Customer receipt " + ref, Lines: out}, nil}
// journalFor is payables.journalFor (itself advances'), copied (AP Ruling P25).document.go — replace the Task-3 Open marker in ListDocuments with AP’s Open clause over ar_allocation (a.invoice_id = h.id OR a.credit_note_id = h.id). Replace Task 5’s inline outstanding in checkReceipt with InvoiceOutstanding.
-
Step 5: Test-world helpers
Record(Staffdraft → received),VoidReceipt(Ownerreceived → voided, reason),Allocate,Reverse,AllocationsOf,Outstanding,Unallocated,CreditTotal,FXLine. Remove the skip fromallocation_schema_test.go. -
Step 6: Run — expect PASS.
go test ./internal/platform/receivables/... -count=1 -
Step 7: Sabotage. (a) Drop the receipt-remainder check from
ar_allocation_guard:TestTheAllocationGuardRefusesByNamered atar_allocation_receipt_not_over. (b) SkipselfAllocate:TestACreditNoteAllocatesItselfAtIssuered (100.0000). (c) Remove the foreign branch inReverseAllocation:TestAForeignReceiptsAllocationIsUndoneOnlyByVoidingred. Restore each. -
Step 8: Commit.
AR Task 6: receipts post, allocations, credit-note self-allocation
Task 7: AR aging, customer balance, statement, sub-ledger = control
Section titled “Task 7: AR aging, customer balance, statement, sub-ledger = control”Files:
- Create:
internal/platform/receivables/aging.go,internal/platform/receivables/statement.go - Test:
internal/platform/receivables/aging_test.go,internal/platform/receivables/statement_test.go,internal/platform/receivables/subledger_test.go
Interfaces:
-
Consumes:
payables.BucketFor,payables.Buckets,payables.BucketAmounts(+ itsAdd),company.Get. -
Produces:
AgingRow{DocumentID; Kind, Reference string; CustomerID; CustomerCode, CustomerName, Currency string; DocumentDate, DueOn time.Time; DaysPastDue int; Bucket string; Outstanding, OutstandingBase money.Amount},AgingCustomer{CustomerID; Code, Name string; Buckets payables.BucketAmounts},Aging{AsOf; Currency; Rows; Customers; Totals};AgingAsOf(ctx, tx, asOf, customerID *uuid.UUID) (Aging, error);StatementLine{EntryDate time.Time; EntryNo, SourceKind string; SourceID *uuid.UUID; Description string; Debit, Credit, Balance money.Amount},Statement{Customer Customer; Currency string; From, To time.Time; Opening, Closing money.Amount; Lines []StatementLine; Aging payables.BucketAmounts};StatementFor(ctx, tx, customerID uuid.UUID, currency string, from, to time.Time) (Statement, error). -
Step 1: Failing tests.
subledger_test.go — the drift test (spec D2):
// TestTheSubledgerEqualsTheControlAccountPerCustomer is spec D2's drift// test: after a mixed sequence, Σ open items per customer (base) equals the// Trade receivables balance per party.func TestTheSubledgerEqualsTheControlAccountPerCustomer(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) a, b := e.Customer(t, "KEDAI"), e.Customer(t, "LAIN") usd, pay := money.MustParseRate("4.2000"), money.MustParseRate("4.3500") i1 := e.Issue(t, e.Invoice(t, a, receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "2", "75.00"))) i2 := e.Issue(t, e.ForeignInvoice(t, a, "USD", usd, e.StdLine(t, "1", "100.00"))) i3 := e.Issue(t, e.Invoice(t, b, e.StdLine(t, "1", "300.00"))) e.IssueCreditNote(t, e.CreditNote(t, i1, "Returned goods", e.StdLine(t, "1", "20.00"))) e.Record(t, e.Receipt(t, a, "100.00", &pay, receivables.ReceiptLine{InvoiceID: i2.ID, Amount: money.MustParse("100.00")})) r := e.Record(t, e.Receipt(t, b, "350.00", nil, receivables.ReceiptLine{InvoiceID: i3.ID, Amount: money.MustParse("200.00")})) e.VoidReceipt(t, e.Record(t, e.Receipt(t, b, "10.00", nil))) next := e.Issue(t, e.Invoice(t, b, e.StdLine(t, "1", "40.00"))) e.Allocate(t, receivables.AllocationInput{InvoiceID: next.ID, ReceiptID: &r.ID, Amount: money.MustParse("40.00")})
control := e.ControlBalanceByParty(t) aging := e.AgingNow(t) sub := map[uuid.UUID]money.Amount{} for _, row := range aging.Rows { sub[row.CustomerID] = sub[row.CustomerID].Add(row.OutstandingBase) } // PRESENCE: both customers have open items and control lines. require.Len(t, control, 2) require.Len(t, sub, 2) for party, bal := range control { require.Equal(t, 0, bal.Cmp(sub[party]), "party %s: control %s, sub-ledger %s", party, bal, sub[party]) }}aging_test.go:
func TestAgingBucketsAtTheBoundaries(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.CustomerWithTerms(t, "KEDAI", 0) // due on the invoice date d := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) for days, want := range map[int]string{0: payables.BucketCurrent, 1: payables.Bucket1To30, 30: payables.Bucket1To30, 31: payables.Bucket31To60, 90: payables.Bucket61To90, 91: payables.BucketOver90} { a := e.AgingAt(t, d.InvoiceDate.AddDate(0, 0, days)) require.Len(t, a.Rows, 1, "day %d", days) require.Equal(t, want, a.Rows[0].Bucket, "day %d", days) } // Before the invoice was issued it is not in aging (the "as of" join). require.Empty(t, e.AgingAt(t, d.InvoiceDate.AddDate(0, 0, -1)).Rows)}
func TestMoneyOnAccountIsANegativeRow(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") e.Record(t, e.Receipt(t, c, "80.00", nil)) a := e.AgingNow(t) require.Len(t, a.Rows, 1) require.Equal(t, receivables.KindReceipt, a.Rows[0].Kind) require.Equal(t, "-80.0000", a.Rows[0].Outstanding.String())}
func TestAPaidInvoiceLeavesAging(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") d := e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "100.00"))) require.Len(t, e.AgingNow(t).Rows, 1) // PRESENCE e.Record(t, e.Receipt(t, c, "100.00", nil, receivables.ReceiptLine{InvoiceID: d.ID, Amount: money.MustParse("100.00")})) require.Empty(t, e.AgingNow(t).Rows)}statement_test.go:
// D14: a statement is the ledger; its closing is the aging total.func TestTheStatementClosesAtTheAgingTotal(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") i1 := e.Issue(t, e.Invoice(t, c, receivablestest.Line(e.Account(t, "4100"), e.TaxCode(t, "SVT8"), "1", "100.00"))) e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "50.00"))) e.Record(t, e.Receipt(t, c, "120.00", nil, receivables.ReceiptLine{InvoiceID: i1.ID, Amount: money.MustParse("108.00")})) s := e.StatementOf(t, c, "MYR", expensestest.Today.AddDate(0, 0, -30), expensestest.Today) require.True(t, s.Opening.IsZero()) require.Len(t, s.Lines, 3, "two invoices, one receipt") require.Equal(t, "108.0000", s.Lines[0].Debit.String()) require.Equal(t, "120.0000", s.Lines[2].Credit.String()) require.Equal(t, "38.0000", s.Closing.String()) // 108 + 50 − 120 total := money.Zero() for _, r := range e.AgingNow(t).Rows { if r.CustomerID == c.PartyID && r.Currency == "MYR" { total = total.Add(r.Outstanding) } } require.Equal(t, 0, s.Closing.Cmp(total)) require.Equal(t, 0, s.Aging.Total.Cmp(total))}
func TestTheStatementOpensWithWhatCameBefore(t *testing.T) { e := receivablestest.New(t) e.UseReceivablesPoster(t) c := e.Customer(t, "KEDAI") e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "70.00"))) e.AdvanceClock(t, 10) e.Issue(t, e.Invoice(t, c, e.StdLine(t, "1", "30.00"))) s := e.StatementOf(t, c, "MYR", expensestest.Today.AddDate(0, 0, 5), expensestest.Today.AddDate(0, 0, 10)) require.Equal(t, "70.0000", s.Opening.String()) require.Len(t, s.Lines, 1) require.Equal(t, "100.0000", s.Closing.String())}-
Step 2: Run — expect failure (
undefined: receivables.AgingAsOf). -
Step 3: Write
aging.go.
// agingSQL: a document is open as of $2 when its posting entry is dated on or// before $2 and no reversal of that entry is; allocations count when// effective_on ≤ $2. Credit-note and receipt remainders are negative; a// receipt's base is its remainder at its own rate (R15).const agingSQL = ` WITH posted AS ( SELECT h.id, h.kind, h.reference, h.customer_party_id AS party, h.currency, h.fx_rate, h.invoice_date AS doc_date, h.due_on, h.gross_amount AS amt, h.gross_base AS base FROM sales_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.customer_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) UNION ALL SELECT p.id, 'customer_receipt', p.reference, p.customer_party_id, p.currency, p.fx_rate, p.receipt_date, p.receipt_date, p.amount, p.base_amount FROM customer_receipt p JOIN journal_entry e ON e.org_id = p.org_id AND e.source_id = p.id AND e.source_kind = 'customer_receipt' AND e.semantic = 'payout' WHERE p.org_id = $1 AND e.org_id = $1 AND e.entry_date <= $2 AND ($3::uuid IS NULL OR p.customer_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 ar_allocation a ON a.org_id = $1 AND a.effective_on <= $2 AND ((p.kind = 'sales_invoice' AND a.invoice_id = p.id) OR (p.kind = 'customer_credit_note' AND a.credit_note_id = p.id) OR (p.kind = 'customer_receipt' AND a.receipt_id = p.id)) GROUP BY p.id ), open AS ( SELECT p.*, p.amt - ap.amt AS left_amt, CASE WHEN p.kind = 'customer_receipt' THEN round((p.amt - ap.amt) * p.fx_rate, 4) ELSE p.base - ap.base END AS left_base FROM posted p JOIN applied ap ON ap.id = p.id ) SELECT o.id, o.kind, o.reference, o.party, c.code, pa.legal_name, o.currency, o.doc_date, o.due_on, CASE WHEN o.kind = 'sales_invoice' THEN o.left_amt ELSE -o.left_amt END, CASE WHEN o.kind = 'sales_invoice' THEN o.left_base ELSE -o.left_base END FROM open o JOIN party_customer c ON c.org_id = $1 AND c.party_id = o.party JOIN party pa ON pa.org_id = $1 AND pa.id = o.party WHERE o.left_amt <> 0 OR o.left_base <> 0 ORDER BY c.code, o.due_on, o.reference`AgingAsOf is payables.AgingAsOf with supplier → customer, using payables.BucketFor and payables.BucketAmounts (a zero value built as AP’s zeroBuckets, copied — it is unexported there). Check the TestEveryTenantQueryCarriesAnExplicitOrgPredicate guard on this query: every table (sales_invoice, journal_entry ×4, customer_receipt, ar_allocation, party_customer, party) names org_id = $1.
- Step 4: Write
statement.go.
// statementSQL is Ruling R16: the customer's lines on every ar account, in// one currency, transaction amounts (not base), fx_difference lines excluded.const statementLinesSQL = ` SELECT e.entry_date, e.entry_no, e.source_kind, e.source_id, e.description, l.debit, l.credit FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1 AND a.control_kind = 'ar' AND l.kind = 'normal' AND l.party_id = $2 AND l.currency = $3 AND e.entry_date BETWEEN $4 AND $5 ORDER BY e.entry_date, e.posted_seq, l.line_no`
const statementOpeningSQL = ` SELECT coalesce(sum(l.debit - l.credit), 0) FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1 AND a.control_kind = 'ar' AND l.kind = 'normal' AND l.party_id = $2 AND l.currency = $3 AND e.entry_date < $4`
func StatementFor(ctx context.Context, tx pgx.Tx, customerID uuid.UUID, currency string, from, to time.Time) (Statement, error) { // org; GetCustomer (ErrNotFound for an unknown id); currency "" → the customer's; // from > to → fieldErr("to", "range", "the end date is on or after the start"); // opening; lines with a running balance; closing = opening + Σ(debit − credit); // Aging: AgingAsOf(to, &customerID), rows in `currency` only, summed by bucket in // document currency (Outstanding, not OutstandingBase).}(journal_entry.entry_no is TEXT, unique per org; order by posted_seq, never by entry_no — 00052’s comment.)
-
Step 5: Test-world helpers
AgingNow,AgingAt,StatementOf,CustomerWithTerms. -
Step 6: Run — expect PASS.
-
Step 7: Sabotage the drift test (watch it fail once). In
agingSQL, change the receipt branch’s base top.base - ap.base(the receipt’s base at its own rate, minus allocations at the invoice’s):TestTheSubledgerEqualsTheControlAccountPerCustomergoes red on KEDAI — control 142.0000, sub-ledger 127.0000 (the foreign receipt’s 15.0000 FX gain surfaces as a phantom open item). Record the message. Restore. Then removeparty_idfrom the receipt’s remainder line inReceiptRule: red again (a nil-party control line —ControlBalanceByPartyrequires a party). Restore. -
Step 8: Commit.
AR Task 7: aging, statement, sub-ledger drift test
Task 8: The receivables module — permissions, wiring, grants, customer routes
Section titled “Task 8: The receivables module — permissions, wiring, grants, customer routes”Files:
- Create:
internal/modules/receivables/{permissions.go,boot.go,routes.go,http.go,errors.go,customer_handlers.go} - Create:
db/migrations/00313_receivables_owner_grants.sql - Modify:
constants.yaml,api/openapi.yaml,internal/app/app.go,internal/app/routes_test.go,cmd/api/main.go,cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go,cmd/{migrate,orgadmin,worker}/wiring_test.go - Test:
internal/modules/receivables/{env_test.go,customer_handlers_test.go,grants_test.go,boot_test.go},internal/platform/receivables/migration00313_test.go
Interfaces:
-
Consumes:
internal/platform/receivables(Tasks 1–7),httpx.Protected,member.RegisterPermission,provisioning.RegisterRolePermissions,accounting.RegisterPostingRule. -
Produces: permission constants
PermissionCustomerRead = "receivables.customer.read",PermissionCustomerManage,PermissionInvoiceRead,PermissionInvoiceCreate,PermissionInvoiceTransition,PermissionReceiptRead,PermissionReceiptCreate,PermissionReceiptTransition;receivables.Deps{DB *db.DB; Clock clock.Clock};receivables.Register(g, deps); routesGET/POST /receivables/customers,GET/PUT /receivables/customers/{id},GET /receivables/deposit-accounts; OpenAPI schemasCustomer,CustomerInput,CustomerList,DepositAccountList. -
Step 1: Failing tests.
internal/modules/receivables/env_test.go mirrors internal/modules/payables/env_test.go (the HTTP test world: a router with Register, members with chosen permissions, do(t, method, path, body, as)).
grants_test.go:
// P3's shape for AR (spec D16): a new organization's Owner and Finance roles// hold all eight receivables keys.func TestANewOrganizationsOwnerAndFinanceHoldTheReceivablesKeys(t *testing.T) { e := receivablestest.New(t) for _, role := range []string{"Owner", "Finance"} { keys := e.RoleKeys(t, role) require.NotEmpty(t, keys, "PRESENCE: the %s role exists and holds keys", role) for _, k := range []string{receivables.PermissionCustomerRead, receivables.PermissionCustomerManage, receivables.PermissionInvoiceRead, receivables.PermissionInvoiceCreate, receivables.PermissionInvoiceTransition, receivables.PermissionReceiptRead, receivables.PermissionReceiptCreate, receivables.PermissionReceiptTransition} { require.Contains(t, keys, k, role) } }}(e.RoleKeys(t, role) []string is a new receivablestest helper: the permission keys of the org’s system role of that name, read as admin.)
internal/platform/receivables/migration00313_test.go mirrors internal/platform/payables/migration00305_test.go (testdb.StartAt below 00313, seed an org with Owner and Finance, migrate up, assert the keys and the perm_version bump; migrate down, assert they are gone).
customer_handlers_test.go:
func TestCustomerRoutes(t *testing.T) { w := newWorld(t) manage := w.member(t, receivables.PermissionCustomerRead, receivables.PermissionCustomerManage) reader := w.member(t, receivables.PermissionCustomerRead) body := `{"code":"KEDAI","party_kind":"organization","legal_name":"Kedai Runcit Sdn Bhd","country_code":"MY", "payment_terms_days":30,"currency":"MYR","tin":"C20880050010","registration_type":"brn","registration_no":"201901000001"}` // PRESENCE: the manager creates; ABSENCE: the reader cannot. res := w.do(t, "POST", "/v1/receivables/customers", body, manage) require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) require.Equal(t, http.StatusForbidden, w.do(t, "POST", "/v1/receivables/customers", body, reader).Code) id := jsonField(t, res, "id") etag := res.Header().Get("ETag") require.Equal(t, http.StatusOK, w.do(t, "GET", "/v1/receivables/customers/"+id, "", reader).Code) // A bad field is 422 naming it; a stale If-Match is 409. bad := w.do(t, "POST", "/v1/receivables/customers", strings.Replace(body, `"KEDAI"`, `"bad code"`, 1), manage) require.Equal(t, http.StatusUnprocessableEntity, bad.Code) require.Contains(t, bad.Body.String(), "field=code rule=code_format") upd := w.doIfMatch(t, "PUT", "/v1/receivables/customers/"+id, body, manage, `"0"`) require.Equal(t, http.StatusConflict, upd.Code) require.Equal(t, http.StatusOK, w.doIfMatch(t, "PUT", "/v1/receivables/customers/"+id, body, manage, etag).Code)}-
Step 2: Run — expect failure.
-
Step 3: Permissions, grants, boot.
constants.yaml: after the payables.* keys, add the eight receivables.* keys. make generate.
permissions.go mirrors payables’: eight member.RegisterPermission calls —
| key | description |
|---|---|
receivables.customer.read |
Read customers and their balances and statements |
receivables.customer.manage |
Create and edit customers |
receivables.invoice.read |
Read sales invoices, credit notes and allocations, and print them |
receivables.invoice.create |
Prepare sales invoices and credit notes, edit their drafts, and allocate credit notes and money on account |
receivables.invoice.transition |
Use the sales document transition endpoint; authority to issue a credit note still comes from the workflow’s grants |
receivables.receipt.read |
Read customer receipts |
receivables.receipt.create |
Prepare customer receipts and edit their drafts |
receivables.receipt.transition |
Use the receipt transition endpoint; authority to void still comes from the workflow’s grants |
— and provisioning.RegisterRolePermissions("Finance", <all eight>). (Owner receives every registered key already; grants_test proves it.)
db/migrations/00313_receivables_owner_grants.sql — 00305’s shape, one array of the eight keys, inserted for r.system AND r.name IN ('Owner','Finance') with ON CONFLICT (role_id, permission_key) DO NOTHING, then the perm_version bump; Down deletes permission_key LIKE 'receivables.%' from those roles and bumps again.
boot.go: init() registers receivables.Rules() with accounting.RegisterPostingRule (payables’ boot.go).
- Step 4: OpenAPI and handlers.
api/openapi.yaml — under a new tag Receivables, following the /payables/suppliers… blocks exactly (security, If-Match/ETag headers, Problem responses):
/receivables/customers: get: operationId: listCustomers tags: [Receivables] summary: List customers description: At most 1000, ordered by code (Ruling R17). Requires receivables.customer.read. parameters: - { name: q, in: query, required: false, schema: { type: string, maxLength: 100 } } - { name: active, in: query, required: false, schema: { type: boolean } } responses: '200': { description: The customers, content: { application/json: { schema: { $ref: '#/components/schemas/CustomerList' } } } } post: operationId: createCustomer tags: [Receivables] summary: Create a customer description: A party (organization or individual) with the customer role. Requires receivables.customer.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/CustomerInput' } } } } responses: '201': { description: Created, headers: { ETag: { $ref: '#/components/headers/ETag' } }, content: { application/json: { schema: { $ref: '#/components/schemas/Customer' } } } } /receivables/customers/{id}: get: { operationId: getCustomer, … Requires receivables.customer.read. } put: { operationId: updateCustomer, … If-Match is required. Requires receivables.customer.manage. } /receivables/deposit-accounts: get: { operationId: listDepositAccounts, … Bank-kind accounts and cash journals' accounts (spec D8). Requires receivables.receipt.create. }and schemas (field names are the JSON keys customer_handlers.go uses):
Customer: type: object required: [id, code, party_kind, legal_name, country_code, address, payment_terms_days, currency, 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_type: { type: string, enum: [brn, nric, passport, army], nullable: true } registration_no: { type: string, nullable: true } sst_registration_no: { type: string, nullable: true } country_code: { type: string } address: { $ref: '#/components/schemas/PartyAddress' } # the AP supplier's address schema; reuse its name email: { type: string, nullable: true } phone: { type: string, nullable: true } payment_terms_days: { type: integer, minimum: 0, maximum: 365 } currency: { type: string } default_revenue_account_id: { type: string, format: uuid, nullable: true } default_tax_code_id: { type: string, format: uuid, nullable: true } is_active: { type: boolean } revision: { type: integer } CustomerInput: # the same without id and revision; is_active optional (ignored on create) CustomerList: { type: object, required: [customers], properties: { customers: { type: array, items: { $ref: '#/components/schemas/Customer' } } } } DepositAccountList: { type: object, required: [accounts], properties: { accounts: { type: array, items: { $ref: '#/components/schemas/Account' } } } }(If AP’s supplier schema inlines the address rather than naming it, name it PartyAddress now and point both at it — a rename of an inline schema changes no wire shape.)
http.go — internal/modules/payables/http.go, copied (its header comment says why a copy, not an import). errors.go — mapErr in AP’s shape for the platform’s errors: ErrNotFound 404; FieldError/SubmissionError 422; RevisionMismatchError 409 revision_mismatch; ErrDuplicateCustomerCode 409 duplicate_customer_code; ErrNotPreparer 403 not_preparer; ErrNotDraft 409; ErrOriginalNotIssued, ErrCreditExceedsInvoice 422 (credit_exceeds_invoice); ErrDepositAccount, ErrInvoiceNotReceivable, ErrAllocationTotal, ErrForeignUnallocated, ErrNotAllocatable, ErrBadCursor 422; ErrRateMismatch 422 rate_mismatch; ErrOverAllocation 422 over_allocated; ErrAlreadyReversed 409; ErrVoidTheReceipt 409 void_the_receipt; the workflow, fiscal-period, control-account and posting cases exactly as AP’s; pg.ConstraintName in sales_invoice_is_draft, sales_invoice_line_is_draft, customer_receipt_is_draft, customer_receipt_line_is_draft → 409 “the document is issued; correct it with a credit note” / “not a draft any more”; ar_allocation_* → 422 allocation_invalid.
customer_handlers.go mirrors internal/modules/payables/supplier_handlers.go’s list/get/create/update (no bank routes); listDepositAccounts returns receivables.DepositAccounts. routes.go:
func Register(g *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} pl := permissionLoader{db: deps.DB} httpx.Protected(g, pl, "GET", "/receivables/customers", PermissionCustomerRead, h.listCustomers) httpx.Protected(g, pl, "POST", "/receivables/customers", PermissionCustomerManage, h.createCustomer) httpx.Protected(g, pl, "GET", "/receivables/customers/:id", PermissionCustomerRead, h.getCustomer) httpx.Protected(g, pl, "PUT", "/receivables/customers/:id", PermissionCustomerManage, h.updateCustomer) httpx.Protected(g, pl, "GET", "/receivables/deposit-accounts", PermissionReceiptCreate, h.listDepositAccounts) // Task 9: documents, allocations, receipts, balance, statement, PDF.}-
Step 5: Wire the module.
internal/app/app.go:Receivables receivables.DepsinDepsandreceivables.Register(api, deps.Receivables)after payables;cmd/api/main.gofills it as payables’;cmd/{migrate,orgadmin,worker}/main.goadd_ "github.com/mcom/erp/internal/modules/receivables"(so provisioning in every binary seeds the three graphs and registers the Finance keys, and the worker’s Poster holds the rules); eachwiring_test.gogains the package (worker’s table gains{kind: "sales_invoice", sem: accrual},{"customer_credit_note", accrual},{"customer_receipt", payout});internal/app/routes_test.gopins the five routes’ permissions. -
Step 6: Run
make generate && go test ./internal/modules/receivables/... ./internal/platform/receivables/... ./internal/app/... ./cmd/... ./internal/kernel/... -count=1— expect PASS, includingTestEverySeededGraphPassesTheValidatorandTestEverySeededGraphShipsAllThreeLocalesnow thatinternal/applinks the module.git statusclean aftermake generate. -
Step 7: Sabotage. Register one route with
PermissionCustomerReadwhere the spec says manage:TestTheOpenAPIDocumentSaysWhatTheRouterEnforces/routes_testred. Restore. Remove theRegisterRolePermissionscall:TestANewOrganizationsOwnerAndFinanceHoldTheReceivablesKeysred on Finance. Restore. -
Step 8: Commit.
AR Task 8: receivables module, permissions, grants, customer routes
Task 9: Sales document, allocation and receipt routes; balance, statement, PDF
Section titled “Task 9: Sales document, allocation and receipt routes; balance, statement, PDF”Files:
- Create:
internal/modules/receivables/{document_handlers.go,receipt_handlers.go,pdf.go} - Modify:
internal/modules/receivables/{routes.go,customer_handlers.go,errors.go},api/openapi.yaml,internal/app/routes_test.go - Test:
internal/modules/receivables/{document_handlers_test.go,receipt_handlers_test.go,pdf_test.go,errors_test.go}
Interfaces:
- Consumes: Tasks 3–8;
render.New(render.FormatPDF),render.Meta/Column/Cell. - Produces: routes below; OpenAPI schemas
SalesDocument,SalesDocumentView(document +allocations,outstanding,available_transitions,timeline),SalesDocumentInput,SalesLine,SalesLineInput,SalesDocumentList(cursor page),ArAllocation,ArAllocationInput,CustomerReceipt,CustomerReceiptView,CustomerReceiptInput,CustomerReceiptList,CustomerBalance,CustomerStatement,SalesDocumentKindenum[sales_invoice, customer_credit_note].
Routes (added to Register; each pinned in routes_test.go):
httpx.Protected(g, pl, "GET", "/receivables/customers/:id/balance", PermissionInvoiceRead, h.customerBalance) httpx.Protected(g, pl, "GET", "/receivables/customers/:id/statement", PermissionInvoiceRead, h.customerStatement) httpx.Protected(g, pl, "GET", "/receivables/invoices", PermissionInvoiceRead, h.listDocuments) httpx.Protected(g, pl, "POST", "/receivables/invoices", PermissionInvoiceCreate, h.createDocument) httpx.Protected(g, pl, "GET", "/receivables/invoices/:id", PermissionInvoiceRead, h.getDocument) httpx.Protected(g, pl, "PUT", "/receivables/invoices/:id", PermissionInvoiceCreate, h.updateDocument) httpx.Protected(g, pl, "POST", "/receivables/invoices/:id/transitions", PermissionInvoiceTransition, h.transitionDocument) httpx.Protected(g, pl, "GET", "/receivables/invoices/:id/allocations", PermissionInvoiceRead, h.listDocumentAllocations) httpx.Protected(g, pl, "GET", "/receivables/invoices/:id/pdf", PermissionInvoiceRead, h.documentPDF) httpx.Protected(g, pl, "POST", "/receivables/allocations", PermissionInvoiceCreate, h.createAllocation) httpx.Protected(g, pl, "POST", "/receivables/allocations/:id/reverse", PermissionInvoiceCreate, h.reverseAllocation) httpx.Protected(g, pl, "GET", "/receivables/receipts", PermissionReceiptRead, h.listReceipts) httpx.Protected(g, pl, "POST", "/receivables/receipts", PermissionReceiptCreate, h.createReceipt) httpx.Protected(g, pl, "GET", "/receivables/receipts/:id", PermissionReceiptRead, h.getReceipt) httpx.Protected(g, pl, "PUT", "/receivables/receipts/:id", PermissionReceiptCreate, h.updateReceipt) httpx.Protected(g, pl, "POST", "/receivables/receipts/:id/transitions", PermissionReceiptTransition, h.transitionReceipt)- Step 1: Failing tests.
document_handlers_test.go:
func TestInvoiceRoutesIssueAndFreeze(t *testing.T) { w := newWorld(t) w.useReceivablesPoster(t) clerk := w.member(t, receivables.PermissionCustomerRead, receivables.PermissionInvoiceRead, receivables.PermissionInvoiceCreate, receivables.PermissionInvoiceTransition, "accounting.ledger.read") cust := w.customer(t, clerk, "KEDAI") body := fmt.Sprintf(`{"kind":"sales_invoice","customer_id":%q,"currency":"MYR","lines":[{"description":"Consulting services", "classification_code":"022","quantity":"2","unit_price":"50.00","discount_amount":"0","account_id":%q,"tax_code_id":%q}]}`, cust, w.account(t, "4100"), w.taxCode(t, "SVT8")) res := w.do(t, "POST", "/v1/receivables/invoices", body, clerk) require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) id := jsonField(t, res, "id") require.Equal(t, "108.0000", jsonField(t, res, "gross_amount")) require.Nil(t, jsonAny(t, res, "reference"), "no number on a draft (R2)") // PRESENCE: the draft has no PDF; after issue it does. require.Equal(t, http.StatusConflict, w.do(t, "GET", "/v1/receivables/invoices/"+id+"/pdf", "", clerk).Code) tr := w.do(t, "POST", "/v1/receivables/invoices/"+id+"/transitions", `{"from":"draft","to":"issued"}`, clerk) require.Equal(t, http.StatusOK, tr.Code, tr.Body.String()) require.Equal(t, "INV-2026-0001", jsonField(t, tr, "reference")) pdf := w.do(t, "GET", "/v1/receivables/invoices/"+id+"/pdf", "", clerk) require.Equal(t, http.StatusOK, pdf.Code) require.True(t, bytes.HasPrefix(pdf.Body.Bytes(), []byte("%PDF-"))) require.Contains(t, pdf.Header().Get("Content-Disposition"), "INV-2026-0001.pdf") // ABSENCE: an issued invoice is not editable — at its CURRENT revision, so // the 409 is the freeze and not a stale If-Match. etag := w.do(t, "GET", "/v1/receivables/invoices/"+id, "", clerk).Header().Get("ETag") put := w.doIfMatch(t, "PUT", "/v1/receivables/invoices/"+id, body, clerk, etag) require.Equal(t, http.StatusConflict, put.Code) require.NotContains(t, put.Body.String(), "revision_mismatch")}
func TestACreditNoteNeedsItsApprover(t *testing.T) { /* clerk submits; clerk's issue is 403 self_approval; the owner's is 200 */ }func TestAllocationRoutes(t *testing.T) { /* on-account receipt → POST /allocations → 201; reverse → 201; again → 409 */ }func TestTheStatementRoute(t *testing.T) { /* GET …/statement?from&to → opening, lines, closing, aging */ }(Write the three stubs in full in the shape of the first test and AP’s invoice_handlers_test.go: the owner is w.owner, a stale from is 409, a missing reason on reject is 422.)
receipt_handlers_test.go: create a receipt planned against an issued invoice (201), record it (200, RCT-2026-0001), a foreign receipt with a remainder is 422, the clerk’s void is 403, the owner’s void (with a reason) is 200 and the invoice’s outstanding is back.
errors_test.go mirrors AP’s: every sentinel mapErr maps, and an unmapped error passes through.
-
Step 2: Run — expect failure.
-
Step 3: OpenAPI. Add the paths above under tag
Receivablesin AP’s shapes (/payables/invoices…for documents, allocations and transitions;/payables/payments…for receipts). The transition body and response reuse AP’sDocumentTransitionInput/DocumentDecisionschemas. Key schemas:
SalesDocumentKind: { type: string, enum: [sales_invoice, customer_credit_note] } SalesLineInput: type: object required: [description, classification_code, quantity, unit_price, account_id, tax_code_id] properties: description: { type: string, maxLength: 500 } classification_code: { type: string, pattern: '^[0-9]{3}$', description: "e-Invoice classification; 022 is Others" } quantity: { $ref: '#/components/schemas/Rate' } unit_code: { type: string, nullable: true, pattern: '^[A-Z0-9]{2,3}$' } unit_price: { $ref: '#/components/schemas/Money' } discount_amount: { $ref: '#/components/schemas/Money' } account_id: { type: string, format: uuid } tax_code_id: { type: string, format: uuid } tax_exemption_reason: { type: string, nullable: true, maxLength: 300 } shares: { type: array, items: { $ref: '#/components/schemas/AnalyticShare' } } SalesLine: # SalesLineInput + id, line_no, net_amount, tax_amount, net_base, tax_base, tax_code, tax_pack_kind, tax_rate (Rate) SalesDocumentInput: type: object required: [customer_id, currency, lines] properties: kind: { $ref: '#/components/schemas/SalesDocumentKind' } # create only customer_id: { type: string, format: uuid } original_invoice_id: { type: string, format: uuid, nullable: true } reason: { type: string, nullable: true, maxLength: 500 } customer_reference: { type: string, maxLength: 64 } currency: { type: string } fx_rate: { $ref: '#/components/schemas/Rate' } payment_terms_days: { type: integer, minimum: 0, maximum: 365 } lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/SalesLineInput' } } SalesDocument: # every Document field: id, kind, reference (nullable), customer_id, original_invoice_id, reason, # customer_reference, currency, fx_rate, payment_terms_days, net_total, tax_total, gross_amount, # gross_base, issued_at, invoice_date, due_on, seller {name, tin, registration_no, sst_no, msic, address} # (nullable object), buyer {name, tin, registration_type, registration_no, sst_no, email, phone, # address, country_code} (nullable object), state, semantic, prepared_by, revision, created_at, lines ArAllocation: # AP's ApAllocation with receipt_id for payment_id, source_kind enum [receipt, credit_note] ArAllocationInput: type: object required: [invoice_id, amount] properties: invoice_id: { type: string, format: uuid } credit_note_id: { type: string, format: uuid } receipt_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } CustomerReceiptInput: type: object required: [customer_id, deposit_account_id, receipt_date, currency, amount, lines] properties: customer_id: { type: string, format: uuid } deposit_account_id: { type: string, format: uuid } receipt_date: { $ref: '#/components/schemas/Date' } bank_reference: { type: string, maxLength: 64 } currency: { type: string } fx_rate: { $ref: '#/components/schemas/Rate' } amount: { $ref: '#/components/schemas/Money' } lines: { type: array, items: { type: object, required: [invoice_id, amount], properties: { invoice_id: { type: string, format: uuid }, amount: { $ref: '#/components/schemas/Money' } } } } CustomerReceipt: # the input + id, reference (nullable), base_amount, unallocated, state, semantic, prepared_by, revision, created_at CustomerBalance: # AP's SupplierBalance: by currency, AgingBuckets CustomerStatement: type: object required: [customer_id, currency, from, to, opening, closing, lines, aging] properties: customer_id: { type: string, format: uuid } currency: { type: string } from: { $ref: '#/components/schemas/Date' } to: { $ref: '#/components/schemas/Date' } opening: { $ref: '#/components/schemas/Money' } closing: { $ref: '#/components/schemas/Money' } aging: { $ref: '#/components/schemas/AgingBuckets' } lines: type: array items: type: object required: [entry_date, entry_no, description, debit, credit, balance] properties: entry_date: { $ref: '#/components/schemas/Date' } entry_no: { type: string } source_kind: { type: string, nullable: true } source_id: { type: string, format: uuid, nullable: true } description: { type: string } debit: { $ref: '#/components/schemas/Money' } credit: { $ref: '#/components/schemas/Money' } balance: { $ref: '#/components/schemas/Money' }The PDF path’s 200 is content: { application/pdf: { schema: { type: string, format: binary } } } and its 409 a Problem with code: not_issued. The statement’s query parameters: from, to (required Date), currency (optional).
make generate.
- Step 4: Handlers.
document_handlers.gomirrorsinternal/modules/payables/invoice_handlers.go(DTOs built fromreceivables.Document,ETag= revision,If-Matchrequired on PUT, list withkind,customer_id,state,currency,open,cursor,limit; the view addsallocations,outstanding/outstanding_base,available_transitionsfromreceivables.Available, thetimelineas AP’s). Every transition handler passesdb.InTx’s error throughledger.MapErrorthenmapErr.receipt_handlers.gomirrorspayment_handlers.go(the view addsunallocated).customer_handlers.gogainscustomerBalance(AP’ssupplierBalanceoverreceivables.AgingAsOf(today, &id)) andcustomerStatement(receivables.StatementFor).
pdf.go (R18):
// documentPDF renders an issued sales document with the report renderer's// PDF writer (spec D15): the snapshots and dates as header fields, the lines// as the table, the totals as the last rows. A commercial copy; MyInvois's// UUID and QR code are the MyInvois item's.func (h handlers) documentPDF(c *gin.Context) { id, ok := parseID(c) if !ok { return } var body []byte var ref string err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { d, err := receivables.GetDocument(ctx, tx, id) if err != nil { return err } if d.IssuedAt == nil { return errNotIssued } ref = *d.Reference body, err = renderDocument(d) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.pdf"`, ref)) c.Data(http.StatusOK, "application/pdf", body)}
func renderDocument(d receivables.Document) ([]byte, error) { w, _, _, err := render.New(render.FormatPDF) if err != nil { return nil, err } title := map[string]string{receivables.KindInvoice: "Invoice", receivables.KindCreditNote: "Credit note"}[d.Kind] f := [][2]string{{"Number", *d.Reference}, {"Date", d.InvoiceDate.Format("2006-01-02")}} if d.Kind == receivables.KindInvoice { f = append(f, [2]string{"Due", d.DueOn.Format("2006-01-02")}) } f = append(f, [2]string{"From", joinNonEmpty(" · ", *d.SellerName, deref(d.SellerAddress))}, [2]string{"Seller TIN / BRN / SST", joinNonEmpty(" / ", deref(d.SellerTIN), deref(d.SellerRegistrationNo), deref(d.SellerSSTNo))}, [2]string{"To", joinNonEmpty(" · ", *d.BuyerName, deref(d.BuyerAddressLine1), deref(d.BuyerAddressCity), deref(d.BuyerAddressPostcode), deref(d.BuyerAddressState))}, [2]string{"Buyer TIN / ID / SST", joinNonEmpty(" / ", deref(d.BuyerTIN), deref(d.BuyerRegistrationNo), deref(d.BuyerSSTNo))}, [2]string{"Currency", d.Currency}) if d.Kind == receivables.KindCreditNote { f = append(f, [2]string{"Reason", *d.Reason}) } if d.CustomerReference != "" { f = append(f, [2]string{"Your reference", d.CustomerReference}) } if err := w.Begin(render.Meta{Title: title, Fields: f, Footer: title + " " + *d.Reference}, []render.Column{ {Title: "#", Weight: 2}, {Title: "Description", Weight: 20}, {Title: "Class", Weight: 3}, {Title: "Qty", Weight: 4}, {Title: "Unit price", Numeric: true, Weight: 7}, {Title: "Discount", Numeric: true, Weight: 6}, {Title: "Amount", Numeric: true, Weight: 8}, {Title: "Tax", Weight: 4}, {Title: "Tax amount", Numeric: true, Weight: 7}, }); err != nil { return nil, err } for _, l := range d.Lines { if err := w.Row([]render.Cell{render.Text(strconv.Itoa(l.LineNo)), render.Text(l.Description), render.Text(l.ClassificationCode), render.Text(trimRate(l.Quantity)), render.Money(l.UnitPrice), render.Money(l.DiscountAmount), render.Money(l.NetAmount), render.Text(l.TaxCode), render.Money(l.TaxAmount)}); err != nil { return nil, err } } blank := render.Cell{} for _, t := range []struct { label string a money.Amount }{{"Total excluding tax", d.NetTotal}, {"Tax", d.TaxTotal}, {"Total", d.GrossAmount}} { if err := w.Row([]render.Cell{blank, render.Bold(render.Text(t.label)), blank, blank, blank, blank, render.Bold(render.Money(t.a))}); err != nil { return nil, err } } return w.Finish()}(trimRate shows a quantity without trailing zeros; deref/joinNonEmpty are two small local helpers; errNotIssued maps to 409 not_issued in mapErr.)
-
Step 5: Run
make generate && go test ./internal/modules/receivables/... ./internal/app/... -count=1→ PASS;git statusclean after generate. -
Step 6: Sabotage. Drop
ledger.MapErrorfromtransitionDocumentand point the fiscal year’s period atclosedin a test: the handler returns 500 instead of 409 — the AP handler test for a closed period, mirrored indocument_handlers_test.go, goes red. Restore. -
Step 7: Commit.
AR Task 9: document, allocation and receipt routes; balance, statement, PDF
Task 10: AR aging report and exports (ar_aging, customer_statement)
Section titled “Task 10: AR aging report and exports (ar_aging, customer_statement)”Files:
- Create:
db/migrations/00314_report_export_ar.sql - Create:
internal/platform/reporting/export/reportcheck_internal_test.go,internal/platform/reporting/export/araging_internal_test.go - Modify:
internal/platform/reporting/export/{export.go,params.go,params_test.go,project.go,run.go,run_test.go},internal/modules/reports/{routes.go,aging_handlers.go},internal/modules/reports/*_test.go,api/openapi.yaml,internal/app/routes_test.go
Interfaces:
-
Consumes:
receivables.AgingAsOf,receivables.StatementFor,receivables.GetCustomer,receivables.ErrNotFound. -
Produces:
export.ReportARAging = "ar_aging",export.ReportCustomerStatement = "customer_statement";Params.CustomerID,Params.Currency;GET /reports/ar-aging(reports.read); OpenAPIArAging, exportreportenum += two values,ExportParams+=customer_id,currency. -
Step 1: Failing tests — the drift test first.
reportcheck_internal_test.go (package export, beside apaging_internal_test.go):
// TestTheExportCheckAcceptsEveryReport: every report this package can run is// allowed by report_export_report_known. Several Phase 2 items each rewrite// that CHECK with their own full list; one that forgets an earlier value// would make that report unrequestable in production and pass every other test.func TestTheExportCheckAcceptsEveryReport(t *testing.T) { h := testdb.Start(t) var def string require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'report_export_report_known'`).Scan(&def)) // PRESENCE: the definition was read and names a report we know is there. require.Contains(t, def, "'trial_balance'") require.NotEmpty(t, allowed) for report := range allowed { require.Contains(t, def, "'"+report+"'", "report_export_report_known does not allow %q; the last migration to rewrite it dropped it", report) }}params_test.go gains TestARAgingTakesAsOfOnly (AP’s twin) and:
func TestACustomerStatementNeedsItsCustomer(t *testing.T) { id := uuid.New() ok := export.Params{From: "2026-01-01", To: "2026-12-31", CustomerID: &id} require.NoError(t, ok.Validate(export.ReportCustomerStatement)) // PRESENCE missing := ok missing.CustomerID = nil var ip *export.InvalidParamsError require.ErrorAs(t, missing.Validate(export.ReportCustomerStatement), &ip) require.Equal(t, "customer_id", ip.Field) // and nothing else takes a customer require.ErrorAs(t, export.Params{AsOf: "2026-12-31", CustomerID: &id}.Validate(export.ReportARAging), &ip)}araging_internal_test.go mirrors apaging_internal_test.go over the receivables test world (an issued invoice appears in the ar_aging CSV; a statement CSV lists its lines and closes at the JSON’s closing). run_test.go’s TestEveryReportRendersInEveryFormat gains {Report: ReportARAging, Params: {AsOf: "2026-12-31"}} and a customer_statement over a customer the world creates.
-
Step 2: Run — expect failure (the drift test:
report_export_report_known does not allow "ar_aging"once the constants exist — firstundefined: ReportARAging). -
Step 3: The migration.
db/migrations/00314_report_export_ar.sql:
-- +goose Up-- AR spec D13, D14. Every later rewrite of this CHECK must carry the whole-- list; TestTheExportCheckAcceptsEveryReport fails the build if one does not.ALTER 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','ar_aging','customer_statement'));-- +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','ap_aging'));- Step 4: Export package.
export.go: the two constants; Filename treats ar_aging like ap_aging (<report>_<as_of>) and customer_statement like a range report; Create gains, after CheckScope:
if req.Report == ReportCustomerStatement { if _, err := receivables.GetCustomer(ctx, tx, *req.Params.CustomerID); errors.Is(err, receivables.ErrNotFound) { return Export{}, &InvalidParamsError{Field: "customer_id", Reason: "no such customer"} } else if err != nil { return Export{}, err } }params.go: the two fields (CustomerID *uuid.UUID json:“customer_id,omitempty”, `Currency string `json:"currency,omitempty"); allowed[ReportARAging] = {"as_of"}, allowed[ReportCustomerStatement] = {"from","to","customer_id","currency"}; present() and the Validate field loop gain customer_id, currency; Validate treats ar_aging like ap_aging, and for customer_statement requires customer_id after the range checks.
project.go: reportTitles += "AR aging", "Statement of account"; meta shows As of for ar_aging, and for customer_statement adds {"Customer", code + " " + legal name} and uses the statement’s currency; project dispatches both:
func projectARAging(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, w render.Writer) (int64, error)// projectAPAging's body over receivables.AgingAsOf, "Customer" for "Supplier", no "Supplier's no." column.
func projectCustomerStatement(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, w render.Writer) (int64, error) { s, err := receivables.StatementFor(ctx, tx, *x.Params.CustomerID, x.Params.Currency, x.Params.Range().From, x.Params.Range().To) if err != nil { return 0, err } m, err := meta(ctx, tx, c, x, s.Currency, false) if err != nil { return 0, err } if err := w.Begin(m, []render.Column{{Title: "Date", Weight: 6}, {Title: "Entry no.", Weight: 8}, {Title: "Description", Weight: 26}, {Title: "Debit", Numeric: true, Weight: 10}, {Title: "Credit", Numeric: true, Weight: 10}, {Title: "Balance", Numeric: true, Weight: 10}}); err != nil { return 0, err } blank := render.Cell{} if err := w.Row([]render.Cell{blank, blank, render.Bold(render.Text("Opening balance")), blank, blank, render.Bold(render.Money(s.Opening))}); err != nil { return 0, err } for _, l := range s.Lines { if err := w.Row([]render.Cell{render.Text(l.EntryDate.Format("2006-01-02")), render.Text(l.EntryNo), render.Text(l.Description), render.Money(l.Debit), render.Money(l.Credit), render.Money(l.Balance)}); err != nil { return 0, err } } if err := w.Row([]render.Cell{blank, blank, render.Bold(render.Text("Closing balance")), blank, blank, render.Bold(render.Money(s.Closing))}); err != nil { return 0, err } a := s.Aging for _, b := range []struct { label string v money.Amount }{{"Current", a.Current}, {"1–30 days", a.D1To30}, {"31–60 days", a.D31To60}, {"61–90 days", a.D61To90}, {"Over 90 days", a.Over90}} { if err := w.Row([]render.Cell{blank, blank, render.Text("Due: " + b.label), blank, blank, render.Money(b.v)}); err != nil { return 0, err } } return int64(len(s.Lines)), nil}run.go’s permanent: errors.Is(err, receivables.ErrNotFound) → FailureInvalidParameters.
-
Step 5: Reports module and OpenAPI.
internal/modules/reports/aging_handlers.gogainsgetARAging(AP’s handler overreceivables.AgingAsOf, DTO withcustomer_id,customer_code,customer_name, no supplier document number);routes.go:httpx.Protected(g, p, "GET", "/reports/ar-aging", read, h.getARAging). OpenAPI:/reports/ar-aging(AP’s/reports/ap-agingblock, schemaArAgingwithcustomers), the exportreportenum andExportParamsfields.routes_test.gopins the route.make generate. -
Step 6: Run
go test ./internal/platform/reporting/... ./internal/modules/reports/... ./internal/app/... -count=1→ PASS. -
Step 7: Sabotage the drift test. Remove
'customer_statement'from00314’s Up list; reset the DB:TestTheExportCheckAcceptsEveryReportred withreport_export_report_known does not allow "customer_statement". Restore. Record the message. -
Step 8: Commit.
AR Task 10: AR aging report, ar_aging and customer_statement exports
Task 11: Web — receivables API hooks, translations, customers screen, nav
Section titled “Task 11: Web — receivables API hooks, translations, customers screen, nav”Files:
- Create:
web/src/features/receivables/{api.ts,routes.tsx,CustomersPage.tsx,CustomerDialog.tsx,CustomerDrawer.tsx},web/src/features/receivables/__tests__/CustomersPage.test.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/receivables.json - Modify:
web/src/app/shell/nav.ts,web/src/app/routes.ts, the i18n namespace registry (whereverpayablesis listed — grep'payables'underweb/src/lib/i18n) - Test: MSW fixtures under
web/src/features/receivables/__tests__/
Interfaces:
-
Consumes: the generated
Schemas['Customer'],/receivables/customers…,/receivables/deposit-accounts. -
Produces:
receivablesKeys;useCustomers(),useCustomer(id),useSaveCustomer(),useDepositAccounts()(Task 13 uses it); typesCustomer; routes/receivables/customers,/new,/$id,/$id/edit. -
Step 1: Failing test.
CustomersPage.test.tsxmirrorsweb/src/features/payables/__tests__/SuppliersPage.test.tsx: the list renders rows from an MSWGET /v1/receivables/customers; “New customer” is shown only withreceivables.customer.manage; the dialog posts the fields (includingregistration_type+registration_noandsst_registration_no) and shows a 422’s field message beside the field. -
Step 2: Run
pnpm -C web test src/features/receivables— FAIL (module not found). -
Step 3: Translations. Every key in all three files;
i18nparity.test.tschecks it. The strings to use:
en/receivables.json:
{ "nav": { "customers": "Customers", "invoices": "Sales invoices", "receipts": "Receipts" }, "save": "Save", "close": "Close", "moved": "Done", "customers": { "title": "Customers", "subtitle": "Who the company sells to, their terms and their tax identifiers", "new": "New customer", "edit": "Edit customer", "detailTitle": "Customer", "active": "Active", "inactive": "Inactive", "termsDays_one": "{{count}} day", "termsDays_other": "{{count}} days", "columns": { "code": "Code", "name": "Name", "tin": "TIN", "terms": "Terms", "currency": "Currency", "balance": "Balance", "status": "Status" }, "empty": { "title": "No customers yet", "description": "Customers you add will appear here." }, "kinds": { "organization": "Organization", "individual": "Individual" }, "registrationTypes": { "brn": "Business registration (SSM)", "nric": "MyKad (NRIC)", "passport": "Passport", "army": "Armed forces ID" }, "fields": { "code": "Code", "kind": "Kind", "legalName": "Registered name", "tin": "Tax identification number (TIN)", "registrationType": "Identification type", "registrationNo": "Identification number", "sstNo": "SST registration number", "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", "revenueAccount": "Default income account", "taxCode": "Default tax code" }, "einvoiceHint": "An e-Invoice to this customer needs their TIN and identification number.", "balance": { "title": "Balance", "none": "Nothing outstanding." }, "statement": { "title": "Statement of account", "from": "From", "to": "To", "currency": "Currency", "opening": "Opening balance", "closing": "Closing balance", "date": "Date", "entry": "Entry", "description": "Description", "debit": "Debit", "credit": "Credit", "running": "Balance", "empty": "No transactions in this period." } }, "invoices": { "title": "Sales invoices", "subtitle": "Invoices and credit notes issued to customers", "tabs": { "sales_invoice": "Invoices", "customer_credit_note": "Credit notes" }, "new": { "sales_invoice": "New invoice", "customer_credit_note": "New credit note" }, "detailTitle": { "sales_invoice": "Sales invoice", "customer_credit_note": "Credit note" }, "draftReference": "Draft", "columns": { "reference": "Number", "customer": "Customer", "date": "Date", "due": "Due", "amount": "Amount", "outstanding": "Outstanding", "status": "Status" }, "empty": { "title": "Nothing here yet", "description": "Documents you prepare will appear here." }, "fields": { "customer": "Customer", "currency": "Currency", "rate": "Exchange rate to {{base}}", "terms": "Payment terms (days)", "customerReference": "Customer's reference (PO number)", "original": "Invoice being corrected", "reason": "Reason for the credit" }, "lines": { "title": "Lines", "add": "Add line", "remove": "Remove line", "description": "Description", "classification": "Classification code", "quantity": "Quantity", "unit": "Unit", "unitPrice": "Unit price", "discount": "Discount", "account": "Income account", "taxCode": "Tax code", "exemption": "Tax exemption details", "net": "Amount", "tax": "Tax", "total": "Total" }, "totals": { "net": "Total excluding tax", "tax": "Tax", "gross": "Total" }, "issuedTo": "Issued to", "issuedBy": "Issued by", "issuedOn": "Issued on {{date}}", "frozen": "Issued documents cannot be changed. Correct one with a credit note.", "saveDraft": "Save draft", "pdf": "Download PDF", "allocations": { "title": "Allocations", "none": "No allocations yet.", "receipt": "Receipt", "credit_note": "Credit note", "reversed": "Reversed", "reverse": "Reverse" }, "allocate": { "title": "Apply to an invoice", "invoice": "Invoice", "amount": "Amount", "apply": "Apply", "done": "Allocation recorded" }, "outstanding": "Outstanding {{amount}}", "unallocated": "Not yet applied {{amount}}" }, "receipts": { "title": "Receipts", "subtitle": "Money received from customers, applied to their invoices", "new": "New receipt", "detailTitle": "Customer receipt", "draftReference": "Draft", "columns": { "reference": "Number", "customer": "Customer", "date": "Received", "amount": "Amount", "unallocated": "On account", "status": "Status" }, "empty": { "title": "No receipts yet", "description": "Receipts you record will appear here." }, "fields": { "customer": "Customer", "depositAccount": "Received into", "date": "Date received", "bankReference": "Bank reference", "currency": "Currency", "rate": "Exchange rate to {{base}}", "amount": "Amount received" }, "apply": "Apply {{reference}}", "onAccount": "On account: {{amount}}", "foreignInFull": "A receipt in a foreign currency must be applied in full.", "voidReason": "Why it is being voided" }, "aging": { "tab": "AR aging", "customer": "Customer", "empty": "No customer owes anything as of this date.", "buckets": { "current": "Current", "days_1_30": "1–30 days", "days_31_60": "31–60 days", "days_61_90": "61–90 days", "over_90": "Over 90 days", "total": "Total" } }}ms/receivables.json — the same keys:
{ "nav": { "customers": "Pelanggan", "invoices": "Invois jualan", "receipts": "Penerimaan" }, "save": "Simpan", "close": "Tutup", "moved": "Selesai", "customers": { "title": "Pelanggan", "subtitle": "Pihak yang syarikat jual kepadanya, terma mereka dan pengenalan cukai mereka", "new": "Pelanggan baharu", "edit": "Sunting pelanggan", "detailTitle": "Pelanggan", "active": "Aktif", "inactive": "Tidak aktif", "termsDays_one": "{{count}} hari", "termsDays_other": "{{count}} hari", "columns": { "code": "Kod", "name": "Nama", "tin": "TIN", "terms": "Terma", "currency": "Mata wang", "balance": "Baki", "status": "Status" }, "empty": { "title": "Belum ada pelanggan", "description": "Pelanggan yang anda tambah akan dipaparkan di sini." }, "kinds": { "organization": "Organisasi", "individual": "Individu" }, "registrationTypes": { "brn": "Pendaftaran perniagaan (SSM)", "nric": "MyKad (No. KP)", "passport": "Pasport", "army": "Kad pengenalan angkatan tentera" }, "fields": { "code": "Kod", "kind": "Jenis", "legalName": "Nama berdaftar", "tin": "Nombor pengenalan cukai (TIN)", "registrationType": "Jenis pengenalan", "registrationNo": "Nombor pengenalan", "sstNo": "Nombor pendaftaran SST", "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", "revenueAccount": "Akaun pendapatan lalai", "taxCode": "Kod cukai lalai" }, "einvoiceHint": "e-Invois kepada pelanggan ini memerlukan TIN dan nombor pengenalan mereka.", "balance": { "title": "Baki", "none": "Tiada baki tertunggak." }, "statement": { "title": "Penyata akaun", "from": "Dari", "to": "Hingga", "currency": "Mata wang", "opening": "Baki awal", "closing": "Baki akhir", "date": "Tarikh", "entry": "Catatan", "description": "Keterangan", "debit": "Debit", "credit": "Kredit", "running": "Baki", "empty": "Tiada transaksi dalam tempoh ini." } }, "invoices": { "title": "Invois jualan", "subtitle": "Invois dan nota kredit yang dikeluarkan kepada pelanggan", "tabs": { "sales_invoice": "Invois", "customer_credit_note": "Nota kredit" }, "new": { "sales_invoice": "Invois baharu", "customer_credit_note": "Nota kredit baharu" }, "detailTitle": { "sales_invoice": "Invois jualan", "customer_credit_note": "Nota kredit" }, "draftReference": "Draf", "columns": { "reference": "Nombor", "customer": "Pelanggan", "date": "Tarikh", "due": "Tarikh akhir", "amount": "Amaun", "outstanding": "Baki tertunggak", "status": "Status" }, "empty": { "title": "Belum ada apa-apa di sini", "description": "Dokumen yang anda sediakan akan dipaparkan di sini." }, "fields": { "customer": "Pelanggan", "currency": "Mata wang", "rate": "Kadar pertukaran kepada {{base}}", "terms": "Terma bayaran (hari)", "customerReference": "Rujukan pelanggan (nombor PO)", "original": "Invois yang dibetulkan", "reason": "Sebab kredit" }, "lines": { "title": "Baris", "add": "Tambah baris", "remove": "Buang baris", "description": "Keterangan", "classification": "Kod klasifikasi", "quantity": "Kuantiti", "unit": "Unit", "unitPrice": "Harga seunit", "discount": "Diskaun", "account": "Akaun pendapatan", "taxCode": "Kod cukai", "exemption": "Butiran pengecualian cukai", "net": "Amaun", "tax": "Cukai", "total": "Jumlah" }, "totals": { "net": "Jumlah tidak termasuk cukai", "tax": "Cukai", "gross": "Jumlah" }, "issuedTo": "Dikeluarkan kepada", "issuedBy": "Dikeluarkan oleh", "issuedOn": "Dikeluarkan pada {{date}}", "frozen": "Dokumen yang telah dikeluarkan tidak boleh diubah. Betulkannya dengan nota kredit.", "saveDraft": "Simpan draf", "pdf": "Muat turun PDF", "allocations": { "title": "Peruntukan", "none": "Belum ada peruntukan.", "receipt": "Penerimaan", "credit_note": "Nota kredit", "reversed": "Dibatalkan", "reverse": "Batalkan" }, "allocate": { "title": "Gunakan pada invois", "invoice": "Invois", "amount": "Amaun", "apply": "Gunakan", "done": "Peruntukan direkodkan" }, "outstanding": "Baki tertunggak {{amount}}", "unallocated": "Belum digunakan {{amount}}" }, "receipts": { "title": "Penerimaan", "subtitle": "Wang yang diterima daripada pelanggan, digunakan pada invois mereka", "new": "Penerimaan baharu", "detailTitle": "Penerimaan pelanggan", "draftReference": "Draf", "columns": { "reference": "Nombor", "customer": "Pelanggan", "date": "Diterima", "amount": "Amaun", "unallocated": "Dalam akaun", "status": "Status" }, "empty": { "title": "Belum ada penerimaan", "description": "Penerimaan yang anda rekodkan akan dipaparkan di sini." }, "fields": { "customer": "Pelanggan", "depositAccount": "Diterima ke dalam", "date": "Tarikh diterima", "bankReference": "Rujukan bank", "currency": "Mata wang", "rate": "Kadar pertukaran kepada {{base}}", "amount": "Amaun diterima" }, "apply": "Gunakan {{reference}}", "onAccount": "Dalam akaun: {{amount}}", "foreignInFull": "Penerimaan dalam mata wang asing mesti digunakan sepenuhnya.", "voidReason": "Sebab ia dibatalkan" }, "aging": { "tab": "Penuaan penghutang", "customer": "Pelanggan", "empty": "Tiada pelanggan yang berhutang pada tarikh ini.", "buckets": { "current": "Semasa", "days_1_30": "1–30 hari", "days_31_60": "31–60 hari", "days_61_90": "61–90 hari", "over_90": "Lebih 90 hari", "total": "Jumlah" } }}zh/receivables.json — the same keys:
{ "nav": { "customers": "客户", "invoices": "销售发票", "receipts": "收款" }, "save": "保存", "close": "关闭", "moved": "完成", "customers": { "title": "客户", "subtitle": "公司的销售对象、付款条件及税务识别资料", "new": "新建客户", "edit": "编辑客户", "detailTitle": "客户", "active": "启用", "inactive": "停用", "termsDays_one": "{{count}} 天", "termsDays_other": "{{count}} 天", "columns": { "code": "代码", "name": "名称", "tin": "税号", "terms": "条件", "currency": "币种", "balance": "余额", "status": "状态" }, "empty": { "title": "尚无客户", "description": "您添加的客户将显示在这里。" }, "kinds": { "organization": "机构", "individual": "个人" }, "registrationTypes": { "brn": "商业注册(SSM)", "nric": "身份证(MyKad)", "passport": "护照", "army": "军人证" }, "fields": { "code": "代码", "kind": "类型", "legalName": "注册名称", "tin": "税务识别号(TIN)", "registrationType": "证件类型", "registrationNo": "证件号码", "sstNo": "SST 注册号", "address1": "地址第 1 行", "address2": "地址第 2 行", "city": "城市", "postcode": "邮编", "state": "州属", "country": "国家", "email": "电邮", "phone": "电话", "terms": "付款期限(天)", "currency": "币种", "revenueAccount": "默认收入科目", "taxCode": "默认税码" }, "einvoiceHint": "开给此客户的电子发票需要其税号和证件号码。", "balance": { "title": "余额", "none": "没有未结余额。" }, "statement": { "title": "对账单", "from": "起", "to": "止", "currency": "币种", "opening": "期初余额", "closing": "期末余额", "date": "日期", "entry": "凭证", "description": "摘要", "debit": "借方", "credit": "贷方", "running": "余额", "empty": "此期间没有交易。" } }, "invoices": { "title": "销售发票", "subtitle": "开给客户的发票和贷项通知单", "tabs": { "sales_invoice": "发票", "customer_credit_note": "贷项通知单" }, "new": { "sales_invoice": "新建发票", "customer_credit_note": "新建贷项通知单" }, "detailTitle": { "sales_invoice": "销售发票", "customer_credit_note": "贷项通知单" }, "draftReference": "草稿", "columns": { "reference": "编号", "customer": "客户", "date": "日期", "due": "到期日", "amount": "金额", "outstanding": "未结金额", "status": "状态" }, "empty": { "title": "这里还没有内容", "description": "您准备的单据将显示在这里。" }, "fields": { "customer": "客户", "currency": "币种", "rate": "兑 {{base}} 汇率", "terms": "付款期限(天)", "customerReference": "客户参考号(采购单号)", "original": "被更正的发票", "reason": "贷记原因" }, "lines": { "title": "明细", "add": "添加明细", "remove": "删除明细", "description": "摘要", "classification": "分类代码", "quantity": "数量", "unit": "单位", "unitPrice": "单价", "discount": "折扣", "account": "收入科目", "taxCode": "税码", "exemption": "免税说明", "net": "金额", "tax": "税额", "total": "合计" }, "totals": { "net": "不含税合计", "tax": "税额", "gross": "合计" }, "issuedTo": "开给", "issuedBy": "开票方", "issuedOn": "开具于 {{date}}", "frozen": "已开具的单据不能修改,请以贷项通知单更正。", "saveDraft": "保存草稿", "pdf": "下载 PDF", "allocations": { "title": "核销", "none": "尚无核销。", "receipt": "收款", "credit_note": "贷项通知单", "reversed": "已冲回", "reverse": "冲回" }, "allocate": { "title": "核销到发票", "invoice": "发票", "amount": "金额", "apply": "核销", "done": "已记录核销" }, "outstanding": "未结 {{amount}}", "unallocated": "未核销 {{amount}}" }, "receipts": { "title": "收款", "subtitle": "向客户收取的款项及其核销的发票", "new": "新建收款", "detailTitle": "客户收款", "draftReference": "草稿", "columns": { "reference": "编号", "customer": "客户", "date": "收款日", "amount": "金额", "unallocated": "预收", "status": "状态" }, "empty": { "title": "尚无收款", "description": "您记录的收款将显示在这里。" }, "fields": { "customer": "客户", "depositAccount": "收入账户", "date": "收款日期", "bankReference": "银行参考号", "currency": "币种", "rate": "兑 {{base}} 汇率", "amount": "收款金额" }, "apply": "核销 {{reference}}", "onAccount": "预收:{{amount}}", "foreignInFull": "外币收款必须全额核销。", "voidReason": "作废原因" }, "aging": { "tab": "应收账龄", "customer": "客户", "empty": "截至此日期没有客户欠款。", "buckets": { "current": "未到期", "days_1_30": "1–30 天", "days_31_60": "31–60 天", "days_61_90": "61–90 天", "over_90": "超过 90 天", "total": "合计" } }}(The zh strings are UI only; record text stays en/ms, §11A.3.)
- Step 4: API hooks and screens.
api.tsmirrorsweb/src/features/payables/api.ts— this task writes the customer half (receivablesKeys,useCustomers,useCustomer,useSaveCustomer,useDepositAccounts); Tasks 12–13 add the rest.CustomersPage,CustomerDialog,CustomerDrawer,routes.tsxmirror the AP supplier files without the bank section; the dialog addsregistration_type(a select of the fourregistrationTypes, blank allowed),sst_registration_no, and the default income account / tax code pickers (accounts filtered totype === 'income' && control_kind == null && is_postable; tax codes tousagesales/both), and showscustomers.einvoiceHintunder the TIN field. The list usesDataTablewithtableId="receivables.customers"(code and namerequired; TIN, currencydefaultHidden). The drawer’s statement and balance sections are Task 13.
nav.ts — append after the three payables items (APPEND-ONLY), icons from lucide-react (UsersRound, FileOutput, HandCoins — HandCoins is already imported):
{ group: 'accounting', to: '/receivables/customers', labelKey: 'receivables:nav.customers', icon: UsersRound, permission: 'receivables.customer.read', }, { group: 'accounting', to: '/receivables/invoices', labelKey: 'receivables:nav.invoices', icon: FileOutput, permission: 'receivables.invoice.read', }, { group: 'accounting', to: '/receivables/receipts', labelKey: 'receivables:nav.receipts', icon: HandCoins, permission: 'receivables.receipt.read', },routes.ts: ...receivablesRoutes after ...payablesRoutes.
-
Step 5: Run
pnpm -C web test && pnpm -C web lint && pnpm -C web typecheck→ PASS (includesi18nparity,nofloat,nohex). -
Step 6: Sabotage. Delete one key from
ms/receivables.json:i18nparity.test.tsred naming it. Restore. -
Step 7: Commit.
AR Task 11: web customers, receivables translations, nav
Task 12: Web — sales invoices and credit notes
Section titled “Task 12: Web — sales invoices and credit notes”Files:
- Create:
web/src/features/receivables/{InvoicesPage.tsx,InvoiceDialog.tsx,InvoiceDrawer.tsx},web/src/features/receivables/__tests__/InvoicesPage.test.tsx,InvoiceDialog.test.tsx - Modify:
web/src/features/receivables/{api.ts,routes.tsx},web/src/lib/money.ts,web/src/lib/money.test.ts
Interfaces:
-
Consumes: Task 9’s generated schemas (
SalesDocument,SalesDocumentView,SalesDocumentInput,ArAllocation). -
Produces:
useDocuments({kind, customer_id?, state?}),useOpenInvoices({customer_id, currency})(open=true,kind=sales_invoice),useDocument(id),useSaveDocument(),useDocumentTransition(id?)(AP P41: transitions go through the hook so the drawer’s cache is replaced),useAllocate(),useReverseAllocation(),documentPdfUrl(id);lineNet(unitPrice, quantity, discount): string. -
Step 1: The money helper, test first.
money.test.ts:
describe('lineNet', () => { it('rounds price × quantity once to the sen, then takes the discount', () => { expect(lineNet('33.3333', '3', '0')).toBe('100.0000'); // 99.9999 → 100.00 expect(lineNet('0.0001', '49.99', '0')).toBe('0.0000'); // 0.004999 → 0.00, not 0.01 expect(lineNet('12.50', '2', '5.00')).toBe('20.0000'); });});money.ts (beside percentOfMoney, same scaled-integer method, the server’s MulRateCents twin):
/** unit price × quantity (a Rate string) rounded half away from zero to cents, minus the * discount, in the wire's 4-decimal form — the preview of a sales line's amount (AR R10). * The server computes the stored value; this only shows it before saving. */export function lineNet(unitPrice: string, quantity: string, discount: string): string { const units = toWireUnits(unitPrice) * toRateUnits(quantity); const den = RATE_FACTOR * 100n; // rate units, then wire units → cents (1 cent = 100 wire units) const negative = units < 0n; const abs = negative ? -units : units; const cents = (abs + den / 2n) / den; return subtractMoney(fromWireUnits((negative ? -cents : cents) * 100n), discount);}The tax preview is the existing percentOfMoney(net, rate) (already half-up to cents, exact).
-
Step 2: Failing screen tests.
InvoicesPage.test.tsxmirrors AP’sInvoicesPage.test.tsx(tabs switchkind; a draft row shows “Draft” in the number column; “New invoice” needsreceivables.invoice.create).InvoiceDialog.test.tsx: picking a customer fills currency, terms, account and tax code from the customer’s defaults; typing quantity 3 and price 33.3333 on an SVT8 line shows100.00and tax8.00, totals108.00; for a credit note the dialog asks for the original invoice (an issued invoice of the customer fromuseDocuments) and a reason, and hides currency and rate (inherited, spec D6); “Issue invoice” posts the draft then the transitiondraft → issued. -
Step 3: Screens. Mirror AP’s
InvoicesPage/InvoiceDialog/InvoiceDrawer: -
InvoicesPage:DataTable tableId="receivables.invoices"; columns number (reference ?? t('invoices.draftReference'), required), customer, date, due, amount (required), outstanding (defaultHidden), status chip. Tabssales_invoice | customer_credit_note. -
InvoiceDialog: no document upload, no tax-record basis (a sales document is ours). Line fields: description, classification (default'022'), quantity (default'1'), unit (optional), unit price, discount (default'0'), income account, tax code (usagesales/bothonly, the P48 filter reversed), exemption details (shown when the chosen code’spack_kindisexempt— display only; the server does not interpret it). Computed per line:lineNet(...)andpercentOfMoney(net, taxRate); totals withsumMoney. Buttons: “Save draft” (PUT/POST) and the primary action fromavailable_transitions(for an invoice “Issue invoice”; for a credit note “Submit”). -
InvoiceDrawer: header chip; when issued, the buyer and seller snapshot blocks (issuedTo,issuedBy,issuedOn) and thefrozennote; lines; totals; allocations with “Reverse” (for credit-note allocations and base-currency receipt allocations — the server refuses the rest withvoid_the_receipt, shown as its translated sentence); for an issued credit note with a remainder, an “Apply to an invoice” form (allocate.*,useOpenInvoicesfor the same customer and currency); “Download PDF” (<a href={documentPdfUrl(id)} download>through the authenticated fetch helper the reportsdownload.tsuses) when issued; the timeline and transition bar as AP’s. -
Step 4: Run
pnpm -C web test && pnpm -C web lint && pnpm -C web typecheck→ PASS. -
Step 5: Sabotage. Change
lineNetto round after adding a half-cent twice (or usepercentOfMoneyfor the product): thelineNettest goes red at0.004999. Restore. -
Step 6: Commit.
AR Task 12: web sales invoices and credit notes
Task 13: Web — receipts, AR aging tab, customer balance and statement
Section titled “Task 13: Web — receipts, AR aging tab, customer balance and statement”Files:
- Create:
web/src/features/receivables/{ReceiptsPage.tsx,ReceiptDialog.tsx,ReceiptDrawer.tsx,CustomerStatement.tsx},web/src/features/reports/ARAgingView.tsx, testsweb/src/features/receivables/__tests__/ReceiptDialog.test.tsx,web/src/features/reports/ARAgingView.test.tsx - Modify:
web/src/features/receivables/{api.ts,routes.tsx,CustomerDrawer.tsx},web/src/features/reports/ReportsPage.tsx,web/src/lib/i18n/locales/{en,ms,zh}/reports.json(the tab label key ifReportsPagereads tab labels fromreports)
Interfaces:
-
Consumes: Tasks 9–10’s generated schemas;
ExportMenu(report="customer_statement",params={customer_id, from, to, currency}). -
Produces:
useReceipts(),useReceipt(id),useSaveReceipt(),useReceiptTransition(id?),useCustomerBalance(id),useCustomerStatement(id, {from, to, currency}),useARAging(asOf). -
Step 1: Failing tests.
ReceiptDialog.test.tsx: choosing a customer lists their open invoices in the receipt’s currency (useOpenInvoices) with an amount input each (“Apply INV-2026-0001”); the “On account” figure isamount − Σ applied(subtractMoney); in a foreign currency a non-zero remainder showsreceipts.foreignInFulland disables “Record receipt”; the deposit-account picker listsuseDepositAccounts().ARAgingView.test.tsxmirrorsAPAgingView.test.tsx. -
Step 2: Screens.
-
ReceiptsPage/ReceiptDrawermirror AP’s payments files (tableId="receivables.receipts", columns number, customer, date, amount (required), on account, status). The drawer shows allocations with reverse where allowed, an “Apply to an invoice” form whileunallocated > 0and the receipt isreceivedin the base currency, and the void action (reason required,receipts.voidReason) for whoever the workflow offers it to. -
ReceiptDialogas above; primary action “Record receipt” (draft → received). -
CustomerStatement(inCustomerDrawer): from/to date inputs (default: first of this month → today), currency select (the customer’s currency default), the table (statement.*keys,MoneyText), opening/closing rows, the aging buckets row, and<ExportMenu report="customer_statement" params={{ customer_id, from, to, currency }} />. The balance section (useCustomerBalance) shows per currency totals, orbalance.none. -
ARAgingView=APAgingViewoveruseARAging, namespacereceivables,customersforsuppliers.ReportsPage: add'ar_aging'beside'ap_aging'everywhereReportsPage.tsxlists or branches onap_aging(the report list, the “as of” branch, the view switch, theExportMenureport). -
Step 3: Run
pnpm -C web test && pnpm -C web lint && pnpm -C web typecheck→ PASS. -
Step 4: Sabotage. Make the dialog allow a foreign remainder (drop the disable):
ReceiptDialog.test.tsxred. Restore. -
Step 5: Commit.
AR Task 13: web receipts, AR aging, customer statement
Task 14: End to end on lane 1, dev seed, roadmap, deploy notes, full suite
Section titled “Task 14: End to end on lane 1, dev seed, roadmap, deploy notes, full suite”Files:
- Modify (only if the integration branch does not already take lane overrides — another Phase 2 lane may have added them; use theirs if so):
scripts/dev-stack.sh,web/playwright.config.ts - Modify:
web/e2e/seed.ts,web/e2e/support.ts - Create:
web/e2e/receivables.spec.ts - Modify:
docs/ROADMAP.md,docs/DEPLOY_NOTES.md
Interfaces:
-
Consumes: the running lane-1 stack;
as,api,runTagfromsupport.ts. -
Produces:
ensureFinanceAR(): Promise<void>; the e2e flow. -
Step 1: Lane ports. If
scripts/dev-stack.shstill hard-codes its ports, make every port and name overridable, defaults unchanged:
PG=${ERP_PG_NAME:-erp-dev-pg}MINIO=${ERP_MINIO_NAME:-erp-dev-minio}PG_PORT=${ERP_PG_PORT:-55432}MINIO_PORT=${ERP_MINIO_PORT:-59000}API_PORT=${ERP_API_PORT:-3000}WEB_PORT=${ERP_WEB_PORT:-5173}STATE="${ERP_STATE_DIR:-$ROOT/.dev-stack}"and use $API_PORT / $WEB_PORT for PORT=, PUBLIC_BASE_URL=, the two health-check curls, API_BASE= and the final echo. web/playwright.config.ts: const WEB = process.env.ERP_WEB_PORT ?? '5173'; const API = process.env.ERP_API_PORT ?? '3000'; in baseURL, the web server command/url and the API health url; support.ts already reads API_BASE. Lane 1 runs with:
export ERP_PG_NAME=erp-lane1-pg ERP_MINIO_NAME=erp-lane1-minio ERP_PG_PORT=56412 ERP_MINIO_PORT=59110 \ ERP_API_PORT=3110 ERP_WEB_PORT=5192 ERP_STATE_DIR="$PWD/.dev-stack-lane1" API_BASE=http://localhost:3110/v1- Step 2: Seed and support.
seed.ts: the Finance role gains the eightreceivables.*keys (next to itspayables.*).support.ts:
/** Arrange, not under test: an org seeded before AR gets Finance's AR keys (00313 does this * for a migrated database; a freshly seeded lane already has them). */export async function ensureFinanceAR() { // ensureFinanceAP's shape: as owner, read Finance's role, PUT it with the eight receivables keys added if missing.}- Step 3: The e2e.
web/e2e/receivables.spec.ts:
import { expect, test, type Page } from '@playwright/test';import { as, ensureFinanceAR, runTag } from './support';
const chip = (page: Page) => page.getByRole('dialog').getByRole('heading', { level: 3 }).first().locator('xpath=following-sibling::*[1]');
test.beforeAll(async () => { await ensureFinanceAR();});
test('customer → invoice issued → aging → receipt → aging zero → statement', async ({ browser }) => { const tag = runTag(); const code = `E2E${tag.replace(/\D/g, '').slice(-6)}`; const name = `${tag} Kedai Runcit Sdn Bhd`;
// 1. Finance creates the customer. const fin = await as(browser, 'finance'); await fin.goto('/receivables/customers'); await fin.getByRole('button', { name: 'New customer' }).first().click(); const cd = fin.getByRole('dialog'); await cd.getByLabel('Code', { exact: true }).fill(code); await cd.getByLabel('Registered name').fill(name); await cd.getByRole('button', { name: 'Save' }).click();
// 2. Finance prepares and issues an invoice (no approval step, spec D5). await fin.goto('/receivables/invoices'); await fin.getByRole('button', { name: 'New invoice' }).first().click(); const inv = fin.getByRole('dialog'); await inv.getByRole('combobox', { name: 'Customer' }).click(); await fin.getByRole('option', { name: new RegExp(code) }).click(); await inv.getByLabel('Description').fill('Consulting services'); await inv.getByRole('combobox', { name: 'Income account' }).click(); await fin.getByRole('option', { name: /4100/ }).click(); await inv.getByRole('combobox', { name: 'Tax code' }).click(); await fin.getByRole('option', { name: /SVT8/ }).click(); await inv.getByLabel('Unit price').fill('100.00'); await expect(inv.getByText('108.00')).toBeVisible(); // 100 + SST 8 await inv.getByRole('button', { name: 'Issue invoice' }).click(); await expect(chip(fin)).toHaveText('Issued'); await expect(fin.getByRole('dialog').getByText(/^INV-\d{4}-\d{4}$/)).toBeVisible();
// PRESENCE: issued and unpaid, the customer is in AR aging. await fin.goto('/reports?report=ar_aging'); await expect(fin.getByText(name)).toBeVisible();
// 3. Finance records the receipt against it. await fin.goto('/receivables/receipts'); await fin.getByRole('button', { name: 'New receipt' }).first().click(); const rd = fin.getByRole('dialog'); await rd.getByRole('combobox', { name: 'Customer' }).click(); await fin.getByRole('option', { name: new RegExp(code) }).click(); await rd.getByRole('combobox', { name: 'Received into' }).click(); await fin.getByRole('option', { name: /1520/ }).click(); await rd.getByLabel('Amount received').fill('108.00'); await rd.getByLabel(/^Apply INV-/).fill('108.00'); await rd.getByRole('button', { name: 'Record receipt' }).click(); await expect(chip(fin)).toHaveText('Received');
// 4. ABSENCE: paid in full, the customer is gone from AR aging. await fin.goto('/reports?report=ar_aging'); await expect(fin.getByText(name)).toHaveCount(0);
// 5. The statement lists both and closes at zero. await fin.goto('/receivables/customers'); await fin.getByRole('row', { name: new RegExp(code) }).click(); const st = fin.getByRole('dialog').getByRole('table', { name: 'Statement of account' }); await expect(st.getByText(/Sales invoice INV-/)).toBeVisible(); await expect(st.getByText(/Customer receipt RCT-/)).toBeVisible(); await expect(st.getByRole('row', { name: /Closing balance/ })).toContainText('0.00');});Run on lane 1: start the stack (bash scripts/dev-stack.sh with the lane-1 exports) and pnpm -C web exec playwright test e2e/receivables.spec.ts with the same exports. First run on a stack whose org predates AR: re-provision the org once (POST /v1/org/provision as owner) so it gains the three workflow kinds. Expected: PASS. Then run the whole e2e directory once on lane 1 (the AP flow must still pass).
- Step 4: Docs.
docs/ROADMAP.md— tick the AR line and nest, in AP’s style:
- [x] AR: customer role, sales invoices, credit notes, receipts, aging, statements Design: [`2026-09-27-accounts-receivable-design.md`](superpowers/specs/2026-09-27-accounts-receivable-design.md) (D1–D20), migrations `00310`–`00314` Plan: [`2026-09-27-accounts-receivable.md`](superpowers/plans/2026-09-27-accounts-receivable.md) (14 tasks, Rulings R1–R23) Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — re-provision every organization (three AR workflow kinds) - Sub-ledger = control per customer is a drift test (`internal/platform/receivables/subledger_test.go`) - Issued documents are frozen by trigger; corrections are credit notes (the MyInvois Ruling) - The export CHECK accepts every report is a drift test (`reporting/export/reportcheck_internal_test.go`) - End to end: customer → invoice issued → receipt → aging zero → statement (`web/e2e/receivables.spec.ts`) - [ ] Open decisions (owner's call): the 13 lines of the spec's "For the owner to confirm"and under the SST-02 item’s note add: “AR lines carry tax_code, tax_pack_kind, tax_rate (AR spec §“How SST-02 reads AR”); the AP side still has only account = tax_code.account_id.” Under the MyInvois item: “Reads AR spec §“Hand-off to MyInvois” (data contract, EventDocumentIssued, graph v2 for cancellation).”
docs/DEPLOY_NOTES.md — an AR entry: migrations 00310–00314; re-provision every organization (POST /v1/org/provision) so it gains the sales_invoice, customer_credit_note, customer_receipt workflow kinds; no chart or tax-code change (Trade receivables 1540 and the sales SST codes already exist); existing Owner and Finance roles gain the AR keys through 00313.
-
Step 5: Full suite, once.
make generate && git status(clean) →go test -p 2 -count=1 ./...→pnpm -C web test && pnpm -C web lint && pnpm -C web typecheck. All green. -
Step 6: Commit.
AR Task 14: e2e, dev seed, roadmap, deploy notes
Self-Review
Section titled “Self-Review”Spec coverage.
| Spec | Task |
|---|---|
D1 customer role, registration_type, sst_registration_no |
1 |
D2 ar control, party on every line, drift test |
4, 6, 7 |
| D3 one platform package, one table | 1, 3 |
| D4 MyInvois shape, snapshots, cents, freeze | 3, 4 |
| D5 issue without approval, number/date at issue, terminal | 2, 4 |
| D6 credit note: original, reason, rate, approval, cap, self-allocation | 2, 3, 4, 6 |
| D7 output SST snapshot and posting | 3, 4 |
| D8 receipts, deposit accounts, void | 2, 5, 6 |
| D9 on account, base currency only | 5, 6 |
| D10 append-only allocation, reversal rules | 5, 6 |
| D11 posting rules | 4, 6 |
| D12 FX | 3, 4, 6 |
| D13 AR aging report + export | 7, 10 |
| D14 statements JSON + export | 7, 9, 10 |
| D15 invoice PDF | 9 |
| D16 permissions and roles | 8 |
| D17 write-off out | — (spec only) |
| D18 identity checks | 1 |
| D19 retention | 1, 3, 5 |
D20 audit and EventDocumentIssued |
4 |
| Tests list | 1–10, 14 |
Guards watched failing: Tasks 1 (2), 2, 3 (3), 4 (3), 5, 6 (3), 7 (2), 8 (2), 9, 10, 11, 12, 13 — each Sabotage step records its red message in the commit body.
Consistency checked across tasks: receivables.Kind{Invoice,CreditNote,Receipt} ↔ workflow kinds ↔ journal_entry.source_kind ↔ sales_invoice.kind CHECK ↔ aging SQL literals ↔ OpenAPI SalesDocumentKind ↔ web tabs; state keys (R4) ↔ test helpers (Issue, Submit, ApproveCreditNote, Record, VoidReceipt) ↔ HTTP tests ↔ e2e labels (“Issue invoice”, “Issued”, “Record receipt”, “Received”); ControlKind = "ar" ↔ graphs ↔ ControlBalanceByParty ↔ statement SQL; permission keys identical in constants.yaml, permissions.go, 00313, routes_test.go, seed.ts, support.ts, nav; AllocationInput ↔ ArAllocationInput; payables.BucketAmounts JSON keys ↔ AgingBuckets ↔ ARAgingView ↔ aging.buckets.*; migration numbers follow task order (00310 T1, 00311 T3, 00312 T5, 00313 T8, 00314 T10). Temporary skips: Task 3 (two, removed in Task 4), Task 5 (one, removed in Task 6); Task 3’s ListDocuments.Open and Task 5’s inline outstanding are replaced in Task 6.
Dependencies on other Phase 2 items: none on their interfaces. Shared-file touch points a merger should expect: migrationranges_test.go (every lane adds a range), report_export_report_known (guarded by Task 10’s drift test), scripts/dev-stack.sh / playwright.config.ts lane overrides (whichever lane lands first; the others reuse), nav.ts (append-only), constants.yaml, api/openapi.yaml + generated clients.