mock-sap
A black-box mock SAP endpoint. It does not implement any SAP business logic — it speaks the shapes: SAP Gateway OData V2 services, BAPI/RFC calls over JSON and SOAP, and IDocs in XML and EDI_DC40 flat-file form. Data is kept in SQLite.
Point your integration at it and develop, test and demo without an SAP system, an SAP licence, or a VPN tunnel to someone's sandbox.
- Zero dependencies. Python 3.8+ standard library and SQLite, nothing else.
- Real wire shapes.
{"d":{"results":[…]}},__metadata,__deferred,/Date(1700000000000)/, decimals as strings, EDMX$metadatawithsap:annotations, CSRF tokens,$batchwith atomic changesets, BAPIRET2 return tables, EDI_DC40 control records. - Deterministic demo data. Business partners, products, sales orders, purchase orders —
same data every run for the same
--seed. - Failure modes on demand. Latency, 500s, locked documents, "no work process available", expired CSRF tokens — per request, or as programmable rules.
MIT licensed. SAP, S/4HANA, ABAP, NetWeaver, BAPI and IDoc are trademarks of SAP SE; this project is not affiliated with, endorsed by, or connected to SAP SE, and imitates publicly documented wire formats for testing purposes only.
By Rick Seufert. The projects page has this mock, mock-edi, mock-bank and the worked examples that use them together.
Quick start
pip install mock-sap
mock-sap --port 8000
curl "http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?\$top=1&\$format=json"
{
"d": {
"results": [
{
"__metadata": {
"id": "http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder('0000004712')",
"uri": "http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder('0000004712')",
"type": "API_SALES_ORDER_SRV.A_SalesOrderType"
},
"SalesOrder": "0000004712",
"SalesOrderType": "OR",
"SalesOrganization": "1710",
"SoldToParty": "1000004",
"TransactionCurrency": "EUR",
"TotalNetAmount": "123991.820",
"CreationDate": "/Date(1754611200000)/",
"to_Item": { "__deferred": { "uri": ".../A_SalesOrder('0000004712')/to_Item" } }
}
]
}
}
Or run it straight from a checkout, with no install at all, or in a container:
python3 -m mocksap --port 8000
docker build -t mock-sap . && docker run -p 8000:8000 mock-sap
A guided tour of every endpoint, in curl:
bash examples/demo.sh
What it serves
| Surface | Endpoint |
|---|---|
| Service catalog | GET /sap/opu/odata/IWFND/CATALOGSERVICE;v=2/ServiceCollection |
| Business Partner | /sap/opu/odata/sap/API_BUSINESS_PARTNER_SRV |
| Supplier bank details | A_BusinessPartnerBank in API_BUSINESS_PARTNER_SRV |
| Product | /sap/opu/odata/sap/API_PRODUCT_SRV |
| Sales Order | /sap/opu/odata/sap/API_SALES_ORDER_SRV |
| Purchase Order | /sap/opu/odata/sap/API_PURCHASEORDER_PROCESS_SRV |
| Outbound Delivery | /sap/opu/odata/sap/API_OUTBOUND_DELIVERY_SRV |
| Billing Document | /sap/opu/odata/sap/API_BILLING_DOCUMENT_SRV |
| Journal Entry | /sap/opu/odata/sap/API_JOURNALENTRY_SRV |
| Supplier Invoice | /sap/opu/odata/sap/API_SUPPLIERINVOICE_PROCESS_SRV |
| Open items (accounting document item cube) | /sap/opu/odata/sap/API_OPLACCTGDOCITEMCUBE_SRV |
| GWSAMPLE_BASIC (the classic demo service) | /sap/opu/odata/IWBEP/GWSAMPLE_BASIC |
| Sales Order, OData V4 | /sap/opu/odata4/sap/api_salesorder/srvd_a2x/sap/api_salesorder/0001 |
| Business Partner, OData V4 | /sap/opu/odata4/sap/api_businesspartner/srvd_a2x/sap/api_businesspartner/0001 |
| Product, OData V4 | /sap/opu/odata4/sap/api_product/srvd_a2x/sap/api_product/0001 |
| Purchase Order, OData V4 | /sap/opu/odata4/sap/api_purchaseorder/srvd_a2x/sap/api_purchaseorder/0001 |
| BAPI over JSON | POST /sap/bc/rfc/<FUNCTION_MODULE> |
| BAPI over SOAP | POST /sap/bc/srt/rfc/sap/<service>/<client>/<name>/<binding> |
| IDoc inbound | POST /sap/bc/idoc (XML or flat file) |
| IDoc outbound | POST /sap/bc/idoc/generate → ORDERS05, INVOIC02 or DELVRY07 from a sales order; PEXR2002 (mestyp=REMADV) from a clearing document |
| OAuth token endpoint | POST /sap/bc/sec/oauth2/token, POST /sap/bc/sec/oauth2/revoke |
| Mock control plane | /_mock/health, /_mock/state, /_mock/services, /_mock/requests, /_mock/rfc-log, /_mock/idocs (?settled= narrows to what settled one document), /_mock/tokens, /_mock/faults, POST /_mock/advance, POST /_mock/reset |
The four API_* services carry the S/4HANA field names; GWSAMPLE_BASIC is the
classic Gateway demo service every SAP OData tutorial uses, with its structured
CT_Address and its entity sets named apart from their types (BusinessPartnerSet
holds a BusinessPartner).
Entity sets carry the S/4HANA field names — A_SalesOrder with SoldToParty,
TotalNetAmount, OverallSDProcessStatus, to_Item; A_BusinessPartner with
BusinessPartnerCategory, to_BusinessPartnerAddress; and so on. Browse
/sap/opu/odata/sap/<SERVICE>/$metadata for the full picture, or open
http://127.0.0.1:8000/ for an index page.
OData V4
Every A2X service is served in V4 as well, over the same rows: what you write through the V2 sales order service you read back through the V4 one. The dialects are kept honestly apart - a V2 option on a V4 service is an error, and the other way round.
| V2 | V4 | |
|---|---|---|
| Collection | {"d":{"results":[…]}} |
{"@odata.context":"…","value":[…]} |
| Entity | {"d":{…}} |
the entity object itself |
| Timestamps | /Date(1754611200000)/ |
2025-08-08T00:00:00Z |
| Decimals | "123991.820" |
123991.82 |
$filter literals |
datetime'2025-08-08T00:00:00', 100.50M |
2025-08-08T00:00:00Z, 2025-08-08, 100.50 |
| Count | $inlinecount=allpages → __count |
$count=true → @odata.count |
| ETag | __metadata.etag |
@odata.etag |
| Links | __deferred, $links |
omitted, $ref |
| Expanded | {"results":[…]} |
a bare array |
| Nested options | - | $expand=to_Item($select=Material;$top=2;$count=true) |
| Aggregation | - | $apply=groupby((SoldToParty),aggregate($count as Orders)) |
| Delta | __delta, !deltatoken='…' |
@odata.deltaLink, $deltatoken=… |
| Annotations | sap: attributes |
UI and Capabilities vocabularies |
| Metadata | EDMX 1.0 | CSDL 4.0, XML or JSON |
| Batch | multipart/mixed | JSON, with atomicityGroup |
| Errors | message: {lang, value} |
message as a string |
curl "http://127.0.0.1:8000/sap/opu/odata4/sap/api_salesorder/srvd_a2x/sap/api_salesorder/0001/SalesOrder?\$top=1&\$count=true"
A literal spelled for the other version is refused, and the refusal says how
this one writes it. An option the mock would not honour is refused too rather
than accepted and dropped: $search (501, use $filter), a $skiptoken (the
mock never pages, so it never issued one - use $top and $skip), and a
$format other than JSON on entity data. The service document and $metadata
are the resources served as XML.
--unsupported-option-status 400 (or 501) gives all three the same status,
for a client that maps one status to "unsupported option"; left unset,
$search is the 501 and the other two are 400s.
An amount is a decimal all the way down. Edm.Decimal is declared with a
scale, stored at that scale and computed in decimal.Decimal, and a figure the
mock makes is rounded with ROUND_HALF_UP - what a tax authority specifies and
what SAP does. So 19% of 2.50 is 0.48, where a binary float and round() make
it 0.47 (#98). The V4 number
above is the one place an amount meets a float, because that is V4's own shape
for a decimal; nothing is computed from it.
Annotations for Fiori elements
$metadata describes structure; a Fiori elements app needs to be told what to
draw. The V4 services carry the handful of vocabulary terms it reads:
| Term | What it decides |
|---|---|
UI.HeaderInfo |
the title and subtitle of an object page |
UI.LineItem |
the columns of a list report |
UI.SelectionFields |
the filter bar |
UI.Identification |
the fields on the object page |
UI.FieldGroup |
the sections that group those fields |
UI.Facets |
the object page's layout, including its items table |
Common.Label |
the label of every property |
Capabilities.Insert/Update/DeleteRestrictions |
which buttons appear |
Each vocabulary is referenced by its published URL, so the terms resolve rather than dangling, and the same content is served in CSDL JSON for clients that ask for it. What a list report would draw is visible directly:
curl "$V4/\$metadata?\$format=json" | jq '."com.sap.gateway.srvd_a2x.api_salesorder.v0001".SalesOrderType."@UI.LineItem"'
An object page gets its sections from UI.FieldGroup and its layout from
UI.Facets - including a facet pointing through a navigation property at the
item type's own line items, which is how the items table appears:
Facet("Items", "to_Item/@UI.LineItem")
The annotations live beside the entity types in mocksap/schema.py, so a column
added to a list report is a line in the same file that defines the property.
The V2 services keep their sap: attributes in $metadata, which is what the V2
smart controls read for labels and visibility. The layout terms a V2 list report
needs live in a document of their own, the way a V2 app expects:
curl "$BASE/sap/opu/odata/IWBEP/GWSAMPLE_BASIC/annotations"
GWSAMPLE_BASIC publishes one - the demo service those tutorials use - and the
service document points at it. The A2X APIs publish none, because SAP's own do
not: they are integration APIs, not UI services.
A caveat worth stating: a SAPUI5 application normally names its annotation URL in its manifest rather than discovering it, so the link from the service document is the mock's own convenience, not a protocol SAP defines.
Aggregation
An analytical client opens with an $apply, and the V4 services answer one:
curl --get "$V4/SalesOrder" --data-urlencode \
'$apply=filter(TotalNetAmount gt 50000)/groupby((SoldToParty),aggregate($count as Orders,TotalNetAmount with average as Avg))/orderby(Orders desc)/top(3)'
{
"@odata.context": ".../$metadata#SalesOrder(SoldToParty,Orders,Avg)",
"value": [
{"SoldToParty": "1000003", "Orders": 3, "Avg": 152921.147},
{"SoldToParty": "1000018", "Orders": 2, "Avg": 123650.425}
]
}
filter, groupby, aggregate, orderby, top and skip compose with /;
the aggregation methods are sum, min, max, average, countdistinct and
$count. The rows are not entities - they carry the grouping keys and the
aggregates and nothing else. $count=true beside an $apply counts the groups,
not the page. Anything the mock does not implement is refused by name rather than
half-honoured.
OData V2 support
| Feature | Notes |
|---|---|
$filter |
eq ne gt ge lt le, and or not, parentheses, arithmetic, substringof, startswith, endswith, contains, tolower, toupper, trim, length, concat, substring, indexof, year…second. Compiled to parameterised SQL. |
$select $expand |
$expand follows to-one and to-many navigations, nested paths included |
$orderby $top $skip |
|
$inlinecount=allpages, /$count |
|
| Complex types | structured properties such as CT_Address nest on the wire with their own __metadata.type, and Address/City works in $filter, $orderby and $select |
| ETags | concurrency-controlled types carry a weak ETag in __metadata.etag and the ETag header; If-Match guards updates and deletes (412 when stale, * matches anything), If-None-Match answers 304 |
$links |
reads the association as bare URIs, to-one and to-many, with $top/$skip/$inlinecount/$count; writes re-point the foreign key, and refuse a change that would rewrite a key |
$format=json, Accept negotiation |
XML/Atom for the service document and errors |
$metadata |
EDMX 1.0 with associations, referential constraints and sap:label/sap:creatable/sap:updatable |
$batch |
multipart/mixed, changesets execute atomically and roll back as a unit |
| Writes | POST (incl. deep insert), PATCH/MERGE, PUT, DELETE, POST to a navigation |
| Conventions | CSRF tokens, sap-client, DataServiceVersion, Location on create, SAP error envelope with /IWBEP/CX_MGW_* codes |
Writing requires a CSRF token
Exactly as against a real Gateway:
TOKEN=$(curl -s -D - -o /dev/null -H 'X-CSRF-Token: Fetch' \
http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/ \
| awk 'tolower($1)=="x-csrf-token:"{print $2}' | tr -d '\r')
curl -X POST "http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?\$expand=to_Item" \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"SalesOrderType":"OR","SalesOrganization":"1710","DistributionChannel":"10",
"OrganizationDivision":"00","SoldToParty":"1000001","TransactionCurrency":"EUR",
"to_Item":[{"Material":"TG11","RequestedQuantity":"2","NetAmount":"1998.00"}]}'
The document number comes from a number range, items are numbered 000010, 000020, …,
TotalNetAmount is recalculated from the items, administrative fields are filled in,
and unset fields come back as ABAP initial values ("", 0.000) rather than null.
Run with --no-csrf to switch the check off.
BAPI / RFC
The same function modules are reachable over JSON and over SOAP. Parameter names are
matched case- and underscore-insensitively, so ORDER_HEADER_IN and OrderHeaderIn
both work.
curl -X POST http://127.0.0.1:8000/sap/bc/rfc/BAPI_SALESORDER_CREATEFROMDAT2 \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"ORDER_HEADER_IN":{"DOC_TYPE":"OR","SALES_ORG":"1710","DISTR_CHAN":"10","DIVISION":"00"},
"ORDER_PARTNERS":[{"PARTN_ROLE":"AG","PARTN_NUMB":"0001000001"}],
"ORDER_ITEMS_IN":[{"ITM_NUMBER":"000010","MATERIAL":"TG11","REQ_QTY":"3","COND_VALUE":"1500.00"}]}'
{
"SALESDOCUMENT": "0000004737",
"RETURN": [{
"TYPE": "S", "ID": "V1", "NUMBER": "311",
"MESSAGE": "Standard Order 4737 has been saved",
"MESSAGE_V1": "Standard Order", "MESSAGE_V2": "4737",
"LOG_NO": "", "LOG_MSG_NO": "000000", "PARAMETER": "", "ROW": 0, "FIELD": "",
"SYSTEM": "MCKCLNT100"
}]
}
Errors arrive the way BAPIs report them — TYPE: "E" in the RETURN table with a
message number, not an HTTP error. Unknown material, unknown customer, missing
sold-to party and test runs (TESTRUN: "X") are all modelled.
Available: BAPI_SALESORDER_CREATEFROMDAT2, BAPI_SALESORDER_CHANGE,
BAPI_SALESORDER_GETLIST, BAPI_SALESORDER_GETSTATUS, BAPI_PO_CREATE1,
BAPI_PO_GETDETAIL1, BAPI_CUSTOMER_GETLIST, BAPI_CUSTOMER_GETDETAIL2,
BAPI_VENDOR_GETDETAIL, BAPI_MATERIAL_GETLIST, BAPI_MATERIAL_GET_DETAIL,
BAPI_OUTB_DELIVERY_CREATE_SLS, BAPI_ACC_DOCUMENT_POST,
BAPI_BUSINESS_PARTNER_GETDETAIL, BAPI_TRANSACTION_COMMIT,
BAPI_TRANSACTION_ROLLBACK, RFC_READ_TABLE, RFC_PING, STFC_CONNECTION.
GET /_mock/services lists them; POST /sap/bc/rfc/ with no name does too.
There is no unit of work to roll back. Every function call is committed as
it runs, so BAPI_TRANSACTION_COMMIT has nothing left to do and
BAPI_TRANSACTION_ROLLBACK has nothing it can undo. A rollback that follows a
call which wrote something therefore answers TYPE: "E", naming the function
modules whose work is still in place, rather than claiming a rollback that did
not happen. One that follows only reads, test runs, refused calls or a commit
answers S as a real one would. POST /_mock/reset is how a test returns to a
known state.
--rollback-type W makes that answer a warning instead of an error, for a
client whose error path calls the rollback and treats any E as a second
failure. There is no setting that makes it S.
BAPI_SALESORDER_CHANGE honours the X structures the way a real BAPI does: only
fields flagged in ORDER_HEADER_INX / ORDER_ITEM_INX are changed, and a call
that forgets them changes nothing and says so. ORDER_ITEM_INX takes UPDATEFLAG
I, U and D to insert, update and delete items.
RFC_READ_TABLE reads the same tables the OData services serve, with FIELDS,
OPTIONS as a WHERE clause, DELIMITER, ROWSKIPS and ROWCOUNT, and returns
the fixed-width DATA rows people expect. It answers only for tables it knows -
ask it for VBAK and it tells you the mock has A_SalesOrder instead - and every
field name and literal in OPTIONS is checked and bound, so nothing from the
caller reaches SQL as text.
SOAP uses the urn:sap-com:document:sap:soap:functions:mc-style namespace, returns
<…Response> envelopes, and answers unknown functions with a SOAP fault.
A valid call that fails anyway
The errors above come from malformed input. The ones production throws do not: a
credit limit, a closed posting period, a material blocked for sales. They arrive
as HTTP 200 with TYPE: "E" in RETURN, which is how an integration that
checks the status code and commits comes to exist. Ask for one:
curl -X POST http://127.0.0.1:8000/_mock/bapi-behaviour \
-H 'Content-Type: application/json' \
-d '{"function":"BAPI_SALESORDER_CREATEFROMDAT2","type":"E","id":"V1","number":"849",
"message":"Credit limit exceeded for customer 0000000001","count":1}'
| Type | Effect |
|---|---|
E |
Error - the handler never runs, so no document and no number drawn |
A |
Abort, the same way |
W |
The call does its work and carries a warning as well |
S |
The call does its work and carries an extra success message |
The status stays 200 and SOAP returns a <…Response> envelope, not a fault - a
business error is not a transport error. W and S are there because a client
that treats any non-empty RETURN as failure has its own bug, and this is how you
find out. count spends the rule, GET lists them, DELETE and
POST /_mock/reset clear them.
RFC_PING, STFC_CONNECTION and RFC_READ_TABLE are refused: they are not BAPIs
and have no RETURN table, so a real one reports trouble by raising an ABAP
exception. Use sap-mock-scenario for those.
IDoc
# outbound: render a stored sales order as ORDERS05, INVOIC02 or DELVRY07
curl -X POST "http://127.0.0.1:8000/sap/bc/idoc/generate?format=xml" \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"mestyp":"INVOIC","SalesOrder":"0000004712"}'
# outbound: tell a supplier what a payment covered, from the payment itself
curl -X POST "http://127.0.0.1:8000/sap/bc/idoc/generate?format=xml" \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"mestyp":"REMADV","ClearingAccountingDocument":"0100000011"}'
# inbound: post an IDoc and get its status record back
curl -X POST http://127.0.0.1:8000/sap/bc/idoc \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/xml' -H 'Accept: application/json' \
--data-binary @order.xml
Generated IDocs carry a full EDI_DC40 control record and the segment tree their
type calls for - E1EDK01/E1EDK14/E1EDKA1/E1EDP01 for an order, E1EDK02
references and E1EDS01 totals for an invoice, E1EDL20/E1EDL24 for a delivery.
An invoice draws a billing document number and a delivery a delivery number, each
from its own number range.
An inbound DELVRY07 is not merely filed: its items name the order they came from
in VGBEL/VGPOS, so posting one moves that order's OverallDeliveryStatus, and
the receipt says what it did. If the delivery it announces is one the mock has
never seen, the delivery is created too.
The status is what every delivery against the order adds up to, not what the
one in hand covers: A nothing delivered, B partly, C fully. An order for 10
delivered as 5 and then 5 is C — it used to end at B and stay there, because
each delivery was asked on its own whether it covered the order and neither 5 did
(#95). Three of the seeded orders
are shipped half short for the same reason, so a split shipment is in the data
rather than something you have to build.
A DELVRY that moved no order is 51, not 53. An unknown VGBEL, or a
VGPOS the order does not have, posts nothing and changes nothing, so filing it as
Application document posted told a client the opposite of what happened
(#96). The status text names the
order or the position. The per-order detail survives the refusal — APPLIED still
says which order it was about, because 51 is the status and not a reason to forget
what was attempted. One order moving is enough for 53; the others keep their own
answer, since 51 is for an IDoc that posted nothing.
Delivering more than was ordered is refused, as SAP refuses past an item's
over-delivery tolerance. The tolerance is the order item's own
OverdelivTolrtdLmtRatioInPct — SAP's UEBTO, initial on a created item, so by
default an order receives exactly what it asked for — and
UnlimitedOverdeliveryIsAllowed (UEBTK) turns the check off. A generated
DELVRY07 ships what the order still has open, so asking twice sends the rest and
then refuses rather than delivering the whole quantity again. The real system
checks its tolerance per schedule line; this mock has no schedule lines and checks
against the item's quantity.
Accepted is not posted
Receiving an IDoc and posting it are two events, and SAP reports them separately:
the port answers, and then the application either posts the document or does not.
By default an inbound IDoc the mock can act on posts, status 53. Ask for
something else:
curl -X POST http://127.0.0.1:8000/_mock/idoc-posting \
-H 'Content-Type: application/json' \
-d '{"mestyp":"INVOIC","status":"51","message":"Posting period 08 2026 is not open","count":1}'
| Status | Meaning |
|---|---|
53 |
Application document posted - the default |
51 |
Application document not posted |
56 |
IDoc with errors added |
68 |
Error - no further processing |
mestyp and idoctyp are optional filters, count spends the rule after that
many IDocs, GET lists the rules and DELETE clears them, as does
POST /_mock/reset.
The mock also declines on its own account, without a rule, when it cannot do what
posting the IDoc means: an INVOIC with no E1EDKA1 naming the supplier owes
nobody money, and a DELVRY with no E1EDL24 naming a document and position
delivers nothing. Both are status 51 with a status text naming the segment that
was missing, because reporting 53 for an IDoc that posted nothing is the very
mistake this section exists to help you catch. An ORDERS05, which is filed and
nothing else, has no application step to decline and still posts 53.
An INVOIC that does not add up is declined the same way. Its total (E1EDS01
SUMID 010) must be its net (011) plus its tax (205), and when every
E1EDP01 states a NETWR the items must come to the net; otherwise the status
is 51 and the text gives the figures and what is left over. FI writes no
document whose debits and credits differ, so there is nothing a 53 could
honestly point at. A sum the IDoc leaves out is worked out from the other two
rather than held against it: a total alone is an invoice with no tax, and a
total with a tax has the difference as its net.
Two things about this are the point. The HTTP status stays 201: the IDoc
was received, a docnum was issued, and the failure is in the status record
where a client has to go looking for it. And a failed posting does nothing -
a DELVRY07 that ends in 51 leaves the order's delivery status exactly where it
was and creates no delivery, which is the entire difference between 53 and 51.
That combination is the expensive one. A 503 is loud and retryable; an IDoc that
is accepted and never posts looks like success from the sending side, and nobody
finds out until someone asks why the invoice was never paid.
The documents an order turns into
A sales order becomes a delivery, an invoice and the journal entry that invoice posts - each carrying the reference back to what it came from, and each readable over OData:
curl "$BASE/sap/opu/odata/sap/API_OUTBOUND_DELIVERY_SRV/A_OutbDeliveryHeader?\$expand=to_DeliveryDocumentItem&\$format=json"
They are created three ways, and a document created one way looks like a document
created another: BAPI_OUTB_DELIVERY_CREATE_SLS and BAPI_ACC_DOCUMENT_POST,
generating an INVOIC02 or DELVRY07, or posting an inbound delivery. Every
number the mock hands out now addresses a document that exists - an invoice adds
up to its items, and its journal entry balances. Inbound IDocs are parsed (XML, or a
flat file read at the documented EDI_DC40 offsets), stored with a 16-digit IDoc number
and status 53, and can be inspected or re-statused:
curl http://127.0.0.1:8000/_mock/idocs
curl -X PUT http://127.0.0.1:8000/sap/bc/idoc/<DOCNUM>/status \
-H "X-CSRF-Token: $TOKEN" -d '{"status":"51"}'
What posting an IDoc decided is remembered, not just answered. A FINSTA01
works out, per line, which invoice it cleared and why a line was refused; a
DELVRY07 says which sales orders it moved and names one it could not find; an
INVOIC02 reports the supplier invoice and the accounting document it posted.
Reading that IDoc back returns the same APPLIED the POST did, so a client that
did not keep the response is not left guessing:
curl http://127.0.0.1:8000/sap/bc/idoc/<DOCNUM>
An IDoc that posts nothing - an ORDERS05, or one the application declined -
has no APPLIED at all, rather than an empty one that reads as an answer.
The listing stays narrow, because one IDoc has one outcome and a listing of fifty
would carry fifty. It takes settled instead, which answers the question an
integration actually has of a statement - not "what did this IDoc do?" but "what
settled my invoice?":
curl "http://127.0.0.1:8000/_mock/idocs?settled=0100000007"
Authentication
By default the mock is open. Two mechanisms can be switched on, and with both on a request passes if it satisfies either.
mock-sap --auth sapuser:secret # HTTP basic, NetWeaver realm
mock-sap --oauth SAP_CLIENT:s3cret --token-ttl 60 # OAuth 2.0 bearer tokens
With --oauth, the flow an S/4HANA Cloud client has to implement works end to end:
TOKEN=$(curl -s -u SAP_CLIENT:s3cret -X POST \
http://127.0.0.1:8000/sap/bc/sec/oauth2/token \
-d 'grant_type=client_credentials' | python3 -c 'import json,sys;print(json.load(sys.stdin)["access_token"])')
curl -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?\$top=1&\$format=json"
| Grant | Notes |
|---|---|
client_credentials |
client id and secret, in the body or as HTTP basic |
password |
the username becomes the principal |
urn:ietf:params:oauth:grant-type:saml2-bearer |
the assertion's NameID becomes the principal |
refresh_token |
rotates: the old refresh token is spent |
The principal follows the token: a document created with a SAML-derived token has
that user in CreatedByUser. --token-ttl 60 makes tokens expire quickly so a
client's refresh path can actually be exercised, POST /sap/bc/sec/oauth2/revoke
ends one early, and GET /_mock/tokens shows what is outstanding.
expires_in means two different things in the two places it appears, deliberately.
In a token response it is the lifetime the token was issued with, as RFC 6749
defines it, so --token-ttl 60 always answers 60 no matter how long the mock
took to reply. In GET /_mock/tokens it is the seconds that token has left,
counting down, which is what a listing of live tokens is for.
None of this is cryptography. Tokens are opaque strings the mock remembers in
memory, and a SAML assertion is read for its NameID and otherwise believed - no
signature is checked, no issuer is verified. It exists so a client can exercise
fetch, use, expire, refresh and retry, not to stand in for an authorization server.
A supplier's invoice, and what it owes
The document chain runs both ways. A sales order becomes a delivery, an invoice
and money owed to us; an inbound INVOIC becomes a supplier invoice and
money owed by us:
curl -X POST http://127.0.0.1:8000/sap/bc/idoc \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/xml' \
-H 'Accept: application/json' --data-binary @supplier-invoice.xml
{"STATUS": "53", "APPLIED": [{"SUPPLIERINVOICE": "5100000001",
"ACCOUNTINGDOCUMENT": "0100000007", "INVOICINGPARTY": "1000009",
"MESSAGE": "Supplier invoice 5100000001 posted; EUR 1190.00 payable to 1000009"}]}
Those two numbers come back from the IDoc as well as from the receipt, so a
client that posted an invoice and kept only the DOCNUM can still find what it
created. The invoice is readable at API_SUPPLIERINVOICE_PROCESS_SRV with the
supplier's own number in SupplierInvoiceIDByInvcgParty - the reference a payment quotes
back - and the purchase order each line bills against under
to_SuplrInvcItemPurOrdRef, so a three-way match can be done over OData rather
than only inside an example. The accounting document it posts leaves an open
payable, which is what a payment run then selects.
Blocking an invoice blocks the money. PaymentBlockingReason on
A POST to A_SupplierInvoice posts the invoice, rather than filing a row: it
draws its number from the SUPPLIERINVOICE range, posts an accounting document and
leaves the payable a payment run selects — the same thing an inbound INVOIC
produces, because both go through the one place a supplier invoice is made. The
entity publishes a gross and no split, so an invoice posted this way carries no
input tax and its net is its gross; items are optional, and if they all state an
amount they have to come to that net. A field the route does not act on is refused
by name rather than dropped, and the document number and fiscal year are refused
outright because posting assigns them.
A_SupplierInvoice reaches the open item its accounting document posted, so a
payment run - which reads the item, not the invoice - stops picking it up.
The two are separate rows and keeping them in step is done rather than assumed;
letting them disagree means an invoice that reads as blocked while the payment
goes out anyway. PaymentMethod is blank by default, which is what SAP leaves
when the vendor master decides.
Three things it deliberately does not do:
- It does not check the invoice against the purchase order. That is the
payer's job, and mock-acme's
invoice_check.pydoes it. A mock that silently refused a mismatched invoice would hide the bug its user is looking for. An invoice that disagrees with itself is different: one whose total is not its net plus its tax is status51, as above. - It does not deduplicate. The same invoice sent twice creates two, because SAP's duplicate check is configuration and inventing one here would hide the commonest way companies pay twice.
- An IDoc that does not post owes nobody anything. Under a
51posting rule there is no invoice and no payable, which the tests assert by counting.
Open items: what is still owed
A payment run does not read journal entries. It asks a narrower question - what
do I still owe, to whom, and was it due - and in S/4 that is
API_OPLACCTGDOCITEMCUBE_SRV. The whole selection is one filter:
curl "$SRV/A_OperationalAcctgDocItemCube?\$filter=\
AccountingDocumentItemType%20eq%20'K'%20and%20\
ClearingAccountingDocument%20eq%20''%20and%20\
PaymentBlockingReason%20eq%20''%20and%20\
PaymentRunID%20eq%20''%20and%20\
NetDueDate%20le%20datetime'2026-09-30T00:00:00'&\$format=json"
| Field | Means |
|---|---|
AccountingDocumentItemType |
K supplier, D customer, S G/L - SAP's account type |
NetDueDate |
the baseline date plus what the terms allow |
PaymentTerms, PaymentBlockingReason |
why it may not be paid yet |
PaymentRunID, PaymentRunDate |
which payment run has it in flight, if one does |
ClearingAccountingDocument, ClearingDate, ClearingItem |
what paid it, once something has |
ClearingIsReversed |
the clearing was undone - a returned payment, not an unpaid invoice |
An item is open while ClearingAccountingDocument is blank, and blank is a
real empty string rather than a missing field, because that is what clients
filter on. A mock that left it null would answer eq '' with nothing at all and
a payment run would quietly find no work to do.
There is a state between open and cleared, and it has a name. A payment run
selects open items and pays them, but the statement that clears them comes back
days later - so without somewhere to record the selection, a second run started
in between selects the same invoice and pays it again. One 1190.00 invoice paid
2380.00, and the bank cannot catch it, because its duplicate check is keyed on a
MsgId the second run makes fresh.
So a run writes its own key, F110's PaymentRunID and PaymentRunDate, on
A_SupplierInvoice, and it reaches the open item the same way a payment block
does - because what the next run reads is the item:
curl -X PATCH "$SRV/A_SupplierInvoice(SupplierInvoice='5105600001',FiscalYear='2026')" \
-H "X-CSRF-Token: $TOKEN" -H 'If-Match: *' -H 'Content-Type: application/json' \
-d '{"PaymentRunID":"F110A","PaymentRunDate":"2026-10-05"}'
A claim is not a block. A block says nobody should pay this at all; a claim
says somebody is paying it right now, and reporting the second as the first
sends a treasury team to unblock something that is merely in flight. That is
why it is the run's identification rather than one more blocking reason:
PaymentRunID says which run, which one character cannot. Six characters,
because LAUFI is six - a seventh is refused rather than truncated, since
truncating would make two runs look like one.
A claim does not outlive the item it was made about. Clearing releases it,
and so does a return: an invoice that was paid and came back has to be
selectable again, or the claim becomes a worse fault than the one it prevents -
an invoice no run will ever pick up, because a run that finished still appears
to hold it. Both rows are released together. A run that was cancelled lets go
by writing the identification back to ''.
What it does not do is stop the statement. Clearing reads the open items directly rather than through a client's filter, so an item in flight is exactly the one an arriving statement should settle. The claim constrains the next payment run, not the bank.
This is a view, not a copy. The cube reads the same rows as
A_JournalEntryItem, so the two services cannot disagree; it stores nothing of
its own and /_mock/state does not count it. The journal entry service does not
publish the open-item fields, which is also true of the real one.
It is read-only, as the real service is. A POST, PATCH or DELETE is
refused with 405 and $metadata says sap:creatable="false". An item is
blocked by blocking its supplier invoice, and cleared by a payment and a bank
statement - not by writing to the cube. A mock that accepted those writes would
let a client work here and fail against S/4.
To arrange an item for a test - blocked, overdue, already cleared - without waiting for a payment, ask the mock rather than the service:
curl -X PATCH http://127.0.0.1:8000/_mock/open-items \
-H 'Content-Type: application/json' \
-d '{"AccountingDocument":"0100000008","CompanyCode":"1710",
"FiscalYear":"2026","AccountingDocumentItem":"000001",
"PaymentBlockingReason":"A"}'
/_mock is plainly the mock's own control plane and cannot be mistaken for an
SAP API, which is the point: the shortcut is somewhere a client would never
find it.
Post a payable with BAPI_ACC_DOCUMENT_POST and an ACCOUNTPAYABLE line
(VENDOR_NO, PMNTTRMS, BLINE_DATE, PMTBLOCK), or an ACCOUNTRECEIVABLE
line for the customer side. The terms table is deliberately short - 0001 is
payable at once, NT30/NT45/NT60 are what they say - and a document that
carries its own net payment days uses those instead.
Paying a supplier: the account on file
A payment run needs the account to pay into, and if it cannot read it from SAP
it keeps its own list somewhere else - the shadow master data that goes stale
and pays the wrong account. A_BusinessPartnerBank puts it where it belongs:
curl "$BP_SRV/A_BusinessPartner('1000009')?\$expand=to_BusinessPartnerBank&\$format=json"
Keyed by BusinessPartner + BankIdentification, with IBAN, SWIFTCode,
BankCountryKey, BankNumber, BankAccount, BankAccountHolderName and
IBANValidityStartDate. A supplier invoice names which account to use in
BPBankAccountInternalID, which is that BankIdentification - so the seed
gives one supplier two accounts, because a client that pays whichever came
back first is right by luck until then.
A wrong IBAN is refused when it is entered, with its check digits tested the way ISO 13616 says: rearrange, letter-to-number, modulo 97. Catching it here is the difference between a payment run that reports a bad account and a bank that returns the payment a week later. Every IBAN the mock seeds passes the same check a client's does, and there is a test that says so.
Not every supplier has an IBAN. The seeded suppliers outside the IBAN countries carry a bank number and an account number instead, because a US supplier is paid on a routing number and inventing a US IBAN would be a shape no bank would take. They carry no BIC either, for the same reason. A payment run has to cope with that, so the mock makes it happen.
Four suppliers bank where mock-bank
can act on them: 1000013 GLOBEX, 1000014 INITECH, 1000015 EURODIS and
1000016 Umbrella Logistics, at NL…MOCK… IBANs with BIC MOCKNL2A. Three
carry mock-edi's partner names, so the same trading partner is recognisable in
EDI, in SAP and at the bank. The suppliers numbered 1000009 to 1000012 are
unchanged, because other projects' tests name them.
A bank statement: what it claims
A bank statement arrives as a FINSTA01 IDoc with message type FINSTA.
mocksap/statement.py reads one into the account it is for (E1IDB02), its
number (E1IDKU1.BGMREF) and date, its balances, and each statement line
(E1IDPF1) with its structured reference (E1EDP02), its note to payee
(E1IDT01, fourteen lines of 70 characters) and its amounts (E1IDPU5).
Amounts are decimals, never floats.
The balances use SAP's own amount qualifiers, the fixed values of domain
EDIF5025: 019/021 opening and closing, 020/022 the same for an interim
statement, 023/024 total debits and credits. These are not the UN/EDIFACT
5025 codes the domain is named after, and an EDIFACT 174 or 300 is not read
as a balance. With all four figures present, a statement can be checked against
itself - opening plus credits less debits must be closing - which catches a
corrupt or partial file, and is a different failure from a statement that does
not follow the one before.
Debit or credit is the mock's convention, not SAP's. SAP's dictionary pins
nothing for it - E1IDPF1-LINACTION (domain EDIF1229) has no fixed values,
E1IDPU5-MOABETR (EDIF5004) is plain text, and no EDIF5025 qualifier means
a direction - so the reader takes it from the amount's sign, written SAP's way
with the minus after the number: 1190.00- is a debit, money out, the
payment that clears an invoice; 1190.00 is a credit, money in. Any
writer producing a FINSTA01 for the mock has to follow the same convention.
A line carrying several qualified amounts keeps all of them, and has neither an
amount nor a side picked for it.
Which kind of credit is the same kind of convention, in the field that made
the one above necessary. Money in has two readings that are opposites - a
payment of ours coming back, or money arriving - and camt.053 separates them,
where a received credit transfer carries PMNT/RCDT/ESCT and no RtrInf. A
FINSTA01 line had nowhere to put that, so the mock reads LINACTION, whose
domain pins no fixed values and is free to carry two of the mock's own:
LINACTION |
the line is |
|---|---|
RET on a credit |
a payment of ours coming back |
RCV on a credit |
money arriving |
RET on a debit |
money we received going back |
They are letters rather than digits so that nothing here is mistaken for the
EDIFACT 1229 code list SAP declined to pin. The code is read whatever case it
arrives in. A credit saying neither is undeclared, which is not the same as
one saying RCV: it is read as nothing at all rather than as either. A debit
saying anything but RET, or nothing, is a payment of ours, so a writer that
stamps LINACTION on every line still has its payments cleared.
Lockbox is not supported. FINSTA01 also carries message type LOCKBX,
and the E1IDLB1/E1IDLB2 subtree belongs to it. The mock reads bank
statements only, and refuses a LOCKBX IDoc rather than misread it. A statement
must be IDoc XML; a flat-file FINSTA01 is refused, since reading one needs
every segment's fixed-width layout.
Posting a statement: clearing what it paid
Reading the file is half of it. Posting one does what electronic bank statement processing does, in miniature:
{"STATEMENT": "00042", "ACCOUNT": "0007000063",
"CLEARED": [{"LINE": "000001", "REFERENCE": "SUP-A1",
"ACCOUNTINGDOCUMENT": "0100000008",
"CLEARINGDOCUMENT": "0100000009", "AMOUNT": "1190.00"}],
"REOPENED": [],
"UNPROCESSED": [{"LINE": "000002",
"REASON": "reference SUP-E1 is item 0100000008 for "
"1190.00 EUR, but the line is for 1000.00 EUR"}],
"FINDINGS": []}
A debit clears an open item when the reference, the amount and its
currency agree. A structured reference (E1EDP02) settles the question on its
own: a bank that named the document it paid is believed, and the note to payee
is not read. A line carrying no structured reference is matched on its note
instead, searched both as written and with its spaces removed - a bank wraps the
note at 70 characters wherever it falls, so SUP-9001 can arrive as SUP- 9001
and an exact search would quietly miss it. Reading the note as well matched
INV-1 to a line that said INV-10, because the shorter number is inside the
longer one.
An amount without a currency is not an amount. A line for 1190.00 USD does
not pay a payable of 1190.00 EUR, and every figure in a refusal is printed with
its currency, because two amounts that happen to be equal are the one
disagreement that reads as agreement. A statement naming a currency nowhere -
no CUXWAERZ on the line, no FIIKWAER on the account - clears nothing:
filling in a house currency would invent the half of the amount that decides
whether this is a payment at all. A code is read whatever case it arrives in.
An invoice number is no key, so a line that fits two suppliers clears
neither. The number is a supplier's own sequence, so two of them can both
have an INV-100, and nothing read off a statement line says which was paid -
E1IDPF1 gives a reference, a note to payee and amounts, and the account the
file names is the one being reconciled, not the payee's. Where the amount does
not separate the candidates either, nothing is cleared and the reason names every
item and its supplier. Clearing whichever was found first paid one supplier's
invoice with another's money, left an item open for the next payment run to
pay again, and - with one payment document per supplier - credited the wrong
party too. Two items of the same supplier sharing a number are a duplicate
invoice, not this, and are still settled once.
Unless a payment run's claim says which. A client that writes
PaymentRunID on an item before it sends the payment has put on the item the
one thing the line leaves out. So where two suppliers' items fit a line equally
and exactly one of them carries a claim, that item is the one paid: it is
cleared, its claim is released, and the other supplier is still owed. No claim
decides nothing, and neither does more than one - the line is refused as above,
and with several the reason names the items and the runs that claimed them.
Without this, the first supplier's item stayed open and claimed for good, and a
run that will not send two items with one reference never paid the second.
One payment document per supplier, not per line. A payment run pays a
supplier, so every item one statement settles for them is cleared by a single
document: one supplier line per invoice, one credit to the bank for the total,
and each item's ClearingItem pointing at the line that paid it. That is what
makes a remittance advice worth sending — the supplier sees one credit and has
to be told which invoices it covers. Items that disagree on company code or
currency cannot share a document, because its header carries one of each.
Everything else is left alone and listed. A line that matches nothing, or matches a reference but not the amount, clears nothing and says why, naming both numbers. That is not a gap in the mock: it is the reconciliation gap a treasury team works through every morning, and guessing at those lines would invent the answer they are paid to find.
A credit that says it is a returned payment, and quotes a cleared item,
reopens it. The clearing is reversed and the item is open again, so a payment
run will try it again. Returns are posted after the payments, so a statement
that pays an invoice and takes the money back reads the same whichever order the
bank listed those two lines in. The clearing document is removed from the item -
the way reversing a clearing in SAP puts it back among the open items - but
ClearingIsReversed stays set, so paid and returned can still be told from
never paid. Without that they look identical, and telling them apart is the
whole job.
A credit has to say which kind it is. A credit was read as a return on the
strength of nothing but its sign, so money genuinely arriving that happened to
quote an invoice already paid - a refund, a credit note, a supplier returning an
overpayment - reopened it, and the next payment run paid it a second time. Now
RCV reverses nothing, and a credit declaring neither reverses nothing either
and is listed with the reason, because guessing RET pays an invoice twice and
guessing RCV hides a payment that genuinely came back.
A credit that says it is money arriving clears the receivable it quotes. A
customer pays quoting the billing document they were sent, so an RCV line is
matched against the open customer items (AccountingDocumentItemType D) by
A_BillingDocument.BillingDocument - structured reference first, then the note
to payee - and on the same terms as a payable: the amount and its currency both
have to agree. What it clears is posted the other way round from a payment: one
DZ document per customer, the bank debited for the total and the customer's
account credited one line per invoice, and that document's own customer lines
cleared so the customer is not left looking as though they are owed money. It is
listed under CLEARED like any other. The side and the kind decide which ledger
a line can touch and the reference never does: money out never clears a
receivable, and money arriving never clears a payable.
A part payment is refused, not posted. SAP has two answers to a customer who pays less than the invoice - clear it and leave a residual item for the difference, or post the payment against an item that stays open - and they behave differently on the next statement. The mock does neither. A credit for anything but the receivable's exact amount clears nothing, makes no residual item, posts no partial payment, and is listed with both figures; an overpayment is treated the same, and the reason says how far short or over it was. Deciding whether a short payment was a deduction or an instalment is the reader's job. Money arriving that quotes no open receivable at all is listed as applied to nothing, never dropped.
One credit can settle several invoices. A customer paying three invoices
with one transfer quotes each and sends the total, so a receipt clears the set
of the receivables it quotes whose amounts come to exactly what arrived, with a
CLEARED row and a receipt line per invoice, each for what that invoice was
for. Only what the line quotes is considered: the customer's other open items
might add up to the credit, and clearing them because they do would be deciding
what the customer meant. If more than one set fits - invoices for 100, 200 and
300 and a credit for 300 - none is cleared and the reason names each set. A line
quoting more than 16 open receivables is not searched.
Paid net of a cash discount, the invoice clears; an unearned discount stays
open. The one exception to the part-payment rule is a shortfall of exactly
the discount the receivable's terms offer - 0002 is 2% 10 net 30 - worked out
by documents.discount_on from the item's PaymentTerms, its
DueCalculationBaseDate and the statement's date. Paid within the discount
days, the invoice clears and the discount is posted to the cash-discount
account 0048000000. Paid later, the invoice still clears, and the discount
goes back on the customer's account as a new line of the receipt document
which is left open: they still owe it. The statement's FINDINGS then name
the invoice, the amount, the terms, the last day the discount could be taken
and where the open item is. CLEARED carries the money that arrived. Nearly
the discount is a part payment like any other, and where a line can be read two
ways - either of two invoices paid net - neither is taken. An invoice that
offers a discount can be paid two ways, so a line is also refused when what it
quotes could be paid in more than 1,048,576 ways; sixteen invoices with a
discount on a few of them is well inside that. Clearing the open discount from
a later statement, and reopening a discounted receipt that goes back, are not
built. The first has no document of ours for the customer to quote. The second
is refused by name whatever the debit is for - the net that arrived or the
invoice's whole amount - because the discount would have to be undone with it.
A customer's payment that goes back reopens the receivable. Money out has
two readings as well: a payment of ours, or money we received going back - a
recalled transfer, a returned direct debit. A debit carrying RET is the
second. It reopens the cleared receivables it quotes, on the receipt's own rule
read backwards: the set of them that comes to exactly what went back, so a
transfer that settled two invoices leaves both owed again. The reversal is a
DZ document, customer debited and bank credited, and ClearingIsReversed
stays set as it does for a payable. It is never a payment of ours, even where
it quotes an open payable for the same money. A debit carrying anything else,
or nothing, is a payment of ours as it always was: unlike a credit, an
undeclared debit is not refused.
A supplier can be told what was paid. Generating a REMADV from a payment
document renders a PEXR2002 naming every invoice that payment settled, each
with the number the supplier quotes and what came off it, and a total that is
the sum of those rows:
curl -X POST "http://127.0.0.1:8000/sap/bc/idoc/generate?format=xml" \
-H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"mestyp":"REMADV","ClearingAccountingDocument":"0100000011"}'
It is the only generator whose source is not a sales order, and it is deliberately short: it carries what the mock knows and nothing else, so there is no bank account on it — that is not in this mock's data, and inventing one would be output nothing wrote. The advice is dated by the payment's own posting date, so it cannot claim a settlement that has not happened, and a document that settled nothing is refused rather than advised.
Two checks on the statement itself, reported separately because they are different failures:
| Finding | Means |
|---|---|
does not add up |
opening + credits - debits is not closing: a corrupt or partial file |
a statement is missing |
its opening balance does not follow the last closing balance |
An interim statement (balances under 020/022) is not chained to the previous
one, because it is a snapshot inside a period rather than the next in sequence,
and treating it as the next would invent a gap that is not there.
A statement that did not post clears nothing, under the 51 posting rules -
an item cleared by an IDoc that never posted would be an invoice nobody can find
and nobody will pay again.
Delta: what changed since last time
A replication client reads once with Prefer: odata.track-changes, keeps the link
it is handed, and comes back with it later:
curl -H 'Prefer: odata.track-changes' "$V4/SalesOrder" # → "@odata.deltaLink": "…?$deltatoken=D20260911T175753033"
curl "$V4/SalesOrder?\$deltatoken=D20260911T175753033" # → only what changed, and what went
{
"@odata.context": ".../$metadata#SalesOrder/$delta",
"value": [
{"SalesOrder": "0000004712", "PurchaseOrderByCustomer": "PO-DELTA", "…": "…"},
{"@id": ".../SalesOrder('0000004714')", "@removed": {"reason": "deleted"}}
],
"@odata.deltaLink": ".../SalesOrder?$deltatoken=D20260911T175812004"
}
The V2 services do the same in their own spelling: __delta in the payload,
!deltatoken='…' on the way back, and deleted entries marked
"__metadata": {"deleted": true, …}.
Entity types with a change timestamp support this - sales orders, products,
business partners - and the ones without say so rather than quietly returning
everything. Deletions are remembered in a table of their own, because a deleted row
leaves nothing behind for a reader to find; POST /_mock/reset forgets them along
with the rest.
A delta read is at-least-once, and so is SAP's. Change timestamps carry
milliseconds and nothing finer, because that is all /Date(ms)/ can express, so a
change made in the token's own millisecond cannot be told apart from the token's
instant. This mock reports it again rather than risk dropping it: the token is
taken before the rows are read, and the comparison is at or after, not after.
A client may therefore see a change it has already applied, which costs it an
idempotent write it has to be capable of anyway. The alternative costs it the row,
silently - it is told nothing changed, and nothing tells it otherwise.
Warnings that do not fail the request
SAP answers are not binary: a request can succeed and still carry messages. The
mock puts them in the sap-message header, and on the V4 services in a
SAP__Messages collection on the entity, which is where SAPUI5's message popover
reads them from.
HTTP/1.1 201 Created
sap-message: {"code":"V1/302","message":"Requested delivery date 2020-01-01 is in the
past; it was moved to 2026-09-11","severity":"warning",
"target":"RequestedDeliveryDate","numericSeverity":3,"details":[]}
Three rules produce warnings from the data itself, so a client can be tested against messages that arise rather than only injected ones:
| Situation | Message |
|---|---|
| a requested delivery date in the past | the order is rescheduled to today and the move is reported |
| a sold-to party blocked centrally | the block is reported |
| an item whose material is flagged for deletion | the flag is reported |
Any warning can also be injected, which is what an integration test usually wants:
curl -X POST http://127.0.0.1:8000/_mock/faults -H 'Content-Type: application/json' \
-d '{"match":"A_SalesOrder","method":"GET","message":"Credit limit exceeded","count":1}'
A rule with a message and no status warns without failing; a rule with a
status fails, and a failure belongs in the error body rather than a warning
header. The message classes and numbers are plausible rather than authentic - what
a client depends on is the shape, the severity and the target.
Simulating a bad day
Per request, with a header or a query parameter:
curl -H 'sap-mock-scenario: busy' http://127.0.0.1:8000/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder
| Scenario | Effect |
|---|---|
slow |
delays by --slow-ms (default 3s) |
timeout |
sleeps past any client timeout |
error |
500, Gateway technical exception |
busy |
503 with Retry-After, "no dialog work process available" |
auth / forbidden |
401 with a NetWeaver realm / 403 missing authorization |
lock |
423, document locked by another user |
precondition |
412, as when the entity was changed after it was read |
expired-token |
401 invalid_token, as when a bearer token has run out |
csrf |
403 with x-csrf-token: Required |
notfound |
404 |
As rules, matched by regex on the path:
curl -X POST http://127.0.0.1:8000/_mock/faults -H 'Content-Type: application/json' \
-d '{"match":"A_SalesOrder","method":"POST","status":500,"message":"Backend unreachable","count":1}'
curl -X DELETE http://127.0.0.1:8000/_mock/faults # clear all rules
Or globally, at startup: --latency-ms 250 --error-rate 0.05.
Every one of these is a transport failure. For the application-level kind, where the response is a success and the payload says otherwise, see A valid call that fails anyway for BAPIs and Accepted is not posted for IDocs.
Pinning the clock
Every date the mock computes - a posting date, a document date, the baseline a
payment term counts from, the timestamp on a log row - comes from one clock.
Left alone it keeps real time in UTC. --clock pins it:
python3 -m mocksap --clock 2026-10-02T16:00
Now the same script posts the same documents with the same dates on every run,
which is what makes a captured run reproducible and an example's dated
assertion stable. Without it, an invoice posted today on NT30 falls due
thirty days from whenever the suite happened to run - and an example asserting
on that date goes stale by itself overnight.
Move it forward to make a due date arrive:
curl -X POST "http://127.0.0.1:8000/_mock/advance?days=30"
curl -X POST "http://127.0.0.1:8000/_mock/advance?to=2026-11-01"
curl http://127.0.0.1:8000/_mock/state # "clock": {"now": ..., "pinned": ...}
It does not go backwards, and says so rather than accepting it: whatever was
dated since would then be in the future. POST /_mock/reset returns it to the
pinned moment rather than to real time, because --clock is configuration and
a reset is not meant to undo configuration.
The clock holds an offset, not an instant, so it keeps ticking between advances. A frozen clock would stamp every row in a run identically, which in a log is indistinguishable from a bug.
It is UTC and knows nothing about business days - no cutoff, no weekend, no holidays. Those are a bank's questions, and mock-bank has them; SAP here has a posting date and a due date. So a mock-sap date that lands on a Saturday is still a Saturday, and if that matters to what you are testing, it is the calendar you have to reckon with rather than this clock.
Inspecting what your client did
Every request is recorded, which makes the mock useful as a contract check in CI:
curl "http://127.0.0.1:8000/_mock/requests?limit=10" # method, path, query, status, duration
curl http://127.0.0.1:8000/_mock/rfc-log # which BAPIs were called
curl "http://127.0.0.1:8000/_mock/rfc-log?verbose=1" # ...and what each one answered
curl http://127.0.0.1:8000/_mock/state # row counts per entity
curl http://127.0.0.1:8000/_mock/idoc-posting # how inbound IDocs will post
curl http://127.0.0.1:8000/_mock/bapi-behaviour # what a BAPI will answer
curl http://127.0.0.1:8000/_mock/open-items # what is still owed, and set its state
curl -X POST http://127.0.0.1:8000/_mock/reset \
-H 'Content-Type: application/json' -d '{"seed":7,"orders":50}'
Both logs record more than they return. ?verbose=1 adds the stored payloads:
the body and headers a caller sent on /_mock/requests, and each call's
parameters and full answer on /_mock/rfc-log. That answer is where a BAPI's
RETURN table lives, and it is the only way to see a business error from
outside - a BAPI reports one by returning normally with HTTP 200 and a RETURN
row of type E, so through the narrow log a refused call and a successful one
are the same two fields.
# a refused BAPI, and the reason it gave
curl -X POST http://127.0.0.1:8000/_mock/bapi-behaviour \
-d '{"function": "BAPI_PO_CREATE1", "type": "E", "message": "Posting period closed"}'
curl "http://127.0.0.1:8000/_mock/rfc-log?limit=1&verbose=1"
It is off by default because the payloads are capped at 20000 characters for an
RFC call and 8000 for a request body, so a default limit of 25 could answer
with half a megabyte where it otherwise answers with a few hundred bytes.
Credential headers - Authorization, Cookie, Set-Cookie, X-CSRF-Token,
Proxy-Authorization - come back redacted. /_mock/requests is not behind
--auth, and a mock that handed a client its own token back through an
unauthenticated endpoint would be teaching a bad habit. The header name is kept
with a marker in place of the value, because a client debugging an auth failure
needs to know the header was sent, which an absent key cannot tell it from one
that was never set.
POST /_mock/reset restores a known dataset between test cases. It also
empties the request log and the function-call log and starts their ids again at
1, so what a test reads from /_mock/requests after a reset is its own.
Input the control plane cannot use is a 400 that names the field - a limit
that is not a whole number, a body that is JSON but not an object. A fault rule
is checked when it is posted: one with a status that is not an HTTP status, or
a match that is not a regular expression, is refused there rather than stored
and left to fail the requests it matches.
Command line
mock-sap [--host 127.0.0.1] [--port 8000] [--db :memory:|path.db] [--client 100]
[--user MOCKUSER] [--auth USER:PASSWORD] [--no-csrf] [--seed 42]
[--latency-ms 0] [--error-rate 0.0] [--slow-ms 3000] [--no-request-log]
[--rollback-type E|W] [--unsupported-option-status 400|501]
[-q] [--version]
--db mock.db keeps data across restarts; the default in-memory system starts fresh
every time. --auth turns on HTTP basic authentication with a NetWeaver realm, and
--oauth turns on bearer tokens.
--require-if-match makes the mock refuse to modify a concurrency-controlled entity
that arrives without a validator, the way newer Gateway services do.
Using it from tests
import threading
from mocksap import Config, make_server
httpd = make_server(Config(port=0, db_path=":memory:", csrf=False, quiet=True))
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
# point the code under test at http://127.0.0.1:{port} ...
httpd.shutdown()
examples/client.py is a dependency-free client showing the token/cookie flow.
The fuller examples that used this mock together with mock-edi and mock-bank have moved to mock-acme, which holds the integration between the mocks in one place and tests it against all three:
mockacme/invoice_check.py, which wasexamples/invoice_check.pyhere, checks a supplier's X12 invoices against the purchase order inAPI_PURCHASEORDER_PROCESS_SRVand the supplier's ship notice, and posts the ones that match here asINVOICIDocs.mockacme/remittance.py, which wasexamples/remittance.pyhere, runs the other way: it takes thePEXR2002payment advice this mock generates from a payment document and sends the supplier the X12 820 they read.mockacme/po_bridge.pysends purchase orders from here to the supplier as 850s and posts the 855 confirmations back asORDRSPIDocs, and its tests use this mock's fault rules to take the IDoc endpoint down mid-run.
The first and the last are walked through, test by test, in
Testing an SAP-to-EDI Integration Without SAP or a Trading Partner,
which was written when they lived in the mocks' own examples/ folders.
Adding entity sets
Everything is generated from the declarations in
mocksap/schema.py — tables, $metadata, payload shapes and
key handling. Add an EntityType with its Props and Navs, list it in a Service,
and it is fully queryable and writable:
_register(EntityType("A_BillingDocument", label="Billing Document", props=[
S("BillingDocument", key=True, nullable=False, max_length=10),
S("BillingDocumentType", max_length=4),
DEC("TotalNetAmount", precision=16, scale=3),
DT("BillingDocumentDate"),
], navs=[Nav("to_Item", "A_BillingDocumentItem", "*", [("BillingDocument", "BillingDocument")])]))
Seed data for it goes in mocksap/db.py, a BAPI wrapper (if you need one) in
mocksap/bapi.py.
Tests
python3 -m unittest discover -s tests -v # everything
python3 tests/test_batch.py # one surface
Every test runs over real HTTP against a running mock, split by
surface: test_metadata, test_odata_read, test_odata_write, test_odata_v4,
test_apply, test_delta, test_annotations, test_v2_annotations,
test_complex, test_links, test_etag, test_batch, test_rfc, test_idoc,
test_documents, test_oauth, test_messages, test_operations and test_auth,
over the shared harness in tests/support.py.
CI runs them on Python 3.8-3.13 across Linux, macOS and Windows, and additionally
checks that examples/demo.sh, the packaged wheel and the Docker image still work,
and that every file in the repository is accounted for in
docs/FILES.md:
python3 tools/check_docs.py # every file is documented
python3 tools/check_changelog.py # the changelog is intact and says what changed
python3 tools/check_csdl.py # every $metadata is valid CSDL, by OASIS's own schemas
Releasing
Releases go to PyPI through Trusted Publishing:
PyPI trusts this repository's publish.yml workflow directly, so no API token is
stored in the repository or on anyone's laptop. Publishing a GitHub Release runs the
test suite, builds the sdist and wheel, checks that the tag matches the version in
pyproject.toml, and uploads. Running the workflow manually publishes to TestPyPI
instead, to rehearse.
pyproject.toml is the only place the version is written: mocksap.__version__
reads it back from the installed package metadata, and CI fails the release if the
tag, pyproject.toml and the built wheel disagree.
# bump `version` in pyproject.toml, then:
git tag v0.2.0 && git push origin v0.2.0
gh release create v0.2.0 --generate-notes
Layout
mocksap/schema.py entity types, navigations, services (add shapes here)
mocksap/db.py SQLite schema, number ranges, seed data
mocksap/odata.py $filter parser, key predicates, V2 shaping, error envelope
mocksap/odata4.py the V4 shapes: annotations, ISO dates, plain numbers
mocksap/apply.py $apply pipelines compiled to GROUP BY
mocksap/delta.py delta tokens, and what changed since one
mocksap/metadata.py EDMX 1.0 / service document
mocksap/annotations.py the V2 annotation document
mocksap/metadata4.py CSDL 4.0, XML and JSON
mocksap/store.py CRUD, deep insert, cascades, document defaults
mocksap/service.py OData request dispatcher
mocksap/batch.py $batch multipart and atomic changesets
mocksap/bapi.py BAPI/RFC functions, JSON and SOAP transports
mocksap/documents.py creating deliveries, invoices and journal entries
mocksap/money.py amounts as decimals: the scale, and rounding a half up
mocksap/bank.py IBAN and BIC checks, and the accounts the seed builds
mocksap/statement.py reading a FINSTA01 bank statement, and checking its sums
mocksap/reconcile.py matching a statement to the open items it pays
mocksap/outcome.py what posting an IDoc decided, kept so a read can say
mocksap/idoc.py IDoc inbox/outbox, ORDERS05 generation
mocksap/messages.py sap-message warnings, and the rules that produce them
mocksap/clock.py system time: one clock, pinnable and movable
mocksap/oauth.py the token store: grants, bearer validation, refresh
mocksap/server.py HTTP front end, CSRF, auth, fault injection, /_mock API
tests/ one module per surface, all driven over real HTTP
examples/ the curl tour and a dependency-free Python client
docs/ ARCHITECTURE.md and FILES.md
CHANGELOG.md what each release changed
CONTRIBUTING.md how to work on the project
.github/workflows/ ci.yml (tests, examples, wheel, image) and publish.yml
docs/ARCHITECTURE.md explains how a request flows through the mock and why the layering is the way it is; docs/FILES.md walks through every file in the repository.
Scope
This is a black box. It stores what you send and returns it in SAP's shapes. It does not do ATP checks, pricing procedures, output determination, authorization objects, workflow or any other real SAP logic. What it is good for: developing and testing integrations, contract tests in CI, demos, and load-testing your side of the wire.
Two roadmaps are done: OData V4, complex types, $links, ETags and OAuth, then
more function modules, more IDoc types, $apply, delta handling, sap-message,
the UI annotations, and then the documents a sales order turns into, an object
page worth opening and a V2 annotation document.
Open an issue if you need something else - a shape the mock gets wrong is worth one, and so is a shape it does not have yet.
Pull requests are welcome - CONTRIBUTING.md covers how to work
on the project and what the code values, and CHANGELOG.md records
what each release changed. Adding an entity set is usually a single declaration in
mocksap/schema.py; everything else - tables, $metadata, payload shapes - follows
from it.
See also
mock-edi is the same idea for EDI: a mock
trading partner that answers an X12 850 or EDIFACT ORDERS with the
acknowledgment, order response, ship notice and invoice a real one sends, and
misbehaves on demand. An IDoc ORDERS05 and an X12 850 are the same business
document, so the two mocks make a reasonable pair of ends for testing the
middleware between SAP and a trading partner.
mock-acme is that middleware, and
invoice_check.py there is one piece of it.
Testing an SAP-to-EDI Integration Without SAP or a Trading Partner uses the two mocks together: purchase orders out and confirmations in, then invoices checked against what was ordered and shipped, with the failure modes each test exercises.
mock-bank is the third: a mock bank
that takes an ISO 20022 pain.001 payment file and answers with a pain.002
status report, a camt.054 debit notification and a camt.053 statement,
on a bank clock a test can move. It is the payment that follows an approved
invoice; a payment_run example joining it to this mock is planned for its
0.2 release.
rickseufert.com lists all three mocks side by side, with the worked examples and how to run each one.
Metadata
Release files for mock-sap 0.21.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mock_sap-0.21.0.tar.gz | 389.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mock_sap-0.21.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 589.5 kB
Release files / mock_sap-0.21.0.tar.gz
| Download URL | mock_sap-0.21.0.tar.gz |
|---|---|
| Size | 389.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
43c25804069cd808871696c1dd510249c6dc90a33e737363615dbd475da31a7a
|
|
BLAKE2b-256 checksum How to use checksums |
0e15e4510f7c4442f9acd854ed0be2d0e3e21d533777aa858ceefe1b4dbe3df2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency logRelease files / mock_sap-0.21.0-py3-none-any.whl
| Download URL | mock_sap-0.21.0-py3-none-any.whl |
|---|---|
| Size | 199.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3dc780cbd6631ed7d273719f46ac568ed1e26e9735df09e3813ca616b1c39990
|
|
BLAKE2b-256 checksum How to use checksums |
ecc5795ecff76f376eb75edebae1bcab31a8d056bac064d7206074cf2c7c07e2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency log