Skip to content

Withholding Tax (WHT, CP37) Implementation Plan

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

Goal: A clerk paying a non-resident supplier deducts withholding tax on the payment (the invoices are settled at gross, the supplier receives the net, the tax is credited to Withholding tax payable 2160 in the same entry); each deduction carries its section, rate, amounts and a due date one month after payment; a separately approved remittance pays one section’s deductions to LHDN (Dr 2160 / Cr bank) and records LHDN’s receipt number; a withholding register report (JSON, CSV/XLSX/PDF) lists every deduction with its status and lateness — the CP37 listing. CP38 is deferred to Phase 5 payroll.

Architecture: No new package. Withholding codes are tax_code rows of a new usage withholding (date-effective, immutable rate, seeded by the MY pack, pack_kind = section). Everything else lives in internal/platform/payables beside the payment it extends: withholding.go (deductions, due date), remittance.go (the wht_remittance document), register.go (the report query), two posting rules (the extended PaymentPayoutRule, a new RemittancePayoutRule) registered with accounting/. internal/modules/payables gains the HTTP shell; internal/modules/reports serves the register; the web extends web/src/features/payables and web/src/features/reports. accounting/ still reacts only to semantics; this plan never writes a journal line.

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

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

Every task’s requirements implicitly include this section.

  • Migrations 00340–00349 only. This plan uses 00340–00342. Task 1 registers {340, 349, "Phase 2 WHT"} in internal/kernel/db/migrationranges_test.go in the same commit as 00340.
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire (OpenAPI Money); rates are money.Rate / NUMERIC(19,10) / OpenAPI Rate. Compare with Cmp. No float64 in any file this plan touches (web/src/drift/nofloat.test.ts scans the web). sqlc.yaml gains a money.Rate override for wht_deduction.rate.
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, CONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite (org_id, x) foreign keys only. Grants follow 00304: REVOKE DELETE … FROM erp_app (amendable).
  • Named constraints. Every CHECK/UNIQUE is CONSTRAINT <table>_<rule>; none ends in _check, _key, _excl. A trigger’s refusal is RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name.
  • Pinned functions. Every function is SET search_path = pg_catalog, public, pg_temp and schema-qualifies every table. No SECURITY DEFINER.
  • Every SQL statement names org_id = $n for every org-scoped table it touches (join equalities included); every INSERT names org_id. Platform functions read the org from tenancy.OrgFromContext (orgOf).
  • Retention. wht_deduction, wht_remittance, wht_remittance_line go into amendable in internal/kernel/db/retention_test.go.
  • Accounting reacts to semantics, never state names. Posting rules keyed (kind, semantic); no Go/SQL compares a state_key to a literal except seeded graphs and tests. Comparing a semantic ('draft', 'cancelled') is allowed.
  • Modules never write journal lines. payables.Rules() (registered from internal/modules/payables/boot.go) gains RemittancePayoutRule{}; rules return an accounting.Draft.
  • Nothing in internal/platform/accounting or internal/modules/accounting names SST, LHDN or MyInvois (TestTheLedgerNamesNoMalaysianTax). The words “LHDN”/“CP37” appear only in the MY pack, translations and docs.
  • Lock order: document header FOR UPDATE (payment or remittance) → supplier FOR SHARE (payment payout only) → invoice rows FOR UPDATE in id order (payment) → wht_deduction rows FOR UPDATE in id order → workflow.Transition → this plan’s audit.Write, last.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …). Handlers whose transaction can post pass db.InTx’s error through ledger.MapError before mapErr.
  • Business date is clock.TodayUTC(c); every DATE in Go is midnight UTC; never time.Now() under test.
  • Record-bearing text (bank_reference, receipt_no) is English or Malay: recordtext.Check in Go and the named script CHECK in SQL (written in full).
  • Errors are RFC 7807 via mapErr; field refusals are 422 field=<name> rule=<code>: <sentence>.
  • Every route goes through httpx.Protected with its permission and is in api/openapi.yaml in the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces). api/public_routes.txt gains nothing. make generate after any migration, OpenAPI or constants.yaml change, then git status must be clean apart from the intended files.
  • i18n. Every UI string is a key in web/src/lib/i18n/locales/{en,ms,zh}/payables.json or reports.json; i18nparity.test.ts requires identical keys. Malay is genuine Malay (strings below are the ones to use). Seeded workflow and pack labels carry en, ms, zh. Vocabulary values (open, in_remittance, remitted, sections) are strings.
  • Presence before absence (AGENTS.md). Every refusal test first shows the accepted case. Every guard is watched failing once: each task with a guard has a Sabotage step — break it, run, read the red message, restore — and the task’s commit message records what was broken and what the message said.
  • Posting is process-global. A test needing a Poster calls e.UsePayablesPoster(t); never t.Parallel().
  • Tests: testdb.Start (through payablestest.New); run only touched packages while iterating (go test ./internal/platform/payables/... -count=1); one full go test -p 2 -count=1 ./... in Task 9 only.
  • E2E stack (lane 3): ERP_STACK=erp-wht ERP_PG_PORT=56442 ERP_MINIO_PORT=59130 ERP_API_PORT=3130 ERP_WEB_PORT=5194. Never the user’s dev stack (3000/5173/55432/59000).
  • Never bare git stash. Use a WIP commit.
  • Git commits end with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Where the spec is silent or the code forces a choice. Format: Ruling: <decision> — <why>.

  • W1 Ruling: pack_kind of a withholding code is the section key (s107a, s109, s109b, s109f); wht_deduction.section and wht_remittance.section snapshot it. Payables compares it only for equality — the ledger stays opaque (spec D1).
  • W2 Ruling: money.Amount.PercentAt(p money.Rate, places int32) (new, kernel/money) is a × p / 100 rounded once, half away from zero, to places. Withheld amount = taxable.PercentAt(rate, 2); base = taxable.MulRate(fx).PercentAt(rate, 2). Percent(...) then rounding again would double-round (12.34495 → 12.3450 → 12.35).
  • W3 Ruling: A payment’s withholding is written with its lines, as wht_deduction rows withdrawn and re-inserted on every draft save (the supplier_payment_line pattern, AP P16). A deduction is effective iff withdrawn_at IS NULL and the payment’s workflow_instance.payout_open. Nothing is copied at payout.
  • W4 Ruling: checkPayment returns the computed deductions: checkPayment(ctx, tx, &in, base) ([]Deduction, error); both call sites (CreatePayment, UpdatePayment) write them after writePaymentLines. Total rule: Σ lines = amount + Σ withheld; ErrAllocationTotal reads “the allocations must add up to the payment amount plus any tax withheld”.
  • W5 Ruling: The payment’s workflow amount (workflow.Start/SetAmount) stays the net cash base_amount; unchanged for payments without WHT (spec owner item 12).
  • W6 Ruling: The bank-account check is extracted from checkPayment into checkBankAccount(ctx, tx, id, currency) error so the remittance reuses it (the only refactor this plan does to AP code).
  • W7 Ruling: wht_remittance is its own seeded graph (RemittanceGraph, cash_only), the PaymentGraph shape and state keys (draft, pending_approval, paid, rejected, cancelled, voided), owner seat granted on paid and voided; edge approve_remittance.
  • W8 Ruling: Remittance currency is the organization’s base currency; its bank account’s currency is null or base. amount is computed server-side from its deductions (no amount in the input) and re-checked under lock at payout (ErrRemittanceTotal).
  • W9 Ruling: A deduction’s line is live iff withdrawn_at IS NULL and the remittance’s semantic is not cancelled. Draft edits withdraw lines; withdrawing or voiding a remittance leaves its lines as they are (they stop being live through the semantic). The DB trigger wht_remittance_line_guard is the authority; Go pre-checks for friendly errors.
  • W10 Ruling: receipt_no is set by RecordRemittanceReceipt exactly once, only at a payout-semantic state; trigger wht_remittance_receipt_guard is the backstop; audited.
  • W11 Ruling: Deduction status (wire vocabulary): planned (payment not yet paid), open (effective, no live line), in_remittance (live line on an unpaid remittance), remitted (live line on a remittance with payout_open). Deductions of a withdrawn draft or a voided payment are not listed.
  • W12 Ruling: credited_on = the earliest entry_date of the unreversed accrual entries of the invoices the payment allocated to; credited_earlier = paid_on > withholdingDueOn(credited_on); late = (remitted and remittance payment_date > due_on) or (not remitted and today > due_on).
  • W13 Ruling: The provisioning step is payables_withholding, order 77. Check at merge that no other Phase 2 item took 77; renumber if so (provisioning.Register panics on a duplicate order? — read internal/platform/provisioning/provisioning.go in Task 2 and record).
  • W14 Ruling: 00341 re-adds report_export_report_known with the union of every report value on the branch at build time plus wht_register — copy the list from the highest-numbered migration touching that constraint (00306 today; AR may add ar_aging in 00310–00329).
  • W15 Ruling: The supplier’s default_wht_code_id is validated like default_tax_code_id but for usage withholding (422 field=default_wht_code_id rule=wht_code); it only pre-fills the web form.
  • W16 Ruling: Deductions list endpoint caps at 500 rows ordered due_on, id — the remittance dialog shows one section’s open deductions; not paginated.

File Task Responsibility
00340_withholding_tax.sql 1 tax_code usage withholding + account CHECK; party_supplier.default_wht_code_id; wht_deduction, wht_remittance, wht_remittance_line; four guard triggers.
00341_report_export_wht_register.sql 5 report_export_report_known gains wht_register.
00342_wht_grants.sql 6 Existing Owner and Finance roles get payables.wht.{read,create,transition}.
Path Task Responsibility
internal/platform/payables/withholding.go 3 WithholdingInput, Deduction, withholdingDueOn, checkWithholding, writeDeductions, deductionsOf, refuseIfRemitted.
internal/platform/payables/withholding_step.go 2 Provisioning step payables_withholding (order 77).
internal/platform/payables/remittance.go 4 Remittance document, graph, service, transition, receipt, ListDeductions.
internal/platform/payables/register.go 5 WithholdingRegister.
Tests: withholding_schema_test.go (1), withholding_step_test.go (2), withholding_test.go (3), remittance_test.go, withholding_drift_test.go (4), register_test.go (5) in internal/platform/payables/
internal/modules/payables/wht_handlers.go (+ _test.go) 6 Deductions and remittance routes.
internal/modules/reports/wht_handlers.go 5 GET /reports/wht-register.
web/src/features/payables/RemittancesPage.tsx, RemittanceDialog.tsx, RemittanceDrawer.tsx, WithholdingFields.tsx, __tests__/Remittances.test.tsx 7, 8 Web.
web/src/features/reports/WHTRegisterView.tsx (+ test) 8 Register tab.
web/e2e/withholding.spec.ts 9 The e2e.
Path Task Change
internal/kernel/db/migrationranges_test.go, retention_test.go 1 Range; three tables amendable.
internal/kernel/money/rate.go (+ test) 2 PercentAt.
internal/platform/accounting/chart/taxcode.go (+ test) 2 TaxUsageWithholding; withholding requires AccountID.
internal/platform/countrypack/validate.go, countrypack/my/my.go, my/my_test.go 2 Usage accepted; account 2160; six codes; counts.
internal/platform/expenses/line.go, internal/platform/pettycash/spend.go (+ tests) 2 Refuse a withholding code.
web/src/features/payables/InvoiceDialog.tsx 2 Line picker: purchase or both only.
internal/platform/payables/{doc.go,payment.go,allocation.go,posting.go,transition.go,supplier.go,graphs.go}, payablestest/env.go 3, 4 WHT on payments; remittance kind; helpers.
sqlc.yaml 1 wht_deduction.rate → money.Rate.
internal/platform/reporting/export/{export.go,params.go,project.go} 5 wht_register.
internal/modules/reports/routes.go, errors.go 5 Route; report list sentence.
constants.yaml (+ generated vocab) 6 Three permission keys.
internal/modules/payables/{permissions.go,routes.go,errors.go,payment_handlers.go,supplier_handlers.go,boot_test.go}, cmd/worker/wiring_test.go, internal/app/routes_test.go 6 Keys, routes, DTOs, rule list.
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) 2, 5, 6 TaxCode.usage; register; payables WHT.
web/src/features/payables/{api.ts,PaymentDialog.tsx,PaymentDrawer.tsx,SupplierDialog.tsx,routes.tsx}, web/src/app/shell/nav.ts, web/src/features/reports/{ReportsPage.tsx,routes.tsx,api.ts}, locales 7, 8 Web.
web/e2e/seed.ts, web/e2e/support.ts 9 Finance WHT keys; ensureFinanceWHT.
docs/ROADMAP.md, docs/DEPLOY_NOTES.md 9 Tick; CP38 → Phase 5; re-provision note.

