Skip to content

Withholding tax — deduction, remittance, CP37 listing (Phase 2): design

Status: decided under the user’s “complete phase 2” goal (2026-09-27); decisions D1–D14 below. Compliance-sensitive choices are repeated, one line each, in For the owner to confirm. Roadmap: Phase 2, “WHT, CP37 / CP38”. Architecture: §19 Phase 2 table (“Withholding on applicable payments, CP37/CP38 support”), §11 (country pack; nothing outside countrypack names Malaysian tax), §10.3 (posting engine), §5.5 (semantics), §11A (retention, record language, traceability). Builds on: AP (docs/superpowers/specs/2026-09-26-accounts-payable-design.md, internal/platform/payables, migrations 00300–00306). Migrations: 00340–00349 (this design uses 00340–00342).

Not reviewed by a Malaysian tax agent. Every rate, section, form letter and deadline below is written from general knowledge of the Income Tax Act 1967 (ITA) and LHDN practice, not verified against the Act, the public rulings or LHDN’s current forms. It is seeded as editable data, never as logic, precisely so a tax agent can correct it without a code change. Re-verify before a paying customer relies on it.

What the law asks (as understood; see For the owner to confirm)

Section titled “What the law asks (as understood; see For the owner to confirm)”

A Malaysian payer who pays or credits certain kinds of income to a non-resident must deduct tax from the payment and remit it to LHDN within one month of paying or crediting, with the form for that section. If it does not, the unpaid amount is increased by 10% and the payer’s own deduction for the expense can be disallowed (ITA s.39).

Section What Rate (as understood) Form (as understood)
s.107A Contract payment to a non-resident contractor (service portion) 10% on account of the contractor + 3% on account of its employees CP37A
s.109 Interest; royalties 15% interest; 10% royalty CP37
s.109B Services performed in Malaysia, technical advice, rent of movable property 10% CP37D
s.109F Other income under s.4(f) 10% CP37F

Tax treaties (DTAs) can reduce a rate when the payee proves residence elsewhere.

CP38 is a different thing: an LHDN direction to an employer to deduct additional tax from a named employee’s salary in instalments. It is a payroll deduction. This system has no payroll until Phase 5.

In (v1):

  • Withholding tax codes as date-effective tax-code rows seeded by the MY country pack (six codes over four sections) and a seeded Withholding tax payable account 2160.
  • A supplier may carry a default withholding code; the payment form proposes it.
  • A supplier payment deducts one or more withholding amounts: the payment settles the invoices in full, the supplier receives the net, and the tax withheld is credited to Withholding tax payable in the same journal entry.
  • Each deduction records its taxable amount, rate, section, amounts (payment currency and base), the date paid and its due date (one month after).
  • A withholding remittance document (its own workflow kind, preparer ≠ approver) pays the deductions of one section to LHDN from a bank account: Dr Withholding tax payable / Cr bank, with the LHDN receipt number recorded once paid.
  • A withholding register report (JSON + CSV/XLSX/PDF through the existing export pipeline): every deduction in a date range with supplier, TIN, country, payment, section, rate, amounts, due date, remittance and status (open, in remittance, remitted, late) — the listing a clerk transcribes into or attaches to the CP37 form of that section.
  • Web: WHT on the payment form and drawer, the supplier default, a remittances screen, the register tab under Reports. en/ms/zh.

Out (each named, with where it goes):

  • CP38 — deferred to Phase 5 payroll (D13). The roadmap line is ticked for WHT/CP37 and CP38 moves to Phase 5 with a note.
  • s.107D (2% on payments to resident agents, dealers, distributors; form CP107D) — a resident-payee regime with its own form and a threshold; add as seed data plus a form later if asked.
  • s.109A (public entertainers) and s.109E-type items — the owner can add codes through the existing POST /accounting/tax-codes; no seed in v1.
  • Gross-up (contract says the supplier receives the full amount; the payer bears the tax) — a later option on the payment; v1 always deducts from the payment.
  • Filing CP37 electronically (e-CP37 / MyTax) — v1 produces the listing; submission is manual.
  • The 10% late-payment increase as a posted amount — the register flags lateness; the increase is imposed by LHDN’s notice and paid as an ordinary expense (D12).
  • Refund / recovery of over-withheld tax, WHT on credit notes, and invoice-time (accrual) withholding — see D3.
  • s.107A notification of the contract to LHDN — not a payment; out.
  • Self-billed e-Invoice for imported services — the self-billed item; it does not interact with WHT.

