# Purchasing & suppliers — what we agreed to buy, what arrived, what we owe, what we paid.
#
# Written after AR, GL and the party master were built and deployed together, and deliberately
# NOT as "AR with the arrows reversed". Money going out has a control money coming in does not:
# somebody has to have agreed to spend it BEFORE the invoice arrives, and the supplier's
# invoice is a claim to be checked rather than a document we author.
id: purchasing
name: Purchasing & suppliers
short: purchasing
version: 1
maturity: tested # ap-core implements the spine; 20 tests pass on a bucket — pattern §14
summary: >-
Agree what you are going to spend before you spend it, check that what a supplier bills you is
what you ordered and what actually arrived, and pay it once — so nobody is chasing an invoice
nobody recognises, and nothing is paid twice.
facets:
domain: finance
industries: [construction, manufacturing, professional_services, retail, hospitality]
customer_words:
- purchase orders
- what did we order
- supplier invoices
- approve spend
- who authorised this
- what do we owe
- pay the suppliers
replaces: [email approvals, spreadsheets, Coupa, Ariba, "a folder of PDFs"]
pillars: [sql]
first_module_fit: medium
depends_on: [business-partner]
feeds: [gl]
owns_master_data: []
references_master_data: [business_partner, product]
actors:
- id: requester
name: whoever needs something
does: asks for what they need and sees where the request got to
sees: their own requests
permissions: [purchasing:request:write, purchasing:order:read]
- id: spend_approver
name: budget holder
does: approves or refuses spend before it is committed, within their limit — and asks for things themselves, like anyone else
sees: requests routed to them, and the orders that came from them
permissions: [purchasing:request:approve, purchasing:request:write, purchasing:request:read, purchasing:order:read]
- id: buyer
name: whoever places orders
does: turns approved requests into orders, sends them, records what arrived
sees: every request and order
permissions:
[purchasing:order:write, purchasing:order:send, purchasing:receipt:write,
purchasing:request:read, purchasing:order:read, purchasing:supplier:write]
- id: payables_clerk
name: accounts payable
does: records supplier invoices, matches them, schedules and records payment
sees: every supplier invoice and payment
permissions:
[purchasing:bill:write, purchasing:bill:read, purchasing:payment:write,
purchasing:order:read]
- id: system
name: The system
does: matches bills against orders and receipts, posts to the ledger, derives what is outstanding
sees: everything, through its own service account
permissions: []
- id: payables_approver
name: whoever releases money
does: approves a payment run, and any bill that failed its match
sees: everything payables sees
permissions:
[purchasing:payment:approve, purchasing:bill:approve, purchasing:bill:read,
purchasing:settings:write]
stories:
# ── asking, and agreeing ────────────────────────────────────────────────────────────────
- id: PUR-01
kind: person
actor: requester
story: As someone who needs something, I want to ask for it and see where my request got to, so that I stop chasing people by email.
given: a request for 4 laptops at 900 each
when: the requester submits it
then: it is recorded with who asked, what for, and the amount, and it appears in the approver's queue
screen: Requests
- id: PUR-02
kind: person
actor: spend_approver
story: As a budget holder, I want to approve or refuse spend before it is committed, so that the first I hear of a cost is not the invoice.
given: a 3,600 request and a 5,000 limit for this approver
when: they approve it
then: the request is approved with their name, the date and any note, and a buyer can raise an order from it — and an approver whose limit is below the amount cannot approve it at all
screen: Requests
customer_varies: some businesses approve by amount, some by category, some by both — config, not code
- id: PUR-03
kind: person
actor: spend_approver
story: As a budget holder, I want to refuse a request with a reason, so that the person asking learns something rather than just waiting.
given: an approved-limit breach or a duplicate request
when: the approver refuses it with a reason
then: the request is closed as refused, the reason is recorded and visible to the requester, and no order can be raised from it
screen: Requests
# ── ordering ────────────────────────────────────────────────────────────────────────────
- id: PUR-04
kind: person
actor: buyer
story: As a buyer, I want to turn an approved request into an order to a supplier, so that nothing is ordered that nobody agreed to.
given: an approved request and a chosen supplier
when: the buyer raises the order
then: the order carries its number, the supplier, the lines and the approval it came from; raising an order from an unapproved request is refused
screen: Orders
- id: PUR-05
kind: person
actor: buyer
story: As a buyer, I want the order sent to the supplier and recorded as sent, so that "did they get it?" has an answer.
given: an order in draft
when: the buyer sends it
then: the send is recorded with when and to whom; sending twice records a second send and does not change the order
screen: Orders
- id: PUR-06
kind: person
actor: buyer
story: As a buyer, I want to change or cancel an order before it is fulfilled, so that a mistake is not a thing we live with.
given: a sent order with nothing received against it
when: the buyer cancels it with a reason
then: the order is cancelled, the reason recorded, and no bill can be matched to it — an order with receipts against it cannot be cancelled, only closed short
screen: Orders
# ── what actually arrived ───────────────────────────────────────────────────────────────
- id: PUR-07
kind: person
actor: buyer
story: As whoever takes delivery, I want to record what actually arrived, so that we only pay for what we got.
given: an order for 10 and a delivery of 7
when: the receipt is recorded
then: 7 are received and 3 outstanding, and the order stays open; recording the same delivery note twice does not receive 14
screen: Receipts
note: the delivery note reference is the idempotency key — it is what a person re-enters when unsure
- id: PUR-08
kind: person
actor: buyer
story: As a buyer, I want to close an order that will never be completed, so that the outstanding list means something.
given: an order for 10 with 7 received and the rest cancelled by the supplier
when: the buyer closes it short with a reason
then: the order is closed, 3 stop being outstanding, and the reason is recorded
screen: Orders
# ── the supplier's claim ────────────────────────────────────────────────────────────────
- id: PUR-10
kind: person
actor: payables_clerk
story: As accounts payable, I want to record a supplier invoice as what it is — their claim, not our document — so that checking it is a step rather than an afterthought.
given: a supplier invoice quoting our order number
when: the clerk records it with the supplier's own invoice number
then: it is held as a bill awaiting match, and recording the same supplier invoice number for the same supplier again returns the existing bill rather than creating a second
screen: Bills
note: >-
the supplier's number is THEIRS and is not ours to generate — two suppliers may use the
same number and the same supplier reuses theirs across years, so the natural key is
(supplier, their number, their date).
- id: PUR-11
kind: system
actor: system
trigger: when a bill is recorded or a receipt is posted against its order
story: The system matches the bill to the order and the receipt, so that a bill for something nobody ordered or nobody received is caught before it is paid.
given: an order at 900 a unit, 7 received, and a bill for 10 at 950
when: the match runs
then: the bill fails on both price and quantity, naming each, and cannot be paid until somebody with authority accepts the difference
note: >-
THE control of this module. Three-way match — order, receipt, invoice — with a tolerance
the customer sets. Two-way (order and invoice) is normal for services, where there is
nothing to receive.
- id: PUR-12
kind: person
actor: payables_approver
story: As whoever releases money, I want to accept a bill that failed its match, with a reason, so that a real price rise is not a permanent blockage.
given: a bill failing on price by 50 a unit
when: the approver accepts the variance with a reason
then: the bill becomes payable, the acceptance is recorded with who and why, and the order is not silently rewritten to match
screen: Bills
# ── paying ──────────────────────────────────────────────────────────────────────────────
- id: PUR-14
kind: person
actor: payables_clerk
story: As accounts payable, I want to see what is due and when, so that I pay on time without paying early.
given: bills with terms and due dates
when: the clerk opens the payables list
then: each bill shows what is outstanding and how many days until or past due, derived on read rather than stored
screen: Payables
- id: PUR-15
kind: person
actor: payables_approver
story: As whoever releases money, I want to approve a payment run, so that one person cannot both enter a supplier and pay it.
given: a run of 12 bills totalling 40,000
when: the approver releases it
then: the run is approved with their name and the date, each bill is marked paid for its amount, and the clerk who entered the bills cannot be the approver
screen: Payments
note: segregation of duties — the one control an auditor will look for by name
- id: PUR-16
kind: person
actor: payables_clerk
story: As accounts payable, I want to record a part payment or a payment covering several bills, so that the ledger matches the bank rather than the other way round.
given: one transfer of 5,000 against three bills
when: the clerk allocates it
then: each bill's outstanding falls by its share, the payment is recorded once, and allocating more than the payment is refused
# ── seams ───────────────────────────────────────────────────────────────────────────────
- id: PUR-20
kind: integration
actor: system
owner: gl
trigger: when a bill is approved for payment, and when a payment is recorded
story: Purchasing posts the accounting entries to the ledger, so that the books show what is owed to suppliers without anyone re-keying it.
given: an approved bill of 1,200 including 200 tax, and an account map with expense, tax and payables set
when: purchasing calls the ledger's own route
then: expense 1,000 and tax 200 are debited and payables 1,200 credited; a payment debits payables and credits bank; the journal id is derived from the bill, so a retry posts once and a closed period refuses the posting and says why
- id: PUR-21
kind: integration
actor: system
owner: business-partner
trigger: when a supplier is needed that does not exist yet
story: Purchasing asks the party master for the supplier, so that the company we buy from and the company we sell to are one record.
given: a supplier known to purchasing only by name and the customer's own reference
when: purchasing calls promote with that reference as the bp_key
then: one party exists with a supplier role and a derived bp_id; calling again returns the same party, and purchasing never writes the partner tables
- id: PUR-22
kind: person
actor: payables_clerk
story: As accounts payable, I want to see whether a supplier is also a customer, so that we can net what we owe against what they owe us rather than paying in full and chasing.
given: a party with both roles and balances on each side
when: the clerk opens the supplier
then: both balances are shown, each read from the module that owns it
screen: Payables
note: contra settlement itself is NOT in this module — see known_gaps; showing both sides is
workflows:
- entity: request
states: [draft, awaiting_approval, approved, refused, ordered, closed]
transitions:
- { from: draft, to: awaiting_approval, by: requester, gate: "a line, an amount and a reason" }
- { from: awaiting_approval, to: approved, by: approver, gate: "the amount is within this approver's limit" }
- { from: awaiting_approval, to: refused, by: approver, requires: reason }
- { from: approved, to: ordered, by: buyer, effect: "an order exists; the approval travels with it" }
- { from: approved, to: closed, by: buyer, requires: reason, effect: "approved but never ordered" }
- entity: order
states: [draft, sent, part_received, received, closed_short, cancelled]
transitions:
- { from: draft, to: sent, by: buyer, effect: "number stamped, contents frozen, send recorded" }
- { from: sent, to: part_received, by: buyer, effect: "derived from receipts, never set by hand" }
- { from: part_received, to: received, by: buyer, effect: "derived: everything ordered has arrived" }
- { from: part_received, to: closed_short, by: buyer, requires: reason }
- { from: sent, to: cancelled, by: buyer, gate: "nothing received against it", requires: reason }
note: >-
received-ness is DERIVED from the receipt rows, not stored on the order. A stored status
and a receipt table disagree the first time someone deletes a receipt.
- entity: bill
states: [recorded, matched, match_failed, approved, paid, part_paid, disputed, cancelled]
transitions:
- { from: recorded, to: matched, by: system, gate: "within tolerance on price and quantity" }
- { from: recorded, to: match_failed, by: system, effect: "the reason names price, quantity or no order at all" }
- { from: match_failed, to: approved, by: payables_approver, requires: reason }
- { from: matched, to: approved, by: payables_approver }
- { from: approved, to: paid, by: payables_clerk, effect: "derived from allocations, never stored" }
- { from: recorded, to: disputed, by: payables_clerk, requires: reason }
- { from: recorded, to: cancelled, by: payables_approver, gate: "nothing paid against it", requires: reason }
note: >-
PAID is derived, like AR's settlement: outstanding = total − allocations. A stored flag and
an allocation table disagree, and the disagreement is always discovered in a payment run.
entities:
- id: purchase_request
meaning: somebody asked to spend money, and who agreed
natural_key: request_id, derived from the requester and the moment they asked
key_attributes: [requester, cost_centre, reason, amount, status, approved_by, approved_at, approval_note]
relationships: [becomes a purchase_order]
master_data: module-owned
reference_table: pur_requests
- id: purchase_order
meaning: what we agreed to buy from a named supplier, at a price
natural_key: order_id, derived from the source request; order_no is the number the supplier sees
key_attributes: [bp_id, bp_key, order_no, currency, status, sent_at, terms_id, approval_ref]
relationships: [has purchase_order_lines, receives goods_receipts, is billed by supplier_bills]
master_data: module-owned
reference_table: pur_orders
- id: purchase_order_line
meaning: one thing ordered, at a quantity and a price
natural_key: (order_id, line_no)
key_attributes: [sku, description, qty, unit_price, tax_code, expense_account, net_amount]
relationships: [received by goods_receipt_lines, billed by supplier_bill_lines]
master_data: module-owned
reference_table: pur_order_lines
- id: goods_receipt
meaning: what actually turned up, and when
natural_key: (order_id, delivery_note) — what a person re-enters when unsure
key_attributes: [received_at, received_by, delivery_note, note]
relationships: [belongs to a purchase_order, has goods_receipt_lines]
master_data: module-owned
reference_table: pur_receipts
- id: supplier_bill
meaning: the supplier's claim on us — their document, not ours
natural_key: (bp_id, supplier_invoice_no, invoice_date)
key_attributes: [order_id, bp_key, currency, subtotal, tax_total, total, due_date, status, match_result, accepted_by, accepted_reason]
relationships: [matched against a purchase_order and its receipts, settled by payments]
master_data: module-owned
reference_table: pur_bills
- id: payment_run
meaning: a batch of bills released for payment by one person, on one date
natural_key: run_id, derived from the approver and the moment of release
key_attributes: [approved_by, approved_at, total, method, status]
relationships: [pays supplier_bills through allocations]
master_data: module-owned
reference_table: pur_payment_runs
- id: payment_allocation
meaning: which money settled which bill, and how much of it
natural_key: (payment_id, bill_id, seq)
key_attributes: [amount, allocated_at, allocated_by]
relationships: [links a payment to a supplier_bill]
master_data: module-owned
reference_table: pur_allocations
- id: approval_limit
meaning: how much this person may commit, and to what
natural_key: (approver, category)
key_attributes: [limit_amount, currency, active_from]
relationships: [gates purchase_request approval]
master_data: module-owned
reference_table: pur_approval_limits
master_data:
owns: []
references:
- entity: business_partner
owner: business-partner
how: "pur_orders.bp_id = business_partner.bp_id — the BIGINT the party master derives"
if_absent: >-
purchasing keeps the supplier's name and its own bp_key on the order and cannot answer
"do we also sell to them?" — which is PUR-22, and the reason the party master exists.
- entity: product
owner: none yet
how: "pur_order_lines.sku, free text until a catalogue module exists"
if_absent: a line is a description and a price, which is how most businesses buy services anyway
jobs:
- story: PUR-11
what: match a bill against its order and receipts
when: on write — when a bill is recorded, and when a receipt lands against its order
idempotent: the result is recomputed from the rows, so running it again lands the same answer
note: >-
NOT a scheduled sweep. A match is a function of three row sets; deriving it on write means
there is no queue to get stuck and nothing to re-run after an outage.
seams:
- story: PUR-20
with: gl
owner: gl
rule: >-
purchasing calls POST /journal-entry/journals with a balanced journal and a journal_id
derived from the bill, exactly as invoicing does. It never writes journal, ledger or
subledger tables. The account map is a config row per expense category.
- story: PUR-21
with: business-partner
owner: business-partner
rule: purchasing calls promote with the supplier role; it never writes the partner tables
- story: PUR-22
with: ar
owner: ar
rule: >-
purchasing reads what a party owes US through invoicing's own route, never a direct read
of ar_invoices — settlement there is derived from allocations, credit notes and write-offs.
screens:
- { id: requests, name: Requests, stories: [PUR-01, PUR-02, PUR-03], actors: [requester, spend_approver] }
- { id: orders, name: Orders, stories: [PUR-04, PUR-05, PUR-06, PUR-08], actors: [buyer] }
- { id: receipts, name: Receipts, stories: [PUR-07], actors: [buyer] }
- { id: bills, name: Bills, stories: [PUR-10, PUR-12], actors: [payables_clerk, payables_approver] }
- { id: payables, name: Payables, stories: [PUR-14, PUR-22], actors: [payables_clerk, payables_approver] }
- { id: payments, name: Payments, stories: [PUR-15, PUR-16], actors: [payables_approver, payables_clerk] }
permissions:
namespace: purchasing
catalog:
- purchasing:request:write
- purchasing:request:read
- purchasing:request:approve
- purchasing:order:write
- purchasing:order:read
- purchasing:order:send
- purchasing:receipt:write
- purchasing:bill:write
- purchasing:bill:read
- purchasing:bill:approve
- purchasing:payment:write
- purchasing:payment:approve
- purchasing:supplier:write
- purchasing:settings:write
config_rows:
- what: approval limits per person and category
table: pur_approval_limits
why: the number that decides who may commit spend changes with a promotion, not a release
- what: match tolerance — price and quantity, absolute and percentage
table: pur_settings
why: "a 2% price tolerance is a business decision; hard-coding it makes every customer a deviation; it is a row, so configure, not code"
- what: payment terms per supplier
table: pur_terms
why: what you pay on is negotiated supplier by supplier
- what: expense account per category
table: pur_account_map
why: which ledger account a category posts to is the customer's chart, not ours
- what: whether a category needs three-way or two-way match
table: pur_settings
why: services have nothing to receive; goods do
in_code_not_rows:
- the three-way match itself — what is compared, and that a failure blocks payment
- that an approver cannot approve their own request
- that the person who entered a bill cannot release the payment run
- that outstanding is derived from allocations rather than stored
tests:
acceptance:
- one per person story, named for it
- a bill for 10 at 950 against an order for 10 at 900 fails on price and names the difference
- a bill for 10 against 7 received fails on quantity and names the difference
- the same supplier invoice number for the same supplier records one bill, not two
- the same delivery note recorded twice receives the quantity once
access:
- an approver cannot approve a request above their limit
- a requester cannot approve their own request
- the clerk who recorded a bill cannot approve the payment run that pays it
- a buyer cannot release a payment
invariants:
- outstanding on a bill equals total minus allocations, always
- an order with receipts cannot be cancelled
- a bill that failed its match cannot be paid without a recorded acceptance
- a payment cannot be allocated beyond its own amount
scale:
reads: "the tables reader serves 16 concurrent reads; the payables list derives outstanding per bill — page it rather than deriving the whole ledger for one screen"
hot_paths: ["the match on bill write", "the payables list", "the outstanding-orders list"]
workers: none — the match runs on write, not on a schedule
volumes: "tens of thousands of bills a year in a mid-sized business; the match is three indexed reads per bill"
platform_constraints:
- "a bill's natural key is (bp_id, supplier_invoice_no, invoice_date) because the supplier owns their numbering and reuses it across years — this is the one key purchasing cannot derive"
- "DataK3 has no sequences: order_no comes from a high-water mark plus MAX(), the same shape invoicing uses, and the caller re-reads the number after stamping it"
- "no read-your-writes inside an open transaction — the match reads receipts committed by an earlier phase, so it runs after the commit, not inside it"
# What differs between customers — with something for them to react to, and where one exists,
# something they can look up.
#
# Two kinds of answer, and they must never be confused:
#
# standard: a NAMED external authority the customer can look up and hold us to.
# typical: arrangements we have SEEN. No authority, no survey behind them.
customer_varies:
- question: Does somebody have to agree to a cost before it is committed, or do people just buy what they need?
changes: whether PUR-01 to PUR-03 exist at all, and the approval limits table
typical:
under_15_staff: "the owner sees everything anyway — requests are often skipped entirely at first"
construction: "a site manager commits within a limit; anything above goes to the QS or the director"
professional_services: "a budget holder per department, one limit each"
warning: an approval step nobody enforces is worse than none — it teaches people the system lies
- question: Do you raise purchase orders, or do you just ring the supplier?
changes: whether PUR-04 to PUR-06 exist, and whether the match is three-way or two-way
typical:
goods_and_materials: "orders, because the order is what the delivery and the invoice are checked against"
services: "often no order — the engagement letter is the order, and the match is two-way"
- question: When a supplier's invoice does not match the order, who decides?
changes: PUR-12, and the match tolerance in settings
typical:
common: "the budget holder who approved the spend, not accounts payable"
note: "a tolerance of 2% or a few pounds absorbs rounding and delivery charges without a human"
- question: When do you pay your suppliers?
changes: pur_terms, and whether a payment run exists or payments are one at a time
standard: >-
EU Directive 2011/7/EU and, in the UK, the Late Payment of Commercial Debts (Interest)
Act 1998 — 30 days unless expressly agreed, 60 maximum between businesses, and interest is
the supplier's statutory right. Large UK companies must also report their payment
performance twice a year (Reporting on Payment Practices and Performance Regulations 2017).
typical:
most_b2b: "a weekly or fortnightly payment run rather than paying each bill as it lands"
construction: "pay-when-paid clauses are common and are unenforceable in UK construction contracts — check before encoding one"
- question: Do you reclaim VAT on what you buy?
changes: tax handling on the bill, and the tax account in the map
standard: >-
Input tax is only recoverable against a valid VAT invoice carrying the supplier's VAT
number and the required particulars (EU VAT Directive 2006/112/EC art. 226; UK VAT
Regulations 1995 reg. 14). A scan of a delivery note is not one.
typical:
vat_registered: "the bill cannot be approved without the supplier's VAT number on file"
not_registered: "tax is just part of the cost — do not model a recoverable element"
- question: Do you buy from anyone you also sell to?
changes: PUR-22, and whether contra settlement is ever needed
typical:
trade_and_construction: "common — and both sides are usually settled separately anyway"
warning: netting what you owe against what you are owed has tax and contract consequences; see known_gaps
reference:
packages:
- { id: ap-core, kind: app, what: "requests, approvals, orders, receipts, bills, the three-way match, payments; FastAPI", url: /code/ap-core/ }
contracts: []
build_guide: null
known_gaps:
- "ap-core covers PUR-01 to PUR-12, PUR-15, PUR-16 and PUR-20; the supplier seam (PUR-21), the customer-balance view (PUR-22) and payment RUNS as a batch are not built — payments are one at a time"
- "contra settlement (netting a supplier balance against a customer balance for the same party) is NOT modelled: it has tax and contract consequences that differ by jurisdiction, and PUR-22 only shows both balances"
- "no stock or inventory: a receipt records that something arrived, not where it went. A goods-for-resale business needs a stock module before this is the whole story"
- "no supplier catalogue or contracted pricing — a line is a description and a price"
- "no landed cost: duty, freight and handling are separate bills rather than being spread across what they were incurred on"
- "no self-billing, no purchasing cards, no staged payments against a construction application for payment"
- "pay-when-paid is deliberately not modelled: it is unenforceable in UK construction contracts and encoding it would help a customer do something the law does not allow"