Tasks touching disjoint files may run in parallel; everything else is ordered.

Wave Tasks Why
1 1 Schema everything else reads.
2 2 Withholding codes must be valid before a payment can use one.
3 3 Payment WHT.
4 4 Remittance reads deductions and changes the payment void.
5 5 ‖ 6 Register (reports, export, 00341) and module (payables routes, 00342) touch disjoint Go files; both edit api/openapi.yaml in different sections — the second to land rebases and re-runs make generate.
6 7 Web payment + supplier (needs 6’s generated types).
7 8 Web remittances + register tab (needs 5 and 6).
8 9 e2e on lane 3, roadmap, deploy notes, full suite.

Task 1: Migration 00340 — schema, guards, range, retention

Section titled “Task 1: Migration 00340 — schema, guards, range, retention”

Files:

  • Create: db/migrations/00340_withholding_tax.sql
  • Create: internal/platform/payables/withholding_schema_test.go
  • Modify: internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go, sqlc.yaml, internal/platform/payables/doc.go

Interfaces:

  • Consumes: tax_code, party_supplier, supplier_payment, account, workflow_instance, organization_member, public.payables_semantic(org, instance) (00302).

  • Produces: tables wht_deduction, wht_remittance, wht_remittance_line; column party_supplier.default_wht_code_id; constraint names tax_code_withholding_names_account, wht_deduction_is_draft, wht_remittance_is_draft, wht_remittance_line_is_draft, wht_remittance_line_effective, wht_remittance_line_section, wht_remittance_line_once, wht_remittance_receipt_once, wht_remittance_receipt_after_payout. Go constants in doc.go: KindRemittance = "wht_remittance", EntityRemittance = "wht_remittance", EntityDeduction = "wht_deduction"; SourceKinds += KindRemittance.

  • Step 1: Register the range and write the failing schema test.

migrationranges_test.go, after {300, 339, "Phase 2 AP"}:

{340, 349, "Phase 2 WHT"},

withholding_schema_test.go (package payables_test; reuse requirePgError from schema_test.go). Set up with payablestest.New(t) + NopPoster; create a withholding tax code as admin by raw SQL (the Go validator for usage withholding lands in Task 2):

func rawWHTCode(t *testing.T, e *payablestest.Env, code, section, rate string) uuid.UUID {
t.Helper()
acct := e.Account(t, "2150") // any liability; 2160 is seeded in Task 2
var id uuid.UUID
require.NoError(t, e.Admin.QueryRow(context.Background(), `
INSERT INTO tax_code (org_id, code, name, pack_kind, rate, account_id, valid_from, usage)
VALUES ($1, $2, $2, $3, $4, $5, '2020-01-01', 'withholding') RETURNING id`,
e.Org, code, section, rate, acct).Scan(&id))
return id
}

Tests (each: accepted case first, then the refusal, with requirePgError(t, err, "23514", <name>)):

  1. TestAWithholdingCodeMustNameAnAccount — insert usage withholding with an account: OK; without: tax_code_withholding_names_account.
  2. TestDeductionsAreWrittenOnlyWhileThePaymentIsADraft — raw insert of a wht_deduction for a draft payment (e.Payment): OK; after e.SubmittedPayment: wht_deduction_is_draft.
  3. TestARemittanceLineNeedsAnEffectiveDeductionOfItsSection — build a draft wht_remittance by raw SQL (instance from workflow.Start with kind supplier_payment is fine for the schema test: the guard reads only the semantic) with section s109b; a deduction whose payment is paid (raw-insert while draft, then e.Pay with NopPoster — payout_open becomes true): OK; a deduction on a draft payment: wht_remittance_line_effective; a paid deduction of section s109: wht_remittance_line_section; the first deduction again from a second draft remittance: wht_remittance_line_once; the second remittance after the first is moved to its cancelled semantic: OK (a cancelled remittance frees its deductions).
  4. TestReceiptIsRecordedOnceAndOnlyAfterPayout — UPDATE wht_remittance SET receipt_no on a draft: wht_remittance_receipt_after_payout; move to payout semantic, set: OK; set again: wht_remittance_receipt_once.

For test 3/4 the simplest honest fixture is to insert the remittance with a workflow.Start(... Kind: payables.KindPayment ...) instance before Task 4 registers wht_remittance; Task 4 switches the fixture to KindRemittance (note it in the file).

Run: go test ./internal/platform/payables/ -run 'Withholding|Deduction|Remittance|Receipt' -count=1 → FAIL (no tables).

  • Step 2: Write the migration.
-- +goose Up
-- WHT (spec 2026-09-27 D1): a withholding code is a tax code of a fourth
-- usage, and always names the liability its tax is credited to.
ALTER TABLE tax_code DROP CONSTRAINT tax_code_usage_known,
ADD CONSTRAINT tax_code_usage_known CHECK (usage IN ('sales','purchase','both','withholding')),
ADD CONSTRAINT tax_code_withholding_names_account CHECK (usage <> 'withholding' OR account_id IS NOT NULL);
-- D4: the code the payment form proposes for this supplier.
ALTER TABLE party_supplier ADD COLUMN default_wht_code_id UUID,
ADD CONSTRAINT party_supplier_wht_code_fk FOREIGN KEY (org_id, default_wht_code_id) REFERENCES tax_code (org_id, id);
-- D5: tax withheld from one supplier payment. section and rate are
-- snapshots of the code. Rewritten (withdrawn + inserted) on every draft
-- save; effective while the payment's payout is open (Ruling W3).
CREATE TABLE wht_deduction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
payment_id UUID NOT NULL,
tax_code_id UUID NOT NULL,
section TEXT NOT NULL CONSTRAINT wht_deduction_section_present CHECK (btrim(section) <> ''),
rate NUMERIC(19,10) NOT NULL CONSTRAINT wht_deduction_rate_range CHECK (rate > 0 AND rate <= 100),
taxable_amount NUMERIC(19,4) NOT NULL CONSTRAINT wht_deduction_taxable_positive CHECK (taxable_amount > 0),
amount NUMERIC(19,4) NOT NULL CONSTRAINT wht_deduction_amount_positive CHECK (amount > 0),
base_amount NUMERIC(19,4) NOT NULL CONSTRAINT wht_deduction_base_positive CHECK (base_amount > 0),
currency TEXT NOT NULL CONSTRAINT wht_deduction_currency_code CHECK (currency ~ '^[A-Z]{3}$'),
due_on DATE NOT NULL,
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT wht_deduction_tenant_id UNIQUE (org_id, id),
CONSTRAINT wht_deduction_amount_within_taxable CHECK (amount <= taxable_amount),
CONSTRAINT wht_deduction_payment_fk FOREIGN KEY (org_id, payment_id) REFERENCES supplier_payment (org_id, id),
CONSTRAINT wht_deduction_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id)
);
CREATE UNIQUE INDEX wht_deduction_code_once ON wht_deduction (org_id, payment_id, tax_code_id) WHERE withdrawn_at IS NULL;
CREATE INDEX wht_deduction_by_section_due ON wht_deduction (org_id, section, due_on) WHERE withdrawn_at IS NULL;
-- D8: paying one section's deductions to the tax authority.
CREATE TABLE wht_remittance (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
reference TEXT NOT NULL,
section TEXT NOT NULL CONSTRAINT wht_remittance_section_present CHECK (btrim(section) <> ''),
bank_account_id UUID NOT NULL,
payment_date DATE NOT NULL,
bank_reference TEXT NOT NULL DEFAULT '' CONSTRAINT wht_remittance_bank_reference_length CHECK (length(bank_reference) <= 64),
amount NUMERIC(19,4) NOT NULL CONSTRAINT wht_remittance_amount_positive CHECK (amount > 0),
receipt_no TEXT CONSTRAINT wht_remittance_receipt_shape CHECK (receipt_no IS NULL OR (btrim(receipt_no) <> '' AND length(receipt_no) <= 64)),
instance_id UUID NOT NULL,
prepared_by UUID NOT NULL,
revision INT NOT NULL DEFAULT 1 CONSTRAINT wht_remittance_revision_positive CHECK (revision >= 1),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT wht_remittance_tenant_id UNIQUE (org_id, id),
CONSTRAINT wht_remittance_reference_once UNIQUE (org_id, reference),
CONSTRAINT wht_remittance_instance_once UNIQUE (org_id, instance_id),
CONSTRAINT wht_remittance_text_language CHECK (bank_reference !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'
AND coalesce(receipt_no, '') !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT wht_remittance_bank_account_fk FOREIGN KEY (org_id, bank_account_id) REFERENCES account (org_id, id),
CONSTRAINT wht_remittance_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id),
CONSTRAINT wht_remittance_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id)
);
CREATE INDEX wht_remittance_by_created ON wht_remittance (org_id, created_at DESC, id DESC);
CREATE TABLE wht_remittance_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
remittance_id UUID NOT NULL,
deduction_id UUID NOT NULL,
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT wht_remittance_line_tenant_id UNIQUE (org_id, id),
CONSTRAINT wht_remittance_line_remittance_fk FOREIGN KEY (org_id, remittance_id) REFERENCES wht_remittance (org_id, id),
CONSTRAINT wht_remittance_line_deduction_fk FOREIGN KEY (org_id, deduction_id) REFERENCES wht_deduction (org_id, id)
);
CREATE UNIQUE INDEX wht_remittance_line_deduction_per_remittance ON wht_remittance_line (org_id, remittance_id, deduction_id) WHERE withdrawn_at IS NULL;
CREATE INDEX wht_remittance_line_by_deduction ON wht_remittance_line (org_id, deduction_id) WHERE withdrawn_at IS NULL;
ALTER TABLE wht_deduction ENABLE ROW LEVEL SECURITY;
ALTER TABLE wht_deduction FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON wht_deduction
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE wht_remittance ENABLE ROW LEVEL SECURITY;
ALTER TABLE wht_remittance FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON wht_remittance
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE wht_remittance_line ENABLE ROW LEVEL SECURITY;
ALTER TABLE wht_remittance_line FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON wht_remittance_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 wht_deduction, wht_remittance, wht_remittance_line FROM erp_app;
-- +goose StatementBegin
CREATE FUNCTION wht_deduction_draft_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF (SELECT public.payables_semantic(p.org_id, p.instance_id) FROM public.supplier_payment p
WHERE p.org_id = NEW.org_id AND p.id = NEW.payment_id) IS DISTINCT FROM 'draft' THEN
RAISE EXCEPTION 'supplier payment % is not a draft: its withholding cannot change', NEW.payment_id
USING ERRCODE = '23514', CONSTRAINT = 'wht_deduction_is_draft';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER wht_deduction_draft_guard BEFORE INSERT OR UPDATE ON wht_deduction
FOR EACH ROW EXECUTE FUNCTION wht_deduction_draft_guard();
-- +goose StatementBegin
CREATE FUNCTION wht_remittance_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 'remittance % is not a draft', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_is_draft';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER wht_remittance_draft_guard
BEFORE UPDATE OF section, bank_account_id, payment_date, bank_reference, amount
ON wht_remittance FOR EACH ROW EXECUTE FUNCTION wht_remittance_draft_guard();
-- Ruling W10: the receipt number is recorded once, after payment.
-- +goose StatementBegin
CREATE FUNCTION wht_remittance_receipt_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF OLD.receipt_no IS NOT NULL THEN
RAISE EXCEPTION 'remittance % already has a receipt number', OLD.reference
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_receipt_once';
END IF;
IF public.payables_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'payout' THEN
RAISE EXCEPTION 'remittance % is not paid', OLD.reference
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_receipt_after_payout';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER wht_remittance_receipt_guard BEFORE UPDATE OF receipt_no ON wht_remittance
FOR EACH ROW WHEN (OLD.receipt_no IS DISTINCT FROM NEW.receipt_no)
EXECUTE FUNCTION wht_remittance_receipt_guard();
-- D8 / Ruling W9: the backstop for every remittance-line rule. It locks the
-- deduction, so two remittances claiming one deduction serialize, and a
-- payment void (which locks its deductions first) cannot race past it.
-- +goose StatementBegin
CREATE FUNCTION wht_remittance_line_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
v_rem public.wht_remittance%ROWTYPE;
v_ded public.wht_deduction%ROWTYPE;
BEGIN
SELECT * INTO v_rem FROM public.wht_remittance WHERE org_id = NEW.org_id AND id = NEW.remittance_id;
IF public.payables_semantic(v_rem.org_id, v_rem.instance_id) IS DISTINCT FROM 'draft' THEN
RAISE EXCEPTION 'remittance % is not a draft: its deductions cannot change', v_rem.reference
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_line_is_draft';
END IF;
IF TG_OP = 'UPDATE' THEN
RETURN NEW; -- a draft edit withdrawing a line
END IF;
SELECT * INTO v_ded FROM public.wht_deduction WHERE org_id = NEW.org_id AND id = NEW.deduction_id FOR UPDATE;
IF v_ded.withdrawn_at IS NOT NULL OR NOT coalesce((
SELECT i.payout_open FROM public.supplier_payment p
JOIN public.workflow_instance i ON i.org_id = p.org_id AND i.id = p.instance_id
WHERE p.org_id = NEW.org_id AND p.id = v_ded.payment_id), false) THEN
RAISE EXCEPTION 'deduction % is not on a paid supplier payment', NEW.deduction_id
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_line_effective';
END IF;
IF v_ded.section <> v_rem.section THEN
RAISE EXCEPTION 'deduction % is section %, the remittance is %', NEW.deduction_id, v_ded.section, v_rem.section
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_line_section';
END IF;
IF EXISTS (SELECT 1 FROM public.wht_remittance_line l
JOIN public.wht_remittance r ON r.org_id = l.org_id AND r.id = l.remittance_id
WHERE l.org_id = NEW.org_id AND l.deduction_id = NEW.deduction_id AND l.withdrawn_at IS NULL
AND public.payables_semantic(r.org_id, r.instance_id) <> 'cancelled') THEN
RAISE EXCEPTION 'deduction % is already in a remittance', NEW.deduction_id
USING ERRCODE = '23514', CONSTRAINT = 'wht_remittance_line_once';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER wht_remittance_line_guard BEFORE INSERT OR UPDATE ON wht_remittance_line
FOR EACH ROW EXECUTE FUNCTION wht_remittance_line_guard();
-- +goose Down
DROP TABLE wht_remittance_line;
DROP TABLE wht_remittance;
DROP TABLE wht_deduction;
DROP FUNCTION wht_remittance_line_guard();
DROP FUNCTION wht_remittance_receipt_guard();
DROP FUNCTION wht_remittance_draft_guard();
DROP FUNCTION wht_deduction_draft_guard();
ALTER TABLE party_supplier DROP CONSTRAINT party_supplier_wht_code_fk, DROP COLUMN default_wht_code_id;
ALTER TABLE tax_code DROP CONSTRAINT tax_code_withholding_names_account, DROP CONSTRAINT tax_code_usage_known,
ADD CONSTRAINT tax_code_usage_known CHECK (usage IN ('sales','purchase','both'));