D1. Withholding codes are tax codes with usage = 'withholding'

Section titled “D1. Withholding codes are tax codes with usage = 'withholding'”

tax_code (00050) is already the date-effective, per-organization, pack-seeded rate table: rate is immutable (a new rate is a new code, trigger tax_code_immutable), valid_from/valid_to bound when it applies, names are translation rows, and POST /accounting/tax-codes lets an administrator add one (a DTA-reduced rate, s.109A). usage (00303) gains a fourth value withholding; a withholding code must name an account (tax_code_withholding_names_account). pack_kind carries the section (s107a, s109, s109b, s109f), which is the grouping key for remittances and the register. Payables code treats pack_kind as an opaque string compared for equality; the words “LHDN” and “CP37” appear only in pack data, translations and docs.

Why not the registry primitive: registry rows are global-plus-org lookups without rates or validity windows; tax rates are per-org copies with a validity window, which tax_code already is. Why not a new withholding_code table: it would duplicate tax_code’s immutability, validity, translations and create API for no behavioral difference.

Consequence: other tax-code consumers must refuse a withholding code. Purchase invoice lines already accept only purchase/both (P40). Expense claim lines (expenses/line.go) and petty-cash spends (pettycash.checkTaxCodeAt) accept any code today; both gain one refusal (tax_code_usage) for withholding. The web invoice line picker changes from usage !== 'sales' to usage === 'purchase' || usage === 'both'.

D2. The MY pack seeds account 2160 and six codes

Section titled “D2. The MY pack seeds account 2160 and six codes”

2160 “Withholding tax payable” / “Cukai pegangan belum bayar” / “应付预扣税”, liability, under 2100, no control kind (D10). Codes (all usage: withholding, AccountCode: 2160, ValidFrom: 2020-01-01 as a floor — not a claim about when each rate began):

Code pack_kind Rate
W107A-10 s107a 10
W107A-3 s107a 3
W109-INT s109 15
W109-ROY s109 10
W109B s109b 10
W109F s109f 10

New organizations get them from the pack (steps 70, 72). Existing organizations get the account and any missing code from a new provisioning step payables_withholding (order 77, the payables_purchase_tax_codes pattern) on their next POST /v1/org/provision.

D3. Tax is withheld when the supplier is paid, not when the invoice is posted

Section titled “D3. Tax is withheld when the supplier is paid, not when the invoice is posted”

The deduction is part of a supplier_payment. The alternative — withholding at invoice accrual (Cr Trade payables net / Cr WHT payable) — changes AP’s outstanding basis (every use of gross_amount in the allocation guard, aging, the sub-ledger test and void rules) and forces WHT on credit notes. Withholding at payment leaves AP untouched: invoices are still settled at gross, the sub-ledger test still holds, and a credit note simply reduces what is paid.

The legal risk this carries (owner to confirm): the Act speaks of “paying or crediting”. If LHDN treats posting the supplier’s invoice to Trade payables as “crediting”, the one-month clock starts at the invoice’s posting, not at payment, and a payment made more than a month after the invoice posts is already late. v1 mitigates, not solves: the register shows each deduction’s invoices’ posting dates and flags a deduction whose earliest allocated invoice was posted more than one month before the payment (credited_earlier). The full fix is D3’s alternative.

D4. A payment settles gross; the supplier receives the net

Section titled “D4. A payment settles gross; the supplier receives the net”

