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)
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00340–00349only. This plan uses00340–00342. Task 1 registers{340, 349, "Phase 2 WHT"}ininternal/kernel/db/migrationranges_test.goin the same commit as00340. - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (OpenAPIMoney); rates aremoney.Rate/NUMERIC(19,10)/ OpenAPIRate. Compare withCmp. Nofloat64in any file this plan touches (web/src/drift/nofloat.test.tsscans the web).sqlc.yamlgains amoney.Rateoverride forwht_deduction.rate. - Every new table has
org_id UUID NOT NULL REFERENCES organization (id),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid,CONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite(org_id, x)foreign keys only. Grants follow00304:REVOKE DELETE … FROM erp_app(amendable). - Named constraints. Every
CHECK/UNIQUEisCONSTRAINT <table>_<rule>; none ends in_check,_key,_excl. A trigger’s refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name. - Pinned functions. Every function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table. NoSECURITY DEFINER. - Every SQL statement names
org_id = $nfor every org-scoped table it touches (join equalities included); everyINSERTnamesorg_id. Platform functions read the org fromtenancy.OrgFromContext(orgOf). - Retention.
wht_deduction,wht_remittance,wht_remittance_linego intoamendableininternal/kernel/db/retention_test.go. - Accounting reacts to semantics, never state names. Posting rules keyed
(kind, semantic); no Go/SQL compares astate_keyto a literal except seeded graphs and tests. Comparing a semantic ('draft','cancelled') is allowed. - Modules never write journal lines.
payables.Rules()(registered frominternal/modules/payables/boot.go) gainsRemittancePayoutRule{}; rules return anaccounting.Draft. - Nothing in
internal/platform/accountingorinternal/modules/accountingnames 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) → supplierFOR SHARE(payment payout only) → invoice rowsFOR UPDATEin id order (payment) →wht_deductionrowsFOR UPDATEin id order →workflow.Transition→ this plan’saudit.Write, last. - No nested
db.InTx. Platform functions take(ctx, tx pgx.Tx, …). Handlers whose transaction can post passdb.InTx’s error throughledger.MapErrorbeforemapErr. - Business date is
clock.TodayUTC(c); everyDATEin Go is midnight UTC; nevertime.Now()under test. - Record-bearing text (
bank_reference,receipt_no) is English or Malay:recordtext.Checkin Go and the named script CHECK in SQL (written in full). - Errors are RFC 7807 via
mapErr; field refusals are 422field=<name> rule=<code>: <sentence>. - Every route goes through
httpx.Protectedwith its permission and is inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces).api/public_routes.txtgains nothing.make generateafter any migration, OpenAPI orconstants.yamlchange, thengit statusmust be clean apart from the intended files. - i18n. Every UI string is a key in
web/src/lib/i18n/locales/{en,ms,zh}/payables.jsonorreports.json;i18nparity.test.tsrequires identical keys. Malay is genuine Malay (strings below are the ones to use). Seeded workflow and pack labels carryen,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); nevert.Parallel(). - Tests:
testdb.Start(throughpayablestest.New); run only touched packages while iterating (go test ./internal/platform/payables/... -count=1); one fullgo 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>
Rulings
Section titled “Rulings”Where the spec is silent or the code forces a choice. Format: Ruling: <decision> — <why>.
- W1 Ruling:
pack_kindof a withholding code is the section key (s107a,s109,s109b,s109f);wht_deduction.sectionandwht_remittance.sectionsnapshot 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) isa × p / 100rounded once, half away from zero, toplaces. 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_deductionrows withdrawn and re-inserted on every draft save (thesupplier_payment_linepattern, AP P16). A deduction is effective iffwithdrawn_at IS NULLand the payment’sworkflow_instance.payout_open. Nothing is copied at payout. - W4 Ruling:
checkPaymentreturns the computed deductions:checkPayment(ctx, tx, &in, base) ([]Deduction, error); both call sites (CreatePayment,UpdatePayment) write them afterwritePaymentLines. Total rule: Σ lines =amount+ Σ withheld;ErrAllocationTotalreads “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 cashbase_amount; unchanged for payments without WHT (spec owner item 12). - W6 Ruling: The bank-account check is extracted from
checkPaymentintocheckBankAccount(ctx, tx, id, currency) errorso the remittance reuses it (the only refactor this plan does to AP code). - W7 Ruling:
wht_remittanceis its own seeded graph (RemittanceGraph,cash_only), thePaymentGraphshape and state keys (draft,pending_approval,paid,rejected,cancelled,voided), owner seat granted onpaidandvoided; edgeapprove_remittance. - W8 Ruling: Remittance currency is the organization’s base currency; its bank account’s currency is null or base.
amountis 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 NULLand the remittance’s semantic is notcancelled. Draft edits withdraw lines; withdrawing or voiding a remittance leaves its lines as they are (they stop being live through the semantic). The DB triggerwht_remittance_line_guardis the authority; Go pre-checks for friendly errors. - W10 Ruling:
receipt_nois set byRecordRemittanceReceiptexactly once, only at apayout-semantic state; triggerwht_remittance_receipt_guardis 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 withpayout_open). Deductions of a withdrawn draft or a voided payment are not listed. - W12 Ruling:
credited_on= the earliestentry_dateof the unreversed accrual entries of the invoices the payment allocated to;credited_earlier=paid_on > withholdingDueOn(credited_on);late= (remitted and remittancepayment_date > due_on) or (not remitted andtoday > 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.Registerpanics on a duplicate order? — readinternal/platform/provisioning/provisioning.goin Task 2 and record). - W14 Ruling:
00341re-addsreport_export_report_knownwith the union of every report value on the branch at build time pluswht_register— copy the list from the highest-numbered migration touching that constraint (00306today; AR may addar_agingin00310–00329). - W15 Ruling: The supplier’s
default_wht_code_idis validated likedefault_tax_code_idbut for usagewithholding(422field=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 Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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}. |
Created
Section titled “Created”| 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. |
Modified
Section titled “Modified”| 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; columnparty_supplier.default_wht_code_id; constraint namestax_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 indoc.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>)):
TestAWithholdingCodeMustNameAnAccount— insert usagewithholdingwith an account: OK; without:tax_code_withholding_names_account.TestDeductionsAreWrittenOnlyWhileThePaymentIsADraft— raw insert of awht_deductionfor a draft payment (e.Payment): OK; aftere.SubmittedPayment:wht_deduction_is_draft.TestARemittanceLineNeedsAnEffectiveDeductionOfItsSection— build a draftwht_remittanceby raw SQL (instance fromworkflow.Startwith kindsupplier_paymentis fine for the schema test: the guard reads only the semantic) with sections109b; a deduction whose payment is paid (raw-insert while draft, thene.PaywithNopPoster—payout_openbecomes true): OK; a deduction on a draft payment:wht_remittance_line_effective; a paid deduction of sections109: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 itscancelledsemantic: OK (a cancelled remittance frees its deductions).TestReceiptIsRecordedOnceAndOnlyAfterPayout—UPDATE wht_remittance SET receipt_noon 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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER wht_deduction_draft_guard BEFORE INSERT OR UPDATE ON wht_deduction FOR EACH ROW EXECUTE FUNCTION wht_deduction_draft_guard();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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 StatementBeginCREATE 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 StatementEndCREATE 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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER wht_remittance_line_guard BEFORE INSERT OR UPDATE ON wht_remittance_line FOR EACH ROW EXECUTE FUNCTION wht_remittance_line_guard();
-- +goose DownDROP 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.Ratedoc.go: the constants in Produces.
- Step 3: Run.
make generate(sqlc) thengo 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
EXISTSblock ofwht_remittance_line_guardin a scratch copy applied by hand (or temporarily in the file), run test 3 → it must fail at thewht_remittance_line_onceassertion; restore. Removewht_remittance_linefromamendable→retention_testnames 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 itsline_test.go),internal/platform/pettycash/spend.go(+ test) - Modify:
api/openapi.yaml(TaxCode.usage, the create-tax-code body’susageenum),web/src/features/payables/InvoiceDialog.tsx - Modify:
internal/modules/accounting/chart_handlers.goonly if it enumerates usages (read first; today it passesusagethrough)
Interfaces:
-
Produces:
money.(Amount).PercentAt(p Rate, places int32) Amount;chart.TaxUsageWithholding = "withholding"; MY account2160; MY codesW107A-10,W107A-3,W109-INT,W109-ROY,W109B,W109F; provisioning steppayables.StepKeyWithholding = "payables_withholding",StepOrderWithholding = 77;whtAccountCode = "2160". -
Step 1: Failing tests.
rate_test.go:MustParse("123.4495").PercentAt(MustParseRate("10"), 2)=12.3400(not12.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:CreateTaxCodewithUsage: "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; withoutAccountCode→ErrInvalidPack.my_test.go:require.Len(t, p.TaxCodes(), 17); the six withholding codes haveUsage == "withholding",AccountCode == "2160", andpack_kindin{s107a, s109, s109b, s109f}; the chart contains2160under2100with no control kind.- Expenses: a claim receipt line with a
salescode → accepted (presence); with a withholding code →lineErr("tax_code_id", "tax_code_usage", …). Petty cash: same throughcheckTaxCodeAt. withholding_step_test.go: a fresh org (pack-provisioned) →Checkdone 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 without2160and the six codes (thecountrypacktest-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.goshows howpurchaseTaxCodesStepwas tested; copy that approach. Assert:Checkfalse;Runcreates2160(with en/ms/zh translation rows) and six codes pointing at it;Checktrue; a secondRuncreates nothing (count codes before/after). Run → FAIL.
-
Step 2:
PercentAt. Inrate.gobesidePercent:
// 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: addTaxUsageWithholding = "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”;validateTaxCodeaccepts it and returnsfmt.Errorf("%w: a withholding code must name an account", ErrInvalidTaxCode)whenAccountID == nil; its usage error sentence lists four values. Do not write “LHDN” in this package.countrypack/validate.go: theswitch tc.Usageacceptschart.TaxUsageWithholding, and a withholding seed withoutAccountCodeisErrInvalidPack(“tax code %q is a withholding code and must name an account”). -
Step 4: MY pack.
my.go— add after2150:
{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— thetradePayablesStep+purchaseTaxCodesSteppair 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.usageenum and the create body’susageenum gainwithholding;TaxCodedescription ofpack_kindunchanged.make generate.InvoiceDialog.tsxline 328:.filter((c) => c.active && (c.usage === 'purchase' || c.usage === 'both'))with the comment// P40; WHT D1: never a withholding code. Runpnpm -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’sDivRound(…, places)withPercent(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.
Task 3: Supplier payments withhold tax
Section titled “Task 3: Supplier payments withhold tax”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.AmountSupplier.DefaultWHTCodeID,SupplierInput.DefaultWHTCodeID *uuid.UUIDfunc withholdingDueOn(paid time.Time) time.Timefunc 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), suppliere.VerifiedSupplier(t, "SGSVC"), invoice 1,000.00 on 6170, posted):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.TestAPaymentWithholdsAndPaysTheNet—PaymentWithWHT(amount "900.00", wht {W109B, taxable "1000.00"}, line 1000.00)thenPay: the invoice outstanding is 0;p.WithholdingAmount=100.0000; deductionSection "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 withPartyID= supplier andTaxCodeID= W109B on the 2160 line.TestTheAllocationsMustEqualTheNetPlusTheTaxWithheld— presence: 900 + 100 = 1000 accepted (test 2);amount "900.01"→ErrAllocationTotal;amount "1000.00"with the WHT line →ErrAllocationTotal.TestAWithholdingLineMustBeAnInForceWithholdingCodeOnce— presence W109B accepted; aPST10(purchase) code →FieldError{Field: "withholding[0].tax_code_id", Rule: "wht_code"}; a withholding code created withValidFromafter Today →wht_code; the same code twice →wht_once; taxable1000.01(> settled) →wht_taxable; taxable0.04at 10% (withholds 0.00) →wht_taxable.TestS107AWithholdsTwoCodesOnTheServicePortion— taxable 600.00 on bothW107A-10andW107A-3against a 1,000.00 invoice: withheld 60.00 + 18.00, amount 922.00; entry has two 2160 credit lines.TestAForeignPaymentWithholdsInBase— USD invoice 1,000 at 4.40; payment USD at 4.47, W109B taxable 1,000:Amount 100.0000USD,BaseAmount 447.0000(= round2(4470 × 10%)); entry balanced (the fx line is ledger.Post’s).TestAPaymentWithoutWithholdingIsUnchanged— the existingpayment_test.gosuite passes untouched (run it).supplier_test.go: a supplier withDefaultWHTCodeID= 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.PaymentgainsWithholding []DeductionandWithholdingAmount money.Amount;getPaymentfills them fromdeductionsOf(…, false).PaymentInputgainsWithholding []WithholdingInput. ExtractcheckBankAccount(the existingchart.GetAccountblock, sameErrBankAccount).checkPaymentnow 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, nilErrAllocationTotal’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.goallocatePayment. Final check:if total.Cmp(p.Amount.Add(p.WithholdingAmount)) != 0 { return ErrAllocationTotal }. -
Step 5:
posting.goPaymentPayoutRule.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_idin the select/insert/update column lists;SupplierInput.DefaultWHTCodeID; validation mirrorsDefaultTaxCodeID’s but requiresusage == withholding(W15). -
Step 7:
payablestest/env.go.WHTCode(chart.ListTaxCodes→ by code),PaymentWithWHT(thePaymenthelper withWithholding: wht;Paymentitself calls it withnil),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 — readControlBalanceByPartyfor 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
whtLinesappend → test 2 fails withledger.ErrUnbalanced(MYR) — read it; restore. ChangewithholdingDueOntopaid.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.Graphregistered forKindRemittance.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 }andDeductionFilter{Section, Status string; PaymentID *uuid.UUID; IDs []uuid.UUID}(PaymentIDfor the payment view,IDsforcheckRemittance)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{}inRules();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: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;WHTBalance0.TestOnlyEffectiveDeductionsOfTheSectionCanBeRemitted— a draft payment’s deduction →ErrWHTNotRemittable(Go pre-check); a paids109deduction in ans109bremittance →ErrWHTSectionMismatch; the same paid deduction in a second draft remittance →ErrWHTNotRemittable— each after the accepted case.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.TestVoidingARemittanceFreesItsDeductions— remit, void: reversal entry mirrors; the deduction isopenagain; a new remittance takes it and pays.TestTheReceiptNumberIsRecordedOnceAfterPayment— before payout →ErrReceiptBeforePayout; after → OK, audited; again →ErrReceiptRecorded.TestThePreparerCannotApproveTheirOwnRemittance— staff submits, staff approving →workflow.RefusedErrorcodeself_approval(grant staff the owner seat withGrantOwnerSeatTofirst so the refusal is the self-approval rule, not a missing grant — presence: the owner approving succeeds).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: registerworkflow.RegisterSeededGraph(KindRemittance, RemittanceGraph);RemittanceGraphisPaymentGraph’s body withKind: KindRemittanceand edge 2ed(2, "pending_approval", "paid", workflow.GateTarget, "approve_remittance", ls("Approve and pay", "批准并付款", "Luluskan dan bayar")); thepaidlabells("Paid", "已缴付", "Telah dibayar"). (A sharedcashGraph(kind, approveKey, approveLabels, …)helper is allowed if it makes both shorter; keepPaymentGraph’s output identical —graphs_test.gopins it.) -
Step 3:
remittance.go. Thepayment.goshape. 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 500then 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:
RemittancePayoutRuleinposting.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.goTransitionPayment, caseCancelled || 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 therefuseIfRemittedcall → 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.gocase),internal/modules/reports/{routes.go,errors.go},api/openapi.yaml(/reports/wht-register, the exportreportenum)
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§ion→{rows: [...], totals: [{section, currency, amount, base_amount}]}underreports.read. -
Step 1: Failing tests (
register_test.go): one open deduction paid today (not late), one remitted on time, one remitted afterdue_on(advance the clock withe.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 onlys109b. Rows outside[from, to]are excluded (a payment dated the day afterto). Export:export_test.gogains awht_registercase 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 — withpa.tin,pa.country_code, the payment’s allocated invoices (array_agg(DISTINCT pi2.reference)fromap_allocation a JOIN purchase_invoice pi2 … WHERE a.payment_id = p.id AND a.reverses_id IS NULL),credited_on(min(je.entry_date)fromjournal_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'— read00052for the exact column names), the remittance’spayment_dateandreceipt_no. Filterp.payment_date BETWEEN from AND to, status ≠planned, section. ComputeLateandCreditedEarlierin Go (Ruling W12) withwithholdingDueOn. -
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”;projectWHTRegisterlandscape 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 DownALTER TABLE report_export DROP CONSTRAINT report_export_report_known, ADD CONSTRAINT report_export_report_known CHECK (report IN ('trial_balance','profit_and_loss','balance_sheet','general_ledger','ap_aging'));(If the branch has another report by then — e.g. ar_aging — include it in both lists.)
- Step 4: JSON route.
wht_handlers.goininternal/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). Routehttpx.Protected(g, p, "GET", "/reports/wht-register", read, h.getWHTRegister);errors.go’s report list sentence gainswht_register. OpenAPI: path +WhtRegisterRow/WhtRegisterTotalschemas (money asMoney, rate asRate,statusenum[open, in_remittance, remitted]); exportreportenum gainswht_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
Latefor 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.gopins the seven routes’ permissions.wht_handlers_test.go(theinvoice_handlers_test.goenv): create a remittance over HTTP → 201 withETag; a staleIf-Match→ 409revision_mismatch; a section mismatch → 422wht_section_mismatch; a void of a payment whose deduction is in a draft remittance → 409wht_in_remittance; receipt twice → 409receipt_already_recorded.payment_handlers_test.go: a POST withwithholdingreturnswithholding_amountandwithholding[];withholding[0].tax_code_idof a purchase code → 422 detailfield=withholding[0].tax_code_id rule=wht_code: ….supplier_handlers_test.go:default_wht_code_idround-trips. A migration test (themigration00305_test.gopattern) for00342: an Owner and a system Finance role gain the three keys;perm_versionbumped. Run → FAIL. - Step 2: Keys.
constants.yamlgains the three keys afterpayables.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 toprovisioning.RegisterRolePermissions("Finance", …).00342copies00305’s two INSERTs (Owner: three keys; Finance: three keys), theperm_versionbump, and a Down that deletespayables.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 thePayables*responses the payment paths use.make generate. - Step 4: Handlers.
payment_handlers.go:paymentBody.Withholding []whtInputDTO→in.Withholding;paymentDTO.WithholdingAmount;paymentViewDTO.Withholding []whtDeductionDTOfromListDeductions(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: thepayment_handlers.goshape (remittanceTx,remittanceView,setETag,If-Matchvia 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 statusshows only intended + generated files. - Step 6: Sabotage. Register
POST /payables/wht/remittanceswithPermissionWHTRead→routes_test.gofails 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,subtractMoneyfrom@/lib/money;/accounting/tax-codes. -
Produces:
useWithholdingCodes()inapi.ts(tax codes filteredactive && usage === 'withholding');<WithholdingFields rows onChange codes gross currency />. -
Step 1: Failing tests.
PaymentsPage.test.tsx(MSW): picking a supplier whosedefault_wht_code_idis 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 carriesamount: "900.0000"-equivalent (sumMoneyform) andwithholding: [{tax_code_id, taxable_amount: "1000.00"}]; removing the row sendswithholding: []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 sendsdefault_wht_code_id. Runpnpm -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). InPaymentDialog:const withheld = sumMoney(rows.map(r => percentOfMoney(r.taxable_amount, rateOf(r.tax_code_id))));amount = subtractMoney(gross, withheld)wheregross = sumMoney(lines.map(l => l.amount)); when the supplier changes and has a default, setrows = [{tax_code_id: default, taxable_amount: gross}]and keep taxable following gross until the clerk edits it (atouchedflag). Show Settled (gross), Tax withheld, Paid to supplier.PaymentDrawer: a Tax withheld section listingview.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 testandpnpm -C web typecheck(ortsc -b, whicheverpackage.jsondefines) → 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})inapi.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 tabwht_register. -
Step 1: Failing tests.
Remittances.test.tsx: the list (DataTabletableId="payables.remittances") shows reference, section label, payment date, amount, state chip; New remittance: choosing sections109blists only opens109bdeductions (MSW asserts the querysection=s109b&status=open), ticking two shows their base total, Submit POSTs then transitions viauseRemittanceTransition; the drawer atpaidshows 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 offerswht_registerwith the from/to params. Run → FAIL. -
Step 2: Build following
PaymentsPage/PaymentDialog/PaymentDrawer(workflow chip, timeline, transition bar fromavailable_transitions). Section labels come frompayables:wht.sections.<pack_kind>with a fallback to the raw key (an org-created section shows as itself).ReportsPage:wht_registerjoins the from/to reports (not theas_ofpair);routes.tsx’s enum gains it;WHTRegisterViewusesGET /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,ensureFinanceAPfromweb/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.tsandweb/playwright.config.tsreadERP_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.tsFINANCEgains'payables.wht.read', 'payables.wht.create', 'payables.wht.transition';support.tsensureFinanceWHT()=ensureFinanceAP’s body with those keys (call both inbeforeAll). -
Step 3: The e2e
withholding.spec.ts, followingpayables.spec.tsstep by step:- Finance creates supplier
E2E…with country Singapore and default withholding WHT s109B services and technical fees 10%; enters bank details. 2. Owner verifies them. - Finance enters an invoice (6170 Professional fees, net 1,000.00, PNT no SST), submits; owner approves and posts.
- 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.
- 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>. - Reports → Withholding register for today: the row for the supplier shows Remitted, 100.00, the receipt number.
- Finance creates supplier
-
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, startbash scripts/dev-stack.shin the background (it seeds the demo org on first run in this worktree’s.dev-stack/), thenpnpm -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/provisionas owner) to gainwht_remittance, 2160 and the six codes. Expected: PASS. Afterwardsbash scripts/dev-stack.sh downwith the same env (removes onlyerp-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 / CP38with:
- [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 WHTsection: migrations00340–00342(00340widenstax_code_usage_known— safe, additive); re-provision every organization (POST /v1/org/provision) so it gains account2160, the six withholding codes (steppayables_withholding, order 77) and thewht_remittanceworkflow kind; existing Owner/Finance roles gainpayables.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".
Self-Review
Section titled “Self-Review”| 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.