# Purchasing & suppliers — module spec (generated from modules/purchasing/module.yaml)
# A starting vocabulary for phase 2: correct it with the customer, then write ERP_PURCHASING.md.
schema: dodil.module-spec/v1
id: purchasing
name: Purchasing & suppliers
short: purchasing
version: 1
maturity: tested
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:
  - 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
  - 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
  - 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
  - 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
  - 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
  - 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 forks the module per customer
  - 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
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: https://blog.dodil.io/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"
lifecycle: https://blog.dodil.io/modules/lifecycle.md
module_pattern: https://blog.dodil.io/modules/pattern.md
design_template: https://blog.dodil.io/modules/template.md
