Skip to content

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.

Every task’s requirements implicitly include this section.

  • Migrations 00310–00329 only. This plan uses 00310–00314. Task 1 narrows AP’s declared range to {300, 309, "Phase 2 AP"} and adds {310, 329, "Phase 2 AR"} in internal/kernel/db/migrationranges_test.go in the same commit as 00310 (AP declared 300–339, which would silently “own” AR’s files; the payment-batches lane will add its own 330–339).
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire (OpenAPI Money). Rates and quantities are money.Rate / NUMERIC(19,10) / OpenAPI Rate (the expense_claim_line.quantity precedent). Compare with Cmp, never ==. No float64 in internal/platform/receivables, internal/modules/receivables, web/src/features/receivables (web/src/drift/nofloat.test.ts scans the web).
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, CONSTRAINT <table>_tenant_id UNIQUE (org_id, id) (or (org_id, party_id)), and composite (org_id, x) foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Named constraints. Every CHECK/UNIQUE/FOREIGN KEY is CONSTRAINT <table>_<rule>; no name ends in _check, _key, _excl (TestEveryConstraintIsNamed). A trigger refusal is RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name.
  • Pinned functions. SET search_path = pg_catalog, public, pg_temp, schema-qualified tables, no SECURITY DEFINER (TestEveryFunctionPinsItsSearchPath). The AR triggers reuse public.payables_semantic(org, instance) from 00302 — despite its name it only reads an instance’s current semantic; it is not renamed (surgical).
  • Every SQL statement names org_id = $n for each org-scoped table it touches, joins included, and every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org from tenancy.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_line are amendable (REVOKE DELETE); ar_allocation is appendOnly (REVOKE UPDATE, DELETE).
  • Accounting reacts to semantics. Rules are keyed (kind, semantic); no Go or SQL here compares a state_key to a literal except the seeded graphs and tests.
  • Modules never write journal lines. receivables.Rules() is registered from internal/modules/receivables/boot.go’s init().
  • Lock order, one for the whole plan: document header FOR UPDATE (sales document or receipt) → a credit note’s original invoice FOR UPDATE → other invoice rows FOR UPDATE in id order (receipt allocation) → numbering.Allocate → workflow.Transition (instance lock, posting) → allocation inserts that need the posted state (a credit note’s self-allocation) → this plan’s audit.Write, last. Nothing writes an audit row before workflow.Transition in 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 passes db.InTx’s error through ledger.MapError before mapping it to HTTP (AP’s handlers do).
  • Business date is clock.TodayUTC(c); every DATE in Go is midnight UTC; never time.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.Check in Go and a named CHECK (… !~ '[぀-ヿ㐀-䶿一-鿿가-힯]') 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 422 field=<name> rule=<code>: <sentence> (receivables.FieldError, AP’s shape).
  • Every route goes through httpx.Protected with a receivables.* (or reports.*) permission and is in api/openapi.yaml in the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces). api/public_routes.txt gains nothing. make generate after every migration, OpenAPI or constants.yaml change; generated files are committed; git status clean 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.ts requires identical keys and placeholders. Malay is genuine Malay — the strings in this plan are the ones to use. Seeded workflow labels carry en, 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), never t.Parallel().
  • Tests: testdb.Start (through expensestest.New) per test; run only the touched packages while iterating (go test ./internal/platform/receivables/... -count=1); one full go test -p 2 -count=1 ./... in Task 14 only.
  • E2E on lane 1 only: pg 56412, minio 59110, api 3110, vite 5192. 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>