retention_test.go: add the three tables to amendable. sqlc.yaml: after supplier_payment.fx_rate:

# WHT (Ruling W2): a deduction's rate is a decimal, not money.
- column: "wht_deduction.rate"
go_type: github.com/mcom/erp/internal/kernel/money.Rate

doc.go: the constants in Produces.

  • Step 3: Run. make generate (sqlc) then go test ./internal/platform/payables/ -run 'Withholding|Deduction|Remittance|Receipt' -count=1 → PASS; go test ./internal/kernel/db/... -count=1 → PASS (RLS coverage, named constraints, pinned functions, composite FKs, retention).
  • Step 4: Sabotage. Comment out the EXISTS block of wht_remittance_line_guard in a scratch copy applied by hand (or temporarily in the file), run test 3 → it must fail at the wht_remittance_line_once assertion; restore. Remove wht_remittance_line from amendable → retention_test names it; restore.
  • Step 5: Commit. git add -A && git commit -m "WHT Task 1: schema 00340 — deductions, remittances, guards" -m "Sabotage: <what, and the red message>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: Withholding codes — usage, PercentAt, MY pack, provisioning, refusals elsewhere

Section titled “Task 2: Withholding codes — usage, PercentAt, MY pack, provisioning, refusals elsewhere”

Files:

  • Modify: internal/kernel/money/rate.go (+ rate_test.go)
  • Modify: internal/platform/accounting/chart/taxcode.go (+ taxcode_test.go)
  • Modify: internal/platform/countrypack/validate.go (+ validate_test.go), internal/platform/countrypack/my/my.go, my/my_test.go
  • Create: internal/platform/payables/withholding_step.go, withholding_step_test.go
  • Modify: internal/platform/expenses/line.go (+ a test in its line_test.go), internal/platform/pettycash/spend.go (+ test)
  • Modify: api/openapi.yaml (TaxCode.usage, the create-tax-code body’s usage enum), web/src/features/payables/InvoiceDialog.tsx
  • Modify: internal/modules/accounting/chart_handlers.go only if it enumerates usages (read first; today it passes usage through)

Interfaces:

  • Produces: money.(Amount).PercentAt(p Rate, places int32) Amount; chart.TaxUsageWithholding = "withholding"; MY account 2160; MY codes W107A-10, W107A-3, W109-INT, W109-ROY, W109B, W109F; provisioning step payables.StepKeyWithholding = "payables_withholding", StepOrderWithholding = 77; whtAccountCode = "2160".

  • Step 1: Failing tests.

    • rate_test.go: MustParse("123.4495").PercentAt(MustParseRate("10"), 2) = 12.3400 (not 12.35 — W2’s double-rounding case: 123.4495 × 10% = 12.34495); MustParse("1000").PercentAt(MustParseRate("13"), 2) = 130.0000; MustParse("0.05").PercentAt(MustParseRate("10"), 2) = 0.0100 (half away from zero).
    • taxcode_test.go: CreateTaxCode with Usage: "withholding" and an account → OK (presence); without an account → ErrInvalidTaxCode.
    • validate_test.go: a test pack with a withholding code naming a seeded account validates; without AccountCode → ErrInvalidPack.
    • my_test.go: require.Len(t, p.TaxCodes(), 17); the six withholding codes have Usage == "withholding", AccountCode == "2160", and pack_kind in {s107a, s109, s109b, s109f}; the chart contains 2160 under 2100 with no control kind.
    • Expenses: a claim receipt line with a sales code → accepted (presence); with a withholding code → lineErr("tax_code_id", "tax_code_usage", …). Petty cash: same through checkTaxCodeAt.
    • withholding_step_test.go: a fresh org (pack-provisioned) → Check done on arrival. Then, as admin, simulate an org provisioned before this task: retire nothing, but delete-by-renaming is impossible (no DELETE) — so create a second org whose chart is seeded from a copy of the MY pack without 2160 and the six codes (the countrypack test-registry pattern, or seed the org, then assert on a pack filter): the simplest honest route is the AP precedent — internal/platform/payables/step_test.go shows how purchaseTaxCodesStep was tested; copy that approach. Assert: Check false; Run creates 2160 (with en/ms/zh translation rows) and six codes pointing at it; Check true; a second Run creates nothing (count codes before/after). Run → FAIL.
  • Step 2: PercentAt. In rate.go beside Percent:

// PercentAt is a × p / 100 rounded ONCE, half away from zero, to places.
// Withholding tax uses it at 2 (Ruling W2): Percent then rounding again
// would round twice (12.34495 → 12.3450 → 12.35).
func (a Amount) PercentAt(p Rate, places int32) Amount {
return Amount{d: a.d.Mul(p.d).DivRound(decimal.New(100, 0), places)}
}
  • Step 3: Chart and pack validation. taxcode.go: add TaxUsageWithholding = "withholding" to the const block with the comment “a withholding code: tax deducted from a payment (spec 2026-09-27 D1); always names the liability account”; validateTaxCode accepts it and returns fmt.Errorf("%w: a withholding code must name an account", ErrInvalidTaxCode) when AccountID == nil; its usage error sentence lists four values. Do not write “LHDN” in this package. countrypack/validate.go: the switch tc.Usage accepts chart.TaxUsageWithholding, and a withholding seed without AccountCode is ErrInvalidPack (“tax code %q is a withholding code and must name an account”).

  • Step 4: MY pack. my.go — add after 2150:

{Code: "2160", ParentCode: "2100", Type: "liability",
Names: names("Withholding tax payable", "Cukai pegangan belum bayar", "应付预扣税")},

and append to taxCodes:

// WHT (spec 2026-09-27 D1, D2). pack_kind is the ITA section; the form
// each section is remitted with (CP37, CP37A, CP37D, CP37F) lives in the
// web translations only.
//
// FLAG FOR REVIEW: rates and sections are from general knowledge of the
// Income Tax Act 1967 and LHDN practice, NOT verified against the Act or
// LHDN's current guidance. ValidFrom is a floor for this product, not the
// date each rate began. A tax agent must confirm before a paying customer
// relies on them; a treaty-reduced rate is a new org code, never an edit.
{Code: "W107A-10", PackKind: "s107a", Usage: "withholding", Rate: "10", AccountCode: "2160", ValidFrom: "2020-01-01",
Names: names("WHT s107A contract payment 10%", "Cukai pegangan s107A bayaran kontrak 10%", "预扣税 s107A 合约付款 10%")},
{Code: "W107A-3", PackKind: "s107a", Usage: "withholding", Rate: "3", AccountCode: "2160", ValidFrom: "2020-01-01",
Names: names("WHT s107A contractor's employees 3%", "Cukai pegangan s107A pekerja kontraktor 3%", "预扣税 s107A 承包商雇员 3%")},
{Code: "W109-INT", PackKind: "s109", Usage: "withholding", Rate: "15", AccountCode: "2160", ValidFrom: "2020-01-01",
Names: names("WHT s109 interest 15%", "Cukai pegangan s109 faedah 15%", "预扣税 s109 利息 15%")},
{Code: "W109-ROY", PackKind: "s109", Usage: "withholding", Rate: "10", AccountCode: "2160", ValidFrom: "2020-01-01",
Names: names("WHT s109 royalty 10%", "Cukai pegangan s109 royalti 10%", "预扣税 s109 特许权使用费 10%")},
{Code: "W109B", PackKind: "s109b", Usage: "withholding", Rate: "10", AccountCode: "2160", ValidFrom: "2020-01-01",
Names: names("WHT s109B services and technical fees 10%", "Cukai pegangan s109B perkhidmatan dan fi teknikal 10%", "预扣税 s109B 服务及技术费 10%")},
{Code: "W109F", PackKind: "s109f", Usage: "withholding", Rate: "10", AccountCode: "2160", ValidFrom: "2020-01-01",
Names: names("WHT s109F other income 10%", "Cukai pegangan s109F pendapatan lain 10%", "预扣税 s109F 其他收入 10%")},

