Skip to main content

invoic_checker/
check.rs

1//! INVOIC plausibility check engine — operates on BO4E [`Rechnung`].
2//!
3//! [`InvoicCheckEngine::check`] runs a multi-stage pipeline of automated
4//! plausibility checks against a [`rubo4e::current::Rechnung`] and returns a
5//! [`CheckReport`] that drives the REMADV / dispute workflow in `invoicd`.
6//!
7//! # Check stages
8//!
9//! Eight stages plus one PID-specific one, in this order. The order matters twice: the currency check
10//! runs before the arithmetic that would otherwise compare a CHF amount against
11//! a EUR one, and the tariff check runs last because it is the only stage that
12//! reaches outside the document.
13//!
14//! | # | Stage | Finding kind | Outcome |
15//! |---|---|---|---|
16//! | 1 | Storno reference | [`FindingKind::StorniertWithoutReference`] | `Dispute` |
17//! | 2 | Period validity | [`FindingKind::PeriodInvalid`] | `Dispute` |
18//! | 3 | Zahlungsziel | [`FindingKind::ZahlungszielInvalid`] · [`FindingKind::ZahlungszielExceeded`] | `Dispute` · `Warn` |
19//! | 3a | WiM 31003 send window | [`FindingKind::RechnungZuSpaet`] | `Warn` |
20//! | 4 | Currency agreement | [`FindingKind::WaehrungMismatch`] | `Dispute` |
21//! | 5 | Position arithmetic | [`FindingKind::ArithmeticError`] | `Dispute` |
22//! | 6 | Document total | [`FindingKind::TotalMismatch`] | `Warn` |
23//! | 7 | Umsatzsteuer | [`FindingKind::SteuerMissing`] · [`FindingKind::SteuerMismatch`] · [`FindingKind::ReverseChargeStatesTax`] | `Dispute` |
24//! | 8 | Tariff / Angebot | [`FindingKind::TariffDeviation`] · [`FindingKind::TariffNotFound`] · [`FindingKind::AngebotDeviation`] · [`FindingKind::AngebotPositionUnknown`] | `Warn` or `Dispute` |
25//!
26//! Stage 8 is skipped for a Stornorechnung, which carries negated original
27//! amounts rather than tariff positions, and stage 3 is skipped when
28//! `CheckConfig::max_zahlungsziel_days` is zero.
29//!
30//! # Outcome escalation
31//!
32//! The overall [`CheckOutcome`] is the highest-severity outcome across all
33//! findings.  A single `Dispute`-severity finding escalates the whole invoice
34//! to `Dispute`.  Warn-only findings produce `Warn`.  A clean invoice is `Ok`.
35//!
36//! # Architecture
37//!
38//! This module has **zero dependency on `edifact-rs`**.  It operates solely on
39//! [`rubo4e::current::Rechnung`] — the industry-standard BO4E domain model.
40//! EDIFACT → BO4E translation is the responsibility of the `makod` transport
41//! adapter (anti-corruption layer).
42//!
43//! # Example
44//!
45//! ```rust
46//! use invoic_checker::check::{CheckConfig, CheckOutcome, FindingKind, InvoicCheckEngine};
47//! use invoic_checker::tariff::InMemoryPreisblattStore;
48//! use rubo4e::current::Rechnung;
49//!
50//! // A default `Rechnung` states no Umsatzsteuer, and §14 Abs. 4 Nr. 8 UStG
51//! // makes the rate and the amount mandatory content — so it is disputed
52//! // before any tariff question arises. An invoice the recipient cannot deduct
53//! // is one the recipient does not pay.
54//! let report = InvoicCheckEngine::check(
55//!     31001,
56//!     "9900357000004",
57//!     &Rechnung::default(),
58//!     &InMemoryPreisblattStore::new(),
59//!     &CheckConfig::default(),
60//! );
61//! assert_eq!(report.outcome, CheckOutcome::Dispute);
62//! assert!(report.findings.iter().any(|f| f.kind == FindingKind::SteuerMissing));
63//! ```
64
65use rubo4e::convenience::{BetragExt, MengeExt, PreisExt};
66use rubo4e::current::{Rechnung, Rechnungsposition};
67
68use crate::{
69    amount::{EuroAmount, euro_from_decimal},
70    tariff::PreisblattStore,
71};
72
73// ── CheckConfig ───────────────────────────────────────────────────────────────
74
75/// Configuration for [`InvoicCheckEngine::check`].
76#[derive(Debug, Clone)]
77pub struct CheckConfig {
78    /// Tolerance for arithmetic checks (line quantity × unit price vs. line net),
79    /// expressed in parts-per-million (ppm). Unsigned — zero means strict equality.
80    ///
81    /// Default: `10_000` ppm = 1 %. Increase for rough invoice types (e.g. MMM
82    /// settlement that uses SLP approximations).
83    pub arithmetic_tolerance_ppm: u32,
84
85    /// Tolerance for the cross-check between sum of line nets and total net.
86    ///
87    /// Default: `10_000` ppm = 1 %.
88    pub total_tolerance_ppm: u32,
89
90    /// Tolerance for tariff deviation findings.
91    ///
92    /// Default: `20_000` ppm = 2 %.
93    pub tariff_tolerance_ppm: u32,
94
95    /// When `true`, a missing tariff entry for the sender GLN produces a
96    /// `Dispute`-severity finding.  When `false` (default), it produces `Warn`.
97    ///
98    /// Set to `true` once the tariff store is fully seeded and the LF has
99    /// received PRICAT 27003 from all active NB counterparties.
100    pub require_tariff: bool,
101
102    /// Maximum allowed payment term (Zahlungsziel) in days from the invoice date
103    /// (`rechnungsdatum`) to the due date (`faelligkeitsdatum`, DTM+265).
104    ///
105    /// Per §7 Allgemeine Festlegungen V6.1d: standard GPKE and WiM payment term
106    /// is **30 days**. Set to `0` to disable this check.
107    ///
108    /// Default: `30`.
109    pub max_zahlungsziel_days: u16,
110}
111
112impl Default for CheckConfig {
113    fn default() -> Self {
114        Self {
115            arithmetic_tolerance_ppm: 10_000,
116            total_tolerance_ppm: 10_000,
117            tariff_tolerance_ppm: 20_000,
118            require_tariff: false,
119            max_zahlungsziel_days: 30,
120        }
121    }
122}
123
124// ── CheckOutcome ──────────────────────────────────────────────────────────────
125
126/// Overall outcome of an automated INVOIC check.
127#[derive(
128    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
129)]
130pub enum CheckOutcome {
131    /// All checks passed.  Safe to auto-dispatch REMADV 33001.
132    Ok,
133    /// Non-blocking issues found.  Route to operator for review before payment.
134    Warn,
135    /// Blocking issues found.  Open dispute process; do NOT auto-pay.
136    Dispute,
137}
138
139// ── FindingKind ───────────────────────────────────────────────────────────────
140
141/// Structured category of a check finding.
142///
143/// Each variant maps to a specific regulatory dispute reason that can be cited
144/// in a REMADV or COMDIS.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
146pub enum FindingKind {
147    /// A billing period is invalid (start ≥ end, or missing a boundary).
148    PeriodInvalid,
149    /// Line item `quantity × unit_price` does not match `gesamtpreis` (BO4E v202607).
150    ArithmeticError,
151    /// Sum of line net amounts does not match the message-level `gesamtnetto`.
152    TotalMismatch,
153    /// INVOIC unit price deviates from the PRICAT-published tariff.
154    TariffDeviation,
155    /// No PRICAT tariff exists in the store for this sender GLN.
156    TariffNotFound,
157    /// `ist_storno = true` but `original_rechnungsnummer` is absent.
158    ///
159    /// Per BK6-24-174 §5: a Stornorechnung must reference the original invoice
160    /// number so the LF can reconcile it against the original receipt.
161    StorniertWithoutReference,
162    /// `faelligkeitsdatum` (DTM+265) exceeds the maximum allowed payment term.
163    ///
164    /// Basis: §7 Allgemeine Festlegungen V6.1d — standard GPKE/WiM payment
165    /// term is 30 days from invoice date.
166    ZahlungszielExceeded,
167    /// `faelligkeitsdatum` (DTM+265) is in the past or before `rechnungsdatum`.
168    ZahlungszielInvalid,
169    /// A WiM-Dienstleistungsrechnung (31003) was issued more than 20 Werktage
170    /// after the period it bills.
171    ///
172    /// Basis: WiM Strom Teil 1 Kap. 3.7.2 Nr. 1 — „Unverzüglich, jedoch
173    /// spätester ÜT ist der 20. WT nach …". The SD names four anchors, one per
174    /// Abrechnungsart (Beendigung der temporären Fortführung, Überlassung der
175    /// Einrichtung, Ende des Abrechnungszeitraums, Versand der Ablesung); all
176    /// four are the end of the thing being billed, which on the wire is the
177    /// invoice's own `rechnungsperiode`.
178    ///
179    /// A `Warn`, not a `Dispute`: the window binds the **sender**, and no
180    /// REMADV tree publishes a code for lateness, so refusing on it would
181    /// invent one.
182    RechnungZuSpaet,
183    /// The invoice states no Umsatzsteuer at all.
184    ///
185    /// §14 Abs. 4 Nr. 8 UStG requires the rate and the tax amount, or a note
186    /// saying why neither is stated. An invoice carrying only a net figure gives
187    /// its recipient no Vorsteuerabzug — which is the receiving LF's money.
188    SteuerMissing,
189    /// `gesamtbrutto` does not equal `gesamtnetto + gesamtsteuer`.
190    SteuerMismatch,
191    /// The document's monetary fields do not agree on a currency.
192    ///
193    /// Every amount in this crate is an [`EuroAmount`], because a German MaKo
194    /// invoice is denominated in EUR — which means a `Betrag` carrying
195    /// `waehrung: CHF` is read *as if it were EUR* and every later comparison
196    /// silently comes out right. This is the check that stops that, and it runs
197    /// before the arithmetic for exactly that reason.
198    WaehrungMismatch,
199    /// A reverse-charge invoice (`RCV`) nonetheless states a tax amount.
200    ///
201    /// Tax shown on a §13b invoice is owed under §14c Abs. 1 UStG and is still
202    /// not deductible, because the recipient owes it too.
203    ReverseChargeStatesTax,
204    /// An INVOIC 31009 position's unit price deviates from the price the MSB
205    /// **offered** and the ESA accepted (QUOTES 15003 `SG31 PRI+CAL`).
206    ///
207    /// Distinct from [`Self::TariffDeviation`], which compares against a
208    /// *published* Preisblatt. An ESA has none: there is no price sheet for
209    /// Kapitel-4.6 Messprodukte, and §35 MsbG leaves the Entgelt for a
210    /// Zusatzleistung to be agreed per request. The Angebot **is** the price
211    /// agreement, which is why UC 4.1.1 has the ESA asking for „die
212    /// Übermittlung von Werten und die damit verbundenen Kosten" and why the
213    /// offer carries a Bindungsfrist at all.
214    AngebotDeviation,
215    /// An INVOIC 31009 position names an Artikel-ID the accepted Angebot never
216    /// priced.
217    ///
218    /// The offer prices one to three Artikel-IDs per `SG27 LIN` (QUOTES AHB
219    /// 1.1a condition `[2042]`); a fourth on the invoice is a charge the ESA
220    /// never agreed to.
221    AngebotPositionUnknown,
222}
223
224// ── Finding ───────────────────────────────────────────────────────────────────
225
226/// A single finding from the check engine.
227#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
228pub struct Finding {
229    /// Category of this finding.
230    pub kind: FindingKind,
231    /// Whether this finding alone escalates the outcome to `Dispute` (vs. `Warn`).
232    pub is_dispute: bool,
233    /// Human-readable description.
234    pub message: String,
235    /// Line item `positionsnummer` this finding applies to.  `None` for
236    /// message-level findings (e.g. total mismatch).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub line_number: Option<u32>,
239    /// Expected amount (for numeric comparisons).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub expected: Option<EuroAmount>,
242    /// Actual amount from the INVOIC.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub actual: Option<EuroAmount>,
245    /// Deviation as a percentage of expected (positive = overbilling).
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub deviation_pct: Option<f64>,
248}
249
250impl Finding {
251    fn dispute(
252        kind: FindingKind,
253        message: impl Into<String>,
254        line_number: Option<u32>,
255        expected: Option<EuroAmount>,
256        actual: Option<EuroAmount>,
257    ) -> Self {
258        let deviation_pct = deviation(expected, actual);
259        Self {
260            kind,
261            is_dispute: true,
262            message: message.into(),
263            line_number,
264            expected,
265            actual,
266            deviation_pct,
267        }
268    }
269
270    fn warn(
271        kind: FindingKind,
272        message: impl Into<String>,
273        line_number: Option<u32>,
274        expected: Option<EuroAmount>,
275        actual: Option<EuroAmount>,
276    ) -> Self {
277        let deviation_pct = deviation(expected, actual);
278        Self {
279            kind,
280            is_dispute: false,
281            message: message.into(),
282            line_number,
283            expected,
284            actual,
285            deviation_pct,
286        }
287    }
288}
289
290/// The signed deviation of `actual` from `expected`, in percent.
291///
292/// A diagnostic figure for the finding message, never an input to a comparison —
293/// which is why `f64` is admissible here and nowhere else in this crate.
294///
295/// The difference is taken in `i128`: two independently valid `Amount<5>` values
296/// can sit at opposite ends of the `i64` range, and their difference does not
297/// fit in one.
298fn deviation(expected: Option<EuroAmount>, actual: Option<EuroAmount>) -> Option<f64> {
299    match (expected, actual) {
300        (Some(exp), Some(act)) if exp.to_raw() != 0 => {
301            let diff = i128::from(act.to_raw()) - i128::from(exp.to_raw());
302            Some(diff as f64 / i128::from(exp.to_raw()).unsigned_abs() as f64 * 100.0)
303        }
304        _ => None,
305    }
306}
307
308// ── CheckReport ───────────────────────────────────────────────────────────────
309
310/// Full report from [`InvoicCheckEngine::check`].
311#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
312pub struct CheckReport {
313    /// Overall outcome — highest severity across all findings.
314    pub outcome: CheckOutcome,
315    /// Ordered list of findings (empty when `outcome == Ok`).
316    pub findings: Vec<Finding>,
317    /// BDEW Prüfidentifikator from the checked INVOIC.
318    pub pid: u32,
319    /// Total net amount as stated in `Rechnung.gesamtnetto`.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub total_net_invoic: Option<EuroAmount>,
322    /// Total net amount as re-computed by summing `Rechnungsposition.gesamtpreis` (BO4E v202607).
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub total_net_computed: Option<EuroAmount>,
325    /// Number of `Rechnungsposition` entries checked.
326    pub line_items_checked: usize,
327}
328
329impl CheckReport {
330    /// Assemble a report from the findings a check pipeline produced.
331    ///
332    /// The outcome is the **highest severity** across the findings: one dispute
333    /// makes the whole report a dispute, any finding at all makes it at least a
334    /// warning, none makes it `Ok`. That rule, the invoice's own stated total
335    /// and the line count were open-coded once per pipeline — three copies of
336    /// the same four lines, in the one place where a divergence would silently
337    /// change whether an invoice is auto-paid.
338    #[must_use]
339    pub fn from_findings(
340        pid: u32,
341        rechnung: &Rechnung,
342        findings: Vec<Finding>,
343        computed_total: Option<EuroAmount>,
344    ) -> Self {
345        let outcome = findings
346            .iter()
347            .map(|f| {
348                if f.is_dispute {
349                    CheckOutcome::Dispute
350                } else {
351                    CheckOutcome::Warn
352                }
353            })
354            .max()
355            .unwrap_or(CheckOutcome::Ok);
356
357        Self {
358            outcome,
359            findings,
360            pid,
361            total_net_invoic: rechnung
362                .gesamtnetto
363                .wert_decimal()
364                .and_then(euro_from_decimal),
365            total_net_computed: computed_total,
366            line_items_checked: rechnung.rechnungspositionen.iter().flatten().count(),
367        }
368    }
369
370    /// `true` when the invoice passed all checks without findings.
371    #[must_use]
372    pub fn is_ok(&self) -> bool {
373        self.outcome == CheckOutcome::Ok
374    }
375
376    /// `true` when at least one finding escalates to `Dispute`.
377    #[must_use]
378    pub fn has_dispute(&self) -> bool {
379        self.outcome == CheckOutcome::Dispute
380    }
381}
382
383// ── InvoicCheckEngine ─────────────────────────────────────────────────────────
384
385/// Return `true` when `rechnung` is a Stornorechnung (cancellation invoice).
386///
387/// A Stornorechnung is identified by `ist_storno = Some(true)`.
388/// When true, the tariff check (stage 8) must be skipped — cancellations
389/// do not carry original tariff positions, they carry negated amounts.
390///
391/// The presence of `original_rechnungsnummer` is checked separately by
392/// `InvoicCheckEngine::check` (finding kind `StorniertWithoutReference`).
393///
394/// # Example
395///
396/// ```rust
397/// use invoic_checker::check::is_stornierung;
398/// use rubo4e::current::Rechnung;
399/// let mut r = Rechnung::default();
400/// r.ist_storno = Some(true);
401/// assert!(is_stornierung(&r));
402/// ```
403#[must_use]
404pub fn is_stornierung(rechnung: &Rechnung) -> bool {
405    rechnung.ist_storno == Some(true)
406}
407
408impl FindingKind {
409    /// Whether a `Warn` of this kind gets more serious as the invoice gets
410    /// larger.
411    ///
412    /// A recipient escalates warnings above a money threshold because the
413    /// amount at stake justifies a human looking at the *arithmetic*. That
414    /// reasoning does not reach a finding about a **procedural** window: a late
415    /// invoice is no more or less late for being a large one, and the sender —
416    /// not the recipient — is who the window binds.
417    ///
418    /// It matters concretely. Escalating [`Self::RechnungZuSpaet`] would turn a
419    /// correct invoice into a refusal, and the REMADV would have to carry a
420    /// code; no tree publishes one for lateness, so it would go out as the
421    /// Summenebene catch-all `A99` — refusing a document for a reason the
422    /// answer cannot state.
423    #[must_use]
424    pub const fn escalates_with_value(self) -> bool {
425        !matches!(self, Self::RechnungZuSpaet)
426    }
427}
428
429/// Stateless INVOIC plausibility check engine.
430///
431/// All logic is in [`InvoicCheckEngine::check`], which is a pure function over
432/// a [`rubo4e::current::Rechnung`].  No state is held between calls.
433pub struct InvoicCheckEngine;
434
435impl InvoicCheckEngine {
436    /// Run all plausibility checks and return a [`CheckReport`].
437    ///
438    /// # Arguments
439    ///
440    /// - `pid` — BDEW Prüfidentifikator (31001–31011) from `InvoicData`.
441    /// - `sender_mp_id` — verified sender GLN from `InvoicData.sender`
442    ///   (identity-checked at transport layer; used for tariff lookups).
443    /// - `rechnung` — BO4E invoice object stored in the event.
444    /// - `tariff_store` — tariff database seeded from PRICAT 27003.
445    /// - `config` — tolerance and policy configuration.
446    #[must_use]
447    pub fn check(
448        pid: u32,
449        sender_mp_id: &str,
450        rechnung: &Rechnung,
451        preisblatt_store: &dyn PreisblattStore,
452        config: &CheckConfig,
453    ) -> CheckReport {
454        let mut findings: Vec<Finding> = Vec::new();
455
456        let storno = is_stornierung(rechnung);
457
458        // ── Stage 1: Stornierung reference check ──────────────────────────────
459        // When ist_storno=true, original_rechnungsnummer must be present.
460        // Source: BK6-24-174 §5; Allgemeine Festlegungen §8.
461        if storno
462            && rechnung
463                .original_rechnungsnummer
464                .as_deref()
465                .unwrap_or("")
466                .is_empty()
467        {
468            findings.push(Finding::dispute(
469                FindingKind::StorniertWithoutReference,
470                "Stornorechnung (ist_storno=true) does not reference the original invoice \
471                 (original_rechnungsnummer is missing). \
472                 Source: BK6-24-174 §5; Allgemeine Festlegungen §8.",
473                None,
474                None,
475                None,
476            ));
477        }
478
479        // ── Stage 2: Period validity ──────────────────────────────────────────
480        Self::check_periods(rechnung, &mut findings);
481
482        // ── Stage 3: Zahlungsziel check ───────────────────────────────────────
483        // DTM+265 (faelligkeitsdatum) must not exceed max_zahlungsziel_days.
484        // Source: §7 Allgemeine Festlegungen V6.1d.
485        if config.max_zahlungsziel_days > 0 {
486            Self::check_zahlungsziel(rechnung, config, &mut findings);
487        }
488
489        // ── Stage 3a: the WiM 31003 send window ───────────────────────────────
490        // Only this PID: the 20 Werktage are WiM Teil 1 Kap. 3.7.2's, and no
491        // other invoice family publishes them.
492        Self::check_wim_dienstleistung_frist(pid, rechnung, &mut findings);
493
494        // ── Stage 4: Currency agreement ───────────────────────────────────────
495        // Before the arithmetic, which would otherwise read a CHF `Betrag` as
496        // if it were EUR and find every later comparison consistent.
497        Self::check_waehrung(rechnung, &mut findings);
498
499        // ── Stage 5: Arithmetic (qty × unit_price ≈ gesamtpreis) ──────────────
500        Self::check_arithmetic(rechnung, config, &mut findings);
501
502        // ── Stage 6: Total consistency (Σ gesamtpreis ≈ gesamtnetto) ──────────
503        let computed_total = Self::check_total(rechnung, config, &mut findings);
504
505        // ── Stage 7: The Umsatzsteuer block ───────────────────────────────────
506        Self::check_steuer(rechnung, config, &mut findings);
507
508        // ── Stage 8: Tariff check (PRICAT vs INVOIC unit price) ───────────────
509        // Skipped for Stornorechnungen: they carry negated original amounts,
510        // not tariff positions. Skipping prevents false TariffDeviation disputes.
511        if !storno {
512            Self::check_tariffs(
513                rechnung,
514                sender_mp_id,
515                preisblatt_store,
516                config,
517                &mut findings,
518            );
519        }
520
521        CheckReport::from_findings(pid, rechnung, findings, computed_total)
522    }
523
524    // ── Stage implementations ──────────────────────────────────────────────────
525
526    /// Stage 3: Validate `faelligkeitsdatum` (Zahlungsziel / DTM+265).
527    ///
528    /// Checks:
529    /// - If `faelligkeitsdatum < rechnungsdatum`: invalid (past due before issued).
530    ///   Produces a `Dispute` finding (`ZahlungszielInvalid`).
531    /// - If `faelligkeitsdatum - rechnungsdatum > max_zahlungsziel_days`:
532    ///   exceeds contractual/regulatory payment term.
533    ///   Produces a `Warn` finding (`ZahlungszielExceeded`).
534    ///
535    /// Source: §7 Allgemeine Festlegungen V6.1d (30 days standard).
536    ///
537    /// # Compared as calendar dates, not timestamps
538    ///
539    /// BO4E types both fields `format: date-time`, but a Zahlungsziel is a
540    /// term in *days*. Senders pin the timestamp to midnight in their own
541    /// offset, so subtracting two `OffsetDateTime`s measures the offsets as well
542    /// as the days: an invoice issued `2026-07-01T00:00+02:00` and due
543    /// `2026-07-31T00:00Z` is 30 calendar days but 30 days *plus two hours*,
544    /// and one issued at `23:00Z` reads as 29. `rechnungsdatum_date()` and
545    /// `faelligkeitsdatum_date()` take the date in the offset the payload
546    /// carries, which is the comparison the rule is about.
547    fn check_zahlungsziel(rechnung: &Rechnung, config: &CheckConfig, findings: &mut Vec<Finding>) {
548        let Some(faellig) = rechnung.faelligkeitsdatum_date() else {
549            return; // DTM+265 absent — not required on all PID types
550        };
551        let Some(rechnungs_datum) = rechnung.rechnungsdatum_date() else {
552            return; // Cannot compute term without invoice date
553        };
554
555        if faellig < rechnungs_datum {
556            findings.push(Finding::dispute(
557                FindingKind::ZahlungszielInvalid,
558                format!(
559                    "Zahlungsziel {faellig} is before invoice date {rechnungs_datum}. \
560                     DTM+265 must not precede rechnungsdatum. \
561                     Source: §7 Allgemeine Festlegungen V6.1d.",
562                ),
563                None,
564                None,
565                None,
566            ));
567            return;
568        }
569
570        let days = (faellig - rechnungs_datum).whole_days();
571        let max = config.max_zahlungsziel_days as i64;
572        if max > 0 && days > max {
573            findings.push(Finding {
574                kind: FindingKind::ZahlungszielExceeded,
575                is_dispute: false, // Warn, not Dispute — give the NB a chance to correct
576                message: format!(
577                    "Zahlungsziel is {days} days (from {rechnungs_datum} to {faellig}), \
578                     exceeding the {max}-day maximum per §7 Allgemeine Festlegungen V6.1d. \
579                     Review before payment.",
580                ),
581                line_number: None,
582                expected: None,
583                actual: None,
584                deviation_pct: Some(days as f64 - max as f64),
585            });
586        }
587    }
588
589    /// The WiM Prüfidentifikator whose send window Kap. 3.7.2 Nr. 1 states.
590    ///
591    /// 31009 is **not** this window: it bills the Messstellenbetrieb and its
592    /// Fristen are Kap. 6.2 / 3.6.3.8.2, which count *back* from the
593    /// Zahlungsziel rather than forward from a period end.
594    const WIM_DIENSTLEISTUNG_PID: u32 = 31_003;
595
596    /// Stage 3a: a WiM-Dienstleistungsrechnung issued too long after the period
597    /// it bills.
598    ///
599    /// WiM Strom Teil 1 Kap. 3.7.2 Nr. 1 gives the sender „unverzüglich, jedoch
600    /// spätester ÜT ist der 20. WT nach" the end of what is being billed. The SD
601    /// names that end four ways — Beendigung der temporären Fortführung des
602    /// Messstellenbetriebes, Überlassung der Einrichtung, Ende des jeweiligen
603    /// Abrechnungszeitraums, Versand der Zusatz-/Kontrollablesung — and a
604    /// recipient cannot tell which Abrechnungsart it holds. All four are the end
605    /// of the billed thing, so the invoice's own `rechnungsperiode` end is the
606    /// anchor for every one of them.
607    ///
608    /// # Why it is a `Warn`
609    ///
610    /// The window binds the **sender**. Kap. 3.7.2 Nr. 2 gives the recipient one
611    /// answer, „zum angegebenen Zahlungsziel", and the trees behind it publish
612    /// no Antwortcode for a late invoice — so a `Dispute` here would refuse a
613    /// document with a reason no REMADV can carry. What lateness does change is
614    /// the recipient's own planning, which is what a warning is for.
615    ///
616    /// # Why the window is read rather than written
617    ///
618    /// `mako_fristen::vorlauf` publishes it as `wim.rechnung-dienstleistungen`
619    /// with its Fundstelle. Restating `20` here would be a second copy of a
620    /// published window with nothing holding the two together — the defect this
621    /// crate exists to catch, one level up.
622    fn check_wim_dienstleistung_frist(pid: u32, rechnung: &Rechnung, findings: &mut Vec<Finding>) {
623        if pid != Self::WIM_DIENSTLEISTUNG_PID {
624            return;
625        }
626        let (Some(period_end), Some(rechnungs_datum)) =
627            (rechnung.period_end(), rechnung.rechnungsdatum_date())
628        else {
629            // No period or no invoice date: stage 2 and the § 14 UStG checks own
630            // those defects. Nothing to measure from is not lateness.
631            return;
632        };
633
634        let obligation = mako_fristen::vorlauf::vorlauf("wim.rechnung-dienstleistungen")
635            .expect("wim.rechnung-dienstleistungen is catalogued in mako_fristen::vorlauf");
636        let werktage = match obligation.shape {
637            mako_fristen::vorlauf::VorlaufShape::LatestWerktageAfter(n) => n,
638            other => unreachable!("the catalogued shape is LatestWerktageAfter, not {other:?}"),
639        };
640        let spaetester = mako_fristen::add_werktage(
641            period_end,
642            werktage,
643            mako_fristen::HolidayCalendar::BdewMaKo,
644        );
645        if rechnungs_datum <= spaetester {
646            return;
647        }
648        let ueberschritten = mako_fristen::werktage_between(
649            spaetester,
650            rechnungs_datum,
651            mako_fristen::HolidayCalendar::BdewMaKo,
652        );
653        findings.push(Finding::warn(
654            FindingKind::RechnungZuSpaet,
655            format!(
656                "Rechnungsdatum {rechnungs_datum} liegt {ueberschritten} Werktage nach dem \
657                 spätesten ÜT {spaetester} ({werktage} WT nach dem Ende des \
658                 Abrechnungszeitraums {period_end}) — WiM Teil 1 Kap. 3.7.2 Nr. 1",
659            ),
660            None,
661            None,
662            None,
663        ));
664    }
665
666    /// Stage 2: Verify that every billing period is orientated forwards.
667    ///
668    /// **The two periods on a `Rechnung` use different interval conventions, and
669    /// the check differs accordingly.** BO4E is not uniform here:
670    ///
671    /// | Field | Kind | Interval | Invalid when |
672    /// |---|---|---|---|
673    /// | `rechnungsperiode` | `Zeitraum` **date** pair | `[start, end]` — „Enddatum … ist **inklusiv**" | `start > end` |
674    /// | `lieferungszeitraum` `von` / `bis` | **date-time** pair | `[start, end)` | `start >= end` |
675    ///
676    /// So a Rechnungsperiode with `start == end` is a legitimate **one-day**
677    /// period — the most common shape in a daily-granularity payload — while a
678    /// Lieferung with `von == bis` is an empty interval.
679    fn check_periods(rechnung: &Rechnung, findings: &mut Vec<Finding>) {
680        // Message-level period (Rechnungsperiode) — inclusive end.
681        if let Some(period) = rechnung.billing_period() {
682            let (start, end) = (*period.start(), *period.end());
683            if start > end {
684                findings.push(Finding::dispute(
685                    FindingKind::PeriodInvalid,
686                    format!("Message-level billing period invalid: start {start} > end {end}"),
687                    None,
688                    None,
689                    None,
690                ));
691            }
692        }
693        // Line-level periods (Lieferung von/bis) — half-open, so an empty
694        // interval is invalid too.
695        for pos in rechnung.rechnungspositionen.iter().flatten() {
696            if let (Some(start), Some(end)) = (pos.lieferung_von_date(), pos.lieferung_bis_date())
697                && start >= end
698            {
699                let (line_no, malo) = pos_ident(pos);
700                findings.push(Finding::dispute(
701                    FindingKind::PeriodInvalid,
702                    format!(
703                        "Line {line_no} ({malo}) billing period invalid: start {start} ≥ end {end}"
704                    ),
705                    Some(line_no),
706                    None,
707                    None,
708                ));
709            }
710        }
711    }
712
713    /// Stage 5: For each position with quantity + unit_price, verify
714    /// `positions_menge × einzelpreis ≈ gesamtpreis` (BO4E v202607).
715    ///
716    /// Uses `billing::Amount::checked_sub` + `checked_mul_qty` — no `f64`
717    /// intermediate — satisfying the §40 EnWG itemised-billing accuracy requirement.
718    /// One currency across every monetary field on the document.
719    ///
720    /// BO4E does not state this as a sentence of its own; it is the premise of
721    /// the two sums it *does* state (`gesamtbrutto` is „Die Summe aus Netto-
722    /// und Steuerbetrag", `steuerbetraege` sum to `gesamtsteuer`) — amounts
723    /// denominated differently have no sum.
724    ///
725    /// A **dispute**, not a warning: the checker reads every amount as an
726    /// [`EuroAmount`], so a mixed-currency document does not fail any later
727    /// comparison. It passes them, wrongly.
728    fn check_waehrung(rechnung: &Rechnung, findings: &mut Vec<Finding>) {
729        // Every field that names a currency, not only the document-level totals.
730        // A position denominated differently from the header is read as EUR by
731        // every later stage exactly as a header field would be, and it is the
732        // positions that carry the arithmetic.
733        let mut fields: Vec<(String, rubo4e::current::Waehrungscode)> = [
734            ("gesamtnetto", &rechnung.gesamtnetto),
735            ("gesamtsteuer", &rechnung.gesamtsteuer),
736            ("gesamtbrutto", &rechnung.gesamtbrutto),
737            ("rabattNetto", &rechnung.rabatt_netto),
738            ("zuZahlen", &rechnung.zu_zahlen),
739        ]
740        .into_iter()
741        .filter_map(|(f, b)| {
742            b.as_ref()
743                .and_then(|b| b.waehrung)
744                .map(|c| (f.to_owned(), c))
745        })
746        .collect();
747        for pos in rechnung.rechnungspositionen.iter().flatten() {
748            let (line_no, _) = pos_ident(pos);
749            if let Some(code) = pos.gesamtpreis.as_ref().and_then(|b| b.waehrung) {
750                fields.push((format!("Line {line_no} gesamtpreis"), code));
751            }
752        }
753        for (i, b) in rechnung.steuerbetraege.iter().flatten().enumerate() {
754            if let Some(code) = b.waehrungscode {
755                fields.push((format!("Steuerbetrag {i}"), code));
756            }
757        }
758
759        let mut first: Option<(String, rubo4e::current::Waehrungscode)> = None;
760        for (field, code) in fields {
761            match &first {
762                None => {}
763                Some((first_field, first_code)) if *first_code != code => {
764                    findings.push(Finding::dispute(
765                        FindingKind::WaehrungMismatch,
766                        format!(
767                            "{first_field} is denominated in {} but {field} in {} — \
768                             amounts in different currencies have no sum, and every \
769                             figure below is read as EUR.",
770                            first_code.as_wire(),
771                            code.as_wire()
772                        ),
773                        None,
774                        None,
775                        None,
776                    ));
777                    return;
778                }
779                Some(_) => {}
780            }
781            if first.is_none() {
782                first = Some((field, code));
783            }
784        }
785    }
786
787    fn check_arithmetic(rechnung: &Rechnung, config: &CheckConfig, findings: &mut Vec<Finding>) {
788        for pos in rechnung.rechnungspositionen.iter().flatten() {
789            let qty = pos.positions_menge.wert_decimal();
790            let price = pos.einzelpreis.wert_decimal().and_then(euro_from_decimal);
791            let stated_net = pos.gesamtpreis.wert_decimal().and_then(euro_from_decimal);
792
793            if let (Some(qty), Some(price), Some(stated_net)) = (qty, price, stated_net) {
794                let (line_no, malo) = pos_ident(pos);
795                // The quantity comes off the wire and nothing has range-checked
796                // it — the price has been through `euro_from_decimal`, the
797                // quantity has not. `mul_qty` panics on a product outside the
798                // representable range, so a counterparty document stating
799                // `menge = 1e15` would take down the request that validates it.
800                // An unrepresentable product is a finding about the document,
801                // not a fault in the checker.
802                let Ok(computed) = price.checked_mul_qty(qty) else {
803                    findings.push(Finding {
804                        kind: FindingKind::ArithmeticError,
805                        is_dispute: true,
806                        message: format!(
807                            "Line {line_no} ({malo}): {qty} × {price} EUR is not a \
808                             representable amount — the stated quantity or unit price is \
809                             out of range, so the position cannot be checked or paid",
810                        ),
811                        line_number: Some(line_no),
812                        expected: None,
813                        actual: Some(stated_net),
814                        deviation_pct: None,
815                    });
816                    continue;
817                };
818                if !stated_net.within_tolerance_ppm(computed, config.arithmetic_tolerance_ppm) {
819                    findings.push(Finding {
820                        kind: FindingKind::ArithmeticError,
821                        is_dispute: true,
822                        message: format!(
823                            "Line {line_no} ({malo}): \
824                             {qty} kWh × {price} EUR/kWh = {computed} EUR, \
825                             but Rechnungsposition states {stated_net} EUR",
826                        ),
827                        line_number: Some(line_no),
828                        expected: Some(computed),
829                        actual: Some(stated_net),
830                        deviation_pct: deviation(Some(computed), Some(stated_net)),
831                    });
832                }
833            }
834        }
835    }
836
837    /// Stage 6: Verify Σ `gesamtpreis` ≈ `gesamtnetto`.
838    ///
839    /// Returns the computed sum (used in the `CheckReport`).
840    /// Stage 7: the Umsatzsteuer block.
841    ///
842    /// §14 Abs. 4 Nr. 8 UStG makes the rate and the tax amount mandatory content
843    /// — or, where the supply is not taxed by the issuer, a note saying so. An
844    /// invoice without either is one the recipient cannot deduct, so this is a
845    /// **dispute**: paying it means paying tax that cannot be recovered.
846    ///
847    /// The arithmetic is checked too. `gesamtbrutto` is what is actually owed,
848    /// and an invoice whose parts do not sum to its whole is the one error
849    /// nobody catches by reading it.
850    fn check_steuer(rechnung: &Rechnung, config: &CheckConfig, findings: &mut Vec<Finding>) {
851        let netto = rechnung
852            .gesamtnetto
853            .wert_decimal()
854            .and_then(euro_from_decimal);
855        let steuer = rechnung
856            .gesamtsteuer
857            .wert_decimal()
858            .and_then(euro_from_decimal);
859        let brutto = rechnung
860            .gesamtbrutto
861            .wert_decimal()
862            .and_then(euro_from_decimal);
863        let breakdown = rechnung.steuerbetraege.as_deref().unwrap_or_default();
864
865        // A reverse charge states no tax by design, so its absence is only a
866        // defect when nothing explains it.
867        let reverse_charge = breakdown
868            .iter()
869            .any(|b| b.steuerart == Some(rubo4e::current::Steuerart::Rcv));
870
871        // Stating `0` is not the same as stating nothing — but with no breakdown
872        // and no reverse-charge entry it carries no *ground* either, and
873        // §14 Abs. 4 Nr. 8 UStG wants the rate and amount **or** the note that
874        // the recipient owes the tax. A Kleinunternehmer invoice (§19 UStG) may
875        // legitimately show zero and carry its ground in free text, which is why
876        // this is a Warning rather than a Dispute: refusing it would reject a
877        // lawful invoice, while staying silent — as this did — hides the one
878        // remaining shape of "states no Umsatzsteuer" that reached acceptance.
879        if steuer == Some(EuroAmount::ZERO) && breakdown.is_empty() {
880            findings.push(Finding::warn(
881                FindingKind::SteuerMissing,
882                "The invoice states 0,00 EUR Umsatzsteuer with no Steuerbetrag \
883                 breakdown and no reverse-charge entry, so it names no ground for \
884                 the exemption. §14 Abs. 4 Nr. 8 UStG requires the rate and amount \
885                 or a note that the recipient owes the tax; if the ground is stated \
886                 only in free text, the document is complete but this check cannot \
887                 see it.",
888                None,
889                None,
890                None,
891            ));
892        }
893
894        if steuer.is_none() && breakdown.is_empty() {
895            findings.push(Finding::dispute(
896                FindingKind::SteuerMissing,
897                "The invoice states no Umsatzsteuer and no Steuerbetrag breakdown. \
898                 §14 Abs. 4 Nr. 8 UStG requires the rate and the amount, or a note \
899                 that the recipient owes the tax — without either there is no \
900                 Vorsteuerabzug.",
901                None,
902                None,
903                None,
904            ));
905            return;
906        }
907
908        if reverse_charge
909            && let Some(steuer) = steuer
910            && steuer != EuroAmount::ZERO
911        {
912            findings.push(Finding::dispute(
913                FindingKind::ReverseChargeStatesTax,
914                format!(
915                    "The invoice is reverse-charged (§13b UStG) and states {steuer} EUR of \
916                     Umsatzsteuer anyway. That tax is owed under §14c Abs. 1 UStG and is \
917                     still not deductible, because the recipient owes it too."
918                ),
919                None,
920                Some(EuroAmount::ZERO),
921                Some(steuer),
922            ));
923        }
924
925        if let (Some(netto), Some(steuer), Some(brutto)) = (netto, steuer, brutto)
926            && !brutto.within_tolerance_ppm(netto + steuer, config.total_tolerance_ppm)
927        {
928            findings.push(Finding::dispute(
929                FindingKind::SteuerMismatch,
930                format!(
931                    "gesamtbrutto = {brutto} EUR, but gesamtnetto + gesamtsteuer = {} EUR",
932                    netto + steuer
933                ),
934                None,
935                Some(netto + steuer),
936                Some(brutto),
937            ));
938        }
939
940        // **The breakdown must add up to the total it breaks down.**
941        //
942        // BO4E states this rule outright („die Summe dieser Beträge ergibt den
943        // Wert für gesamtsteuer") and enforces it nowhere: `rubo4e` ships a
944        // validator for it behind a feature mako does not enable, and no
945        // reference implementation runs it. This check verified
946        // `netto + steuer == brutto` and never looked inside `steuerbetraege`. The two figures are read by different
947        // parties for different purposes — the recipient computes its
948        // Vorsteuerabzug from the per-rate breakdown (§14 Abs. 4 Nr. 8 UStG,
949        // §15 Abs. 1) and pays from the total — so an invoice stating 19 % on
950        // 50 EUR and 7 % on 10 EUR while `gesamtsteuer` says 100 EUR is
951        // internally consistent to neither of them, and passed.
952        //
953        // Skipped when the breakdown is absent: its absence is already a
954        // `SteuerMissing` finding above, and a reverse-charged invoice states
955        // no amounts by design.
956        if let Some(steuer) = steuer
957            && !breakdown.is_empty()
958            && !reverse_charge
959        {
960            let summed = breakdown
961                .iter()
962                .filter_map(|b| b.steuerwert.and_then(euro_from_decimal))
963                .fold(EuroAmount::ZERO, |acc, v| acc + v);
964            if !steuer.within_tolerance_ppm(summed, config.total_tolerance_ppm) {
965                findings.push(Finding::dispute(
966                    FindingKind::SteuerMismatch,
967                    format!(
968                        "gesamtsteuer = {steuer} EUR, but the {} Steuerbetrag entries \
969                         sum to {summed} EUR. §14 Abs. 4 Nr. 8 UStG makes the per-rate \
970                         breakdown the basis of the recipient's Vorsteuerabzug, so it \
971                         must agree with the total it is a breakdown of.",
972                        breakdown.len()
973                    ),
974                    None,
975                    Some(summed),
976                    Some(steuer),
977                ));
978            }
979        }
980
981        // **The rate must produce the amount it is stated beside.**
982        //
983        // §14 Abs. 4 Nr. 8 UStG makes „der anzuwendende Steuersatz sowie der
984        // auf das Entgelt entfallende Steuerbetrag" mandatory content, and the
985        // recipient's Vorsteuerabzug is the second figure while the tax office
986        // reads the first. Checking that the breakdown sums to `gesamtsteuer`
987        // does not reach this: an invoice stating 19 % on a base of 10 000 with
988        // a Steuerwert of 100 sums to its own total perfectly, so
989        // `netto + steuer = brutto` holds, the breakdown agrees with
990        // `gesamtsteuer`, and 1 800 EUR of tax is neither charged nor
991        // deductible.
992        //
993        // A **dispute**: paying it books a Vorsteuer the invoice does not
994        // support, and the difference is recoverable from nobody.
995        for (i, b) in breakdown.iter().enumerate() {
996            if b.steuerart == Some(rubo4e::current::Steuerart::Rcv) {
997                continue;
998            }
999            let (Some(basis), Some(satz), Some(stated)) = (
1000                b.basiswert.and_then(euro_from_decimal),
1001                b.steuersatz,
1002                b.steuerwert.and_then(euro_from_decimal),
1003            ) else {
1004                continue;
1005            };
1006            // The rate arrives off the wire unchecked, so the product is taken
1007            // in the checked form rather than panicking the request.
1008            let Ok(computed) = basis
1009                .checked_mul_qty(satz)
1010                .and_then(|x| x.checked_div(rust_decimal::Decimal::ONE_HUNDRED))
1011            else {
1012                findings.push(Finding::dispute(
1013                    FindingKind::SteuerMismatch,
1014                    format!(
1015                        "Steuerbetrag {i}: {satz} % of {basis} EUR is not a representable \
1016                         amount — the stated rate or base is out of range."
1017                    ),
1018                    None,
1019                    None,
1020                    Some(stated),
1021                ));
1022                continue;
1023            };
1024            // § 14 UStG amounts are stated in whole cents, so the lawful figure
1025            // is the rounded one and a deviation below the rounding unit is not
1026            // a defect. A relative tolerance alone cannot express that: 19 % of
1027            // one cent is 0,19 cent, which rounds to 0,00 — correct, and 100 %
1028            // away from the unrounded product. One cent is therefore the floor,
1029            // and it is negligible against the base a real breakdown carries.
1030            // Taken in `i128`: both operands are independently valid amounts,
1031            // so their difference can leave `i64`.
1032            const ONE_CENT_RAW: i128 = 1_000;
1033            let within_a_cent =
1034                (i128::from(stated.to_raw()) - i128::from(computed.to_raw())).abs() <= ONE_CENT_RAW;
1035            if !within_a_cent && !stated.within_tolerance_ppm(computed, config.total_tolerance_ppm)
1036            {
1037                findings.push(Finding::dispute(
1038                    FindingKind::SteuerMismatch,
1039                    format!(
1040                        "Steuerbetrag {i}: {satz} % of a basiswert of {basis} EUR is \
1041                         {computed} EUR, but the entry states {stated} EUR. §14 Abs. 4 Nr. 8 \
1042                         UStG makes the rate and the amount it produces both mandatory, and \
1043                         the recipient deducts the amount."
1044                    ),
1045                    None,
1046                    Some(computed),
1047                    Some(stated),
1048                ));
1049            }
1050        }
1051    }
1052
1053    fn check_total(
1054        rechnung: &Rechnung,
1055        config: &CheckConfig,
1056        findings: &mut Vec<Finding>,
1057    ) -> Option<EuroAmount> {
1058        let line_nets: Vec<EuroAmount> = rechnung
1059            .rechnungspositionen
1060            .iter()
1061            .flatten()
1062            .filter_map(|pos| pos.gesamtpreis.wert_decimal().and_then(euro_from_decimal))
1063            .collect();
1064
1065        if line_nets.is_empty() {
1066            return None;
1067        }
1068
1069        let computed = line_nets
1070            .iter()
1071            .copied()
1072            .fold(EuroAmount::ZERO, |acc, a| acc + a);
1073
1074        if let Some(stated) = rechnung
1075            .gesamtnetto
1076            .wert_decimal()
1077            .and_then(euro_from_decimal)
1078            && !stated.within_tolerance_ppm(computed, config.total_tolerance_ppm)
1079        {
1080            findings.push(Finding::warn(
1081                FindingKind::TotalMismatch,
1082                format!(
1083                    "Total net mismatch: \u{03a3} gesamtpreis = {computed} EUR, \
1084                     gesamtnetto = {stated} EUR",
1085                ),
1086                None,
1087                Some(computed),
1088                Some(stated),
1089            ));
1090        }
1091
1092        Some(computed)
1093    }
1094
1095    /// Stage 8: Compare `einzelpreis` against the tariff store (PRICAT 27003).
1096    fn check_tariffs(
1097        rechnung: &Rechnung,
1098        sender_mp_id: &str,
1099        preisblatt_store: &dyn PreisblattStore,
1100        config: &CheckConfig,
1101        findings: &mut Vec<Finding>,
1102    ) {
1103        // Use billing_period() start or fall back to the invoice document date.
1104        // Both are native time::Date in rubo4e v0.5.
1105        let billing_date: time::Date = rechnung
1106            .billing_period()
1107            .map(|p| *p.start())
1108            .or_else(|| rechnung.rechnungsdatum_date())
1109            .unwrap_or_else(mako_fristen::heute);
1110
1111        if !preisblatt_store.has_preisblatt_for(sender_mp_id) {
1112            findings.push(Finding {
1113                kind: FindingKind::TariffNotFound,
1114                is_dispute: config.require_tariff,
1115                message: format!(
1116                    "No PRICAT tariff found for sender GLN {sender_mp_id} on {billing_date}. \
1117                     Tariff check skipped — seed the tariff store from PRICAT 27003.",
1118                ),
1119                line_number: None,
1120                expected: None,
1121                actual: None,
1122                deviation_pct: None,
1123            });
1124            return;
1125        }
1126
1127        for pos in rechnung.rechnungspositionen.iter().flatten() {
1128            let Some(invoic_price) = pos.einzelpreis.wert_decimal().and_then(euro_from_decimal)
1129            else {
1130                continue;
1131            };
1132            let (line_no, malo) = pos_ident(pos);
1133            // lieferung_von_date() reads lieferungszeitraum.startdatum (v202607).
1134            let line_date = pos.lieferung_von_date().unwrap_or(billing_date);
1135
1136            let Some(preisblatt) = preisblatt_store.get(sender_mp_id, line_date) else {
1137                findings.push(Finding::warn(
1138                    FindingKind::TariffNotFound,
1139                    format!(
1140                        "Line {line_no} ({malo}): no Preisblatt effective on {line_date} \
1141                         for GLN {sender_mp_id}",
1142                    ),
1143                    Some(line_no),
1144                    None,
1145                    Some(invoic_price),
1146                ));
1147                continue;
1148            };
1149
1150            // Collect published prices split into flat and ToU (§14a Modul 2) sets.
1151            //
1152            // - `flat_prices`: prices from `Preisposition.preisstaffeln`
1153            //   (flat Arbeitspreis, Leistungspreis, Grundpreis)
1154            // - `tou_prices`: prices from `zeitvariablePreispositionen` extension
1155            //   (HT/NT band prices per §14a Modul 3, BK8-22/010-A Tenor 3.)
1156            //
1157            // ToU-aware matching (L3):
1158            //   • Position text contains "HT" (Hochlast/Hochtarif) → only `tou_prices`
1159            //   • Position text contains "NT" (Niedertarif) → only `tou_prices`
1160            //   • All others → `flat_prices` (primary) then fallback to all prices
1161            //
1162            // This prevents a ToU-banded NB INVOIC from accidentally passing
1163            // plausibility when a flat band price coincidentally equals a ToU rate.
1164            let tol = config.tariff_tolerance_ppm;
1165
1166            // **The Staffel that applies to this quantity, not every Staffel.**
1167            //
1168            // A Preisposition states its price in tiers — `0 – 1000 → 0.30`,
1169            // `1001 – 2000 → 0.25`, `2001+ → 0.20`. Collecting every tier's price
1170            // and asking whether the invoice matches *any* of them ignores the
1171            // bounds completely: it accepted a 500 kWh position billed at the
1172            // 2001+ rate, which is the cheapest tier applied to the smallest
1173            // quantity and exactly the deviation this check exists to catch.
1174            //
1175            // `select_for` picks the tier by the position's own quantity and
1176            // implements BO4E's gap rule with it — the schema states bounds as
1177            // `0 – 1000, 1001 – 2000` and rules that a value *between* two tiers
1178            // („1000.6") *„rutscht in die obere Zone"*, which a plain
1179            // `von <= x <= bis` scan finds no tier for at all.
1180            //
1181            // Without a quantity there is no tier to select, so the check falls
1182            // back to every published price — permissive, but it only widens
1183            // what is accepted and never invents a deviation.
1184            use rubo4e::convenience::PreisstaffelSliceExt as _;
1185            let flat_prices: Vec<EuroAmount> = match pos.positions_menge.wert_decimal() {
1186                Some(menge) => preisblatt
1187                    .preispositionen
1188                    .iter()
1189                    .flatten()
1190                    .filter_map(|pp| {
1191                        pp.preisstaffeln
1192                            .as_deref()
1193                            .and_then(|staffeln| staffeln.select_for(menge))
1194                    })
1195                    .filter_map(|ps| ps.preis)
1196                    .filter_map(euro_from_decimal)
1197                    .collect(),
1198                None => preisblatt
1199                    .preispositionen
1200                    .iter()
1201                    .flatten()
1202                    .flat_map(|pp| pp.preisstaffeln.iter().flatten())
1203                    .filter_map(|ps| ps.preis)
1204                    .filter_map(euro_from_decimal)
1205                    .collect(),
1206            };
1207
1208            // Extract (zaehlzeitregister, price) pairs from zeitvariablePreispositionen.
1209            // Band codes are validated on PUT (M5) — every entry has a non-empty register.
1210            use rubo4e::json::Bo4eExtensionData as _;
1211            let tou_bands: Vec<(String, EuroAmount)> = preisblatt
1212                .extension_data()
1213                .get("zeitvariablePreispositionen")
1214                .and_then(|v| v.as_array())
1215                .map(|arr| {
1216                    arr.iter()
1217                        .filter_map(|entry| {
1218                            let register = entry
1219                                .get("zaehlzeitregister")
1220                                .and_then(|v| v.as_str())
1221                                .unwrap_or("")
1222                                .to_owned();
1223                            let price_val = entry
1224                                .get("preis")
1225                                .and_then(|p| p.get("wert"))
1226                                .and_then(|w| w.as_str())
1227                                .and_then(|s| rust_decimal::Decimal::from_str_exact(s).ok())
1228                                .and_then(euro_from_decimal)?;
1229                            Some((register, price_val))
1230                        })
1231                        .collect()
1232                })
1233                .unwrap_or_default();
1234
1235            // Determine which band(s) apply to this INVOIC position.
1236            // 1. Try direct `zaehlzeitregister` match (case-insensitive contains).
1237            // Match position text against published `zaehlzeitregister` band codes.
1238            let pos_text = pos.positionstext.as_deref().unwrap_or("").to_lowercase();
1239
1240            let matching_band_prices: Vec<EuroAmount> = tou_bands
1241                .iter()
1242                .filter(|(code, _)| {
1243                    let code_lc = code.to_lowercase();
1244                    !code_lc.is_empty() && pos_text.contains(code_lc.as_str())
1245                })
1246                .map(|(_, price)| *price)
1247                .collect();
1248
1249            let all_tou_prices: Vec<EuroAmount> = tou_bands.iter().map(|(_, p)| *p).collect();
1250
1251            let published: Vec<EuroAmount> = if !matching_band_prices.is_empty() {
1252                // Direct zaehlzeitregister match — most precise.
1253                matching_band_prices
1254            } else if !flat_prices.is_empty() {
1255                // No matching band: use flat prices.
1256                flat_prices.clone()
1257            } else {
1258                // No flat prices — fall back to all ToU band prices.
1259                all_tou_prices
1260            };
1261
1262            if published.is_empty() {
1263                findings.push(Finding::warn(
1264                    FindingKind::TariffNotFound,
1265                    format!(
1266                        "Line {line_no} ({malo}): Preisblatt for GLN {sender_mp_id} \
1267                         on {line_date} contains no Preisstaffeln — skipping price check",
1268                    ),
1269                    Some(line_no),
1270                    None,
1271                    Some(invoic_price),
1272                ));
1273                continue;
1274            }
1275
1276            if !published
1277                .iter()
1278                .any(|p| invoic_price.within_tolerance_ppm(*p, tol))
1279            {
1280                // Report the closest published rate for diagnostics.
1281                // The distance is taken in i128: two valid `Amount<5>` values
1282                // can sit at opposite ends of the i64 range.
1283                let closest = *published
1284                    .iter()
1285                    .min_by_key(|p| {
1286                        (i128::from(invoic_price.to_raw()) - i128::from(p.to_raw())).unsigned_abs()
1287                    })
1288                    .unwrap_or(&EuroAmount::ZERO);
1289                findings.push(Finding::dispute(
1290                    FindingKind::TariffDeviation,
1291                    format!(
1292                        "Line {line_no} ({malo}): einzelpreis {invoic_price} EUR/kWh \
1293                         does not match any published rate in Preisblatt for GLN {sender_mp_id} \
1294                         on {line_date} (closest: {closest} EUR/kWh, tolerance {pct:.1}%)",
1295                        pct = tol as f64 / 10_000.0,
1296                    ),
1297                    Some(line_no),
1298                    Some(closest),
1299                    Some(invoic_price),
1300                ));
1301            }
1302        }
1303    }
1304
1305    // ── The ESA's price basis is its own accepted Angebot ────────────────────
1306
1307    /// Check an INVOIC 31009 against the **Angebot the ESA accepted**.
1308    ///
1309    /// # Why an ESA cannot use the Preisblatt path
1310    ///
1311    /// [`check_msb_rechnung`](Self::check_msb_rechnung) compares against
1312    /// `PreisblattMessung` — the price sheet an MSB publishes toward the NB and
1313    /// the LF. **An ESA has none**: there is no published sheet for the
1314    /// Kapitel-4.6 Messprodukte, because §35 MsbG leaves the Entgelt for a
1315    /// Zusatzleistung to be agreed per request.
1316    ///
1317    /// Its basis is the offer it accepted. UC 4.1.1 has the ESA asking for „die
1318    /// Übermittlung von Werten **und die damit verbundenen Kosten**"; QUOTES AHB
1319    /// 1.1a §4.3 makes `SG4 CUX` and one `SG31 PRI+CAL` per `SG27 PIA+Z02`
1320    /// Artikel-ID **Muss**; and the offer carries a Bindungsfrist because it
1321    /// binds. The invoice names the same Artikel-IDs back (`SG26 LIN` DE 7143
1322    /// `Z09`, INVOIC AHB 1.0b), so the two join exactly rather than by a
1323    /// plausibility band.
1324    ///
1325    /// # What it reports
1326    ///
1327    /// - a position whose `einzelpreis` deviates from the agreed one beyond
1328    ///   `tariff_tolerance_ppm` → [`FindingKind::AngebotDeviation`], a dispute;
1329    /// - a position naming an Artikel-ID the offer never priced →
1330    ///   [`FindingKind::AngebotPositionUnknown`], a dispute — a charge the ESA
1331    ///   did not agree to;
1332    /// - a position carrying **no** Artikel-ID → skipped with a warning. DE 7143
1333    ///   admits `Z01` Artikelnummer as well as `Z09` Artikel-ID, and an
1334    ///   Artikelnummer names no offer position.
1335    ///
1336    /// `agreed` is the accepted offer as `(Artikel-ID, price)` pairs. Empty
1337    /// means no accepted offer is on record: the checks are **skipped with a
1338    /// warning**, never disputed, because absence is a gap in mako's own
1339    /// records rather than a defect in the MSB's invoice.
1340    #[must_use]
1341    pub fn check_esa_rechnung(
1342        sender_mp_id: &str,
1343        rechnung: &Rechnung,
1344        agreed: &[(String, EuroAmount)],
1345        config: &CheckConfig,
1346    ) -> CheckReport {
1347        let mut findings = Vec::new();
1348
1349        // The structural checks are the same invoice arithmetic as everywhere
1350        // else; only the price basis differs.
1351        Self::check_periods(rechnung, &mut findings);
1352        Self::check_waehrung(rechnung, &mut findings);
1353        Self::check_arithmetic(rechnung, config, &mut findings);
1354        let computed_total = Self::check_total(rechnung, config, &mut findings);
1355        Self::check_zahlungsziel(rechnung, config, &mut findings);
1356        Self::check_steuer(rechnung, config, &mut findings);
1357
1358        Self::check_against_angebot(rechnung, sender_mp_id, agreed, config, &mut findings);
1359
1360        CheckReport::from_findings(31009, rechnung, findings, computed_total)
1361    }
1362
1363    /// The price comparison of [`check_esa_rechnung`], split out so the
1364    /// Preisblatt path and the Angebot path cannot drift into one another.
1365    fn check_against_angebot(
1366        rechnung: &Rechnung,
1367        sender_mp_id: &str,
1368        agreed: &[(String, EuroAmount)],
1369        config: &CheckConfig,
1370        findings: &mut Vec<Finding>,
1371    ) {
1372        if agreed.is_empty() {
1373            findings.push(Finding {
1374                kind: FindingKind::TariffNotFound,
1375                // Never a dispute: mako not holding the accepted offer says
1376                // nothing about whether the MSB billed correctly.
1377                is_dispute: false,
1378                message: format!(
1379                    "No accepted Angebot on record for MSB {sender_mp_id}. The ESA price basis \
1380                     is the QUOTES 15003 the ESA ordered against (§35 MsbG — there is no \
1381                     published Preisblatt for Kapitel-4.6 Messprodukte), so the price check is \
1382                     skipped."
1383                ),
1384                line_number: None,
1385                expected: None,
1386                actual: None,
1387                deviation_pct: None,
1388            });
1389            return;
1390        }
1391
1392        let tol = config.tariff_tolerance_ppm;
1393        for pos in rechnung.rechnungspositionen.iter().flatten() {
1394            let (line_no, text) = pos_ident(pos);
1395            let Some(invoiced) = pos.einzelpreis.wert_decimal().and_then(euro_from_decimal) else {
1396                continue;
1397            };
1398            // DE 7143 admits `Z01` Artikelnummer beside `Z09` Artikel-ID, and an
1399            // Artikelnummer names no offer position — so a position without an
1400            // Artikel-ID is not comparable rather than wrong.
1401            let Some(artikel_id) = pos.artikel_id.as_deref().filter(|a| !a.is_empty()) else {
1402                findings.push(Finding::warn(
1403                    FindingKind::TariffNotFound,
1404                    format!(
1405                        "Line {line_no} ({text}): no Artikel-ID, so the position cannot be \
1406                         matched to the accepted Angebot — price check skipped for this line"
1407                    ),
1408                    Some(line_no),
1409                    None,
1410                    Some(invoiced),
1411                ));
1412                continue;
1413            };
1414
1415            let Some((_, expected)) = agreed.iter().find(|(id, _)| id == artikel_id) else {
1416                findings.push(Finding::dispute(
1417                    FindingKind::AngebotPositionUnknown,
1418                    format!(
1419                        "Line {line_no} ({text}): Artikel-ID {artikel_id} was never priced in \
1420                         the Angebot this subscription was ordered against — the ESA did not \
1421                         agree to this charge"
1422                    ),
1423                    Some(line_no),
1424                    None,
1425                    Some(invoiced),
1426                ));
1427                continue;
1428            };
1429
1430            if !invoiced.within_tolerance_ppm(*expected, tol) {
1431                findings.push(Finding::dispute(
1432                    FindingKind::AngebotDeviation,
1433                    format!(
1434                        "Line {line_no} ({text}): Artikel-ID {artikel_id} billed at {invoiced} \
1435                         EUR, but the accepted Angebot from MSB {sender_mp_id} priced it at \
1436                         {expected} EUR (tolerance {pct:.1}%)",
1437                        pct = f64::from(tol) / 10_000.0,
1438                    ),
1439                    Some(line_no),
1440                    Some(*expected),
1441                    Some(invoiced),
1442                ));
1443            }
1444        }
1445    }
1446
1447    // ── The MSB price basis is `PreisblattMessung` ──────────────────────────
1448
1449    /// Check a WiM MSB-Rechnung (PID 31003 / 31009) against `PreisblattMessung`.
1450    ///
1451    /// Replaces the standard [`check`](Self::check) call for those PIDs. The
1452    /// only difference is the price basis: the document stages — period,
1453    /// currency, position arithmetic, document total, Zahlungsziel and
1454    /// Umsatzsteuer — run identically, and the tariff comparison reads
1455    /// `PreisblattMessung.preispositionen` instead of
1456    /// `PreisblattNetznutzung.preispositionen`.
1457    ///
1458    /// `PreisblattMessung` has `preispositionen: Option<Vec<Preisposition>>` — the same type
1459    /// as `PreisblattNetznutzung` — so the price extraction logic is identical.
1460    ///
1461    /// When `preisblatt_messung` is `None`, the tariff comparison emits a
1462    /// warning (never a hard dispute) to match the standard engine's
1463    /// missing-tariff behaviour.
1464    #[must_use]
1465    pub fn check_msb_rechnung(
1466        pid: u32,
1467        sender_mp_id: &str,
1468        rechnung: &Rechnung,
1469        preisblatt_messung: Option<&rubo4e::current::PreisblattMessung>,
1470        config: &CheckConfig,
1471    ) -> CheckReport {
1472        Self::check_msb_rechnung_with_aufabschlaege(
1473            pid,
1474            sender_mp_id,
1475            rechnung,
1476            preisblatt_messung,
1477            &[],
1478            config,
1479        )
1480    }
1481
1482    /// MSB-Rechnung (INVOIC 31003 / 31009) plausibility check with
1483    /// `AufAbschlag` validation.
1484    ///
1485    /// Runs the document stages of [`check`](Self::check) — period, currency,
1486    /// position arithmetic, document total, Zahlungsziel and Umsatzsteuer —
1487    /// prices against `PreisblattMessung` instead of `PreisblattNetznutzung`,
1488    /// and adds one check of its own:
1489    ///
1490    /// | # | Check | Source |
1491    /// |---|---|---|
1492    /// | — | Discount/surcharge positions are backed by a contracted `AufAbschlag` | WiM PRICAT 27001–27003 |
1493    ///
1494    /// `contracted_names` is the list of contracted AufAbschlag names from
1495    /// `PreisblattMessungRecord.auf_abschlaege` (pre-extracted by the caller).
1496    /// Pass `&[]` when absent (check 6 is then skipped, not disputed).
1497    pub fn check_msb_rechnung_with_aufabschlaege(
1498        pid: u32,
1499        sender_mp_id: &str,
1500        rechnung: &Rechnung,
1501        preisblatt_messung: Option<&rubo4e::current::PreisblattMessung>,
1502        contracted_names: &[String],
1503        config: &CheckConfig,
1504    ) -> CheckReport {
1505        let mut findings = Vec::new();
1506
1507        // The document-level stages are identical to the standard pipeline:
1508        // period, currency, position arithmetic and document total.
1509        Self::check_periods(rechnung, &mut findings);
1510        Self::check_waehrung(rechnung, &mut findings);
1511        Self::check_arithmetic(rechnung, config, &mut findings);
1512        let computed_total = Self::check_total(rechnung, config, &mut findings);
1513
1514        // **The Zahlungsziel and the Umsatzsteuer block are checked here too.**
1515        //
1516        // They were not, and the omission was accidental rather than a
1517        // judgement about MSB invoices: this entry point was written when the
1518        // pipeline had five stages, and the two were added to `check()`
1519        // afterwards without being wired in here. Nothing about a
1520        // Messstellenbetriebs-Rechnung exempts it from either —
1521        //
1522        // - `SG8 DTM+265` (Fälligkeitsdatum, MIG Nr. 00033) is **Muss** on
1523        //   PIDs 31003 and 31009 in the INVOIC AHB, exactly as it is on the
1524        //   31001/31002 invoices the standard pipeline checks; and
1525        // - `TAX` Nr. 00058 with `MOA` Nr. 00061/00062 is **Muss** on those
1526        //   same PIDs, because §14 Abs. 4 Nr. 8 UStG makes the rate and the
1527        //   tax amount (or the ground for stating neither) mandatory content
1528        //   of *every* invoice. Messstellenbetrieb is a taxable service at the
1529        //   regular rate — §13b UStG does not reach it — so an MSB invoice
1530        //   carrying only a net figure leaves its recipient without the
1531        //   Vorsteuerabzug that is the recipient's own money.
1532        //
1533        // The same PID 31009 already ran both through
1534        // [`check_esa_rechnung`](Self::check_esa_rechnung), so which door the
1535        // invoice came through decided whether its tax block was looked at.
1536        //
1537        // `check_steuer` distinguishes an absent tax block from a zero one
1538        // carrying a `RCV` ground, so a §13b invoice is not disputed for
1539        // stating 0,00 EUR.
1540        if config.max_zahlungsziel_days > 0 {
1541            Self::check_zahlungsziel(rechnung, config, &mut findings);
1542        }
1543        Self::check_steuer(rechnung, config, &mut findings);
1544
1545        // Stage 8, against `PreisblattMessung.preispositionen`.
1546        let billing_date: time::Date = rechnung
1547            .billing_period()
1548            .map(|p| *p.start())
1549            .or_else(|| rechnung.rechnungsdatum_date())
1550            .unwrap_or_else(mako_fristen::heute);
1551
1552        let published_prices: Vec<EuroAmount> = preisblatt_messung
1553            .and_then(|pm| pm.preispositionen.as_ref())
1554            .into_iter()
1555            .flatten()
1556            .flat_map(|pp| pp.preisstaffeln.iter().flatten())
1557            .filter_map(|ps| ps.preis)
1558            .filter_map(euro_from_decimal)
1559            .collect();
1560
1561        if preisblatt_messung.is_none() {
1562            findings.push(Finding {
1563                kind: FindingKind::TariffNotFound,
1564                is_dispute: config.require_tariff,
1565                message: format!(
1566                    "No PreisblattMessung found for MSB GLN {sender_mp_id} on {billing_date}. \
1567                     Tariff check skipped — upload via \
1568                     PUT /api/v1/preisblaetter-messung/{{msb_mp_id}}.",
1569                ),
1570                line_number: None,
1571                expected: None,
1572                actual: None,
1573                deviation_pct: None,
1574            });
1575        } else {
1576            let tol = config.tariff_tolerance_ppm;
1577            for pos in rechnung.rechnungspositionen.iter().flatten() {
1578                let Some(invoic_price) = pos.einzelpreis.wert_decimal().and_then(euro_from_decimal)
1579                else {
1580                    continue;
1581                };
1582                let (line_no, malo) = pos_ident(pos);
1583
1584                if published_prices.is_empty() {
1585                    findings.push(Finding::warn(
1586                        FindingKind::TariffNotFound,
1587                        format!(
1588                            "Line {line_no} ({malo}): PreisblattMessung for GLN \
1589                             {sender_mp_id} contains no Preisstaffeln — skipping price check",
1590                        ),
1591                        Some(line_no),
1592                        None,
1593                        Some(invoic_price),
1594                    ));
1595                    continue;
1596                }
1597
1598                if !published_prices
1599                    .iter()
1600                    .any(|p| invoic_price.within_tolerance_ppm(*p, tol))
1601                {
1602                    let closest = *published_prices
1603                        .iter()
1604                        .min_by_key(|p| {
1605                            (i128::from(invoic_price.to_raw()) - i128::from(p.to_raw()))
1606                                .unsigned_abs()
1607                        })
1608                        .unwrap_or(&EuroAmount::ZERO);
1609                    findings.push(Finding::dispute(
1610                        FindingKind::TariffDeviation,
1611                        format!(
1612                            "Line {line_no} ({malo}): einzelpreis {invoic_price} does not \
1613                             match any MSB tariff in PreisblattMessung for GLN {sender_mp_id} \
1614                             on {billing_date} (closest: {closest}, tolerance {pct:.1}%)",
1615                            pct = tol as f64 / 10_000.0,
1616                        ),
1617                        Some(line_no),
1618                        Some(closest),
1619                        Some(invoic_price),
1620                    ));
1621                }
1622            }
1623        }
1624
1625        // Check 6 — AufAbschlag: verify discount/surcharge positions are
1626        // contracted. `contracted_names` holds the names of the authorised
1627        // AufAbschlag entries from the MSB's PRICAT 27001–27003; with none of
1628        // them, check 6 is skipped.
1629        //
1630        // An empty entry is a substring of every description, so leaving one in
1631        // the set would pass every discount position — the opposite of what this
1632        // check does. Blank entries are dropped, and a set holding nothing else
1633        // skips the check as though none had been supplied.
1634        let name_set: std::collections::HashSet<String> = contracted_names
1635            .iter()
1636            .map(|s| s.trim().to_lowercase())
1637            .filter(|s| !s.is_empty())
1638            .collect();
1639        if !name_set.is_empty() {
1640            for pos in rechnung.rechnungspositionen.iter().flatten() {
1641                let net = pos.einzelpreis.wert_decimal().unwrap_or_default();
1642                if net >= rust_decimal::Decimal::ZERO {
1643                    continue; // Only check negative (discount) positions
1644                }
1645                let (line_no, malo) = pos_ident(pos);
1646                let description = pos.positionstext.as_deref().unwrap_or("").to_lowercase();
1647
1648                let is_contracted = name_set
1649                    .iter()
1650                    .any(|name: &String| description.contains(name.as_str()));
1651
1652                if !is_contracted {
1653                    findings.push(Finding::dispute(
1654                        FindingKind::TariffNotFound,
1655                        format!(
1656                            "Line {line_no} ({malo}): discount \"{}\" not backed by \
1657                             any AufAbschlag in PreisblattMessung for GLN {sender_mp_id} \
1658                             (check 6). Verify PRICAT 27001-27003.",
1659                            pos.positionstext.as_deref().unwrap_or("?"),
1660                        ),
1661                        Some(line_no),
1662                        None,
1663                        None,
1664                    ));
1665                }
1666            }
1667        }
1668
1669        CheckReport::from_findings(pid, rechnung, findings, computed_total)
1670    }
1671
1672    /// Arithmetic-only check for Stornorechnungen (cancellation invoices).
1673    ///
1674    /// Runs stages 1–6 — Storno reference, period, Zahlungsziel, currency,
1675    /// position arithmetic and document total. Stages 7 and 8 are skipped: a
1676    /// Stornierung carries the original invoice's negated amounts rather than
1677    /// new tariff positions, so there is no Preisblatt to compare against.
1678    ///
1679    /// Returns a `CheckReport` with outcome `AcceptedPartial` when all checks
1680    /// pass (represented as `Ok` in `CheckOutcome` — the `AcceptedPartial` label
1681    /// is set by `invoicd` when it detects a Storno outcome).
1682    ///
1683    /// Call this instead of `check()` when you know the invoice is a Storno
1684    /// (either by PID routing — e.g. PID 31004 — or by `is_stornierung()` check).
1685    ///
1686    /// # Example
1687    ///
1688    /// ```rust
1689    /// use invoic_checker::check::{CheckConfig, CheckOutcome, InvoicCheckEngine, is_stornierung};
1690    /// use rubo4e::current::Rechnung;
1691    ///
1692    /// let mut r = Rechnung::default();
1693    /// r.ist_storno = Some(true);
1694    /// r.original_rechnungsnummer = Some("31001-2026-001".to_owned());
1695    /// assert!(is_stornierung(&r));
1696    ///
1697    /// // A Storno still states its own Umsatzsteuer: `TAX` and the header `MOA`
1698    /// // are Muss for 31004, so a reversal of a 19 % invoice reverses the tax
1699    /// // with it. Without this block the report disputes with `SteuerMissing`.
1700    /// r.gesamtsteuer = Some(rubo4e::current::Betrag {
1701    ///     wert: Some("-19.00".parse().unwrap()),
1702    ///     ..Default::default()
1703    /// });
1704    /// r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
1705    ///     steuersatz: Some("19".parse().unwrap()),
1706    ///     steuerwert: Some("-19.00".parse().unwrap()),
1707    ///     ..Default::default()
1708    /// }]);
1709    ///
1710    /// let report = InvoicCheckEngine::check_storno(31004, &r, &CheckConfig::default());
1711    /// assert_eq!(report.outcome, CheckOutcome::Ok);
1712    /// ```
1713    #[must_use]
1714    pub fn check_storno(pid: u32, rechnung: &Rechnung, config: &CheckConfig) -> CheckReport {
1715        let mut findings = Vec::new();
1716
1717        // Stage 1: Storno reference must be present.
1718        if rechnung
1719            .original_rechnungsnummer
1720            .as_deref()
1721            .unwrap_or("")
1722            .is_empty()
1723        {
1724            findings.push(Finding::dispute(
1725                FindingKind::StorniertWithoutReference,
1726                "Stornorechnung does not reference the original invoice \
1727                 (original_rechnungsnummer is missing). Source: BK6-24-174 §5.",
1728                None,
1729                None,
1730                None,
1731            ));
1732        }
1733
1734        // Stage 2: Period validity (same as full check).
1735        Self::check_periods(rechnung, &mut findings);
1736
1737        // Stage 3: Zahlungsziel check.
1738        if config.max_zahlungsziel_days > 0 {
1739            Self::check_zahlungsziel(rechnung, config, &mut findings);
1740        }
1741
1742        // Stages 4-6: currency, arithmetic and total (still apply to Storno amounts).
1743        Self::check_waehrung(rechnung, &mut findings);
1744        Self::check_arithmetic(rechnung, config, &mut findings);
1745        let computed_total = Self::check_total(rechnung, config, &mut findings);
1746
1747        // Stage 7: the Storno states its own tax. Verified against the imported
1748        // AHB: for 31004 the header `TAX` (Nr 00058) and `MOA` (00061/00062) are
1749        // **Muss** in both fv20260401 and fv20261001 — only the *position*-level
1750        // `TAX` (00044) is absent, which is why stage 8 below stays skipped and
1751        // this one does not. A Storno stating no Umsatzsteuer at all was
1752        // accepted, and it reverses an invoice that had to state one.
1753        //
1754        // The negated amounts are not an obstacle: `check_steuer` is entirely
1755        // sign-agnostic — it asserts `netto + steuer == brutto` and that the
1756        // breakdown sums to `gesamtsteuer`, both of which hold under negation.
1757        Self::check_steuer(rechnung, config, &mut findings);
1758
1759        // Stage 8: SKIPPED — position-level `TAX` is not published for 31004.
1760
1761        CheckReport::from_findings(pid, rechnung, findings, computed_total)
1762    }
1763
1764    /// MMM settlement price check: validate that the Mehrmengen / Mindermengen
1765    /// positions of an MMM INVOIC (PIDs 31005, 31006, 31007, 31008) match the
1766    /// reference prices from the `marktd` MMMA store within tolerance.
1767    ///
1768    /// Called by the `invoicd` handler **after** the standard pipeline, when
1769    /// `mehr_ct_kwh` / `minder_ct_kwh` are available from `marktd`.
1770    ///
1771    /// Returns additional `Finding` objects to be merged into an existing
1772    /// `CheckReport`. Does not modify the existing findings.
1773    pub fn check_mmm_settlement(
1774        rechnung: &Rechnung,
1775        mehr_ct_kwh: rust_decimal::Decimal,
1776        minder_ct_kwh: rust_decimal::Decimal,
1777        config: &CheckConfig,
1778    ) -> Vec<Finding> {
1779        let tol = config.tariff_tolerance_ppm;
1780
1781        // Convert reference prices from ct/kWh → EUR/kWh
1782        let ref_mehr = euro_from_decimal(mehr_ct_kwh / rust_decimal::Decimal::from(100));
1783        let ref_minder = euro_from_decimal(minder_ct_kwh / rust_decimal::Decimal::from(100));
1784
1785        let mut findings = Vec::new();
1786
1787        for pos in rechnung.rechnungspositionen.iter().flatten() {
1788            let Some(invoic_price) = pos.einzelpreis.wert_decimal().and_then(euro_from_decimal)
1789            else {
1790                continue;
1791            };
1792            let (line_no, malo) = pos_ident(pos);
1793            let text = pos.positionstext.as_deref().unwrap_or("").to_lowercase();
1794            let is_mehr = text.contains("mehrmengen");
1795            let is_minder = text.contains("mindermengen");
1796            if !is_mehr && !is_minder {
1797                continue;
1798            }
1799            let Some(ref_p) = (if is_mehr { ref_mehr } else { ref_minder }) else {
1800                continue;
1801            };
1802
1803            if !invoic_price.within_tolerance_ppm(ref_p, tol) {
1804                let ref_raw = ref_p.to_raw() as f64;
1805                let pct = if ref_raw != 0.0 {
1806                    ((invoic_price.to_raw() as f64 - ref_raw) / ref_raw.abs() * 100.0).abs()
1807                } else {
1808                    0.0
1809                };
1810                let kind_str = if is_mehr {
1811                    "Mehrmengen"
1812                } else {
1813                    "Mindermengen"
1814                };
1815                findings.push(Finding {
1816                    kind: FindingKind::TariffDeviation,
1817                    is_dispute: config.require_tariff,
1818                    message: format!(
1819                        "Line {line_no} ({malo}): MMM {kind_str} price {invoic_price} EUR/kWh \
1820                         deviates {pct:.1}% from MMMA reference {ref_p} EUR/kWh \
1821                         (tolerance {t:.1}%)",
1822                        t = tol as f64 / 10_000.0,
1823                    ),
1824                    line_number: Some(line_no),
1825                    expected: Some(ref_p),
1826                    actual: Some(invoic_price),
1827                    deviation_pct: Some(pct),
1828                });
1829            }
1830        }
1831        findings
1832    }
1833}
1834
1835// ── Helper ────────────────────────────────────────────────────────────────────
1836
1837/// Extract a stable (line_number, malo_id) pair for error messages.
1838fn pos_ident(pos: &Rechnungsposition) -> (u32, &str) {
1839    let line_no = pos.positionsnummer.unwrap_or(0) as u32;
1840    // `lokations_id` was removed in BO4E v202607; fall back to positionstext.
1841    let malo = pos.positionstext.as_deref().unwrap_or("-");
1842    (line_no, malo)
1843}
1844
1845// ── Unit tests ────────────────────────────────────────────────────────────────
1846
1847#[cfg(test)]
1848mod tests {
1849    use rubo4e::current::{
1850        Betrag, Menge, Mengeneinheit, Preis, Rechnung, Rechnungsposition, Zeitraum,
1851    };
1852    use rust_decimal::Decimal;
1853
1854    use super::*;
1855    use crate::{amount::EuroAmount, tariff::InMemoryPreisblattStore};
1856    use rubo4e::current::{PreisblattNetznutzung, Preisposition, Preisstaffel};
1857
1858    const SENDER: &str = "9900357000004";
1859
1860    fn betrag(eur: EuroAmount) -> Betrag {
1861        Betrag {
1862            wert: Some(Decimal::from_str_exact(&eur.to_string()).expect("valid decimal")),
1863            ..Default::default()
1864        }
1865    }
1866
1867    /// Parse a `"YYYY-MM-DD"` string to `time::Date` (rubo4e v0.5 field type).
1868    fn parse_date(s: &str) -> time::Date {
1869        time::Date::parse(s, &time::format_description::well_known::Iso8601::DEFAULT)
1870            .expect("valid ISO date")
1871    }
1872
1873    /// A market date as the `date-time` BO4E declares for `rechnungsdatum` and
1874    /// `faelligkeitsdatum`: midnight UTC, which is how a producer pins a value
1875    /// BDEW transmits as a bare `YYYYMMDD`.
1876    fn parse_dt(s: &str) -> time::OffsetDateTime {
1877        parse_date(s).midnight().assume_utc()
1878    }
1879
1880    /// Parse a `"YYYY-MM-DD"` string to midnight UTC `OffsetDateTime`.
1881    fn periode(start: &str, end: &str) -> Zeitraum {
1882        Zeitraum {
1883            startdatum: Some(parse_date(start)),
1884            enddatum: Some(parse_date(end)),
1885            ..Default::default()
1886        }
1887    }
1888
1889    fn make_pos(
1890        n: i64,
1891        malo: &str,
1892        qty: Option<&str>,
1893        price: Option<EuroAmount>,
1894        net: Option<EuroAmount>,
1895    ) -> Rechnungsposition {
1896        Rechnungsposition {
1897            positionsnummer: Some(n),
1898            // lokations_id removed in v202607; use positionstext for test ident.
1899            positionstext: Some(malo.to_owned()),
1900            lieferungszeitraum: Some(periode("2024-12-01", "2024-12-31")),
1901            positions_menge: qty.map(|q| Menge {
1902                wert: Some(Decimal::from_str_exact(q).expect("valid decimal literal")),
1903                einheit: Some(Mengeneinheit::Kwh),
1904                ..Default::default()
1905            }),
1906            einzelpreis: price.map(|pr| Preis {
1907                wert: Some(Decimal::from_str_exact(&pr.to_string()).expect("valid decimal")),
1908                ..Default::default()
1909            }),
1910            gesamtpreis: net.map(betrag),
1911            ..Default::default()
1912        }
1913    }
1914
1915    fn make_rechnung(
1916        positions: Vec<Rechnungsposition>,
1917        gesamtnetto: Option<EuroAmount>,
1918    ) -> Rechnung {
1919        // Every fixture carries a lawful tax block: §14 Abs. 4 Nr. 8 UStG makes
1920        // it mandatory content, so an invoice without one is not a realistic
1921        // subject for the other checks — it is already a dispute.
1922        let netto =
1923            gesamtnetto.map(|n| Decimal::from_str_exact(&n.to_string()).unwrap_or_default());
1924        let steuer = netto.map(|n| {
1925            (n * Decimal::from(19) / Decimal::from(100))
1926                .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
1927        });
1928        Rechnung {
1929            rechnungsperiode: Some(periode("2024-12-01", "2024-12-31")),
1930            rechnungsdatum: Some(parse_dt("2025-01-15")),
1931            gesamtnetto: gesamtnetto.map(betrag),
1932            gesamtsteuer: steuer.map(|w| Betrag {
1933                wert: Some(w),
1934                ..Default::default()
1935            }),
1936            gesamtbrutto: netto.zip(steuer).map(|(n, t)| Betrag {
1937                wert: Some(n + t),
1938                ..Default::default()
1939            }),
1940            steuerbetraege: steuer.map(|w| {
1941                vec![rubo4e::current::Steuerbetrag {
1942                    steuerart: Some(rubo4e::current::Steuerart::Ust),
1943                    steuersatz: Some(Decimal::from(19)),
1944                    basiswert: netto,
1945                    steuerwert: Some(w),
1946                    ..Default::default()
1947                }]
1948            }),
1949            rechnungspositionen: if positions.is_empty() {
1950                None
1951            } else {
1952                Some(positions)
1953            },
1954            ..Default::default()
1955        }
1956    }
1957
1958    fn empty_store() -> InMemoryPreisblattStore {
1959        InMemoryPreisblattStore::new()
1960    }
1961
1962    fn seeded_store(price: EuroAmount) -> InMemoryPreisblattStore {
1963        use rust_decimal::Decimal;
1964        let mut store = InMemoryPreisblattStore::new();
1965        let einheitspreis = Decimal::from_str_exact(&price.to_string()).expect("valid decimal");
1966        let sheet = PreisblattNetznutzung {
1967            gueltigkeit: None,
1968            herausgeber: None,
1969            preispositionen: Some(vec![Preisposition {
1970                preisstaffeln: Some(vec![Preisstaffel {
1971                    preis: Some(einheitspreis),
1972                    ..Default::default()
1973                }]),
1974                ..Default::default()
1975            }]),
1976            ..Default::default()
1977        };
1978        store.insert(SENDER.to_owned(), sheet);
1979        store
1980    }
1981
1982    // ── Period check ──────────────────────────────────────────────────────────
1983
1984    #[test]
1985    fn period_start_gte_end_is_dispute() {
1986        let mut r = make_rechnung(vec![], None);
1987        r.rechnungsperiode = Some(periode("2024-12-31", "2024-12-01"));
1988        let report =
1989            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
1990        assert!(report.has_dispute());
1991        assert!(
1992            report
1993                .findings
1994                .iter()
1995                .any(|f| f.kind == FindingKind::PeriodInvalid)
1996        );
1997    }
1998
1999    #[test]
2000    fn period_valid_no_finding() {
2001        let r = make_rechnung(vec![], None);
2002        let report =
2003            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2004        assert!(
2005            !report
2006                .findings
2007                .iter()
2008                .any(|f| f.kind == FindingKind::PeriodInvalid)
2009        );
2010    }
2011
2012    #[test]
2013    fn line_period_invalid_is_dispute() {
2014        let mut pos = make_pos(1, "DE001", None, None, None);
2015        // Override the lieferungszeitraum to an invalid range (start > end).
2016        pos.lieferungszeitraum = Some(periode("2024-12-31", "2024-12-01"));
2017        let r = make_rechnung(vec![pos], None);
2018        let report =
2019            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2020        assert!(report.has_dispute());
2021        assert_eq!(report.findings[0].line_number, Some(1));
2022    }
2023
2024    // ── Arithmetic check ──────────────────────────────────────────────────────
2025
2026    #[test]
2027    fn arithmetic_correct_no_finding() {
2028        // 1000 kWh × 0.03456 EUR/kWh = 34.56000 EUR
2029        let pos = make_pos(
2030            1,
2031            "DE001",
2032            Some("1000.0"),
2033            Some(EuroAmount::from_raw_units(3_456)),
2034            Some(EuroAmount::from_raw_units(3_456_000)),
2035        );
2036        let r = make_rechnung(vec![pos], None);
2037        let report =
2038            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2039        assert!(
2040            !report
2041                .findings
2042                .iter()
2043                .any(|f| f.kind == FindingKind::ArithmeticError)
2044        );
2045    }
2046
2047    #[test]
2048    fn arithmetic_mismatch_is_dispute() {
2049        // 1000 × 0.03456 = 34.56, but invoice says 40.00
2050        let pos = make_pos(
2051            1,
2052            "DE001",
2053            Some("1000.0"),
2054            Some(EuroAmount::from_raw_units(3_456)),
2055            Some(EuroAmount::from_raw_units(4_000_000)),
2056        );
2057        let r = make_rechnung(vec![pos], None);
2058        let report =
2059            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2060        assert!(report.has_dispute());
2061        assert!(
2062            report
2063                .findings
2064                .iter()
2065                .any(|f| f.kind == FindingKind::ArithmeticError)
2066        );
2067    }
2068
2069    #[test]
2070    fn arithmetic_within_tolerance_no_finding() {
2071        // 1% tolerance: 34.56 vs 34.90 → ~0.98% deviation → no finding
2072        let pos = make_pos(
2073            1,
2074            "DE001",
2075            Some("1000.0"),
2076            Some(EuroAmount::from_raw_units(3_456)),
2077            Some(EuroAmount::from_raw_units(3_490_000)),
2078        );
2079        let config = CheckConfig {
2080            arithmetic_tolerance_ppm: 10_000,
2081            ..Default::default()
2082        };
2083        let r = make_rechnung(vec![pos], None);
2084        let report = InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &config);
2085        assert!(
2086            !report
2087                .findings
2088                .iter()
2089                .any(|f| f.kind == FindingKind::ArithmeticError)
2090        );
2091    }
2092
2093    // ── Total check ───────────────────────────────────────────────────────────
2094
2095    #[test]
2096    fn total_match_no_finding() {
2097        let pos = make_pos(
2098            1,
2099            "DE001",
2100            None,
2101            None,
2102            Some(EuroAmount::from_raw_units(3_456_000)),
2103        );
2104        let r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(3_456_000)));
2105        let report =
2106            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2107        assert!(
2108            !report
2109                .findings
2110                .iter()
2111                .any(|f| f.kind == FindingKind::TotalMismatch)
2112        );
2113    }
2114
2115    #[test]
2116    fn total_mismatch_is_warn() {
2117        let pos = make_pos(
2118            1,
2119            "DE001",
2120            None,
2121            None,
2122            Some(EuroAmount::from_raw_units(3_456_000)),
2123        );
2124        let r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(5_000_000)));
2125        let report =
2126            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2127        assert!(!report.has_dispute()); // warn only
2128        assert!(
2129            report
2130                .findings
2131                .iter()
2132                .any(|f| f.kind == FindingKind::TotalMismatch)
2133        );
2134    }
2135
2136    // ── Umsatzsteuer ──────────────────────────────────────────────────────────
2137
2138    /// An invoice stating no tax is disputed, not merely flagged.
2139    ///
2140    /// §14 Abs. 4 Nr. 8 UStG makes the rate and the amount mandatory content.
2141    /// Paying an invoice without them means paying tax that cannot be recovered,
2142    /// which is the receiving LF's money.
2143    #[test]
2144    fn an_invoice_without_a_tax_block_is_disputed() {
2145        let pos = make_pos(
2146            1,
2147            "DE001",
2148            None,
2149            None,
2150            Some(EuroAmount::from_raw_units(100_000)),
2151        );
2152        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2153        r.gesamtsteuer = None;
2154        r.gesamtbrutto = None;
2155        r.steuerbetraege = None;
2156
2157        let report =
2158            InvoicCheckEngine::check(31002, SENDER, &r, &empty_store(), &CheckConfig::default());
2159        assert_eq!(report.outcome, CheckOutcome::Dispute);
2160        assert!(
2161            report
2162                .findings
2163                .iter()
2164                .any(|f| f.kind == FindingKind::SteuerMissing)
2165        );
2166    }
2167
2168    /// A reverse charge states no tax, and that is correct rather than missing.
2169    #[test]
2170    fn a_reverse_charge_without_a_tax_amount_is_accepted() {
2171        let pos = make_pos(
2172            1,
2173            "DE001",
2174            None,
2175            None,
2176            Some(EuroAmount::from_raw_units(100_000)),
2177        );
2178        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2179        r.gesamtsteuer = Some(betrag(EuroAmount::ZERO));
2180        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(100_000)));
2181        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2182            steuerart: Some(rubo4e::current::Steuerart::Rcv),
2183            steuersatz: Some(Decimal::ZERO),
2184            steuerwert: Some(Decimal::ZERO),
2185            ..Default::default()
2186        }]);
2187
2188        let report =
2189            InvoicCheckEngine::check(31005, SENDER, &r, &empty_store(), &CheckConfig::default());
2190        assert!(
2191            !report
2192                .findings
2193                .iter()
2194                .any(|f| f.kind == FindingKind::SteuerMissing),
2195            "a §13b invoice states no tax by design: {:#?}",
2196            report.findings
2197        );
2198    }
2199
2200    /// A reverse charge that states tax anyway is disputed.
2201    ///
2202    /// That tax is owed under §14c Abs. 1 UStG *and* undeductible, because the
2203    /// recipient owes it too under §13b — the worst of both.
2204    #[test]
2205    fn a_reverse_charge_stating_tax_is_disputed() {
2206        let pos = make_pos(
2207            1,
2208            "DE001",
2209            None,
2210            None,
2211            Some(EuroAmount::from_raw_units(100_000)),
2212        );
2213        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2214        r.gesamtsteuer = Some(betrag(EuroAmount::from_raw_units(19_000)));
2215        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(119_000)));
2216        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2217            steuerart: Some(rubo4e::current::Steuerart::Rcv),
2218            steuersatz: Some(Decimal::ZERO),
2219            steuerwert: Some(Decimal::from(190)),
2220            ..Default::default()
2221        }]);
2222
2223        let report =
2224            InvoicCheckEngine::check(31005, SENDER, &r, &empty_store(), &CheckConfig::default());
2225        assert_eq!(report.outcome, CheckOutcome::Dispute);
2226        assert!(
2227            report
2228                .findings
2229                .iter()
2230                .any(|f| f.kind == FindingKind::ReverseChargeStatesTax)
2231        );
2232    }
2233
2234    /// The gross must equal net plus tax.
2235    ///
2236    /// An invoice whose parts do not sum to its whole is the one error nobody
2237    /// catches by reading it.
2238    #[test]
2239    fn a_gross_that_does_not_equal_net_plus_tax_is_disputed() {
2240        let pos = make_pos(
2241            1,
2242            "DE001",
2243            None,
2244            None,
2245            Some(EuroAmount::from_raw_units(100_000)),
2246        );
2247        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2248        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(999_999)));
2249
2250        let report =
2251            InvoicCheckEngine::check(31002, SENDER, &r, &empty_store(), &CheckConfig::default());
2252        assert_eq!(report.outcome, CheckOutcome::Dispute);
2253        assert!(
2254            report
2255                .findings
2256                .iter()
2257                .any(|f| f.kind == FindingKind::SteuerMismatch)
2258        );
2259    }
2260
2261    /// A Stornorechnung passes the tax stage: every amount is negative, and the
2262    /// arithmetic holds with the signs.
2263    ///
2264    /// Every reversal `netzbilanzd` issues goes through this gate, so a stage
2265    /// that only reasons about positive amounts would block them all.
2266    #[test]
2267    fn a_storno_with_negative_amounts_passes_the_tax_stage() {
2268        let pos = make_pos(
2269            1,
2270            "DE001",
2271            None,
2272            None,
2273            Some(EuroAmount::from_raw_units(-100_000)),
2274        );
2275        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(-100_000)));
2276        r.ist_storno = Some(true);
2277        r.original_rechnungsnummer = Some("NNE-2026-000001".to_owned());
2278        r.gesamtsteuer = Some(betrag(EuroAmount::from_raw_units(-19_000)));
2279        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(-119_000)));
2280        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2281            steuerart: Some(rubo4e::current::Steuerart::Ust),
2282            steuersatz: Some(Decimal::from(19)),
2283            basiswert: Some(Decimal::from(-1)),
2284            steuerwert: Some(Decimal::from_str_exact("-0.19").expect("decimal")),
2285            ..Default::default()
2286        }]);
2287
2288        let report =
2289            InvoicCheckEngine::check(31002, SENDER, &r, &empty_store(), &CheckConfig::default());
2290        assert!(
2291            !report.findings.iter().any(|f| {
2292                matches!(
2293                    f.kind,
2294                    FindingKind::SteuerMissing
2295                        | FindingKind::SteuerMismatch
2296                        | FindingKind::ReverseChargeStatesTax
2297                )
2298            }),
2299            "a reversal is a lawful document: {:#?}",
2300            report.findings
2301        );
2302    }
2303
2304    // ── Tariff check ──────────────────────────────────────────────────────────
2305
2306    #[test]
2307    fn no_tariff_warn_by_default() {
2308        // A realistic invoice, so the assertion isolates the tariff stage: an
2309        // empty document fails §14 UStG on its own and would dispute for that.
2310        let pos = make_pos(
2311            1,
2312            "DE001",
2313            None,
2314            None,
2315            Some(EuroAmount::from_raw_units(3_456_000)),
2316        );
2317        let r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(3_456_000)));
2318        let report =
2319            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2320        assert!(!report.has_dispute());
2321        assert!(
2322            report
2323                .findings
2324                .iter()
2325                .any(|f| f.kind == FindingKind::TariffNotFound)
2326        );
2327    }
2328
2329    #[test]
2330    fn no_tariff_dispute_when_required() {
2331        let config = CheckConfig {
2332            require_tariff: true,
2333            ..Default::default()
2334        };
2335        let r = make_rechnung(vec![], None);
2336        let report = InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &config);
2337        assert!(report.has_dispute());
2338    }
2339
2340    #[test]
2341    fn tariff_match_no_finding() {
2342        let price = EuroAmount::from_raw_units(3_456);
2343        let pos = make_pos(
2344            1,
2345            "DE001",
2346            Some("1000.0"),
2347            Some(price),
2348            Some(EuroAmount::from_raw_units(3_456_000)),
2349        );
2350        let r = make_rechnung(vec![pos], None);
2351        let report = InvoicCheckEngine::check(
2352            31001,
2353            SENDER,
2354            &r,
2355            &seeded_store(price),
2356            &CheckConfig::default(),
2357        );
2358        assert!(
2359            !report
2360                .findings
2361                .iter()
2362                .any(|f| f.kind == FindingKind::TariffDeviation)
2363        );
2364    }
2365
2366    /// A price sheet stated in **tiers**, as BO4E states them: bounds on
2367    /// `staffelgrenzeVon` / `staffelgrenzeBis`, cheaper as the quantity grows.
2368    fn tiered_store() -> InMemoryPreisblattStore {
2369        use rust_decimal::Decimal;
2370        let mut store = InMemoryPreisblattStore::new();
2371        let tier = |von: i64, bis: Option<i64>, preis: &str| Preisstaffel {
2372            staffelgrenze_von: Some(Decimal::from(von)),
2373            staffelgrenze_bis: bis.map(Decimal::from),
2374            preis: Some(Decimal::from_str_exact(preis).expect("valid decimal")),
2375            ..Default::default()
2376        };
2377        store.insert(
2378            SENDER.to_owned(),
2379            PreisblattNetznutzung {
2380                gueltigkeit: None,
2381                herausgeber: None,
2382                preispositionen: Some(vec![Preisposition {
2383                    preisstaffeln: Some(vec![
2384                        tier(0, Some(1000), "0.30"),
2385                        tier(1001, Some(2000), "0.25"),
2386                        tier(2001, None, "0.20"),
2387                    ]),
2388                    ..Default::default()
2389                }]),
2390                ..Default::default()
2391            },
2392        );
2393        store
2394    }
2395
2396    /// A 500 kWh position billed at the **2001+** rate is a deviation.
2397    ///
2398    /// The tier is selected by the position's **quantity**, not by matching the
2399    /// billed price against any published tier: accepting whichever tier happens
2400    /// to match would let the cheapest tier price the smallest quantity and pass
2401    /// silently. `PreisstaffelSliceExt::select_for` picks the tier the quantity
2402    /// falls in, so the position is measured against 0.30 and disputed.
2403    #[test]
2404    fn a_position_billed_at_the_wrong_staffel_is_a_deviation() {
2405        let invoic_price = EuroAmount::from_raw_units(20_000); // 0.20 EUR/kWh — the 2001+ tier
2406        let pos = make_pos(
2407            1,
2408            "DE001",
2409            Some("500.0"), // …but only 500 kWh, which is the 0 – 1000 tier
2410            Some(invoic_price),
2411            Some(EuroAmount::from_raw_units(10_000_000)), // 500 × 0.20
2412        );
2413        let r = make_rechnung(vec![pos], None);
2414        let report =
2415            InvoicCheckEngine::check(31001, SENDER, &r, &tiered_store(), &CheckConfig::default());
2416        assert!(
2417            report
2418                .findings
2419                .iter()
2420                .any(|f| f.kind == FindingKind::TariffDeviation),
2421            "500 kWh belongs in the 0 – 1000 tier at 0.30, not the 2001+ tier at 0.20"
2422        );
2423    }
2424
2425    /// The tier the quantity really falls in passes.
2426    #[test]
2427    fn a_position_billed_at_its_own_staffel_is_clean() {
2428        let invoic_price = EuroAmount::from_raw_units(30_000); // 0.30 EUR/kWh
2429        let pos = make_pos(
2430            1,
2431            "DE001",
2432            Some("500.0"),
2433            Some(invoic_price),
2434            Some(EuroAmount::from_raw_units(15_000_000)), // 500 × 0.30
2435        );
2436        let r = make_rechnung(vec![pos], None);
2437        let report =
2438            InvoicCheckEngine::check(31001, SENDER, &r, &tiered_store(), &CheckConfig::default());
2439        assert!(
2440            !report
2441                .findings
2442                .iter()
2443                .any(|f| f.kind == FindingKind::TariffDeviation)
2444        );
2445    }
2446
2447    /// BO4E's **gap rule**: a quantity between two tiers „rutscht in die obere
2448    /// Zone", so 1000.6 kWh bills at the `1001 – 2000` rate rather than matching
2449    /// no tier at all.
2450    #[test]
2451    fn a_quantity_in_the_gap_between_two_staffeln_bills_at_the_upper_one() {
2452        let invoic_price = EuroAmount::from_raw_units(25_000); // the 1001 – 2000 tier
2453        let pos = make_pos(
2454            1,
2455            "DE001",
2456            Some("1000.6"),
2457            Some(invoic_price),
2458            Some(EuroAmount::from_raw_units(25_015_000)), // 1000.6 × 0.25
2459        );
2460        let r = make_rechnung(vec![pos], None);
2461        let report =
2462            InvoicCheckEngine::check(31001, SENDER, &r, &tiered_store(), &CheckConfig::default());
2463        assert!(
2464            !report
2465                .findings
2466                .iter()
2467                .any(|f| f.kind == FindingKind::TariffDeviation),
2468            "1000.6 falls between the tiers and rutscht in die obere Zone (1001 – 2000)"
2469        );
2470    }
2471
2472    /// A breakdown that does not add up to `gesamtsteuer` is a dispute.
2473    ///
2474    /// The recipient's Vorsteuerabzug comes from the per-rate entries and its
2475    /// payment from the total; when the two disagree the invoice is usable for
2476    /// neither. Checked since the rule became explicit in BO4E.
2477    #[test]
2478    fn a_tax_breakdown_that_does_not_sum_to_gesamtsteuer_is_a_dispute() {
2479        use rust_decimal::Decimal;
2480        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(100_000_000)));
2481        // gesamtsteuer says 19.00; the single entry says 5.00.
2482        r.gesamtsteuer = Some(betrag(EuroAmount::from_raw_units(1_900_000)));
2483        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(101_900_000)));
2484        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2485            steuerart: Some(rubo4e::current::Steuerart::Ust),
2486            steuersatz: Some(Decimal::from(19)),
2487            basiswert: Some(Decimal::from(1000)),
2488            steuerwert: Some(Decimal::from(5)),
2489            ..Default::default()
2490        }]);
2491        let report =
2492            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2493        assert!(
2494            report
2495                .findings
2496                .iter()
2497                .any(|f| f.kind == FindingKind::SteuerMismatch),
2498            "a breakdown summing to 5.00 against a stated 19.00 must be disputed"
2499        );
2500    }
2501
2502    /// **Invariant: the tax equals the rate applied to the base.**
2503    ///
2504    /// §14 Abs. 4 Nr. 8 UStG makes both the rate and the amount it produces
2505    /// mandatory content, and the recipient deducts the amount. The three checks
2506    /// around this one — presence, `netto + steuer = brutto`, and Σ breakdown =
2507    /// `gesamtsteuer` — are all satisfied by an invoice stating 19 % on a base of
2508    /// 10 000 with a Steuerwert of 100: it returns `Ok`, triggers an auto-REMADV
2509    /// 33001 and an auto-payment, and books 100 EUR of Vorsteuer where 1 900 is
2510    /// owed.
2511    #[test]
2512    fn a_tax_amount_that_is_not_the_rate_times_the_base_is_a_dispute() {
2513        use rust_decimal::Decimal;
2514        // netto 10 000, steuer 100, brutto 10 100 — internally consistent.
2515        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(1_000_000_000)));
2516        r.gesamtsteuer = Some(betrag(EuroAmount::from_raw_units(10_000_000)));
2517        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(1_010_000_000)));
2518        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2519            steuerart: Some(rubo4e::current::Steuerart::Ust),
2520            steuersatz: Some(Decimal::from(19)),
2521            basiswert: Some(Decimal::from(10_000)),
2522            steuerwert: Some(Decimal::from(100)),
2523            ..Default::default()
2524        }]);
2525
2526        let report =
2527            InvoicCheckEngine::check(31002, SENDER, &r, &empty_store(), &CheckConfig::default());
2528        assert_eq!(
2529            report.outcome,
2530            CheckOutcome::Dispute,
2531            "19 % of 10 000 is 1 900, not 100: {:#?}",
2532            report.findings
2533        );
2534        assert!(
2535            report
2536                .findings
2537                .iter()
2538                .any(|f| f.kind == FindingKind::SteuerMismatch && f.is_dispute),
2539            "{:#?}",
2540            report.findings
2541        );
2542    }
2543
2544    /// The same entry, stated correctly, is silent — including a rate of zero.
2545    #[test]
2546    fn a_tax_amount_that_is_the_rate_times_the_base_is_silent() {
2547        use rust_decimal::Decimal;
2548        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(1_000_000_000)));
2549        r.gesamtsteuer = Some(betrag(EuroAmount::from_raw_units(190_000_000)));
2550        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(1_190_000_000)));
2551        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2552            steuerart: Some(rubo4e::current::Steuerart::Ust),
2553            steuersatz: Some(Decimal::from(19)),
2554            basiswert: Some(Decimal::from(10_000)),
2555            steuerwert: Some(Decimal::from(1_900)),
2556            ..Default::default()
2557        }]);
2558        let report =
2559            InvoicCheckEngine::check(31002, SENDER, &r, &empty_store(), &CheckConfig::default());
2560        assert!(
2561            !report
2562                .findings
2563                .iter()
2564                .any(|f| f.kind == FindingKind::SteuerMismatch),
2565            "{:#?}",
2566            report.findings
2567        );
2568    }
2569
2570    /// **Invariant: an oversized quantity is a finding, not a panic.**
2571    ///
2572    /// The unit price is range-checked on the way in and the quantity is not, so
2573    /// an absurd Menge reaches the multiplication unbounded. Aborting the
2574    /// request that validates it would make a counterparty document a remote
2575    /// denial of service on a message-processing path. It is a fact about the
2576    /// document, so it is reported as one.
2577    #[test]
2578    fn an_unrepresentable_line_product_is_reported_rather_than_panicking() {
2579        let pos = make_pos(
2580            1,
2581            "DE001",
2582            // 10^15 kWh at 1.00 EUR/kWh overflows the 5-dp fixed-point range.
2583            Some("1000000000000000"),
2584            Some(EuroAmount::from_raw_units(100_000)),
2585            Some(EuroAmount::from_raw_units(100_000)),
2586        );
2587        let r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2588        let report =
2589            InvoicCheckEngine::check(31002, SENDER, &r, &empty_store(), &CheckConfig::default());
2590        assert!(
2591            report
2592                .findings
2593                .iter()
2594                .any(|f| f.kind == FindingKind::ArithmeticError && f.is_dispute),
2595            "{:#?}",
2596            report.findings
2597        );
2598    }
2599
2600    /// **Invariant: a blank contracted name authorises nothing.**
2601    ///
2602    /// `""` is a substring of every description, so a single blank entry in the
2603    /// PRICAT-derived set passed every discount position — the opposite of what
2604    /// check 6 is for. It is dropped, and the names beside it still decide.
2605    #[test]
2606    fn a_blank_contracted_name_does_not_authorise_every_discount() {
2607        let discount = |text: &str| {
2608            make_pos(
2609                1,
2610                text,
2611                Some("1.0"),
2612                Some(EuroAmount::from_raw_units(-500_000)),
2613                Some(EuroAmount::from_raw_units(-500_000)),
2614            )
2615        };
2616        let contracted = ["".to_owned(), "   ".to_owned(), "winterrabatt".to_owned()];
2617        let disputed = |text: &str| {
2618            let r = make_rechnung(
2619                vec![discount(text)],
2620                Some(EuroAmount::from_raw_units(-500_000)),
2621            );
2622            InvoicCheckEngine::check_msb_rechnung_with_aufabschlaege(
2623                31_009,
2624                SENDER,
2625                &r,
2626                None,
2627                &contracted,
2628                &CheckConfig::default(),
2629            )
2630            .findings
2631            .iter()
2632            .any(|f| f.kind == FindingKind::TariffNotFound && f.is_dispute)
2633        };
2634
2635        assert!(
2636            disputed("Nachlass Sondervereinbarung"),
2637            "the blank entry must not back a discount nothing else names"
2638        );
2639        assert!(
2640            !disputed("Winterrabatt Netznutzung"),
2641            "a contracted name still authorises its discount"
2642        );
2643    }
2644
2645    // ── The MSB/WiM path runs the same document checks as every other ────────
2646
2647    /// A WiM/MSB invoice (PIDs 31003 and 31009) that states **no Umsatzsteuer
2648    /// at all** is disputed, exactly as a Netznutzungsrechnung is.
2649    ///
2650    /// §14 Abs. 4 Nr. 8 UStG makes the rate and the amount mandatory content of
2651    /// every invoice, and the INVOIC AHB agrees: `TAX` Nr. 00058 and `MOA`
2652    /// Nr. 00061/00062 are **Muss** on 31003 and 31009 just as on 31001/31002.
2653    #[test]
2654    fn an_msb_invoice_without_a_tax_block_is_disputed() {
2655        let pos = make_pos(
2656            1,
2657            "Messstellenbetrieb",
2658            None,
2659            None,
2660            Some(EuroAmount::from_raw_units(100_000)),
2661        );
2662        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2663        r.gesamtsteuer = None;
2664        r.gesamtbrutto = None;
2665        r.steuerbetraege = None;
2666
2667        let report = InvoicCheckEngine::check_msb_rechnung(
2668            31_009,
2669            SENDER,
2670            &r,
2671            None,
2672            &CheckConfig::default(),
2673        );
2674        assert!(
2675            report
2676                .findings
2677                .iter()
2678                .any(|f| f.kind == FindingKind::SteuerMissing && f.is_dispute),
2679            "an MSB invoice stating no Umsatzsteuer gives its recipient no \
2680             Vorsteuerabzug and must be disputed: {:#?}",
2681            report.findings
2682        );
2683        assert_eq!(report.outcome, CheckOutcome::Dispute);
2684    }
2685
2686    /// **A zero tax with a stated ground is not a missing tax.** A §13b
2687    /// reverse-charged MSB invoice states 0,00 EUR by design, and naming the
2688    /// ground is what distinguishes it from an invoice that simply omits the
2689    /// tax — so wiring the Umsatzsteuer stage into this path must not dispute
2690    /// it.
2691    #[test]
2692    fn a_reverse_charged_msb_invoice_is_not_a_missing_tax_block() {
2693        let pos = make_pos(
2694            1,
2695            "Messstellenbetrieb",
2696            None,
2697            None,
2698            Some(EuroAmount::from_raw_units(100_000)),
2699        );
2700        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
2701        r.gesamtsteuer = Some(betrag(EuroAmount::ZERO));
2702        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(100_000)));
2703        r.steuerbetraege = Some(vec![rubo4e::current::Steuerbetrag {
2704            steuerart: Some(rubo4e::current::Steuerart::Rcv),
2705            steuersatz: Some(Decimal::ZERO),
2706            steuerwert: Some(Decimal::ZERO),
2707            ..Default::default()
2708        }]);
2709
2710        let report = InvoicCheckEngine::check_msb_rechnung(
2711            31_009,
2712            SENDER,
2713            &r,
2714            None,
2715            &CheckConfig::default(),
2716        );
2717        assert!(
2718            !report.findings.iter().any(|f| matches!(
2719                f.kind,
2720                FindingKind::SteuerMissing | FindingKind::ReverseChargeStatesTax
2721            )),
2722            "a §13b invoice states no tax by design: {:#?}",
2723            report.findings
2724        );
2725    }
2726
2727    /// A Fälligkeitsdatum before the invoice date is a dispute on the MSB path
2728    /// too. `SG8 DTM+265` is **Muss** on 31003 and 31009, so the date is there
2729    /// to be checked.
2730    #[test]
2731    fn an_msb_invoice_due_before_it_was_issued_is_disputed() {
2732        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(100_000)));
2733        r.rechnungsdatum = Some(parse_dt("2026-07-15"));
2734        r.faelligkeitsdatum = Some(parse_dt("2026-07-01"));
2735
2736        let report = InvoicCheckEngine::check_msb_rechnung(
2737            31_009,
2738            SENDER,
2739            &r,
2740            None,
2741            &CheckConfig::default(),
2742        );
2743        assert!(
2744            report
2745                .findings
2746                .iter()
2747                .any(|f| f.kind == FindingKind::ZahlungszielInvalid && f.is_dispute),
2748            "a due date before the invoice date must be disputed: {:#?}",
2749            report.findings
2750        );
2751        assert_eq!(report.outcome, CheckOutcome::Dispute);
2752    }
2753
2754    /// A payment term beyond the 30 days of §7 Allgemeine Festlegungen V6.1d
2755    /// warns on the MSB path, as it does on the standard one — a warning, so
2756    /// the MSB can correct it.
2757    #[test]
2758    fn an_msb_invoice_with_an_overlong_zahlungsziel_warns() {
2759        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(100_000)));
2760        r.rechnungsdatum = Some(parse_dt("2026-07-01"));
2761        r.faelligkeitsdatum = Some(parse_dt("2026-09-01")); // 62 days
2762
2763        let report = InvoicCheckEngine::check_msb_rechnung(
2764            31_009,
2765            SENDER,
2766            &r,
2767            None,
2768            &CheckConfig::default(),
2769        );
2770        let finding = report
2771            .findings
2772            .iter()
2773            .find(|f| f.kind == FindingKind::ZahlungszielExceeded)
2774            .unwrap_or_else(|| panic!("no ZahlungszielExceeded in {:#?}", report.findings));
2775        assert!(!finding.is_dispute, "ZahlungszielExceeded is a warning");
2776    }
2777
2778    // ── WiM 31003 send window (Kap. 3.7.2 Nr. 1) ─────────────────────────────
2779
2780    /// A Rechnung whose period ends and whose date is `days_after` Werktage on.
2781    fn wim_dienstleistung(period_end: &str, rechnungsdatum: &str) -> Rechnung {
2782        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(100_000_000)));
2783        r.rechnungsperiode = Some(periode("2026-06-01", period_end));
2784        r.rechnungsdatum = Some(parse_dt(rechnungsdatum));
2785        r
2786    }
2787
2788    fn late_findings(pid: u32, r: &Rechnung) -> Vec<Finding> {
2789        let mut f = Vec::new();
2790        InvoicCheckEngine::check_wim_dienstleistung_frist(pid, r, &mut f);
2791        f
2792    }
2793
2794    /// The 20th Werktag after a 2026-06-30 period end is 2026-07-28.
2795    ///
2796    /// Computed from the BDEW calendar rather than asserted as a guess: the
2797    /// point of reading `vorlauf` is that the window and the calendar are one
2798    /// source, so the fixture derives the boundary the same way the check does.
2799    fn spaetester_uet(period_end: &str) -> time::Date {
2800        mako_fristen::add_werktage(
2801            parse_date(period_end),
2802            20,
2803            mako_fristen::HolidayCalendar::BdewMaKo,
2804        )
2805    }
2806
2807    /// On the last lawful day there is no finding; one Werktag later there is.
2808    #[test]
2809    fn the_send_window_closes_on_the_twentieth_werktag() {
2810        let end = "2026-06-30";
2811        let last = spaetester_uet(end);
2812        let ok = wim_dienstleistung(end, &last.to_string());
2813        assert!(
2814            late_findings(31_003, &ok).is_empty(),
2815            "the 20th Werktag itself is still inside the window"
2816        );
2817
2818        let day_after =
2819            mako_fristen::add_werktage(last, 1, mako_fristen::HolidayCalendar::BdewMaKo);
2820        let late = wim_dienstleistung(end, &day_after.to_string());
2821        let f = late_findings(31_003, &late);
2822        assert_eq!(f.len(), 1);
2823        assert_eq!(f[0].kind, FindingKind::RechnungZuSpaet);
2824        assert!(
2825            !f[0].is_dispute,
2826            "lateness binds the sender; no tree refuses it"
2827        );
2828        assert!(
2829            f[0].message.contains("Kap. 3.7.2"),
2830            "the finding cites its Fundstelle: {}",
2831            f[0].message
2832        );
2833    }
2834
2835    /// The window is read from `mako_fristen`, not restated here.
2836    ///
2837    /// If the catalogued row ever moved off 20 Werktage this would fail rather
2838    /// than silently keep checking the old number — which is the whole reason
2839    /// the check looks the window up.
2840    #[test]
2841    fn the_window_comes_from_the_published_catalogue() {
2842        let row =
2843            mako_fristen::vorlauf::vorlauf("wim.rechnung-dienstleistungen").expect("catalogued");
2844        assert_eq!(
2845            row.shape,
2846            mako_fristen::vorlauf::VorlaufShape::LatestWerktageAfter(20),
2847            "WiM Teil 1 Kap. 3.7.2 Nr. 1 states 20 Werktage"
2848        );
2849        assert_eq!(row.pid, Some(31_003));
2850        assert_eq!(row.pid_gas, Some(31_003), "beide Sparten");
2851    }
2852
2853    /// No other invoice family carries this window.
2854    ///
2855    /// 31009 is the one that could plausibly be confused with it, and its
2856    /// Fristen count *back* from the Zahlungsziel instead.
2857    #[test]
2858    fn only_31003_is_measured() {
2859        let very_late = wim_dienstleistung("2026-06-30", "2027-01-15");
2860        assert_eq!(late_findings(31_003, &very_late).len(), 1);
2861        for pid in [31_001, 31_002, 31_004, 31_005, 31_009, 31_011] {
2862            assert!(
2863                late_findings(pid, &very_late).is_empty(),
2864                "PID {pid} does not publish the Kap. 3.7.2 window"
2865            );
2866        }
2867    }
2868
2869    /// Nothing to measure from is not lateness.
2870    ///
2871    /// A missing period or invoice date is a defect stage 2 and the § 14 UStG
2872    /// checks already name; reporting it again as "too late" would be a second
2873    /// finding for one cause, and a wrong one.
2874    #[test]
2875    fn a_missing_anchor_is_not_reported_as_late() {
2876        let mut no_period = wim_dienstleistung("2026-06-30", "2027-01-15");
2877        no_period.rechnungsperiode = None;
2878        assert!(late_findings(31_003, &no_period).is_empty());
2879
2880        let mut no_date = wim_dienstleistung("2026-06-30", "2027-01-15");
2881        no_date.rechnungsdatum = None;
2882        assert!(late_findings(31_003, &no_date).is_empty());
2883    }
2884
2885    /// A breakdown that does add up passes — including one split across rates.
2886    #[test]
2887    fn a_tax_breakdown_split_across_rates_that_sums_is_clean() {
2888        use rust_decimal::Decimal;
2889        let mut r = make_rechnung(vec![], Some(EuroAmount::from_raw_units(100_000_000)));
2890        r.gesamtsteuer = Some(betrag(EuroAmount::from_raw_units(2_600_000))); // 26.00
2891        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(102_600_000)));
2892        let entry = |satz: i64, basis: i64, wert: i64| rubo4e::current::Steuerbetrag {
2893            steuerart: Some(rubo4e::current::Steuerart::Ust),
2894            steuersatz: Some(Decimal::from(satz)),
2895            basiswert: Some(Decimal::from(basis)),
2896            steuerwert: Some(Decimal::from(wert)),
2897            ..Default::default()
2898        };
2899        r.steuerbetraege = Some(vec![entry(19, 100, 19), entry(7, 100, 7)]);
2900        let report =
2901            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2902        assert!(
2903            !report
2904                .findings
2905                .iter()
2906                .any(|f| f.kind == FindingKind::SteuerMismatch),
2907            "19 + 7 = 26, which is what gesamtsteuer states"
2908        );
2909    }
2910
2911    #[test]
2912    fn tariff_deviation_is_dispute() {
2913        let tariff_price = EuroAmount::from_raw_units(3_456); // 0.03456 EUR/kWh (PRICAT)
2914        let invoic_price = EuroAmount::from_raw_units(4_000); // 0.04000 EUR/kWh (INVOIC, +15.7%)
2915        let pos = make_pos(
2916            1,
2917            "DE001",
2918            Some("1000.0"),
2919            Some(invoic_price),
2920            Some(EuroAmount::from_raw_units(4_000_000)),
2921        );
2922        let r = make_rechnung(vec![pos], None);
2923        let report = InvoicCheckEngine::check(
2924            31001,
2925            SENDER,
2926            &r,
2927            &seeded_store(tariff_price),
2928            &CheckConfig::default(),
2929        );
2930        assert!(report.has_dispute());
2931        assert!(
2932            report
2933                .findings
2934                .iter()
2935                .any(|f| f.kind == FindingKind::TariffDeviation)
2936        );
2937    }
2938
2939    #[test]
2940    fn clean_invoice_outcome_is_ok() {
2941        let price = EuroAmount::from_raw_units(3_456);
2942        let net = EuroAmount::from_raw_units(3_456_000);
2943        let pos = make_pos(1, "DE001", Some("1000.0"), Some(price), Some(net));
2944        let r = make_rechnung(vec![pos], Some(net));
2945        let report = InvoicCheckEngine::check(
2946            31001,
2947            SENDER,
2948            &r,
2949            &seeded_store(price),
2950            &CheckConfig::default(),
2951        );
2952        assert_eq!(report.outcome, CheckOutcome::Ok);
2953        assert!(report.findings.is_empty());
2954    }
2955
2956    #[test]
2957    fn pid_is_carried_in_report() {
2958        let r = make_rechnung(vec![], None);
2959        let report =
2960            InvoicCheckEngine::check(31005, SENDER, &r, &empty_store(), &CheckConfig::default());
2961        assert_eq!(report.pid, 31005);
2962    }
2963
2964    // ── Stornierung tests ─────────────────────────────────────────────────────
2965
2966    #[test]
2967    fn stornierung_with_reference_skips_tariff_check() {
2968        // A valid Storno: ist_storno=true + original_rechnungsnummer present.
2969        // Tariff stage must be skipped — no TariffNotFound finding expected.
2970        let price = EuroAmount::from_raw_units(3_456);
2971        let net = EuroAmount::from_raw_units(3_456_000);
2972        let pos = make_pos(1, "DE001", Some("1000.0"), Some(price), Some(net));
2973        let mut r = make_rechnung(vec![pos], Some(net));
2974        r.ist_storno = Some(true);
2975        r.original_rechnungsnummer = Some("31001-2025-0042".to_owned());
2976
2977        // Empty tariff store — would produce TariffNotFound if tariff stage ran.
2978        let report =
2979            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
2980        assert_eq!(
2981            report.outcome,
2982            CheckOutcome::Ok,
2983            "Storno with valid ref + correct arithmetic should be Ok"
2984        );
2985        assert!(
2986            !report
2987                .findings
2988                .iter()
2989                .any(|f| f.kind == FindingKind::TariffNotFound),
2990            "Tariff stage must be skipped for Stornierung"
2991        );
2992    }
2993
2994    #[test]
2995    fn stornierung_without_reference_is_dispute() {
2996        // ist_storno=true but original_rechnungsnummer absent → StorniertWithoutReference.
2997        let mut r = make_rechnung(vec![], None);
2998        r.ist_storno = Some(true);
2999        r.original_rechnungsnummer = None;
3000
3001        let report =
3002            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
3003        assert!(report.has_dispute());
3004        assert!(
3005            report
3006                .findings
3007                .iter()
3008                .any(|f| f.kind == FindingKind::StorniertWithoutReference),
3009            "Missing original_rechnungsnummer must produce StorniertWithoutReference"
3010        );
3011    }
3012
3013    #[test]
3014    fn is_stornierung_predicate() {
3015        let mut r = Rechnung::default();
3016        assert!(!is_stornierung(&r), "default Rechnung is not a Storno");
3017        r.ist_storno = Some(true);
3018        assert!(is_stornierung(&r), "ist_storno=true → is Storno");
3019        r.ist_storno = Some(false);
3020        assert!(!is_stornierung(&r), "ist_storno=false → not Storno");
3021    }
3022
3023    #[test]
3024    fn check_storno_clean_returns_ok() {
3025        let price = EuroAmount::from_raw_units(3_456);
3026        let net = EuroAmount::from_raw_units(3_456_000);
3027        let pos = make_pos(1, "DE001", Some("1000.0"), Some(price), Some(net));
3028        let mut r = make_rechnung(vec![pos], Some(net));
3029        r.ist_storno = Some(true);
3030        r.original_rechnungsnummer = Some("31001-2025-0042".to_owned());
3031
3032        let report = InvoicCheckEngine::check_storno(31004, &r, &CheckConfig::default());
3033        assert_eq!(report.outcome, CheckOutcome::Ok);
3034        assert!(report.findings.is_empty());
3035    }
3036
3037    /// A Storno reverses an invoice that had to state Umsatzsteuer, so it states
3038    /// its own — and 31004 publishes the header `TAX` (Nr 00058) and `MOA`
3039    /// (00061/00062) as **Muss** in both imported Formatversionen. The Storno
3040    /// path skipped the Steuer stage entirely, so a reversal with no tax block
3041    /// at all was accepted.
3042    #[test]
3043    fn a_storno_without_a_tax_block_is_disputed() {
3044        let r = Rechnung {
3045            ist_storno: Some(true),
3046            original_rechnungsnummer: Some("31001-2026-001".to_owned()),
3047            ..Default::default()
3048        };
3049
3050        let report = InvoicCheckEngine::check_storno(31_004, &r, &CheckConfig::default());
3051        assert!(
3052            report
3053                .findings
3054                .iter()
3055                .any(|f| f.kind == FindingKind::SteuerMissing),
3056            "a Storno stating no Umsatzsteuer must be disputed, got {:?}",
3057            report.findings
3058        );
3059    }
3060
3061    /// The negated amounts are not an obstacle: `check_steuer` asserts
3062    /// `netto + steuer == brutto` and that the breakdown sums to `gesamtsteuer`,
3063    /// both of which hold under negation. A correctly-reversed Storno passes.
3064    #[test]
3065    fn a_storno_that_reverses_its_tax_is_accepted() {
3066        let r = Rechnung {
3067            ist_storno: Some(true),
3068            original_rechnungsnummer: Some("31001-2026-001".to_owned()),
3069            gesamtnetto: Some(betrag(EuroAmount::from_raw_units(-100_000))),
3070            gesamtsteuer: Some(betrag(EuroAmount::from_raw_units(-19_000))),
3071            gesamtbrutto: Some(betrag(EuroAmount::from_raw_units(-119_000))),
3072            steuerbetraege: Some(vec![rubo4e::current::Steuerbetrag {
3073                steuersatz: Some(Decimal::from(19)),
3074                steuerwert: Some(Decimal::from(-190)),
3075                ..Default::default()
3076            }]),
3077            ..Default::default()
3078        };
3079
3080        let report = InvoicCheckEngine::check_storno(31_004, &r, &CheckConfig::default());
3081        assert!(
3082            !report
3083                .findings
3084                .iter()
3085                .any(|f| f.kind == FindingKind::SteuerMissing),
3086            "a Storno that reverses its tax states one, got {:?}",
3087            report.findings
3088        );
3089    }
3090
3091    /// Stating `0` is not the same as stating nothing, but with no breakdown and
3092    /// no reverse-charge entry it names no ground either — and that shape passed
3093    /// silently on every check path. It warns rather than disputes, because a
3094    /// §19 UStG Kleinunternehmer invoice may carry its ground in free text.
3095    #[test]
3096    fn zero_tax_with_no_stated_ground_is_reported() {
3097        let pos = make_pos(
3098            1,
3099            "DE001",
3100            None,
3101            None,
3102            Some(EuroAmount::from_raw_units(100_000)),
3103        );
3104        let mut r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(100_000)));
3105        r.gesamtsteuer = Some(betrag(EuroAmount::ZERO));
3106        r.gesamtbrutto = Some(betrag(EuroAmount::from_raw_units(100_000)));
3107        r.steuerbetraege = None;
3108
3109        let mut findings = Vec::new();
3110        InvoicCheckEngine::check_steuer(&r, &CheckConfig::default(), &mut findings);
3111        let f = findings
3112            .iter()
3113            .find(|f| f.kind == FindingKind::SteuerMissing)
3114            .expect("zero tax with no ground is reported");
3115        assert!(
3116            !f.is_dispute,
3117            "a lawful §19 UStG invoice must not be refused outright"
3118        );
3119    }
3120
3121    #[test]
3122    fn check_storno_without_reference_is_dispute() {
3123        let mut r = make_rechnung(vec![], None);
3124        r.ist_storno = Some(true);
3125        r.original_rechnungsnummer = None;
3126
3127        let report = InvoicCheckEngine::check_storno(31004, &r, &CheckConfig::default());
3128        assert!(report.has_dispute());
3129        assert!(
3130            report
3131                .findings
3132                .iter()
3133                .any(|f| f.kind == FindingKind::StorniertWithoutReference)
3134        );
3135    }
3136
3137    // ── Zahlungsziel tests ────────────────────────────────────────────────────
3138
3139    #[test]
3140    fn zahlungsziel_within_limit_no_finding() {
3141        let mut r = make_rechnung(vec![], None);
3142        r.rechnungsdatum = Some(parse_dt("2026-07-01"));
3143        r.faelligkeitsdatum = Some(parse_dt("2026-07-31")); // exactly 30 days
3144
3145        let report =
3146            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
3147        assert!(
3148            !report
3149                .findings
3150                .iter()
3151                .any(|f| f.kind == FindingKind::ZahlungszielExceeded),
3152            "Exactly 30 days is within the default limit"
3153        );
3154    }
3155
3156    #[test]
3157    fn zahlungsziel_exceeded_is_warn() {
3158        let mut r = make_rechnung(vec![], None);
3159        r.rechnungsdatum = Some(parse_dt("2026-07-01"));
3160        r.faelligkeitsdatum = Some(parse_dt("2026-09-01")); // 62 days — exceeds 30
3161
3162        let report =
3163            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
3164        let finding = report
3165            .findings
3166            .iter()
3167            .find(|f| f.kind == FindingKind::ZahlungszielExceeded);
3168        assert!(
3169            finding.is_some(),
3170            "62-day payment term must produce ZahlungszielExceeded"
3171        );
3172        assert!(
3173            !finding.unwrap().is_dispute,
3174            "ZahlungszielExceeded is Warn, not Dispute"
3175        );
3176    }
3177
3178    #[test]
3179    fn zahlungsziel_before_invoice_date_is_dispute() {
3180        let mut r = make_rechnung(vec![], None);
3181        r.rechnungsdatum = Some(parse_dt("2026-07-15"));
3182        r.faelligkeitsdatum = Some(parse_dt("2026-07-01")); // before invoice date
3183
3184        let report =
3185            InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
3186        assert!(report.has_dispute());
3187        assert!(
3188            report
3189                .findings
3190                .iter()
3191                .any(|f| f.kind == FindingKind::ZahlungszielInvalid),
3192            "pay_by before rechnungsdatum must produce ZahlungszielInvalid Dispute"
3193        );
3194    }
3195
3196    #[test]
3197    fn zahlungsziel_check_disabled_at_zero() {
3198        let mut r = make_rechnung(vec![], None);
3199        r.rechnungsdatum = Some(parse_dt("2026-01-01"));
3200        r.faelligkeitsdatum = Some(parse_dt("2026-12-31")); // 364 days — would normally trigger
3201
3202        let config = CheckConfig {
3203            max_zahlungsziel_days: 0,
3204            ..Default::default()
3205        };
3206        let report = InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &config);
3207        assert!(
3208            !report.findings.iter().any(|f| matches!(
3209                f.kind,
3210                FindingKind::ZahlungszielExceeded | FindingKind::ZahlungszielInvalid
3211            )),
3212            "Zahlungsziel check must be skipped when max_zahlungsziel_days = 0"
3213        );
3214    }
3215
3216    // ── The ESA's price basis is its accepted Angebot ─────────────────────────
3217
3218    /// An ESA-flavoured position: the same shape as `make_pos`, plus the
3219    /// `SG26 LIN` DE 7143 `Z09` Artikel-ID that joins it to the offer.
3220    fn esa_pos(n: i64, artikel_id: &str, price: EuroAmount) -> Rechnungsposition {
3221        Rechnungsposition {
3222            artikel_id: Some(artikel_id.to_owned()),
3223            ..make_pos(n, "ESA-Messprodukt", Some("1"), Some(price), Some(price))
3224        }
3225    }
3226
3227    /// `EuroAmount` is fixed-point at 5 decimal places, so one cent is 1 000
3228    /// raw units.
3229    fn cents(n: i64) -> EuroAmount {
3230        EuroAmount::from_raw_units(n * 1_000)
3231    }
3232
3233    fn agreed() -> Vec<(String, EuroAmount)> {
3234        vec![
3235            // Betriebspreis, per Tag.
3236            ("9990001100002".to_owned(), cents(1)),
3237            // Einrichtungspreis, per Stück.
3238            ("9990001100001".to_owned(), cents(2_500)),
3239        ]
3240    }
3241
3242    /// The offer priced it, the invoice bills it, the two agree.
3243    #[test]
3244    fn an_invoice_matching_the_accepted_angebot_passes() {
3245        let r = make_rechnung(
3246            vec![
3247                esa_pos(1, "9990001100002", cents(1)),
3248                esa_pos(2, "9990001100001", cents(2_500)),
3249            ],
3250            Some(cents(2_501)),
3251        );
3252        let report =
3253            InvoicCheckEngine::check_esa_rechnung(SENDER, &r, &agreed(), &CheckConfig::default());
3254        assert_eq!(
3255            report.outcome,
3256            CheckOutcome::Ok,
3257            "clean ESA invoice: {:?}",
3258            report.findings
3259        );
3260        assert_eq!(report.pid, 31009);
3261    }
3262
3263    /// A price the ESA never agreed to is a dispute — and this is the check an
3264    /// ESA had **no** substitute for: `PreisblattMessung` is the MSB's sheet
3265    /// toward NB and LF, and there is none for Kapitel-4.6 Messprodukte, so the
3266    /// Preisblatt path skipped price checking entirely.
3267    #[test]
3268    fn a_position_billed_above_the_agreed_price_is_disputed() {
3269        let r = make_rechnung(
3270            // Agreed 25.00, billed 40.00.
3271            vec![esa_pos(1, "9990001100001", cents(4_000))],
3272            Some(cents(4_000)),
3273        );
3274        let report =
3275            InvoicCheckEngine::check_esa_rechnung(SENDER, &r, &agreed(), &CheckConfig::default());
3276        assert_eq!(report.outcome, CheckOutcome::Dispute);
3277        let f = report
3278            .findings
3279            .iter()
3280            .find(|f| f.kind == FindingKind::AngebotDeviation)
3281            .expect("the deviation is reported");
3282        assert_eq!(f.expected, Some(cents(2_500)));
3283        assert_eq!(f.actual, Some(cents(4_000)));
3284        assert!(f.is_dispute);
3285    }
3286
3287    /// The offer prices one to three Artikel-IDs per position block (QUOTES AHB
3288    /// 1.1a condition `[2042]`); a fourth on the invoice is a charge nobody
3289    /// agreed to, which is a different defect from a wrong price.
3290    #[test]
3291    fn an_artikel_id_the_angebot_never_priced_is_its_own_finding() {
3292        let r = make_rechnung(
3293            vec![esa_pos(1, "9990009900009", cents(500))],
3294            Some(cents(500)),
3295        );
3296        let report =
3297            InvoicCheckEngine::check_esa_rechnung(SENDER, &r, &agreed(), &CheckConfig::default());
3298        assert_eq!(report.outcome, CheckOutcome::Dispute);
3299        assert!(
3300            report
3301                .findings
3302                .iter()
3303                .any(|f| f.kind == FindingKind::AngebotPositionUnknown),
3304            "{:?}",
3305            report.findings
3306        );
3307    }
3308
3309    /// No accepted offer on record is a gap in **mako's** records, not a defect
3310    /// in the MSB's invoice — so it warns and skips, never disputes. Disputing
3311    /// it would send a REMADV 33002 rejecting a correct invoice.
3312    #[test]
3313    fn a_missing_angebot_warns_rather_than_disputing() {
3314        let r = make_rechnung(vec![esa_pos(1, "9990001100002", cents(1))], Some(cents(1)));
3315        let report =
3316            InvoicCheckEngine::check_esa_rechnung(SENDER, &r, &[], &CheckConfig::default());
3317        assert_eq!(report.outcome, CheckOutcome::Warn);
3318        let f = report
3319            .findings
3320            .iter()
3321            .find(|f| f.kind == FindingKind::TariffNotFound)
3322            .expect("the gap is reported");
3323        assert!(!f.is_dispute);
3324        assert!(f.message.contains("Angebot"), "{}", f.message);
3325    }
3326
3327    /// DE 7143 admits `Z01` Artikelnummer beside `Z09` Artikel-ID, and an
3328    /// Artikelnummer names no offer position — so such a line is not comparable
3329    /// rather than wrong.
3330    #[test]
3331    fn a_position_without_an_artikel_id_is_skipped_not_disputed() {
3332        let r = make_rechnung(
3333            vec![make_pos(
3334                1,
3335                "Artikelnummer-Position",
3336                Some("1"),
3337                Some(cents(999)),
3338                Some(cents(999)),
3339            )],
3340            Some(cents(999)),
3341        );
3342        let report =
3343            InvoicCheckEngine::check_esa_rechnung(SENDER, &r, &agreed(), &CheckConfig::default());
3344        assert_eq!(report.outcome, CheckOutcome::Warn);
3345        assert!(
3346            report
3347                .findings
3348                .iter()
3349                .all(|f| f.kind != FindingKind::AngebotDeviation)
3350        );
3351    }
3352
3353    /// The structural checks still run: an ESA invoice is an invoice.
3354    #[test]
3355    fn the_esa_path_still_checks_arithmetic_and_totals() {
3356        let r = make_rechnung(
3357            // 1 × 0.01 EUR billed as a 5.00 EUR line net.
3358            vec![Rechnungsposition {
3359                artikel_id: Some("9990001100002".to_owned()),
3360                ..make_pos(1, "ESA", Some("1"), Some(cents(1)), Some(cents(500)))
3361            }],
3362            Some(cents(500)),
3363        );
3364        let report =
3365            InvoicCheckEngine::check_esa_rechnung(SENDER, &r, &agreed(), &CheckConfig::default());
3366        assert!(
3367            report
3368                .findings
3369                .iter()
3370                .any(|f| f.kind == FindingKind::ArithmeticError),
3371            "{:?}",
3372            report.findings
3373        );
3374    }
3375}
3376
3377#[cfg(test)]
3378mod waehrung_tests {
3379    use super::{CheckConfig, FindingKind, InvoicCheckEngine};
3380    use rubo4e::current::{Betrag, Rechnung, Waehrungscode};
3381    use rust_decimal::dec;
3382
3383    fn betrag(wert: rust_decimal::Decimal, waehrung: Waehrungscode) -> Option<Betrag> {
3384        Some(Betrag {
3385            wert: Some(wert),
3386            waehrung: Some(waehrung),
3387            ..Default::default()
3388        })
3389    }
3390
3391    /// The arithmetic below this check reads every amount as EUR, so a
3392    /// mixed-currency invoice does not fail it — it *passes* it, wrongly.
3393    #[test]
3394    fn a_mixed_currency_invoice_is_disputed() {
3395        let mut findings = Vec::new();
3396        let r = Rechnung {
3397            gesamtnetto: betrag(dec!(300.00), Waehrungscode::Eur),
3398            gesamtsteuer: betrag(dec!(57.00), Waehrungscode::Eur),
3399            gesamtbrutto: betrag(dec!(357.00), Waehrungscode::Chf),
3400            ..Default::default()
3401        };
3402        InvoicCheckEngine::check_waehrung(&r, &mut findings);
3403        assert_eq!(findings.len(), 1);
3404        assert_eq!(findings[0].kind, FindingKind::WaehrungMismatch);
3405        assert!(findings[0].is_dispute);
3406        // …and note the totals themselves reconcile, which is the point.
3407        assert_eq!(dec!(300.00) + dec!(57.00), dec!(357.00));
3408    }
3409
3410    #[test]
3411    fn one_currency_throughout_is_silent() {
3412        let mut findings = Vec::new();
3413        let r = Rechnung {
3414            gesamtnetto: betrag(dec!(300.00), Waehrungscode::Eur),
3415            gesamtbrutto: betrag(dec!(357.00), Waehrungscode::Eur),
3416            ..Default::default()
3417        };
3418        InvoicCheckEngine::check_waehrung(&r, &mut findings);
3419        assert!(findings.is_empty());
3420    }
3421
3422    /// **Invariant: the check reaches every field that names a currency.**
3423    ///
3424    /// A position or a Steuerbetrag denominated differently from the header is
3425    /// read as EUR by every later stage exactly as a header field would be — and
3426    /// it is the positions that carry the arithmetic the recipient pays from.
3427    #[test]
3428    fn a_position_or_tax_entry_in_another_currency_is_disputed() {
3429        use rubo4e::current::{Rechnungsposition, Steuerbetrag};
3430
3431        let mut findings = Vec::new();
3432        let r = Rechnung {
3433            gesamtnetto: betrag(dec!(300.00), Waehrungscode::Eur),
3434            rechnungspositionen: Some(vec![Rechnungsposition {
3435                positionsnummer: Some(1),
3436                gesamtpreis: betrag(dec!(300.00), Waehrungscode::Chf),
3437                ..Default::default()
3438            }]),
3439            ..Default::default()
3440        };
3441        InvoicCheckEngine::check_waehrung(&r, &mut findings);
3442        assert_eq!(findings.len(), 1, "{findings:?}");
3443        assert_eq!(findings[0].kind, FindingKind::WaehrungMismatch);
3444
3445        let mut findings = Vec::new();
3446        let r = Rechnung {
3447            gesamtnetto: betrag(dec!(300.00), Waehrungscode::Eur),
3448            steuerbetraege: Some(vec![Steuerbetrag {
3449                waehrungscode: Some(Waehrungscode::Chf),
3450                ..Default::default()
3451            }]),
3452            ..Default::default()
3453        };
3454        InvoicCheckEngine::check_waehrung(&r, &mut findings);
3455        assert_eq!(findings.len(), 1, "{findings:?}");
3456        assert_eq!(findings[0].kind, FindingKind::WaehrungMismatch);
3457    }
3458
3459    /// A document that states no currency at all is not this check's business —
3460    /// BO4E makes the field optional, and there is nothing to disagree about.
3461    #[test]
3462    fn an_absent_currency_is_not_a_mismatch() {
3463        let mut findings = Vec::new();
3464        let r = Rechnung {
3465            gesamtnetto: Some(Betrag {
3466                wert: Some(dec!(300.00)),
3467                ..Default::default()
3468            }),
3469            ..Default::default()
3470        };
3471        InvoicCheckEngine::check_waehrung(&r, &mut findings);
3472        assert!(findings.is_empty());
3473        let _ = CheckConfig::default();
3474    }
3475}