PaymentInput gains Withholding []WithholdingInput{TaxCodeID, TaxableAmount} (zero or more).

  • Each code: active, usage = withholding, in force on payment_date (valid_from ≤ date < valid_to), at most once per payment.
  • TaxableAmount > 0 and ≤ Σ allocations (the gross being settled). It defaults on the web to Σ allocations; the clerk narrows it for s.107A’s service portion.
  • amount_withheld = taxable.PercentAt(rate, 2); base_withheld = taxable.MulRatePercentAt(fx_rate, rate, 2) — computed from the RM taxable, because LHDN is paid in RM. Both round ONCE, half away from zero, to 2 decimals: PercentAt (Ruling W2, kernel/money) is a × p / 100 rounded once; MulRatePercentAt is its fx-converted twin, a × fx × p / 100 rounded once the same way. The plan originally computed the base as taxable.MulRate(fx).PercentAt(rate, 2), which rounds the fx-converted amount to 4dp before taking the percentage — a second rounding that double-rounds (USD 1003.57 × 4.4071 at 3%: 132.68 instead of 132.69). Task 3’s fix round 1 added MulRatePercentAt as the single-rounding replacement. Computed, never typed.
  • The total rule changes from “allocations = amount” to “allocations = amount + Σ amount_withheld” (ErrAllocationTotal’s sentence says so). A payment without withholding is unchanged.
  • The supplier’s default_wht_code_id (new, nullable) pre-fills one line on the web; nothing forces it.

D5. Deductions are rows of the payment, effective at payout

Section titled “D5. Deductions are rows of the payment, effective at payout”

wht_deduction (org_id, id, payment_id, tax_code_id, section, rate, taxable_amount, amount, base_amount, currency, due_on, withdrawn_at, created_at) — section and rate are snapshots of the code. Written with the payment’s lines while a draft (a save withdraws the old rows, the supplier_payment_line pattern; a DB guard refuses a write once the payment is not a draft). Effective = not withdrawn and the payment’s workflow instance has payout_open — so a deduction becomes real exactly when the payment posts, and stops being real exactly when the payment’s posting is reversed. Nothing is copied or deleted.

D6. The payout entry credits Withholding tax payable

Section titled “D6. The payout entry credits Withholding tax payable”

PaymentPayoutRule gains one credit line per effective deduction: account = the code’s account_id, Credit = amount, Base = base_amount, TaxCodeID = the code, PartyID = the supplier (so the register and an auditor can walk from the tax line to the payee), memo SP-… WHT <code>. The rule’s total check becomes Σ allocations = amount + Σ withheld. Realised FX is still the fx_difference line ledger.Post adds when the frozen bases differ. Voiding the payment is the engine’s mirror, which reverses the WHT lines with it.

D7. Due date: the day before the same date next month (unverified, stricter reading)

Section titled “D7. Due date: the day before the same date next month (unverified, stricter reading)”

due_on = the day before the same date next month, that date clamped to the month’s last day (15 Jan → 14 Feb; 31 Jan → 27 Feb, 28 Feb in a leap year), frozen on the deduction row. The rule is one function (withholdingDueOn) with the statute cited in its comment; not a rate, so not seed data. Unverified, stricter reading (owner confirmations 2026-09-29, D3 X6): the owner chose the day before over the same date next month, since paying a day early costs nothing and paying late costs a 10% increase, until a tax agent confirms. It affects only deductions saved after the change; the register’s late flag reads the frozen due_on.