Update the package comment’s count (“seventeen tax codes: six SST on sales, five on purchases, six withholding”) and my_test.go’s comment and counts (accounts 40 → 41 if the test counts accounts).

  • Step 5: Provisioning step withholding_step.go — the tradePayablesStep + purchaseTaxCodesStep pair folded into one step:
// The withholding step (spec D2): a new organization's pack already seeds
// 2160 and the withholding codes, so this is done on arrival; an
// organization provisioned before WHT gets them on its next
// POST /v1/org/provision. Order 77 (Ruling W13): after purchase codes (76).
const (
StepKeyWithholding = "payables_withholding"
StepOrderWithholding = 77
whtAccountCode = "2160"
whtAccountParent = "2100"
)
func init() { provisioning.Register(withholdingStep{}) }
type withholdingStep struct{}
func (withholdingStep) Key() string { return StepKeyWithholding }
func (withholdingStep) Order() int { return StepOrderWithholding }
// Check is done outside Malaysia, or when 2160 exists (whatever it is: never
// seed over an administrator's chart) and every withholding code of the
// pack exists by code.
func (withholdingStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) {
profile, err := company.Get(ctx, tx)
if err != nil {
return false, nil, fmt.Errorf("payables: withholding check: %w", err)
}
if profile.CountryCode != countryMY {
return true, nil, nil
}
if _, err := chart.GetAccountByCode(ctx, tx, whtAccountCode); errors.Is(err, chart.ErrNotFound) {
return false, nil, nil
} else if err != nil {
return false, nil, err
}
missing, err := missingCodesOfUsage(ctx, tx, chart.TaxUsageWithholding)
return len(missing) == 0, nil, err
}
func (withholdingStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error {
if _, err := chart.GetAccountByCode(ctx, tx, whtAccountCode); errors.Is(err, chart.ErrNotFound) {
if err := createNamedLiability(ctx, tx, c, whtAccountCode, whtAccountParent, map[string]string{
"en": "Withholding tax payable", "ms": "Cukai pegangan belum bayar", "zh": "应付预扣税"}); err != nil {
return fmt.Errorf("payables: withholding run: %w", err)
}
} else if err != nil {
return err
}
missing, err := missingCodesOfUsage(ctx, tx, chart.TaxUsageWithholding)
if err != nil || len(missing) == 0 {
return err
}
profile, err := company.Get(ctx, tx)
if err != nil {
return err
}
pack, _ := countrypack.Lookup(profile.CountryCode)
return countrypack.SeedTaxCodesWhere(ctx, tx, c, pack, profile.DefaultLocale,
func(s countrypack.TaxCodeSeed) bool { return missing[s.Code] })
}

Refactor in step.go (same package, small): missingPurchaseCodes becomes missingCodesOfUsage(ctx, tx, usage string) (its body with s.Usage == usage), and tradePayablesStep.Run’s account-plus-translations-plus-audit body becomes createNamedLiability(ctx, tx, c, code, parentCode string, names map[string]string) error with an optional control kind argument if trade payables needs it (controlKind *string). Both existing steps call the helpers; their tests must stay green unchanged. Read internal/platform/provisioning/provisioning.go for duplicate-order behaviour and record it in Ruling W13’s place in the commit message.

  • Step 6: Refusals elsewhere. expenses/line.go, after the in-force check:
if tc.Usage == chart.TaxUsageWithholding { // WHT spec D1: not a tax on a receipt
return Line{}, lineErr("tax_code_id", "tax_code_usage", "a withholding tax code cannot be used on a claim")
}

pettycash/spend.go checkTaxCodeAt: the same, as &FieldError{Field: "tax_code_id", Rule: "tax_code_usage", Detail: "a withholding tax code cannot be used on a spend"}. The purchase invoice line already refuses it (P40 — add one assertion to invoice_test.go’s usage test that a withholding code is refused with tax_code_usage).

  • Step 7: API and web. api/openapi.yaml: TaxCode.usage enum and the create body’s usage enum gain withholding; TaxCode description of pack_kind unchanged. make generate. InvoiceDialog.tsx line 328: .filter((c) => c.active && (c.usage === 'purchase' || c.usage === 'both')) with the comment // P40; WHT D1: never a withholding code. Run pnpm -C web test -- InvoicesPage → PASS.

  • Step 8: Run go test ./internal/kernel/money/... ./internal/platform/accounting/... ./internal/platform/countrypack/... ./internal/platform/payables/... ./internal/platform/expenses/... ./internal/platform/pettycash/... -count=1 → PASS.

  • Step 9: Sabotage. (a) Replace PercentAt’s DivRound(…, places) with Percent(p) + a second round → the 12.34495 case fails; restore. (b) Remove the expenses refusal → the claim test fails with the line accepted; restore.

  • Step 10: Commit "WHT Task 2: withholding tax codes, 2160, provisioning step 77, refusals" with the sabotage notes.


Files:

  • Create: internal/platform/payables/withholding.go, withholding_test.go
  • Modify: internal/platform/payables/payment.go, allocation.go, posting.go, supplier.go, payablestest/env.go, posting_test.go, supplier_test.go

Interfaces:

  • Consumes: chart.GetTaxCode, chart.TaxUsageWithholding, money.PercentAt.

  • Produces:

    • type WithholdingInput struct{ TaxCodeID uuid.UUID; TaxableAmount money.Amount }
    • type Deduction struct{ ID, PaymentID, TaxCodeID uuid.UUID; Section string; Rate money.Rate; TaxableAmount, Amount, BaseAmount money.Amount; Currency string; DueOn time.Time }
    • PaymentInput.Withholding []WithholdingInput; Payment.Withholding []Deduction; Payment.WithholdingAmount money.Amount
    • Supplier.DefaultWHTCodeID, SupplierInput.DefaultWHTCodeID *uuid.UUID
    • func withholdingDueOn(paid time.Time) time.Time
    • func checkPayment(ctx, tx, in *PaymentInput, base string) ([]Deduction, error) (W4); func checkBankAccount(ctx, tx, id uuid.UUID, currency string) error (W6)
    • payablestest: (e *Env) WHTCode(t, code string) uuid.UUID (looks up a seeded code by code), (e *Env) PaymentWithWHT(t, s, amount string, rate *money.Rate, wht []payables.WithholdingInput, lines ...payables.PaymentLine) payables.Payment, (e *Env) WHTBalance(t) money.Amount (base balance of 2160, admin read).
  • Step 1: Failing tests (withholding_test.go, e.UsePayablesPoster(t), supplier e.VerifiedSupplier(t, "SGSVC"), invoice 1,000.00 on 6170, posted):

    1. TestWithholdingDueOnIsOneCalendarMonthLater — table: 2026-01-15 → 2026-02-15; 2026-01-31 → 2026-02-28; 2028-01-31 → 2028-02-29; 2026-03-31 → 2026-04-30; 2026-12-10 → 2027-01-10.
    2. TestAPaymentWithholdsAndPaysTheNet — PaymentWithWHT(amount "900.00", wht {W109B, taxable "1000.00"}, line 1000.00) then Pay: the invoice outstanding is 0; p.WithholdingAmount = 100.0000; deduction Section "s109b", Rate 10, BaseAmount 100.0000, DueOn = Today + 1 month; the payout entry (e.EntryFor(t, p.InstanceID, "payout")) has Dr 2105 1000.00, Cr 1520 900.00, Cr 2160 100.00 with PartyID = supplier and TaxCodeID = W109B on the 2160 line.
    3. TestTheAllocationsMustEqualTheNetPlusTheTaxWithheld — presence: 900 + 100 = 1000 accepted (test 2); amount "900.01" → ErrAllocationTotal; amount "1000.00" with the WHT line → ErrAllocationTotal.
    4. TestAWithholdingLineMustBeAnInForceWithholdingCodeOnce — presence W109B accepted; a PST10 (purchase) code → FieldError{Field: "withholding[0].tax_code_id", Rule: "wht_code"}; a withholding code created with ValidFrom after Today → wht_code; the same code twice → wht_once; taxable 1000.01 (> settled) → wht_taxable; taxable 0.04 at 10% (withholds 0.00) → wht_taxable.
    5. TestS107AWithholdsTwoCodesOnTheServicePortion — taxable 600.00 on both W107A-10 and W107A-3 against a 1,000.00 invoice: withheld 60.00 + 18.00, amount 922.00; entry has two 2160 credit lines.
    6. TestAForeignPaymentWithholdsInBase — USD invoice 1,000 at 4.40; payment USD at 4.47, W109B taxable 1,000: Amount 100.0000 USD, BaseAmount 447.0000 (= round2(4470 × 10%)); entry balanced (the fx line is ledger.Post’s).
    7. TestAPaymentWithoutWithholdingIsUnchanged — the existing payment_test.go suite passes untouched (run it).
    8. supplier_test.go: a supplier with DefaultWHTCodeID = W109B saves and reads back; a purchase code there → field=default_wht_code_id rule=wht_code. Run → FAIL (compile).
  • Step 2: withholding.go.

package payables
// Withholding tax on supplier payments (spec 2026-09-27 D3–D7). A payment
// settles its invoices at gross; the tax withheld is credited to the
// code's liability account in the payment's own entry; the supplier is paid
// the net. Deductions are rewritten with every draft save (Ruling W3) and
// are effective while the payment's payout is open.
var ErrWHTInRemittance = errors.New("payables: tax withheld on this payment is in a remittance; withdraw or void the remittance first")
type WithholdingInput struct {
TaxCodeID uuid.UUID
TaxableAmount money.Amount
}
type Deduction struct {
ID, PaymentID, TaxCodeID uuid.UUID
Section string
Rate money.Rate
TaxableAmount, Amount, BaseAmount money.Amount
Currency string
DueOn time.Time
}
// withholdingDueOn is "within one month after paying or crediting" (ITA
// 1967 ss.107A, 109, 109B, 109F as understood; spec D7): the same date next
// month, clamped to that month's last day. Frozen on the deduction.
func withholdingDueOn(paid time.Time) time.Time {
y, m, d := paid.Date()
last := time.Date(y, m+2, 0, 0, 0, 0, 0, time.UTC).Day() // day 0 of m+2 = last day of m+1
return time.Date(y, m+1, min(d, last), 0, 0, 0, 0, time.UTC)
}
// checkWithholding computes in's deductions against gross (Σ allocations).
// Call after in.FXRate is set (checkPayment does).
func checkWithholding(ctx context.Context, tx pgx.Tx, in *PaymentInput, gross money.Amount) ([]Deduction, error) {
var out []Deduction
seen := map[uuid.UUID]bool{}
for i, w := range in.Withholding {
field := func(s string) string { return fmt.Sprintf("withholding[%d].%s", i, s) }
tc, err := chart.GetTaxCode(ctx, tx, w.TaxCodeID)
if err != nil && !errors.Is(err, chart.ErrNotFound) {
return nil, err
}
if err != nil || !tc.IsActive || tc.Usage != chart.TaxUsageWithholding || tc.AccountID == nil ||
tc.ValidFrom.After(in.PaymentDate) || (tc.ValidTo != nil && !in.PaymentDate.Before(*tc.ValidTo)) {
return nil, fieldErr(field("tax_code_id"), "wht_code", "choose a withholding tax code in force on the payment date")
}
if seen[tc.ID] {
return nil, fieldErr(field("tax_code_id"), "wht_once", "each withholding code once per payment")
}
seen[tc.ID] = true
if !w.TaxableAmount.IsPositive() || w.TaxableAmount.Cmp(gross) > 0 {
return nil, fieldErr(field("taxable_amount"), "wht_taxable",
fmt.Sprintf("a positive amount no more than the %s being settled", gross))
}
amt := w.TaxableAmount.PercentAt(tc.Rate, 2)
base := w.TaxableAmount.MulRate(*in.FXRate).PercentAt(tc.Rate, 2) // D4: from the RM taxable
if !amt.IsPositive() || !base.IsPositive() {
return nil, fieldErr(field("taxable_amount"), "wht_taxable", "the amount is too small to withhold anything")
}
out = append(out, Deduction{TaxCodeID: tc.ID, Section: tc.PackKind, Rate: tc.Rate, TaxableAmount: w.TaxableAmount,
Amount: amt, BaseAmount: base, Currency: in.Currency, DueOn: withholdingDueOn(in.PaymentDate)})
}
return out, nil
}
func sumWithheld(ds []Deduction) money.Amount {
t := money.Zero()
for _, d := range ds {
t = t.Add(d.Amount)
}
return t
}
// writeDeductions is writePaymentLines' twin: withdraw, then insert.
func writeDeductions(ctx context.Context, tx pgx.Tx, org, paymentID uuid.UUID, now time.Time, ds []Deduction) error {
if _, err := tx.Exec(ctx, `UPDATE wht_deduction SET withdrawn_at = $3
WHERE org_id = $1 AND payment_id = $2 AND withdrawn_at IS NULL`, org, paymentID, now); err != nil {
return err
}
for _, d := range ds {
if _, err := tx.Exec(ctx, `
INSERT INTO wht_deduction (org_id, payment_id, tax_code_id, section, rate, taxable_amount, amount, base_amount,
currency, due_on, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
org, paymentID, d.TaxCodeID, d.Section, d.Rate, d.TaxableAmount, d.Amount, d.BaseAmount, d.Currency, d.DueOn, now); err != nil {
return fmt.Errorf("payables: deduction: %w", err)
}
}
return nil
}
// deductionsOf reads a payment's current (not withdrawn) deductions; lock
// adds FOR UPDATE, taken in id order (the plan's lock order).
func deductionsOf(ctx context.Context, tx pgx.Tx, paymentID uuid.UUID, lock bool) ([]Deduction, error) { /* SELECT … ORDER BY id [FOR UPDATE] */ }
// refuseIfRemitted (spec D9) locks the payment's deductions and refuses when
// any is on a live remittance line.
func refuseIfRemitted(ctx context.Context, tx pgx.Tx, paymentID uuid.UUID) error {
org, err := orgOf(ctx)
if err != nil {
return err
}
if _, err := deductionsOf(ctx, tx, paymentID, true); err != nil {
return err
}
var live bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM wht_remittance_line l
JOIN wht_remittance r ON r.org_id = l.org_id AND r.id = l.remittance_id
JOIN wht_deduction d ON d.org_id = l.org_id AND d.id = l.deduction_id
WHERE l.org_id = $1 AND r.org_id = $1 AND d.org_id = $1 AND d.payment_id = $2
AND l.withdrawn_at IS NULL AND d.withdrawn_at IS NULL
AND public.payables_semantic(r.org_id, r.instance_id) <> 'cancelled')`, org, paymentID).Scan(&live); err != nil {
return err
}
if live {
return ErrWHTInRemittance
}
return nil
}

(refuseIfRemitted is wired in Task 4, which owns the test that needs a remittance.)

  • Step 3: payment.go. Payment gains Withholding []Deduction and WithholdingAmount money.Amount; getPayment fills them from deductionsOf(…, false). PaymentInput gains Withholding []WithholdingInput. Extract checkBankAccount (the existing chart.GetAccount block, same ErrBankAccount). checkPayment now returns ([]Deduction, error); its tail becomes:
ded, err := checkWithholding(ctx, tx, in, total)
if err != nil {
return nil, err
}
if total.Cmp(in.Amount.Add(sumWithheld(ded))) != 0 {
return nil, ErrAllocationTotal // D4 (Ruling W4): allocations = net paid + tax withheld
}
return ded, nil

ErrAllocationTotal’s text: "payables: the allocations must add up to the payment amount plus any tax withheld". CreatePayment/UpdatePayment call writeDeductions(ctx, tx, org, id, now, ded) right after writePaymentLines. workflow.Start/SetAmount keep baseAmt (W5). The audit After is the re-read payment (it now includes the deductions).

  • Step 4: allocation.go allocatePayment. Final check: if total.Cmp(p.Amount.Add(p.WithholdingAmount)) != 0 { return ErrAllocationTotal }.

  • Step 5: posting.go PaymentPayoutRule.Build. After the allocation loop, before the bank line:

withheld := money.Zero()
var whtLines []ledger.Line
for _, d := range p.Withholding { // D6: Cr the code's liability, at the frozen base
tc, err := chart.GetTaxCode(ctx, tx, d.TaxCodeID)
if err != nil {
return accounting.Draft{}, fmt.Errorf("payables: payout: %w", err)
}
if tc.AccountID == nil {
return accounting.Draft{}, fmt.Errorf("payables: payout: withholding code %s names no account", tc.Code)
}
code, base := tc.ID, d.BaseAmount
whtLines = append(whtLines, ledger.Line{AccountID: *tc.AccountID, Credit: d.Amount, Base: &base, PartyID: &party,
TaxCodeID: &code, Memo: p.Reference + " WHT " + tc.Code})
withheld = withheld.Add(d.Amount)
}
if len(out) == 0 || total.Cmp(p.Amount.Add(withheld)) != 0 {
return accounting.Draft{}, fmt.Errorf("%w: allocations %s, payment %s + withheld %s", ErrAllocationTotal, total, p.Amount, withheld)
}
bankBase := p.BaseAmount
out = append(out, ledger.Line{AccountID: p.BankAccountID, Credit: p.Amount, Base: &bankBase, PartyID: &party,
Memo: p.Reference + " " + p.BankReference})
out = append(out, whtLines...)

(replacing the existing total check and bank append). Update the rule’s doc comment.

  • Step 6: supplier.go. default_wht_code_id in the select/insert/update column lists; SupplierInput.DefaultWHTCodeID; validation mirrors DefaultTaxCodeID’s but requires usage == withholding (W15).

  • Step 7: payablestest/env.go. WHTCode (chart.ListTaxCodes → by code), PaymentWithWHT (the Payment helper with Withholding: wht; Payment itself calls it with nil), WHTBalance (admin: SELECT coalesce(sum(base_credit - base_debit),0) FROM journal_line jl JOIN account a ON … WHERE a.code = '2160' with org predicates — read ControlBalanceByParty for the column names and copy its shape).

  • Step 8: Run go test ./internal/platform/payables/... -count=1 → PASS (the whole AP suite: nothing without WHT changed).

  • Step 9: Sabotage. Drop the whtLines append → test 2 fails with ledger.ErrUnbalanced (MYR) — read it; restore. Change withholdingDueOn to paid.AddDate(0, 1, 0) → the 31 Jan row fails (2026-03-03); restore.

  • Step 10: Commit "WHT Task 3: supplier payments withhold tax (D3–D7)" with sabotage notes.


Task 4: Remittances, the payment void guard, the 2160 drift test

Section titled “Task 4: Remittances, the payment void guard, the 2160 drift test”

Files:

  • Create: internal/platform/payables/remittance.go, remittance_test.go, withholding_drift_test.go
  • Modify: internal/platform/payables/graphs.go, posting.go (Rules(), RemittancePayoutRule), transition.go (payment void guard), payablestest/env.go, withholding_schema_test.go (fixture kind → KindRemittance), internal/modules/payables/boot_test.go, cmd/worker/wiring_test.go

Interfaces:

  • Produces:

    • RemittanceGraph(ownerSeat uuid.UUID) workflow.Graph registered for KindRemittance.
    • type Remittance struct{ ID uuid.UUID; Reference, Section string; BankAccountID uuid.UUID; PaymentDate time.Time; BankReference string; Amount money.Amount; ReceiptNo *string; InstanceID, PreparedBy uuid.UUID; Revision int; CreatedAt, UpdatedAt time.Time; State string; Semantic posting.Semantic; Deductions []DeductionRow }
    • type RemittanceInput struct{ Section string; BankAccountID uuid.UUID; PaymentDate time.Time; BankReference string; DeductionIDs []uuid.UUID }
    • type DeductionRow struct{ Deduction; TaxCode string; SupplierID uuid.UUID; SupplierCode, SupplierName string; PaymentReference string; PaidOn time.Time; Status string; RemittanceID *uuid.UUID; RemittanceReference *string } and DeductionFilter{Section, Status string; PaymentID *uuid.UUID; IDs []uuid.UUID} (PaymentID for the payment view, IDs for checkRemittance)
    • CreateRemittance(ctx, tx, c, actor, in) (Remittance, error), UpdateRemittance(ctx, tx, c, id, actor, expectedRevision, in), GetRemittance, ListRemittances(ctx, tx, RemittanceFilter{Section, State string; Limit int; After *Cursor}) ([]Remittance, *Cursor, error), TransitionRemittance(ctx, tx, c, TransitionInput) (Remittance, workflow.Decision, error), RecordRemittanceReceipt(ctx, tx, c, id, actor uuid.UUID, receiptNo string) (Remittance, error), ListDeductions(ctx, tx, DeductionFilter) ([]DeductionRow, error)
    • Errors: ErrRemittanceTotal, ErrWHTSectionMismatch, ErrWHTNotRemittable, ErrReceiptRecorded, ErrReceiptBeforePayout.
    • RemittancePayoutRule{} in Rules(); SourceKinds += KindRemittance.
    • payablestest: Remittance(t, section string, deductionIDs ...uuid.UUID) payables.Remittance, Remit(t, r) payables.Remittance (submit as staff, approve as owner), VoidRemittance(t, r), WithdrawRemittance(t, r).
  • Step 1: Failing tests.

    • remittance_test.go:
      1. TestARemittancePaysTheTaxToTheAuthority — pay a WHT payment (Task 3 helper); ListDeductions(Section "s109b", Status "open") has it (presence); Remit(Remittance("s109b", d.ID)): entry Dr 2160 100.00 / Cr 1520 100.00 in base; ListDeductions(Status "remitted") has it; WHTBalance 0.
      2. TestOnlyEffectiveDeductionsOfTheSectionCanBeRemitted — a draft payment’s deduction → ErrWHTNotRemittable (Go pre-check); a paid s109 deduction in an s109b remittance → ErrWHTSectionMismatch; the same paid deduction in a second draft remittance → ErrWHTNotRemittable — each after the accepted case.
      3. TestAPaymentCannotBeVoidedWhileItsTaxIsInARemittance — presence: a paid WHT payment with no remittance voids fine (a separate payment); a second one placed in a draft remittance → VoidPayment → ErrWHTInRemittance; WithdrawRemittance → the void succeeds and the deduction is no longer listed.
      4. TestVoidingARemittanceFreesItsDeductions — remit, void: reversal entry mirrors; the deduction is open again; a new remittance takes it and pays.
      5. TestTheReceiptNumberIsRecordedOnceAfterPayment — before payout → ErrReceiptBeforePayout; after → OK, audited; again → ErrReceiptRecorded.
      6. TestThePreparerCannotApproveTheirOwnRemittance — staff submits, staff approving → workflow.RefusedError code self_approval (grant staff the owner seat with GrantOwnerSeatTo first so the refusal is the self-approval rule, not a missing grant — presence: the owner approving succeeds).
      7. TestARemittanceTotalIsItsDeductionsInBase — a USD deduction (base 447.00) and a MYR one (100.00) → remittance amount 547.00.
    • withholding_drift_test.go:
// TestWithholdingPayableEqualsOpenDeductions is spec D10's drift test: after
// a mixed sequence, the base balance of every account a withholding code
// names equals Σ base of effective deductions not on a paid remittance.
func TestWithholdingPayableEqualsOpenDeductions(t *testing.T) {
e := payablestest.New(t)
e.UsePayablesPoster(t)
s := e.VerifiedSupplier(t, "SGSVC")
exp := e.Account(t, "6170")
w := e.WHTCode(t, "W109B")
pay := func(no, gross string, rate *money.Rate, cur string) payables.Payment { /* post invoice (foreign when cur != "MYR"),
PaymentWithWHT(net, {w, gross}, line gross), Pay */ }
p1 := pay("S-1", "1000.00", nil, "MYR") // remitted below
usd := money.MustParseRate("4.47")
p2 := pay("S-2", "500.00", &usd, "USD") // stays open: base 223.50
p3 := pay("S-3", "300.00", nil, "MYR")
e.VoidPayment(t, p3) // never effective
r1 := e.Remit(t, e.Remittance(t, "s109b", p1.Withholding[0].ID))
_ = r1
r2 := e.Remit(t, e.Remittance(t, "s109b", p2.Withholding[0].ID))
e.VoidRemittance(t, r2) // p2's deduction is open again
open := money.Zero()
for _, d := range listOpenOrInRemittance(t, e) { open = open.Add(d.BaseAmount) }
// PRESENCE: something is open, so the equality is not 0 = 0.
require.Equal(t, "223.5000", open.String())
require.Zero(t, e.WHTBalance(t).Cmp(open), "2160 %s, open deductions %s", e.WHTBalance(t), open)
}

Run → FAIL.

  • Step 2: Graph in graphs.go: register workflow.RegisterSeededGraph(KindRemittance, RemittanceGraph); RemittanceGraph is PaymentGraph’s body with Kind: KindRemittance and edge 2 ed(2, "pending_approval", "paid", workflow.GateTarget, "approve_remittance", ls("Approve and pay", "批准并付款", "Luluskan dan bayar")); the paid label ls("Paid", "已缴付", "Telah dibayar"). (A shared cashGraph(kind, approveKey, approveLabels, …) helper is allowed if it makes both shorter; keep PaymentGraph’s output identical — graphs_test.go pins it.)

  • Step 3: remittance.go. The payment.go shape. Non-obvious parts:

// checkRemittance validates in and returns its deductions' base total.
// The trigger re-checks every rule at insert (Ruling W9); this is for a
// friendly 422.
func checkRemittance(ctx context.Context, tx pgx.Tx, in *RemittanceInput, base string, self *uuid.UUID) (money.Amount, error) {
in.Section, in.BankReference = strings.TrimSpace(in.Section), strings.TrimSpace(in.BankReference)
switch {
case in.Section == "":
return money.Amount{}, fieldErr("section", "required", "choose the section being remitted")
case len(in.BankReference) > 64 || recordtext.Check("bank_reference", in.BankReference) != nil:
return money.Amount{}, fieldErr("bank_reference", "record_language", "a reference of up to 64 characters in English or Bahasa Malaysia")
case len(in.DeductionIDs) == 0:
return money.Amount{}, fieldErr("deduction_ids", "lines_count", "include at least one deduction")
}
if err := checkBankAccount(ctx, tx, in.BankAccountID, base); err != nil {
return money.Amount{}, err
}
rows, err := ListDeductions(ctx, tx, DeductionFilter{IDs: in.DeductionIDs}) // IDs: an internal filter field
if err != nil {
return money.Amount{}, err
}
if len(rows) != len(uniq(in.DeductionIDs)) {
return money.Amount{}, ErrWHTNotRemittable
}
total := money.Zero()
for _, r := range rows {
switch {
case r.Section != in.Section:
return money.Amount{}, ErrWHTSectionMismatch
case r.Status == StatusOpen, r.Status == StatusInRemittance && self != nil && *r.RemittanceID == *self:
default:
return money.Amount{}, ErrWHTNotRemittable
}
total = total.Add(r.BaseAmount)
}
return total, nil
}

CreateRemittance: company.Get for base; checkRemittance; fiscal.YearContaining(TodayUTC); numbering.Allocate(ctx, tx, KindRemittance, year.EndYear); reference numbering.Format("WR", …); workflow.Start(Kind: KindRemittance, Amount: total); insert header; writeRemittanceLines (withdraw + insert in deduction-id order); audit last. UpdateRemittance: preparer-only, revision, draft, the same (with self), workflow.SetAmount.

Status SQL in ListDeductions (one query, org predicate on every table):

SELECT d.id, d.payment_id, d.tax_code_id, d.section, d.rate, d.taxable_amount, d.amount, d.base_amount, d.currency, d.due_on,
tc.code, p.supplier_party_id, s.code, pa.legal_name, p.reference, p.payment_date,
lr.remittance_id, lr.reference,
CASE WHEN NOT pi.payout_open THEN 'planned'
WHEN lr.remittance_id IS NULL THEN 'open'
WHEN lr.paid THEN 'remitted' ELSE 'in_remittance' END
FROM wht_deduction d
JOIN tax_code tc ON tc.org_id = d.org_id AND tc.id = d.tax_code_id
JOIN supplier_payment p ON p.org_id = d.org_id AND p.id = d.payment_id
JOIN workflow_instance pi ON pi.org_id = p.org_id AND pi.id = p.instance_id
JOIN party_supplier s ON s.org_id = p.org_id AND s.party_id = p.supplier_party_id
JOIN party pa ON pa.org_id = p.org_id AND pa.id = p.supplier_party_id
LEFT JOIN LATERAL (
SELECT l.remittance_id, r.reference, ri.payout_open AS paid
FROM wht_remittance_line l
JOIN wht_remittance r ON r.org_id = l.org_id AND r.id = l.remittance_id
JOIN workflow_instance ri ON ri.org_id = r.org_id AND ri.id = r.instance_id
WHERE l.org_id = $1 AND r.org_id = $1 AND ri.org_id = $1 AND l.deduction_id = d.id AND l.withdrawn_at IS NULL
AND public.payables_semantic(r.org_id, r.instance_id) <> 'cancelled'
LIMIT 1) lr ON true
WHERE d.org_id = $1 AND tc.org_id = $1 AND p.org_id = $1 AND pi.org_id = $1 AND s.org_id = $1 AND pa.org_id = $1
AND d.withdrawn_at IS NULL
AND (pi.payout_open OR public.payables_semantic(p.org_id, p.instance_id) NOT IN ('cancelled')) -- W11: no voided payments
AND ($2 = '' OR d.section = $2)
AND ($4::uuid[] IS NULL OR d.id = ANY($4))
AND ($5::uuid IS NULL OR d.payment_id = $5)
ORDER BY d.due_on, d.id
LIMIT 500

then filter by $3 status in an outer SELECT * FROM (…) q WHERE ($3 = '' OR q.status = $3) (write it as one statement). A voided payment has payout_open = false and a cancelled semantic, so the second predicate drops it; a draft/pending payment shows as planned. StatusPlanned/Open/InRemittance/Remitted are Go consts with those string values.

TransitionRemittance — ORDER: remittance FOR UPDATE → to payout: lock its live lines’ deductions FOR UPDATE in id order, recheck each is effective (payout_open of its payment) and Σ base = Amount, else ErrRemittanceTotal/ErrWHTNotRemittable → workflow.Transition → audit. No work on cancelling/rejecting (the semantic frees the lines, W9).

RecordRemittanceReceipt: lock; Semantic != posting.Payout → ErrReceiptBeforePayout; ReceiptNo != nil → ErrReceiptRecorded; recordtext.Check, ≤ 64; UPDATE … SET receipt_no, updated_at; audit Action: "record_receipt".

  • Step 4: RemittancePayoutRule in posting.go:
// RemittancePayoutRule (spec D8): Dr each withholding account, at the
// deductions' frozen base; Cr the bank; in base currency.
type RemittancePayoutRule struct{}
func (RemittancePayoutRule) Kind() string { return KindRemittance }
func (RemittancePayoutRule) Semantic() posting.Semantic { return posting.Payout }
func (RemittancePayoutRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) {
r, err := GetRemittance(ctx, tx, src.Event.SourceID)
if err != nil {
return accounting.Draft{}, fmt.Errorf("payables: remittance payout: %w", err)
}
if r.InstanceID != src.Event.InstanceID {
return accounting.Draft{}, ErrInstanceMismatch
}
profile, err := company.Get(ctx, tx)
if err != nil {
return accounting.Draft{}, err
}
byAccount, order, total := map[uuid.UUID]money.Amount{}, []uuid.UUID{}, money.Zero()
for _, d := range r.Deductions {
tc, err := chart.GetTaxCode(ctx, tx, d.TaxCodeID)
if err != nil {
return accounting.Draft{}, fmt.Errorf("payables: remittance payout: %w", err)
}
if _, ok := byAccount[*tc.AccountID]; !ok {
order = append(order, *tc.AccountID)
}
byAccount[*tc.AccountID] = byAccount[*tc.AccountID].Add(d.BaseAmount)
total = total.Add(d.BaseAmount)
}
if len(order) == 0 || total.Cmp(r.Amount) != 0 {
return accounting.Draft{}, fmt.Errorf("%w: deductions %s, remittance %s", ErrRemittanceTotal, total, r.Amount)
}
var out []ledger.Line
for _, a := range order {
amt := byAccount[a]
out = append(out, ledger.Line{AccountID: a, Debit: amt, Base: &amt, Memo: r.Reference + " " + r.Section})
}
bank := r.Amount
out = append(out, ledger.Line{AccountID: r.BankAccountID, Credit: r.Amount, Base: &bank, Memo: r.Reference + " " + r.BankReference})
jid, jtype, err := journalFor(ctx, tx, r.BankAccountID)
if err != nil {
return accounting.Draft{}, err
}
return accounting.Draft{JournalID: jid, JournalType: jtype, Currency: profile.BaseCurrency,
Description: "Withholding tax remittance " + r.Reference, Lines: out}, nil
}

GetRemittance fills Deductions from the live lines. Rules() appends RemittancePayoutRule{}. boot_test.go and cmd/worker/wiring_test.go rule lists gain {"wht_remittance", posting.Payout}.

  • Step 5: Payment void guard in transition.go TransitionPayment, case Cancelled || Rejected, first line:
if err := refuseIfRemitted(ctx, tx, p.ID); err != nil { // spec D9
return Payment{}, workflow.Decision{}, err
}
  • Step 6: Run go test ./internal/platform/payables/... ./internal/modules/payables/... ./cmd/worker/... -count=1 → PASS.
  • Step 7: Sabotage. (a) In RemittancePayoutRule, debit the bank account instead of the withholding account (and credit it too) — the drift test fails with “2160 x, open deductions y”; restore. (b) Remove the refuseIfRemitted call → test 3 voids happily and fails; restore.
  • Step 8: Commit "WHT Task 4: remittances, void guard, 2160 drift test (D8–D10)".

Task 5: The withholding register — query, JSON, export (00341)

Section titled “Task 5: The withholding register — query, JSON, export (00341)”

Files:

  • Create: internal/platform/payables/register.go, register_test.go, db/migrations/00341_report_export_wht_register.sql, internal/modules/reports/wht_handlers.go (+ test beside the aging handler test)
  • Modify: internal/platform/reporting/export/{export.go,params.go,project.go} (+ export_test.go/run_test.go case), internal/modules/reports/{routes.go,errors.go}, api/openapi.yaml (/reports/wht-register, the export report enum)

Interfaces:

  • Produces: type RegisterRow struct{ DeductionRow; SupplierTIN *string; SupplierCountry string; InvoiceReferences []string; CreditedOn *time.Time; RemittedOn *time.Time; ReceiptNo *string; Late, CreditedEarlier bool }; WithholdingRegister(ctx, tx, today, from, to time.Time, section string) ([]RegisterRow, error); export.ReportWHTRegister = "wht_register"; GET /reports/wht-register?from&to&section → {rows: [...], totals: [{section, currency, amount, base_amount}]} under reports.read.

  • Step 1: Failing tests (register_test.go): one open deduction paid today (not late), one remitted on time, one remitted after due_on (advance the clock with e.AdvanceClock(t, 40) before remitting → Late), one open and past due (clock advanced → Late), one whose invoice was posted 40 days before the payment (CreditedEarlier — post the invoice, AdvanceClock(40), then pay) — and presence for each flag: a row with the flag false exists. Section filter returns only s109b. Rows outside [from, to] are excluded (a payment dated the day after to). Export: export_test.go gains a wht_register case that renders CSV with the header row. Run → FAIL.

  • Step 2: register.go. ListDeductions’ query (Task 4) extended — or a separate query reusing its joins — with pa.tin, pa.country_code, the payment’s allocated invoices (array_agg(DISTINCT pi2.reference) from ap_allocation a JOIN purchase_invoice pi2 … WHERE a.payment_id = p.id AND a.reverses_id IS NULL), credited_on (min(je.entry_date) from journal_entry je WHERE je.source_kind = 'purchase_invoice' AND je.source_id = a.invoice_id AND je.reversal_of_id IS NULL AND je.semantic = 'accrual' — read 00052 for the exact column names), the remittance’s payment_date and receipt_no. Filter p.payment_date BETWEEN from AND to, status ≠ planned, section. Compute Late and CreditedEarlier in Go (Ruling W12) with withholdingDueOn.

  • Step 3: Export. export.go: ReportWHTRegister; params.go: allowed[ReportWHTRegister] = {"from": true, "to": true} (from/to validation is the default branch); project.go: name “Withholding tax register”; projectWHTRegister landscape with columns Section, Code, Rate %, Supplier, TIN, Country, Payment, Paid on, Invoices, Taxable, Currency, Withheld, Withheld (base), Due, Status, Remittance, Remitted on, Receipt no., Late (increase may apply), Credited earlier; a bold total row per section in base. 00341:

-- +goose Up
-- W14: the union of every report on the branch at build time, plus wht_register.
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','wht_register'));
-- +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'));

(If the branch has another report by then — e.g. ar_aging — include it in both lists.)

  • Step 4: JSON route. wht_handlers.go in internal/modules/reports: allowQuery(c, "Withholding register", "from", "to", "section"), parse dates (422 as the aging handler does), payables.WithholdingRegister(ctx, tx, clock.TodayUTC(h.deps.Clock), …), totals per (section, currency). Route httpx.Protected(g, p, "GET", "/reports/wht-register", read, h.getWHTRegister); errors.go’s report list sentence gains wht_register. OpenAPI: path + WhtRegisterRow/WhtRegisterTotal schemas (money as Money, rate as Rate, status enum [open, in_remittance, remitted]); export report enum gains wht_register. make generate.
  • Step 5: Run go test ./internal/platform/payables/... ./internal/platform/reporting/... ./internal/modules/reports/... ./internal/app/... -count=1 → PASS.
  • Step 6: Sabotage. Invert Late for remitted rows → the on-time row fails; restore.
  • Step 7: Commit "WHT Task 5: withholding register report and export (D11)".

Task 6: The payables module — permissions, grants, routes, DTOs (00342)

Section titled “Task 6: The payables module — permissions, grants, routes, DTOs (00342)”

Files:

  • Create: db/migrations/00342_wht_grants.sql, internal/modules/payables/wht_handlers.go, wht_handlers_test.go
  • Modify: constants.yaml, internal/modules/payables/{permissions.go,routes.go,errors.go,payment_handlers.go,supplier_handlers.go} (+ their tests), internal/app/routes_test.go, api/openapi.yaml

Interfaces:

  • Produces: PermissionWHTRead = "payables.wht.read", PermissionWHTCreate = "payables.wht.create", PermissionWHTTransition = "payables.wht.transition"; routes:
Method Path Permission
GET /payables/wht/deductions payables.wht.read
GET /payables/wht/remittances payables.wht.read
POST /payables/wht/remittances payables.wht.create
GET /payables/wht/remittances/:id payables.wht.read
PUT /payables/wht/remittances/:id payables.wht.create (If-Match)
POST /payables/wht/remittances/:id/transitions payables.wht.transition
PUT /payables/wht/remittances/:id/receipt payables.wht.create
  • Step 1: Failing tests. routes_test.go pins the seven routes’ permissions. wht_handlers_test.go (the invoice_handlers_test.go env): create a remittance over HTTP → 201 with ETag; a stale If-Match → 409 revision_mismatch; a section mismatch → 422 wht_section_mismatch; a void of a payment whose deduction is in a draft remittance → 409 wht_in_remittance; receipt twice → 409 receipt_already_recorded. payment_handlers_test.go: a POST with withholding returns withholding_amount and withholding[]; withholding[0].tax_code_id of a purchase code → 422 detail field=withholding[0].tax_code_id rule=wht_code: …. supplier_handlers_test.go: default_wht_code_id round-trips. A migration test (the migration00305_test.go pattern) for 00342: an Owner and a system Finance role gain the three keys; perm_version bumped. Run → FAIL.
  • Step 2: Keys. constants.yaml gains the three keys after payables.payment.transition; make generate. permissions.go: register them (“Read withholding tax deductions and remittances”, “Prepare withholding tax remittances, edit their drafts and record the tax authority’s receipt number”, “Use the remittance transition endpoint; authority to approve still comes from the workflow’s grants”) and add all three to provisioning.RegisterRolePermissions("Finance", …). 00342 copies 00305’s two INSERTs (Owner: three keys; Finance: three keys), the perm_version bump, and a Down that deletes payables.wht.% from both roles.
  • Step 3: OpenAPI (write before handlers): SupplierPaymentInput.withholding (type: array, items: WithholdingInput, not required, default empty); WithholdingInput {tax_code_id: uuid, taxable_amount: Money} required both; SupplierPayment.withholding_amount: Money (required); SupplierPaymentView.withholding: WhtDeduction[] (required); Supplier.default_wht_code_id / SupplierInput.default_wht_code_id (uuid, nullable); WhtDeduction {id, payment_id, payment_reference, supplier_id, supplier_code, supplier_name, tax_code_id, tax_code, section, rate, taxable_amount, amount, base_amount, currency, paid_on, due_on, status (enum planned, open, in_remittance, remitted), remittance_id?, remittance_reference?}; WhtRemittanceInput {section, bank_account_id, payment_date, bank_reference?, deduction_ids (minItems 1)}; WhtRemittance {id, reference, section, bank_account_id, payment_date, bank_reference, amount, receipt_no (nullable), instance_id, prepared_by, state, semantic, revision, created_at}; WhtRemittanceView {remittance, deductions, available_transitions, decisions}; WhtReceiptInput {receipt_no: string, maxLength 64}; the seven paths with the Payables* responses the payment paths use. make generate.
  • Step 4: Handlers. payment_handlers.go: paymentBody.Withholding []whtInputDTO → in.Withholding; paymentDTO.WithholdingAmount; paymentViewDTO.Withholding []whtDeductionDTO from ListDeductions(DeductionFilter{PaymentID: &id}), so each row carries its status and remittance (a voided payment’s view lists none — its deductions are not effective). supplier_handlers.go: the field in DTO and body. wht_handlers.go: the payment_handlers.go shape (remittanceTx, remittanceView, setETag, If-Match via the existing helper). routes.go: the seven lines. errors.go:
case errors.Is(err, p.ErrWHTInRemittance):
return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "wht_in_remittance")
case errors.Is(err, p.ErrWHTSectionMismatch):
return apperr.New(apperr.KindValidation, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "wht_section_mismatch")
case errors.Is(err, p.ErrWHTNotRemittable), errors.Is(err, p.ErrRemittanceTotal):
return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "wht_already_remitted")
case errors.Is(err, p.ErrReceiptRecorded):
return apperr.New(apperr.KindConflict, "a receipt number is already recorded").With("code", "receipt_already_recorded")
case errors.Is(err, p.ErrReceiptBeforePayout):
return apperr.New(apperr.KindConflict, "record the receipt number once the remittance is paid")
case errors.As(err, &pg) && strings.HasPrefix(pg.ConstraintName, "wht_remittance_line_"),
errors.As(err, &pg) && pg.ConstraintName == "wht_deduction_is_draft",
errors.As(err, &pg) && pg.ConstraintName == "wht_remittance_is_draft":
return apperr.New(apperr.KindConflict, "this remittance or payment changed; reload it and retry").With("code", "wht_changed")
case errors.As(err, &pg) && strings.HasPrefix(pg.ConstraintName, "wht_remittance_receipt_"):
return apperr.New(apperr.KindConflict, "a receipt number is already recorded").With("code", "receipt_already_recorded")