Plan-level choices the spec leaves open. Format: Ruling: <decision> — <why>.

  • R1 Ruling: Kinds and entities are sales_invoice, customer_credit_note, customer_receipt; tables party_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, RCT from numbering.Allocate(kind, fiscal EndYear), allocated at issue / at recording (spec D5, D8), so sales_invoice.reference and customer_receipt.reference are NULL while a draft. (CN is AP’s supplier credit note; CRN avoids 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 note draft, pending_approval, issued, rejected, cancelled; receipt draft, received, voided, cancelled. Action keys issue, withdraw, submit, issue (credit note, target-gated), reject, revise, record, void. issued is terminal on both document kinds (spec D5).
  • R5 Ruling: Seeded grants: credit note issued → OWNER seat; receipt voided → OWNER seat. The invoice graph has no grant (only reporter-gated edges).
  • R6 Ruling: workflow.AccrualOnlyKinds gains sales_invoice, customer_credit_note. SignoffKinds is unchanged: no seeded step to sign, and revenue lines’ analytic shares go to the ledger (Distribution), not to the engine. Every draft save calls workflow.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 (AP ErrNotPreparer).
  • 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_id required (422 tax_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). Bases net.MulRate(fx), tax.MulRate(fx) (AP P13).
  • R11 Ruling: Submission (credit note draft → pending_approval) and issue both run checkIssuable: ≥ 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_at is set by the transition service on entering a cancelled-semantic state (withdraw). An issued document is never cancelled in v1.
  • R13 Ruling: A receipt’s planned lines become ar_allocation rows on entering its payout state, in that transaction, before workflow.Transition (AP P20); voiding inserts the reversals first. A credit note’s self-allocation is inserted after workflow.Transition, because the allocation guard requires the credit note’s accrual_open.
  • R14 Ruling: POST /receivables/allocations takes {invoice_id, amount} and exactly one of credit_note_id / receipt_id; a receipt source must be received, in the base currency (spec D9); a credit-note source must share the invoice’s rate (AP P23). POST /receivables/allocations/{id}/reverse refuses (409 void_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 dated receipt_date; their base is remainder × fx_rate (0 for a foreign receipt, which is fully allocated).
  • R16 Ruling: Statement lines are journal lines of kind = 'normal' on accounts with control_kind = 'ar', party_id = the customer, currency = the chosen currency (default: the customer’s currency), entry_date in [from, to], ordered by entry_date, posted_seq, line_no (the general ledger’s order; entry_no is text, numbered per journal); opening = Σ(debit − credit) before from.
  • R17 Ruling: Lists are keyset-paginated on (created_at DESC, id DESC) (AP P30, cursor.go copied); customers ordered by code, capped at 1000.
  • R18 Ruling: GET /receivables/invoices/{id}/pdf is synchronous (a document is ≤ 200 lines), rendered with render.New(render.FormatPDF), Content-Disposition: attachment; filename="<reference>.pdf", under receivables.invoice.read; a draft is 409 not_issued.
  • R19 Ruling: GET /receivables/deposit-accounts under receivables.receipt.create lists active postable accounts of control kind bank plus the default accounts of active cash journals (spec D8), never an account with an owner_module (a petty-cash float), in code order.
  • R20 Ruling: The export Params gains CustomerID *uuid.UUID (customer_id) and Currency string (currency); ar_aging takes as_of; customer_statement takes from, to, customer_id (required), currency (optional). Create refuses an unknown customer as InvalidParamsError{Field: "customer_id"}; the worker treats receivables.ErrNotFound as invalid_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 accounting group (/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 with ExportMenu.
  • R23 Ruling: EventDocumentIssued = "receivables.document_issued", payload DocumentIssued{OrgID, DocumentID uuid.UUID; Kind, Reference string}, published with eventbus.Publish after the audit write (spec D20).

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.
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.
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; constants receivables.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; types FieldError, RevisionMismatchError, ErrNotFound, ErrDuplicateCustomerCode; Customer, CustomerInput, CustomerFilter, Address; funcs GetCustomer, ListCustomers, CreateCustomer, UpdateCustomer; receivablestest.Env{*expensestest.Env} with New, 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 Down
DROP 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 receivables

plus 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.go and 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_no from the migration, reset the test DB, run TestPartyTakesTheBuyerIdentifiers: red on party_registration_type_needs_no. Restore. Then comment out the registration_needs_number case in validate: TestCustomerFieldsAreRefusedByName red. 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 in init(); workflow.AccrualOnlyKinds includes 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──▶ cancelled
func 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──▶ cancelled
func 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──▶ cancelled
func 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.TestEverySeededGraphPassesTheValidator and TestEverySeededGraphShipsAllThreeLocales only see the graphs once Task 8 links the module into internal/app; Task 8 re-runs them).

  • Step 5: Sabotage. Remove "sales_invoice" from AccrualOnlyKinds: TestASalesInvoiceCannotCarryAPayout red (codes lacks payout_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; tables sales_invoice, sales_invoice_line; types Document, Line, LineInput, DocumentInput, DocumentFilter, Cursor; funcs GetDocument, ListDocuments, CreateDocument, UpdateDocument, lockDocument; errors ErrNotDraft, ErrNotPreparer, ErrBadCursor; test helpers receivablestest.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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 Down
DROP 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, no bindDocument, no invoiceShares/SetShares (R6). reprice still calls workflow.SetAmount(ctx, tx, c, instanceID, grossBase).
  • Document carries every column of sales_invoice (the issue snapshot as pointers), plus State, Semantic, AccrualOpen from the instance join; Line carries every column of sales_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}.
  • CreateDocument does not allocate a number (R2); it inserts with reference NULL and payment_terms_days = the input’s or the customer’s.
  • For a credit note, CreateDocument/UpdateDocument load the original (GetDocument), require Kind == 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-language Reason, and overwrite in.Currency, in.FXRate with the original’s (D6), before checkInput.

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_cents from the migration: TestTheSchemaKeepsTheDocumentConsistent red. (b) Replace MulRateCents in checkInput with MulRate(l.Quantity): TestADraftPricesItsLinesToTheSen red (99.9999), then fails the cents CHECK. (c) Remove the tax_code_account case: TestAPositiveRateCodeMustNameAnAccount red. 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 two AccrualRules; Task 6 adds ReceiptRule); EventDocumentIssued, DocumentIssued; errors ErrCreditExceedsInvoice, ErrOriginalNotIssued, ErrInstanceMismatch, ErrTotalsDrift, ErrTaxAccount, SubmissionError; test helpers e.Issue, e.Submit, e.ApproveCreditNote, e.IssueCreditNote, e.UseReceivablesPoster, e.EntryFor, e.ControlBalanceByParty (on control_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 with a.control_kind = 'ar' and sum(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 RAISE in sales_invoice_draft_guard: TestAnIssuedDocumentIsFrozen’s absence half is red (the UPDATE of an issued header succeeds). Restore. (b) Move numbering.Allocate to CreateDocument: TestIssuingNumbersDatesAndSnapshotsTheInvoice red (INV-2026-0002). Restore. (c) Remove the cap check: TestCreditNotesCannotExceedTheirInvoice red. 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; types Receipt, ReceiptLine{InvoiceID; Amount}, ReceiptInput, ReceiptFilter; funcs GetReceipt, lockReceipt, ListReceipts, CreateReceipt, UpdateReceipt, DepositAccounts(ctx, tx) ([]chart.Account, error), checkDepositAccount; errors ErrDepositAccount, ErrInvoiceNotReceivable, ErrForeignUnallocated; helper e.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, mirroring internal/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 exist in 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE TRIGGER ar_allocation_guard BEFORE INSERT ON ar_allocation
FOR EACH ROW EXECUTE FUNCTION ar_allocation_guard();
-- +goose Down
DROP 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): reference is 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
}
  • checkDepositAccount and DepositAccounts (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 into 1520, dated expensestest.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 ErrForeignUnallocated branch: TestAForeignReceiptCannotLeaveMoneyOnAccount red. 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 (ListDocuments Open), 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’s fx_difference behaviour; chart.ResolveControl.

  • Produces: Allocation (AP’s fields with ReceiptID for PaymentID); 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; errors ErrOverAllocation, ErrAlreadyReversed, ErrVoidTheReceipt, ErrRateMismatch, ErrNotAllocatable; helpers e.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: TransitionReceipt and 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 (Staff draft → received), VoidReceipt (Owner received → voided, reason), Allocate, Reverse, AllocationsOf, Outstanding, Unallocated, CreditTotal, FXLine. Remove the skip from allocation_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: TestTheAllocationGuardRefusesByName red at ar_allocation_receipt_not_over. (b) Skip selfAllocate: TestACreditNoteAllocatesItselfAtIssue red (100.0000). (c) Remove the foreign branch in ReverseAllocation: TestAForeignReceiptsAllocationIsUndoneOnlyByVoiding red. 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 (+ its Add), 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 to p.base - ap.base (the receipt’s base at its own rate, minus allocations at the invoice’s): TestTheSubledgerEqualsTheControlAccountPerCustomer goes 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 remove party_id from the receipt’s remainder line in ReceiptRule: red again (a nil-party control line — ControlBalanceByParty requires 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); routes GET/POST /receivables/customers, GET/PUT /receivables/customers/{id}, GET /receivables/deposit-accounts; OpenAPI schemas Customer, 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.Deps in Deps and receivables.Register(api, deps.Receivables) after payables; cmd/api/main.go fills it as payables’; cmd/{migrate,orgadmin,worker}/main.go add _ "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); each wiring_test.go gains the package (worker’s table gains {kind: "sales_invoice", sem: accrual}, {"customer_credit_note", accrual}, {"customer_receipt", payout}); internal/app/routes_test.go pins 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, including TestEverySeededGraphPassesTheValidator and TestEverySeededGraphShipsAllThreeLocales now that internal/app links the module. git status clean after make generate.

  • Step 7: Sabotage. Register one route with PermissionCustomerRead where the spec says manage: TestTheOpenAPIDocumentSaysWhatTheRouterEnforces / routes_test red. Restore. Remove the RegisterRolePermissions call: TestANewOrganizationsOwnerAndFinanceHoldTheReceivablesKeys red 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, SalesDocumentKind enum [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 Receivables in AP’s shapes (/payables/invoices… for documents, allocations and transitions; /payables/payments… for receipts). The transition body and response reuse AP’s DocumentTransitionInput / DocumentDecision schemas. 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.go mirrors internal/modules/payables/invoice_handlers.go (DTOs built from receivables.Document, ETag = revision, If-Match required on PUT, list with kind, customer_id, state, currency, open, cursor, limit; the view adds allocations, outstanding/outstanding_base, available_transitions from receivables.Available, the timeline as AP’s). Every transition handler passes db.InTx’s error through ledger.MapError then mapErr. receipt_handlers.go mirrors payment_handlers.go (the view adds unallocated). customer_handlers.go gains customerBalance (AP’s supplierBalance over receivables.AgingAsOf(today, &id)) and customerStatement (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 status clean after generate.

  • Step 6: Sabotage. Drop ledger.MapError from transitionDocument and point the fiscal year’s period at closed in a test: the handler returns 500 instead of 409 — the AP handler test for a closed period, mirrored in document_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); OpenAPI ArAging, export report enum += 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 — first undefined: 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 Down
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'));
  • 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.go gains getARAging (AP’s handler over receivables.AgingAsOf, DTO with customer_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-aging block, schema ArAging with customers), the export report enum and ExportParams fields. routes_test.go pins 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' from 00314’s Up list; reset the DB: TestTheExportCheckAcceptsEveryReport red with report_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 (wherever payables is listed — grep 'payables' under web/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); types Customer; routes /receivables/customers, /new, /$id, /$id/edit.

  • Step 1: Failing test. CustomersPage.test.tsx mirrors web/src/features/payables/__tests__/SuppliersPage.test.tsx: the list renders rows from an MSW GET /v1/receivables/customers; “New customer” is shown only with receivables.customer.manage; the dialog posts the fields (including registration_type + registration_no and sst_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.ts checks 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.ts mirrors web/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.tsx mirror the AP supplier files without the bank section; the dialog adds registration_type (a select of the four registrationTypes, blank allowed), sst_registration_no, and the default income account / tax code pickers (accounts filtered to type === 'income' && control_kind == null && is_postable; tax codes to usage sales/both), and shows customers.einvoiceHint under the TIN field. The list uses DataTable with tableId="receivables.customers" (code and name required; TIN, currency defaultHidden). 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 (includes i18nparity, nofloat, nohex).

  • Step 6: Sabotage. Delete one key from ms/receivables.json: i18nparity.test.ts red 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.tsx mirrors AP’s InvoicesPage.test.tsx (tabs switch kind; a draft row shows “Draft” in the number column; “New invoice” needs receivables.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 shows 100.00 and tax 8.00, totals 108.00; for a credit note the dialog asks for the original invoice (an issued invoice of the customer from useDocuments) and a reason, and hides currency and rate (inherited, spec D6); “Issue invoice” posts the draft then the transition draft → 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. Tabs sales_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 (usage sales/both only, the P48 filter reversed), exemption details (shown when the chosen code’s pack_kind is exempt — display only; the server does not interpret it). Computed per line: lineNet(...) and percentOfMoney(net, taxRate); totals with sumMoney. Buttons: “Save draft” (PUT/POST) and the primary action from available_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 the frozen note; lines; totals; allocations with “Reverse” (for credit-note allocations and base-currency receipt allocations — the server refuses the rest with void_the_receipt, shown as its translated sentence); for an issued credit note with a remainder, an “Apply to an invoice” form (allocate.*, useOpenInvoices for the same customer and currency); “Download PDF” (<a href={documentPdfUrl(id)} download> through the authenticated fetch helper the reports download.ts uses) 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 lineNet to round after adding a half-cent twice (or use percentOfMoney for the product): the lineNet test goes red at 0.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, tests web/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 if ReportsPage reads tab labels from reports)

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 is amount − Σ applied (subtractMoney); in a foreign currency a non-zero remainder shows receipts.foreignInFull and disables “Record receipt”; the deposit-account picker lists useDepositAccounts(). ARAgingView.test.tsx mirrors APAgingView.test.tsx.

  • Step 2: Screens.

  • ReceiptsPage / ReceiptDrawer mirror 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 while unallocated > 0 and the receipt is received in the base currency, and the void action (reason required, receipts.voidReason) for whoever the workflow offers it to.

  • ReceiptDialog as above; primary action “Record receipt” (draft → received).

  • CustomerStatement (in CustomerDrawer): 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, or balance.none.

  • ARAgingView = APAgingView over useARAging, namespace receivables, customers for suppliers. ReportsPage: add 'ar_aging' beside 'ap_aging' everywhere ReportsPage.tsx lists or branches on ap_aging (the report list, the “as of” branch, the view switch, the ExportMenu report).

  • 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.tsx red. 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, runTag from support.ts.

  • Produces: ensureFinanceAR(): Promise<void>; the e2e flow.

  • Step 1: Lane ports. If scripts/dev-stack.sh still hard-codes its ports, make every port and name overridable, defaults unchanged:

Terminal window
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:

Terminal window
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 eight receivables.* keys (next to its payables.*). 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


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.