Metadata-Version: 2.4
Name: mock-edi
Version: 0.9.0
Summary: A mock EDI trading partner: X12 and EDIFACT over AS2, with the whole order-to-cash choreography, over SQLite, with zero dependencies.
License: MIT
Project-URL: Homepage, https://rickseufert.com/#projects
Project-URL: Repository, https://github.com/rseufert/mock-edi
Project-URL: Issues, https://github.com/rseufert/mock-edi/issues
Project-URL: Changelog, https://github.com/rseufert/mock-edi/blob/main/CHANGELOG.md
Keywords: edi,x12,edifact,as2,mock,850,856,810,997,orders,desadv,invoic,edi-integration,testing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Testing :: Mocking
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# mock-edi

[![CI](https://github.com/rseufert/mock-edi/actions/workflows/ci.yml/badge.svg)](https://github.com/rseufert/mock-edi/actions/workflows/ci.yml)
[![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/mock-edi)](https://pypi.org/project/mock-edi/)

**A mock EDI trading partner.** Not an EDI library and not an AS2 server — the
thing on the *other end*. Send it an 850 and it sends back a 997, then an 855
that answers line by line, then an 856 with a shipment tree, then an 810 that
bills what shipped. An EDIFACT partner sends an `ORDERS` and the same thing
happens in `CONTRL` / `ORDRSP` / `DESADV` / `INVOIC`.

```
   you ──850──▶  mock-edi
       ◀──997──  the syntax parsed
       ◀──855──  2 lines: one confirmed, one short
       ◀──856──  shipment / order / item, with a tracking number
       ◀──810──  1132.80, terms 2% 10 net 30
```

There is plenty of open source for *speaking* EDI — OpenAS2 and mendelson will
terminate an AS2 connection, and a dozen libraries will parse an X12 segment.
What none of them is, is a counterparty. To test the code that runs when an
856 arrives unannounced, or when the invoice comes twice, or when the
acknowledgment never comes at all, you need a partner that does those things
on demand. Real ones do them on their own schedule, and getting one to do it
deliberately is a support ticket and a fortnight.

- **Zero dependencies.** Python 3.8+ standard library and SQLite, nothing else.
  It installs in a locked-down CI image.
- **Real wire shapes.** A 106-character fixed-width ISA that declares its own
  delimiters, EDIFACT's `?` release character, composite elements, `TDS` with
  two implied decimals, `HL` parent pointers, `AK3`/`AK4` error codes, MDNs
  with a `Received-Content-MIC`.
- **Failure on demand.** Short shipments, rejected lines, refused orders,
  duplicate invoices, a strict partner, and a partner that never answers —
  each one PATCH away.
- **It validates its own output.** Every document the mock writes is checked
  against the same dictionary it checks yours against. There is a test for it.

MIT licensed. ASC X12 and UN/EDIFACT are standards published by their
respective bodies; AS2 is RFC 4130. This project implements publicly
documented wire formats for testing purposes and is not affiliated with or
endorsed by any standards body or vendor.

By [Rick Seufert](https://rickseufert.com). The [projects page](https://rickseufert.com/#projects)
has this mock, [mock-sap](https://github.com/rseufert/mock-sap), [mock-bank](https://github.com/rseufert/mock-bank)
and the worked examples that use them together.

---

## Quick start

```bash
pip install mock-edi
mock-edi --port 8080
```

`8080` is the default, so plain `mock-edi` listens there too. The three mocks
keep distinct defaults - mock-sap on `8000`, mock-edi on `8080`, mock-bank on
`8090` - so all three run side by side with no flag to pass.

```bash
curl -X POST --data-binary @order.edi http://127.0.0.1:8080/edi
```

```json
{
  "accepted": true,
  "partner": "ACME",
  "dialect": "X12",
  "orders": ["4500000042"],
  "transactionSets": [
    {"code": "850", "control": "0001", "kind": "order", "accepted": true, "findings": []}
  ],
  "queued": [
    {"kind": "acknowledgment", "code": "997", "reference": "000000077", "dueAt": "..."},
    {"kind": "response",       "code": "855", "reference": "4500000042", "dueAt": "..."},
    {"kind": "despatch",       "code": "856", "reference": "4500000042", "dueAt": "..."},
    {"kind": "invoice",        "code": "810", "reference": "4500000042", "dueAt": "..."}
  ]
}
```

Then collect what it sent you:

```bash
curl "http://127.0.0.1:8080/_mock/mailbox?raw"
```

```
ISA*00*          *00*          *ZZ*MOCKEDI        *ZZ*ACME           *260924*1030*U*00401*000000001*0*T*>~
GS*PR*MOCKEDI*ACME*20260924*1030*2*X*004010~
ST*855*0002~
BAK*00*AD*4500000042*20260924****5100002*20260924~
...
PO1*1*100*EA*12.50**VP*WIDGET-001*UP*076123400003~
ACK*IA*100*EA*067*20260926~
```

Run it from a checkout with no install at all, or in a container:

```bash
python3 -m mockedi --port 8080
docker build -t mock-edi . && docker run -p 8080:8080 mock-edi
```

The container binds `0.0.0.0`, because `127.0.0.1` inside a container is
unreachable from outside it. On anything but your own machine, give it a
password — the control plane can reset the database, rewrite the partners and
read every archived document, and the mock says so on startup if you have not:

```bash
docker run -p 8080:8080 mock-edi --auth edi:s3cret
```

A guided tour of every endpoint, in curl:

```bash
bash examples/demo.sh
```

## Driving it from a test

The control plane is plain HTTP and JSON so that any language can drive it.
From Python, the package ships the client rather than leaving you to write it:

```python
from mockedi.testing import Mock

with Mock.start(invoice_delay_ms=3600_000) as mock:   # own port, in this process
    summary = mock.send(order)                        # dialect sniffed from the bytes
    assert summary["accepted"]

    response = mock.document(partner="ACME", kind="response")
    assert response.find("ACK").get(1) == "IA"        # no splitting on ~

    assert mock.order("4500000042")["status"] == "confirmed"
    mock.settle()                                     # nothing left undelivered
```

A document also reads as a model, when the assertion is about what the seller
said rather than about one segment:

```python
response = mock.document(partner="ACME", kind="response").as_response()
assert response.short[0].short_by == 40          # confirmed 60 of 100

invoice = mock.document(partner="ACME", kind="invoice").as_invoice()
assert invoice.line_total == invoice.total       # the supplier's own arithmetic
```

`as_order`, `as_response`, `as_change_response`, `as_despatch` and
`as_invoice` cover every business document in both dialects, and they are
forgiving about how the other side writes one — which is the point, since the
documents your partner sends are not the ones this mock writes.

Two mocks wired to each other — a seller and a buyer — are a conversation
rather than a request, and one call runs it to a stop:

```python
seller.exchange(buyer)
```

Settling one mock leaves the other holding work, and a document that is only
*promised* — a delayed invoice, which does not exist yet — is never `ready`
however often you settle. `exchange` moves each clock and settles each mock
until a whole pass changes nothing, and raises with what each side still holds
rather than hanging. Pass `advance=False` for a test about *when* something
arrives rather than about what.

When the test is about the state *between* two hops of that conversation,
start the mocks held and send one document at a time:

```python
seller = Mock.start(as2_id="SELLCO", hold_delivery=True)
buyer = Mock.start(as2_id="BUYCO", hold_delivery=True)
...
buyer.step()                    # the 850 goes; {"code": "850", "status": "delivered", ...}
seller.timeline("4500000042")   # the seller has promised, and nobody has answered it
seller.step()                   # its 997
```

See [One document at a time](#one-document-at-a-time).

`Mock("http://host:9000")` talks to one that is already running, wherever it
is. `Mock.start(**config)` starts one on a port the OS picks and stops it
again, and takes the same keywords as the command line.

A call that comes back with an unexpected status raises `MockError` carrying
what the mock said, because a refusal here names the field, the flag or the
finding. `mock.get(...)` and friends return the status instead, for a test
that is *about* the refusal.

Still no dependencies. `tests/support.py` in this repository is written on top
of it, which is the only test of such a thing that means anything.

The code it exists to test lives in [mock-acme](https://github.com/rseufert/mock-acme), the integration between
the mocks. [`mockacme/po_bridge.py`](https://github.com/rseufert/mock-acme/blob/main/mockacme/po_bridge.py), which
was `examples/po_bridge.py` here, sends SAP purchase orders as 850s and posts
the 855s back into SAP, tested against this mock and
[mock-sap](https://github.com/rseufert/mock-sap).
The other half of the same integration is beside it:
[`mockacme/invoice_check.py`](https://github.com/rseufert/mock-acme/blob/main/mockacme/invoice_check.py)
checks this mock's 810 invoices against the purchase order and the 856 ship
notice before posting them into SAP, and its tests cover a short shipment, a
price disagreement and the `duplicate-invoice` behaviour.

Both are walked through, test by test, in
[Testing an SAP-to-EDI Integration Without SAP or a Trading Partner](https://rickseufert.com/blog/2026/09/24/testing-an-sap-to-edi-integration).

## What it serves

| Surface | Endpoint |
| --- | --- |
| AS2 inbound | `POST /as2` — answers with an MDN, synchronous or asynchronous |
| Asynchronous MDN inbound | `POST /as2/mdn` — a partner's receipt for something the mock sent |
| Plain EDI inbound | `POST /edi` — the same pipeline, answering with a JSON summary |
| Validate only | `POST /_mock/validate` — findings, and nothing changed |
| Mailbox | `GET /_mock/mailbox` — collect what is waiting; `?leave` to peek, `?raw` for payloads |
| Outbox | `GET /_mock/outbox` — the queue, including what is not due yet; `POST /_mock/outbox/<id>/retry` for a failed delivery, `POST /_mock/outbox/<id>/resend` to send one again unchanged |
| Deliver one | `POST /_mock/deliver` — with `--hold-delivery`, send the next held document or asynchronous MDN and say what it was; `?all` sends everything held |
| Release the queue | `POST /_mock/advance` — `?seconds=N` or `?all` |
| Send out of band | `POST /_mock/send` — replay an invoice or a despatch advice, or send one unprompted; `"shipment"` names the consignment when an order shipped more than once, and the latest is meant without it |
| Partners | `GET/POST /_mock/partners`, `GET/PATCH/DELETE /_mock/partners/<id>`, `GET/PUT/DELETE /_mock/partners/<id>/profile` |
| Orders | `GET /_mock/orders`, `GET /_mock/orders/<po>`, `?partner=` when two partners hold the number |
| Buying | `POST /_mock/purchase`, `POST /_mock/purchase/<po>/change` |
| Where a supplier disagrees | `GET /_mock/disagreements?partner=&po=` |
| Remittance advices received | `GET /_mock/remittances?partner=` |
| Archive | `GET /_mock/documents`, `GET /_mock/interchanges`, `GET /_mock/interchanges/<id>?raw` |
| Receipts | `GET /_mock/mdns` |
| Outstanding documents | `GET /_mock/unacknowledged?older-than=60` |
| Work promised, not done | `GET /_mock/scheduled` |
| Directory trading | `GET /_mock/drop`, `POST /_mock/drop/scan` |
| The dictionary | `GET /_mock/dictionary`, `/_mock/dictionary/X12/850`, `/_mock/dictionary/X12/envelope` |
| Health and state | `GET /_mock/health`, `GET /_mock/state`, `GET /_mock/requests` |
| Reset | `POST /_mock/reset` |
| Index page | `GET /` |

**A request is matched on its method and its path exactly.** The method is the
one in the table: a read sent with `POST`, or an action sent with `GET`, is a
`405` that says what to send and carries an `Allow` header. The path is the
one in the table, segment for segment: a trailing slash, a segment too many
or a path that only begins with `/_mock` is a `404` that lists what there is.
The three doors also answer under the names a partner's AS2 software may
have been configured with: `/as2/`, `/as2/receive`, `/as2/mdn/` and `/edi/`.

Every `GET` also answers `HEAD`, for a liveness probe, and `OPTIONS` answers
with the methods the mock takes. A query value that cannot be read -
`?seconds=abc`, `?limit=ten` - is a `400` naming the parameter. A `500` is
always a bug: its traceback goes to stderr, even with `-q`.

## The documents

| Business document | X12 | EDIFACT |
| --- | --- | --- |
| Purchase order | **850** | **ORDERS** |
| Purchase order response | **855** | **ORDRSP** |
| Purchase order change | **860** | **ORDCHG** |
| Change acknowledgment | **865** | **ORDRSP** |
| Despatch advice / ship notice | **856** | **DESADV** |
| Invoice | **810** | **INVOIC** |
| Remittance advice, received | **820** | **REMADV** |
| Syntax acknowledgment | **997** | **CONTRL** |

**The answer comes in the partner's dialect, not the order's.** A partner
has a `dialect`, as a real one does, and the response, the despatch advice and
the invoice are written in it whatever the order arrived in. Only the
acknowledgment follows the interchange, because it answers that interchange's
syntax:

| Who sends an EDIFACT `ORDERS` | Acknowledgment | Response, despatch, invoice |
| --- | --- | --- |
| `ACME`, an X12 partner | `CONTRL` | 855, 856, 810 |
| `EURODIS`, an EDIFACT partner | `CONTRL` | `ORDRSP`, `DESADV`, `INVOIC` |

So to see the EDIFACT flow, send as `EURODIS` - step 9 of
[`examples/demo.sh`](examples/demo.sh) does - or give a partner the dialect:
`PATCH /_mock/partners/ACME` with `{"dialect": "EDIFACT", "version":
"D:96A:UN"}`.

**A long description is said in pieces.** A line's description is whatever
the buyer sent, and it goes back in every document that answers the order, in
the partner's dialect. `PID05` holds 80 characters and D.96A's `7008` holds
35, so the mock writes a longer one the way each standard does: as several
free-form `PID` segments, or in both `7008`s of an `IMD` and then a further
`IMD`. It is cut at spaces and reads back as the one text it was; so do two
free-form `PID`s, or several `IMD`s **of one type code**, that a partner
sends for one line. Where a partner offers two renderings instead - D.96A's
`IMD+A` is a long description and `IMD+E` a short one - the mock takes one
whole rather than running both together, preferring `F`, then `A`, then `E`,
then `D`. A partner that describes a line by characteristic is read
differently, and on purpose: several free-form `IMD`s that differ in `7081`,
the item characteristic, are read as one description, in the order they came,
so `IMD+F+8+:::BRACKET'`, `IMD+F+35+:::BLUE'` and `IMD+F+98+:::LARGE'` are
`BRACKET BLUE LARGE`. Two type codes are two renderings of the whole item,
and joining them says it twice; three characteristics are three different
facts about it, and joining them loses none. The mock has one description
for a line, and writes it back with no `7081` at all. A description that fits
is written exactly as before. One that *arrives* in a single element over
length is still reported as over length; the mock then answers it in pieces
that fit. A single word longer than the element has nowhere to be cut, and
reads back with a space where the cut fell. And the pieces have an end: a line
takes 200 `PID` segments or 99 `IMD`s, so a description past 16,000 characters
in X12 or 6,930 in EDIFACT is cut there.

**A letter the character set cannot carry is said in plain letters.** An
EDIFACT interchange declares its character set in `UNB`, and a partner's
`syntax` says which the mock writes. A buyer in `Łódź` is written `Lódz` in
`UNOC` (ISO 8859-1 has the `ó` and not the other two), `Lodz` in `UNOB` and
`LODZ` in `UNOA`; in `UNOD` or `UNOY` it is `Łódź`, untouched, because those
carry it. A character the declared set does carry is never changed. There is
no EDIFACT rule for which letters, so the mock follows a published one,
Unicode CLDR's `Latin-ASCII`: marks come off (`ü` is `u`) and the letters
with no mark to remove have a rule each (`Ł` is `L`, `ß` is `ss`, `Æ` is
`AE`, `Ø` is `O`, `Þ` is `TH`). That table is language-neutral, and it is a
choice of legibility over locale: a German counterparty would send `Mueller`
where the mock sends `Muller`, and a Danish one `Oere` for `Ore`. A value
never outgrows its element for it - where `ss` would not fit, the `ß` is a
`?` - and anything with no plain form at all, a `€` or a Greek word, is
still a `?`. Typographic punctuation goes the same way, by the same table:
the apostrophe a word processor curls is an apostrophe (`O’Brien` is
`O'Brien`), a dash is a hyphen, curled quotation marks are straight ones, a
no-break space is a space and `…` is three full stops where there is room.
One rule is not CLDR's: a soft hyphen is dropped, where that table makes it a
hyphen. It marks where a line may break and is not a character of the name,
and a name is matched, not displayed - `Gross-handel` is nobody's customer.
X12 declares no character set and is not touched. One place the two rules
above meet: a long description is cut into pieces before any of this, and a
piece that was cut to fill its 35 characters has no room left. So a `ß` in a
full piece is a `?` while the same word in a shorter piece of the same
description is spelled `ss`. Both are valid; it is legibility that depends on
where the cut fell.

Both dialects are read and written from one dictionary
([`mockedi/schema.py`](mockedi/schema.py)), and one pipeline drives both, so
what you assert about an X12 flow holds for the EDIFACT one. `GET
/_mock/dictionary/X12/850` serves that dictionary as JSON — the actual rules,
not a description of them that can go stale.

A set runs from `ST` to `SE`, or `UNH` to `UNT`. What goes round it belongs
to no set, so it is served once for each dialect: `GET
/_mock/dictionary/X12/envelope` is `ISA`, `GS`, `GE` and `IEA`, and
`/_mock/dictionary/EDIFACT/envelope` is `UNA`, `UNB`, `UNG`, `UNE` and `UNZ`,
each described as any other segment is, in the order they are on the wire,
with a `level` (`interchange` or `group`) and a `role` (`header`, `trailer`,
or `advice` for `UNA`). Every set's entry names its envelope's path, so the
whole of one interchange can be read from two requests. An element that
has to say again what its header said carries `repeats`, naming the header's
element: `IEA02` repeats `ISA13`, `GE02` `GS06`, `SE02` `ST02`, and in
EDIFACT `UNZ` and `UNT` repeat the references `UNB` and `UNH` gave. It is
one declaration, and the validator's check of the pair reads it too. An
envelope's `version` is the version of the sets inside it, and
`envelopeVersion` is its own: `ISA12`, or EDIFACT's syntax version. A set, version or
dialect the mock does not have is a 404 saying which.

**Versions.** X12 **004010** and **005010**, and EDIFACT **D.96A**. An X12 set
is read against the version its group's `GS08` names — an industry suffix such
as `004010VICS` or `005010X222A1` is the same version — and served that way at
`/_mock/dictionary/X12/850?version=005010`. The sets are declared at 004010,
and 005010 is recorded as what differs: `ST03`, `AK103` and `AK203`, and
data element 127, *Reference Identification*, widened from 30 characters to
50 - wherever it is used, since a length belongs to the element: `REF02`,
`BAK08`, `TRN02`, `RMR02` and the rest. A group in any other version is
refused in its 997 with `AK905 = 2`, *functional group version not
supported*, rather than read by rules its sender never used, and a partner
cannot be set to a version the mock would write on the wire without having
the dictionary for it.

**A 997 at every version, not a 999.** A 005010 partner may prefer a 999,
but a 999 reports conformance to an *implementation guide* — the TR3 named in
`ST03` — and the mock checks documents against the base standard, not against
any guide. A 999 from it would claim a check it never made; the 997 says
exactly what it did.

**A remittance advice** is sent *to* the mock, as the payee, and answered
with a 997 or CONTRL like anything else it receives. An 820 whose `BPR01`
instructs a bank - `D`, `P`, `U` or `X` - is a payment order, a bank's
document, and is refused by name rather than half-read; `I` and `C` are the
remittance uses. An advice is filed under its `TRN02` trace number, or its
`BGM` number. Whether its total is the sum of what it says was paid is not a
syntax question - an advice that disagrees with itself can still be read, and
is acknowledged - so a total that does not add up is a *disagreement* beside
the 997, with the same shape as a supplier's (`remittance-total-not-parts`),
in the summary and at `GET /_mock/disagreements`. `BPR02` is checked against
the `RMR04` amounts plus any `ADX` directly in an `ENT` loop - a deduction
not tied to one invoice, which is part of what was paid - and the `MOA+12`
after `UNS` against each `DOC`'s `MOA+12`. That is how a remittance still
claiming an invoice whose payment came back gets caught before a supplier
starts dunning for it.

Two more findings are about *when*. An 820 whose `BPR16` effective date is
still ahead of the mock's clock is `remitted-before-settlement`: the payee is
being told to reconcile cash that has not arrived, and `/_mock/advance` moves
what counts as early. And an 820 with `BPR03 = D` reverses the advice with the
same `TRN02` trace - the correction a payer owes once the bank returns a
payment - so `GET /_mock/remittances` lists that earlier advice as `reversed`
and names the one that reversed it; a debit for a trace never advised is a
`reversal-of-nothing`. An advice left `advised` after its payment came back
is the failure to test for. **A REMADV reaches both, by EANCOM's conventions
rather than D.96A's, because D.96A prescribes neither.** Its settlement date
is the header `DTM+138`, the directory's "Payment date": EANCOM's REMADV
admits 137, 138, 203, 227 and 263 at the head and not 209, and says each
advice relates to one settlement date. And a REMADV corrects another by
**replacing** it rather than by debiting it - there is no REMADV debit and no
negative-amount convention anywhere we could find - so an advice with `BGM`
1225 code 5 naming an earlier one in a header `RFF+RA` leaves that one
`replaced`, naming what replaced it, and a replacement of an advice never
received is a `replacement-of-nothing`. The conventions are the published
EANCOM REMADV guide's; the mock follows them because a mock that judges
nothing is less useful than one that says whose rule it is judging by.

`GET /_mock/remittances` answers the **currency** the advice states beside its
`total`, because an amount on its own cannot be tied to a bank payment or a
cleared item. An 820 states it in `CUR02` — `BPR` carries no currency at all,
and `CUR` is one per message, outside the loop that holds the invoices, so an
820 names exactly one. A REMADV states it in the summary `MOA`'s own currency
component, falling back to the header `CUX`.

An advice that states none is answered `null`, not `USD`. An absent currency
and a guessed one are different claims, and a default would put a guess beside
the facts the orders and invoices of this mock state for themselves.

Two findings come with it. `remittance-currency-not-the-invoice` is an advice
in one currency paying an invoice the mock issued in another — the amounts can
agree to the penny and still be two different sums of money. And
`remittance-currency-disagrees` is a REMADV that contradicts itself: a header
`CUX` and a summary `MOA` naming different currencies, several header `CUX`
segments naming different ones — the group repeats up to nine times — or
either of those inside one `DOC` group, which admits five `CUX` of its own.
D.96A permits all of it and gives no rule for which wins, so the mock takes
the **reference** currency (`6347` code 2, "the currency applicable to amounts
stated") at the head, the first inside a group, and reports the rest rather
than keeping one quietly. A group may also state the amount remitted more than
once — five `MOA` are allowed there too — and that is
`remittance-amounts-repeated`: the first is what was remitted, in the listing
and in the arithmetic alike, and the others are named. **Nothing is summed.**
D.96A does not say two amounts in one group add up, and a mock that assumed
they did would be quietly changing what a payer said. An advice naming an
invoice the mock never issued to that partner draws none of these: it can only
say two currencies differ about an invoice it wrote.

A REMADV may state a currency **per document**, which is how one advice pays
invoices in two currencies. It can say it two ways, as the advice itself can
one level up, and both levels resolve it alike: the document's own `MOA`
carries a currency in its third component and that is the document's, with a
`CUX` inside the `DOC` group as the fallback. A guide may use either — D.96A
gives the `CUX` in SG5, and EANCOM puts the invoice's currency on the `MOA`.
Each entry of `invoices` answers its own `currency`, falling back to the
advice's where the document states none, and the comparison is made per
document — so an advice can be right about one invoice and wrong about
another, and where the header and a document disagree the document is what
that entry is judged on. D.96A has a third `CUX`, inside the line group; this
message declares no line group, so a `CUX` inside a line is still an
unexpected segment.

Coverage is the commonly traded core of each set, not the full standard. A
real 850 admits some fifty segment types and almost nobody sends more than a
dozen; the mock implements the dozen, validates them properly, and reports an
unrecognised segment rather than pretending to understand it.

The same goes inside a segment, and the dictionary says where the line is.
`SAC` has sixteen elements in 004010 and the five the mock declares carry
almost every real allowance — so every segment also reports the `width` the
standard gives it beside the `checkedTo` the mock validates. A position
between the two is carried and not checked: your `SAC15` description is not
wrong, it is untested. A position beyond the width is reported as error 3,
because there the element really does not exist.

## Money

Every amount is rounded to the cent, **half up**: 0.625 is 0.63. That covers a
line's extended amount, tax, an order's or invoice's total, and the integer
`TDS` carries, and it is the same for a negative amount, which rounds the
same distance from zero. A line is rounded before it is added, so a total is
the sum of what each line says. 12.50 at `--tax-rate 0.05` is `TXI*ST*0.63`.

A **unit price is not an amount** and is not rounded. It is stored, written
and compared exactly as it was given, with at least two decimals: `12.5` is
`12.50` and `0.125` stays `0.125`, in `PO104`, `POC06`, `IT104` and `PRI`.
A thousand at 0.125 is an order for 125.00, and a supplier confirming at
0.124 is a `price-differs` between 0.125 and 0.124.

## Timestamps

Every timestamp the control plane returns has one shape: UTC, second
precision, trailing `Z`.

```json
{"due_at": "2026-09-25T08:34:19Z", "at": "2026-09-25T07:34:19Z"}
```

That is not cosmetic. The mock compares these as *strings* when it decides
what is due and what has gone unacknowledged, so they have to be produced by
one formatter to sort correctly — and a test driving the Docker image, which
runs on UTC, is usually not on UTC itself. A `due_at` in the host's local time
with no zone on it is wrong by the host's offset, in whichever direction the
host happens to be: the kind of failure that passes on a laptop and fails in
CI.

The dates **on the wire** are the opposite case and stay as they are. ISA09/10,
GS04/05 and UNB S004 carry no zone and are the sender's local time by the
standards' long convention, so they are written as the host's clock reads
them. So is every date and time inside a document - `BAK09`, `BSN03/04`,
`BIG01`, an ORDRSP's `DTM+137` - which is the mock's clock read in the host's
zone, so a document and its envelope always name the same day. (A mock
started with `--start-at` reads them in the zone its start time is written
in instead, so that they do not depend on the machine.) A date a
partner sends without a zone is read the same way: `BPR16` is compared with
the day the mock would write today.

## One order, one conversation

When a test fails, the question is always the same: what actually happened to
this order?

```bash
curl -s http://127.0.0.1:8080/_mock/orders/4500000042/timeline
```

```
received 850 0001 (order)
order recorded: 2 line(s), 1416.00 USD
despatch promised, due 2026-09-26T21:14:19Z
invoice promised, due 2026-09-26T22:14:19Z (not done yet)
packed SHP8000002: 2 line(s), 6 carton(s), United Parcel Service
sent 997 0001 (acknowledgment) accepting your 850 0001; delivery delivered
sent 855 0002 (response); delivery delivered after 2 attempts
the partner's receipt accepted our 855 0002
```

Both directions, in order, with findings inline, whether each document was
delivered and how many attempts it took, and what the seller promised but has
not done yet. Every event carries the structured form beside that prose, so a
test can assert on it. `?raw` adds the payloads, which makes one call enough
to attach to a bug report.

An acknowledgment the mock sent says what it is for. Its event carries
`answers`: the envelope it answers, its own verdict on all of it (`AK901`,
the `UCI` action or `TA104`), and each set it names with that set's code,
control number and verdict. A `TA1` answers an envelope and no set, so its
`sets` is empty. The event's own `interchange` is still the envelope the
acknowledgment travelled in.

A document says which business document it carries, sent or received, in the
names the business events use. Every one but an acknowledgment has `order`;
an 856 or `DESADV` has `shipment` as well, the same value as the `packed`
event's; and an 810 or `INVOIC` has `invoice` and the `shipment` it bills, as
the `invoiced` event does. So the second 810 of an order shipped in two
consignments is matched to its invoice by a field, not by where it sits. The
numbers were recorded when the mock wrote the document, or read a supplier's:
they are not parsed back out of the payload. A document from a `--db` file
written before 0.8.0 has the fields and nothing in them. `/_mock/documents`
serves the same two numbers on each row, as `shipment_id` and
`invoice_number`, and so does a row collected from `/_mock/mailbox`: whoever
collects an 810 is told which invoice it is, without parsing it.

What was done says which promise it kept. A `promised` event has `promise`,
its id, which is the `id` `/_mock/scheduled` serves for it; and a `packed`,
an `invoiced`, and a document the mock sent carry the `promise` they were
done in keeping. An order shipped and then changed to a larger quantity holds
two promises to despatch, and each consignment and each 856 names its own.
The promise is the one in whose keeping the thing happened, which is not
always one of its own kind: a seller that bills before it despatches packs in
keeping the invoice's promise. One promise to invoice can raise two invoices,
when two consignments are waiting to be billed. `promise` is `null` where
none was being kept: an answer sent at once, a document sent with
`/_mock/send`, or anything in a `--db` file written before 0.8.0. A row
from `/_mock/documents` or collected from `/_mock/mailbox` has the same
number as `promise_id`, where 0 means none.

Nothing new is recorded — this is the same rows `/_mock/documents`,
`/_mock/outbox` and `/_mock/scheduled` return, sorted into the sequence they
happened in.

## Partner behaviours

Five partners are seeded. The mock sells to four customers - `ACME`,
`GLOBEX`, `INITECH` and `EURODIS` - and buys from one supplier, `NORTHWIND`.
Change any of them at runtime:

```bash
curl -X PATCH -H 'Content-Type: application/json' \
     -d '{"behaviour":"short-ship"}' \
     http://127.0.0.1:8080/_mock/partners/ACME
```

`POST /_mock/partners` adds a partner and only ever adds one: for an id that
is already there it answers 409 and changes nothing. `PATCH` is how an
existing partner is changed, and it changes only the fields it is given.

A body of the wrong shape is the caller's mistake and is answered as one: a
`null`, a list or a number where a field is text, a line that is not an
object, or a body that is not a JSON object at all, is a 400 that names the
field and says what it was given - `id must be a string, not a number` - on
`/_mock/partners`, `/_mock/purchase` and `/_mock/send` alike.

A partner is refused anything the mock could not then act on: an unknown
field is named rather than dropped, a `version` has to match the dialect
(`004010` or `D:96A:UN`), `test` is a flag, `as2_url` needs a scheme the
courier can use, and an id has to fit the envelope — fifteen characters for
X12, because ISA06 is fixed at that width and a longer one would be truncated
to something the partner could never be found by. It may use only letters,
digits, and `.`, `-` or `_` between them: narrower than the standards allow,
because an id also becomes a pickup filename and part of a URL, and one with a
`/` in it once wrote documents outside `--pickup-dir`.

A partner's `syntax` is the identifier an EDIFACT answer declares in `UNB`
S001, and so the character set the mock writes it in. It defaults to `UNOC` -
ISO 8859-1 - which is what the mock always sent, so nothing changes until it
is set. Set it to what the partner's own translator reads:

```bash
curl -sX PATCH localhost:8080/_mock/partners/EURODIS \
     -d '{"syntax": "UNOY"}'
```

`UNOY` is UTF-8, so a partner set to it is sent `Łódź` whole where a `UNOC`
answer substituted `?ód?`. It is **configured, not mirrored** from whatever
the last inbound interchange declared: a partner the mock has only ever sent
to has nothing to mirror, and an answer that depended on which document
arrived first would undo what `--start-at` is for. X12 declares no character
set at all, so the field is ignored for an X12 partner.

`UNOA` is level A: ISO 646 *without lower case*. That is a **repertoire**
rather than an encoding — a set of permitted characters, not a way of turning
them into bytes — so the mock holds a document to it rather than relying on a
codec, which would admit lower case. And it does what a real level A sender
does with the lower case it cannot send: **folds it**.

```
level B   NAD+BY+ACME::91++Widget Co++Lodz
level A   NAD+BY+ACME::91++WIDGET CO++LODZ
```

Folding is `a`–`z` to `A`–`Z` and not a general upper-casing: level A's
alphabet *is* A–Z, and `ß` upper-cased would become `SS`, a character longer,
so a value at an element's maximum would grow past it. Anything outside ISO
646 is substituted as it always was — `Łódź` becomes `??D?` — and each `?`
appears doubled on the wire because the release character is escaped after
the substitution, which is what stops a substituted character from swallowing
the separator after it.

**A level A partner's id may not have lower case.** Folding a description
loses nothing that matters; folding an *identifier* does, because this mock
holds partner ids case-distinctly, so `acme` and `ACME` can both be partners
and a folded `UNB` would address a document to a party the mock itself cannot
tell from another. In real EDI the question does not arise — a level A
partner's id is upper case, because the syntax demands it — so a lower-case
one is refused with `UNOA` rather than folded. Any other syntax carries it
unchanged.

The twelve ISO 646 positions open to national substitution — `#`, `$`, `@`,
`[`, `\`, `]`, `^`, `` ` ``, `{`, `|`, `}`, `~` — are excluded by level A's
definition too, and the mock does **not** enforce them: only one published
source for which twelve they are could be found, and refusing a character
level A permits is the worse mistake to make. An unknown identifier is
refused, and the refusal lists the ones the mock answers in.

A partner's `role` is what it is to the mock: a `customer` the mock sells to,
the default and every partner there was before, or a `supplier` it buys from.
A behaviour that only a seller can have - one that changes what the mock does
with an order it received, such as `short-ship` or `no-invoice` - is refused
on a supplier, and so is a role change that would strand such a behaviour.
The *Partner* column below says which roles each behaviour fits: the ones
that change how the mock answers, or damage what it sends, fit both. The
mock places orders with a supplier; see [Buying from a
supplier](#buying-from-a-supplier).

A role change is refused, too, while the partner holds an order that is not
finished - `invoiced`, `cancelled` or `rejected` - in either direction, and
the refusal names those orders. A partner's role and the direction of its
orders say the same thing: a supplier holds what the mock placed, a customer
what it received, and the mock reads the pair as one fact when it decides
whose order it is acting on. Let the orders finish, or `DELETE` the partner
and `POST` it again, which says what becomes of its work. **Orders that are
already finished stay, read-only, as history** - nothing will act on them
again, so nothing can act on them wrongly, and they are still the evidence of
what the mock did.

| Behaviour | Partner | What the partner does |
| --- | --- | --- |
| `accept` | customer or supplier | Confirms everything in full and ships what was ordered. |
| `short-ship` | customer | Confirms less than was ordered (`855` `IQ`, `ORDRSP` `QTY+83`), and ships and invoices the confirmed quantity. No balance follows, unlike a line short of stock, which is [backordered](#backorders). |
| `over-ship` | customer | Confirms every line as ordered (`855` `IA`, `ORDRSP` `QTY+21`), then ships three in ten more than it confirmed on every line - rounded up to a whole unit and never less than one extra, so 100 is 130, 10 is 13 and 1 is 2 - and invoices what it shipped. The 856 and 810 agree with each other and not with the 855. A buyer mock reports `shipped-more-than-confirmed` and `shipped-more-than-ordered` against the 856. It over-ships a [backorder](#backorders) too: of 100 ordered with 35 in stock, 46 go at once and 71 on the day, so the "65 to follow" in the reason is what was owed, not what will arrive. |
| `reject-line` | customer | Refuses one line outright (`IR`) and leaves it out of the shipment and the invoice. |
| `reject-all` | customer | Acknowledges the syntax, then refuses the order (`BAK` `RD`, every line detailed as `IR`). |
| `no-ack` | customer or supplier | Says nothing at all. No 997, no 855. For testing your chase-up timer — the failure that actually costs money. |
| `duplicate-invoice` | customer | Sends the invoice twice with the same invoice number, as a partner with a retry bug does. |
| `strict` | customer or supplier | Rejects a transaction set for any finding, not only a fatal one. |
| `out-of-order` | customer | Sends the invoice before the despatch advice and the order response after both — 997, 810, 856, 855 — which is where a buyer's matching usually breaks. The documents still describe one consignment. |
| `late` | customer or supplier | Answers everything an hour late, on top of any configured delay: after the chase-up window rather than never. `POST /_mock/advance?seconds=3601` brings it in. |
| `corrupt` | customer or supplier | Sends every business document with its trailer count (`SE01` or `UNT`) one out, so your translator's own 997 or CONTRL has something to reject. That one fault and no other; its acknowledgments are sound. |
| `reject-ack` | customer or supplier | Rejects every transaction set in its 997 or CONTRL (`AK5*R` with no reason, `UCM` action 4), however clean, and acts on none of them — a partner whose translator is misconfigured. |
| `no-invoice` | customer | Ships and never invoices, so a three-way match has to give up waiting. |
| `duplicate-order` | supplier | Sends the order twice under one PO number, a moment apart, as a buyer with a retry bug does. The mirror of `duplicate-invoice`, and what a supplier's own duplicate handling should be tested against. |
| `change-after-confirm` | supplier | Sends an 860 lowering a line the moment the 855 confirming it arrives: a change that lands after the order was answered rather than before. |
| `cancel-late` | supplier | Sends an 860 cancelling the order the moment the 856 arrives - after the goods have left - which a supplier must refuse rather than honour. |

Some rules apply whatever the behaviour says, because they are what real
sellers actually do. The first that fires wins:

1. **A quantity of zero or less is rejected** (`IR`). Nothing downstream can
   make sense of a line that asks for nothing.
2. **An item that is not in the catalogue is rejected** (`IR`). The most
   common real rejection there is.
3. The partner's behaviour, above.
4. **Confirmed is capped at what is in stock** — `IQ` when it falls short,
   `IB` when there is none. This cap outranks the price rule, so a line that
   is both short *and* mispriced comes back `IQ` with the price named in its
   reason rather than changed in silence. **The balance is backordered, and
   ships**: see [Backorders](#backorders) below. No seeded item is out of
   stock, so `IB` needs an order for something the catalogue has none of,
   and the ordinary case is `IQ`: order 100 of `PANEL-A3`, which has 35.
5. **A price the seller disagrees with is billed at the seller's price** and
   flagged `IP`. Price discrepancies are the commonest EDI dispute there is.

## Timing

By default every document is released the moment it is produced, so a test can
POST an order and read four documents back on the next line. Give them delays
when what you are testing is the waiting:

```bash
mock-edi --ack-delay 2000 --response-delay 30000 --invoice-delay 86400000
```

Nothing is released on a timer of its own. `POST /_mock/advance?all` releases
whatever is queued, whenever it was due — a test that has to sleep is slow and
flaky, and one that advances the clock is neither. It moves the mock's clock
as far as it has to and no further: the clock steps to each due time in turn,
the work due then is done and stamped then, and the clock is left at the last
of them. With no delays nothing is waiting and the clock does not move.

**The mock has one clock.** Every timestamp about the conversation - the `at`
on an order, a document, a shipment, an invoice, a promise, an acknowledgment,
and every `dueAt` - is read from it, so nothing is stamped before the promise
it keeps was due. Two things stay on the host's clock, because they are about
the process and not the conversation: the request log (`/_mock/requests`) and
`started` in `/_mock/health`.

`POST /_mock/advance?seconds=N` moves the mock's clock forward by `N` seconds
and releases whatever that makes due. The clock stays moved: two advances of
60 release a document due in 90, and everything written afterwards - document
dates, due times, timestamps, MDN dates - is dated by the moved clock, and
`/_mock/unacknowledged?older-than=` and `--retention-days` count from it. The response says
where the clock is (`clock`) and how far it has been moved in all
(`advancedSeconds`). It only goes forward, and `/_mock/reset` puts it back.
On a `--db` file the advance is kept with the data, so a mock that is
restarted comes back as far ahead as it was stopped, and nothing it stamps
afterwards is earlier than what the file already holds.
It can be at most 100 years (3,153,600,000 seconds) ahead in total: an advance
that would take it further is a 400 that says how much room is left, and
leaves the clock where it was. A parameter the endpoint does not take is a
400 as well, naming the ones it does - `seconds`, `all`, `failed`, and
`partner` with `failed` - so `?days=30` is refused and told what it is in
seconds, where it used to answer 200 and move nothing.

### Starting the clock at a chosen time

Left alone, the clock is the host's, so no two runs of a script carry the
same dates. Give the mock a start time and it does not follow the host at
all:

```bash
mock-edi --start-at 2026-11-02T09:00:00Z
```

```python
with Mock.start(start_at="2026-11-02T09:00:00Z") as mock: ...
```

The clock reads that time until it is advanced, and after an advance it
stands at the new time. **Two runs of the same script from a fresh start
write the same documents, byte for byte, and the same timeline**, on any day
and on any machine.

- **The time is ISO 8601 with a `Z` or an offset.** One with neither is
  refused at start: read as UTC or as the host's zone it would be a guess.
- **The zone it is written in is the zone documents are dated in.**
  `...T09:00:00Z` dates them in UTC; `...T09:00:00+01:00` in +01:00, with no
  daylight saving. The host's zone has no say, which is what makes a capture
  the same on a laptop and in CI. (Without `--start-at`, document dates are
  in the host's zone, as [Timestamps](#timestamps) says.) So the same
  instant written two ways can date documents a day apart:
  `2026-11-02T00:30:00+01:00` dates them 2 November and
  `2026-11-01T23:30:00Z` dates them 1 November.
- **Many events share one time.** Everything between two advances carries the
  same `at`. The timeline's order is still the order things happened in: it
  is decided by a sequence the mock keeps, not by the time.
- **`/_mock/advance` is unchanged.** `?seconds=N` and `?all` move the clock
  from where it stands. `/_mock/reset` puts it back to the start time.
  `/_mock/state` says where it is under `clock`: `now`, `startAt` (`null`
  when there is none) and `advancedSeconds`.
- **A `--db` file keeps the clock it was started on.** Started again with the
  same `--start-at`, it comes back as far on as it was left. A different
  start time, or none, is refused and names the one the file has; so is a
  start time for a file that has already traded by the host's clock.

**What follows the start time:** every `at` and `due_at`; `ISA09`/`10`,
`GS04`/`05` and the `UNB` date and time; every date inside a document the
mock writes, the orders it places with `/_mock/purchase` included; the dates
of the seeded demo orders; and the date an MDN states.

**What does not**, and so differs between two runs:

| | Why |
| --- | --- |
| The request log's `at` (`/_mock/requests`), and `started` in `/_mock/health` | About the process, not the conversation. |
| The HTTP `Date` header on the mock's responses | Written by the HTTP server. |
| The AS2 `Date` header on a document or an asynchronous MDN the mock posts | When it was posted, which is the host's business. |
| An MDN's `Message-ID` and MIME boundary | Random. A document's own AS2 `Message-ID` is a counter and does repeat. |

## AS2

```bash
curl -X POST --data-binary @order.edi \
  -H 'Content-Type: application/edi-x12' \
  -H 'AS2-From: ACME' -H 'AS2-To: MOCKEDI' \
  -H 'Message-ID: <po-1@acme.example>' \
  -H 'Disposition-Notification-To: edi@acme.example' \
  -H 'Disposition-Notification-Options: signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha256' \
  http://127.0.0.1:8080/as2
```

comes back as a `multipart/report` MDN with the MIC of what arrived:

```
Disposition: automatic-action/MDN-sent-automatically; processed
Received-Content-MIC: +H1EWvEMSJH/IHGsjy7c/dviFRwLgRoGBmxnTEbMkGA=, sha256
```

Name a `Receipt-Delivery-Option` and the response is `202` with the MDN posted
back to that URL instead, carrying the headers it was built with — including
the MIME boundary, without which no MIME library can read it.

**S/MIME is deliberately not implemented.** Signing and encrypting AS2
payloads needs certificates and a cryptography library, and this project has no
dependencies on purpose. A message that arrives encrypted or signed is refused
with an MDN saying exactly that, rather than being mangled. If your integration
must be tested against signed AS2, this mock is the wrong tool and will tell
you so on the first message.

That applies to the receipt as well. `signed-receipt-protocol=required` says
the sender will not accept an unsigned MDN, so it is answered with a
`failed/Failure` MDN saying the mock cannot sign, and the interchange is not
read — RFC 4130 asks for a failure here, not an unsigned success the sender
has already said it cannot use. `=optional` is answered normally.

A body may arrive with a `Content-Length` or chunked, as an AS2 client
streaming a large interchange sends it. What cannot be read is refused rather
than guessed at, and the connection is closed so the next request on it is not
parsed out of the leftovers: a length that is not a number is `400`, a body
over `--max-body` (16 MiB) is `413` before it is read, another transfer coding
is `501`, and a body that stops arriving for `--request-timeout` seconds (60)
is `408`.

## Making the mock come to you

A partner with no `as2_url` is a mailbox. Give one a URL and the mock stops
being something you poll and becomes something that *arrives*:

```bash
curl -X PATCH -H 'Content-Type: application/json' \
     -d '{"as2_url":"http://localhost:9000/as2"}' \
     http://127.0.0.1:8080/_mock/partners/ACME
```

Documents are then POSTed to your listener with AS2 headers, in the order they
were queued, and whatever MDN you return is recorded against them in
`/_mock/outbox`.

## One document at a time

The courier posts each document as soon as it is released. Wire two mocks to
each other and one `settle()` runs the whole rally: the seller makes its
promises on receipt of the 850, the buyer answers the 855 with a 997, and
both have happened before anything outside can look. The two events sit in
different mocks with the same time on them, so nothing orders them afterwards.

Start a mock with `--hold-delivery` (`Mock.start(hold_delivery=True)`) and it
posts nothing until it is asked. What it releases stays `ready`:

```bash
curl -X POST http://127.0.0.1:8080/_mock/deliver          # the next one
curl -X POST "http://127.0.0.1:8080/_mock/deliver?all"    # everything held, in order
```

```json
{"sent": {"type": "document", "id": 2, "partner": "ACME", "code": "855",
          "kind": "response", "reference": "4500000042", "control": "2",
          "messageId": "<...>", "to": "http://localhost:9000/as2",
          "status": "delivered", "note": "MDN: ...; processed"},
 "waiting": 2}
```

One step is one POST of the mock's own, and the answer comes once that one
has finished, `delivered` or `failed`. With nothing to send the answer is
`{"sent": null, "waiting": 0}`, at once, so a loop over it ends:
`while mock.step(): ...`.

- **"Next" is what an unheld mock would have posted next.** The courier has
  one queue, first in, first out, and a held one keeps the same queue: the
  order of release, which for documents released together is their order in
  the outbox. After a restart on a `--db` file it is documents by id, then
  asynchronous MDNs by id.
- **An asynchronous MDN is its own step.** It is its own POST, queued behind
  what was already waiting, and its `sent` has `"type": "mdn"`. A read
  between the two steps shows the document delivered and the receipt still
  `pending`. A synchronous MDN is the HTTP response to the document it
  answers and travels with it.
- **A retry is held too, and so is a resend.** `/_mock/outbox/<id>/retry`,
  `/_mock/outbox/<id>/resend` and `/_mock/advance?failed` put the document
  back on the end of the queue and answer as they always did; it goes when
  it is stepped. So does anything `/_mock/advance` releases.
- **`settle()` and `exchange()` raise on a held mock**, at once, naming the
  hold. They wait for deliveries, and a held mock makes none on its own.
- **What is not held:** receiving (the mock answers what it is sent, MDN
  included), the mailbox (a partner with no `as2_url` collects as before, and
  a step passes its documents over), and `--pickup-dir`, where a released
  document is written at once.
- **Only at start.** A running mock cannot be switched between held and not;
  `?all` lets the rest go. `POST /_mock/deliver` on a mock that is not held is
  a `409`, and `/_mock/health` says `"deliveryHeld"`.

## A delivery that failed can be tried again

A partner's listener restarts between the 997 and the 855, and the mock has
four documents in flight. The 997 fails; the rest arrive. The partner now
holds a response, a ship notice and an invoice for an order whose
acknowledgment it never received.

```bash
curl -X POST http://127.0.0.1:8080/_mock/outbox/3/retry     # one document
curl -X POST "http://127.0.0.1:8080/_mock/advance?failed"   # everything that failed
```

The same bytes and the same control numbers go out again, in the order they
were queued. That is not `/_mock/send`, which writes a new document with a
new control number, a different event on the wire.

A retry is for a delivery that **failed**, and refuses any other. To send a
document that **arrived** a second time - which is how to find out whether a
listener is idempotent about a control number it has already seen - ask for
that by name:

```bash
curl -X POST http://127.0.0.1:8080/_mock/outbox/3/resend
```

A delivered document is posted to the partner again with the same bytes,
control numbers and AS2 `Message-ID`; a collected one goes back in the mailbox
to be collected again. The answer's `was` says what had become of it. A
document not yet due, or cancelled, has not been sent once and is a `409`.

One that is waiting to go is left waiting, and nothing is resent. Its answer
carries `sent_before`: `false` for a document that has not gone out at all,
`true` for one already queued to go again. So two `resend` posts in a row put
one more copy on the wire, not two. Let the first be delivered or collected
before asking for another: `mock.settle()`, with `mockedi.testing`.

`/_mock/outbox` carries the history: `attempts`, `last_error` and
`last_attempt_at`, so a document delivered on the second try says so.

Nothing retries on a timer. A test that wants a retry asks for one, the same
reason `/_mock/advance` exists.

## Changing an order

A buyer changes an order it has already placed with an **860** (or an
**ORDCHG**, or an 850 or ORDERS restated as a change), and the seller answers
with an **865**. EDIFACT has no separate change acknowledgment message, so an
ORDCHG is answered by an **ORDRSP** — the difference most likely to catch out
someone porting a mapping from X12.

A restated 850 (`BEG01 = 04` or `05`) is read as the whole order: a line it
leaves out is deleted, and the 865 says so with `DI` — or refuses, if that
line has already shipped. For `05` (Replace) that is the only reading; for
`04` it is the mock's choice, because an 850 has no other way to drop a line.
To change some lines and leave the rest alone, send an 860. `BEG01 = 01`
cancels the order, as an 860 with `BCH01 = 01` does.

An ORDERS says the same in BGM's message function code, 1225, in EDIFACT's own
values: `1` cancels, `4` changes and `5` replaces.

A restated order is never a new one. One that cancels, changes or replaces an
order the mock does not hold is refused beside the acknowledgment, as an 860
for an unknown order is: the 997 or CONTRL accepts it, `refusals` says *no such
purchase order*, and nothing is recorded, answered, shipped or billed. A
cancellation that arrives before its order, or instead of it, would otherwise
be fulfilled as the order it withdraws.

```
POC*1*QD*60**EA*12.50**VP*WIDGET-001~     the buyer wants 60, not 100
ACK*IA*60*EA*067*20260926~                 the seller agrees
POC*2*DI*40**EA*4.15**VP*BRKT-050~         the buyer drops line 2
ACK*IR*0*EA~
REF*ZZ**Line deleted at the buyer's request~
```

**A change cannot unmake what has already happened.** A quantity cannot go
below what shipped, a shipped line cannot be deleted, an order that shipped
cannot be cancelled, and an order that has been invoiced cannot be changed at
all. A refused line comes back `IR` with the reason, and the order keeps what
it had — reporting the order's state instead would tell the buyer its request
succeeded.

**Nor promise what will never happen.** Between despatch and invoice a
quantity can still be raised and a line added, and what the 865 confirms then
ships as a **second consignment**: its own 856 carrying the difference, and its
own 810 naming it. Every consignment is billed by one invoice, so an order that
shipped twice is invoiced twice — which is the buyer-side matching worth
testing. A change that revives a cancelled order is packed and billed the same
way. The second consignment is advised before it is billed: when the first
invoice comes due before the new despatch, it bills what has shipped, and the
second consignment gets an invoice of its own after its 856.

### Backorders

A line confirmed short **because stock ran out** is not finished. The 855 or
ORDRSP confirms what there is, dates the line, and says in its reason how
many follow and when: `Confirmed 35 of 100; 65 to follow on 2026-10-09`.
What is in stock ships and is billed at once. The balance is held on the
line as `backordered`, and `GET /_mock/scheduled` shows a `backorder`
promised for the start of that day.

When the mock's clock reaches the day - `POST /_mock/advance`, or the real
clock - the balance is confirmed and goes out as a **second consignment**,
exactly as a quantity raised by an 865 does: its own shipment number and
856, its own invoice number and 810, each carrying the balance and nothing
else. Two payables against one purchase order, and a second invoice that is
not the first arriving again, which is what a buyer's duplicate check has to
tell apart. The line is then `IA`, or `IP` where the price was in dispute.

```bash
curl -X POST --data-binary @order-for-100-PANEL-A3.edi http://127.0.0.1:8080/edi
curl "http://127.0.0.1:8080/_mock/scheduled"          # a backorder, due in a few days
curl -X POST "http://127.0.0.1:8080/_mock/advance?all"   # the clock goes to that day
```

Three things follow from it being a promise like any other:

- **`/_mock/advance?all` now goes to the backorder's day**, since that is
  the last thing due. A test that orders more than is in stock and then
  settles everything gets the second 856 and 810, and a clock some days on.
- **A change while it waits is not held up by it.** A quantity raised on
  another line ships at once as its own consignment; the backorder keeps its
  day. A line lowered to what is in stock, an order restated, and an order
  cancelled each close the promise unkept, and `/_mock/scheduled?all` says
  why.
- **A waiting balance can be lowered or cancelled, even on an invoiced
  order.** With no delays the order is invoiced as soon as its first
  consignment is billed, and an invoiced order takes no changes - except
  for what has not happened yet. An 860 or ORDCHG that only lowers a line's
  quantity, to no less than the line has confirmed, takes the difference off
  the backorder: lowered to what is confirmed, the promise is closed unkept
  and nothing more ships; lowered part-way, the smaller balance ships on the
  day. The 865 or ORDRSP says what came off. A line waiting whole, with
  nothing shipped, can be deleted. Anything else in the change - a line
  with nothing waiting, a quantity raised or lowered below what shipped, a
  price, a new line, cancelling the order - and the whole change is
  refused, with nothing in it applied, in words that say what may still be
  asked.
- **An order with no stock for any line waits** as `received`; it is not
  refused, and ships whole on its day.

`short-ship` is a different thing and is unchanged: that partner confirms
less than it was asked for on purpose, says the balance is not available,
promises nothing and sends nothing more. In EDIFACT both write the shortfall
as `QTY+83`, as before; the reason in `FTX` is what tells them apart.

**Give yourself a window.** A change is only meaningful before the goods
leave, and with every delay at zero the order is invoiced before the POST
returns, so every change would be refused. That is correct behaviour, not a
limitation to work around:

```bash
mock-edi --despatch-delay 3600000 --invoice-delay 3600000
```

Delays postpone the *work*, not merely the posting. A despatch that is not due
yet has not been packed, so a change arriving in the meantime affects it —
which is the whole point, and why `GET /_mock/scheduled` shows work promised
but not done, separately from `/_mock/outbox`, which shows documents that
already exist.

## Buying from a supplier

The mock sells to its customers and buys from its suppliers - a partner whose
`role` is `supplier`, such as the seeded `NORTHWIND`. It places an order when
told to; nothing is ordered on a timer.

```bash
curl -X POST localhost:8080/_mock/purchase -d '{
  "partner": "NORTHWIND", "po_number": "4500001001", "requested_on": "2026-10-15",
  "lines": [{"sku": "WIDGET-001", "quantity": "100", "uom": "EA", "price": "12.50"}]}'
```

The order is stored with `direction: placed`, and an **850** (an **ORDERS**
for an EDIFACT supplier) goes out like anything else the mock writes: through
the delays, to the partner's `as2_url` or the pickup directory, and into
`/_mock/mailbox`. With no `po_number` the mock numbers the order from its own
range. `POST /_mock/purchase/<po>/change` sends an **860** or **ORDCHG**:
`{"lines": [{"line": "1", "quantity": "80"}]}`, with `action` `add` or
`delete` on a line, or `{"cancel": true}`.

An order or change the document could not carry is refused with a 400 before
anything is stored or sent: a PO number longer than `BEG03` allows, a currency
or unit that is not one of the element's codes. The check is the mock's own
validator run on the document it was about to send, in that supplier's
dialect, so it says exactly what `/_mock/validate` would have said about the
bytes afterwards - and each problem names the field of the request it came
from: `currency 'DOLLARS': CUR02 is 7 characters, the maximum is 3`.

What the supplier sends back - an **855**, **856**, **810** or **865**, or
their EDIFACT counterparts - is accepted, acknowledged and filed under the
order it names, and `/_mock/orders/<po>/timeline` shows the whole exchange
from the order going out. The receipt lists these under `filed`. A document
naming an order the mock never placed with that supplier is **rejected** in
the 997 or CONTRL, at the element holding the order number: with no order
there is nothing to hold it against. A supplier's 850 is refused as a
customer's 855 is, as the wrong document for the relationship.

### Where a supplier disagrees

What the supplier says is kept document by document and compared against what
the mock asked for. Where they disagree is a **business finding, not a syntax
finding**: the 997 or CONTRL is exactly what it would have been for a document
that agreed, and the set is accepted. A buyer that receives a well-formed 855
confirming 120 against an order for 100 acknowledges it and takes the dispute
up elsewhere, and a mock that bounced it would teach a supplier's integration
something false.

The rules for an 855, ORDRSP or 865, each one comparison naming both numbers:

| Rule | When |
| --- | --- |
| `confirmed-unknown-line` | a line the order does not have |
| `confirmed-more` | more than was ordered |
| `confirmed-less` | less than was ordered, a refused line included; the sentence says whether the supplier gave a reason, or claimed `IA` |
| `price-differs` | a price other than the ordered one, even with `IP` |
| `substituted` | a different item on the line, without `IS` |

And for an 856 or DESADV, against the order and the 855, with quantities
counted across every consignment so far rather than one document at a time,
because a split delivery is ordinary:

| Rule | When |
| --- | --- |
| `shipped-before-confirmed` | no 855 has arrived for the order |
| `shipped-unknown-line` | a consignment against a line the order does not have |
| `shipped-ambiguous-item` | a consignment with no line number naming an item the order has on more than one line; it is counted against none of them |
| `shipped-more-than-confirmed` | more shipped than the latest 855 confirmed, a line confirmed at nothing included |
| `shipped-more-than-ordered` | more shipped than was ordered; both quantity rules can fire on one line, since exceeding a promise and exceeding an order are different things to say |

And for an 810 or INVOIC, the three-way match against the order, the 855 and
the 856:

| Rule | When |
| --- | --- |
| `billed-before-shipped` | no 856 has arrived for the order |
| `billed-more-than-shipped` | a line billed, over every invoice so far, beyond what shipped |
| `billed-more-than-ordered` | a line billed, over every invoice so far, beyond what was ordered, whether or not anything has shipped; both quantity rules can fire on one invoice. What was ordered is the order as it stands: a confirmation of more does not raise it, a change the mock sent does |
| `price-not-agreed` | a price that is neither the ordered nor the confirmed one |
| `total-not-lines` | MOA+79 is not the sum of the lines, or the total (TDS01, MOA+139) is not the lines plus allowances and charges (SAC, ALC) plus tax; not judged when an allowance gives only a percentage |
| `invoice-repeated` | an invoice number already received for the order; it is counted once |
| `billed-cancelled` | the order was cancelled |

They are reported in four places: the receipt's `disagreements`, beside
`findings`; `/_mock/orders/<po>`, which for a placed order shows ordered,
confirmed, shipped and billed per line; the timeline, on the event of the
document that said it; and `GET /_mock/disagreements?partner=&po=`.

## One file, several interchanges

A file from a VAN or an SFTP drop often holds more than one interchange, one
after another. Every one of them is read: each is its own envelope, with its
own control number, its own acknowledgment and its own verdict, so one being
refused says nothing about the rest. Each may even declare its own delimiters,
because the VAN concatenated what its senders gave it and they need not agree.

The summary keeps the shape it has for a single interchange — the keys beside
`interchanges` describe the whole payload, and `interchanges` describes each
one in turn:

```json
{
  "accepted": true,
  "orders": ["4500000042", "4500000043"],
  "interchanges": [
    {"interchange": "000000077", "accepted": true, "orders": ["4500000042"], "...": "..."},
    {"interchange": "000000078", "accepted": true, "orders": ["4500000043"], "...": "..."}
  ]
}
```

An interchange that is refused carries its own `error` and leaves the others
alone; the payload as a whole is `accepted` only when every interchange in it
was. Over AS2 one MDN answers the whole file, and says *processed* only when
all of it was. A dropped file is filed as processed on the same terms.

## An order belongs to the partner that placed it

A purchase order number is not an identity: real numbers are unique per buyer,
and two customers may both use `4500000042`. The mock keeps an order by its
partner *and* its number, so both are held, each answered, packed and billed
on its own, and neither's documents touch the other's.

Where a URL names an order by number, `?partner=` says whose:

```bash
curl -s 'http://127.0.0.1:8080/_mock/orders/4500000042?partner=GLOBEX'
```

Without it the number has to be unambiguous. When two partners hold it,
`/_mock/orders/<po>` and its `/timeline` answer `409`, naming both, rather
than guessing. `/_mock/orders` lists every order, and `mock.order(po,
partner=...)` in `mockedi.testing` takes the same choice.

A change, a cancellation or a restated 850 is about the sender's own order. One
naming a number the sender holds no order under is answered exactly as one
against an order that does not exist, in the same words, because a customer
has no business learning which numbers its competitors use. The same partner
may send a number again while the order is still only *received*, and it
replaces its own order as it always has. The despatch and invoice the first
was promised are withdrawn - `/_mock/scheduled?all` shows them closed with
the note *order restated* - and the restated order is promised its own, so
the goods are packed, advised and billed once.

Once that order has been acted on - shipped, invoiced, cancelled or refused -
the number is spoken for, and a fresh 850 under it is refused with an 855 that
says *order number already in use*. A buyer with a retry bug sends the order
again as an **original**, not as a change, so nothing above catches it, and
replacing an order that has already been fulfilled starts the fulfilment
again: the mock would ship and bill the whole order twice. A change that
arrives as a restated 850 still works, because `BEG01` says `04` or `05` and
that is read as the change it is. `--allow-duplicates` turns this off with
the rest of the duplicate refusals.

The one number a partner cannot reuse is that of an order the mock placed with
it as a supplier, should it later become a customer: that order is the mock's
own, and an 850 on its number is refused with an 855 that says *order number
already in use*.

## A replayed interchange is refused

A retry bug on the sender's side is ordinary, and processing a duplicate order
is expensive. An interchange control number a partner has used before is
refused in the envelope's own words — a `TA1` with note code `025`, *duplicate
interchange control number*, or a `CONTRL` whose `UCI` carries `0085 = 26`,
*duplicate detected*.

```
TA1*000000077*260925*0820*R*025~
```

Nothing behind a refused envelope is read: no 997, no 855, and the order it
carried is not shipped and invoiced a second time. The replay is still
archived — refusing it is not forgetting it.

Control numbers belong to a pair of partners, so another partner may use the
same one. `--allow-duplicates` turns the check off for a test that wants the
older behaviour of replacing the order.

## Acknowledgments, both ways

The mock sends a 997 for everything it receives — except a 997, which is
never acknowledged with another (nor a CONTRL with a CONTRL); two systems that
both did that would answer each other for ever. It also *reads* one for
everything it sends, which is what makes the most expensive EDI failure
testable: nobody acknowledged my invoice.

```bash
curl "http://127.0.0.1:8080/_mock/unacknowledged?older-than=60"
```

```json
[
  {"code": "810", "kind": "invoice", "reference": "4500000042",
   "group_control": "4", "control": "0004", "partner": "ACME", "at": "..."}
]
```

Send a 997 back and the document it names is marked with the verdict:

```json
{"acknowledged": [
  {"code": "810", "control": "0004", "matched": true, "status": "rejected",
   "note": "BIG at segment 2: Segment has data element errors; element 4: Invalid code value ('BADPO')"}
]}
```

The two dialects address what they are acknowledging differently, and both are
matched properly. X12 names a *transaction set inside a functional group* —
`AK102` quotes GS06, `AK202` quotes ST02, and both are needed because ST02 is
only unique within its group. EDIFACT names a *message inside an interchange*,
with `UCI01` quoting UNB's control reference and `UCM01` quoting UNH01.

Either can also answer for everything at once, and most translators do when
nothing went wrong: a 997 of `AK1` and `AK9` with no `AK2` loop marks every set
of that functional group with `AK901`'s verdict (only sets of the group `AK101`
names — an `AK1*PR` answers 855s), and a CONTRL with `UCI` and no `UCM` marks
every message in the interchange it quotes.

An acknowledgment naming something the mock never sent comes back
`"matched": false` rather than being silently dropped — it is real and common,
and usually evidence of the bug you are looking for.

Set a partner to `no-ack` and nothing is ever acknowledged, so
`/_mock/unacknowledged` keeps filling up. That is the point.

## Trading over a directory

Not all EDI is AS2. A great deal of it is still a folder: the partner writes a
file into it, you pick the file up; you write a file back, they pick it up.

```bash
mock-edi --drop-dir ./edi/in --pickup-dir ./edi/out
```

Anything dropped in `./edi/in` goes through the same pipeline a POST does, and
the answers are written into `./edi/out` as `<partner>-<code>-<control>.edi` —
written to a temporary name and renamed, so nothing watching the directory
ever sees a half-written file. Nothing already there is overwritten: after a
`/_mock/reset` the control numbers start again, so a name can recur before the
first file is collected, and the new one is written as `...-1.edi` instead and
listed under `renamed` in `GET /_mock/drop`.

Two things every directory integration meets are handled rather than left to
bite. A file still being written is not read: anything modified within
`--drop-settle-ms` is left for the next pass, and `.tmp`, `.part` and dotfiles
are never read at all. A file that has been read is not read again: it is
moved into `processed/`, or into `failed/` if it could not be read — moved
rather than deleted, because a mock that eats the evidence is no use when a
test fails. While it is being read it is renamed `<name>.processing`, so the
poller and a manual scan cannot both take it. And if it cannot be moved — a
read-only `processed/`, say — it keeps its name, is listed under `stuck` in
`GET /_mock/drop` and on stderr, and is not read again until it changes.

The poller runs every `--drop-interval-ms`, but it is not the only way in:

```bash
curl -X POST http://127.0.0.1:8080/_mock/drop/scan
```

scans once and returns what it found, so a test never has to wait for a poll
interval — the same reason `/_mock/advance` exists.

## Validation

Every inbound document is checked against the dictionary, and the findings
become a real 997 or CONTRL — `AK3`/`AK4` with X12 error codes, `UCS`/`UCD`
with EDIFACT ones. `AK404` quotes the offending value back, up to 99
characters, unless it holds a control character: a 997 may not carry one
either, so the copy is left out and `AK401`–`AK403` say which element and why. The CONTRL uses 0085's own word where it has one: 39 and
40 for an element too long or too short, 37 for a letter in a number, and in
the `UCM` 29 or 28 for a `UNT` that miscounts or names another message and
14 for a message type the mock does not know, with the service segment
named. `UCI` says 4 only when the interchange itself is at fault; a sound one
carrying a refused message is 7, with the 4 on that message's `UCM`.

An EDIFACT date is read by the format it states. `DTM`'s third component says
what the second is: `102` a date, `203` a date and a time, `204` with seconds,
`101` with a two-digit year - and the mock takes the date from each. A value
that is not what its format says, `2026-10-10` called `102` or eight digits
called `203`, is reported on 2380 as an invalid date (0085 code 12 in the
CONTRL) and is not read; a `DTM` that states no format is read by its length.

The mock sells, so it refuses what a seller sends. An 855, 856, 810 or 865 -
or an ORDRSP, DESADV or INVOIC - arriving from a partner is rejected the way a
real translator rejects a set its relationship with that partner does not
process: `AK5*R*1` or `UCM` 0085 = 14, *not supported*, with the reason in the
findings. It is archived, rejected, and not filed under the PO number it
names. A supplier-side integration pointed here by mistake is told so rather
than handed a clean 997 for its ASN.

Ask for the findings as prose instead:

```bash
curl -X POST --data-binary @broken.edi http://127.0.0.1:8080/_mock/validate
```

```json
{
  "clean": false,
  "groupCode": "R",
  "explain": [
    "850/0001: rejected",
    "  BEG at segment 2: ZZ is not a code BEG01 accepts (00, 01, 04, 05, 06, 07, ...)",
    "  BEG at segment 2: BEG05 is not a valid date: '2026-09-24'",
    "  PO1 at segment 3 in the PO1 loop: PO102 must be a number, got 'ten'",
    "  SE01 counts 99 segments, the message holds 6 (4)"
  ]
}
```

Severity is the mock's own policy, and it is stated rather than implied. A
*fatal* finding rejects the transaction set — an unknown set, a missing
mandatory segment or element, a control number that does not match its
trailer, a segment count that does not add up, a line number used twice in
one order (the standards allow it; almost no implementation guide does). Everything else is accepted
with errors noted: an invalid code, a length violation, a malformed date, a
segment the set does not define. A partner set to `strict` rejects on either.

The envelope is checked too, and a fault there outranks anything inside it.
A `GE` whose count or control number disagrees with what arrived, or a group
with no `GE` at all, rejects the group: `AK9*R` with the standard's reason in
`AK905`. `AK902` quotes the count the sender's own `GE01` gave and `AK903`
says how many sets arrived, so a miscount shows which way it was out. Every
set inside it is then `AK5*R` as well, however clean it was -
no set in a rejected group is acted on, so none is acknowledged as accepted -
and a set with a fault of its own keeps its `AK3`/`AK4`. An `IEA` that disagrees with the `ISA`, or a file that stops before
its `IEA`, rejects the whole interchange, and the answer is a `TA1` with
`TA104 = R` and no 997, because no group inside it was read. A `TA1` also comes
back whenever `ISA14 = 1` asks for one - `kind=interchange-acknowledgment` in
the mailbox. An `ISA` element off its fixed width is noted (`TA104 = E`) but
the interchange is still read; that is the mock's choice, since receivers
differ. On the EDIFACT side a `UNZ` that miscounts, names another interchange
or never arrives rejects the interchange in the CONTRL's `UCI`, with `0085`
codes 29, 28 and 13.

**A simple element that arrives in pieces is reported.** The component
separator means something only inside a composite. An element the standard
makes simple, sent with one in it - `REF*ZZ*A>B` where `ISA16` is `>`, or a
`BGM` whose message function is `9:X` - is read as its first piece, as it
always was, and now says so: `REF02 is a simple element and arrived with 2
components ('A', 'B'); read as 'A'`, with `AK403` 6 in a 997 and `0085` 16 in
a CONTRL. It is an error and not a fatal one, so the set is accepted with
errors unless the partner is `strict`. Only a position the dictionary
declares is judged: one past the declaration and inside the segment's width
is carried and not checked, because the standard may have a composite there,
as it does at `REF04`. Free text that contains the sender's own separator is
the usual way to meet this, and a real translator on the other side would
split it too.

Two limits, stated plainly: loop *membership* and repetition counts are
checked but loop *sequence* is not, and conditional requirements ("if PO104 is
present then PO103 must be") are not modelled. Both would need a rule language
to express, and the mock would rather leave them out than pretend.

## A partner's implementation guide

The standard allows more than any one partner accepts. Their guide makes
`REF*IA` mandatory, never uses `PO4`, allows `EA` and `CS` and nothing else,
gives `REF02` fifteen characters — and a document that satisfies 004010 but
not the guide is the one that bounces in a real onboarding. Give a partner a
**profile** and the mock bounces it too:

```bash
curl -X PUT -H 'Content-Type: application/json' \
     http://127.0.0.1:8080/_mock/partners/ACME/profile -d '{
  "name": "Acme 850 guide v2",
  "sets": {"850": {
    "require": ["REF"],
    "forbid": ["PO1/PO4"],
    "segments": {
      "REF": {"elements": {"1": {"codes": ["IA"]}, "2": {"maxLength": 15}}},
      "PO1": {"elements": {"3": {"codes": ["EA", "CS"]}}}},
    "loops": {"PO1": {"repeat": 100}}}}}'
```

A profile is a **narrowing** of the dictionary and nothing else: a segment or
loop required or forbidden, a segment's uses or a loop's repeats lowered, a
code list cut down, an element shortened or made mandatory. A segment is named
by *where* it is — `REF` is the header's, `PO1/REF` the one on each line —
because a guide that requires the header's `REF*IA` says nothing about a line's
`REF*VN`. One that would *widen* the dictionary — a code it does not have, a
longer maximum, a mandatory segment forbidden — is refused when it is loaded,
with every reason at once; accepting more than 004010 does is another version.

What the guide rejects, the 997 rejects: the set is refused, with the
standard's codes (`AK304 = 3` for the missing `REF`, `2` for the `PO4`,
`AK403 = 7` for the `DZ`), and the finding says whose rule it broke —
`ACME's guide 'Acme 850 guide v2': REF01 ...`. 005010 has implementation codes
of its own for these; the mock uses the standard codes, and the words. The
profile is kept with the partner, so it survives a restart on `--db`; a file
loads the same way (`curl -T guide.json`). `GET
/_mock/dictionary/X12/850?partner=ACME` serves the rules as the guide narrows
them, and `POST /_mock/validate?partner=ACME` checks a document against them
before you send it.

## Configuration

Everything is a command-line flag; `mock-edi --help` prints the same list.
With none at all the mock is a throwaway partner on `127.0.0.1:8080`, holding
everything in memory.

**Where it listens and what it keeps**

| Flag | What it is for |
| --- | --- |
| `--host ADDRESS` | The bind address (default `127.0.0.1`). `0.0.0.0` to reach it from another machine or a container. |
| `--port N` | The port (default `8080`). |
| `--db PATH` | A SQLite file instead of `:memory:`. Partners, orders, the archive, the control-number ranges and how far the clock was advanced then survive a restart, and documents left undelivered are picked up again; an older file is upgraded in place when it is opened. |
| `-q`, `--quiet` | No access log. |
| `--version` | Print the version and exit. |

**Who the mock is on the wire**

| Flag | What it is for |
| --- | --- |
| `--as2-id ID` | Its interchange and AS2 identifier (default `MOCKEDI`): `ISA08`, `UNB` recipient, `AS2-To`. |
| `--name NAME` | Its company name in `N1` / `NAD` (default `Mock EDI Supply Co`). |
| `--qualifier Q` | Its interchange id qualifier, `ISA07` (default `ZZ`). |

**How it behaves**

| Flag | What it is for |
| --- | --- |
| `--ack-delay`, `--response-delay`, `--despatch-delay`, `--invoice-delay MS` | Hold each document back; see [Timing](#timing). |
| `--tax-rate RATE` | Tax on invoices, as a fraction: `0.0825` (default `0`). Checked at startup: anything that is not a number from 0 to 1 is refused there, so `8.25` is not billed as 825%. |
| `--allow-duplicates` | Accept a control number a partner has used before; see [A replayed interchange is refused](#a-replayed-interchange-is-refused). |
| `--no-mdn` | Never return an MDN, whatever the sender asks for - for testing a client that waits for one. |
| `--any-receiver` | Accept an interchange addressed to someone other than `--as2-id`. The usual first-run failure is an interchange addressed to the real partner's id; this is the switch for it. |
| `--compact` | Write documents without a newline after each segment. |

**Trading over a directory** - `--drop-dir`, `--pickup-dir`,
`--drop-interval-ms` and `--drop-settle-ms`; see
[Trading over a directory](#trading-over-a-directory).

**Testing the unhappy paths**

| Flag | What it is for |
| --- | --- |
| `--auth USER:PASSWORD` | Require HTTP basic authentication on every request, control plane included - before pointing a shared staging environment at it. Binding a non-loopback address without it prints a warning. The password may hold any characters; a client may send it as UTF-8 or as Latin-1. |
| `--deliver-to HOST[,HOST]` | Hosts the courier may POST to; anywhere by default. The mock posts released documents to whatever `as2_url` a partner carries, and asynchronous MDNs to whatever `Receipt-Delivery-Option` an AS2 sender names - and `/as2` cannot require authentication and still be AS2. This says which hosts are allowed: a partner `as2_url` outside the list is refused by the control plane with a `400`, a document already bound for one fails without being posted, and a `Receipt-Delivery-Option` outside it is refused with a failure MDN. |
| `--hold-delivery` | Post nothing until asked. Released documents and asynchronous MDNs wait, in order, and `POST /_mock/deliver` sends the next one and says what it was. For reading the state between two hops of a conversation between two mocks. See [One document at a time](#one-document-at-a-time). |
| `--latency-ms MS` | Add a delay to every request. |
| `--error-rate FRACTION` | Answer that fraction of requests with a `500`, for a client's retry logic. Only the trading endpoints are failed - never anything under `/_mock/`. |
| `--start-at TIME` | Start the mock's clock at this time and hold it there until it is advanced: `2026-11-02T09:00:00Z`, or with an offset. Two runs of one script then write the same documents and the same timeline. Documents are dated in the zone written here, not the host's. See [Starting the clock at a chosen time](#starting-the-clock-at-a-chosen-time). |
| `--seed N` | Seed for the demo data and for `--error-rate`'s choices (default `42`), so a run can be repeated exactly. |
| `--no-request-log` | Keep requests out of the `request_log` table, and out of `/_mock/requests`. |

**Keeping it bounded** - `--keep-requests` and `--retention-days`, for a mock
left running on a `--db` file; see [Leaving it running](#leaving-it-running).

## Layout

```
mockedi/schema.py        elements, segments, loops, transaction sets  (add shapes here)
mockedi/envelope.py      the shape both dialects share, and delimiter handling
mockedi/x12.py           reading and writing ASC X12 interchanges
mockedi/edifact.py       reading and writing UN/EDIFACT interchanges
mockedi/charsets.py      which character set a document is in, bytes and back
mockedi/validate.py      checking a document against the dictionary
mockedi/ack.py           turning findings into a 997 or a CONTRL
mockedi/reconcile.py     reading an acknowledgment for something we sent
mockedi/claims.py        what a supplier says about a placed order, and where it disagrees
mockedi/remittance.py    what a remittance advice says that does not add up
mockedi/timeline.py      one order's whole conversation, in order
mockedi/testing.py       the client a test drives the mock with
mockedi/transactions.py  business documents in, business documents out
mockedi/documents.py     what the seller decides, and the shipment and invoice
mockedi/partners.py      who we trade with, and how each one misbehaves
mockedi/profiles.py      a partner's implementation guide, as a narrowing of the dictionary
mockedi/pipeline.py      the choreography: an order in, four documents back
mockedi/delivery.py      posting to a partner that has somewhere to receive
mockedi/as2.py           AS2 headers, the MIC, and the MDN
mockedi/drop.py          trading over a directory rather than over HTTP
mockedi/db.py            SQLite: schema, number ranges, demo data
mockedi/money.py         the one rounding rule: to the cent, half up
mockedi/server.py        HTTP: the handler and the mock's state
mockedi/routes/__init__.py  the route table every endpoint registers with
mockedi/routes/transport.py the doors: /as2, /as2/mdn and /edi
mockedi/routes/index.py     the index page
mockedi/routes/control.py   health, state, reset, and the 404 for an unknown /_mock path
mockedi/routes/validate.py  /_mock/validate and the dictionary served as data
mockedi/routes/orders.py    orders, their timelines, purchasing, disagreements
mockedi/routes/documents.py documents, interchanges and MDNs as they crossed the wire
mockedi/routes/partners.py  partners, their guides, and the catalog
mockedi/routes/mailbox.py   the mailbox, the outbox and the drop directory
mockedi/routes/clock.py     scheduled work, advance, send, and what is unacknowledged
```

[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) explains how they fit together;
[`docs/FILES.md`](docs/FILES.md) is an index of every file in the repository.

## Tests

```bash
python3 -m unittest discover -s tests -v
```

Every test talks to a real mock over real HTTP. Nothing is stubbed. The most
valuable one is in `tests/test_dictionary.py`: every document the mock
generates is validated against the same dictionary it validates yours with, so
the day someone adds a segment to a writer and forgets the definition, the
suite says so.

## Extending it

Add a segment or a transaction set in `schema.py` and it is parsed, validated
and published in `/_mock/dictionary` without touching anything else. Add a
*behaviour* in `documents.decide()`. Add an endpoint in `mockedi/routes/`.
[CONTRIBUTING.md](CONTRIBUTING.md) says where each kind of change goes and what
a good pull request carries.

## See also

[mock-sap](https://github.com/rseufert/mock-sap) — the same idea for SAP:
OData V2 and V4, BAPI/RFC and IDoc shapes over SQLite, also with zero
dependencies. An IDoc `ORDERS05` and an X12 850 are the same business
document, so the two mocks make a reasonable pair of ends for testing a
middleware layer. [mock-acme](https://github.com/rseufert/mock-acme) is that layer:
[`po_bridge.py`](https://github.com/rseufert/mock-acme/blob/main/mockacme/po_bridge.py) is one piece of it and
[`invoice_check.py`](https://github.com/rseufert/mock-acme/blob/main/mockacme/invoice_check.py) is another.

[Testing an SAP-to-EDI Integration Without SAP or a Trading Partner](https://rickseufert.com/blog/2026/09/24/testing-an-sap-to-edi-integration)
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](https://github.com/rseufert/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, the one an 810 from this mock asks for; a `payment_run` example
joining it to mock-sap is planned for its 0.2 release.

[rickseufert.com](https://rickseufert.com/#projects) lists all three mocks side
by side, with the worked examples and how to run each one.