(placed before the generic ap_allocation_ case; ErrWHTNotRemittable covers “not effective / already in a remittance”).

  • Step 5: Run go test ./internal/modules/payables/... ./internal/app/... ./internal/platform/payables/... -count=1 → PASS; git status shows only intended + generated files.
  • Step 6: Sabotage. Register POST /payables/wht/remittances with PermissionWHTRead → routes_test.go fails naming it; restore.
  • Step 7: Commit "WHT Task 6: payables module — WHT routes, keys, grants 00342 (D14)".

Task 7: Web — withholding on the payment and the supplier

Section titled “Task 7: Web — withholding on the payment and the supplier”

Files:

  • Create: web/src/features/payables/WithholdingFields.tsx
  • Modify: web/src/features/payables/{api.ts,PaymentDialog.tsx,PaymentDrawer.tsx,SupplierDialog.tsx,SupplierDrawer.tsx}, __tests__/PaymentsPage.test.tsx, __tests__/SuppliersPage.test.tsx, locales {en,ms,zh}/payables.json

Interfaces:

  • Consumes: generated components['schemas']['WithholdingInput' | 'WhtDeduction' | 'TaxCode']; percentOfMoney, sumMoney, subtractMoney from @/lib/money; /accounting/tax-codes.

  • Produces: useWithholdingCodes() in api.ts (tax codes filtered active && usage === 'withholding'); <WithholdingFields rows onChange codes gross currency />.

  • Step 1: Failing tests. PaymentsPage.test.tsx (MSW): picking a supplier whose default_wht_code_id is W109B pre-fills one withholding row with taxable = the allocated total; allocating 1,000.00 shows Tax withheld 100.00 and Paid to supplier 900.00; the POST body carries amount: "900.0000"-equivalent (sumMoney form) and withholding: [{tax_code_id, taxable_amount: "1000.00"}]; removing the row sends withholding: [] and amount 1,000.00. The drawer lists a deduction with its status chip text. SuppliersPage.test.tsx: the supplier form’s Default withholding tax picker lists only withholding codes and sends default_wht_code_id. Run pnpm -C web test -- payables → FAIL.

  • Step 2: Build. WithholdingFields: rows of [code Select, taxable MoneyInput, computed withheld MoneyText, remove], Add withholding tax button; withheld = percentOfMoney(taxable, rate). In PaymentDialog: const withheld = sumMoney(rows.map(r => percentOfMoney(r.taxable_amount, rateOf(r.tax_code_id)))); amount = subtractMoney(gross, withheld) where gross = sumMoney(lines.map(l => l.amount)); when the supplier changes and has a default, set rows = [{tax_code_id: default, taxable_amount: gross}] and keep taxable following gross until the clerk edits it (a touched flag). Show Settled (gross), Tax withheld, Paid to supplier. PaymentDrawer: a Tax withheld section listing view.withholding (code, taxable, withheld, due date, status). SupplierDialog: the picker.

  • Step 3: Strings (payables.json; genuine Malay):

