← all projects

Worked examples

Small integrations between mock-sap, mock-edi, mock-bank and mock-einvoice, each with its tests. They live in mock-acme: ACME's middleware, one package with one copy of each, tested against the released mocks and every night against each mock's main. Its tests start the four mocks themselves, so there is nothing to run first.

Until 5 October 2026 each lived in the examples/ of the mock it was written beside. Each mock's examples/README.md says which file became which.

Procure to pay all three mocks

One purchase, carried the whole way: a purchase order in SAP becomes an X12 850, the supplier confirms it, ships it and invoices it, a three-way match posts the invoice into SAP as money owed, a payment run pays it in an ISO 20022 pain.001, the bank's statement clears it, and the supplier is told what was paid. Four of the six examples below are its individual legs; this is the one that runs end to end.

SAP  ──850──▶  supplier          a purchase order becomes an EDI order
     ◀──855/856/810──  supplier  confirmed, shipped, invoiced
SAP  ◀──INVOIC──                 matched, posted, and now owed
     ──pain.001──▶  bank         a payment run selects what is due
SAP  ◀──FINSTA01◀──camt.053──    the statement clears what was paid
SAP  ──PEXR2002──▶ 820 ──▶  supplier   and the supplier is told what for
  • It was written to find bugs, and it found three. Each pair of mocks was green while the chain was broken, because each pair's tests assert what the next system received rather than what it could do with it. An INVOIC that named no supplier and so created nothing owed; a mock that answered "posted" for having posted nothing; an order placed in EUR that came back invoiced in dollars. All three had been passing for days.
  • The same invoice twice, and two things that look like they catch it. A supplier retries an invoice after the first was taken, and a restarted middleware posts it again. Upstream, an invoice arriving alone is held, waiting for a ship notice the restart forgot - which keeps it out of SAP, not because anything checked: resend the ship notice with it and it posts again. Downstream, the payment run skips a repeated reference within one run - and pays it in the next, once the first has cleared. Neither end is checking, and the second payment goes out through the gap.
  • The fix is to ask the system of record. Does SAP already hold a supplier invoice with this number, from this supplier? One $filter, and it survives a restart because SAP is where the answer lives. Per supplier, because an invoice number is only unique within one.
  • One order, two deliveries, two payables. A supplier with less in stock than was ordered ships the rest later, with a ship notice and an invoice of its own. Each invoice is matched against the shipment it names, neither is taken for a copy of the other, and each is paid once, in one run or in two. Before mock-acme 0.5.0, with both deliveries read in one run, the first invoice was blocked against the second delivery's notice.
  • An invoice ahead of its ship notice is held, not paid. It is kept and reported, a payment run that fires meanwhile pays nothing for it, and the run after the notice arrives pays it once. Anything else wrong with it blocks it at once: a wrong price does not get better when the goods arrive.
  • A price disagreement never reaches the bank, blocked by a real 810 disagreeing with the purchase order rather than by a flag a test set.
  • A payment the bank rejects leaves the invoice owed. The supplier's account is closed, SAP still holds its details, and AC04 comes back: the item stays open and stays distinguishable from one that was paid and came back.
  • The supplier is told what was paid, which is why it stops dunning you for an invoice you paid. Once the statement clears a payment, SAP writes the advice from the payment itself, one per payment document naming every invoice it settled, Remittance sends it on as an X12 820, and mock-edi says whether it agrees. Only what SAP has cleared is advised: a payment the bank accepted is not yet money that arrived.
  • It does not take an advice back, and says so. A payment the bank returns after the supplier was told reopens the invoice in SAP, while the supplier still holds an advice saying it was paid; the correction is a reversing 820, which nothing here sends.
