use std::collections::HashMap;
use mako_pruefung::HolidayCalendar;
use mako_pruefung::rechnung::{
PositionsFakten, RechnungsAntwort, RechnungsFakten, RechnungsFamilie, Steuersatzpruefung,
StornoAntwort, UrsprungsAntwort, Zeitraum,
};
use rubo4e::convenience::{BetragExt as _, MengeExt as _, PreisExt as _};
use rubo4e::current::{Rechnung, Rechnungsposition};
use rust_decimal::Decimal;
use crate::amount::EuroAmount;
use crate::check::CheckConfig;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EmpfaengerFakten {
pub eingangsdatum: Option<time::Date>,
pub ustg_konform: Option<bool>,
pub bestellung_bekannt: bool,
pub rechnungsnummer_bereits_verwendet: bool,
pub leistung_erbracht: HashMap<u16, bool>,
pub bereits_abgerechnet_in: HashMap<u16, String>,
pub einwaende_entkraeftet: Option<bool>,
pub preisblatt_version_gueltig: Option<bool>,
pub zeitraum_bereits_abgerechnet_in: Option<String>,
}
impl EmpfaengerFakten {
#[must_use]
pub fn neu(eingangsdatum: time::Date) -> Self {
Self {
eingangsdatum: Some(eingangsdatum),
bestellung_bekannt: true,
..Self::default()
}
}
}
#[must_use]
pub fn antwort_auf_rechnung(
familie: RechnungsFamilie,
rechnung: &Rechnung,
agreed: &[(String, EuroAmount)],
fakten: &EmpfaengerFakten,
config: &CheckConfig,
cal: HolidayCalendar,
) -> RechnungsAntwort {
let input = build(rechnung, agreed, fakten, config);
mako_pruefung::rechnung::pruefe_rechnung(familie, &input, cal)
}
#[must_use]
pub fn antwort_auf_erneute_rechnung(
familie: RechnungsFamilie,
rechnung: &Rechnung,
agreed: &[(String, EuroAmount)],
fakten: &EmpfaengerFakten,
config: &CheckConfig,
cal: HolidayCalendar,
) -> RechnungsAntwort {
let input = build(rechnung, agreed, fakten, config);
mako_pruefung::rechnung::pruefe_rechnung_erneut(familie, &input, cal)
}
fn build(
rechnung: &Rechnung,
agreed: &[(String, EuroAmount)],
fakten: &EmpfaengerFakten,
config: &CheckConfig,
) -> RechnungsFakten {
let rechnungsdatum = rechnung.rechnungsdatum_date();
let eingangsdatum = fakten
.eingangsdatum
.or(rechnungsdatum)
.unwrap_or_else(mako_fristen::heute);
let leistungszeitraum = rechnung.billing_period().map(|p| Zeitraum {
von: *p.start(),
bis: *p.end(),
});
let preis_je_artikel: HashMap<&str, EuroAmount> = agreed
.iter()
.map(|(id, preis)| (id.as_str(), *preis))
.collect();
let positionen: Vec<PositionsFakten> = rechnung
.rechnungspositionen
.iter()
.flatten()
.map(|pos| position(pos, &preis_je_artikel, fakten, config))
.collect();
let billed: Vec<&str> = positionen
.iter()
.filter_map(|p| p.artikel_id.as_deref())
.collect();
let fehlende_artikel_ids = agreed
.iter()
.map(|(id, _)| id)
.filter(|id| !billed.contains(&id.as_str()))
.cloned()
.collect();
RechnungsFakten {
einwaende_entkraeftet: fakten.einwaende_entkraeftet,
ustg_konform: fakten.ustg_konform,
rechnungsdatum: rechnungsdatum.unwrap_or(eingangsdatum),
eingangsdatum,
leistungszeitraum,
bestellung_bekannt: fakten.bestellung_bekannt,
rechnungsnummer_bereits_verwendet: fakten.rechnungsnummer_bereits_verwendet,
faelliger_betrag_nicht_negativ: faelliger_betrag_nicht_negativ(rechnung),
zahlungsziel: rechnung.faelligkeitsdatum_date(),
preisblatt_version_gueltig: fakten.preisblatt_version_gueltig,
zeitraum_bereits_abgerechnet_in: fakten.zeitraum_bereits_abgerechnet_in.clone(),
sonstiger_kopffehler: None,
positionen,
fehlende_artikel_ids,
steuersaetze: steuersaetze(rechnung, config),
rechnungsbetrag_stimmt: rechnungsbetrag_stimmt(rechnung, config),
sonstiger_summenfehler: None,
}
}
fn position(
pos: &Rechnungsposition,
preis_je_artikel: &HashMap<&str, EuroAmount>,
fakten: &EmpfaengerFakten,
config: &CheckConfig,
) -> PositionsFakten {
let nr = u16::try_from(pos.positionsnummer.unwrap_or(0)).unwrap_or(0);
let artikel_id = pos.artikel_id.clone().filter(|a| !a.is_empty());
let artikel_id_aus_bestellung = preis_je_artikel.is_empty()
|| artikel_id
.as_deref()
.is_some_and(|id| preis_je_artikel.contains_key(id));
let invoiced = pos.einzelpreis.wert_decimal().and_then(euro);
let preis_wie_angebot = match (invoiced, artikel_id.as_deref()) {
(Some(actual), Some(id)) => preis_je_artikel
.get(id)
.map(|expected| actual.within_tolerance_ppm(*expected, config.tariff_tolerance_ppm)),
_ => None,
};
PositionsFakten {
positionsnummer: nr,
artikel_id,
artikel_id_aus_bestellung,
leistung_erbracht: fakten.leistung_erbracht.get(&nr).copied(),
preis_wie_angebot,
steuersatz_korrekt: None,
zeitraum: match (pos.lieferung_von_date(), pos.lieferung_bis_date()) {
(Some(von), Some(bis)) => Some(Zeitraum { von, bis }),
(Some(tag), None) | (None, Some(tag)) => Some(Zeitraum::tag(tag)),
(None, None) => None,
},
bereits_abgerechnet_in: fakten.bereits_abgerechnet_in.get(&nr).cloned(),
rechenfehler: rechenfehler(pos, config),
sonstiger_fehler: None,
}
}
fn rechenfehler(pos: &Rechnungsposition, config: &CheckConfig) -> bool {
let (Some(menge), Some(preis), Some(gesamt)) = (
pos.positions_menge.wert_decimal(),
pos.einzelpreis.wert_decimal(),
pos.gesamtpreis.wert_decimal(),
) else {
return false;
};
let Some(product) = menge.checked_mul(preis) else {
return true;
};
let (Some(computed), Some(stated)) = (euro(product), euro(gesamt)) else {
return true;
};
!computed.within_tolerance_ppm(stated, config.arithmetic_tolerance_ppm)
}
fn faelliger_betrag_nicht_negativ(rechnung: &Rechnung) -> bool {
rechnung
.zu_zahlen
.wert_decimal()
.or_else(|| rechnung.gesamtbrutto.wert_decimal())
.is_none_or(|w| w >= Decimal::ZERO)
}
fn steuersaetze(rechnung: &Rechnung, config: &CheckConfig) -> Vec<Steuersatzpruefung> {
let tol = config.total_tolerance_ppm;
rechnung
.steuerbetraege
.iter()
.flatten()
.map(|tax| {
let satz = tax.steuersatz.unwrap_or_default();
let basis: Decimal = rechnung
.rechnungspositionen
.iter()
.flatten()
.filter(|p| {
p.steuerbetrag
.as_ref()
.and_then(|t| t.steuersatz)
.is_some_and(|s| s == satz)
})
.filter_map(|p| p.gesamtpreis.wert_decimal())
.sum();
let stated_basis = tax.basiswert;
let stated_tax = tax.steuerwert;
let vergleichbar = rechnung
.rechnungspositionen
.iter()
.flatten()
.any(|p| p.steuerbetrag.as_ref().and_then(|t| t.steuersatz).is_some());
Steuersatzpruefung {
steuersatz: satz.to_string(),
steuerkategorie: tax
.steuerart
.map_or_else(|| "-".to_owned(), |a| format!("{a:?}")),
besteuerungsgrundlage_stimmt: !vergleichbar
|| stated_basis.is_none_or(|s| decimals_match(s, basis, tol)),
steuerbetrag_stimmt: !vergleichbar
|| stated_tax.is_none_or(|s| {
decimals_match(s, basis * satz / Decimal::ONE_HUNDRED, tol)
}),
}
})
.collect()
}
fn rechnungsbetrag_stimmt(rechnung: &Rechnung, config: &CheckConfig) -> bool {
let (Some(netto), Some(steuer), Some(brutto)) = (
rechnung.gesamtnetto.wert_decimal(),
rechnung.gesamtsteuer.wert_decimal(),
rechnung.gesamtbrutto.wert_decimal(),
) else {
return true;
};
decimals_match(brutto, netto + steuer, config.total_tolerance_ppm)
}
fn decimals_match(a: Decimal, b: Decimal, tol_ppm: u32) -> bool {
match (euro(a), euro(b)) {
(Some(x), Some(y)) => x.within_tolerance_ppm(y, tol_ppm),
_ => a == b,
}
}
fn euro(d: Decimal) -> Option<EuroAmount> {
crate::amount::euro_from_decimal(d)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StornoEmpfaengerFakten {
pub ursprungsrechnung_bekannt: bool,
pub rechnungsnummer_bereits_verwendet: bool,
pub ustg_konform: Option<bool>,
pub bereits_storniert: bool,
pub rechnungstyp_identisch: bool,
pub zeitraum_identisch: bool,
pub betraege_negiert_identisch: bool,
pub ursprungsantwort: UrsprungsAntwort,
}
impl StornoEmpfaengerFakten {
#[must_use]
pub fn neu(ursprungsantwort: UrsprungsAntwort) -> Self {
Self {
ursprungsrechnung_bekannt: true,
rechnungsnummer_bereits_verwendet: false,
ustg_konform: None,
bereits_storniert: false,
rechnungstyp_identisch: true,
zeitraum_identisch: true,
betraege_negiert_identisch: true,
ursprungsantwort,
}
}
}
#[must_use]
pub fn antwort_auf_stornorechnung(
familie: RechnungsFamilie,
rechnung: &Rechnung,
fakten: &StornoEmpfaengerFakten,
) -> StornoAntwort {
mako_pruefung::rechnung::pruefe_stornorechnung(
familie,
&mako_pruefung::rechnung::StornoFakten {
ursprungsrechnung_bekannt: fakten.ursprungsrechnung_bekannt
&& rechnung
.original_rechnungsnummer
.as_deref()
.is_some_and(|n| !n.is_empty()),
rechnungsnummer_bereits_verwendet: fakten.rechnungsnummer_bereits_verwendet,
ustg_konform: fakten.ustg_konform,
bereits_storniert: fakten.bereits_storniert,
rechnungstyp_identisch: fakten.rechnungstyp_identisch,
zeitraum_identisch: fakten.zeitraum_identisch,
betraege_negiert_identisch: fakten.betraege_negiert_identisch,
sonstiger_fehler: None,
ursprungsantwort: fakten.ursprungsantwort,
},
)
}
#[cfg(test)]
mod tests {
use mako_pruefung::rechnung::ESA as ESA_F;
use super::*;
use rubo4e::current::{Betrag, Menge, Mengeneinheit, Preis, Steuerbetrag, Waehrungscode};
use time::macros::date;
const CAL: HolidayCalendar = HolidayCalendar::BdewMaKo;
fn dec(s: &str) -> Decimal {
s.parse().expect("decimal")
}
fn betrag(s: &str) -> Betrag {
Betrag {
wert: Some(dec(s)),
waehrung: Some(Waehrungscode::Eur),
..Betrag::default()
}
}
fn pos(nr: i64, artikel: &str, einzel: &str, menge: &str, gesamt: &str) -> Rechnungsposition {
Rechnungsposition {
positionsnummer: Some(nr),
artikel_id: Some(artikel.to_owned()),
positions_menge: Some(Menge {
wert: Some(dec(menge)),
einheit: Some(Mengeneinheit::Tag),
..Menge::default()
}),
einzelpreis: Some(Preis {
wert: Some(dec(einzel)),
..Preis::default()
}),
gesamtpreis: Some(betrag(gesamt)),
lieferungszeitraum: Some(rubo4e::current::Zeitraum {
startdatum: Some(date!(2026 - 03 - 01)),
enddatum: Some(date!(2026 - 03 - 31)),
..rubo4e::current::Zeitraum::default()
}),
steuerbetrag: Some(Steuerbetrag {
steuersatz: Some(dec("19")),
..Steuerbetrag::default()
}),
..Rechnungsposition::default()
}
}
fn rechnung() -> Rechnung {
Rechnung {
rechnungsdatum: Some(date!(2026 - 04 - 01).midnight().assume_utc()),
faelligkeitsdatum: Some(date!(2026 - 05 - 15).midnight().assume_utc()),
rechnungsperiode: Some(rubo4e::current::Zeitraum {
startdatum: Some(date!(2026 - 03 - 01)),
enddatum: Some(date!(2026 - 03 - 31)),
..rubo4e::current::Zeitraum::default()
}),
rechnungspositionen: Some(vec![pos(1, "9990001100002", "0.45", "31", "13.95")]),
steuerbetraege: Some(vec![Steuerbetrag {
steuersatz: Some(dec("19")),
basiswert: Some(dec("13.95")),
steuerwert: Some(dec("2.6505")),
..Steuerbetrag::default()
}]),
gesamtnetto: Some(betrag("13.95")),
gesamtsteuer: Some(betrag("2.6505")),
gesamtbrutto: Some(betrag("16.6005")),
..Rechnung::default()
}
}
fn agreed() -> Vec<(String, EuroAmount)> {
vec![(
"9990001100002".to_owned(),
crate::amount::euro_from_decimal(dec("0.45")).expect("euro"),
)]
}
fn fakten() -> EmpfaengerFakten {
EmpfaengerFakten::neu(date!(2026 - 04 - 01))
}
#[test]
fn a_conformant_invoice_is_a_zahlungsavis() {
let a = antwort_auf_rechnung(
ESA_F,
&rechnung(),
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert!(a.ist_zustimmung(), "{:?}", a.befunde);
assert_eq!(a.remadv_pid(), 33_001);
}
#[test]
fn a_price_away_from_the_offer_is_a11_on_the_positionsebene() {
let mut r = rechnung();
r.rechnungspositionen = Some(vec![pos(1, "9990001100002", "0.90", "31", "27.90")]);
r.gesamtnetto = Some(betrag("27.90"));
r.steuerbetraege = Some(vec![Steuerbetrag {
steuersatz: Some(dec("19")),
basiswert: Some(dec("27.90")),
steuerwert: Some(dec("5.301")),
..Steuerbetrag::default()
}]);
r.gesamtsteuer = Some(betrag("5.301"));
r.gesamtbrutto = Some(betrag("33.201"));
let a = antwort_auf_rechnung(
ESA_F,
&r,
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A11"]);
assert_eq!(a.remadv_pid(), 33_004);
}
#[test]
fn an_unagreed_artikel_id_is_a09() {
let mut r = rechnung();
r.rechnungspositionen = Some(vec![pos(1, "9990009999999", "0.45", "31", "13.95")]);
let a = antwort_auf_rechnung(
ESA_F,
&r,
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A09"]);
}
#[test]
fn without_an_offer_on_record_nothing_is_refused_on_price() {
let a = antwort_auf_rechnung(
ESA_F,
&rechnung(),
&[],
&fakten(),
&CheckConfig::default(),
CAL,
);
assert!(a.ist_zustimmung(), "{:?}", a.befunde);
}
#[test]
fn an_agreed_position_the_invoice_omits_is_a21() {
let mut agreed = agreed();
agreed.push((
"9990001100001".to_owned(),
crate::amount::euro_from_decimal(dec("120.00")).expect("euro"),
));
let a = antwort_auf_rechnung(
ESA_F,
&rechnung(),
&agreed,
&fakten(),
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A21"]);
assert!(a.befunde[0].detail.contains("9990001100001"));
}
#[test]
fn a_zahlungsziel_under_ten_werktage_is_a07() {
let mut r = rechnung();
r.faelligkeitsdatum = Some(date!(2026 - 04 - 08).midnight().assume_utc());
let a = antwort_auf_rechnung(
ESA_F,
&r,
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A07"]);
assert_eq!(a.remadv_pid(), 33_003);
}
#[test]
fn a_negative_total_is_a06() {
let mut r = rechnung();
r.gesamtbrutto = Some(betrag("-16.6005"));
let a = antwort_auf_rechnung(
ESA_F,
&r,
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A06"]);
}
#[test]
fn a_wrong_document_total_is_a24() {
let mut r = rechnung();
r.gesamtbrutto = Some(betrag("99.99"));
let a = antwort_auf_rechnung(
ESA_F,
&r,
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A24"]);
}
#[test]
fn a_line_arithmetic_error_is_a20() {
let mut r = rechnung();
r.rechnungspositionen = Some(vec![pos(1, "9990001100002", "0.45", "31", "99.00")]);
let a = antwort_auf_rechnung(
ESA_F,
&r,
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert!(a.codes().contains(&"A20"), "{:?}", a.codes());
}
#[test]
fn an_invoice_against_an_unknown_order_is_a04() {
let mut f = fakten();
f.bestellung_bekannt = false;
let a = antwort_auf_rechnung(
ESA_F,
&rechnung(),
&agreed(),
&f,
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A04"]);
assert_eq!(a.remadv_pid(), 33_003);
}
#[test]
fn caller_supplied_facts_only_refuse_once_they_are_known() {
let mut f = fakten();
f.leistung_erbracht.insert(1, false);
let a = antwort_auf_rechnung(
ESA_F,
&rechnung(),
&agreed(),
&f,
&CheckConfig::default(),
CAL,
);
assert_eq!(a.codes(), vec!["A10"]);
let quiet = antwort_auf_rechnung(
ESA_F,
&rechnung(),
&agreed(),
&fakten(),
&CheckConfig::default(),
CAL,
);
assert!(quiet.ist_zustimmung());
}
#[test]
fn the_second_round_runs_e_0266() {
let mut f = fakten();
f.einwaende_entkraeftet = Some(false);
let a = antwort_auf_erneute_rechnung(
ESA_F,
&rechnung(),
&agreed(),
&f,
&CheckConfig::default(),
CAL,
);
assert_eq!(a.tree, "E_0266");
assert_eq!(a.codes(), vec!["A25"]);
}
#[test]
fn the_summen_checks_read_the_same_tolerance_as_the_plausibility_engine() {
let mut r = rechnung();
r.gesamtnetto = Some(betrag("100.00"));
r.gesamtsteuer = Some(betrag("19.00"));
r.gesamtbrutto = Some(betrag("120.80"));
let config = CheckConfig {
total_tolerance_ppm: 50_000, ..CheckConfig::default()
};
let antwort = antwort_auf_rechnung(
ESA_F,
&r,
&[],
&EmpfaengerFakten::neu(date!(2026 - 04 - 02)),
&config,
CAL,
);
assert!(
!antwort.antwortcodes().contains(&"A24"),
"the walk must follow total_tolerance_ppm, got {:?}",
antwort.antwortcodes()
);
let config = CheckConfig {
total_tolerance_ppm: 1,
..CheckConfig::default()
};
let antwort = antwort_auf_rechnung(
ESA_F,
&r,
&[],
&EmpfaengerFakten::neu(date!(2026 - 04 - 02)),
&config,
CAL,
);
assert!(
antwort.antwortcodes().contains(&"A24"),
"a 1 ppm tolerance must reach Prüfschritt 540, got {:?}",
antwort.antwortcodes()
);
}
}