key en ms zh
wht.title Tax withheld Cukai pegangan 预扣税
wht.add Add withholding tax Tambah cukai pegangan 添加预扣税
wht.code Withholding code Kod cukai pegangan 预扣税代码
wht.taxable Taxable amount Amaun boleh cukai 应税金额
wht.withheld Withheld Dipegang 已预扣
wht.remove Remove Buang 移除
wht.gross Settled (gross) Diselesaikan (kasar) 结清金额(总额)
wht.net Paid to supplier Dibayar kepada pembekal 支付给供应商
wht.dueOn Due to LHDN Perlu dibayar kepada LHDN 应缴 LHDN 日期
wht.status.planned Not yet paid Belum dibayar 尚未付款
wht.status.open To remit Untuk diremit 待缴付
wht.status.in_remittance In a remittance Dalam remitan 缴付中
wht.status.remitted Remitted Telah diremit 已缴付
suppliers.fields.defaultWht Default withholding tax Cukai pegangan lalai 默认预扣税
suppliers.fields.defaultWhtHint For payments to non-residents under the Income Tax Act Untuk bayaran kepada bukan pemastautin di bawah Akta Cukai Pendapatan 适用于根据所得税法向非居民支付的款项
  • Step 4: Run pnpm -C web test and pnpm -C web typecheck (or tsc -b, whichever package.json defines) → PASS.
  • Step 5: Commit "WHT Task 7: web — withholding on payments and suppliers".

