dodil/erp-method

node_modules/.cache/erp-method/v0.1.20/modules/purchasing/module.yaml

# 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"