wht_remittance, cash_only, the payment graph’s shape: draft → pending_approval → paid (payout) → voided, rejected → draft, cancelled from draft. Owner seat granted on paid and voided; the preparer cannot approve (the engine’s self_approval).

  • Header: reference (WR-<Y>-NNNN), section, bank_account_id (an active postable bank control account whose currency is null or base), payment_date, bank_reference, amount (base, = Σ its deductions’ base_amount, computed server-side), receipt_no (nullable).
  • Lines: wht_remittance_line (remittance_id, deduction_id, withdrawn_at). A deduction can be on at most one live line (not withdrawn, remittance not in a cancelled-semantic state); all lines share the header’s section; only effective deductions can be added. A DB trigger enforces all three under a row lock on the deduction.
  • One section per remittance because each section is a separate LHDN form; any number of suppliers.
  • Payout rule: Dr each deduction’s account (grouped by account, base), Cr bank; currency = base; journal = the bank account’s journal (journalFor). Void mirrors it and frees the deductions (the lines are no longer live).
  • receipt_no — LHDN’s receipt/acknowledgement number — is set once, after paid, by PUT …/receipt; a trigger allows only NULL → value while the remittance is at its payout state.

D9. A payment cannot be voided while its tax is in a remittance

Section titled “D9. A payment cannot be voided while its tax is in a remittance”

TransitionPayment on a reversing edge locks the payment’s deductions and refuses (409 wht_in_remittance) if any sits on a live remittance line. Withdraw or void the remittance first — the P24 pattern. Lock order: payment → its deductions (id order); remittance → its deductions (id order); the line trigger locks the one deduction it inserts. No cycle.

D10. No new control kind; a drift test holds the balance

Section titled “D10. No new control kind; a drift test holds the balance”

2160 is an ordinary liability named by the codes’ account_id. A new control kind would buy the “module-owned, no manual lines” refusal but costs a vocabulary value, both CHECKs, chart fixtures and UI lists across the codebase. Instead a drift test asserts, after a mixed sequence, that the base balance of every account a withholding code names equals Σ base_amount of effective deductions not on a paid remittance. A manual journal to 2160 remains possible (as to 2140 today) and is the accountant’s to explain. Owner to confirm. Superseded by Phase 2 completion O-7: 2160 carries the wht_payable control kind (and 2140 sst_payable), so manual journals and banking’s journal-from-line refuse both.

D11. The register is a report; the CP37 listing is the register filtered by section

Section titled “D11. The register is a report; the CP37 listing is the register filtered by section”

payables.WithholdingRegister(ctx, tx, from, to, section) — rows by payment_date in [from, to], ordered section, due_on, reference. Columns: section, code, rate, supplier code/name, supplier TIN, supplier country, payment reference, payment date, invoice references and their earliest posting date, taxable amount, currency, withheld (currency), withheld (base), due on, status, remittance reference, remitted on, receipt no, late (remitted after, or open past, due_on), credited_earlier (D3). Status ∈ open | in_remittance | remitted (voided payments are excluded). JSON at GET /reports/wht-register (reports.read); export report = 'wht_register' (reports.export, params from, to), the ap_aging pattern.

late is computed; the 10% increase and any compound penalty are LHDN’s to assess and arrive by notice. The increase is paid as an ordinary purchase or manual journal to a non-deductible expense account. The register’s column heading says “Late (increase may apply)”. The rate is not stored, because nothing computes with it.

D13. CP38 is deferred to payroll (Phase 5)

Section titled “D13. CP38 is deferred to payroll (Phase 5)”

A CP38 direction is a monthly deduction from one employee’s salary, remitted with that month’s PCB (the payroll remittance). Without payroll, this system never computes a salary, so it cannot deduct; a register of CP38 notices here would be a second copy of what the organization’s payroll software already holds (that software must process CP38 today to produce its PCB file) and would drift from it. v1 builds nothing for CP38. The roadmap’s Phase 5 payroll line gains “CP38 directions (from the Phase 2 WHT item)”; the Phase 2 line is ticked with the note “CP38 → Phase 5”. Owner to confirm.