Task 8: Web — remittances screen and the register tab

Section titled “Task 8: Web — remittances screen and the register tab”

Files:

  • Create: web/src/features/payables/{RemittancesPage.tsx,RemittanceDialog.tsx,RemittanceDrawer.tsx}, __tests__/Remittances.test.tsx, web/src/features/reports/WHTRegisterView.tsx (+ .test.tsx)
  • Modify: web/src/features/payables/{api.ts,routes.tsx}, web/src/app/shell/nav.ts, web/src/features/reports/{ReportsPage.tsx,routes.tsx,api.ts}, locales {en,ms,zh}/payables.json, {en,ms,zh}/reports.json

Interfaces:

  • Produces: useRemittances, useRemittance(id), useSaveRemittance, useRemittanceTransition(id?) (the P41 pattern), useRecordReceipt, useDeductions({section, status}) in api.ts; routes /payables/wht, /payables/wht/new, /payables/wht/$id; nav item {group: 'accounting', to: '/payables/wht', labelKey: 'payables:nav.wht', permission: 'payables.wht.read'} after Supplier payments; report tab wht_register.

  • Step 1: Failing tests. Remittances.test.tsx: the list (DataTable tableId="payables.remittances") shows reference, section label, payment date, amount, state chip; New remittance: choosing section s109b lists only open s109b deductions (MSW asserts the query section=s109b&status=open), ticking two shows their base total, Submit POSTs then transitions via useRemittanceTransition; the drawer at paid shows Record LHDN receipt and PUTs the number; the button is absent before payout. WHTRegisterView.test.tsx: renders rows with the status label and a Late badge; the export menu offers wht_register with the from/to params. Run → FAIL.

  • Step 2: Build following PaymentsPage/PaymentDialog/PaymentDrawer (workflow chip, timeline, transition bar from available_transitions). Section labels come from payables:wht.sections.<pack_kind> with a fallback to the raw key (an org-created section shows as itself). ReportsPage: wht_register joins the from/to reports (not the as_of pair); routes.tsx’s enum gains it; WHTRegisterView uses GET /reports/wht-register.

  • Step 3: Strings. payables.json:

key en ms zh
nav.wht Withholding tax Cukai pegangan 预扣税
wht.remittances.title Withholding tax remittances Remitan cukai pegangan 预扣税缴付
wht.remittances.new New remittance Remitan baharu 新建缴付
wht.remittances.section Section Seksyen 条款
wht.remittances.deductions Deductions Potongan 扣缴项目
wht.remittances.none Nothing to remit in this section Tiada apa-apa untuk diremit dalam seksyen ini 此条款下没有待缴付项目
wht.remittances.total Total to pay Jumlah perlu dibayar 应缴总额
wht.remittances.receipt LHDN receipt number Nombor resit LHDN LHDN 收据编号
wht.remittances.recordReceipt Record LHDN receipt Rekod resit LHDN 记录 LHDN 收据
wht.sections.s107a Section 107A — contract payments (CP37A) Seksyen 107A — bayaran kontrak (CP37A) 第107A条 — 合约付款 (CP37A)
wht.sections.s109 Section 109 — interest and royalties (CP37) Seksyen 109 — faedah dan royalti (CP37) 第109条 — 利息及特许权使用费 (CP37)
wht.sections.s109b Section 109B — services and technical fees (CP37D) Seksyen 109B — perkhidmatan dan fi teknikal (CP37D) 第109B条 — 服务及技术费 (CP37D)
wht.sections.s109f Section 109F — other income (CP37F) Seksyen 109F — pendapatan lain (CP37F) 第109F条 — 其他收入 (CP37F)

reports.json: tabs.wht_register Withholding register / Daftar cukai pegangan / 预扣税登记册; wht.late Late (increase may apply) / Lewat (kenaikan mungkin dikenakan) / 逾期(可能加征); wht.creditedEarlier Invoice posted over a month before payment / Invois dipos lebih sebulan sebelum bayaran / 发票在付款前一个多月已入账; column headings matching the export’s.

  • Step 4: Run pnpm -C web test, typecheck, pnpm -C web lint → PASS.
  • Step 5: Commit "WHT Task 8: web — remittances and the withholding register".

Task 9: End to end on lane 3, dev seed, roadmap, deploy notes, full suite

Section titled “Task 9: End to end on lane 3, dev seed, roadmap, deploy notes, full suite”

Files:

  • Create: web/e2e/withholding.spec.ts
  • Modify: web/e2e/seed.ts, web/e2e/support.ts, docs/ROADMAP.md, docs/DEPLOY_NOTES.md

Interfaces:

  • Consumes: as, api, runTag, uniqueReceipt, ensureFinanceAP from web/e2e/support.ts.

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

  • Step 1: Lane ports. The phase-2 branch’s scripts/dev-stack.sh, web/vite.config.ts and web/playwright.config.ts read ERP_STACK, ERP_PG_PORT, ERP_MINIO_PORT, ERP_API_PORT, ERP_WEB_PORT (defaults = the user’s dev stack). Use them; change nothing there. If they are absent when this task runs, stop and report — the controller owns that change.

  • Step 2: Seed. seed.ts FINANCE gains 'payables.wht.read', 'payables.wht.create', 'payables.wht.transition'; support.ts ensureFinanceWHT() = ensureFinanceAP’s body with those keys (call both in beforeAll).

  • Step 3: The e2e withholding.spec.ts, following payables.spec.ts step by step:

    1. Finance creates supplier E2E… with country Singapore and default withholding WHT s109B services and technical fees 10%; enters bank details. 2. Owner verifies them.
    2. Finance enters an invoice (6170 Professional fees, net 1,000.00, PNT no SST), submits; owner approves and posts.
    3. Finance opens New payment, picks the supplier, pays the invoice 1,000.00: the Tax withheld row is pre-filled, Tax withheld shows 100.00 and Paid to supplier 900.00; submits. Owner approves → chip Paid; the drawer’s Tax withheld row shows To remit.
    4. Finance opens Withholding tax → New remittance, section Section 109B — services and technical fees (CP37D), ticks the deduction (total 100.00), bank 1520, submits. Owner approves (Approve and pay) → Paid; owner records receipt E2E-RCPT-<tag>.
    5. Reports → Withholding register for today: the row for the supplier shows Remitted, 100.00, the receipt number.
  • Step 4: Run on lane 3. Set export ERP_STACK=erp-wht ERP_PG_PORT=56442 ERP_MINIO_PORT=59130 ERP_API_PORT=3130 ERP_WEB_PORT=5194, start bash scripts/dev-stack.sh in the background (it seeds the demo org on first run in this worktree’s .dev-stack/), then pnpm -C web exec playwright test e2e/withholding.spec.ts e2e/payables.spec.ts. A lane stack created before this branch’s migrations needs the demo org re-provisioned once (POST /v1/org/provision as owner) to gain wht_remittance, 2160 and the six codes. Expected: PASS. Afterwards bash scripts/dev-stack.sh down with the same env (removes only erp-wht-* and this worktree’s .dev-stack/).

  • Step 5: Full suite, once. go test -p 2 -count=1 ./...; pnpm -C web test; pnpm -C web lint; make generate && git status (clean). Fix anything red before committing.

  • Step 6: Roadmap. Phase 2: replace - [ ] WHT, CP37 / CP38 with:

- [x] WHT, CP37 (CP38 → Phase 5 payroll, spec D13)
Design: [`2026-09-27-withholding-tax-design.md`](superpowers/specs/2026-09-27-withholding-tax-design.md) (D1–D14), migrations `00340`–`00342`
Plan: [`2026-09-27-withholding-tax.md`](superpowers/plans/2026-09-27-withholding-tax.md) (9 tasks, Rulings W1–W16)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — re-provision every organization (account 2160, six withholding codes, `wht_remittance` kind)
- Tax is withheld on the supplier payment (gross settled, net paid, `Cr 2160`); a separately approved
remittance per section pays LHDN and records its receipt number
- 2160 = open deductions is a drift test (`internal/platform/payables/withholding_drift_test.go`)
- Withholding register (`wht_register`, CSV/XLSX/PDF) is the CP37 listing, with late and
credited-earlier flags
- End to end: supplier → invoice → pay with WHT → remit → register shows remitted (`web/e2e/withholding.spec.ts`)
- [ ] Open decisions (owner's call): rates/sections/forms unverified; "paid or credited" timing;
deadline arithmetic; rounding; taxable base; late increase not posted; no control kind for 2160
(see the spec's *For the owner to confirm*)

Phase 5 payroll line gains - CP38 directions: deduct from salary and remit with PCB (deferred from the Phase 2 WHT item, spec D13).

  • Step 7: Deploy notes. A new ## Phase 2 WHT section: migrations 00340–00342 (00340 widens tax_code_usage_known — safe, additive); re-provision every organization (POST /v1/org/provision) so it gains account 2160, the six withholding codes (step payables_withholding, order 77) and the wht_remittance workflow kind; existing Owner/Finance roles gain payables.wht.* by migration; the seeded rates are unverified (link the spec’s owner list).
  • Step 8: Commit "WHT Task 9: e2e, dev seed, roadmap, deploy notes".

Spec requirement Task
D1 withholding usage on tax_code; refusals in claims/petty cash/invoice 1, 2
D2 account 2160, six codes, step 77 2
D3/D4 withhold at payment; allocations = net + withheld; supplier default 3
D5 deductions effective at payout 1, 3
D6 payout entry credits 2160 with party and tax code 3
D7 due date one calendar month, clamped 3
D8 remittance kind, one section, line guard, receipt once 1, 4, 6
D9 payment void refused while in a remittance 4
D10 drift test 2160 = open deductions 4
D11 register report JSON + export 5
D12 late flagged, not posted 5, 8
D13 CP38 deferred 9 (roadmap)
D14 permissions and grants 6
Web payment/supplier/remittances/register 7, 8
e2e on lane 3 9

Checked names across tasks: KindRemittance = "wht_remittance" ↔ graph ↔ SourceKinds ↔ boot_test/wiring_test ↔ numbering scope; statuses planned/open/in_remittance/remitted ↔ ListDeductions SQL ↔ OpenAPI enum ↔ locale wht.status.*; sections s107a/s109/s109b/s109f ↔ MY pack PackKind ↔ locale wht.sections.*; constraint names in 00340 ↔ withholding_schema_test.go ↔ mapErr; permission keys identical in constants.yaml, permissions.go, 00342, routes_test.go, seed.ts, support.ts, nav.ts; checkPayment returns ([]Deduction, error) at both call sites; PercentAt(rate, 2) in Go ↔ percentOfMoney (half-up to cents) on the web.