invoic-checker
Pure INVOIC plausibility and tariff validation library for German energy market suppliers.
invoic-checker implements the eight-stage pipeline that invoicd runs automatically
against every incoming INVOIC — and that netzbilanzd runs before dispatching to
prevent an immediate dispute.
Design constraints
| Constraint | Detail |
|---|---|
| No I/O | All inputs are passed as arguments. No database calls, no HTTP. |
| No async | Synchronous throughout. |
| No float money | All monetary comparisons use rust_decimal. |
| Pure functions | InvoicCheckEngine::check() cannot fail — it always returns a CheckReport. |
Why this is a separate crate from mako-pruefung
The two answer different questions for different audiences:
mako-pruefung |
invoic-checker |
|
|---|---|---|
| Produces | published BDEW Antwortcodes for the wire | mako's own Findings for the operator queue and the § 147 AO receipt |
| Runs for | Prüfidentifikatoren (PIDs) with an Entscheidungsbaum | every INVOIC PID, including those with none |
| Knows about | nothing but Prüfschritte | BO4E, money, Preisblätter |
| Dependencies | mako-fristen, serde, time, uuid |
+ rubo4e, billing, rust_decimal |
| Consumers | every crate that answers a BDEW process | invoicd, netzbilanzd |
Merging them would put rubo4e and billing behind mako-wim, mako-gpke,
mako-mabis, processd and einsd, none of which touches an invoice — and
hand netzbilanzd, which uses only CheckOutcome, every BDEW tree in the
catalogue to get one enum.
The dependency runs one way: this crate maps BO4E onto
mako_pruefung::rechnung's Prüfschritte and calls the walk. That module holds
no BO4E type and no money type, which is what keeps it linkable from a
role-gated build.
Where the two overlap, they must agree. Several checks below ask the same
question as a Prüfschritt — position arithmetic against A20, the document
total against A24, the tax breakdown against A22/A23. Both paths therefore
read the same tolerance: Summen-level checks total_tolerance_ppm,
position-level ones arithmetic_tolerance_ppm. Two knobs for one question would
let the engine record a TotalMismatch Dispute while the walk dispatched a
Zahlungsavis.
The checks
The pipeline is eight stages plus one PID-specific one, in this order:
| # | Rule | Outcome on failure |
|---|---|---|
| 1 | Storno reference — ist_storno = true must name an original_rechnungsnummer |
Dispute |
| 2 | Period validity — rechnungsperiode_start < end, both within plausible range |
Dispute |
| 3 | Zahlungsziel — faelligkeitsdatum < rechnungsdatum (invalid) or beyond max_zahlungsziel_days (exceeded; default 30 per §7 Allg. Festlegungen) |
Dispute · Warn |
| 3a | WiM 31003 send window — a Dienstleistungsrechnung dated more than 20 Werktage after the period it bills (WiM Teil 1 Kap. 3.7.2 Nr. 1). Only PID 31003 | Warn |
| 4 | Currency agreement — the document totals, every position's gesamtpreis and every steuerbetraege entry agree on one currency. Runs before the arithmetic, because every amount is read as EUR and a CHF field would otherwise compare silently right |
Dispute |
| 5 | Position arithmetic — every Rechnungsposition satisfies menge × einzelpreis ≈ gesamtpreis. An unrepresentable product is itself a finding, not a panic |
Dispute |
| 6 | Document total — the positions sum to gesamtnetto |
Warn |
| 7 | Umsatzsteuer — the invoice states a rate and an amount (§14 Abs. 4 Nr. 8 UStG); gesamtbrutto = gesamtnetto + gesamtsteuer; each breakdown entry's steuerwert is the stated rate applied to its stated basiswert (to within one cent, the unit the amounts are stated in); and a reverse-charged invoice states no tax |
Dispute |
| 8 | Tariff / Angebot — einzelpreis against the published Preisblatt, or against the accepted QUOTES for an ESA invoice. Skipped for a Stornorechnung, which carries the original's negated amounts rather than new tariff positions. For PIDs 31005–31008 this is the Mehr-/Mindermengen price from the MMMA store |
Warn or Dispute |
Stage 7's per-entry check is what stops an invoice that sums to its own totals
perfectly while charging the wrong tax: 19 % stated on a base of 10 000 with a
Steuerwert of 100 satisfies netto + steuer = brutto and agrees with
gesamtsteuer, and 1 800 EUR is then neither charged nor deductible.
Why a missing tax block is a dispute
§14 Abs. 4 Nr. 8 UStG makes the rate and the tax amount mandatory content, or a note saying why neither is stated. Paying an invoice without them means paying tax that cannot be recovered — the receiving LF's money — so this is a refusal rather than a note. A reverse-charged invoice states no tax by design and is accepted on that footing; one that states tax anyway is a dispute, because that tax is owed under §14c Abs. 1 UStG and is still not deductible.
The market branch — published Antwortcodes, three families
The pipeline above answers „is this invoice plausible" in mako's own vocabulary
(Finding), which is what an operator queue and a § 147 AO receipt need. It
is not what the market resolves: the answer owed is a REMADV carrying
published Antwortcodes, one per defect, each naming the Ebene and — on the
Positionsebene — the Positionsnummer.
The rechnung module is that bridge. antwort_auf_rechnung maps a BO4E
Rechnung plus the recipient's own facts onto the tree's Prüfschritte and
returns a RechnungsAntwort, which also knows which REMADV Prüfidentifikator it
must ride (33001 Zahlungsavis, 33003 Kopf/Summe, 33004 Position).
antwort_auf_erneute_rechnung runs the second round, after the MSB answered a
Nicht-Zahlungsavis with a COMDIS 29001.
One walk, three families. The BDEW publishes the same Rechnungsprüfung under twelve EBD numbers, because a code is resolved against the tree the answer names:
| Familie | Rechnung | erneut | Nicht-Zahlungsavis | Storno |
|---|---|---|---|---|
ESA — WiM Teil 2 Kap. 4.5 |
E_0264 |
E_0266 |
E_0265 |
E_0267 |
PREISBLATT_B_LF — AWH Kap. 9.3 |
E_0270 |
E_0276 |
E_0271 |
E_0272 |
PREISBLATT_B_NB — AWH Kap. 9.4 |
E_0273 |
E_0277 |
E_0274 |
E_0275 |
Every entry point takes the RechnungsFamilie, and the walk itself lives in
mako_pruefung::rechnung. Three things differ and nothing else does: the second
round's Prüfschritt-1 code (A25 for the ESA, AC1 for Preisblatt B),
two Kopf-Prüfschritte that only the Preisblatt-B trees publish (80 „kein
gültiges Preisblatt", 90 „Abrechnungszeitraum doppelt abgerechnet"), and the
Prüfschritt number A90 therefore sits at (90 against 100).
That first difference is the reason this is one parameterised walk rather than
three copies: A25 is the ESA's second-round refusal and the Preisblatt-B
doppelter Abrechnungszeitraum — one spelling, two meanings, in trees that
answer on the same REMADV Prüfidentifikatoren.
The ESA's price basis is the offer, not a Preisblatt. § 35 MsbG leaves the Entgelt for a Zusatzleistung to be agreed per request, so there is no published sheet for a Kapitel-4.6 Messprodukt; the QUOTES 15003 the ESA ordered against is the agreement, and the join is exact — the offer prices Artikel-IDs and the invoice names the same ones back. An empty offer list means mako holds no record of one, and Prüfschritte 300 / 320 / 500 then answer „not comparable" rather than „wrong": disputing on a gap in mako's own books refuses a correct invoice. A Preisblatt-B invoice does have a sheet — PRICAT 27002, called „Preisblatt Technik" — which is what Prüfschritt 80 asks about.
Prüfschritte that need facts no INVOIC carries live on EmpfaengerFakten, and
an unknown answer never refuses. Two are not among them: Prüfschritt 40
(SG1 RFF+ACE names the order, so the invoice either matches one on record or
bills against none) and Prüfschritt 50 (the Rechnungsnummer is einmalig per
Rechnungssteller under § 14 Abs. 4 Nr. 4 UStG, and invoicd answers it from its
own receipt store).
Stornierung handling (ist_storno = true)
When ist_storno = Some(true), stage 8 (tariff check) is automatically skipped.
A Stornierung carries negated amounts from the original invoice, not new tariff positions —
checking them against PRICAT would always produce false TariffDeviation disputes.
check_storno runs stages 1–6; stage 7 (Umsatzsteuer) is skipped with it.
Stage 1 enforces that original_rechnungsnummer is present on every Storno.
Use is_stornierung(&rechnung) to test the flag before routing to check_storno().
use ;
use Rechnung;
let rechnung: Rechnung = /* ... */;
if is_stornierung else
Stage 3 — Zahlungsziel
faelligkeitsdatum (DTM+265) is validated against rechnungsdatum and the
configured max_zahlungsziel_days (default: 30, per §7 Allgemeine Festlegungen V6.1d).
Set max_zahlungsziel_days = 0 in CheckConfig to disable this check.
Stage 3a — the WiM 31003 send window
WiM Strom Teil 1 Kap. 3.7.2 Nr. 1 gives the sender „unverzüglich, jedoch
spätester ÜT ist der 20. WT nach" the end of what is being billed. The SD names
that end four ways — Beendigung der temporären Fortführung des
Messstellenbetriebes, Überlassung der Einrichtung, Ende des jeweiligen
Abrechnungszeitraums, Versand der Zusatz-/Kontrollablesung — and a recipient
cannot tell which Abrechnungsart it holds. All four are the end of the billed
thing, which on the wire is the invoice's own rechnungsperiode.
It is a Warn, and deliberately one that does not escalate on the
invoice's value. The window binds the sender; Kap. 3.7.2 Nr. 2 gives the
recipient one answer, „zum angegebenen Zahlungsziel", and no REMADV tree
publishes an Antwortcode for lateness — so a refusal would have to go out under
a Summenebene catch-all meaning something else. FindingKind::escalates_with_value
is what keeps a large late invoice a warning.
The 20 Werktage are read from mako_fristen::vorlauf's
wim.rechnung-dienstleistungen row, with the BDEW MaKo calendar, rather than
restated here: a second copy of a published window is the defect this crate
exists to catch, one level up.
Stage 8 — ToU-aware tariff matching
For a time-of-use Preisblatt (§ 14a Modul 3, BK8-22/010-A Tenor 3.), the bands come from
the zeitvariablePreispositionen extension, each entry pairing a
zaehlzeitregister code with its price. There is no hard-coded HT/NT
keyword list: the position's own positionstext is lower-cased and matched
against the register codes the Preisblatt actually publishes, so an operator who
names a band ST or NT2 needs no code change.
The band search then degrades in a fixed order — a matching register code wins;
failing that the flat preisstaffeln prices apply; failing those, every ToU band
price is accepted. Each fallback only widens what passes, so a missing band
produces no invented deviation — but it does mean a TariffDeviation finding is
evidence and a clean result is not proof.
Stage 8 — MMM settlement price
InvoicCheckEngine::check_mmm_settlement() fetches the monthly Mehr-/Mindermengenpreis
(Gas or Strom) from marktd's MMMA store and compares it against the invoice's
mehr_preis / minder_preis fields.
The MSB path — PIDs 31003 and 31009
For a WiM/MSB-Rechnung, use check_msb_rechnung(). It runs the document stages
2–7 exactly as check() does — period, currency, position arithmetic, document
total, Zahlungsziel and Umsatzsteuer — and reads the tariff for stage 8 from
PreisblattMessung.preispositionen rather than PreisblattNetznutzung. With no
Preisblatt it warns instead of disputing, as the standard engine does.
check_msb_rechnung_with_aufabschlaege() adds one further check: every discount
or surcharge position is backed by a contracted AufAbschlag (WiM PRICAT
27001–27003).
Stages 3 and 7 run here like everywhere else. Nothing about metering service
exempts them: the INVOIC AHB makes the Fälligkeitsdatum (SG8 DTM+265) and the
tax block (TAX/MOA) Muss on 31003 and 31009 just as on 31001/31002, and
§ 14 Abs. 4 Nr. 8 UStG reaches every invoice.
Supported PIDs
| PID | Process | Billing direction |
|---|---|---|
| 31001 | Abschlagsrechnung Netznutzung | NB → LF |
| 31002 | NN-Rechnung (Netznutzung Strom + Gas) | NB → LF |
| 31005 | MMM-Rechnung (Mehr-/Mindermengensaldo) | NB → LF |
| 31006 | MMM Mehrmenge, selbst ausgestellt | LF → LF |
| 31007 | Aggreg. MMM-Rechnung Gas | NB → MGV |
| 31008 | Selbst ausgest. Aggreg. MMM-Rechnung Gas | MGV → MGV |
| 31009 | MSB-Rechnung | MSB → LF |
| 31003 | WiM-Rechnung (Dienstleistungen im Messwesen) | NB ↔ MSBN, beide Sparten |
| 31004 | Stornorechnung — Sparte-neutral, routed to check_storno |
as the original |
| 31010 | Kapazitätsrechnung Gas | FNB/VNB → BKV |
| 31011 | AWH Sperrprozesse Gas | GNB → LFG |
FindingKind variants
| Variant | Stage | Dispute? | Meaning |
|---|---|---|---|
StorniertWithoutReference |
1 | ✓ | ist_storno=true but original_rechnungsnummer missing |
PeriodInvalid |
2 | ✓ | Billing period start ≥ end |
ZahlungszielInvalid |
3 | ✓ | faelligkeitsdatum before rechnungsdatum |
ZahlungszielExceeded |
3 | ✗ | Payment term exceeds max_zahlungsziel_days |
RechnungZuSpaet |
3a | ✗ | 31003 issued > 20 WT after the billed period ended |
WaehrungMismatch |
4 | ✓ | The document, a position or a tax entry names a currency the others do not |
ArithmeticError |
5 | ✓ | Line menge × einzelpreis ≠ gesamtpreis |
TotalMismatch |
6 | ✗ | Σ line nets ≠ gesamtnetto |
SteuerMissing |
7 | ✓ | No Umsatzsteuer stated at all — no Vorsteuerabzug for the recipient |
SteuerMismatch |
7 | ✓ | gesamtbrutto ≠ gesamtnetto + gesamtsteuer, or a breakdown entry's steuerwert is not its rate on its basiswert |
ReverseChargeStatesTax |
7 | ✓ | A §13b invoice states tax anyway — owed under §14c Abs. 1 and still not deductible |
TariffDeviation |
8 | ✓ | Unit price deviates from the Preisblatt |
TariffNotFound |
8 | config | No Preisblatt for the sender |
AngebotDeviation |
8 | ✓ | An ESA invoice prices an Artikel-ID differently from the accepted QUOTES |
AngebotPositionUnknown |
8 | ✓ | An ESA invoice names an Artikel-ID the accepted QUOTES does not offer |
Regulatory basis
- BK6-24-174 — INVOIC AHB Strom (NNE/MSB-Rechnung, PIDs 31001/31002/31005/31006/31009)
- BK7-24-01-009 — INVOIC AHB Gas (GeLi Gas PID 31011)
- BK7-24-01-008 — INVOIC AHB Gas (GaBi Gas PIDs 31007/31008)
- BK7 billing — WiM Gas PIDs 31003/31004
- §7 Allgemeine Festlegungen V6.1d — Zahlungsziel 30 days (Strom + Gas)
- § 147 AO / GoBD — Pflicht zur Rechnungslegung (MSB-Rechnung receipt persistence)
- REMADV AHB 1.0a — § 3.1.1 (33001/33002) and § 3.1.2 (33003/33004);
SG7 AJTDE 1082 admits a different list of Entscheidungsbäume on each - Entscheidungsbaum-Diagramme und Codelisten 4.3 Kap. 8.27 —
E_0264–E_0267, the ESA billing round trip; Kap. 9.3/9.4 —E_0270–E_0277, the Preisblatt-B one - BDEW AWH Prozesse zur Änderung der Technik an Lokationen V1.1 (31.03.2025)
Related crates
| Crate | Role |
|---|---|
invoic-checker ← this crate |
The eight-stage plausibility and tariff pipeline — mako's own Findings |
mako-pruefung |
The published BDEW Antwortcodes a REMADV must carry |
mako-fristen |
When an answer is due — Werktage, the MaKo holiday calendar, the per-PID Antwortfristen |
mako-invoic |
The settle/dispute workflow whose decision these findings feed |
grid-billing |
Produces the grid-side invoices this crate checks on the receiving end |
invoicd · netzbilanzd |
Production daemons — run the pipeline on receipt and before dispatch |
Part of mako, an open-source Rust platform for German energy market communication (Marktkommunikation). Full documentation: https://hupe1980.github.io/mako/