Procure to pay is everything between someone needing something and the supplier being paid for it. In SAP it runs as a chain of documents: purchase requisition, purchase order, supplier confirmation, goods receipt, supplier invoice and payment.
Two checks decide whether the money goes out:
Did we get it? The goods receipt records what actually arrived, and, for some materials, whether it passed inspection.
Is the bill right? The three-way match compares the invoice with the purchase order (what we agreed) and the goods receipt (what came). If the price or quantity is off by more than an allowed tolerance, SAP still posts the invoice but blocks it for payment.
A blocked invoice waits for a person: a buyer, a warehouse clerk or a quality engineer. Meanwhile the supplier isn't paid and calls to ask why.
Three ideas carry this topic:
The purchase order is a promise in the other direction. It commits the company to pay, but nothing is owed until goods or services arrive.
Matching is the control. Most procure-to-pay problems show up as a mismatch between three documents owned by three teams.
Blocked invoices cost twice. They take people's time, and late payments cost supplier goodwill and early-payment discounts.
Procure to pay decides how much a company pays for what it buys and how well it treats its suppliers. Finance watches days payable outstanding (how long the company takes to pay) and the share of invoices that post without anyone touching them. Purchasing and production watch supplier on-time delivery.
Here is a snapshot from Calder Pumpworks, the fictional pump maker this unit follows. On 23 October 2026, Calder's procure-to-pay worklist shows six items stuck:
What is stuck
Why
Who frees it
Value (USD)
Five motors received a month ago
No invoice from the supplier yet
Accounts payable
4,525.00
Twenty motors ordered, needed 5 November
Supplier hasn't confirmed the order
Buyer
18,100.00
Annual crane inspection, done and invoiced
The service entry sheet isn't approved
The requester
3,800.00
Forty impellers from Calder's German company
Invoiced 40, only 36 received
Goods receiving, with the buyer
13,400.00
Ten motors invoiced 6.6% above the order price
Price outside tolerance
Buyer, with accounts payable
9,650.00
Two hundred seal kits
Still in quality inspection
Quality engineer
10,400.00
The business decision is not "pay faster" or "block more". A block that stops a 600 USD overcharge is doing its job. The decision is how tight the tolerances are, and how fast each block reaches the person who can clear it.
As of October 2026, SAP S/4HANA covers the whole chain as standard. Each step is documented in SAP's training:
Requisitions. Material requirements planning (MRP) can create purchase requisitions directly. People create them by hand or through self-service requisitioning (SAP Learning).
Sourcing. The system proposes a supplier from source lists, contracts and purchasing info records, in a fixed order of priority (SAP Learning). Requests for quotation can run in S/4HANA or go to SAP Ariba Sourcing (SAP Learning).
Confirmations. A confirmation control key says which confirmations the supplier must send, such as an order acknowledgment or a shipping notification. The Monitor Supplier Confirmations app shows what is missing (SAP Learning).
Receipt and inspection. A goods receipt can create an inspection lot in quality management and put the stock into quality inspection until someone records a decision (SAP Learning).
Invoice verification. Tolerances are set per company code. An invoice outside them posts but is blocked for payment (SAP Learning).
Payment. The automatic payment program proposes due items, a person reviews the proposal, and the run pays and clears them (SAP Learning).
Suppliers can collaborate through SAP Business Network. It carries purchase orders, order confirmations, advance ship notices and invoices between buyer and supplier. A standard supplier account is free; an enterprise account, needed for integration and reporting, has fees based on transactions (SAP Learning).
For AI, SAP lists AI-assisted multi-model data extraction in SAP Ariba Invoicing as generally available in its Q2 2026 release highlights (SAP News). Machine-learning proposals in GR/IR reconciliation need a separate subscription license (SAP Learning). Where AI creates value in S/4HANA places these in the wider picture.
"A blocked invoice wasn't posted." It was posted. It is on the books and owed, but held from payment until the block is cleared.
"Tighter tolerances are always safer." Very tight limits block invoices for cents, flood accounts payable and delay payments. The right limit is a trade-off, and it is a decision.
"Three-way match catches every overpayment." It only compares three documents. If the receipt itself is wrong, or the order price was wrong, the match passes.
"Services work like goods." There is no truck at the dock. Someone has to confirm the work was done, on a service entry sheet, before the invoice can match.
"An AI agent should release blocked invoices." It can gather the evidence and recommend. Releasing payment is a person's decision with an audit trail.
Pick one answer for each question. The explanation appears after you choose.
1Calder sends a purchase order for twenty motors. What has changed in its books?
Answer: D. A purchase order posts nothing in finance. The goods receipt posts stock against GR/IR, and the invoice posts the payable to the supplier.
2An invoice is 6.6% above the purchase order price, beyond Calder's tolerance. What does SAP do?
Answer: C. SAP's invoice verification posts an invoice that exceeds the upper tolerance and blocks it for payment. The whole invoice waits until someone clears the cause.
3Accounts payable wants tolerances near zero so no overcharge ever gets through. What is the main cost of that choice?
Answer: A. A very tight limit blocks invoices for trivial differences. People spend time releasing them and suppliers wait; the limit is a trade-off between review effort and money at risk.
4Goods were received a month ago, but no invoice has arrived. Where does this show up?
Answer: B. The goods receipt credited GR/IR and nothing has debited it yet. Old GR/IR items like this one are what a month-end review chases.
5A crane inspection is done and invoiced, but the invoice can't match. What is usually missing?
Answer: A. For services, the approved service entry sheet confirms the work and, in S/4HANA lean services, posts the goods receipt. Until it is approved, there is nothing for the invoice to match.
6A vendor pitches an agent that releases blocked invoices on its own. What should you ask for?
Answer: B. Releasing a payment block is a financial decision with an audit trail. An agent can gather the order, receipt and invoice facts and recommend, while a person in accounts payable decides.
7Calder wants its smaller suppliers to confirm orders and send invoices electronically at no cost to them. Which option fits?
Answer: D. SAP describes the standard supplier account as free, with email-based exchange of basic documents. Enterprise accounts add integration and reporting, with fees based on transactions.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Think of procure to pay as three documents that must agree before money leaves.
The purchase order says what was agreed: material, quantity, price, date.
The goods receipt (or the approved service entry sheet) says what actually arrived.
The supplier invoice says what the supplier wants paid.
Everything before the match prepares one of the three. Everything after it, the payment run, only pays what matched. The GR/IR clearing account is the scoreboard: the receipt credits it, the invoice debits it, and a balance means one side is missing or different. Each block, worklist and AI triage tool in this process reads which of the three disagree and who owns that document.
MRP can create planned orders, purchase requisitions or schedule lines on a scheduling agreement. MRP Live creates schedule lines when a valid scheduling agreement exists and requisitions otherwise. In classic MRP, a planned order must first be converted to a requisition by the MRP controller; a requisition reaches purchasing at once (SAP Learning).
People create requisitions too. In SAP S/4HANA Cloud Public Edition, SAP's training uses Manage Purchase Requisitions - Professional and Create Purchase Requisition - Advanced (SAP Learning). Employees who rarely buy use self-service requisitioning in My Purchase Requisitions - New: they pick from a catalog or describe a free-text item, and ordering the cart creates the requisition (SAP Learning).
Approval comes in two styles. The classic release procedure uses release strategies and release codes (transactions ME54N and ME55); a blocked requisition item can't become a purchase order (SAP Learning). Flexible workflow, available since S/4HANA 1709, defines approval steps in apps such as Manage Workflows for Purchase Requisitions; it can replace or coexist with the classic procedure, chosen per document type (SAP Learning).
Source determination proposes the supplier. In the MRP run, SAP checks in this order: quota arrangement, source list entries flagged for MRP, scheduling agreements, contracts (plant-specific before cross-plant), then info records marked for automatic sourcing. For a requisition created by a person, the order is quota arrangement, source list (the fixed entry wins if there are several), outline agreements, then info records (SAP Learning). Source determination doesn't use the condition technique, so there is no determination analysis to read when it picks the "wrong" supplier: you check each source object.
When there is no source yet, purchasing runs a request for quotation. In S/4HANA, Manage RFQs sends it by SAP Business Network, email or print, or hands it to SAP Ariba Sourcing, whose awarded quotation comes back and creates a purchase order or contract (SAP Learning). Compare Supplier Quotations puts up to three side by side (SAP Learning).
Requisitions become purchase orders by hand in Process Purchase Requisitions, or in a background job "Automatic Creation of Purchase Orders from Requisitions", which needs the automatic-PO flags on both the material and the supplier (SAP Learning).
Level
Holds
Calder example (fictional)
Header
Supplier, company code, purchasing organization and group, payment terms, currency
PO 4500000001, Orrin Motor Works 20000001, CP10, PUUS
Item
Material, plant, quantity, net price, info record, confirmation control key, GR and invoice flags
Item 10: 10 × CP-MTR15 for CP11 at 950.00 USD
Schedule line
Delivery date and quantity, quantity committed by the supplier
10 on 2026-10-05
Account assignment
For consumption items: cost center, project, order or asset
Crane inspection PO 4500000007 to cost center CP11-410
In SAP's model of the Purchase Order API, the item carries GoodsReceiptIsExpected, InvoiceIsExpected, InvoiceIsGoodsReceiptBased, SupplierConfirmationControlKey, PurchasingInfoRecord, IsCompletelyDelivered and IsFinallyInvoiced. The schedule line carries ScheduleLineDeliveryDate, ScheduleLineOrderQuantity and ScheduleLineCommittedQuantity (SAP Cloud SDK model, 2022). Account assignment categories such as K (cost center) send the cost of a consumable to that object at goods receipt instead of to stock (SAP Learning).
The confirmation control key on the item says which confirmations are expected, in which sequence, and whether each counts for MRP and goods receipt. A shipping notification can create an inbound delivery, a separate document that a goods receipt can reference. Monitor Supplier Confirmations and transaction ME2A show the state; reminders go out with ME91F, or ME91FF with S/4HANA output management (SAP Learning). A confirmed date that is relevant for MRP replaces the order's own date in planning, which is why a missing or late confirmation matters to production, not only to the buyer.
The goods receipt is posted with reference to the purchase order, for example in Post Goods Receipt for Purchasing Document. It updates the PO history, creates a material document and, for valuated stock, an accounting document; the movement type drives account determination (SAP Learning). Movement type 101 is the receipt, 102 its reversal, 122 a return delivery to the supplier (SAP Learning).
If quality management is active for the movement type and the material has an active inspection type for origin 01, the receipt creates an inspection lot. With "Post to Inspection Stock" set, the stock waits in quality inspection (SAP Learning). The usage decision moves it: a positive decision to unrestricted-use stock, a negative one to blocked stock (SAP Learning). Quality is covered in more depth in Quality, projects and service.
Services have no delivery to receive. In S/4HANA lean services, the PO item uses product type group "Service (2)" with an account assignment (SAP Learning). The work is recorded in Manage Service Entry Sheets - Lean Services. Saving a sheet has no effect in finance. Approval is automatic or under dual control, and on automatic approval a goods receipt document is posted (SAP Learning). Limit items cover services whose total cost isn't known up front (SAP Learning).
SAP calls the three-way match the standard procedure: the purchase order posts nothing; the goods receipt debits stock (or consumption) and credits GR/IR; the invoice, posted through Logistics Invoice Verification, debits GR/IR and credits the supplier (SAP Learning). Invoices are entered in Create Supplier Invoice or Create Supplier Invoice - Advanced (MIRO), and managed in Manage Supplier Invoices (SAP Learning). With goods-receipt-based invoice verification, each partial receipt becomes its own invoice item and receipts and invoices are matched in the PO history (SAP Learning).
Tolerances are set per company code. SAP groups the tolerance keys by variance type: DQ and DW for quantity, KW, PP and PS for price, BR and BW for PO price quantity, ST for date, AN and AP for amounts, BD for small differences. Below the lower limit the system warns but doesn't block. Above the upper limit it warns, posts the invoice and blocks it for payment, and the block covers the whole invoice even if one item varies. For a standard-price material, a price variance goes to a price difference account; for a moving-average material, to stock if there is stock coverage (SAP Learning). Read each key's exact checks from your system's Customizing; this topic doesn't define them one by one.
Other blocks exist: a manual payment block in the invoice header, a manual item block, and a stochastic block that holds a random sample of invoices above a threshold (SAP Learning).
Blocked invoices are released in Release Blocked Invoices (MRBR), also offered as a launchpad app. The blocking reasons are quantity, price, quality and date, and each becomes invalid on its own: quantity after a further receipt or credit memo, price after a PO price change or a subsequent credit, quality after a positive usage decision, date once the delivery date is reached. Automatic release works only for invoices blocked due to variances, can run in the background with RM08RELEASE, and never releases an invoice that also carries a manual header block (SAP Learning).
That last point shapes triage: the fastest fix for most blocks is to correct the cause (post the missing receipt, record the usage decision, get the credit memo) and let automatic release do the rest.
Three apps support the GR/IR clean-up: Reconcile GR/IR Accounts (F3302) with worklists, statuses, root causes and write-offs; Monitor GR/IR Account Reconciliation (F3303); and GR/IR Process Insights (F5796). With machine learning, which needs a separate subscription license, the service learns from past decisions and proposes the next step, priority and root cause for each item (SAP Learning). Quantity differences that will never be resolved are cleared with Clear GR/IR Clearing Account or MR11, which creates an account maintenance document (SAP Learning).
The automatic payment run has four steps: set the parameters, schedule a proposal, edit the proposal, and schedule the payment run, which posts payments, clears open items and creates payment media. Parameters include company code, payment method, supplier selection and the next payment date. Manage Automatic Payments runs it, Manage Payment Media downloads the bank file and Manage Payment Blocks blocks or unblocks suppliers or items. Open items are cleared only when the run completes (SAP Learning). Items without a valid payment method or bank details land on the proposal's exception list (SAP Learning).
flowchart LR
PR[Requisition<br/>0010000001] --> PO[Purchase order<br/>4500000001]
PO --> CF[Acknowledgment]
PO --> GR[Goods receipt<br/>4900000002]
GR --> J1[Journal<br/>stock vs GR/IR]
GR --> IV[Invoice<br/>5105600001]
IV --> J2[Journal<br/>GR/IR vs supplier]
J2 --> PAY[Payment run]
Step
Posts to finance
Why
Requisition, RFQ, purchase order, confirmation
Nothing
Commitments and preparation
Goods receipt (stock material)
Stock at standard price against GR/IR at PO price; the difference to price difference
Goods arrived; the receipt is valued at the agreed price
Goods receipt (consumable, account assigned)
Cost center, project or order against GR/IR
No stock; the cost goes to the object
Usage decision
A stock move (quality inspection to unrestricted or blocked)
Same value, different stock type
Service entry sheet approved
Cost object against GR/IR, through the goods receipt it posts
The service counts as received
Supplier invoice
GR/IR against the supplier's payable, with any price variance and input tax
The supplier's claim exists now
Payment
Supplier against bank; the open item is cleared
The claim is settled
At Calder (fictional), PO 4500000001 posts 9,200.00 USD to stock at standard, 300.00 USD to price difference and 9,500.00 USD to GR/IR at goods receipt. The invoice for 9,400.00 USD clears the GR/IR and credits 100.00 USD back to price difference. The postings and the account determination behind them are traced in Document flow and how logistics becomes finance, Step 6.
As of 23 October 2026, these released interfaces carry each step. The CDS views come from SAP's Cloudification Repository (file read 23 October 2026), which also marks the classic tables EBAN, EKKO, EKPO, EKET, EKBE, RBKP, RSEG, MSEG, QALS and LFA1 as not to be released and names a successor for each (Cloudification Repository). The OData API names and fields come from SAP's generated API models, published in 2022 (SAP Cloud SDK model, 2022).
The Release function on the supplier invoice API releases a blocked invoice. Use it only in a design where a person triggers the action. SAP also publishes business events for purchasing objects; check their exact names in the event catalog on the SAP Business Accelerator Hub, and see Event-driven agents with SAP events for how to react to them.
You will write one script that does four things. It traces Calder's purchase order 4500000001 from requisition to the planned payment, with the three-way match. It runs the same checks on six more purchases and prints the worklist an accounts payable lead would want each morning. It compares price tolerance limits on past invoices, so you can see the trade-off behind a tolerance design. And it writes the worklist to a file. An optional step counts blocked supplier invoices in SAP's sandbox.
flowchart LR
P[calder_pack/<br/>suppliers, products] --> S[procure_to_pay.py]
SC[Scenario in the script<br/>info records, tolerances,<br/>POs 4500000001-07] --> S
S --> T[Trace and<br/>three-way match]
S --> W[Worklist:<br/>what is stuck, who acts]
S --> TL[Tolerance<br/>trade-off]
S --> M[calder_p2p_worklist.md]
K[SAP sandbox<br/>optional] --> S
In VS Code, create a new file, paste the script below, and save it as procure_to_pay.py inside the unit1b folder.
Read the blocks INFO_RECORDS, the tolerance constants and PURCHASES first. They are Calder's scenario written as Python data. Every value is invented. The tolerance values and block labels such as price block are the course's own, not SAP's tolerance keys or block codes. The service supplier Corvane Lifting Services (20000004) lives only in this script.
"""Procure to pay at Calder: trace one purchase end to end and triage the ones that are stuck.
Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
scenario below (info records, tolerances, open purchase orders, receipts and
invoices) are made up for the course. Field names follow SAP's released APIs
where one exists; every code, value and amount is illustrative, not SAP
standard and not from any real system.
Run it from your course folder:
python unit1b/procure_to_pay.py trace PO 4500000001, then triage Calder's open purchases
python unit1b/procure_to_pay.py --po 4500000004 show the three-way match for one purchase order
python unit1b/procure_to_pay.py --tolerance compare price tolerance limits on past invoices
python unit1b/procure_to_pay.py --write write unit1b/calder_p2p_worklist.md
python unit1b/procure_to_pay.py --sandbox count blocked supplier invoices in SAP's sandbox (needs a key)
Uses only Python's built-in modules. Only --sandbox needs a key and the network.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
from pathlib import Path
HERE = Path(__file__).resolve().parent
PACK = HERE / "calder_pack"
OUT = HERE / "calder_p2p_worklist.md"
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/"
AS_OF = date(2026, 10, 23) # fixed "today", so every run prints the same numbers
# ------------------------------------------------------------ master data
# Info records and the source list, as in the master data topic (fictional).
INFO_RECORDS = {
("20000001", "CP-MTR15"): {"PurchasingInfoRecord": "5300000001", "NetPriceAmount": 905.00, "Days": 21},
("20000010", "CP-IMP200"): {"PurchasingInfoRecord": "5300000002", "NetPriceAmount": 335.00, "Days": 28},
("20000003", "CP-SK200"): {"PurchasingInfoRecord": "5300000003", "NetPriceAmount": 52.00, "Days": 10},
}
SOURCE_LIST = {"CP-MTR15": "20000001", "CP-IMP200": "20000010"} # fixed sources in plant CP11
TERMS_DAYS = {"NT30": 30, "NT45": 45, "NT60": 60}
SERVICE_SUPPLIER = {"20000004": "Corvane Lifting Services"} # not in the pack: services only
# Calder's tolerance rules for company code CP10. Course values, not SAP defaults.
# SAP sets tolerance limits per company code; these mimic a price and a quantity check.
PRICE_UPPER_PCT = 5.0 # block when the invoice price is more than 5% above the PO price ...
PRICE_UPPER_AMOUNT = 250.0 # ... or more than 250.00 USD above it for the item
PRICE_LOWER_PCT = -10.0 # below the PO price by more than 10%: a message, but no block
CONFIRM_WITHIN_DAYS = 3 # Calder expects an order acknowledgment within 3 days
GRIR_AGE_DAYS = 30 # received but not invoiced for 30 days: chase the invoice
PAYMENT_RUNS = [date(2026, 10, 27) + timedelta(weeks=w) for w in range(10)] # Tuesdays
# ------------------------------------------------------------ the scenario
# One dict per purchase order item (each PO here has one item). Dates are ISO strings.
PURCHASES = [
{"PurchaseOrder": "4500000001", "Requisition": "0010000001", "ReqBy": "MRP", "ReqDate": "2026-09-11",
"Supplier": "20000001", "Material": "CP-MTR15", "Qty": 10, "Price": 950.00, "PODate": "2026-09-12",
"Needed": "2026-10-05", "PriceNote": "buyer agreed an expedite price",
"Confirmation": {"Date": "2026-09-14", "DeliveryDate": "2026-10-03"},
"GoodsReceipts": [{"MaterialDocument": "4900000002", "Date": "2026-10-05", "Qty": 10, "Stock": "unrestricted"}],
"Invoice": {"SupplierInvoice": "5105600001", "Date": "2026-10-09", "Qty": 10, "Price": 940.00}},
{"PurchaseOrder": "4500000002", "Requisition": "0010000002", "ReqBy": "MRP", "ReqDate": "2026-10-14",
"Supplier": "20000001", "Material": "CP-MTR15", "Qty": 20, "Price": 905.00, "PODate": "2026-10-15",
"Needed": "2026-11-05", "Confirmation": None, "GoodsReceipts": [], "Invoice": None},
{"PurchaseOrder": "4500000003", "Requisition": "0010000003", "ReqBy": "MRP", "ReqDate": "2026-09-30",
"Supplier": "20000003", "Material": "CP-SK200", "Qty": 200, "Price": 52.00, "PODate": "2026-10-01",
"Needed": "2026-10-15", "Confirmation": {"Date": "2026-10-02", "DeliveryDate": "2026-10-15"},
"GoodsReceipts": [{"MaterialDocument": "4900000011", "Date": "2026-10-16", "Qty": 200,
"Stock": "quality inspection", "InspectionLot": "010000000041"}],
"Invoice": {"SupplierInvoice": "5105600002", "Date": "2026-10-19", "Qty": 200, "Price": 52.00}},
{"PurchaseOrder": "4500000004", "Requisition": "0010000004", "ReqBy": "MRP", "ReqDate": "2026-09-19",
"Supplier": "20000001", "Material": "CP-MTR15", "Qty": 10, "Price": 905.00, "PODate": "2026-09-20",
"Needed": "2026-10-12", "Confirmation": {"Date": "2026-09-21", "DeliveryDate": "2026-10-11"},
"GoodsReceipts": [{"MaterialDocument": "4900000009", "Date": "2026-10-12", "Qty": 10, "Stock": "unrestricted"}],
"Invoice": {"SupplierInvoice": "5105600003", "Date": "2026-10-14", "Qty": 10, "Price": 965.00}},
{"PurchaseOrder": "4500000005", "Requisition": "0010000005", "ReqBy": "MRP", "ReqDate": "2026-09-07",
"Supplier": "20000010", "Material": "CP-IMP200", "Qty": 40, "Price": 335.00, "PODate": "2026-09-08",
"Needed": "2026-10-06", "Confirmation": {"Date": "2026-09-09", "DeliveryDate": "2026-10-06"},
"GoodsReceipts": [{"MaterialDocument": "4900000008", "Date": "2026-10-08", "Qty": 36, "Stock": "unrestricted"}],
"Invoice": {"SupplierInvoice": "5105600004", "Date": "2026-10-10", "Qty": 40, "Price": 335.00}},
{"PurchaseOrder": "4500000006", "Requisition": "0010000006", "ReqBy": "manual", "ReqDate": "2026-08-24",
"Supplier": "20000001", "Material": "CP-MTR15", "Qty": 5, "Price": 905.00, "PODate": "2026-08-25",
"Needed": "2026-09-18", "Confirmation": {"Date": "2026-08-26", "DeliveryDate": "2026-09-16"},
"GoodsReceipts": [{"MaterialDocument": "4900000004", "Date": "2026-09-18", "Qty": 5, "Stock": "unrestricted"}],
"Invoice": None},
{"PurchaseOrder": "4500000007", "Requisition": "0010000007", "ReqBy": "manual", "ReqDate": "2026-09-28",
"Supplier": "20000004", "Material": "", "Service": "Annual inspection, overhead crane",
"CostCenter": "CP11-410", "Qty": 1, "Price": 3800.00, "PODate": "2026-09-29", "Needed": "2026-10-13",
"Confirmation": None, "GoodsReceipts": [],
"ServiceEntrySheet": {"ServiceEntrySheet": "1000000001", "Submitted": "2026-10-14", "Approved": None},
"Invoice": {"SupplierInvoice": "5105600005", "Date": "2026-10-20", "Qty": 1, "Price": 3800.00}},
]
# Price variances (percent of PO price) on Calder's last 24 PO-based invoices. Made up,
# fixed so that --tolerance prints the same table every time.
PAST_VARIANCES = [0.0, 0.0, 0.4, -1.2, 0.0, 2.1, 0.0, 3.4, 0.8, 0.0, -0.5, 6.2,
0.0, 1.5, 0.0, 4.8, 0.0, 0.0, 9.5, 0.3, 0.0, -2.0, 2.7, 12.0]
PAST_ITEM_VALUE = 4000.00 # USD per invoice item, the same for every past invoice in this sketch
OWNER = {
"confirmation missing": "Buyer (purchasing group)",
"quality block": "Quality engineer (usage decision)",
"price block": "Buyer, with accounts payable",
"quantity block": "Goods receiving, with the buyer",
"received, not invoiced": "Accounts payable (ask the supplier)",
"service not approved": "Requester (approve the service entry sheet)",
}
# --------------------------------------------------------------- helpers
def d(text: str) -> date:
return date.fromisoformat(text)
def money(x: float) -> str:
return f"{x:,.2f}"
def load_pack() -> dict:
try:
return {name: json.loads((PACK / f"{name}.json").read_text(encoding="utf-8"))
for name in ("business_partners", "products")}
except FileNotFoundError:
sys.exit(f"Cannot read {PACK}. Run: python unit1b/make_calder_pack.py (Unit 1B setup).")
def suppliers(pack: dict) -> dict:
"""Supplier number -> (name, payment terms), from the pack's business partners."""
out = {}
for bp in pack["business_partners"]["BusinessPartners"]:
if bp["Supplier"]:
company = bp["to_Supplier"]["to_SupplierCompany"][0]
out[bp["Supplier"]] = (bp["BusinessPartnerFullName"], company["PaymentTerms"])
for number, name in SERVICE_SUPPLIER.items():
out[number] = (name, "NT30")
out["20000010"] = ("Calder GmbH (intercompany)", out["20000010"][1]) # shorter name for tables
return out
def standard_prices(pack: dict) -> dict:
return {p["Product"]: p["to_Valuation"][0]["StandardPrice"] for p in pack["products"]["Products"]}
def received(po: dict) -> int:
return sum(gr["Qty"] for gr in po["GoodsReceipts"])
def due_date(po: dict, terms: dict) -> date:
"""Baseline date = invoice date in this sketch; due = baseline + the terms' days."""
return d(po["Invoice"]["Date"]) + timedelta(days=TERMS_DAYS[terms[po["Supplier"]][1]])
def payment_run_for(due: date) -> date:
"""Calder pays in the last weekly run on or before the due date (the script's own rule)."""
runs = [r for r in PAYMENT_RUNS if r <= due]
return runs[-1] if runs else PAYMENT_RUNS[0]
# ------------------------------------------------------- three-way match
def match(po: dict) -> dict:
"""Compare PO, goods receipt and invoice the way a GR-based check would.
Returns the checks, any block reasons and the GR/IR balance for the item."""
checks, blocks = [], []
inv = po["Invoice"]
got = received(po)
gr_value = round(got * po["Price"], 2) # GR/IR is credited at the PO price
if inv is None:
return {"checks": [("invoice", "none received yet")], "blocks": [],
"grir": 0.0 - gr_value, "variance": 0.0}
if po.get("ServiceEntrySheet") and not po["ServiceEntrySheet"]["Approved"]:
ses = po["ServiceEntrySheet"]
checks.append(("service entry", f"sheet {ses['ServiceEntrySheet']} submitted {ses['Submitted']}, not approved"))
blocks.append("service not approved")
return {"checks": checks, "blocks": blocks, "grir": 0.0, "variance": 0.0}
# Quantity: with GR-based checking, the invoice may not bill more than was received.
if inv["Qty"] > got:
checks.append(("quantity", f"invoiced {inv['Qty']}, received {got}, ordered {po['Qty']}"))
blocks.append("quantity block")
else:
checks.append(("quantity", f"invoiced {inv['Qty']} = received {got}"))
# Price: invoice price against the PO price, per Calder's tolerance rules.
diff_each = inv["Price"] - po["Price"]
pct = round(100 * diff_each / po["Price"], 2)
diff_item = round(diff_each * inv["Qty"], 2)
if pct > PRICE_UPPER_PCT or diff_item > PRICE_UPPER_AMOUNT:
checks.append(("price", f"{money(inv['Price'])} vs PO {money(po['Price'])} ({pct:+.2f}%, "
f"{money(diff_item)} USD): above Calder's limit"))
blocks.append("price block")
elif pct < PRICE_LOWER_PCT:
checks.append(("price", f"{pct:+.2f}% below the PO price: message, no block"))
else:
checks.append(("price", f"{money(inv['Price'])} vs PO {money(po['Price'])} ({pct:+.2f}%): within tolerance"))
# Quality: stock still in quality inspection means no usage decision yet.
lots = [gr["InspectionLot"] for gr in po["GoodsReceipts"] if gr["Stock"] == "quality inspection"]
if lots:
checks.append(("quality", f"inspection lot {lots[0]} has no usage decision; stock in quality inspection"))
blocks.append("quality block")
# GR/IR: credited at GR (qty x PO price), debited at invoice (qty x PO price).
# A price difference goes elsewhere (here: a price difference account), not to GR/IR.
grir = round(inv["Qty"] * po["Price"] - gr_value, 2) + 0.0 # + 0.0 turns -0.0 into 0.0
return {"checks": checks, "blocks": blocks, "grir": grir, "variance": diff_item}
def evaluate(pack: dict) -> list:
"""Run the checks on every open purchase and record what is stuck, with value at risk."""
terms = suppliers(pack)
rows = []
for po in PURCHASES:
m = match(po)
problems = list(m["blocks"])
conf = po["Confirmation"]
if conf is None and not po.get("Service") and not po["GoodsReceipts"] \
and (AS_OF - d(po["PODate"])).days > CONFIRM_WITHIN_DAYS:
problems.append("confirmation missing")
if po["Invoice"] is None and po["GoodsReceipts"]:
age = (AS_OF - d(po["GoodsReceipts"][-1]["Date"])).days
if age >= GRIR_AGE_DAYS:
problems.append("received, not invoiced")
if po["Invoice"]:
when = due_date(po, terms)
else:
when = d(po["Needed"])
value = round(po["Invoice"]["Qty"] * po["Invoice"]["Price"], 2) if po["Invoice"] \
else round(po["Qty"] * po["Price"], 2)
rows.append({"po": po, "match": m, "problems": problems, "when": when, "value": value})
return rows
def detail(row: dict) -> str:
kind = row["problems"][0]
po, m = row["po"], row["match"]
if kind == "confirmation missing":
return f"PO sent {po['PODate']}, no acknowledgment after {(AS_OF - d(po['PODate'])).days} days"
if kind == "received, not invoiced":
return f"GR {po['GoodsReceipts'][-1]['Date']}, GR/IR credit {money(-m['grir'])} open"
return next(text for check, text in m["checks"]
if check in kind or (kind == "service not approved" and check == "service entry"))
# --------------------------------------------------------------- trace
def trace_4500000001(pack: dict, show: bool = True) -> list:
"""Rebuild PO 4500000001 from requisition to payment and print each step."""
po = PURCHASES[0]
terms = suppliers(pack)
std = standard_prices(pack)
name = terms[po["Supplier"]][0]
info = INFO_RECORDS[(po["Supplier"], po["Material"])]
gr = po["GoodsReceipts"][0]
inv = po["Invoice"]
m = match(po)
due = due_date(po, terms)
run = payment_run_for(due)
stock_value = po["Qty"] * std[po["Material"]]
steps = [
(po["ReqDate"], f"Requisition {po['Requisition']}", "MRP run / planner",
f"{po['Qty']} x {po['Material']} needed {po['Needed']}"),
(po["ReqDate"], f"Source: {name}", "Purchasing",
f"fixed source list entry; info record {info['PurchasingInfoRecord']}, "
f"{money(info['NetPriceAmount'])} USD, {info['Days']} days"),
(po["PODate"], f"Purchase order {po['PurchaseOrder']}", "Buyer",
f"{money(po['Price'])} each ({po['PriceNote']}), net {money(po['Qty'] * po['Price'])} USD"),
(po["Confirmation"]["Date"], "Order acknowledgment", "Supplier",
f"delivery confirmed for {po['Confirmation']['DeliveryDate']}"),
(gr["Date"], f"Goods receipt {gr['MaterialDocument']}", "Goods receiving",
f"stock {money(stock_value)} at standard, GR/IR credit {money(gr['Qty'] * po['Price'])}"),
(inv["Date"], f"Supplier invoice {inv['SupplierInvoice']}", "Accounts payable",
f"{money(inv['Qty'] * inv['Price'])} USD, matched, not blocked; due {due.isoformat()}"),
(run.isoformat(), "Payment run (planned)", "Accounts payable",
f"pays {money(inv['Qty'] * inv['Price'])} USD and clears the supplier item"),
]
if not show:
return steps
print("Purchase order 4500000001, end to end (fictional Calder data)\n")
for when, doc, team, text in steps:
print(f" {when} {doc:<33} {team:<19} {text}")
print("\nThree-way match for item 10 (PO, goods receipt, invoice):")
for check, text in m["checks"]:
print(f" {check:<9} {text}")
print(f" GR/IR balance after the invoice: {money(m['grir'])}")
if po["Price"] != info["NetPriceAmount"]:
pct = 100 * (po["Price"] - info["NetPriceAmount"]) / info["NetPriceAmount"]
print(f" note the PO price is {pct:+.1f}% above info record {info['PurchasingInfoRecord']}; "
"the match checks the invoice against the PO, not the info record")
late = (d(gr["Date"]) - d(po["Confirmation"]["DeliveryDate"])).days
print("\nCycle times:")
print(f" requisition to PO {(d(po['PODate']) - d(po['ReqDate'])).days} day")
print(f" PO to goods receipt {(d(gr['Date']) - d(po['PODate'])).days} days ({late} days after the confirmed date)")
print(f" receipt to invoice {(d(inv['Date']) - d(gr['Date'])).days} days")
print(f" invoice to payment {(run - d(inv['Date'])).days} days (planned; terms "
f"{terms[po['Supplier']][1]}, due {due.isoformat()})")
return steps
# ----------------------------------------------------------------- worklist
def worklist(rows: list) -> list:
"""Stuck items, most urgent first: earliest due or needed date, then most value."""
return sorted([r for r in rows if r["problems"]], key=lambda r: (r["when"], -r["value"]))
def print_worklist(pack: dict, rows: list) -> None:
who = suppliers(pack)
items = worklist(rows)
print(f"\nProcure-to-pay worklist as of {AS_OF.isoformat()} (fictional Calder data)\n")
print(f" {'#':<2} {'PO':<11} {'Supplier':<26} {'Due':<10} {'Problem':<23} {'Value':>10} Owner")
for n, r in enumerate(items, start=1):
kind = r["problems"][0]
past = " (past)" if r["when"] < AS_OF else ""
print(f" {n:<2} {r['po']['PurchaseOrder']:<11} {who[r['po']['Supplier']][0]:<26} "
f"{r['when'].isoformat():<10} {kind:<23} {money(r['value']):>10} {OWNER[kind]}{past}")
total = sum(r["value"] for r in items)
credit = -sum(r["match"]["grir"] for r in rows if r["match"]["grir"] < 0)
debit = sum(r["match"]["grir"] for r in rows if r["match"]["grir"] > 0)
print(f"\n {len(items)} items stuck, {money(total)} USD held. "
f"GR/IR open: {money(credit)} credit (received, not invoiced), "
f"{money(debit)} debit (invoiced, not received).")
print("\nPayment runs (Calder pays every Tuesday):")
for r in rows:
inv = r["po"]["Invoice"]
if not inv:
continue
if r["match"]["blocks"]:
print(f" invoice {inv['SupplierInvoice']} held: {', '.join(r['match']['blocks'])}")
else:
print(f" invoice {inv['SupplierInvoice']} due {r['when'].isoformat()}, "
f"paid in the run on {payment_run_for(r['when']).isoformat()}")
def show_po(pack: dict, rows: list, number: str) -> int:
row = next((r for r in rows if r["po"]["PurchaseOrder"] == number), None)
if not row:
print(f"PO {number} is not in the scenario. Try one of: "
+ ", ".join(r["po"]["PurchaseOrder"] for r in rows))
return 1
po = row["po"]
what = po.get("Service") or f"{po['Qty']} x {po['Material']}"
print(f"PO {number} from {suppliers(pack)[po['Supplier']][0]}: {what} at {money(po['Price'])} USD, "
f"needed {po['Needed']}\n")
for check, text in row["match"]["checks"]:
mark = "STUCK" if any(check in b for b in row["match"]["blocks"]) or \
(check == "service entry" and "service not approved" in row["match"]["blocks"]) else \
("INFO " if check == "invoice" else "OK ")
print(f" {mark} {check:<22} {text}")
for kind in row["problems"]:
if kind not in row["match"]["blocks"]:
print(f" STUCK {kind:<22} {detail({**row, 'problems': [kind]})}")
print(f"\n GR/IR balance: {money(row['match']['grir'])}")
if row["problems"]:
print(f" Next: {OWNER[row['problems'][0]]}.")
return 0
# --------------------------------------------------------- tolerance design
def tolerance_table() -> int:
"""How many past invoices would block, and how much variance passes unreviewed, per limit."""
print(f"Price tolerance on Calder's last {len(PAST_VARIANCES)} PO-based invoices "
f"({money(PAST_ITEM_VALUE)} USD per item; fictional)\n")
print(" Percentage limit only; Calder's amount limit is left out here.\n")
print(f" {'Upper limit':<12} {'Blocked':>8} {'Share':>7} {'Overcharge passed unreviewed (USD)':>36}")
for limit in (1.0, 2.0, 5.0, 10.0):
blocked = [v for v in PAST_VARIANCES if v > limit]
passed = sum(v for v in PAST_VARIANCES if 0 < v <= limit) * PAST_ITEM_VALUE / 100
share = 100 * len(blocked) / len(PAST_VARIANCES)
print(f" {limit:>5.1f}% {len(blocked):>8} {share:>6.0f}% {money(passed):>36}")
print("\nA tighter limit blocks more invoices (more work in accounts payable);"
"\na looser one lets more overcharge through. Synthetic data: it shows the trade-off, not a benchmark.")
return 0
# --------------------------------------------------------------- deliverable
def write_markdown(pack: dict, steps: list, rows: list) -> None:
who = suppliers(pack)
fence = "`" * 3
out = ["# Calder procure to pay: one purchase traced, open purchases triaged", "",
"All data is fictional (Calder Pumpworks sample pack and the scenario in procure_to_pay.py).", "",
fence + "mermaid", "flowchart LR",
" PR[Requisition<br/>from MRP] --> PO[Purchase order] --> CF[Confirmation]",
" CF --> GR[Goods receipt<br/>GR/IR credit] --> IV[Invoice<br/>three-way match] --> PAY[Payment run]",
" IV -.-> BLK[Blocked<br/>for payment]", fence, "",
"## Purchase order 4500000001", "", "| Date | Document | Team | What happened |",
"| --- | --- | --- | --- |"]
out += [f"| {w} | {doc} | {t} | {x} |" for w, doc, t, x in steps]
out += ["", f"## Worklist as of {AS_OF.isoformat()}", "",
"| # | PO | Supplier | Due | Problem | Detail | Value (USD) | Owner |",
"| --- | --- | --- | --- | --- | --- | ---: | --- |"]
for n, r in enumerate(worklist(rows), start=1):
kind = r["problems"][0]
out.append(f"| {n} | {r['po']['PurchaseOrder']} | {who[r['po']['Supplier']][0]} | {r['when'].isoformat()} |"
f" {kind} | {detail(r)} | {money(r['value'])} | {OWNER[kind]} |")
out += ["", "Synthetic numbers: they show the method, not results from an SAP system.", ""]
OUT.write_text("\n".join(out), encoding="utf-8")
print(f"wrote unit1b/{OUT.name}")
# ------------------------------------------------------------------ sandbox
def read_env_key(name: str) -> str:
if os.environ.get(name):
return os.environ[name]
env = Path(".env")
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
key, sep, value = line.partition("=")
if sep and key.strip() == name:
return value.strip().strip('"').strip("'")
return ""
def sandbox() -> int:
"""Count supplier invoices with a payment block in SAP's demo data. Codes are printed
as they come, because their meanings are configuration: read them, don't assume them."""
key = read_env_key("SAP_API_KEY")
if not key:
sys.exit("SAP_API_KEY is not set. Add it to .env (course setup topic), or run without --sandbox.")
fields = ("SupplierInvoice,FiscalYear,CompanyCode,InvoicingParty,InvoiceGrossAmount,"
"DocumentCurrency,PaymentBlockingReason,SupplierInvoiceStatus")
url = SANDBOX + f"API_SUPPLIERINVOICE_PROCESS_SRV/A_SupplierInvoice?$top=50&$select={fields}&$format=json"
request = urllib.request.Request(url, headers={"APIKey": key, "Accept": "application/json"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
invoices = json.load(response)["d"]["results"]
except urllib.error.HTTPError as err:
sys.exit(f"Sandbox answered HTTP {err.code}. 401 means the key is wrong; see the table below.")
except (urllib.error.URLError, TimeoutError) as err:
sys.exit(f"Cannot reach the sandbox: {getattr(err, 'reason', err)}. Check your network or proxy, or use the scenario.")
print(f"SAP sandbox: {len(invoices)} supplier invoices read (SAP demo data, not Calder's)\n")
for field in ("SupplierInvoiceStatus", "PaymentBlockingReason"):
counts = {}
for inv in invoices:
value = inv.get(field) or "(empty)"
counts[value] = counts.get(value, 0) + 1
print(f" {field}: " + ", ".join(f"'{k}' x{v}" for k, v in sorted(counts.items())))
blocked = [inv["SupplierInvoice"] for inv in invoices if inv.get("PaymentBlockingReason")]
print(f" PaymentBlockingReason set on {len(blocked)} invoices"
+ (f", e.g. {', '.join(blocked[:3])}" if blocked else ""))
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Trace and triage Calder's procure to pay.")
parser.add_argument("--po", help="show the three-way match for one purchase order, e.g. 4500000004")
parser.add_argument("--tolerance", action="store_true", help="compare price tolerance limits")
parser.add_argument("--write", action="store_true", help="write the worklist to unit1b/calder_p2p_worklist.md")
parser.add_argument("--sandbox", action="store_true", help="count blocked supplier invoices in SAP's sandbox")
args = parser.parse_args()
if args.sandbox:
return sandbox()
if args.tolerance:
return tolerance_table()
pack = load_pack()
rows = evaluate(pack)
if args.po:
return show_po(pack, rows, args.po)
if args.write:
write_markdown(pack, trace_4500000001(pack, show=False), rows)
return 0
trace_4500000001(pack)
print_worklist(pack, rows)
return 0
if __name__ == "__main__":
sys.exit(main())
You should see exactly this. The script has no random parts, so your numbers must match:
Purchase order 4500000001, end to end (fictional Calder data)
2026-09-11 Requisition 0010000001 MRP run / planner 10 x CP-MTR15 needed 2026-10-05
2026-09-11 Source: Orrin Motor Works Purchasing fixed source list entry; info record 5300000001, 905.00 USD, 21 days
2026-09-12 Purchase order 4500000001 Buyer 950.00 each (buyer agreed an expedite price), net 9,500.00 USD
2026-09-14 Order acknowledgment Supplier delivery confirmed for 2026-10-03
2026-10-05 Goods receipt 4900000002 Goods receiving stock 9,200.00 at standard, GR/IR credit 9,500.00
2026-10-09 Supplier invoice 5105600001 Accounts payable 9,400.00 USD, matched, not blocked; due 2026-11-23
2026-11-17 Payment run (planned) Accounts payable pays 9,400.00 USD and clears the supplier item
Three-way match for item 10 (PO, goods receipt, invoice):
quantity invoiced 10 = received 10
price 940.00 vs PO 950.00 (-1.05%): within tolerance
GR/IR balance after the invoice: 0.00
note the PO price is +5.0% above info record 5300000001; the match checks the invoice against the PO, not the info record
Cycle times:
requisition to PO 1 day
PO to goods receipt 23 days (2 days after the confirmed date)
receipt to invoice 4 days
invoice to payment 39 days (planned; terms NT45, due 2026-11-23)
Procure-to-pay worklist as of 2026-10-23 (fictional Calder data)
# PO Supplier Due Problem Value Owner
1 4500000006 Orrin Motor Works 2026-09-18 received, not invoiced 4,525.00 Accounts payable (ask the supplier) (past)
2 4500000002 Orrin Motor Works 2026-11-05 confirmation missing 18,100.00 Buyer (purchasing group)
3 4500000007 Corvane Lifting Services 2026-11-19 service not approved 3,800.00 Requester (approve the service entry sheet)
4 4500000005 Calder GmbH (intercompany) 2026-11-24 quantity block 13,400.00 Goods receiving, with the buyer
5 4500000004 Orrin Motor Works 2026-11-28 price block 9,650.00 Buyer, with accounts payable
6 4500000003 Ridgeline Bearing Supply 2026-12-03 quality block 10,400.00 Quality engineer (usage decision)
6 items stuck, 59,875.00 USD held. GR/IR open: 4,525.00 credit (received, not invoiced), 1,340.00 debit (invoiced, not received).
Payment runs (Calder pays every Tuesday):
invoice 5105600001 due 2026-11-23, paid in the run on 2026-11-17
invoice 5105600002 held: quality block
invoice 5105600003 held: price block
invoice 5105600004 held: quantity block
invoice 5105600005 held: service not approved
How to read it:
The trace rebuilds the purchase from the MRP requisition to the planned payment. The source list names Orrin as the fixed source, and the info record proposes 905.00 USD and 21 days.
The match compares the invoice with the PO price of 950.00, not the info record. The buyer agreed an expedite price, so the PO is 5% above the info record, and the invoice at 940.00 is within tolerance. A wrong PO price passes the match: the three-way match only checks that the three documents agree with each other.
Cycle times show the supplier delivered 2 days after its own confirmed date, and the invoice will be paid 39 days after it arrived, in the last weekly run before the due date.
The worklist is sorted by the invoice due date (or, without an invoice, the date the goods are needed), then by value. Past dates are marked (past). Each line names one owner.
GR/IR shows both kinds of imbalance: 4,525.00 credit for motors received but not invoiced, and 1,340.00 debit for four impellers invoiced but not received.
PO 4500000004 from Orrin Motor Works: 10 x CP-MTR15 at 905.00 USD, needed 2026-10-12
OK quantity invoiced 10 = received 10
STUCK price 965.00 vs PO 905.00 (+6.63%, 600.00 USD): above Calder's limit
GR/IR balance: 0.00
Next: Buyer, with accounts payable.
The quantity matches and GR/IR is balanced: the receipt credited 9,050.00 and the invoice debited 9,050.00, because GR/IR always moves at the PO price. The 600.00 difference sits outside GR/IR, which is why a balanced GR/IR account doesn't prove an invoice was right. The buyer either accepts the new price (and fixes the PO) or asks Orrin for a credit memo; either removes the cause, and automatic release can do the rest.
Now try --po 4500000005:
PO 4500000005 from Calder GmbH (intercompany): 40 x CP-IMP200 at 335.00 USD, needed 2026-10-06
STUCK quantity invoiced 40, received 36, ordered 40
OK price 335.00 vs PO 335.00 (+0.00%): within tolerance
GR/IR balance: 1,340.00
Next: Goods receiving, with the buyer.
Here the price is right and the quantity isn't. The first question is for the dock: did four impellers arrive without a receipt, or are they still on the way? Try --po 4500000003, 4500000006 and 4500000007 too.
Price tolerance on Calder's last 24 PO-based invoices (4,000.00 USD per item; fictional)
Percentage limit only; Calder's amount limit is left out here.
Upper limit Blocked Share Overcharge passed unreviewed (USD)
1.0% 8 33% 60.00
2.0% 7 29% 120.00
5.0% 3 12% 640.00
10.0% 1 4% 1,268.00
A tighter limit blocks more invoices (more work in accounts payable);
a looser one lets more overcharge through. Synthetic data: it shows the trade-off, not a benchmark.
At 1%, a third of invoices wait for a person, to protect 60 USD. At 10%, almost nothing blocks, and 1,268 USD of overcharge passes without review. Calder's 5% limit sits between the two, and its 250 USD amount limit catches large items that a percentage alone would let through. That is the tolerance design decision in miniature: review effort against money at risk.
You should see wrote unit1b/calder_p2p_worklist.md.
Open the file in VS Code and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) to preview it. You should see a process diagram, the trace of PO 4500000001 and a six-line worklist with an owner on every line.
#Step 8 (optional): Count blocked supplier invoices in SAP's sandbox
This step reads SAP's demo data, not Calder's. It needs your sandbox key and the network.
Make sure .env in the course folder has a line SAP_API_KEY="...".
Run:
Windows (PowerShell):
python unit1b\procure_to_pay.py --sandbox
macOS / Linux:
python unit1b/procure_to_pay.py --sandbox
The output has this shape. Values in angle brackets come from SAP's demo data and change over time:
SAP sandbox: <n> supplier invoices read (SAP demo data, not Calder's)
SupplierInvoiceStatus: '<code>' x<count>, '<code>' x<count>
PaymentBlockingReason: '(empty)' x<count>, '<code>' x<count>
PaymentBlockingReason set on <count> invoices, e.g. <number>, <number>
The script prints the codes as they come and doesn't translate them, because their meanings are configuration. A valid "empty" result is set on 0 invoices: the first 50 demo invoices simply have no payment block. The sandbox is read-only, so you can't release anything there.
Where it sits. At Calder (fictional), procure to pay for CP10 runs inside S/4HANA. Purchase orders go to large suppliers through SAP Business Network and to small ones by email. Invoices arrive as network documents, as PDFs by email and on paper. The bank receives payment files and returns statements. CP20 on ECC buys its castings separately and sells impellers to CP10. Anything that triages or explains reads released interfaces and writes only to a worklist; releases happen in SAP's own apps.
flowchart LR
BN[SAP Business Network<br/>POs, confirmations,<br/>ship notices, invoices] <--> S4[S/4HANA CP10<br/>purchasing, receipts,<br/>invoices, payments]
MAIL[PDF invoices<br/>by email] --> CAP[Invoice capture<br/>extraction]
CAP -->|draft invoice| S4
S4 -->|payment file| BANK[Bank]
S4 -->|released APIs, CDS| TRI[Triage service<br/>read-only]
TRI --> WL[Worklist per owner]
WL -->|person releases in SAP app| S4
ECC[ECC CP20] -.->|intercompany| S4
Non-functional requirements that decide the design
Requirement
Typical target
If missed
Invoice volume and peaks
Capture and matching keep up with month-end peaks
Invoices pile up, discounts and due dates are missed
Touchless rate
A target share of invoices posts and pays without review, set with finance
Accounts payable staffing grows with volume
Block resolution time
Each block reaches its owner the same day
Suppliers chase payments; late fees and lost goodwill
Payment run window
Proposal reviewed and run completed before the bank's cut-off
Payments a day late across all suppliers
Security
Reviewers see only their company codes; supplier bank details changed under dual control
Fraudulent bank changes, exposed prices
Auditability
Every release, tolerance change and write-off logged with who and why
Audit findings; no basis to evaluate any AI recommendation
Integration and identity. With SAP Business Network, a ship notice becomes an inbound delivery in S/4HANA through the managed gateway, and cancelling a ship notice deletes the inbound delivery unless a goods receipt exists (SAP Learning). Network invoices arrive in S/4HANA as drafts for accounts payable to complete (SAP Learning). For PDF invoices, SAP's reference architecture shows SAP Document AI reading an inbox, extracting fields and passing them on through SAP Integration Suite or a CAP application (SAP Architecture Center). The triage service reads with the user's identity where possible, so authorizations on company code and purchasing organization apply (grounding with authorizations). Integration patterns are covered in how SAP systems talk.
Context: Calder receives about a third of its invoices as PDFs by email, keyed by hand. The large suppliers already use SAP Business Network. Accounts payable wants fewer touches without losing the three-way match.
Options: (a) keep keying PDFs; (b) move every supplier to SAP Business Network; (c) network for suppliers who can, plus extraction for the remaining PDFs, with every extracted invoice landing as a draft for a person to post.
Decision: (c). Network documents are structured from the start, and the free standard account lowers the bar for small suppliers. Extraction covers the rest without asking suppliers to change.
Consequences: Two intake channels to monitor. Extraction errors are possible, so drafts stay drafts until a person posts them, and extraction accuracy is measured per field. Tolerances and the match stay in S/4HANA, unchanged.
What a design review will ask
Which channel does each supplier segment use for orders, confirmations and invoices, and who monitors failed messages?
What are the tolerance limits per company code, who owns them, and how were they chosen?
Can any component post an invoice, release a block or change supplier bank details without a person's decision?
How is a duplicate invoice caught when the same bill arrives by email and by the network?
How will you measure success: touchless rate, block age, GR/IR age, payments on time?
SAP Ariba Supplier Risk monitors financial, operational and other risk signals (SAP)
Spend classification
Predict (classify)
Strong
SAP announced SAP Ariba Spend Analysis and Insights, mapping spend to UNSPSC or a customer taxonomy, on 16 September 2026 (SAP News)
Payment run
Plain automation is enough
The risk is fraud in bank details; control the master data change, not the run
SAP has also announced agents for this process. Its Sapphire 2026 innovation guide listed an Invoicing Assistant in Joule as planned for general availability in June 2026 (SAP); check its status and license before you plan on it.
The line is the same at every step: AI reads, extracts, explains and ranks; a person posts drafts, releases blocks, changes prices and tolerances, and approves bank details. Human approval in AI workflows and agent permissions show how to enforce that in code. Supplier invoices are third-party text, so an agent that reads them is exposed to prompt injection.
Measuring blocked invoices per day. Teams push to loosen tolerances. Measure block age, touchless rate and the overcharge the blocks caught.
Trusting a balanced GR/IR. GR/IR moves at the PO price. A wrong PO price or a price variance doesn't show there.
Releasing instead of fixing. A manual release clears the symptom. Fix the cause (receipt, usage decision, credit memo) and let automatic release work; manual header blocks still need a person.
Forgetting services. Service invoices wait on approvals by people who aren't in purchasing. Put approvers on the worklist.
Unconfirmed dates in planning. If confirmations aren't required or chased, MRP plans on dates nobody promised.
Reading block reasons as universal codes. Payment block keys and tolerance settings are configuration. Read them from the system.
Assuming ECC and S/4HANA match. Business partners, flexible workflow and lean services differ, which matters for Calder's CP20.
Turn the worklist into a triage note a Calder accounts payable lead could use, and test the tolerance rules yourself.
Run python unit1b/procure_to_pay.py --write (Windows: python unit1b\procure_to_pay.py --write). Copy unit1b/calder_p2p_worklist.md to unit1b/calder_p2p_triage.md.
In the copy, add a column Next action to the worklist table. For each line write one sentence: what the owner should check or do.
Add a column AI could with one of: extract, explain, predict, detect or nothing. Use the table under Where AI fits to choose.
In procure_to_pay.py, find "Price": 965.00 (invoice 5105600003) and change it to 930.00. Run python unit1b/procure_to_pay.py --po 4500000004. Note the result.
Change it to 931.00 and run the same command again. Note what blocks it now, and why the percentage alone wouldn't have.
Change it back to 965.00. Then, in PO 4500000003, change "Stock": "quality inspection" to "Stock": "unrestricted" (a positive usage decision). Run python unit1b/procure_to_pay.py and find which payment run now pays invoice 5105600002.
Change the stock back to "quality inspection".
In the triage file, add a heading Tolerance decision with three lines: the limit you would set for Calder, the run of Step 6 that supports it, and what you saw in Steps 4 and 5.
Add a heading ECC note with one line on what a group view of blocked invoices needs from CP20. Preview the file and commit it with Git.
Done when:unit1b/calder_p2p_triage.md has the six-line worklist with Next action and AI could filled in for every line, a Tolerance decision section that says 930.00 passes and 931.00 is blocked by the 250.00 USD amount limit (260.00 USD over), a line stating that invoice 5105600002 is paid in the run on 2026-12-01 after the usage decision, and an ECC note. Keep it: the capstone at the end of Unit 1B traces one late supplier confirmation across the whole enterprise, and this triage note is its procure-to-pay part.
Pick one answer for each question. The explanation appears after you choose.
1The trace shows the PO at 950.00 and the info record at 905.00. The invoice says 940.00. Why does the match pass?
Answer: B. The three-way match compares invoice, PO and receipt. The PO price was 950.00, and 940.00 is within Calder's tolerance; the info record only supplied a default when the PO was created.
2Invoice 5105600003 is blocked for price, yet GR/IR for PO 4500000004 is balanced. How can that be?
Answer: B. Both the receipt and the invoice move GR/IR at the PO price, so it balances. The 600.00 variance goes to a price difference account here, which is why a clean GR/IR doesn't prove the invoice was right.
3An invoice is blocked for quantity and also carries a manual payment block in the header. The missing goods receipt is then posted. What happens?
Answer: B. Automatic release removes blocking reasons that are no longer valid, but SAP never auto-releases an invoice with a manual header block. That one is released separately by a person.
4You need supplier invoices with their payment block reason in custom code on an S/4HANA cloud edition. What do you read?
Answer: B. The Cloudification Repository lists I_SupplierInvoiceAPI01 as released and RBKP as not to be released, with that view as its successor. Released views survive upgrades; table access in custom code doesn't fit clean core.
5Seal kits for PO 4500000003 sit in quality inspection and the invoice is blocked. What is the right next step?
Answer: D. The block's cause is the open inspection lot. A positive usage decision moves the stock to unrestricted use and makes the quality reason invalid, so automatic release can do the rest.
6The tolerance table shows 33% of invoices blocked at 1% and 4% at 10%. What is the design question?
Answer: D. A tighter limit catches more overcharge but blocks more invoices for people to review. The limit is a decision about effort against money at risk, which finance and audit should own.
7Calder plans PDF invoice extraction. Where should the extracted data land?
Answer: B. Extraction can misread fields, so the design keeps extracted invoices as drafts until a person posts them. The match and tolerances then run in S/4HANA as before.
8A triage agent finds invoice 5105600004 blocked because 36 of 40 impellers were received. What should it do?
Answer: B. The agent can't know whether four impellers sit unbooked at the dock or never shipped. It routes the question to goods receiving with the facts; posting receipts and releasing blocks stay with people.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
Orchestrate Lab is a free practice ERP in your browser, built around the fictional Calder Pumpworks. These are the places in it where you do what this topic teaches.
Stage 6 of 11ProcureThe whole stage practices this topic, on Purchase requisitions, Purchase orders and Supplier invoices.
Stage 1 of 11Found the companyPractice it on Purchasing organizations.
Stage 2 of 11Set up financePractice it on Invoice tolerances.
ExercisePlanner's morningOne planning run, five exception messages, one of every kind: rank them, fix each where it is fixed, run planning again and explain what is left.
ChapterProduct lifecycleChange how a product is built without stopping the line: an engineering change record with a reason and a date, a redesigned ES-200 impeller with its plant data, price and supplier, and a bill of material with two dated lives, the old impeller ending and the new one starting on the change's date, so every order reads the bill valid on the day it starts; then the planner runs MRP across the date and reads the old impeller's run-out and the new one's first order.
ChapterQualityInspect what Calder buys before anyone uses it: an inspection setup and characteristics for seal kits, goods receipts that go to quality inspection and open inspection lots, results measured on a sample, and the usage decision that releases a good delivery to unrestricted stock or blocks a bad one, while planning counts only what can be used. Then the final inspection of a pump Calder builds: ES-300 tested at production receipt before anyone can sell it, and an order that confirms only the pumps that passed until a recheck after the decision. Then the complaint: a supplier's invoice blocked for quality, a quality notification, a return delivery and the credit memo that settles it. Then the supplier's replacement inspected and its invoice released by the decision, and the supplier's quality record read for what it can and can't say. Last, ES-300's final inspection fed by the test bench: one measurement per pump, recorded once against its serial number.
ChapterShop floorRun Rockford's floor from its own system: released production orders become dispatch lists per work center, in operation order with their scheduled dates, hours and the parts to issue; operators start and finish each operation by shift and confirm it with the good quantity, the scrap and its reason, and the hours; cheap parts are backflushed by the confirmation; the test bench's machine time is costed apart from the technician's; every pump gets a serial number and its own bench results; a pump that fails twice is repaired on its own rework order; every stop gets a reason, each work center's week reads as OEE, a breakdown goes to the maintenance planner, and in the end the floor runs its own system, fed by messages from the core, including cancellations the floor must agree to, and sending its confirmations back the same way, each of which can fail and must be fixed.
ChapterTransportationPlan Calder's freight from Rockford: carriers and their agreements, freight units from deliveries, freight orders, the carrier chosen by cost, loads with stops, truck capacity and a departure worked back from the dates, the final charges with stops and detention after free time, tendering each load to a carrier that has to accept it, following the truck by its execution events, and settling the freight: each carrier invoice checked against the agreed charges, the freight cost shared among the deliveries so each customer's margin shows what its freight really cost, and inbound freight on purchased motors planned on the purchase order and carried into the motors' cost.
ChapterWarehouseLay out Calder's Rockford distribution center: storage types, sections and bins, then put stock away, pick, pack and count it, put people on the work and watch it.
Stages build on each other: a stage link opens the core path at that stage. To start there without doing the earlier stages yourself, use Do this stage for me on the stage before it. A chapter starts inside your own company once the core path is done (or the lab finishes the core for you first); drills and exercises start from their own prepared copy. Everything is saved in this browser only.
Sources
Running Materials Requirements Planning (SAP Learning)— MRP creates planned orders, purchase requisitions or delivery schedule lines; MRP Live creates schedule lines when a valid scheduling agreement exists, otherwise requisitions; a requisition reaches purchasing at once
Monitoring Purchase Orders (SAP Learning)— confirmation control key sets expected confirmations and their sequence and relevance for MRP and goods receipt; inbound delivery; Monitor Supplier Confirmations app; ME2A; reminders ME91F and ME91FF
Describing Quality Management at Goods Receipt (SAP Learning)— inspection lot created at goods receipt for a purchase order, origin 01, stock-relevant; QM active for the movement type; inspection type in the material's QM view; post to inspection stock
Entering Invoices with Variances (SAP Learning)— tolerance limits per company code; tolerance keys grouped by variance type; above the upper limit the invoice posts and is blocked for payment, whole invoice; price variance postings for standard and moving average price
Releasing Blocked Invoices (SAP Learning)— MRBR; blocking reasons quantity, price, quality, date and when each becomes invalid; automatic release only for invoices blocked due to variances; RM08RELEASE; manual header block released separately
Using Fiori Apps for GR/IR Reconciliation (SAP Learning)— Reconcile GR/IR Accounts (F3302), Monitor GR/IR Account Reconciliation (F3303), GR/IR Process Insights (F5796); machine learning proposes next step, priority and root cause; separate subscription license
Processing Automatic Payments (SAP Learning)— parameters, proposal, edit, payment run; Manage Automatic Payments, Revise Payment Proposals (Public Edition), Manage Payment Media, Manage Payment Blocks; open items cleared when the run completes
Processing a Service Entry Sheet (SAP Learning)— Manage Service Entry Sheets - Lean Services; saving has no financial effect; automatic or manual approval; on approval a goods receipt document is posted
Cloudification Repository (SAP on GitHub)— objectReleaseInfoLatest.json read 23 October 2026; I_PurchaseRequisitionItemAPI01, I_PurchaseOrderAPI01, I_PurchaseOrderItemAPI01, I_PurOrdScheduleLineAPI01, I_PurchaseOrderHistoryAPI01, I_POSupplierConfirmationAPI01, I_SupplierInvoiceAPI01, I_MaterialDocumentItem_2, I_InspectionLot, I_ServiceEntrySheetAPI01 released; EBAN, EKKO, EKPO, EKET, EKBE, RBKP, RSEG, MSEG, QALS, LFA1 not to be released, with successors