payables.wht.read (deductions list, remittances), payables.wht.create (prepare and edit a draft remittance, record the receipt number), payables.wht.transition (the transition endpoint; authority still from grants). Owner and Finance: all three, for new organizations through provisioning.RegisterRolePermissions and for existing ones through migration 00342 (the 00305 pattern). The register reuses reports.read / reports.export (AP P4). Payment WHT fields ride on the existing payables.payment.* keys; the supplier default on payables.supplier.manage.

  • tax_code: tax_code_usage_known adds withholding; new tax_code_withholding_names_account CHECK (usage <> 'withholding' OR account_id IS NOT NULL).
  • party_supplier.default_wht_code_id UUID NULL + composite FK to tax_code.
  • wht_deduction, wht_remittance, wht_remittance_line — org_id, ENABLE + FORCE RLS, org_isolation, composite FKs, named CHECKs, REVOKE DELETE (amendable, seven years).
  • Triggers: wht_deduction_draft_guard (insert/update only while the payment is a draft), wht_remittance_draft_guard (header fields), wht_remittance_line_guard (draft, effective, same section, once), wht_remittance_receipt_guard (NULL → value at payout only).

Migration 00341: report_export_report_known gains wht_register. Migration 00342: Owner and Finance grants.

  • SupplierPaymentInput.withholding[] {tax_code_id, taxable_amount}; SupplierPayment.withholding_amount; SupplierPaymentView.withholding[] (WhtDeduction).
  • Supplier/SupplierInput.default_wht_code_id (nullable).
  • TaxCode.usage enum gains withholding.
  • GET /payables/wht/deductions?section&status (≤ 500, by due date).
  • GET|POST /payables/wht/remittances, GET|PUT /payables/wht/remittances/{id}, POST /payables/wht/remittances/{id}/transitions, PUT /payables/wht/remittances/{id}/receipt.
  • GET /reports/wht-register?from&to&section; export wht_register.

