dodil/erp-method

modules/purchasing/module.yaml

# Purchasing & suppliers: the module source.
#
# Version 1 was 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. ap-core (the version-1 package) implements its spine.
#
# Version 2 (2026-10-22, DRAFT for sign-off; wave 2 of the method), to the conventions of the signed tax,
# sales-order, ar and payments specs, and to parity with a mainstream SME suite's Purchase app, kept small:
#   - an order line is priced in the PURCHASE unit (product's unit_code, with the exact numerator/denominator
#     to the base unit copied at line time); the supplier's own price, pack and lead time are purchasing's
#     supplier prices (pur_supplier_price), never the product master's
#   - a receipt records what arrived, dated by the delivery (a fact: today or earlier, never later), and is
#     recorded ONLY through POST /purchase-orders/{purchase_order_id}/receipts (inventory's scanner calls it too).
#     Purchasing then HANDS each line to inventory as "received qty X of product P in unit U at location L"
#     (POST /receipts, served by inventory; returns: POST /returns-to-supplier). Purchasing never writes stock
#   - the three-way match (order <-> receipt <-> bill) is DERIVED on read and on write, within a tolerance the
#     OWNER holds (purchasing:settings:controls); a failing line is accepted by a second person, recorded
#   - approvals are a second person: identity is the email AND the sub, so the same person under a second
#     login cannot approve their own request, order, payment run or bank-detail change
#   - tax on a bill comes from the tax module (POST /documents/compute, check-contents, freeze), the supplier's
#     stated VAT kept beside it; posting goes to the ledger (POST /journal-entry/journals)
#   - supplier payments are SCHEDULED and APPROVED here and handed to the bank as a file; DODIL never executes
#     a transfer. A payment is "paid" when a person confirms the bank's reference and date
#   - dated rows, no back-dating: "today" is the date in the ONE business_timezone (shared.yaml conventions);
#     limits, tolerances, terms, the account map and supplier bank details take effect after today. Facts dated
#     by their own document are the exception, said where they occur: a delivery date, the supplier's invoice
#     date, the bank's payment date
#   - corrections are new or reversing rows (a return to supplier, a supplier credit note, a reversed
#     allocation), never edits; machine actions are exact-grant machine permissions; AI steps suggest
#   - the Odoo slice (purchase.order, stock.picking incoming for received quantities, account.move in_invoice /
#     in_refund, supplier payments, product.supplierinfo) under `migration:`
#
# Version 3 (2026-10-22, wave 2, DRAFT for sign-off; "up to SAP standard" for units of measure, shared.yaml
# units.uom), on top of version 2:
#   - an ORDER PRICE UNIT (SAP BPRME): a line ordered in one unit can be priced in another (10 bars priced per kg),
#     with a pricing quantity (per 100), and the conversion between the two FIXED on the line when it is priced:
#     the product's, or the one the supplier's price row states (PUR-35)
#   - the three-way match compares in the line's PRICING unit through that fixed conversion, as exact fractions:
#     a bill in kg matches an order in bars and a receipt in bars (PUR-36)
#   - the purchase unit's minimum and multiple (product's rounding, or the supplier price row's own multiple) are a
#     readiness gate on confirm, accepted with a reason by a second person, recorded per order revision (PUR-37)
#   - catch weight on receipts is designed in inventory and its columns reserved here (`deferred:`, M7);
#     `sap_parity:` compares this module with SAP MM purchasing and invoice verification
#
# Version 4 (2026-10-22, the SAP full-scope pass, DRAFT for sign-off): every sap_parity row that was deferred is
# designed, on top of version 3:
#   - a release strategy of up to three levels, each a different person (PUR-38); requests for quotation to several
#     suppliers, compared and awarded with a reason (PUR-39, PUR-40)
#   - outline agreements: quantity and value contracts approved once, release orders claiming them; scheduling
#     agreements with schedule lines, forecast and JIT releases, each JIT release a call-off order (PUR-41 to PUR-43)
#   - a source list (allowed, fixed, blocked suppliers per product) and a quota arrangement proposing the supplier
#     (PUR-44, PUR-45); a vendor evaluation scored from rows (PUR-46)
#   - supplier down payments: a request against the order, paid in the run, cleared against the final bill in full or
#     in part, tracked per down payment (PUR-47 to PUR-49)
#   - evaluated receipt settlement under a dated, approved self-billing agreement (PUR-50, PUR-51); tolerance keys
#     per tolerance group with upper and lower limits and small differences (PUR-52)
#   - supplier consignment (stock owned by the supplier until withdrawn, settled by invoice or ERS) and
#     subcontracting (components provided to the supplier, consumed at receipt), both through inventory (PUR-53,
#     PUR-54, PUR-57, PUR-58); planned delivery costs (landed cost) with the actual handed to inventory (PUR-62, PUR-63)
#   - account assignment asset (the fixed-assets seam), cost centre and project on an order line (PUR-55, PUR-56)
#   - BACS Standard 18 files beside pain.001 (PUR-59); CIS deductions on subcontractors' payments through tax's CIS
#     (PUR-60, PUR-61)
#
# Version 8 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found):
#   - a request can be cancelled (state cancelled): by its requester or a buyer before it is ordered, by sales-order
#     for a request it raised, and by the system when the order raised from it is cancelled (PUR-67). An ordered
#     request is never cancelled on its own: the order is cancelled, and the request follows
#   - the bill's content check sends tax what TAX-24 needs: the supplier's address and VAT number as printed, the
#     supplier's invoice date and our company's name (from gl), so a bill can be approved with tax installed
#   - approve_bill checks every cost centre on the bill against gl (gate cost_centre_open), as gl's journal does
#   - ship_to_address is the one address shape (shared.yaml conventions.address), as sales-order sends it
#   - use cases (MCP tools) of the shared families name the module noun: tool_names, shared.yaml conventions.tool_names
#   - use-case names are unique across the method: confirm_purchase_order, record_goods_receipt,
#     load_purchasing_from_odoo and the rest (the old names collided with sales-order, payments, ar and gl)
#
# Four decisions an auditor checks first:
#   - nothing is ordered that nobody agreed to: an order above the approver's limit waits for a second person
#   - received-ness, billed-ness, the match and what is outstanding are DERIVED from rows, never stored flags
#   - the supplier's invoice number is THEIRS: the natural key is (bp_id, supplier_invoice_no, invoice_date)
#   - the person who entered a bill, or changed a supplier's bank details, never releases the money to it
#
# APQC PCF v8.0: 20022 (4.0 Manage Supply Chain for Physical Products: procure materials and services) and
# 17058 (9.0 Manage Financial Resources: accounts payable). Lower-level PCF ids are not cited here because they
# were not verified (erp-bench standards/processes.yaml carries the verified category ids only).

id: purchasing
name: Purchasing & suppliers
short: Purchasing
version: 8
maturity: tested           # ap-core implements version 1's spine (20 tests pass on a bucket, pattern §14); the brick is not built; versions 2 to 4 are drafts for sign-off
summary: >-
  Agree what you are going to spend before you spend it, order it in the supplier's own packs, record what
  actually arrived, check that what a supplier bills you is what you ordered and what arrived, and pay it once
  in an approved run, so nobody is chasing an invoice nobody recognises and nothing is paid twice.

