Skip to content

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.

party (00009) today has kind IN ('employee') and legal_name. Extend it, do not add a second party table:

  • party gains 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.kind stays 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 later party_customer is the AR twin. One party may hold several roles. kind widens to ('employee','organization','individual') — what the party is, not what it does.
  • Supplier code unique per org. TIN checked with taxrecord’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/payables if the pattern calls for it), workflow kind purchase_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 via documents, internal number from numbering (scope purchase_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"), plus rejected → draft (revise) and cancelled from 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 add CommitmentKinds.
  • 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), Cr ap_trade (gross) with party_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 (a bank control 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, cancelled from 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) with party_id, Cr bank (payment amount at the payment’s rate), difference to fx_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.

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 with reverses_id and 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 carries payment_id and credit_note_id (exactly one set) with a generated source_kind, so both sides have composite foreign keys; it also stores currency, base_amount and effective_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 reports with CSV export; an “as of” date gives historical aging.
  • 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_at with the same CHECKs as expense_claim_line, via taxrecord. einvoice_employee is 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.

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.

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.

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.

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.

  • 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_id on every ap_trade line.
  • 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.

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.