Four lanes: SAP, ACME, mock-edi as the supplier, and mock-bank; between the mocks runs procure_to_pay from the installed mock-acme package, driven by the capture script, which raised the order to 5000 where the supplier holds 4200, moved the supplier's clock to the day the balance was promised, and held the payment run to Monday 2026-11-16. Act one: ACME's 850 is acknowledged and answered, the supplier promises a backorder for two days on, and ships and invoices what it has. Acts two and three: the middleware posts that invoice and SAP owes EUR 52500.00; the supplier's clock moves 46 hours, the promise is kept by a second delivery and a second invoice, and SAP owes EUR 10000.00 more on the same order. Act four: one payment file pays both, the bank's statement has two lines, and SAP clears each invoice once. Not drawn: the promises made and kept in the same instant, SAP's posted rows, and the bank's booked and camt.054.
one order, two deliveries, each paid once drawn by mock-films from a real run
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_procure_to_pay

PO bridge mock-sap + mock-edi

A worked example of the two mocks together. po_bridge.py reads a purchase order from SAP over OData, sends it to the supplier as an X12 850, and posts the supplier's 855 back into SAP as an ORDRSP IDoc. Any line that isn't a clean confirmation comes back as an exception for a buyer to look at.

send:     SAP PO (OData)  ──▶  X12 850  ──▶  supplier
receive:  SAP  ◀──  ORDRSP IDoc  ◀──  X12 855  ◀──  supplier
  • The supplier confirms everything, and one IDoc lands in SAP.
  • The supplier ships short, and both short lines are flagged.
  • The supplier refuses a line, and SAP's IDoc carries the rejection on the right item.
  • The supplier never answers, and nothing is posted: the case a chase-up alert is for.
  • SAP is down when the 855 arrives, and the confirmation is kept until SAP takes it. This test found a real bug.
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_po_bridge

Invoice check mock-sap + mock-edi

What happens after the goods ship. invoice_check.py is accounts-payable middleware: it takes a supplier's X12 810 invoice and posts it into SAP as an INVOIC IDoc only if it passes a three-way match against the purchase order and the 856 ship notice. Anything else is blocked, with the reasons.

PO (SAP)  ──┐
856 ship  ──┼──▶  match?  ──▶  yes: INVOIC IDoc into SAP
810 bill  ──┘                  no:  blocked, with reasons
  • A clean invoice is posted, and the test reads back the money owed - the supplier invoice, its line amounts and the open payable a payment run selects. Asserting only that SAP took the IDoc is what let this example post invoices that owed nobody anything, until mock-sap 0.13.2.
  • A short shipment, billed as shipped, is posted. Matching against the ordered quantity would wrongly block it.
  • A price disagreement is blocked. mock-edi bills its own catalogue price, the way real suppliers do.
  • An invoice in the wrong currency is blocked - and one that names no currency is not. The order declares its currency, so a EUR purchase order comes back invoiced in EUR; before mock-sap 0.13.3 it did not, and the match compared 12.50 with 12.50 without noticing they meant different money. Absence is not disagreement, so an invoice carrying no CUR segment still passes.
  • An invoice is matched against the shipment it bills. An 810 can name its consignment (REF*SI), the number on the 856 (BSN02), and is then compared with that ship notice and no other. If the notice has not arrived the invoice is held: kept, reported on every run, and posted on the run after it comes. Before mock-acme 0.5.0 it was blocked for billing more than was shipped, and dropped, so it never posted when the notice did come.
  • The same invoice sent twice is posted once. No sleeping: the test advances mock-edi's clock.
  • An invoice for more than was ordered is blocked, counting what earlier invoices for the same item billed. The ship notice is the supplier's own account of what it sent, so a supplier that ships too much and bills it agrees with itself; only the order disagrees.
  • An invoice that carries sales tax is posted, and owed in full. The tax is added in before the total is compared, and SAP is told the net, the tax and the gross apart. Before, every correct taxed invoice was blocked as not adding up.
  • SAP not answering loses nothing. An invoice taken from the supplier's mailbox is kept until SAP has dealt with it, and one whose IDoc may have arrived is not sent twice.
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_invoice_check

