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}