Accounts payable (Phase 2): design
Status: approved in chat 2026-09-26 (decisions D1–D12 below).
Roadmap: Phase 2, “AP: supplier role, purchase invoices, credit notes, aging, payment allocation”.
Architecture: §18.2 item 3 (party model), §19 Phase 2 table, §5 (workflow semantics), §10 (ledger).
Branch: phase-2-ap. Migrations: 00300–00339 (Plan 11 190–209, Plan 12 210–229,
operator console 230–249, Figma batch 2 250–289 are taken).
In: supplier master, purchase invoices, supplier credit notes, supplier payments recorded manually, allocation of payments and credit notes to invoices, supplier balances, AP aging, the web screens for all of it.
Out, each its own roadmap item: payment batches and bank files, MyInvois submission, self-billed e-Invoice, SST-02 return, WHT, bank reconciliation, the PR → PO → GRN chain (Phase 4), AR.
Decisions
Section titled “Decisions”D1. Supplier is a role on party
Section titled “D1. Supplier is a role on party”party (00009) today has kind IN ('employee') and legal_name. Extend it, do not add a second
party table:
partygains the fields every role shares:tin,registration_no(SSM / BRN),country_code,address(structured: line1, line2, city, postcode, state, country),email,phone. Record-bearing text keeps 00024’s script CHECK.party.kindstays for back-compatibility of the employee path, but roles move to role tables:party_supplier (org_id, party_id PK, code, payment_terms_days, currency, default_expense_account_id, default_tax_code_id, bank fields (D10), is_active, …). A laterparty_customeris the AR twin. One party may hold several roles.kindwidens to('employee','organization','individual')— what the party is, not what it does.- Supplier
codeunique per org. TIN checked withtaxrecord’s regex when present.
D2. Supplier payables get their own control account
Section titled “D2. Supplier payables get their own control account”New control_kind value ap_trade (liability), alongside the existing ap that expense claims use.
MFRS presentation separates trade payables from other payables, so auditors expect separate
accounts. Seed/onboarding creates a “Trade payables” account; constants.yaml and chart_test
move with it. Every journal line on ap_trade carries the supplier’s party_id; a drift test asserts
the sum of open items per supplier equals the control account’s balance per party.
D3. Purchase invoice: a workflow kind in a new payables module
Section titled “D3. Purchase invoice: a workflow kind in a new payables module”- New module
internal/modules/payables(+internal/platform/payablesif the pattern calls for it), workflow kindpurchase_invoice. Header: supplier, supplier invoice number, invoice date, due date (defaults to date + supplier terms), currency, fx rate, document basis + e-Invoice identifiers (D7), attachments viadocuments, internal number fromnumbering(scopepurchase_invoice, period = fiscal year). - Lines: description, expense account, tax code, net amount, tax amount, analytic distribution (projects — same shape as claim lines, so approval-by-project works unchanged).
- Seeded graph:
draft → pending_review → approved (accrual, "Posted"), plusrejected→draft(revise) andcancelledfrom draft. No payout state: paying is D4. The workflow validator gains a rule for accrual-only kinds (exactly one accrual state, no payout state), the way purchase requests addCommitmentKinds. - Approvers come from grants, like claims (a submitter never picks their approver).
- Accrual posting rule (registered with
accounting/, purchase journal): Dr each line’s expense account (net, plus tax per D8), Dr tax account (D8), Crap_trade(gross) withparty_id.
D4. Supplier payment: its own document and workflow kind
Section titled “D4. Supplier payment: its own document and workflow kind”- Kind
supplier_payment: supplier, bank account (abankcontrol account), payment date, reference, currency, fx rate, amount, and its allocations (D6) to that supplier’s posted invoices. - Graph:
draft → pending_approval → paid (payout),rejected → draft,cancelledfrom draft. The preparer and the approver are different people (grants; a self-approval guard like claims’). - Payout posting rule: Dr
ap_trade(the allocated invoices’ amounts at their rates) withparty_id, Cr bank (payment amount at the payment’s rate), difference tofx_gain_loss(D9). - Allocation total must equal the payment amount (no unallocated prepayments in v1; add “payment on account” when asked). Recorded manually; bank files are the payment-batches item.
D5. Supplier credit note
Section titled “D5. Supplier credit note”Kind supplier_credit_note, same header/lines as an invoice, graph like D3, accrual rule posts the
mirror: Dr ap_trade, Cr expense/tax. Once posted it can be allocated to invoices of the same
supplier and currency (D6); allocating a credit note posts nothing (both sides are the same party on
the same control account).
D6. Allocation is append-only; balances and aging are derived
Section titled “D6. Allocation is append-only; balances and aging are derived”ap_allocation (org_id, id, invoice_id, source_kind ('payment','credit_note'), source_id, amount (invoice currency), reverses_id NULL, created_by, created_at). No UPDATE, no DELETE grant. Undoing an allocation inserts a row withreverses_idand the negated amount; a row can be reversed once (unique partial index).- Amendment (plan Ruling P21, accepted 2026-09-26): instead of a polymorphic
source_id, the row carriespayment_idandcredit_note_id(exactly one set) with a generatedsource_kind, so both sides have composite foreign keys; it also storescurrency,base_amountandeffective_on(for historical aging). - A payment’s allocations take effect when the payment reaches
paid; a credit note’s when inserted after it is posted. - Invoice outstanding = gross − Σ effective allocations; CHECK/guard in the same transaction that it never goes below zero (row lock on the invoice).
- Aging buckets by days past due as of a chosen date: current, 1–30, 31–60, 61–90, 90+. Computed
from invoices + allocations, never stored. Report in
reportswith CSV export; an “as of” date gives historical aging.
D7. Duplicates and e-Invoice identifiers
Section titled “D7. Duplicates and e-Invoice identifiers”- Unique
(org_id, supplier_party_id, lower(supplier_invoice_no))on non-cancelled invoices and credit notes (separately). The API returns a clear conflict naming the existing document. - Invoice header stores
document_basis,supplier_tin,einvoice_uuid,einvoice_validated_atwith the same CHECKs asexpense_claim_line, viataxrecord.einvoice_employeeis not allowed here (a supplier invoice is issued to the company). MyInvois submission and self-billed documents are separate items.
D8. SST on purchases is cost unless the tax code names an account
Section titled “D8. SST on purchases is cost unless the tax code names an account”Each line has a tax code (nullable for out-of-scope). If the tax code has account_id, the tax
amount posts there; otherwise it is added to the line’s expense debit. The tax code is kept on the
line either way for SST-02 later. Tax amount is entered (defaulted from rate × net, editable within
rounding) because supplier invoices round their own way.
Amendment (approved 2026-09-26): tax codes carry usage ∈ sales | purchase | both (named CHECK
tax_code_usage_known; existing codes are backfilled to sales). The MY pack seeds purchase-side
codes — sales tax 5%/10%, service tax 6%/8% on purchases, and a no-SST (exempt / out-of-scope)
code — with no account, so their tax is cost; existing organizations get them through a
provisioning step. Purchase invoice and credit-note lines accept only purchase or both codes
(422 otherwise), and the web line picker lists only those. Expense claims are unchanged.
D9. Foreign currency
Section titled “D9. Foreign currency”Invoice, credit note and payment each carry currency + rate to base (rate required when currency ≠
base, 1 otherwise; NUMERIC, never float). v1: a payment or credit note allocates only to invoices
in its own currency. Realised FX = Σ allocated × (invoice rate) − payment amount × (payment rate), in
base, posted to fx_gain_loss on the payment. No unrealised revaluation in v1.
D10. Supplier bank details need a second person
Section titled “D10. Supplier bank details need a second person”party_supplier bank fields: bank (registry), account number (stored full, shown masked), account
name, plus bank_changed_by/at, bank_verified_by/at. Any change clears verification; a different
member with payables.supplier.verify_bank verifies (DB CHECK verified_by <> changed_by). A payment
to a supplier with unverified bank details cannot be approved. Every change is in audit_log, and
the verifier sees old → new.
D11. Never deleted; corrections are reversals
Section titled “D11. Never deleted; corrections are reversals”Posted documents are never edited or deleted. A wrong posted invoice is fixed by a credit note or by a reversal transition (accounting’s existing reversal), which also reverses its effective allocations first (refused while allocations exist unless they are reversed in the same action). All new tables join the retention lists (no DELETE grant), 7 years like claims.
Amendment (plan Ruling P24, accepted 2026-09-26; surfaced to the user): the void is refusal-only — while the document has allocations it is refused, and the clerk reverses the credit-note allocations (and voids the payments) first, then voids. There is no combined “reverse in the same action”; the audit outcome is the same, in two steps instead of one. A combined action can be added later.
D12. Purchase requests not linked in v1
Section titled “D12. Purchase requests not linked in v1”Project I’s purchase_request stays independent. Phase 4’s PO/GRN chain will add
purchase_invoice.purchase_order_id; nothing here is named in a way that conflicts.
Permissions
Section titled “Permissions”payables.supplier.read, payables.supplier.manage, payables.supplier.verify_bank,
payables.invoice.read, payables.invoice.create, payables.invoice.transition,
payables.payment.read, payables.payment.create, payables.payment.transition.
AP aging reuses reports.read / reports.export (no payables.aging.read).
Seeded by default (amendment approved 2026-09-26): Owner gets all nine; the Finance role gets all
of them except payables.supplier.verify_bank, which stays Owner-only, plus
accounting.ledger.read (the AP forms read accounts and tax codes). New organizations through
provisioning; existing Owner and Finance roles through one migration.
API (all in api/openapi.yaml first)
Section titled “API (all in api/openapi.yaml first)”Suppliers CRUD (+ verify bank), purchase invoices / credit notes / payments: list (filters: supplier,
state, due before, currency), get, create, update draft, transition (the existing workflow transition
shape), allocations list/create/reverse, supplier balance, aging (JSON + CSV). Money is a string on the
wire (money.Amount overrides from day one).
Sidebar Accounting section gains Suppliers, Purchase invoices, Supplier payments (a “Payables”
section if it makes Accounting too long — decide at build). List screens use DataTable with
tableId; detail drawers reuse the workflow chip/timeline/transition bar; invoice form reuses the
claim wizard’s line and document components where they fit. AP aging under Reports. en/ms/zh.
Tests that must exist
Section titled “Tests that must exist”- RLS/tenant isolation for every new table (existing drift test covers it once tables are listed).
- Posting: invoice, credit note, payment (with and without FX) produce balanced entries on the right
accounts with
party_idon everyap_tradeline. - Sub-ledger = control account per supplier after a mixed sequence (drift-style test).
- Allocation: over-allocation refused; reversal restores outstanding; double reversal refused.
- Duplicate supplier invoice number refused (presence first: a different number is accepted).
- Bank verification: same person refused; payment approval refused while unverified (presence first: approved once verified).
- Aging buckets at boundary days (0, 1, 30, 31, 90, 91) and “as of” a past date.
- Retention: no DELETE privilege on the new tables.
- e2e: create supplier → invoice → approve → pay → aging shows zero.
Open for the plan (not user decisions)
Section titled “Open for the plan (not user decisions)”Exact module split (platform vs module), whether credit notes share the invoice table with a
direction, the Accounts role’s existence, and the aging permission — follow existing patterns and
record the ruling.