invoic-checker 0.20.0

Automated INVOIC plausibility and tariff validation over BO4E for German energy market invoice recipients (LF, NB, ESA). Pure library — no I/O, no async.
Documentation

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 referenceist_storno = true must name an original_rechnungsnummer Dispute
2 Period validityrechnungsperiode_start < end, both within plausible range Dispute
3 Zahlungszielfaelligkeitsdatum < 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 / Angeboteinzelpreis 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 invoic_checker::{InvoicCheckEngine, is_stornierung, CheckConfig};
use rubo4e::current::Rechnung;

let rechnung: Rechnung = /* ... */;
if is_stornierung(&rechnung) {
    // Arithmetic-only path — no tariff check.
    let report = InvoicCheckEngine::check_storno(pid, &rechnung, &CheckConfig::default());
} else {
    let report = InvoicCheckEngine::check(pid, sender_mp_id, &rechnung, &store, &config);
}

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 AJT DE 1082 admits a different list of Entscheidungsbäume on each
  • Entscheidungsbaum-Diagramme und Codelisten 4.3 Kap. 8.27 — E_0264E_0267, the ESA billing round trip; Kap. 9.3/9.4 — E_0270E_0277, the Preisblatt-B one
  • BDEW AWH Prozesse zur Änderung der Technik an Lokationen V1.1 (31.03.2025)

Related crates

Crate Role
invoic-checkerthis 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/