Error codes: 422 field=withholding[i].… rule=wht_code|wht_taxable|wht_once, 422 allocation total, 409 wht_in_remittance, 422 wht_section_mismatch, 409 wht_already_remitted, 409 receipt_already_recorded.

  • Payment dialog: a Tax withheld section — rows of code + taxable, computed withheld, and the net “Paid to supplier”; pre-filled from the supplier’s default code. Drawer shows the deductions.
  • Supplier dialog: Default withholding tax picker (codes of usage withholding).
  • Withholding tax screen (/payables/wht, nav item under Accounting): remittances list, “New remittance” dialog (section → open deductions with checkboxes → total), drawer with transitions, deductions and “Record LHDN receipt”.
  • Reports: Withholding register tab (from/to, section) with export.
  • Schema guards (raw SQL as erp_app): deduction write refused after draft; remittance line refused for a non-effective deduction, a different section, a second live line — each after the accepted case.
  • A withholding code on an expense claim line and a petty-cash spend is refused (presence: a sales code is accepted).
  • Payment: allocations = amount + withheld accepted; off by 0.01 refused; out-of-force code, wrong usage, twice, taxable > allocations refused.
  • Posting: MYR and USD payments with WHT post balanced; the WHT line’s base is round2(taxable × rate × rate%); TaxCodeID/PartyID set.
  • Void: payment void refused while its deduction is in a draft remittance; accepted after the remittance is withdrawn.
  • Remittance: posts Dr 2160 / Cr bank; its void frees the deduction for another remittance.
  • Drift: 2160’s base balance = Σ effective, unremitted deductions after a mixed sequence (watched failing by posting the WHT line to the wrong account).
  • Due date: 15 Jan → 15 Feb, 31 Jan → 28 Feb (29 in a leap year), 31 Mar → 30 Apr.
  • Register: status, late, credited_earlier, section filter; export renders.
  • Provisioning: an organization without 2160 gains it and the six codes; a re-run is a no-op.
  • e2e: non-resident supplier with default W109B → invoice RM 1,000 → approve → payment (withheld 100, paid 900) → approve → remittance → approve → register shows Remitted.
  • Payment batches: a batch that pays supplier payments must pay supplier_payment.amount (the net) and must not bypass checkPayment; if batches create payments themselves they take Withholding too.
  • SST-02: reads tax codes of usage sales/purchase/both; withholding lines on 2160 carry a tax_code_id of usage withholding and must be excluded by usage, not by account.
  • AR and others editing report_export_report_known: 00341 must re-add the constraint with the union of every report on the branch at build time.
  • Provisioning step order 77 is assumed free; re-number at merge if another item took it.
  1. Rates and sections — seeded 107A 10%+3%, 109 interest 15% / royalty 10%, 109B 10%, 109F 10%, from memory not the Act; alternative is none (they must be checked by a tax agent before use; they are editable data, so correcting them costs no code). Needs outside authority: a tax agent confirms each code against ITA 1967 and the current LHDN WHT pages (and any 2025+ budget changes). Meanwhile the codes stay editable, and the register keeps a “rates unverified” note.
  2. Form letters — shown as CP37 (s.109), CP37A (s.107A), CP37D (s.109B), CP37F (s.109F) in labels only; if wrong, only translations change. Needs outside authority: the same tax-agent pass as item 1; only translations change if wrong.
  3. “Paid or credited” timing — v1 withholds at payment and flags credited_earlier; if LHDN counts invoice posting as crediting, the alternative (withhold at invoice accrual) costs a rework of AP’s outstanding basis and credit-note WHT. Needs outside authority: the tax agent’s written reading of “credited” (LHDN treats crediting to the payee’s account in the books as crediting). Until then keep the flag visible on the register and in the payment drawer. If accrual timing is confirmed, it is an M–L rework.
  4. Deadline arithmetic — decided 2026-09-29 (D3 X6): the stricter reading, the day before the same date next month (clamped). Still unverified until a tax agent confirms. Changed (2026-09-29, D3): paying a day early costs nothing, while late payment carries a 10% increase, so the stricter reading is now the enforced due date, until a tax agent confirms otherwise.
  5. Rounding — withheld amounts rounded half-up to the sen per deduction, base computed from the RM taxable; LHDN may compute on the RM total per form instead (a per-remittance rounding difference of a few sen). Confirmed (2026-09-29): CP37 is per payment; per-deduction rounding matches the payment-level form.
  6. Taxable base — v1 lets the clerk enter it (default: the gross settled, including any SST on the invoice); whether SST or reimbursements are excluded from the base is for the tax agent. Needs outside authority: the tax agent rules whether SST and reimbursements are excluded from the base. Keep the field editable and show the SST portion beside it.
  7. Late-payment increase — 10% flagged not posted (D12); alternative is a penalty line on the remittance, which needs the rate as data and an expense account choice. Confirmed (2026-09-29): the increase is LHDN’s assessment; post it when a notice arrives (a remittance line or journal).
  8. CP38 — deferred to Phase 5 payroll (D13); alternative (a notice register now) costs a table, screens and a second copy of payroll data that will drift. Confirmed (2026-09-29): as designed.
  9. DTA reduced rates — handled by the administrator creating an org-specific code; the certificate of residence is not tracked or required by the system. Changed (2026-09-29, D3): any withholding code whose rate is below the section’s standard pack rate now requires a treaty reference, as a 0% code already must. COR upload per supplier stays out.
  10. No control kind for 2160 (D10) — manual journals to it are possible; the alternative (wht_payable control kind, sub-ledger only) costs vocabulary and chart-fixture churn. Ruled: O-7 — 2160 is the wht_payable control kind; manual journals to it are refused.
  11. Base currency — assumes an MY organization’s base currency is MYR (LHDN is paid in RM); a non-MYR base would post base amounts that are not RM. Changed (2026-09-29, D3): now refused, not only assumed — a withholding deduction or remittance is refused when base currency is not MYR (X5, shared with the SST-02 change).
  12. Approval thresholds — a payment’s workflow amount stays the cash paid (net), not the gross settled; approvers see the gross in the drawer. Changed (2026-09-29, D2): the approver authorises settling the whole obligation (cash to the supplier plus tax to LHDN); the workflow amount is now the gross settled (net + withheld), not the net cash (X8).