use rubo4e::convenience::{BetragExt, MengeExt, PreisExt};
use rubo4e::current::{Rechnung, Rechnungsposition};
use crate::{amount::EuroAmount, tariff::PreisblattStore};
#[derive(Debug, Clone)]
pub struct CheckConfig {
pub arithmetic_tolerance_ppm: u32,
pub total_tolerance_ppm: u32,
pub tariff_tolerance_ppm: u32,
pub require_tariff: bool,
pub max_zahlungsziel_days: u16,
}
impl Default for CheckConfig {
fn default() -> Self {
Self {
arithmetic_tolerance_ppm: 10_000,
total_tolerance_ppm: 10_000,
tariff_tolerance_ppm: 20_000,
require_tariff: false,
max_zahlungsziel_days: 30,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum CheckOutcome {
Ok,
Warn,
Dispute,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum FindingKind {
PeriodInvalid,
ArithmeticError,
TotalMismatch,
TariffDeviation,
TariffNotFound,
StorniertWithoutReference,
ZahlungszielExceeded,
ZahlungszielInvalid,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Finding {
pub kind: FindingKind,
pub is_dispute: bool,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_number: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected: Option<EuroAmount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual: Option<EuroAmount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deviation_pct: Option<f64>,
}
impl Finding {
fn dispute(
kind: FindingKind,
message: impl Into<String>,
line_number: Option<u32>,
expected: Option<EuroAmount>,
actual: Option<EuroAmount>,
) -> Self {
let deviation_pct = deviation(expected, actual);
Self {
kind,
is_dispute: true,
message: message.into(),
line_number,
expected,
actual,
deviation_pct,
}
}
fn warn(
kind: FindingKind,
message: impl Into<String>,
line_number: Option<u32>,
expected: Option<EuroAmount>,
actual: Option<EuroAmount>,
) -> Self {
let deviation_pct = deviation(expected, actual);
Self {
kind,
is_dispute: false,
message: message.into(),
line_number,
expected,
actual,
deviation_pct,
}
}
}
fn deviation(expected: Option<EuroAmount>, actual: Option<EuroAmount>) -> Option<f64> {
match (expected, actual) {
(Some(exp), Some(act)) if exp.to_raw() != 0 => {
Some((act.to_raw() - exp.to_raw()) as f64 / exp.to_raw().unsigned_abs() as f64 * 100.0)
}
_ => None,
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CheckReport {
pub outcome: CheckOutcome,
pub findings: Vec<Finding>,
pub pid: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_net_invoic: Option<EuroAmount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_net_computed: Option<EuroAmount>,
pub line_items_checked: usize,
}
impl CheckReport {
#[must_use]
pub fn is_ok(&self) -> bool {
self.outcome == CheckOutcome::Ok
}
#[must_use]
pub fn has_dispute(&self) -> bool {
self.outcome == CheckOutcome::Dispute
}
}
#[must_use]
pub fn is_stornierung(rechnung: &Rechnung) -> bool {
rechnung.ist_storno == Some(true)
}
pub struct InvoicCheckEngine;
impl InvoicCheckEngine {
#[must_use]
pub fn check(
pid: u32,
sender_mp_id: &str,
rechnung: &Rechnung,
preisblatt_store: &dyn PreisblattStore,
config: &CheckConfig,
) -> CheckReport {
let mut findings: Vec<Finding> = Vec::new();
let storno = is_stornierung(rechnung);
if storno
&& rechnung
.original_rechnungsnummer
.as_deref()
.unwrap_or("")
.is_empty()
{
findings.push(Finding::dispute(
FindingKind::StorniertWithoutReference,
"Stornorechnung (ist_storno=true) does not reference the original invoice \
(original_rechnungsnummer is missing). \
Source: BK6-24-174 §5; Allgemeine Festlegungen §8.",
None,
None,
None,
));
}
Self::check_periods(rechnung, &mut findings);
if config.max_zahlungsziel_days > 0 {
Self::check_zahlungsziel(rechnung, config, &mut findings);
}
Self::check_arithmetic(rechnung, config, &mut findings);
let computed_total = Self::check_total(rechnung, config, &mut findings);
if !storno {
Self::check_tariffs(
rechnung,
sender_mp_id,
preisblatt_store,
config,
&mut findings,
);
}
let outcome = findings
.iter()
.map(|f| {
if f.is_dispute {
CheckOutcome::Dispute
} else {
CheckOutcome::Warn
}
})
.max()
.unwrap_or(CheckOutcome::Ok);
let total_net_invoic = rechnung
.gesamtnetto
.wert_decimal()
.and_then(EuroAmount::from_decimal);
CheckReport {
outcome,
findings,
pid,
total_net_invoic,
total_net_computed: computed_total,
line_items_checked: rechnung.rechnungspositionen.iter().flatten().count(),
}
}
fn check_zahlungsziel(rechnung: &Rechnung, config: &CheckConfig, findings: &mut Vec<Finding>) {
let Some(faellig) = rechnung.faelligkeitsdatum else {
return; };
let rechnungs_datum = match rechnung.rechnungsdatum {
Some(d) => d,
None => return, };
if faellig < rechnungs_datum {
findings.push(Finding::dispute(
FindingKind::ZahlungszielInvalid,
format!(
"Zahlungsziel {faellig} is before invoice date {rechnungs_datum}. \
DTM+92 must not precede rechnungsdatum. \
Source: §7 Allgemeine Festlegungen V6.1d.",
),
None,
None,
None,
));
return;
}
let days = (faellig - rechnungs_datum).whole_days();
let max = config.max_zahlungsziel_days as i64;
if max > 0 && days > max {
findings.push(Finding {
kind: FindingKind::ZahlungszielExceeded,
is_dispute: false, message: format!(
"Zahlungsziel is {days} days (from {rechnungs_datum} to {faellig}), \
exceeding the {max}-day maximum per §7 Allgemeine Festlegungen V6.1d. \
Review before payment.",
),
line_number: None,
expected: None,
actual: None,
deviation_pct: Some(days as f64 - max as f64),
});
}
}
fn check_periods(rechnung: &Rechnung, findings: &mut Vec<Finding>) {
if let Some((start, end)) = rechnung.billing_period()
&& start >= end
{
findings.push(Finding::dispute(
FindingKind::PeriodInvalid,
format!("Message-level billing period invalid: start {start} ≥ end {end}"),
None,
None,
None,
));
}
for pos in rechnung.rechnungspositionen.iter().flatten() {
if let (Some(start), Some(end)) = (pos.lieferung_von_date(), pos.lieferung_bis_date())
&& start >= end
{
let (line_no, malo) = pos_ident(pos);
findings.push(Finding::dispute(
FindingKind::PeriodInvalid,
format!(
"Line {line_no} ({malo}) billing period invalid: start {start} ≥ end {end}"
),
Some(line_no),
None,
None,
));
}
}
}
fn check_arithmetic(rechnung: &Rechnung, config: &CheckConfig, findings: &mut Vec<Finding>) {
for pos in rechnung.rechnungspositionen.iter().flatten() {
let qty = pos.positions_menge.wert_decimal();
let price = pos
.einzelpreis
.wert_decimal()
.and_then(EuroAmount::from_decimal);
let stated_net = pos
.gesamtpreis
.wert_decimal()
.and_then(EuroAmount::from_decimal);
if let (Some(qty), Some(price), Some(stated_net)) = (qty, price, stated_net) {
let computed = price.mul_qty(qty);
if !stated_net
.within_tolerance_ppm(computed, config.arithmetic_tolerance_ppm)
.unwrap_or(false)
{
let (line_no, malo) = pos_ident(pos);
findings.push(Finding {
kind: FindingKind::ArithmeticError,
is_dispute: true,
message: format!(
"Line {line_no} ({malo}): \
{qty} kWh × {price} EUR/kWh = {computed} EUR, \
but Rechnungsposition states {stated_net} EUR",
),
line_number: Some(line_no),
expected: Some(computed),
actual: Some(stated_net),
deviation_pct: deviation(Some(computed), Some(stated_net)),
});
}
}
}
}
fn check_total(
rechnung: &Rechnung,
config: &CheckConfig,
findings: &mut Vec<Finding>,
) -> Option<EuroAmount> {
let line_nets: Vec<EuroAmount> = rechnung
.rechnungspositionen
.iter()
.flatten()
.filter_map(|pos| {
pos.gesamtpreis
.wert_decimal()
.and_then(EuroAmount::from_decimal)
})
.collect();
if line_nets.is_empty() {
return None;
}
let computed = line_nets
.iter()
.copied()
.fold(EuroAmount::ZERO, |acc, a| acc + a);
if let Some(stated) = rechnung
.gesamtnetto
.wert_decimal()
.and_then(EuroAmount::from_decimal)
&& !stated
.within_tolerance_ppm(computed, config.total_tolerance_ppm)
.unwrap_or(false)
{
findings.push(Finding::warn(
FindingKind::TotalMismatch,
format!(
"Total net mismatch: \u{03a3} gesamtpreis = {computed} EUR, \
gesamtnetto = {stated} EUR",
),
None,
Some(computed),
Some(stated),
));
}
Some(computed)
}
fn check_tariffs(
rechnung: &Rechnung,
sender_mp_id: &str,
preisblatt_store: &dyn PreisblattStore,
config: &CheckConfig,
findings: &mut Vec<Finding>,
) {
let billing_date: time::Date = rechnung
.billing_period()
.map(|(start, _)| start)
.or(rechnung.rechnungsdatum)
.unwrap_or_else(|| time::OffsetDateTime::now_utc().date());
if !preisblatt_store.has_preisblatt_for(sender_mp_id) {
findings.push(Finding {
kind: FindingKind::TariffNotFound,
is_dispute: config.require_tariff,
message: format!(
"No PRICAT tariff found for sender GLN {sender_mp_id} on {billing_date}. \
Tariff check skipped — seed the tariff store from PRICAT 27003.",
),
line_number: None,
expected: None,
actual: None,
deviation_pct: None,
});
return;
}
for pos in rechnung.rechnungspositionen.iter().flatten() {
let Some(invoic_price) = pos
.einzelpreis
.wert_decimal()
.and_then(EuroAmount::from_decimal)
else {
continue;
};
let (line_no, malo) = pos_ident(pos);
let line_date = pos.lieferung_von_date().unwrap_or(billing_date);
let Some(preisblatt) = preisblatt_store.get(sender_mp_id, line_date) else {
findings.push(Finding::warn(
FindingKind::TariffNotFound,
format!(
"Line {line_no} ({malo}): no Preisblatt effective on {line_date} \
for GLN {sender_mp_id}",
),
Some(line_no),
None,
Some(invoic_price),
));
continue;
};
let tol = config.tariff_tolerance_ppm;
let flat_prices: Vec<EuroAmount> = preisblatt
.preispositionen
.iter()
.flatten()
.flat_map(|pp| pp.preisstaffeln.iter().flatten())
.filter_map(|ps| ps.preis)
.filter_map(EuroAmount::from_decimal)
.collect();
use rubo4e::json::Bo4eExtensionData as _;
let tou_bands: Vec<(String, EuroAmount)> = preisblatt
.extension_data()
.get("zeitvariablePreispositionen")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let register = entry
.get("zaehlzeitregister")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let price_val = entry
.get("preis")
.and_then(|p| p.get("wert"))
.and_then(|w| w.as_str())
.and_then(|s| rust_decimal::Decimal::from_str_exact(s).ok())
.and_then(EuroAmount::from_decimal)?;
Some((register, price_val))
})
.collect()
})
.unwrap_or_default();
let pos_text = pos.positionstext.as_deref().unwrap_or("").to_lowercase();
let matching_band_prices: Vec<EuroAmount> = tou_bands
.iter()
.filter(|(code, _)| {
let code_lc = code.to_lowercase();
!code_lc.is_empty() && pos_text.contains(code_lc.as_str())
})
.map(|(_, price)| *price)
.collect();
let all_tou_prices: Vec<EuroAmount> = tou_bands.iter().map(|(_, p)| *p).collect();
let published: Vec<EuroAmount> = if !matching_band_prices.is_empty() {
matching_band_prices
} else if !flat_prices.is_empty() {
flat_prices.clone()
} else {
all_tou_prices
};
if published.is_empty() {
findings.push(Finding::warn(
FindingKind::TariffNotFound,
format!(
"Line {line_no} ({malo}): Preisblatt for GLN {sender_mp_id} \
on {line_date} contains no Preisstaffeln — skipping price check",
),
Some(line_no),
None,
Some(invoic_price),
));
continue;
}
if !published
.iter()
.any(|p| invoic_price.within_tolerance_ppm(*p, tol).unwrap_or(false))
{
let closest = *published
.iter()
.min_by_key(|p| (invoic_price.to_raw() - p.to_raw()).unsigned_abs())
.unwrap_or(&EuroAmount::ZERO);
findings.push(Finding::dispute(
FindingKind::TariffDeviation,
format!(
"Line {line_no} ({malo}): einzelpreis {invoic_price} EUR/kWh \
does not match any published rate in Preisblatt for GLN {sender_mp_id} \
on {line_date} (closest: {closest} EUR/kWh, tolerance {pct:.1}%)",
pct = tol as f64 / 10_000.0,
),
Some(line_no),
Some(closest),
Some(invoic_price),
));
}
}
}
#[must_use]
pub fn check_msb_rechnung(
sender_mp_id: &str,
rechnung: &Rechnung,
preisblatt_messung: Option<&rubo4e::current::PreisblattMessung>,
config: &CheckConfig,
) -> CheckReport {
Self::check_msb_rechnung_with_aufabschlaege(
sender_mp_id,
rechnung,
preisblatt_messung,
&[],
config,
)
}
pub fn check_msb_rechnung_with_aufabschlaege(
sender_mp_id: &str,
rechnung: &Rechnung,
preisblatt_messung: Option<&rubo4e::current::PreisblattMessung>,
contracted_names: &[String],
config: &CheckConfig,
) -> CheckReport {
let mut findings = Vec::new();
Self::check_periods(rechnung, &mut findings);
Self::check_arithmetic(rechnung, config, &mut findings);
let computed_total = Self::check_total(rechnung, config, &mut findings);
let billing_date: time::Date = rechnung
.billing_period()
.map(|(start, _)| start)
.or(rechnung.rechnungsdatum)
.unwrap_or_else(|| time::OffsetDateTime::now_utc().date());
let published_prices: Vec<EuroAmount> = preisblatt_messung
.and_then(|pm| pm.preispositionen.as_ref())
.into_iter()
.flatten()
.flat_map(|pp| pp.preisstaffeln.iter().flatten())
.filter_map(|ps| ps.preis)
.filter_map(EuroAmount::from_decimal)
.collect();
if preisblatt_messung.is_none() {
findings.push(Finding {
kind: FindingKind::TariffNotFound,
is_dispute: config.require_tariff,
message: format!(
"No PreisblattMessung found for MSB GLN {sender_mp_id} on {billing_date}. \
Tariff check 4/5 skipped — upload via \
PUT /api/v1/preisblaetter-messung/{{msb_mp_id}}.",
),
line_number: None,
expected: None,
actual: None,
deviation_pct: None,
});
} else {
let tol = config.tariff_tolerance_ppm;
for pos in rechnung.rechnungspositionen.iter().flatten() {
let Some(invoic_price) = pos
.einzelpreis
.wert_decimal()
.and_then(EuroAmount::from_decimal)
else {
continue;
};
let (line_no, malo) = pos_ident(pos);
if published_prices.is_empty() {
findings.push(Finding::warn(
FindingKind::TariffNotFound,
format!(
"Line {line_no} ({malo}): PreisblattMessung for GLN \
{sender_mp_id} contains no Preisstaffeln — skipping price check",
),
Some(line_no),
None,
Some(invoic_price),
));
continue;
}
if !published_prices
.iter()
.any(|p| invoic_price.within_tolerance_ppm(*p, tol).unwrap_or(false))
{
let closest = *published_prices
.iter()
.min_by_key(|p| (invoic_price.to_raw() - p.to_raw()).unsigned_abs())
.unwrap_or(&EuroAmount::ZERO);
findings.push(Finding::dispute(
FindingKind::TariffDeviation,
format!(
"Line {line_no} ({malo}): einzelpreis {invoic_price} does not \
match any MSB tariff in PreisblattMessung for GLN {sender_mp_id} \
on {billing_date} (closest: {closest}, tolerance {pct:.1}%)",
pct = tol as f64 / 10_000.0,
),
Some(line_no),
Some(closest),
Some(invoic_price),
));
}
}
}
if !contracted_names.is_empty() {
let name_set: std::collections::HashSet<String> =
contracted_names.iter().map(|s| s.to_lowercase()).collect();
for pos in rechnung.rechnungspositionen.iter().flatten() {
let net = pos.einzelpreis.wert_decimal().unwrap_or_default();
if net >= rust_decimal::Decimal::ZERO {
continue; }
let (line_no, malo) = pos_ident(pos);
let description = pos.positionstext.as_deref().unwrap_or("").to_lowercase();
let is_contracted = name_set
.iter()
.any(|name: &String| description.contains(name.as_str()));
if !is_contracted {
findings.push(Finding::dispute(
FindingKind::TariffNotFound,
format!(
"Line {line_no} ({malo}): discount \"{}\" not backed by \
any AufAbschlag in PreisblattMessung for GLN {sender_mp_id} \
(check 6). Verify PRICAT 27001-27003.",
pos.positionstext.as_deref().unwrap_or("?"),
),
Some(line_no),
None,
None,
));
}
}
}
let outcome = findings
.iter()
.map(|f| {
if f.is_dispute {
CheckOutcome::Dispute
} else {
CheckOutcome::Warn
}
})
.max()
.unwrap_or(CheckOutcome::Ok);
let total_net_invoic = rechnung
.gesamtnetto
.wert_decimal()
.and_then(EuroAmount::from_decimal);
CheckReport {
outcome,
findings,
pid: 31009,
total_net_invoic,
total_net_computed: computed_total,
line_items_checked: rechnung.rechnungspositionen.iter().flatten().count(),
}
}
#[must_use]
pub fn check_storno(pid: u32, rechnung: &Rechnung, config: &CheckConfig) -> CheckReport {
let mut findings = Vec::new();
if rechnung
.original_rechnungsnummer
.as_deref()
.unwrap_or("")
.is_empty()
{
findings.push(Finding::dispute(
FindingKind::StorniertWithoutReference,
"Stornorechnung does not reference the original invoice \
(original_rechnungsnummer is missing). Source: BK6-24-174 §5.",
None,
None,
None,
));
}
Self::check_periods(rechnung, &mut findings);
if config.max_zahlungsziel_days > 0 {
Self::check_zahlungsziel(rechnung, config, &mut findings);
}
Self::check_arithmetic(rechnung, config, &mut findings);
let computed_total = Self::check_total(rechnung, config, &mut findings);
let outcome = findings
.iter()
.map(|f| {
if f.is_dispute {
CheckOutcome::Dispute
} else {
CheckOutcome::Warn
}
})
.max()
.unwrap_or(CheckOutcome::Ok);
let total_net_invoic = rechnung
.gesamtnetto
.wert_decimal()
.and_then(EuroAmount::from_decimal);
CheckReport {
outcome,
findings,
pid,
total_net_invoic,
total_net_computed: computed_total,
line_items_checked: rechnung.rechnungspositionen.iter().flatten().count(),
}
}
pub fn check_mmm_settlement(
rechnung: &Rechnung,
mehr_ct_kwh: rust_decimal::Decimal,
minder_ct_kwh: rust_decimal::Decimal,
config: &CheckConfig,
) -> Vec<Finding> {
let tol = config.tariff_tolerance_ppm;
let ref_mehr = EuroAmount::from_decimal(mehr_ct_kwh / rust_decimal::Decimal::from(100));
let ref_minder = EuroAmount::from_decimal(minder_ct_kwh / rust_decimal::Decimal::from(100));
let mut findings = Vec::new();
for pos in rechnung.rechnungspositionen.iter().flatten() {
let Some(invoic_price) = pos
.einzelpreis
.wert_decimal()
.and_then(EuroAmount::from_decimal)
else {
continue;
};
let (line_no, malo) = pos_ident(pos);
let text = pos.positionstext.as_deref().unwrap_or("").to_lowercase();
let is_mehr = text.contains("mehrmengen");
let is_minder = text.contains("mindermengen");
if !is_mehr && !is_minder {
continue;
}
let Some(ref_p) = (if is_mehr { ref_mehr } else { ref_minder }) else {
continue;
};
if !invoic_price
.within_tolerance_ppm(ref_p, tol)
.unwrap_or(false)
{
let ref_raw = ref_p.to_raw() as f64;
let pct = if ref_raw != 0.0 {
((invoic_price.to_raw() as f64 - ref_raw) / ref_raw.abs() * 100.0).abs()
} else {
0.0
};
let kind_str = if is_mehr {
"Mehrmengen"
} else {
"Mindermengen"
};
findings.push(Finding {
kind: FindingKind::TariffDeviation,
is_dispute: config.require_tariff,
message: format!(
"Line {line_no} ({malo}): MMM {kind_str} price {invoic_price} EUR/kWh deviates {pct:.1}% from MMMA reference {ref_p} EUR/kWh (tolerance {t:.1}%)",
t = tol as f64 / 10_000.0,
),
line_number: Some(line_no),
expected: Some(ref_p),
actual: Some(invoic_price),
deviation_pct: Some(pct),
});
}
}
findings
}
}
fn pos_ident(pos: &Rechnungsposition) -> (u32, &str) {
let line_no = pos.positionsnummer.unwrap_or(0) as u32;
let malo = pos.positionstext.as_deref().unwrap_or("-");
(line_no, malo)
}
#[cfg(test)]
mod tests {
use rubo4e::current::{
Betrag, Menge, Mengeneinheit, Preis, Rechnung, Rechnungsposition, Zeitraum,
};
use rust_decimal::Decimal;
use super::*;
use crate::{amount::EuroAmount, tariff::InMemoryPreisblattStore};
use rubo4e::current::{PreisblattNetznutzung, Preisposition, Preisstaffel};
const SENDER: &str = "9900357000004";
fn betrag(eur: EuroAmount) -> Betrag {
Betrag {
wert: Some(Decimal::from_str_exact(&eur.to_string()).expect("valid decimal")),
..Default::default()
}
}
fn parse_date(s: &str) -> time::Date {
time::Date::parse(s, &time::format_description::well_known::Iso8601::DEFAULT)
.expect("valid ISO date")
}
fn periode(start: &str, end: &str) -> Zeitraum {
Zeitraum {
startdatum: Some(parse_date(start)),
enddatum: Some(parse_date(end)),
..Default::default()
}
}
fn make_pos(
n: i64,
malo: &str,
qty: Option<&str>,
price: Option<EuroAmount>,
net: Option<EuroAmount>,
) -> Rechnungsposition {
Rechnungsposition {
positionsnummer: Some(n),
positionstext: Some(malo.to_owned()),
lieferungszeitraum: Some(periode("2024-12-01", "2024-12-31")),
positions_menge: qty.map(|q| Menge {
wert: Some(Decimal::from_str_exact(q).expect("valid decimal literal")),
einheit: Some(Mengeneinheit::Kwh),
..Default::default()
}),
einzelpreis: price.map(|pr| Preis {
wert: Some(Decimal::from_str_exact(&pr.to_string()).expect("valid decimal")),
..Default::default()
}),
gesamtpreis: net.map(betrag),
..Default::default()
}
}
fn make_rechnung(
positions: Vec<Rechnungsposition>,
gesamtnetto: Option<EuroAmount>,
) -> Rechnung {
Rechnung {
rechnungsperiode: Some(periode("2024-12-01", "2024-12-31")),
rechnungsdatum: Some(parse_date("2025-01-15")),
gesamtnetto: gesamtnetto.map(betrag),
rechnungspositionen: if positions.is_empty() {
None
} else {
Some(positions)
},
..Default::default()
}
}
fn empty_store() -> InMemoryPreisblattStore {
InMemoryPreisblattStore::new()
}
fn seeded_store(price: EuroAmount) -> InMemoryPreisblattStore {
use rust_decimal::Decimal;
let mut store = InMemoryPreisblattStore::new();
let einheitspreis = Decimal::from_str_exact(&price.to_string()).expect("valid decimal");
let sheet = PreisblattNetznutzung {
gueltigkeit: None,
herausgeber: None,
preispositionen: Some(vec![Preisposition {
preisstaffeln: Some(vec![Preisstaffel {
preis: Some(einheitspreis),
..Default::default()
}]),
..Default::default()
}]),
..Default::default()
};
store.insert(SENDER.to_owned(), sheet);
store
}
#[test]
fn period_start_gte_end_is_dispute() {
let mut r = make_rechnung(vec![], None);
r.rechnungsperiode = Some(periode("2024-12-31", "2024-12-01"));
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::PeriodInvalid)
);
}
#[test]
fn period_valid_no_finding() {
let r = make_rechnung(vec![], None);
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::PeriodInvalid)
);
}
#[test]
fn line_period_invalid_is_dispute() {
let mut pos = make_pos(1, "DE001", None, None, None);
pos.lieferungszeitraum = Some(periode("2024-12-31", "2024-12-01"));
let r = make_rechnung(vec![pos], None);
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(report.has_dispute());
assert_eq!(report.findings[0].line_number, Some(1));
}
#[test]
fn arithmetic_correct_no_finding() {
let pos = make_pos(
1,
"DE001",
Some("1000.0"),
Some(EuroAmount::from_raw_units(3_456)),
Some(EuroAmount::from_raw_units(3_456_000)),
);
let r = make_rechnung(vec![pos], None);
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ArithmeticError)
);
}
#[test]
fn arithmetic_mismatch_is_dispute() {
let pos = make_pos(
1,
"DE001",
Some("1000.0"),
Some(EuroAmount::from_raw_units(3_456)),
Some(EuroAmount::from_raw_units(4_000_000)),
);
let r = make_rechnung(vec![pos], None);
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ArithmeticError)
);
}
#[test]
fn arithmetic_within_tolerance_no_finding() {
let pos = make_pos(
1,
"DE001",
Some("1000.0"),
Some(EuroAmount::from_raw_units(3_456)),
Some(EuroAmount::from_raw_units(3_490_000)),
);
let config = CheckConfig {
arithmetic_tolerance_ppm: 10_000,
..Default::default()
};
let r = make_rechnung(vec![pos], None);
let report = InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &config);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ArithmeticError)
);
}
#[test]
fn total_match_no_finding() {
let pos = make_pos(
1,
"DE001",
None,
None,
Some(EuroAmount::from_raw_units(3_456_000)),
);
let r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(3_456_000)));
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TotalMismatch)
);
}
#[test]
fn total_mismatch_is_warn() {
let pos = make_pos(
1,
"DE001",
None,
None,
Some(EuroAmount::from_raw_units(3_456_000)),
);
let r = make_rechnung(vec![pos], Some(EuroAmount::from_raw_units(5_000_000)));
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(!report.has_dispute()); assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::TotalMismatch)
);
}
#[test]
fn no_tariff_warn_by_default() {
let r = make_rechnung(vec![], None);
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(!report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::TariffNotFound)
);
}
#[test]
fn no_tariff_dispute_when_required() {
let config = CheckConfig {
require_tariff: true,
..Default::default()
};
let r = make_rechnung(vec![], None);
let report = InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &config);
assert!(report.has_dispute());
}
#[test]
fn tariff_match_no_finding() {
let price = EuroAmount::from_raw_units(3_456);
let pos = make_pos(
1,
"DE001",
Some("1000.0"),
Some(price),
Some(EuroAmount::from_raw_units(3_456_000)),
);
let r = make_rechnung(vec![pos], None);
let report = InvoicCheckEngine::check(
31001,
SENDER,
&r,
&seeded_store(price),
&CheckConfig::default(),
);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TariffDeviation)
);
}
#[test]
fn tariff_deviation_is_dispute() {
let tariff_price = EuroAmount::from_raw_units(3_456); let invoic_price = EuroAmount::from_raw_units(4_000); let pos = make_pos(
1,
"DE001",
Some("1000.0"),
Some(invoic_price),
Some(EuroAmount::from_raw_units(4_000_000)),
);
let r = make_rechnung(vec![pos], None);
let report = InvoicCheckEngine::check(
31001,
SENDER,
&r,
&seeded_store(tariff_price),
&CheckConfig::default(),
);
assert!(report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::TariffDeviation)
);
}
#[test]
fn clean_invoice_outcome_is_ok() {
let price = EuroAmount::from_raw_units(3_456);
let net = EuroAmount::from_raw_units(3_456_000);
let pos = make_pos(1, "DE001", Some("1000.0"), Some(price), Some(net));
let r = make_rechnung(vec![pos], Some(net));
let report = InvoicCheckEngine::check(
31001,
SENDER,
&r,
&seeded_store(price),
&CheckConfig::default(),
);
assert_eq!(report.outcome, CheckOutcome::Ok);
assert!(report.findings.is_empty());
}
#[test]
fn pid_is_carried_in_report() {
let r = make_rechnung(vec![], None);
let report =
InvoicCheckEngine::check(31005, SENDER, &r, &empty_store(), &CheckConfig::default());
assert_eq!(report.pid, 31005);
}
#[test]
fn stornierung_with_reference_skips_tariff_check() {
let price = EuroAmount::from_raw_units(3_456);
let net = EuroAmount::from_raw_units(3_456_000);
let pos = make_pos(1, "DE001", Some("1000.0"), Some(price), Some(net));
let mut r = make_rechnung(vec![pos], Some(net));
r.ist_storno = Some(true);
r.original_rechnungsnummer = Some("31001-2025-0042".to_owned());
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert_eq!(
report.outcome,
CheckOutcome::Ok,
"Storno with valid ref + correct arithmetic should be Ok"
);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TariffNotFound),
"Tariff stage must be skipped for Stornierung"
);
}
#[test]
fn stornierung_without_reference_is_dispute() {
let mut r = make_rechnung(vec![], None);
r.ist_storno = Some(true);
r.original_rechnungsnummer = None;
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::StorniertWithoutReference),
"Missing original_rechnungsnummer must produce StorniertWithoutReference"
);
}
#[test]
fn is_stornierung_predicate() {
let mut r = Rechnung::default();
assert!(!is_stornierung(&r), "default Rechnung is not a Storno");
r.ist_storno = Some(true);
assert!(is_stornierung(&r), "ist_storno=true → is Storno");
r.ist_storno = Some(false);
assert!(!is_stornierung(&r), "ist_storno=false → not Storno");
}
#[test]
fn check_storno_clean_returns_ok() {
let price = EuroAmount::from_raw_units(3_456);
let net = EuroAmount::from_raw_units(3_456_000);
let pos = make_pos(1, "DE001", Some("1000.0"), Some(price), Some(net));
let mut r = make_rechnung(vec![pos], Some(net));
r.ist_storno = Some(true);
r.original_rechnungsnummer = Some("31001-2025-0042".to_owned());
let report = InvoicCheckEngine::check_storno(31004, &r, &CheckConfig::default());
assert_eq!(report.outcome, CheckOutcome::Ok);
assert!(report.findings.is_empty());
}
#[test]
fn check_storno_without_reference_is_dispute() {
let mut r = make_rechnung(vec![], None);
r.ist_storno = Some(true);
r.original_rechnungsnummer = None;
let report = InvoicCheckEngine::check_storno(31004, &r, &CheckConfig::default());
assert!(report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::StorniertWithoutReference)
);
}
#[test]
fn zahlungsziel_within_limit_no_finding() {
let mut r = make_rechnung(vec![], None);
r.rechnungsdatum = Some(parse_date("2026-07-01"));
r.faelligkeitsdatum = Some(parse_date("2026-07-31"));
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ZahlungszielExceeded),
"Exactly 30 days is within the default limit"
);
}
#[test]
fn zahlungsziel_exceeded_is_warn() {
let mut r = make_rechnung(vec![], None);
r.rechnungsdatum = Some(parse_date("2026-07-01"));
r.faelligkeitsdatum = Some(parse_date("2026-09-01"));
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
let finding = report
.findings
.iter()
.find(|f| f.kind == FindingKind::ZahlungszielExceeded);
assert!(
finding.is_some(),
"62-day payment term must produce ZahlungszielExceeded"
);
assert!(
!finding.unwrap().is_dispute,
"ZahlungszielExceeded is Warn, not Dispute"
);
}
#[test]
fn zahlungsziel_before_invoice_date_is_dispute() {
let mut r = make_rechnung(vec![], None);
r.rechnungsdatum = Some(parse_date("2026-07-15"));
r.faelligkeitsdatum = Some(parse_date("2026-07-01"));
let report =
InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &CheckConfig::default());
assert!(report.has_dispute());
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ZahlungszielInvalid),
"pay_by before rechnungsdatum must produce ZahlungszielInvalid Dispute"
);
}
#[test]
fn zahlungsziel_check_disabled_at_zero() {
let mut r = make_rechnung(vec![], None);
r.rechnungsdatum = Some(parse_date("2026-01-01"));
r.faelligkeitsdatum = Some(parse_date("2026-12-31"));
let config = CheckConfig {
max_zahlungsziel_days: 0,
..Default::default()
};
let report = InvoicCheckEngine::check(31001, SENDER, &r, &empty_store(), &config);
assert!(
!report.findings.iter().any(|f| matches!(
f.kind,
FindingKind::ZahlungszielExceeded | FindingKind::ZahlungszielInvalid
)),
"Zahlungsziel check must be skipped when max_zahlungsziel_days = 0"
);
}
}