facets:
  domain: finance
  industries: [construction, manufacturing, professional_services, retail, hospitality, wholesale]
  customer_words:
    - purchase orders
    - what did we order
    - did it arrive
    - supplier invoices
    - approve spend
    - who authorised this
    - what do we owe
    - pay the suppliers
    - payment run
    - down payment to a supplier
    - blanket order
    - call-off
    - self-billing
    - CIS deductions
    - BACS file
    - subcontractor
    - consignment stock
    - supplier scorecard
  replaces: [email approvals, spreadsheets, Coupa, Ariba, "a folder of PDFs", Odoo Purchase]
  pillars: [sql]
  first_module_fit: medium
  depends_on:
    - module: business-partner
      why: a supplier is a party; the company we buy from and the one we sell to are one record
      required: true
    - module: product
      why: what is bought, its purchase unit and the exact conversion to the unit it is stocked in
      required: recommended
    - module: tax
      why: the input VAT on every bill line and the per-rate summary, frozen at approval
      required: recommended
    - module: gl
      why: posting bills, credits and payments to the books
      required: recommended
    - module: inventory
      why: owning the stock moves a receipt causes (purchasing tells it what arrived, where)
      required: optional
    - module: payments
      why: the bank accounts money is paid from (and their ledger accounts)
      required: optional
    - module: ar
      why: showing what a supplier who is also a customer owes us (PUR-22)
      required: optional
    - module: fixed-assets
      why: an order line with account assignment asset is capitalised there when its bill is approved (PUR-55, PUR-56)
      required: optional
  feeds: [gl, inventory, fixed-assets, tax]
  owns_master_data: [supplier_price, source_list, quota_arrangement, purchase_agreement]
  references_master_data: [business_partner, product, tax_code, location, asset, cost_centre]

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:request:read_own]
  - 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 and orders routed to them, and the orders that came from them
    permissions: [purchasing:request:approve, purchasing:order:approve, purchasing:agreement:approve, purchasing:request:write, purchasing:request:read, purchasing:order:read]
  - id: buyer
    name: whoever places orders
    does: turns approved requests into orders, keeps supplier prices, confirms and sends orders, closes them short
    sees: every request and order, every supplier price
    permissions:
      [purchasing:order:write, purchasing:order:confirm, purchasing:order:send, purchasing:request:read, purchasing:order:read,
       purchasing:supplier:write, purchasing:supplier:read, purchasing:supplier_price:write, purchasing:receipt:read,
       purchasing:rfq:write, purchasing:agreement:write, purchasing:source:write, purchasing:evaluation:write, purchasing:down_payment:request]
  - id: receiver
    name: whoever takes delivery (stores, site, reception)
    does: records what actually arrived and what goes back to the supplier
    sees: orders still to arrive, and receipts
    permissions: [purchasing:receipt:write, purchasing:receipt:read, purchasing:order:read_expected, purchasing:subcontract:provide]
  - id: payables_clerk
    name: accounts payable
    does: records supplier invoices and credit notes, disputes them, proposes payment runs, confirms what the bank paid, keeps supplier bank details
    sees: every bill, credit, run and payment
    permissions:
      [purchasing:bill:write, purchasing:bill:read, purchasing:payment:write, purchasing:payment:read, purchasing:order:read,
       purchasing:receipt:read, purchasing:supplier:read, purchasing:supplier_bank:write, purchasing:down_payment:clear,
       purchasing:self_billing:write, purchasing:ers:run, purchasing:consignment:settle]
  - id: payables_approver
    name: whoever releases money (finance manager)
    does: approves bills, accepts match variances with a reason, approves payment runs and supplier bank-detail changes, keeps day-to-day settings
    sees: everything payables sees
    permissions:
      [purchasing:bill:approve, purchasing:bill:read, purchasing:payment:approve, purchasing:payment:read,
       purchasing:supplier_bank:approve, purchasing:supplier:read, purchasing:settings:write, purchasing:migration:read,
       purchasing:down_payment:approve, purchasing:self_billing:approve]
  - id: owner
    name: The owner (or whoever the owner names)
    does: holds the controls on the people who spend and pay (approval limits and the release strategy, tolerance keys, the order approval threshold, number formats, the payment-file format and the BACS service user number, vendor-evaluation weights, the self-billing maximum) and signs the migration reconciliation
    sees: everything
    permissions: [purchasing:bill:read, purchasing:order:read, purchasing:settings:controls, purchasing:migration:read, purchasing:migration:sign]
    note: "the controls are never held by whoever they control: a spend_approver, buyer, clerk or payables_approver holding purchasing:settings:controls is a configuration error the access test refuses"
  - id: supplier
    name: The supplier
    external: true
    does: receives the order, delivers, sends an invoice, is paid
    sees: the order sent to them; nothing in the system
    permissions: []
  - id: system
    name: The system
    does: stamps numbers, hands receipts to inventory, computes the match, asks tax, posts to the ledger, writes the bank file, reads a supplier's PDF into a draft (suggest), loads and reconciles a migration
    sees: everything, through its own service account
    permissions:
      [purchasing:number_series:claim, purchasing:receipt:hand_over, purchasing:bill:post, purchasing:payment:post,
       purchasing:payment:export, purchasing:bill:ai_read, purchasing:bill:ai_check, purchasing:migration:load,
       purchasing:migration:reconcile, purchasing:consignment:intake, purchasing:source:propose, purchasing:asset:hand_over,
       purchasing:cis:record, purchasing:request:intake, purchasing:payment:confirm]
    note: "each is an exact grant to one service client, never reached by a wildcard (permissions.machine); purchasing:consignment:intake and purchasing:source:propose go to inventory's client"

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.00 each, cost centre IT
    when: the requester submits it
    then: it is recorded with who asked (email and sub), what for, the cost centre and 3,600.00, and it appears in the queue of an approver whose limit covers it
    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.00 request and a limit of 5,000.00 in force today for this approver and category
    when: they approve it
    then: the request is approved with their email, sub, today's date in the business time zone and any note, and a buyer can raise an order from it; an approver whose limit is below the amount, or who asked for it (same email OR same sub), 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: a duplicate request
    when: the approver refuses it with a reason
    then: the request is refused, the reason is visible to the requester, and no order can be raised from it
    screen: Requests
  - id: PUR-13
    kind: person
    actor: owner
    story: As the owner, I want to set who may commit how much, from a date, so that a promotion changes a limit without a release and nobody can back-date one.
    given: a buyer promoted to budget holder for IT with a limit of 10,000.00
    when: the owner records the limit starting tomorrow
    then: it is a new dated row; today's approvals use today's limit; a row dated today or earlier is refused with its reason (the first row of a person is the exception and may start today)
    screen: Settings

  # ── 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 draft order carries the supplier (bp_id), the lines and the approval it came from; raising an order from an unapproved request is refused; a second raise from the same request returns the same order
    screen: Orders
  - id: PUR-23
    kind: person
    actor: buyer
    story: As a buyer, I want each supplier's own price, pack and lead time for a product kept once, so that an order line fills itself in and I can see who is cheapest.
    given: supplier A sells COLA-075 in PK24 at 18.0000 a pack from 10 packs, lead time 3 days, their code A-COLA
    when: the buyer records the supplier price, valid from tomorrow
    then: a dated supplier-price row exists; a new order line for supplier A and COLA-075 defaults to PK24 at 18.0000 for 10 or more; the product master is unchanged
    screen: Suppliers
    customer_varies: whether supplier prices are kept at all, or every line is priced by hand
  - id: PUR-24
    kind: person
    actor: buyer
    story: As a buyer, I want to order in the supplier's pack and have the system know exactly how many units that is, so that stores, the bill and the stock all agree without anyone converting.
    given: COLA-075 stocked in bottles (C62), a purchase unit PK24 = 24 bottles
    when: the buyer orders 5 PK24 at 18.0000
    then: the line keeps unit_code PK24, the exact conversion 24/1 copied from product, 120 bottles in the base unit, and net 90.00; at confirmation product is told the unit was used so its conversion can never change under the order
    screen: Orders
  - id: PUR-35
    kind: person
    actor: buyer
    story: As a buyer, I want to order in one unit and pay in another (bars ordered, priced per kilogram), so that the order says exactly what the supplier's price list says and the bill can be checked against it.
    given: "STEEL-BAR, base unit C62 (a bar), product unit KGM at 1 KGM = 2/5 C62 (a bar is nominally 2.5 KGM); supplier B's price row: 3.2000 per 1 KGM, order unit C62, from 10 bars"
    when: the buyer orders 10 C62 from supplier B, confirms, and later amends the line to 12 C62
    then: "the line keeps unit_code C62 (10, 10 in the base unit) and the ORDER PRICE UNIT: pricing_unit_code KGM, pricing_numerator 2 / pricing_denominator 5, pricing_quantity 1, unit_price 3.2000; quantity_priced 25 KGM (exact) and net 80.00; the conversion is FIXED on the line at pricing and never re-read: where supplier B's row states its own (2.48 KGM a bar, 25/62), the line takes that one instead and says whose it is; at confirmation product is told both C62 and KGM were used; the amendment to 12 keeps the fixed conversion (30 KGM, 96.00); a pricing unit the product cannot convert exactly is refused naming the unit"
    screen: Orders
    reference: "SAP order price unit (EKPO-BPRME) with its conversion to the order unit (EKPO-BPUMZ / BPUMN), price unit (EKPO-PEINH), variable order unit on the info record; Odoo has none"
    customer_varies: whether any supplier prices in a unit other than the one you order in (steel, cable, timber, food by weight)
  - id: PUR-37
    kind: person
    actor: spend_approver
    story: As a budget holder, I want an order that breaks the supplier's pack rule to come to a second person, so that buying 100 packs when the supplier ships pallets of 80 is a decision, not a surprise on the delivery.
    given: "COLA-075 purchase role PK24 at least 80, multiples of 80 (product's rounding, PRD-30); a buyer's draft order of 100 PK24 from supplier A, whose price row sets no multiple of its own"
    when: the buyer prices the line, tries to confirm, tries to accept the difference himself, and a budget holder accepts it with the reason "supplier agreed a mixed pallet"; the buyer then amends the line to 110
    then: "pricing shows the gate {gate: purchase_rounding, met: false, reason: \"COLA-075 is bought in multiples of 80 PK24 (a full pallet): 100 is not one\", fix: \"order 80 or 160, or a second person accepts 100 with a reason\"}; confirm is refused with it; the buyer's own acceptance is refused (second_person: same email or same sub); the budget holder's acceptance is an override row for this order revision and line (who, email and sub, when, why) and confirm then passes; the amendment to 110 is a new revision and asks again; where the supplier's price row sets its own order_multiple, that is the rule checked instead"
    screen: Orders
    reference: "SAP rounding value / rounding profile on the material and the purchasing info record (MARC-BSTRF, EINE-RDPRF), minimum order quantity (EINE-MINBM); the warning in ME21N"
    customer_varies: "whether the rule warns or needs a second person (here a gate a second person may accept); which products and suppliers have rules"
  - id: PUR-05
    kind: person
    actor: buyer
    story: As a buyer, I want the order confirmed, numbered and sent to the supplier, so that "did they get it?" has an answer.
    given: a draft order within the buyer's authority or already approved
    when: the buyer confirms and sends it
    then: the order number is stamped once (PO-2026-0042, from the series), the contents freeze, the send is recorded with when and to whom; sending twice records a second send and changes nothing else
    screen: Orders
  - id: PUR-09
    kind: person
    actor: spend_approver
    story: As a budget holder, I want an order raised without a request, or grown past what was approved, to come to me, so that ordering direct is not a way around approval.
    given: an order threshold of 1,000.00 and a direct order of 2,400.00
    when: the buyer confirms it
    then: it waits for an approver whose limit covers 2,400.00 and who is not the buyer (email AND sub); once approved it can be confirmed; an amendment that raises the total beyond what was approved goes back to approval
    screen: Orders
  - id: PUR-06
    kind: person
    actor: buyer
    story: As a buyer, I want to change or cancel an order before anything arrives, so that a mistake is not a thing we live with.
    given: a confirmed order with nothing received and nothing billed against it
    when: the buyer cancels it with a reason
    then: the order is cancelled, keeps its number, the reason is recorded and no bill can be matched to it; an order with receipts or bills against it cannot be cancelled, only closed short
    screen: Orders
  - 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 stop being a commitment), and the reason is recorded
    screen: Orders

  # ── what actually arrived ───────────────────────────────────────────────────────────────
  - id: PUR-07
    kind: person
    actor: receiver
    story: As whoever takes delivery, I want to record what actually arrived and where it went, so that we only pay for what we got.
    given: an order line for 10 PK24 and a delivery note DN-881 with 7 PK24, delivered yesterday, into location WH/STOCK
    when: the receipt is recorded
    then: 7 PK24 (168 bottles) are received dated yesterday (the delivery's own date, never later than today), 3 are outstanding and the order stays open; recording DN-881 again 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-25
    kind: integration
    actor: system
    owner: inventory
    trigger: when a receipt or a return to supplier is recorded
    story: Purchasing tells inventory what arrived (product, quantity, unit, location), so that stock moves are owned by one module and nobody keys a delivery twice.
    given: the receipt of 7 PK24 of COLA-075 into WH/STOCK
    when: purchasing calls inventory's receive route with the receipt line's move_ref
    then: inventory records one move of 168 bottles into WH/STOCK and answers its move id; a retry with the same move_ref records nothing new; with inventory not installed the receipt still stands and the hand-over is answered "not installed"
  - id: PUR-26
    kind: person
    actor: receiver
    story: As whoever takes delivery, I want to send goods back to the supplier and have it count, so that a damaged pallet is neither paid for nor left in stock.
    given: 7 PK24 received on DN-881, 2 damaged
    when: the receiver records a return of 2 PK24 against that receipt with a reason
    then: a return row (a reversing receipt row) is dated today; received becomes 5, inventory is told to move 48 bottles out; the original receipt is unchanged; a return larger than what was received is refused
    screen: Receipts

  # ── 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 INV-5521 dated 2026-10-20 quoting our order PO-2026-0042
    when: the clerk records it with the supplier's own number and date
    then: it is held as a recorded bill with lines against the order lines; recording the same number and date 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 (bp_id, supplier_invoice_no, invoice_date).
      The invoice date is the supplier's, a fact dated by their document, so it may be earlier than today.
  - id: PUR-32
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want the supplier's PDF read into a draft bill for me to check, so that I stop typing invoices but nothing is recorded that I did not see.
    given: a PDF from a supplier's email
    when: the AI read step runs on it
    then: a DRAFT bill with each field (supplier, number, date, lines, VAT) as a suggestion with its evidence; the clerk accepts, corrects or rejects each; only then can it be recorded; every decision is recorded in pur_ai_finding
    screen: Bills
  - id: PUR-11
    kind: system
    actor: system
    trigger: when a bill is recorded, when a receipt or return lands against its order, and on read
    story: The system matches each bill line to the order line and what was received, so that a bill for something nobody ordered or nobody received is caught before it is paid.
    given: an order line at 900.0000 a unit, 7 received, and a bill line for 10 at 950.0000
    when: the match runs
    then: the line fails on price (50.0000 a unit, 5.56%) and on quantity (3 billed beyond received), naming each against the tolerance in force on the bill's date, and the bill cannot be approved until somebody with authority accepts each difference
    note: >-
      THE control of this module. Three-way (order, receipt, bill) for goods, two-way (order, bill) for services,
      chosen per product kind by the owner. Quantities are compared in the line's PRICING unit (version 3; the
      purchase unit when no order price unit is set) through the line's fixed conversion, so a bill in bottles
      matches an order in packs and a bill in kg an order in bars. The result is derived from rows, never stored as a status.
  - id: PUR-12
    kind: person
    actor: payables_approver
    story: As whoever releases money, I want to accept a match difference with a reason, so that a real price rise is not a permanent blockage.
    given: a bill line failing on price by 50.0000 a unit
    when: the approver accepts the variance with a reason
    then: an acceptance row records who (email and sub), when and why; the order is not silently rewritten; the clerk who recorded the bill cannot accept its variance
    screen: Bills
  - id: PUR-36
    kind: system
    actor: system
    trigger: when a bill is recorded against a line with an order price unit, and on read (version 3)
    story: The system matches price and quantity in the unit the price was agreed in, so that a supplier billing by the kilogram for bars ordered by the piece is checked exactly.
    given: "the STEEL-BAR line of PUR-35: 10 C62 ordered and received, priced 3.2000 per KGM with the fixed conversion 2/5; tolerance 2%"
    when: "bill line A arrives as 25 KGM at 3.2500 per KGM; bill line B (another bill) as 10 C62 at 8.1250 a bar; bill line C as 26 KGM at 3.2000"
    then: "A: received 10 C62 = 25 KGM through the line's conversion, so quantity passes; price 0.0500 per KGM above (1.56%) passes the 2% tolerance; amount 81.25. B: the bar price is re-expressed in the pricing unit as an exact fraction (8.1250 / 2.5 = 3.2500 per KGM) and the same answer results. C: quantity fails by 1 KGM beyond received, named against the tolerance in force; every comparison is an exact fraction, rounded only where an amount is shown; billed_claim still claims quantity_base (10 C62)"
    reference: "SAP MIRO / logistics invoice verification in the order price unit (quantity in OPUn), tolerance keys PP / BW; Odoo compares in the line's unit only"
  - id: PUR-17
    kind: person
    actor: payables_approver
    story: As whoever releases money, I want to approve a bill once it matches, so that it is in the books and can be paid.
    given: a recorded bill that matches, or whose differences are accepted, with its tax computed
    when: the approver approves it
    then: the bill number is stamped (BILL-2026-00017), the tax is frozen in the tax module, the bill is posted to the ledger, and the billed quantity is claimed on each order line; a second approval changes nothing
    screen: Bills
  - id: PUR-27
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want to record a supplier's credit note against the bill it corrects, so that a return or an overcharge reduces what we owe without editing anything.
    given: an approved bill for 10 and a return of 2
    when: the clerk records the supplier's credit note for 2
    then: a credit (kind credit) with its own supplier number, matched against the return, approved like a bill; the original bill is never edited; what we owe is reduced through an allocation
    screen: Bills
  - id: PUR-18
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want to put a bill on hold as disputed, with a reason, so that it is not paid while we argue about it.
    given: a recorded or approved bill the site manager disputes
    when: the clerk disputes it with a reason
    then: it is held out of every payment run until the dispute is resolved; the reason and who raised it are recorded
    screen: Bills
  - id: PUR-28
    kind: integration
    actor: system
    owner: tax
    trigger: when a bill or credit is recorded, and when it is approved
    story: Purchasing asks the tax module for the VAT on a bill and keeps the supplier's stated VAT beside it, so that we reclaim exactly what the invoice supports and nothing a scan of a delivery note pretends to.
    given: a bill with two lines (standard and zero-rated) and the supplier's VAT 180.00
    when: purchasing calls tax's compute with the bill as the document, then check-contents and freeze at approval
    then: >-
      tax answers per line the code and rate and a per-rate summary; a difference between the supplier's stated VAT and the computed VAT beyond the tolerance is a match failure named "tax"; a missing supplier VAT number fails the contents gate where the business reclaims VAT; at approval the tax is frozen. v8: check-contents and freeze carry the bill's particulars as the supplier printed them: invoice_number and issue_date (the supplier's number and invoice date), supplier_name, supplier_address (the address shape, shared.yaml conventions.address) and supplier_vat_number (as on the bill, defaulted from tax's GET /parties/{bp_id} and the partner's legal_name when the clerk records the bill, and editable to what the paper says), and customer_name (our company's name, gl's GET /companies/{company_code} name); without them TAX-24's gates are unmet and no bill could be approved with tax installed

  # ── 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: approved 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 as of today in the business time zone, derived on read rather than stored
    screen: Payables
  - id: PUR-19
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want to propose a payment run of what falls due by a date, so that suppliers are paid on a rhythm rather than one at a time.
    given: approved, undisputed bills due by Friday from 12 suppliers
    when: the clerk proposes a run for Friday from bank account BARC-01
    then: a draft run lists one payment per supplier and currency, each with the bills it pays and the amount; disputed bills and bills without approved bank details are left out and named
    screen: Payments
  - 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 invoice and pay it.
    given: a run of 12 payments totalling 40,000.00
    when: the approver releases it
    then: the run is approved with their email, sub and today's date; the approver cannot be the clerk who proposed the run or who recorded any bill in it (email OR sub matching is refused)
    screen: Payments
    note: segregation of duties, the one control an auditor will look for by name
  - id: PUR-29
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want the approved run as a file for the bank, and to confirm what the bank actually paid, so that our books say paid only when the money left.
    given: an approved run
    when: the system writes the bank file and, after the upload, the clerk confirms each payment with the bank's reference and date
    then: the file (pain.001 or CSV) is kept as an object, once; each confirmed payment allocates to its bills (dated by the bank, today or earlier, never later), posts payables against bank, and a payment the bank rejected is reversed with a reversing allocation; DODIL never moves the money itself
    screen: Payments
  - id: PUR-16
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want to record a payment made outside a run, or one covering several bills, so that the ledger matches the bank rather than the other way round.
    given: one transfer of 5,000.00 against three bills
    when: the clerk records and allocates it
    then: each bill's outstanding falls by its share, the payment is recorded once, allocating more than the payment or more than a bill's outstanding is refused, and two clerks allocating the same bill at the same moment cannot over-pay it
    screen: Payments
  - id: PUR-30
    kind: person
    actor: payables_approver
    story: As whoever releases money, I want a change to a supplier's bank details to need a second person, so that a fraudulent "we have changed bank" email cannot redirect a payment run.
    given: a clerk entering new bank details for supplier A
    when: the approver approves the change, which starts tomorrow
    then: the new details are a dated row used by runs from tomorrow; the clerk who entered them cannot approve them (email OR sub); a run proposed today uses today's details
    screen: Suppliers
    note: bank-detail fraud is the commonest loss in payables; the control is drawn in the process, not left to policy

  # ── settings and controls ───────────────────────────────────────────────────────────────
  - id: PUR-31
    kind: person
    actor: owner
    story: As the owner, I want the tolerances, the order threshold and the numbering to be mine to set, from a date, so that the people they control cannot loosen them.
    given: a price tolerance of 2% and a payables approver who wants 10%
    when: the approver tries, and then the owner sets 3% from the first of next month
    then: the approver is refused (purchasing:settings:write cannot change a control); the owner's row is dated, refused if today or earlier, and the match uses the tolerance in force on each bill's date
    screen: Settings

  # ── seams ───────────────────────────────────────────────────────────────────────────────
  - id: PUR-20
    kind: integration
    actor: system
    owner: gl
    trigger: when a bill or credit is approved, and when a payment is confirmed or reversed
    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.00 including 200.00 VAT, and an account map in force on the bill date
    when: purchasing calls the ledger's posting route
    then: expense (or goods received not invoiced) 1,000.00 and input VAT 200.00 are debited and payables 1,200.00 credited; a payment debits payables and credits the bank's account; the journal id is derived from the document and event, so a retry posts once; a closed period is refused and named
  - 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 only by name and the customer's own reference
    when: purchasing calls promote with role supplier
    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 talk to them knowing both balances.
    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 (ours, and invoicing's status route)
    screen: Payables
    note: contra settlement itself is NOT in this module (known_gaps); showing both sides is

  # ── version 4: release levels, sourcing and agreements ──────────────────────────────────
  - id: PUR-38
    kind: person
    actor: spend_approver
    story: As a budget holder, I want an order above a figure to need a second, more senior approver as well, so that a large commitment has two names on it, not one.
    given: "release strategy for category IT from 1 October: up to 4,999.99 one level; from 5,000.00 two levels (level 1 an IT budget holder, level 2 an approver whose limit covers the total); a direct order of 18,000.00"
    when: "the IT budget holder approves level 1, then tries to approve level 2 as well; then the finance director (limit 50,000.00) approves"
    then: "after level 1 the order stays to_approve showing 'level 2 of 2 waiting'; the same person at level 2 (same email OR same sub) is refused second_person; the director's approval completes the release; each level is a row (order, revision, level, who, email and sub, when); the strategy in force on the day the order was submitted applies, so a strategy changed later does not move a pending order; an amendment beyond what was approved starts the levels again; the buyer approves no level; requests follow the same strategy"
    screen: Orders
    reference: "SAP release strategy with release codes and levels (ME28 / ME29N, CL20N classification by value and material group)"
    customer_varies: "how many levels and from what amount; most SMEs need one, with a director's second from a figure"
  - id: PUR-39
    kind: person
    actor: buyer
    story: As a buyer, I want to ask several suppliers to quote for the same thing, so that a large purchase is competed rather than placed with whoever we used last time.
    given: "an approved request for 200 office chairs (30,000.00); suppliers A, B and C; a quote deadline of 14 November"
    when: the buyer raises an RFQ from the request and sends it to the three suppliers
    then: "an RFQ (RFQ-2026-0012 from the series) with its lines, units and deadline, one invitation per supplier recorded with when and to whom; sending twice records a second send and changes nothing else; a supplier blocked on the source list of a product that needs one is refused naming the product; a deadline today or earlier is refused"
    screen: Sourcing
    reference: "SAP request for quotation (ME41) and quotation entry (ME47); Odoo's RFQ is a draft order to one supplier"
  - id: PUR-40
    kind: person
    actor: buyer
    story: As a buyer, I want the suppliers' quotes side by side and to award one with a reason, so that the choice is recorded and an auditor can see why we did not take the cheapest.
    given: "quotes: A 140.0000 a chair, 10 days; B 132.0000, 30 days; C none by the deadline"
    when: "the buyer records A's and B's quotes and awards A with the reason 'B cannot deliver before the office opens'"
    then: "the comparison shows each line per supplier in the line's unit, the net, the delivery days and the cheapest marked; a quote recorded after the deadline is flagged late; awarding a quote that is not the cheapest needs a reason; the award drafts the order keyed rfq:<rfq_id>:<bp_id> with the quoted prices fixed on its lines and the request's approval carried; awarding twice returns the same order; the unsuccessful suppliers are listed to be told"
    screen: Sourcing
    reference: "SAP price comparison list (ME49), quotation acceptance and rejection"
  - id: PUR-41
    kind: person
    actor: buyer
    story: As a buyer, I want a contract with a supplier for a quantity or a value over a period, approved once, so that each order against it is placed at the agreed price without asking for approval again.
    given: "supplier D: a value contract for site consumables, 1 January to 31 December 2027, target 60,000.00, prices for 12 products; supplier E: a quantity contract for 5,000 TNE of aggregate at 21.5000 per TNE"
    when: the buyer drafts both and a budget holder whose limit covers 60,000.00 approves the value contract
    then: "each agreement is keyed ag:<bp_id>:<kind>:<valid_from>:<supplier's contract reference> with agreement_no AG-2027-0003 from the series; its approval (under the release strategy, PUR-38) is the spend approval for every release within its target and validity; the approver cannot be the buyer (email AND sub); a revision raising the target or a price goes back to approval while releases continue under the approved revision; the agreement's prices take precedence over supplier prices for its release orders"
    screen: Agreements
    reference: "SAP outline agreements: quantity contract (MK) and value contract (WK), ME31K, with a release strategy on the contract"
  - id: PUR-42
    kind: person
    actor: buyer
    story: As a buyer, I want to order against a contract and see what is left on it, so that we never go past what was agreed without noticing.
    given: "the value contract of PUR-41 with 52,400.00 released; the quantity contract with 4,800 TNE released"
    when: "the buyer confirms a release order of 9,000.00 against the value contract; two buyers at the same moment confirm 150 TNE each against the quantity contract"
    then: "the value release is refused with the gate within_agreement ('7,600.00 is left: release up to 7,600.00, or revise the contract'); of the two quantity releases one confirms (4,950 released) and the other is refused within_agreement with 50 left, never both (claim rows keyed (agreement_id, line_no, purchase_order_id, order_line_no)); a release dated after valid_to is refused; released and remaining are derived from the claims; a release order needs no further approval within the contract"
    screen: Agreements
    reference: "SAP release orders against contracts (ME21N with outline agreement reference), target value / quantity check, release documentation"
  - id: PUR-43
    kind: person
    actor: buyer
    story: As a buyer, I want a scheduling agreement whose delivery dates and quantities I update each week, each update sent as a numbered release, so that a regular supplier delivers to our plan without a new order every time.
    given: "a scheduling agreement with supplier F for packaging film, 1 November 2026 to 31 October 2027, at 0.8200 per MTR, firm zone 7 days; schedule lines for weeks 46 to 52"
    when: "the buyer changes week 48 from 20,000 to 26,000 MTR and sends a JIT release for weeks 46 to 48 and a forecast release for weeks 49 to 52"
    then: "each send is a release row numbered per agreement (release 7 jit, release 8 forecast) with the schedule lines as they stood, frozen; the JIT release opens a call-off order keyed sa:<agreement_id>:<release_no>, confirmed at once under the agreement's approval, so receipts, bills and the match work as for any order; a forecast release commits nothing; changing a line already released JIT inside the firm zone needs the supplier's agreement (a reason recorded); schedule lines dated before today cannot change"
    screen: Agreements
    reference: "SAP scheduling agreements (ME31L) with delivery schedules (ME38), forecast and JIT release documentation (ME84), firm and trade-off zones"
  - id: PUR-44
    kind: person
    actor: buyer
    story: As a buyer, I want to say which suppliers a product may be bought from, from a date, and block one, so that a new buyer cannot place orders with a supplier we have stopped using.
    given: "STEEL-BAR, source list required: B fixed (preferred), G allowed, H blocked from 1 December"
    when: "a buyer prices a line for STEEL-BAR from H on 2 December, and from K (not on the list)"
    then: "both are refused with the gate source_allowed ('STEEL-BAR may be bought from B or G; H is blocked from 2026-12-01'); a product without source list required may be bought from anyone and the list only proposes; source list rows are dated and start after today (the first row of a product may start today)"
    screen: Sourcing
    reference: "SAP source list (ME01) with fixed and blocked sources, source list requirement on the material"
  - id: PUR-45
    kind: system
    actor: system
    trigger: "when an approved request is turned into an order without a supplier, and when inventory's replenishment asks for a source"
    story: The system proposes the supplier for a quantity from the quota arrangement, so that an agreed split between suppliers (70 / 30) is kept over time and not left to whoever the buyer rings first.
    given: "COLA-075 quota from 1 January: A 70%, B 30%, base quantity 0; confirmed this year A 1,400 PK24, B 400 PK24; a request for 200 PK24"
    when: the proposal is asked
    then: "the quota rating (allocated + base) / quota is A 1,400 / 70 = 20.0 and B 400 / 30 = 13.3, so B is proposed (the lowest rating), with both ratings shown; the buyer may choose A with a reason (recorded on the line); the allocated quantity is DERIVED from confirmed order lines since the quota's valid_from, never a counter; a blocked source is never proposed; shares that do not sum to 100 are refused at entry"
    reference: "SAP quota arrangement (MEQ1), quota rating = (quota-allocated quantity + quota base quantity) / quota"
  - id: PUR-46
    kind: person
    actor: buyer
    story: As a buyer, I want a scorecard per supplier for price, delivery, quantity and quality, so that we renew contracts with suppliers who perform and can show a supplier why we did not.
    given: "supplier A in 2026-Q3: 40 receipts, 34 on or before expected_on plus 2 days' grace; 2 of 40 lines returned with reason quality; billed prices on average 0.8% above order; one delivery short; the buyer's service score 4 of 5; weights price 30, delivery 30, quality 30, service 10"
    when: the buyer opens A's evaluation for 2026-Q3 and freezes it
    then: "each criterion is a score of 1 to 100 derived from rows (delivery 85: 34 of 40 on time; quality 95: 2 of 40 returned; price from the match results; quantity from received vs ordered), service is the buyer's score scaled, and the overall score is the weighted sum; freezing writes the evaluation (bp_id, period_id, seq) for comparison over time; a frozen evaluation never changes (a re-evaluation is a new row with its reason); the weights are owner-controlled dated rows"
    screen: Suppliers
    reference: "SAP vendor evaluation (ME61 / ME63: main and sub-criteria, automatic and manual scores)"

  # ── version 4: down payments, self-billing, tolerance keys ──────────────────────────────
  - id: PUR-47
    kind: person
    actor: buyer
    story: As a buyer, I want to request the down payment a supplier asks for on an order, approved by finance, so that money paid before delivery is tied to the order and recovered from the final bill.
    given: "PO-2026-0060 for a machine, 48,000.00 net; the supplier's terms: 30% with order"
    when: "the buyer requests a down payment of 30% (14,400.00 net, 2,880.00 VAT, 17,280.00 to pay) due 15 November and the payables approver approves it"
    then: "a down-payment request DP-2026-0004 against the order is approved with who (email and sub) and when; it is a noted item, not a liability in the ledger, until paid; requests against an order may not together exceed its total (refused, naming what remains); the buyer cannot approve their own (email OR sub); a request against a cancelled or closed order is refused"
    screen: Orders
    reference: "SAP down payment request (F-47, a noted item) from the PO's down payment fields (ME21N: category, percentage, due date; ME2DP monitor)"
  - id: PUR-48
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want an approved down payment paid in the run like a bill, and the supplier's VAT invoice for it recorded if they send one, so that the prepayment is in the books and the VAT is reclaimed only when the law allows.
    given: "DP-2026-0004 approved, due 15 November; the supplier later issues a VAT invoice for the down payment: 14,400.00 net + 2,880.00 VAT"
    when: "the run of 15 November includes it, is approved by another person and confirmed paid; the clerk records the supplier's VAT invoice for it"
    then: "the payment posts debit supplier down payments 17,280.00, credit bank; the VAT invoice is a bill of kind down_payment_invoice (keyed like any bill), its tax computed and frozen through the tax module, and at its approval 2,880.00 is moved from supplier down payments to input VAT; without a VAT invoice nothing is reclaimed; the down payment shows on the supplier as paid in advance"
    screen: Payments
    reference: "SAP down payment (F-48 or the payment run with special G/L indicator A), tax on down payments"
  - id: PUR-49
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want a down payment cleared against the final bills, in full or in part, so that we pay only the balance and nothing is paid twice.
    given: "DP-2026-0004 (17,280.00, its VAT invoice recorded); the machine billed in two parts of 28,800.00 each (24,000.00 + 4,800.00 VAT)"
    when: "the clerk clears 50% of the down payment against the first bill and the rest against the second"
    then: "each clearing is a row (down payment, bill, seq) claiming 8,640.00 from the down payment; the bill's outstanding falls by it (an allocation of source_kind down_payment), so 20,160.00 is paid on each; the clearing line carries the tax code of the down payment's VAT invoice, so the VAT reclaimed on the down payment is reversed and the bill's VAT counted once; clearing more than remains on the down payment, or more than the bill's outstanding, is refused; two clerks clearing the same down payment at once clear it once (claim row keyed (down_payment_id, bill_id, seq), the down payment's version bumped); a bill whose order has an uncleared down payment is left out of a run until a clearing (or a zero clearing with the reason 'a later bill takes it') is recorded; a down payment left when the order closes is listed with the fix 'clear it, or record the supplier's refund'"
    screen: Bills
    reference: "SAP down payment clearing (F-54) and the open down payment warning in MIRO"
  - id: PUR-50
    kind: person
    actor: payables_approver
    story: As whoever releases money, I want a self-billing agreement per supplier, approved and dated, so that we only issue invoices on a supplier's behalf where the VAT conditions are met.
    given: "supplier F agrees in writing that we raise their invoices; their VAT number on file"
    when: "the clerk records the agreement (signed PDF, 1 December 2026 to 30 November 2027) and the approver approves it"
    then: "an agreement row (bp_id, valid_from) with the signed document as an object raw/purchasing/self-billing/<sha256>.pdf, the supplier's VAT number and its end date; the clerk cannot approve it (email OR sub); an agreement with no end date, longer than the owner's maximum (12 months), or without the supplier's VAT number where they are registered, is refused naming the rule; 30 days before the end it is listed for renewal; without an agreement in force on a receipt's date ERS is refused for that receipt"
    screen: Suppliers
    reference: "SAP ERS indicator on the supplier and info record (LFM1-XERSY); HMRC VAT Notice 700/62 (self-billing)"
  - id: PUR-51
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want receipts from self-billing suppliers settled into invoices we issue for them, so that nobody waits for an invoice the supplier will never send.
    given: "supplier F under the agreement of PUR-50; three November receipts against two orders at 0.8200 per MTR, not yet billed, lines flagged ers"
    when: the clerk runs the ERS settlement up to 30 November
    then: "one self-billed invoice per supplier, currency and tax point (or per receipt, by ers_grouping), numbered from OUR self-billing series (SB-2026-00031, never reused) as the supplier_invoice_no, at the order price for the received quantity, with tax computed through the tax module (direction purchase, self_billed true) and frozen; it carries the particulars a self-billed VAT invoice needs and is sent to the supplier; it is recorded and approved by the run (no match failure is possible: the price is the order's and the quantity the receipt's) and posted; a second run settles nothing new; a receipt already billed by the supplier is never self-billed; a return after settlement becomes a self-billed credit in the next run; the clerk who ran it cannot approve a payment run containing it (PUR-15)"
    screen: Bills
    reference: "SAP evaluated receipt settlement (MRRL); consignment and pipeline settlement (MRKO)"
  - id: PUR-52
    kind: person
    actor: owner
    story: As the owner, I want tolerances per kind of difference and per group of suppliers, with upper and lower limits, so that a trusted supplier's rounding passes and a new supplier's 1% overcharge does not.
    given: "tolerance groups STANDARD and NEW; in NEW: PP price upper 1% or 5.00 (the stricter), lower none; DQ quantity 0%; AN a line without an order 0.00; AP an order line 250.00; ST date 50.00 per day early; KW delivery cost 3%; LA / LD agreement value and validity; BD small difference 2.00"
    when: "the owner sets them from the first of next month; then a NEW supplier's bill arrives 1.10 above the sum of its matched lines"
    then: "each key is a dated row (tolerance_group, key, valid_from) with lower and upper limits as an amount and a percent, the stricter applying; the supplier profile names its group (STANDARD where none); the match names the key of every failure; the 1.10 total difference within BD is accepted by the system, posted to small differences and recorded as an acceptance of kind small_difference (by: system); the settings price_tolerance_pct, price_tolerance_amount, qty_tolerance_pct and tax_tolerance_amount are the STANDARD group's PP, DQ and tax rows; a payables approver is refused (purchasing:settings:controls)"
    screen: Settings
    reference: "SAP tolerance keys for invoice verification (OMR6: AN, AP, BD, BR, BW, DQ, KW, LA, LD, PP, ST, VP) per company and tolerance group (OMRX)"

  # ── version 4: consignment, assets, subcontracting ──────────────────────────────────────
  - id: PUR-53
    kind: person
    actor: buyer
    story: As a buyer, I want a supplier's consignment stock ordered and received onto our shelves as theirs, so that we hold stock without paying for it until we take it.
    given: "supplier A consigns COLA-075; a consignment order line (item category consignment: no price on the line, the price is A's supplier price in force on the day of withdrawal) for 50 PK24"
    when: the delivery arrives into WH/STOCK
    then: "the receipt is recorded as usual (PUR-07) and handed to inventory's POST /receipts with stock_owner_bp_id = A, so inventory books supplier-owned stock (no value to us, no goods received not invoiced); no bill can match a consignment receipt; the line is billed only through withdrawals (PUR-54); returning unused consignment stock is a return to supplier as usual"
    screen: Orders
    reference: "SAP supplier consignment: item category K, special stock K (MIGO 101 K)"
  - id: PUR-54
    kind: integration
    actor: system
    owner: purchasing
    trigger: "when inventory moves supplier-owned stock into our own (an issue, a delivery, a transfer to own stock), and when it reverses one"
    story: Inventory tells purchasing what consignment stock we have taken, so that we owe the supplier for exactly that, at their consignment price on the day, and settle it by their invoice or by self-billing.
    given: "18 PK24 of A's consignment COLA-075 issued on 12 November; A's supplier price 18.0000 per PK24 in force that day"
    when: "inventory calls POST /consignment-withdrawals with move_ref inv:<move_id>"
    then: "a withdrawal row keyed on move_ref (bp_id, product, 18 PK24, 12 November, a fact dated by inventory's move, today or earlier) priced at 324.00, answered {withdrawal_id, unit_price, amount} so inventory values the stock it now owns and credits consignment clearing; a retry with the same move_ref records nothing new; a product with no consignment price in force is refused (409, with its reason) and the stock stays supplier-owned; settlement: A's invoice for the month is matched two-way against the month's withdrawals, or a self-billed invoice is raised by ERS (PUR-51) where an agreement exists; either debits consignment clearing and credits payables; a reversed withdrawal before settlement is a negative row, after it a credit in the next settlement"
  - id: PUR-55
    kind: person
    actor: buyer
    story: As a buyer, I want an order line for equipment assigned to a fixed asset, or an expense to a cost centre or project, so that the invoice lands where it belongs instead of in a general expense.
    given: "an approved request for a van, cost centre SITE-A; fixed-assets composed with class VEH; capitalisation threshold 500.00"
    when: "the buyer orders the van on PO-2026-0051 line 1 with account assignment asset (a new asset in class VEH, or an existing asset_id such as an asset under construction)"
    then: "the line carries account_assignment asset, the asset_id (or asset_class_code to create one at hand-over), company_code and cost_centre; confirm asks fixed-assets' GET /assets/{asset_id} (gate asset_assignable: exists, same company, not retired, above the threshold or class LVA); the receipt is non-valuated (no stock, no GRNI); the bill line debits asset clearing instead of an expense and is handed to fixed-assets at approval (PUR-56); the other assignments are cost_centre (an expense with its cost centre on the journal line), project (project_id kept on the line and the journal line's memo, posted with the project's cost centre) and stock (inventory); the assignment is fixed at confirmation"
    screen: Orders
    reference: "SAP account assignment categories A (asset), K (cost centre), P (project) and blank (stock) on the PO item; non-valuated goods receipt for asset items"
  - id: PUR-56
    kind: integration
    actor: system
    owner: fixed-assets
    trigger: when a bill or credit with an asset-assigned line is approved
    story: Purchasing hands the value of each asset line to the asset register after the bill is approved, so that the asset is capitalised once in every depreciation area.
    given: "BILL-2026-00031 line 1: 32,000.00 net for the van, assigned to asset UK01-2026-VEH-0003-0, VAT reclaimable"
    when: "approve_bill commits and purchasing calls fixed-assets' POST /acquisitions with acquisition_ref pur:BILL-2026-00031:1"
    then: "fixed-assets answers the acquisition transaction and its journals per area; purchasing keeps the transaction id on the bill line; a retry with the same acquisition_ref posts nothing new; non-reclaimable VAT is included in the amount; a supplier credit against the line is the same call with a negative amount; a refusal (asset retired, other company, period closed) keeps the bill approved and lists the hand-over with its reason for the clock worker's retry; with fixed-assets not composed, account assignment asset is not offered"
  - id: PUR-57
    kind: person
    actor: buyer
    story: As a buyer, I want to order work from a subcontractor and send them our components, so that what they hold of ours is tracked and the finished goods carry the components' cost.
    given: "subcontractor S galvanises frames: per finished STEEL-FRAME-GALV we provide 1 STEEL-FRAME-RAW and 0.2 KGM of fixings; a subcontract line for 10 STEEL-FRAME-GALV at 35.0000 each (the fee)"
    when: the buyer confirms the order with its component list and the receiver provides the components
    then: "the line has item category subcontract and components (product, quantity per finished unit, unit), entered or copied from product's bill of materials where one exists; providing calls inventory's POST /subcontract-provisions (move_ref pur:<purchase_order_id>:<line_no>:prov:<seq>), moving 10 RAW and 2 KGM to stock held at S, still ours; providing more than the line needs is refused unless a reason is given; what S holds of ours is shown per order"
    screen: Orders
    reference: "SAP subcontracting: item category L, components on the PO item, provision to the supplier (ME2O / 541), special stock O"
  - id: PUR-58
    kind: integration
    actor: system
    owner: inventory
    trigger: when the receipt of a subcontract line is handed to inventory
    story: When the finished goods come back, purchasing tells inventory to consume the components held at the subcontractor, so that the stock at the supplier falls and the finished goods are valued at the components plus the fee.
    given: "10 STEEL-FRAME-GALV received from S; at S: 10 STEEL-FRAME-RAW and 2 KGM of fixings"
    when: "purchasing hands the receipt to inventory's POST /receipts with consume_provided"
    then: "inventory records the 10 finished and consumes 10 RAW and 2 KGM from the stock at S in one move document, idempotent on move_ref, valuing the finished goods at the components' value plus the fee of 350.00; a component short at S is refused by inventory (409, naming it) and the receipt stands with the hand-over listed; the fee bill matches the line three-way on the finished quantity"
    reference: "SAP subcontracting goods receipt with component consumption (101 with 543 O)"

  # ── version 4: payment files, CIS, delivery costs ───────────────────────────────────────
  - id: PUR-59
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want the run as a BACS Standard 18 file where our bank or bureau takes BACS, so that UK suppliers are paid without anyone retyping a payment.
    given: "a GBP run for 14 UK suppliers; our service user number set (owner's control); the BACS cycle 3 working days"
    when: the approver releases the run and the system writes the file
    then: "with payment_file_format bacs_std18 one Standard 18 file: the header labels (VOL1, HDR1, HDR2, UHL1 carrying the processing day), one 100-character detail record per payment (the supplier's sort code and account, transaction code 99, our sort code and account, the amount in pence, our reference and the supplier's account name), the contra record, and the trailer labels (EOF1, EOF2, UTL1 with the totals and count); kept once as raw/purchasing/runs/<run_id>.std18; the run is refused with bacs_ready where a payee has no UK sort code and 8-digit account, the run is not in GBP, the service user number is empty, or the pay date is less than the BACS cycle away (the earliest BACS pay date shown); a second export returns the same file; pain.001 stays available per bank account"
    screen: Payments
    reference: "SAP payment medium formats for UK BACS (PMW / DMEE); the Bacs Payment Schemes Standard 18 file format"
  - id: PUR-60
    kind: person
    actor: payables_clerk
    story: As accounts payable, I want a subcontractor's bill split into labour and materials and the CIS deduction taken when we pay it, so that we pay the subcontractor the right net and HMRC the deduction.
    given: "subcontractor T, CIS, verified for net payment at 20%; a bill of 5,000.00 net: labour 3,600.00, materials 1,400.00; the VAT under the domestic reverse charge (the tax module's treatment)"
    when: "the clerk records the bill with each line's CIS part, and it is paid in a run"
    then: "each line records cis_part (labour | materials | none; materials only as the subcontractor's own cost of materials); the run proposes 4,280.00 to T (5,000.00 - 20% x 3,600.00) and a deduction of 720.00; at confirm_paid the payment posts debit payables 5,000.00, credit bank 4,280.00, credit CIS liability 720.00, and the deduction is recorded with tax (PUR-61); paying part of a bill deducts in proportion to the labour share paid; a CIS supplier with no verification in force is refused at propose_run (cis_verified) unless tax answers 'unmatched' (30%); a rejected payment reverses the deduction"
    screen: Bills
    reference: "SAP extended withholding tax (FI-WT) with the UK CIS localisation; Finance Act 2004 ss57-77 and the Income Tax (Construction Industry Scheme) Regulations 2005"
  - id: PUR-61
    kind: integration
    actor: system
    owner: tax
    trigger: "when a run is proposed for a CIS subcontractor, and when a payment to one is confirmed or rejected"
    story: Purchasing asks the tax module for the subcontractor's CIS status and records each deduction with it, so that the monthly CIS return and the subcontractor's deduction statement come from the payments actually made.
    given: "the payment of PUR-60 confirmed on 18 November 2026"
    when: "purchasing calls tax's GET /cis/subcontractors/{bp_id} at propose_run and POST /cis/deductions at confirm_paid"
    then: "tax answers the status and rate in force on the payment date; the deduction is recorded keyed pur:<payment_id>:<bill_id> with gross paid, materials, labour, the deduction and the tax month (6 November to 5 December); a retry records nothing new; a rejected payment calls the reversal with the same key; purchasing keeps tax's answer on the payment and never computes the return"
  - id: PUR-62
    kind: person
    actor: buyer
    story: As a buyer, I want freight and duty planned on the order, so that stock is valued at what it really cost to land and the forwarder's bill is checked like any other.
    given: "an import order of 1,000 PK24 at 15.0000 (15,000.00); planned freight 600.00 for the order, payable to forwarder W; duty 4% of the value"
    when: "the order is confirmed and received in full; the supplier bills 15,000.00; W bills 640.00; the duty is billed at 600.00"
    then: "delivery-cost conditions (order or line, kind freight | duty | insurance | other, amount or percent, payee bp_id) are on the order; the receipt hands inventory a unit cost including them (15.0000 + 0.6000 freight + 0.6000 duty per PK24) and the planned costs post to delivery-cost clearing per kind; W's bill matches the freight condition under key KW (3%): 640.00 is 6.67% over and fails until a second person accepts it with a reason; the 40.00 difference is handed to inventory (PUR-63)"
    screen: Orders
    reference: "SAP planned delivery costs (freight and duty conditions on the PO, delivery costs in MIRO), landed cost"
  - id: PUR-63
    kind: integration
    actor: system
    owner: inventory
    trigger: when a delivery-cost bill is approved with a difference from the planned cost
    story: Purchasing tells inventory when the actual delivery cost differs from what it valued the receipt at, so that stock on hand carries the real landed cost.
    given: "the freight difference of 40.00 on 1,000 PK24, 600 still on hand"
    when: "purchasing calls inventory's POST /valuation-adjustments keyed pur:<bill_id>:<line_no>"
    then: "inventory spreads 24.00 onto the 600 on hand and posts 16.00 to price difference for what has left (inventory decides the split from its own moves); a retry changes nothing; with inventory not composed the difference posts to the order line's expense category"

  # ── migration ───────────────────────────────────────────────────────────────────────────
  - id: PUR-34
    kind: system
    actor: system
    trigger: once at cutover, and again after a fix until signed
    story: The system loads the Odoo purchasing history read-only, so that open orders, what arrived, what we owe and what we paid continue here without re-keying.
    given: an Odoo database with confirmed purchase orders, done incoming receipts, vendor bills and refunds, supplier payments
    when: the migration run loads twice
    then: every record is keyed odoo:<db>:<model>:<id>; the second load changes nothing; migrated receipts are NOT handed to inventory (inventory migrates its own moves) and migrated bills are NOT posted (the ledger migrates its journals); a run after signature never rewrites signed history
  - id: PUR-33
    kind: person
    actor: owner
    story: As the owner, I want to sign the migration only when every difference from Odoo is zero or explained, so that go-live starts from numbers I agreed.
    given: a reconciled run with one explained difference
    when: the owner signs
    then: the signature records who (email and sub), when and the run's checksums; signing is refused while a must-equal check differs
    screen: Migration
  - id: PUR-64
    kind: integration
    actor: system
    owner: purchasing
    trigger: when ar issues an invoice from one of our companies to another, and when gl matches intercompany balances
    story: The other company's invoice arrives as a bill here without anyone keying it, so that both sides of an intercompany charge carry the same lines, the same tax and the same reference.
    given: UK01 issues invoice INV-90 of 500.00 + 100.00 VAT to UK02 (trading_partner UK02); both companies are in this installation
    when: ar calls POST /bills/intercompany with bill_id ic:INV-90, and later gl calls GET /intercompany/open?company_code=UK02&trading_partner=UK01&as_at=2026-10-31
    then: a bill of UK02 from the partner company's business partner exists, status recorded, keyed ic:INV-90, with ar's lines, its frozen tax mapped to the purchase codes and ic_reference INV-90 on every line; a second call with the same bill_id records nothing new (200, the same bill); it is matched two-way against ar's lines and approved like any bill, posting with partner_company UK01 and ic_reference; gl's read answers the bill with its outstanding, so the pair matches on ic_reference first
  - id: PUR-65
    kind: integration
    actor: system
    owner: purchasing
    trigger: when sales-order confirms a third-party line, and when inventory's replenishment proposes a purchase
    story: Sales-order and inventory raise purchase requests here rather than orders, so that what is bought is still approved and sourced by purchasing's own rules.
    given: order SO-2026-0042 line 2 (third-party, 10 C62 of KETTLE-01, ship to the customer) confirmed at revision 1; inventory's replenishment for COLA-075 at WH1 proposing 40 PK24 on 2026-11-03
    when: sales-order calls POST /purchase-requests/from-sales-order with request_ref so:SO-2026-0042:2@r1, and inventory calls POST /purchase-requests/from-replenishment with request_ref inv:repl:WH1:COLA-075:2026-11-03
    then: 'two requests exist, each keyed on its request_ref and answered {request_ref, request_id, status: awaiting_approval}; the first carries the ship-to party and address and the sales order line (a drop-ship order follows), the second the warehouse and its goods-in location; a repeat with the same request_ref answers the same request; a product that cannot be bought is refused (422, with its reason) and nothing is written; each then follows the request workflow (approve_request, raise_order) like one raised by a person; v8: ship_to_address is the address object (shared.yaml conventions.address), never a string'
  - id: PUR-66
    kind: integration
    actor: system
    owner: purchasing
    trigger: when payments matches a debit on the bank statement to a supplier payment, and when tax prepares a cash-accounting return
    story: Payments confirms a supplier payment from the bank statement and tax reads what was paid when, so that 'paid' is the bank's word and input VAT under cash accounting follows the money.
    given: run R-2026-44 exported with payment SP-118 to supplier A of 1,200.00 GBP from bank account BA-1, paying bills B-501 and B-502; the statement of 2026-11-04 shows a debit of 1,200.00 with reference R-2026-44/118
    when: payments calls GET /supplier-payments?bank_account_id=BA-1&status=sent, then POST /supplier-payments/SP-118/confirm with bank_ref and paid_on 2026-11-04; tax later calls GET /supplier-payments?from=2026-11-01&to=2026-11-30
    then: 'SP-118 is a candidate and is confirmed with the bank''s reference and date (the same as confirm_paid by a person: allocations per bill, the payment posts); a second confirm answers the same, unchanged; a confirm of an unknown or cancelled payment is refused (409, with its reason); tax''s read lists SP-118 paid on 2026-11-04 with its two bills and amounts'
  - id: PUR-67
    kind: person
    actor: buyer
    story: As a requester or a buyer, I want to cancel a purchase request that is no longer needed, so that nobody approves or orders what we will not buy, and a cancelled sale stops its drop-ship purchase.
    given: 'request R-118 awaiting approval; request R-119 (request_ref so:SO-2026-0042:2@r1) approved; request R-120 ordered as PO-2026-00311, confirmed and sent to the supplier'
    when: 'the requester cancels R-118 with a reason; sales-order calls POST /purchase-requests/R-119/cancel; sales-order calls it for R-120; the buyer then cancels PO-2026-00311 (nothing received or billed) and sales-order calls again for R-120'
    then: >-
      R-118 is cancelled with who, when and why, and leaves the approval queue; R-119 is cancelled (by system, for the
      request sales-order raised) and leaves inventory's expected stock; the first call for R-120 is refused 422 (gate
      not_cancellable: "R-120 is ordered as PO-2026-00311 at <supplier>: cancel the order"), whether or not the supplier
      has confirmed the order, because an ordered request is cancelled only through its order; cancelling
      PO-2026-00311 moves R-120 to cancelled (cancel_with_order), so the second call answers 200 {request_id: R-120,
      status: cancelled}; a repeat answers the same; a closed or refused request is not cancellable (its own end)
    screen: Requests
    reference: not built

processes:
  - key: procure
    pcf_id: 20022
    pcf_name: Manage Supply Chain for Physical Products (procure materials and services)
    reference_names: ["Requisition to Payment", "Source to Pay", "Procure to Pay"]
    brick: purchasing
  - key: payables
    pcf_id: 17058
    pcf_name: Manage Financial Resources (accounts payable)
    reference_names: ["Supplier Invoice to Payment"]
    brick: purchasing

workflows:
  - entity: request
    process: procure
    states: [draft, awaiting_approval, approved, refused, ordered, closed, cancelled]
    terminal: [refused, closed, cancelled]
    transitions:
      - { key: draft_request, name: Ask for what you need, from: none, to: draft, by: requester, effect: "request_id derived from the requester and the moment they asked" }
      - { key: intake_request_from_sales_order, name: Take a request from a confirmed third-party order line (PUR-65), from: none, to: awaiting_approval, by: system, gate: "request_ref so:{sales_order_id}:{line_no}@r{revision} unique (a repeat answers the same); the product can be bought", effect: "a request carrying the ship-to party and address and the sales order line; the order raised from it is a drop-ship order" }
      - { key: intake_request_from_replenishment, name: Take a request from inventory's replenishment (PUR-65), from: none, to: awaiting_approval, by: system, gate: "request_ref inv:repl:{warehouse_id}:{product_id}:{business_date} unique (a repeat answers the same); the product can be bought", effect: "a request for the warehouse's goods-in location, needed by the date inventory named" }
      - { key: submit_request, name: Submit the request, from: draft, to: awaiting_approval, by: requester, gate: "a line, an amount, a cost centre and a reason", effect: "routed to the approvers whose limit in force today covers it" }
      - { key: approve_request_level, name: Approve one release level, from: awaiting_approval, to: awaiting_approval, by: spend_approver, approval: true, gate: "the release strategy in force on the submit date needs more levels than are approved; the approver's limit meets this level; the approver is not the requester and approved no earlier level (email AND sub)", effect: "a release row (request, level, who, email and sub, when) (v4, PUR-38)" }
      - { key: approve_request, name: Approve the spend, from: awaiting_approval, to: approved, by: spend_approver, approval: true, gate: "the final level: the amount is within this approver's limit in force today; the approver is not the requester nor any earlier level's approver (email AND sub)", effect: "who, when (today in the business time zone) and any note recorded" }
      - { key: refuse_request, name: Refuse with a reason, from: awaiting_approval, to: refused, by: spend_approver, requires: reason }
      - { key: raise_order, name: Raise the order, from: approved, to: ordered, by: buyer, gate: "a supplier is chosen", effect: "an order exists, keyed req:<request_id>; the approval travels with it" }
      - { key: close_request, name: Close without ordering, from: approved, to: closed, by: buyer, requires: reason }
      - { key: cancel_request, name: Cancel the request (v8; PUR-67), from: [draft, awaiting_approval, approved], to: cancelled, by: [requester, buyer], requires: reason, gate: "not ordered; a requester cancels only their own request (purchasing:request:write), a buyer any request (purchasing:order:write)", effect: "who (email and sub), when and why recorded; the request leaves the approval queue and inventory's expected stock" }
      - { key: cancel_request_from_sales_order, name: Cancel a request sales-order raised (v8; PUR-67), from: [awaiting_approval, approved], to: cancelled, by: system, requires: reason, gate: "request_ref so:... raised by the caller; not ordered (an ordered request answers not_cancellable naming its order and supplier)", effect: "as cancel_request; a repeat answers the same" }
      - { key: cancel_with_order, name: Cancel the request with its order (v8; PUR-67), from: ordered, to: cancelled, by: system, gate: "the purchase order raised from it (req:<request_id>) is cancelled", effect: "the request ends with the order; the order's reason copied" }
    note: >-
      v8: ordered is no longer an end state: a request ordered stays ordered unless its order is cancelled, which
      cancels the request with it. An ordered request is never cancelled on its own, whether or not the supplier has
      confirmed the order: the commitment is the order's, and cancel_purchase_order (nothing received, nothing billed)
      is the one control over it.
    readiness:
      - { transition: cancel_request, gate: not_ordered, reason: "the request is ordered as {order_no} at {supplier}", fix: "cancel the purchase order (PUR-08); the request follows it" }
      - { transition: cancel_request_from_sales_order, gate: not_cancellable, reason: "the request is ordered as {order_no} at {supplier}", fix: "the buyer cancels the purchase order; then cancel again" }
      - { transition: submit_request, gate: complete, reason: "the request has no line, amount, cost centre or reason", fix: "fill in what is missing" }
      - { transition: approve_request, gate: within_limit, reason: "the amount is above your limit in force today for this category", fix: "an approver with a higher limit (Settings: approval limits)" }
      - { transition: approve_request, gate: second_person, reason: "you asked for this (same email or same sign-in)", fix: "another approver" }
      - { transition: approve_request_level, gate: second_person, reason: "you asked for this, or approved an earlier level (same email or same sign-in)", fix: "another approver" }
      - { transition: approve_request_level, gate: level_limit, reason: "your limit is below this level's threshold", fix: "an approver of this level (Settings: release strategy)" }
      - { transition: raise_order, gate: supplier_known, reason: "the supplier is not in the partner register", fix: "add it (promote, role supplier)" }

  - entity: order
    process: procure
    states: [draft, to_approve, approved, confirmed, closed, cancelled]
    terminal: [closed, cancelled]
    transitions:
      - { key: raise_from_request, name: Draft the order from the request, from: none, to: draft, by: buyer, gate: "an approved request", effect: "keyed req:<request_id>; a second raise returns the same order" }
      - { key: draft_order, name: Draft an order directly, from: none, to: draft, by: buyer, effect: "keyed po:<buyer>:<moment>" }
      - { key: raise_from_quote, name: Draft the order from the awarded quote, from: none, to: draft, by: buyer, gate: "an awarded quote (PUR-40)", effect: "keyed rfq:<rfq_id>:<bp_id>; the quoted prices fixed on the lines; the request's approval carried (v4)" }
      - { key: raise_release_order, name: Draft a release order against a contract, from: none, to: draft, by: buyer, gate: "an active quantity or value contract in its validity", effect: "keyed po:<buyer>:<moment> with agreement_id; prices from the agreement line; the agreement's approval is the spend approval within its target (v4, PUR-42)" }
      - { key: open_call_off, name: Open a call-off from a JIT release, from: none, to: confirmed, by: system, gate: "a JIT release of an active scheduling agreement", effect: "keyed sa:<agreement_id>:<release_no>, confirmed at once with order_no from the series; the agreement claims written (v4, PUR-43)" }
      - { key: price_lines, name: Price the lines in the supplier's unit, from: draft, to: draft, by: buyer, effect: "unit_code and its exact conversion copied from the product; the order price unit, its conversion (the supplier price row's own, else the product's), pricing_quantity and unit_price from the supplier price in force today, fixed on the line; net = quantity_priced x unit_price / pricing_quantity, rounded once; the purchase rounding gate shown (v3); the source list checked, the quota's proposal shown, the account assignment (stock | cost_centre | asset | project), item category (standard | consignment | subcontract), subcontract components and planned delivery costs set (v4)" }
      - { key: accept_rounding, name: Accept an order off the pack rule, from: draft, to: draft, by: spend_approver, approval: true, requires: reason, gate: "a line fails purchase_rounding; the acceptor is not the buyer (email AND sub)", effect: "an override row (order, revision, line, gate purchase_rounding, who, why), before the order is sent for approval or confirmed; the buyer confirms (v3)" }
      - { key: approve_order_level, name: Approve one release level, from: to_approve, to: to_approve, by: spend_approver, approval: true, gate: "the release strategy in force on the submit date needs more levels than are approved; the approver's limit meets this level; not the buyer nor an earlier level's approver (email AND sub)", effect: "a release row (order, revision, level, who) (v4, PUR-38)" }
      - { key: submit_order, name: Send for approval, from: draft, to: to_approve, by: buyer, gate: "no approved request covers the total, and the total is above the owner's order threshold" }
      - { key: approve_order, name: Approve the order, from: to_approve, to: approved, by: spend_approver, approval: true, gate: "the final level: the total is within the approver's limit in force today; the approver is not the buyer nor an earlier level's approver (email AND sub)" }
      - { key: reject_order, name: Send back with a reason, from: to_approve, to: draft, by: spend_approver, requires: reason }
      - { key: confirm_purchase_order, name: Confirm the order, from: [draft, approved], to: confirmed, by: buyer, gate: "within authority (an approved request or approval covers the total, or below the threshold); every line has a unit and a pricing unit that convert; every line meets its purchase rounding or carries an override for this revision (v3); every line's supplier is allowed by the source list; a release order fits what is left on its agreement; an asset-assigned line is assignable; a subcontract line has its components (v4)", effect: "order_no claimed from the series; contents, each line's pricing conversion and account assignment frozen; product told each unit and pricing unit was used; agreement claims written (v4)" }
      - { key: send_order, name: Send it to the supplier, from: confirmed, to: confirmed, by: buyer, effect: "each send recorded with when and to whom" }
      - { key: provide_components, name: Send the subcontractor our components, from: confirmed, to: confirmed, by: receiver, gate: "a subcontract line; not more than the line needs, unless a reason is given", effect: "inventory's POST /subcontract-provisions per component, keyed pur:<purchase_order_id>:<line_no>:prov:<seq>: the components move to stock held at the supplier, still ours (v4, PUR-57)" }
      - { key: receive_order, name: The supplier delivers, from: confirmed, to: confirmed, by: supplier }
      - { key: amend_purchase_order, name: Amend the confirmed order, from: confirmed, to: confirmed, by: buyer, gate: "the new total is still within what was approved; a line is never reduced below what was received or billed", effect: "a new revision row; the previous revision kept" }
      - { key: amend_beyond_approval, name: Amend beyond what was approved, from: confirmed, to: to_approve, by: buyer, gate: "the new total exceeds what was approved" }
      - { key: close_short, name: Close short with a reason, from: confirmed, to: closed, by: buyer, requires: reason, effect: "what was not received stops being outstanding and committed" }
      - { key: finish_purchase_order, name: Finish the order, from: confirmed, to: closed, by: system, gate: "every line received and billed in full (or two-way: billed)" }
      - { key: cancel_purchase_order, name: Cancel the order, from: [draft, to_approve, approved, confirmed], to: cancelled, by: buyer, requires: reason, gate: "nothing received and nothing billed", effect: "a confirmed order keeps its number; v8: the request it was raised from is cancelled with it (cancel_with_order)" }
    note: >-
      received, billed and outstanding are DERIVED from receipt and bill rows; part_received is not a state. A
      stored status and a receipt table disagree the first time someone returns goods.
    readiness:
      - { transition: confirm_purchase_order, gate: authority, reason: "the total is above the order threshold and no approval covers it", fix: "send it for approval (PUR-09)" }
      - { transition: confirm_purchase_order, gate: units_convert, reason: "a line's unit has no exact conversion to the product's base unit", fix: "Product: add the unit with its conversion" }
      - { transition: confirm_purchase_order, gate: purchase_rounding, reason: "a line is not a multiple (or is below the minimum) of its purchase unit's rule: COLA-075 is bought in multiples of 80 PK24", fix: "order the next valid quantity (shown), or ask a second person to accept it with a reason (PUR-37)" }
      - { transition: confirm_purchase_order, gate: pricing_unit_converts, reason: "a line's order price unit has no exact conversion to the product's base unit", fix: "Product: add the unit, or price the line in its order unit" }
      - { transition: accept_rounding, gate: second_person, reason: "you raised this order (same email or same sign-in)", fix: "another budget holder" }
      - { transition: confirm_purchase_order, gate: number_series, reason: "no order number series for this year", fix: "Settings: number format (owner)" }
      - { transition: approve_order, gate: within_limit, reason: "the total is above your limit in force today", fix: "an approver with a higher limit" }
      - { transition: approve_order, gate: second_person, reason: "you raised this order (same email or same sign-in)", fix: "another approver" }
      - { transition: approve_order_level, gate: second_person, reason: "you raised this order, or approved an earlier level (same email or same sign-in)", fix: "another approver" }
      - { transition: confirm_purchase_order, gate: source_allowed, reason: "the supplier is not on the product's source list, or is blocked on the order date", fix: "pick an allowed supplier, or ask the buyer who keeps the list (PUR-44)" }
      - { transition: confirm_purchase_order, gate: within_agreement, reason: "the release is more than what is left on the contract (or the contract has expired)", fix: "release up to what is left, or revise the contract (a new revision approved)" }
      - { transition: confirm_purchase_order, gate: asset_assignable, reason: "the asset does not exist, is of another company, is retired, or the line is below the capitalisation threshold", fix: "pick another asset or class, or expense the line (cost_centre)" }
      - { transition: confirm_purchase_order, gate: components_listed, reason: "a subcontract line has no components", fix: "list what we provide to the supplier" }
      - { transition: provide_components, gate: within_need, reason: "more than the line needs of this component", fix: "lower the quantity, or give the reason (scrap allowance)" }
      - { transition: amend_purchase_order, gate: not_below_received, reason: "a line would fall below what was received or billed", fix: "return the goods first (PUR-26) or ask the supplier for a credit" }
      - { transition: cancel_purchase_order, gate: nothing_received, reason: "goods were received or billed against it", fix: "close it short instead (PUR-08)" }

  - entity: receipt
    process: procure
    states: [recorded, handed_over]
    terminal: [handed_over]
    transitions:
      - { key: record_goods_receipt, name: Record what arrived, from: none, to: recorded, by: receiver, gate: "a confirmed order; the delivery date is today or earlier; each quantity is above zero", effect: "keyed rcpt:<purchase_order_id>:<delivery_note>; quantity also kept in the base unit" }
      - { key: record_return, name: Return goods to the supplier, from: none, to: recorded, by: receiver, requires: reason, gate: "no more than was received on the receipt it names", effect: "a reversing receipt row (kind return) dated today; the original receipt untouched" }
      - { key: hand_to_inventory, name: Tell inventory what arrived and where, from: recorded, to: handed_over, by: system, gate: "inventory is installed", effect: "inventory's POST /receipts (a return: POST /returns-to-supplier) per line, keyed move_ref pur:<receipt_id>:<line_no>; {move_id, quantity_base} answered and the move id kept on the line; a consignment line names stock_owner_bp_id, a subcontract line consume_provided, a line with planned delivery costs a unit cost including them (v4)" }
      - { key: no_stock_kept, name: Keep the receipt without stock, from: recorded, to: handed_over, by: system, gate: "inventory is not installed, the line is a service, or the line is assigned to an asset, a cost centre or a project (non-valuated; v4)" }
    note: "a receipt is a fact and is never edited; a wrong receipt is corrected by a return row, and a missing one by another receipt"
    readiness:
      - { transition: record_goods_receipt, gate: not_future, reason: "a delivery cannot be dated after today", fix: "record it on the day it arrives" }
      - { transition: record_goods_receipt, gate: location, reason: "inventory is installed and the line has no location", fix: "pick the receiving location" }
      - { transition: record_return, gate: within_received, reason: "the return is more than was received on that delivery", fix: "lower the quantity" }

  - entity: bill
    process: payables
    states: [draft, recorded, approved, disputed, cancelled]
    terminal: [approved, cancelled]
    transitions:
      - { key: ai_read_bill, name: "AI reads the supplier's PDF into a draft (suggest)", from: none, to: draft, by: system, gate: "a PDF was received and the step is on", effect: "each field a suggestion with its evidence, in pur_ai_finding" }
      - { key: accept_ai_fields, name: Accept or correct each read field, from: draft, to: draft, by: payables_clerk, effect: "each decision recorded; nothing is recorded without the clerk" }
      - { key: record_bill, name: Record the supplier's invoice or credit, from: [none, draft], to: recorded, by: payables_clerk, gate: "supplier, their number and date, lines against order lines, consignment withdrawals or delivery-cost conditions (or none for a two-way service bill); each line's cis_part for a CIS subcontractor (v4)", effect: "keyed (bp_id, supplier_invoice_no, invoice_date); tax asked of the tax module; the match computed" }
      - { key: record_intercompany_bill, name: Record the bill another of our companies sent (PUR-64), from: none, to: recorded, by: system, gate: "the sending company and the receiving company are both companies of these books; bill_id ic:<invoice_id> unique (a repeat answers the same bill)", effect: "a bill of the receiving company with ar's lines and frozen tax mapped to purchase codes, ic_reference on every line; matched two-way to those lines" }
      - { key: ai_check_bill, name: "AI checks the bill for duplicates and anomalies (suggest)", from: recorded, to: recorded, by: system, gate: "the step is on", effect: "a likely duplicate (same amount, near date, other number), an unusual amount or a changed bank listed for the clerk" }
      - { key: match_bill, name: Match against the order and the receipts, from: recorded, to: recorded, by: system, effect: "per line: price (per pricing quantity of the line's pricing unit), quantity (in the pricing unit, through the line's fixed conversion, v3) and tax, each pass or fail with the difference as an exact fraction and the tolerance in force on the invoice date, named by its key (PP, DQ, AN, AP, ST, KW, LA, LD) for the supplier's tolerance group; a total difference within BD accepted by the system as a small difference (v4); derived, never stored" }
      - { key: accept_variance, name: Accept a match difference with a reason, from: recorded, to: recorded, by: payables_approver, requires: reason, approval: true, gate: "the line fails; the approver is not the clerk who recorded it (email AND sub)", effect: "an acceptance row (bill, line, kind, difference, who, why)" }
      - { key: dispute_bill, name: Dispute the bill, from: [recorded, approved], to: disputed, by: payables_clerk, requires: reason }
      - { key: resolve_bill_dispute, name: Resolve the dispute, from: disputed, to: recorded, by: payables_clerk, requires: outcome, effect: "back to recorded; an approved bill disputed and resolved returns to approved through approve_bill again (a no-op re-check)" }
      - { key: approve_bill, name: Approve the bill, from: recorded, to: approved, by: payables_approver, approval: true, gate: "every line matches or its difference is accepted; tax contents pass; the period is open; the billed quantity still fits under what was received; v8: every cost centre on the bill's lines is open in gl on the invoice date (GET /cost-centres/{cost_centre})", effect: "bill_no claimed; tax frozen; billed quantity claimed per order line; posted to the ledger" }
      - { key: post_bill, name: Post to the ledger, from: approved, to: approved, by: system, gate: "the ledger is installed", effect: "a balanced journal keyed on the bill and event approve; an asset-assigned line debits asset_clearing, a consignment line consignment_clearing, a delivery-cost line delivery_cost_clearing (v4)" }
      - { key: hand_asset_to_register, name: Hand asset lines to the asset register, from: approved, to: approved, by: system, gate: "fixed-assets is installed and a line is assigned to an asset", effect: "fixed-assets' POST /acquisitions per line keyed pur:<bill_id>:<line_no>; the transaction id kept on the line; a refusal listed and retried (v4, PUR-56)" }
      - { key: hand_cost_difference, name: Hand a delivery-cost difference to inventory, from: approved, to: approved, by: system, gate: "a delivery-cost line differs from its planned cost and inventory is installed", effect: "inventory's POST /valuation-adjustments keyed pur:<bill_id>:<line_no> (v4, PUR-63)" }
      - { key: settle_ers, name: Settle receipts by self-billing, from: none, to: approved, by: payables_clerk, gate: "an active self-billing agreement on each receipt's date; order lines flagged ers; receipts not yet billed", effect: "a self-billed invoice per supplier, currency and tax point, numbered from our SB series; tax computed (self_billed true) and frozen; recorded and approved by the run; posted; sent to the supplier (v4, PUR-51)" }
      - { key: cancel_bill, name: Cancel the bill, from: [recorded, disputed], to: cancelled, by: payables_approver, requires: reason, gate: "never approved, nothing allocated" }
    note: >-
      PAID is derived, like invoicing's settlement: outstanding = total - SUM(allocations of payments and credits).
      The match is derived from order, receipt and bill rows plus acceptance rows. An approved bill is never
      edited; a correction is a supplier credit (kind credit, PUR-27). A migrated bill (source_kind odoo) is
      approved history: post_bill and the match never run on it; allocations do.
    readiness:
      - { transition: record_bill, gate: not_duplicate, reason: "this supplier already has that invoice number on that date", fix: "open the existing bill" }
      - { transition: record_bill, gate: order_open, reason: "the order it names is cancelled", fix: "ask the supplier, or record it without an order and dispute it" }
      - { transition: approve_bill, gate: match, reason: "a line fails on price, quantity or tax beyond the tolerance, and no acceptance is recorded", fix: "accept the difference with a reason (PUR-12), or dispute it" }
      - { transition: approve_bill, gate: not_over_billed, reason: "the order line has already been billed for what was received (another bill claimed it)", fix: "wait for the receipt, or dispute" }
      - { transition: approve_bill, gate: tax_content, reason: "tax's content check failed: the supplier's VAT number or a required particular is missing", fix: "ask the supplier for a valid VAT invoice, or record the VAT as not reclaimable" }
      - { transition: approve_bill, gate: period_open, reason: "the invoice date falls in a closed ledger period", fix: "ask the accountant to reopen, o