E-invoice mock-sap + mock-edi + mock-einvoice

The invoice check again, with the invoice arriving the new way. e_invoice.py collects a supplier's UBL e-invoice from mock-einvoice in place of an 810, reads it into what the 810 reader makes, and runs the same three-way match, the same INVOIC IDoc and the same duplicate question. Then it does what an 810 has no answer for: it tells the supplier what became of the invoice, in Peppol Invoice Responses. The order and the ship notice still go by EDI, with a supplier who sends no 810.

supplier ──UBL Invoice──▶           collected from the supplier's side
supplier ◀──AB──                    received
SAP ◀──INVOIC──                     matched, posted
supplier ◀──AP── or ◀──RE / UQ──    accepted, or not and why
SAP clears the payable              (payment run)
supplier ◀──PD──                    paid
  • Rejected or queried, with Peppol's reason and the problem in words. An invoice only a new invoice can cure (a price, a quantity, an item, a total, a duplicate) is rejected with RE; one that information could cure (an order SAP does not have, or none named) is queried with UQ. A block that is not the supplier's doing, SAP not answering among them, is not told.
  • Paid is SAP's clearing and nothing else. PD is said when the payable the invoice became is cleared in SAP, which is the fact the remittance advice rests on too. A payment sent and not yet on a statement is not paid.
  • Every response it writes passes Peppol's 82 rules, because mock-einvoice refuses one that does not. The UBL here is read and written by hand, so that refusal is what holds the writer to something.
  • Started again, it asks. A new process asks the supplier's side what each invoice was last told, so nothing is acknowledged, accepted or rejected twice, and an invoice accepted before the restart is still told paid. One case is left to a person and told nothing: an invoice only acknowledged that SAP already holds, since SAP does not say whether the earlier process posted it.
  • An XRechnung invoice is matched, posted and told nothing, because XRechnung has no response message.
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_e_invoice

Pay invoices mock-edi + mock-bank

The step after the invoice is approved: paying it. pay_invoices.py takes a supplier's EDIFACT INVOICs, pays each on its due date in an ISO 20022 pain.001, and then works out from what the bank sends back which invoices are actually paid.

INVOIC  ──▶  pain.001  ──▶  bank
             pain.002  ◀──  accepted: scheduled, not paid
             camt.053  ◀──  on the statement: paid
  • Accepted is not paid. An invoice is paid when the statement shows the debit, not when the bank says it will try.
  • A retried run pays nothing twice. The same payments make the same MsgId, so the bank refuses the copy with DUPL, and that refusal does not undo the original.
  • A closed account, or no money, keeps the invoice open, held with the bank's reason code (AC04, AM04) rather than retried.
  • A returned payment reopens the invoice, and a payment missing from the statement is reported as unreconciled rather than cleared.
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_pay_invoices

Payment run mock-sap + mock-bank

The same step, but paying what is in SAP rather than what arrived over EDI. payment_run.py does what SAP's F110 does: select the open supplier items that are due, pay them in one pain.001, and post each camt.053 back as a FINSTA01 so SAP clears what was paid - and reopens what came back.

