use rubo4e::validation::ValidationFailure;
use rust_decimal::Decimal;
use rust_decimal::RoundingStrategy;
fn failure(path: &str, message: impl Into<String>) -> ValidationFailure {
ValidationFailure {
path: path.to_owned(),
message: message.into(),
}
}
pub trait Bo4eConformance {
fn residual_rules(&self) -> Vec<ValidationFailure> {
Vec::new()
}
fn emission_rules(&self) -> Vec<ValidationFailure> {
Vec::new()
}
}
macro_rules! no_residual_rules {
($($t:ty),* $(,)?) => { $(
impl Bo4eConformance for $t {}
)* };
}
no_residual_rules![
rubo4e::current::Adresse,
rubo4e::current::Angebot,
rubo4e::current::Bilanzierung,
rubo4e::current::Energiemenge,
rubo4e::current::Energiemix,
rubo4e::current::Fremdkosten,
rubo4e::current::Geraet,
rubo4e::current::Geschaeftspartner,
rubo4e::current::Kosten,
rubo4e::current::Lastgang,
rubo4e::current::LastvariablePreisposition,
rubo4e::current::Lokationszuordnung,
rubo4e::current::Marktlokation,
rubo4e::current::Messlokation,
rubo4e::current::Netzlokation,
rubo4e::current::Person,
rubo4e::current::Preisgarantie,
rubo4e::current::PreisblattDienstleistung,
rubo4e::current::PreisblattHardware,
rubo4e::current::PreisblattKonzessionsabgabe,
rubo4e::current::PreisblattMessung,
rubo4e::current::PreisblattNetznutzung,
rubo4e::current::Standorteigenschaften,
rubo4e::current::SteuerbareRessource,
rubo4e::current::Tarifinfo,
rubo4e::current::Tarifpreisblatt,
rubo4e::current::TechnischeRessource,
rubo4e::current::Vertrag,
rubo4e::current::Vorauszahlung,
rubo4e::current::Zaehler,
rubo4e::current::Zahlungsinformation,
rubo4e::current::Zaehlzeitdefinition,
rubo4e::current::Zeitreihe,
rubo4e::current::ZeitvariablePreisposition,
];
fn agrees(computed: Decimal, stated: Decimal) -> bool {
let scale = stated.scale().max(2);
let kfm = |d: Decimal| d.round_dp_with_strategy(scale, RoundingStrategy::MidpointAwayFromZero);
kfm(computed) == kfm(stated)
}
impl Bo4eConformance for rubo4e::current::Rechnung {
fn residual_rules(&self) -> Vec<ValidationFailure> {
let mut out = Vec::new();
if let (Some(stated), Some(positions)) = (
self.gesamtnetto.as_ref().and_then(|b| b.wert),
self.rechnungspositionen.as_ref(),
) {
let summarises_children = self.teilrechnungen.as_ref().is_some_and(|t| !t.is_empty());
let amounts: Option<Vec<Decimal>> = positions
.iter()
.map(|p| p.gesamtpreis.as_ref().and_then(|b| b.wert))
.collect();
if let Some(amounts) = amounts
&& !amounts.is_empty()
&& self.rabatt_netto.is_none()
&& !summarises_children
{
if let Some(sum) = amounts
.iter()
.copied()
.try_fold(Decimal::ZERO, Decimal::checked_add)
&& !agrees(sum, stated)
{
out.push(failure(
"gesamtnetto",
format!(
"the {} position{} sum to {sum}, not the stated \
gesamtnetto ({stated}) — BO4E: \"Die Summe der \
Nettobeträge der Rechnungsteile\"",
positions.len(),
if positions.len() == 1 { "" } else { "s" }
),
));
}
}
}
if self.ist_storno == Some(true) && self.original_rechnungsnummer.is_none() {
out.push(failure(
"originalRechnungsnummer",
"istStorno is true but originalRechnungsnummer is absent; a \
reversal that does not name the invoice it reverses is one no \
receiver can book",
));
}
out
}
fn emission_rules(&self) -> Vec<ValidationFailure> {
rubo4e::validation::current::quality::rechnung_totals_are_complete(self)
.err()
.map(|e| failure("gesamtbrutto", e.to_string()))
.into_iter()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::Bo4eConformance as _;
use rubo4e::current::{Betrag, Rechnung, Rechnungsposition, Steuerbetrag, Waehrungscode};
use rust_decimal::{Decimal, dec};
fn eur(wert: Decimal) -> Betrag {
Betrag {
wert: Some(wert),
waehrung: Some(Waehrungscode::Eur),
..Default::default()
}
}
fn balanced_invoice() -> Rechnung {
Rechnung {
gesamtnetto: Some(eur(dec!(300.00))),
gesamtsteuer: Some(eur(dec!(57.00))),
gesamtbrutto: Some(eur(dec!(357.00))),
steuerbetraege: Some(vec![Steuerbetrag {
steuerwert: Some(dec!(57.00)),
..Default::default()
}]),
rechnungspositionen: Some(vec![Rechnungsposition {
gesamtpreis: Some(eur(dec!(300.00))),
..Default::default()
}]),
..Default::default()
}
}
#[test]
fn a_balanced_invoice_has_no_residual_failure() {
assert!(balanced_invoice().residual_rules().is_empty());
}
#[test]
fn positions_that_miss_the_net_total_are_reported() {
let mut r = balanced_invoice();
r.rechnungspositionen = Some(vec![Rechnungsposition {
gesamtpreis: Some(eur(dec!(299.00))),
..Default::default()
}]);
let f = r.residual_rules();
assert_eq!(f.len(), 1);
assert_eq!(f[0].path, "gesamtnetto");
}
#[test]
fn a_rabatt_suspends_the_position_sum() {
let mut r = balanced_invoice();
r.rabatt_netto = Some(eur(dec!(10.00)));
r.rechnungspositionen = Some(vec![Rechnungsposition {
gesamtpreis: Some(eur(dec!(310.00))),
..Default::default()
}]);
assert!(r.residual_rules().is_empty());
}
#[test]
fn an_unpriced_position_suspends_the_sum() {
let mut r = balanced_invoice();
r.rechnungspositionen = Some(vec![
Rechnungsposition {
gesamtpreis: Some(eur(dec!(300.00))),
..Default::default()
},
Rechnungsposition::default(),
]);
assert!(r.residual_rules().is_empty());
}
#[test]
fn position_rounding_does_not_break_the_sum() {
let mut r = balanced_invoice();
r.rechnungspositionen = Some(vec![
Rechnungsposition {
gesamtpreis: Some(eur(dec!(100.004999))),
..Default::default()
},
Rechnungsposition {
gesamtpreis: Some(eur(dec!(199.995001))),
..Default::default()
},
]);
assert!(r.residual_rules().is_empty());
}
#[test]
fn an_overflowing_position_sum_does_not_panic() {
let mut r = balanced_invoice();
r.rechnungspositionen = Some(vec![
Rechnungsposition {
gesamtpreis: Some(eur(Decimal::MAX)),
..Default::default()
},
Rechnungsposition {
gesamtpreis: Some(eur(Decimal::MAX)),
..Default::default()
},
]);
assert!(r.residual_rules().is_empty());
}
#[test]
fn a_reversal_must_name_the_invoice_it_reverses() {
let mut r = balanced_invoice();
r.ist_storno = Some(true);
let f = r.residual_rules();
assert_eq!(f.len(), 1);
assert_eq!(f[0].path, "originalRechnungsnummer");
r.original_rechnungsnummer = Some("2026-000123".to_owned());
assert!(r.residual_rules().is_empty());
}
#[test]
fn every_residual_failure_is_reported_at_once() {
let mut r = balanced_invoice();
r.ist_storno = Some(true);
r.rechnungspositionen = Some(vec![Rechnungsposition {
gesamtpreis: Some(eur(dec!(299.00))),
..Default::default()
}]);
assert_eq!(r.residual_rules().len(), 2);
}
#[test]
fn rubo4e_owns_the_rules_this_module_no_longer_states() {
use rubo4e::prelude::Validate as _;
let mut r = balanced_invoice();
r.gesamtbrutto = Some(eur(dec!(358.00)));
assert!(r.validate().is_err(), "rubo4e checks gesamtbrutto");
assert!(
r.residual_rules().is_empty(),
"and mako does not restate it"
);
let mut r = balanced_invoice();
r.steuerbetraege = Some(vec![Steuerbetrag {
steuerwert: Some(dec!(56.00)),
..Default::default()
}]);
assert!(r.validate().is_err(), "rubo4e checks the tax breakdown");
let mut r = balanced_invoice();
r.gesamtbrutto = Some(Betrag {
wert: Some(dec!(357.00)),
waehrung: Some(Waehrungscode::Chf),
..Default::default()
});
assert!(r.validate().is_err(), "rubo4e checks currency agreement");
}
#[test]
fn a_location_reference_carries_no_ortsangabe() {
use rubo4e::prelude::Validate as _;
assert!(rubo4e::current::Marktlokation::default().validate().is_ok());
assert!(rubo4e::current::Messlokation::default().validate().is_ok());
}
#[test]
fn rubo4e_descends_into_nested_values() {
use rubo4e::prelude::Validate as _;
let r = Rechnung {
rechnungspositionen: Some(vec![Rechnungsposition {
lieferungszeitraum: Some(rubo4e::current::Zeitraum {
startdatum: Some(time::macros::date!(2026 - 02 - 01)),
enddatum: Some(time::macros::date!(2026 - 01 - 01)),
..Default::default()
}),
..Default::default()
}]),
..Default::default()
};
assert!(
r.validate().is_err(),
"an inverted Zeitraum on a position is reported by rubo4e's own dive"
);
}
#[test]
fn a_half_cent_sum_agrees_with_the_kaufmaennisch_total() {
let r = Rechnung {
gesamtnetto: Some(eur(dec!(12.35))),
rechnungspositionen: Some(vec![
Rechnungsposition {
gesamtpreis: Some(eur(dec!(6.1725))),
..Default::default()
},
Rechnungsposition {
gesamtpreis: Some(eur(dec!(6.1725))),
..Default::default()
},
]),
..Default::default()
};
assert!(r.residual_rules().is_empty(), "{:?}", r.residual_rules());
}
#[test]
fn a_total_off_by_a_cent_is_still_refused() {
let r = Rechnung {
gesamtnetto: Some(eur(dec!(12.36))),
rechnungspositionen: Some(vec![Rechnungsposition {
gesamtpreis: Some(eur(dec!(12.345))),
..Default::default()
}]),
..Default::default()
};
assert_eq!(r.residual_rules().len(), 1);
}
}