select:     SAP open items (OData)  ──▶  pain.001  ──▶  bank
reconcile:  SAP  ◀──  FINSTA01  ◀──  camt.053  ◀──  bank
            cleared · reopened · unreconciled
  • Accepted is not paid. A payment made after Friday's 15:00 cutoff settles on Monday. Friday's statement clears nothing, Monday's clears everything, and an integration that cleared on the acknowledgment reported cash it still had.
  • A returned payment and an invoice nobody paid are both open in SAP. One flag separates them: ClearingIsReversed. Without it you either pay the returned invoice twice or never chase it.
  • A statement that doesn't add up stops one payment, not the run. The arithmetic is checked before anything reaches SAP; the missing payment goes unreconciled and stays open. When two payments would equally explain the gap, both are named.
  • A second run before the statement does not pay again. SAP used to have nothing between open and cleared, so an invoice already sent to the bank still looked open to the next run. Since mock-sap 0.19.0 an open item can say which payment run has it: before the file goes out, the run writes its identification and date on each item, as PaymentRunID and PaymentRunDate, and any other run, on any machine, skips the item and names the run that has it. SAP lets go when a statement clears the item or a returned payment reopens it; the run lets go when the bank refuses the payment. The run used to keep its own register of what it sent, and two installations kept two; a register is optional now.
  • Two suppliers, one invoice number. Where two suppliers number an invoice the same and bill the same amount, a statement line paying one of them fits both, and nothing on the line says which. Since mock-sap 0.21.0 the claim does: the item the run claimed is the one cleared, and the other is still owed and paid the next day. Without a claim, as under mock-acme 0.2.0, SAP leaves the line and says why: the paid invoice stays open, the run will not send a second payment with the same reference, and that week the second supplier is not paid at all. The two films below are that week, with a claim and without.
  • A blocked invoice is never selected, and the selection asks SAP to leave blocked and cleared items out rather than filtering them afterwards.
  • Anything either side did that the run couldn't use is said in words. run.problems is neither raised nor swallowed - a bank that answers with an error is three sentences, not silence. A host that is down raises and stops the run instead - a deliberate difference, not an oversight.
Three lanes: SAP, ACME and mock-bank; between the mocks runs the payment run from the installed mock-acme package, ACME's middleware, and the capture script posted two suppliers' invoices to SAP, both numbered INV-7 for EUR 60000.00, and moves both clocks a day at a time. Monday: SAP owes both, as documents 0100000007 and 0100000008; the run pays the first, and SAP records the run's claim, R1, on document 0100000007. Tuesday: the bank's statement has one line, INV-7 for 60000.00, which fits both documents, and SAP clears 0100000007, the claimed one. Wednesday: the run claims and pays 0100000008. Not drawn, because they say nothing about the twin line: SAP's posted rows, and the bank's booked and camt.054; twins_unclaimed_film is the same week with no claim.
two suppliers, one invoice number, one claim drawn by mock-films from a real run
The week of twins_film, driven by mock-acme 0.2.0, whose payment run writes no claim in SAP; three lanes, SAP, ACME and mock-bank, and the capture script posted both INV-7 invoices and moves both clocks a day at a time. Monday: SAP owes both, as documents 0100000007 and 0100000008, and the run pays the first. Tuesday: the bank's statement has one line, INV-7 for 60000.00, and mock-sap leaves it, saying in red that the reference is both items and nothing else on the line says which was paid; ACME's run then skips 0100000007 as in payment. Wednesday: ACME's run skips both, the second for having the same reference, in the middleware's own words, which are not red and which begin at the left of the screen. Not drawn, as in twins_film: SAP's posted rows, and the bank's booked and camt.054.
the same week, and no claim drawn by mock-films from a real run
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_payment_run

Remittance mock-sap + mock-edi

Telling the supplier what was paid. mock-sap writes a payment advice from a payment it posted, a PEXR2002 naming every invoice the payment settled; remittance.py converts it into the X12 820 a supplier reads and sends it to mock-edi, which reads it as the payee.

SAP  ──PEXR2002──▶  remittance  ──820──▶  supplier
                                 ◀──997──  and what it made of the advice
  • The supplier agrees with it, and lists the advice against the payment it describes.
  • A total that is not the sum of its rows is refused. Without that test, the clean run could be a check that never fires.
  • An advice dated after the payment settles is refused, judged by mock-edi's clock, which is not mock-sap's.
  • The direction is read, not assumed. SAP writes money leaving as 3570.00-; an amount that is not money out is refused rather than relabelled, and BPR01 says no money moves on this document.
git clone https://github.com/rseufert/mock-acme && cd mock-acme
pip install -e ".[test]"         # mock-acme, and the four mocks to test against
python3 -m unittest -v tests.test_remittance