use crate::{InvoiceDocument, QuantityUnit, SettlementResult};
use rubo4e::current::{
Betrag, Menge, Mengeneinheit, NetznutzungRechnungsart, NetznutzungRechnungstyp, Preis,
Rechnung, Rechnungsposition, Rechnungstyp, Steuerart, Steuerbetrag, Vorauszahlung,
Waehrungscode, Zeitraum, ZusatzAttribut,
};
#[must_use]
pub fn kind_to_artikelnummer(
kind: crate::BillingPositionKind,
settlement_type: crate::SettlementType,
) -> Option<rubo4e::current::BdewArtikelnummer> {
kind.artikelnummer(settlement_type)
.and_then(|name| rubo4e::current::BdewArtikelnummer::from_wire(name).ok())
}
#[must_use]
pub fn rechnungstyp_for(settlement_type: crate::SettlementType) -> Option<Rechnungstyp> {
use crate::SettlementType as S;
matches!(
settlement_type,
S::NneAbschlag | S::NneStrom | S::NneGas | S::MmmStrom | S::MmmGas | S::MmmSelbstausstellt
)
.then_some(Rechnungstyp::Netznutzungsrechnung)
}
#[must_use]
pub fn netznutzungrechnungsart_for(
settlement_type: crate::SettlementType,
) -> Option<NetznutzungRechnungsart> {
use crate::SettlementType as S;
match settlement_type {
S::MmmSelbstausstellt => Some(NetznutzungRechnungsart::Selbstausgestellt),
S::NneAbschlag | S::NneStrom | S::NneGas | S::MmmStrom | S::MmmGas => {
Some(NetznutzungRechnungsart::Handelsrechnung)
}
_ => None,
}
}
#[must_use]
pub fn netznutzungrechnungstyp_for(
settlement_type: crate::SettlementType,
cadence: Option<crate::Rechnungscharakter>,
) -> Option<NetznutzungRechnungstyp> {
use crate::Rechnungscharakter as C;
use crate::SettlementType as S;
if matches!(
settlement_type,
S::MmmStrom | S::MmmGas | S::MmmSelbstausstellt
) {
return Some(NetznutzungRechnungstyp::Mehrmindermengenrechnung);
}
if settlement_type == S::NneAbschlag {
return Some(NetznutzungRechnungstyp::Abschlagsrechnung);
}
cadence.map(|c| match c {
C::Abschlagsrechnung => NetznutzungRechnungstyp::Abschlagsrechnung,
C::Abschlussrechnung => NetznutzungRechnungstyp::Abschlussrechnung,
C::Turnusrechnung => NetznutzungRechnungstyp::Turnusrechnung,
C::Monatsrechnung => NetznutzungRechnungstyp::Monatsrechnung,
C::Zwischenrechnung => NetznutzungRechnungstyp::Zwischenrechnung,
})
}
#[must_use]
pub fn into_rechnung(document: &InvoiceDocument) -> Rechnung {
let invoice = &document.settlement;
let lz = Zeitraum::builder()
.startdatum(invoice.period.from())
.enddatum(invoice.period.to())
.build();
let positions: Vec<Rechnungsposition> = document
.numbered_positions()
.map(|(number, p)| {
let einheit = match p.unit {
QuantityUnit::Kwh => Mengeneinheit::Kwh,
QuantityUnit::Kw => Mengeneinheit::Kw,
QuantityUnit::Kvarh => Mengeneinheit::Kvarh,
QuantityUnit::Kvar => Mengeneinheit::Kvar,
QuantityUnit::Monat => Mengeneinheit::Monat,
};
Rechnungsposition::builder()
.positionsnummer(i64::from(number))
.positionstext(p.text.clone())
.artikelnummer(kind_to_artikelnummer(p.kind, invoice.settlement_type))
.lieferungszeitraum(lz.clone())
.positions_menge(Menge::builder().wert(p.quantity).einheit(einheit).build())
.einzelpreis(Preis::builder().wert(p.unit_price_eur.round_dp(6)).build())
.gesamtpreis(Betrag::builder().wert(p.net_eur.round_dp(5)).build())
.zusatz_attribute(trace_attribute(p))
.build()
})
.collect();
let settlement_type = invoice.settlement_type;
let mut rechnung = Rechnung::builder()
.rechnungsnummer(document.rechnungsnummer.clone())
.rechnungsdatum(as_bo4e_timestamp(document.invoice_date))
.faelligkeitsdatum(as_bo4e_timestamp(document.due_date))
.rechnungsperiode(lz)
.gesamtnetto(betrag(invoice.total_eur))
.gesamtsteuer(betrag(invoice.steuer.steuer_eur))
.gesamtbrutto(betrag(invoice.steuer.brutto_eur()))
.zu_zahlen(betrag(zu_zahlen_eur(document)))
.steuerbetraege(vec![steuerbetrag(invoice)])
.rechnungspositionen(positions)
.zusatz_attribute(settlement_attributes(invoice))
.build();
rechnung.rechnungstyp = rechnungstyp_for(settlement_type);
rechnung.netznutzungrechnungsart = netznutzungrechnungsart_for(settlement_type);
rechnung.netznutzungrechnungstyp =
netznutzungrechnungstyp_for(settlement_type, document.cadence);
rechnung.sparte = Some(match invoice.sparte {
crate::Sparte::Strom => rubo4e::current::Sparte::Strom,
crate::Sparte::Gas => rubo4e::current::Sparte::Gas,
});
if invoice.status == crate::types::SettlementStatus::Reversal {
rechnung.ist_storno = Some(true);
}
rechnung.original_rechnungsnummer = document.correction_of.clone();
if !document.abschlaege.is_empty() {
rechnung.vorauszahlungen = Some(
document
.abschlaege
.iter()
.map(|a| Vorauszahlung {
betrag: Some(betrag(a.betrag_brutto_eur)),
datum: Some(a.rechnungsdatum.midnight().assume_utc()),
referenz: Some(a.rechnungsnummer.clone()),
..Default::default()
})
.collect(),
);
}
rechnung
}
fn zu_zahlen_eur(document: &InvoiceDocument) -> rust_decimal::Decimal {
document.settlement.steuer.brutto_eur()
- document
.abschlaege
.iter()
.map(|a| a.betrag_brutto_eur)
.sum::<rust_decimal::Decimal>()
}
fn as_bo4e_timestamp(date: time::Date) -> Option<time::OffsetDateTime> {
(0..=9999)
.contains(&date.year())
.then(|| date.midnight().assume_utc())
}
fn betrag(wert: rust_decimal::Decimal) -> Betrag {
Betrag::builder()
.wert(wert)
.waehrung(Waehrungscode::Eur)
.build()
}
fn steuerbetrag(invoice: &SettlementResult) -> Steuerbetrag {
Steuerbetrag::builder()
.steuerart(match invoice.steuer.kategorie {
crate::TaxCategory::ReverseCharge => Steuerart::Rcv,
_ => Steuerart::Ust,
})
.steuersatz(invoice.steuer.satz_prozent)
.basiswert(invoice.steuer.bemessungsgrundlage_eur)
.steuerwert(invoice.steuer.steuer_eur)
.waehrungscode(Waehrungscode::Eur)
.zusatz_attribute(steuer_attribute(invoice))
.build()
}
fn steuer_attribute(invoice: &SettlementResult) -> Vec<ZusatzAttribut> {
let mut attrs = vec![ZusatzAttribut {
name: Some("mako:steuer_rechtsgrundlage".to_owned()),
wert: Some(serde_json::Value::String(
invoice.steuer.rechtsgrundlage.to_owned(),
)),
..Default::default()
}];
if let Some(hinweis) = invoice.steuer.hinweis {
attrs.push(ZusatzAttribut {
name: Some("mako:umsatzsteuer_hinweis".to_owned()),
wert: Some(serde_json::Value::String(hinweis.to_owned())),
..Default::default()
});
}
attrs
}
fn trace_attribute(p: &crate::SettlementPosition) -> Option<Vec<ZusatzAttribut>> {
let trace = serde_json::to_value(&p.trace).ok()?;
Some(vec![ZusatzAttribut {
name: Some("mako:calculation_trace".to_owned()),
wert: Some(trace),
..Default::default()
}])
}
fn settlement_attributes(invoice: &SettlementResult) -> Vec<ZusatzAttribut> {
let mut attrs = vec![ZusatzAttribut {
name: Some("mako:legal_references".to_owned()),
wert: Some(serde_json::json!(invoice.all_legal_refs())),
..Default::default()
}];
if !invoice.warnings.is_empty()
&& let Ok(warnings) = serde_json::to_value(&invoice.warnings)
{
attrs.push(ZusatzAttribut {
name: Some("mako:settlement_warnings".to_owned()),
wert: Some(warnings),
..Default::default()
});
}
attrs
}
#[cfg(test)]
mod tests {
use super::*;
fn as_document(settlement: crate::SettlementResult) -> crate::InvoiceDocument {
crate::InvoiceDocument {
settlement,
pid: 31002,
rechnungsnummer: "NNE-2026-001".to_owned(),
correction_of: None,
invoice_date: time::macros::date!(2026 - 02 - 15),
due_date: time::macros::date!(2026 - 03 - 15),
cadence: None,
abschlaege: Vec::new(),
}
}
fn sample_nne() -> crate::NneInput {
crate::NneInput {
blindarbeit: None,
malo_id: "51238696012".to_owned(),
nb_mp_id: "9900357000004".to_owned(),
lf_mp_id: "9900012345678".to_owned(),
period: crate::SettlementPeriod::new(
time::macros::date!(2026 - 01 - 01),
time::macros::date!(2026 - 01 - 31),
)
.expect("valid period"),
arbeitspreis: crate::ArbeitspreisModell::Einheitlich(crate::MengePreis {
menge_kwh: rust_decimal::Decimal::from(1000),
preis_ct_per_kwh: rust_decimal::Decimal::new(35, 1),
}),
leistungspreis: None,
letztverbrauchergruppe: Default::default(),
sect19_umlage_ct_per_kwh: None,
offshore_umlage_ct_per_kwh: None,
kwkg_umlage_ct_per_kwh: None,
netzebene: None,
sect19: None,
gas_kapazitaet: None,
jahreshoechstleistung_kw: None,
jahresarbeit_kwh: None,
konzessionsabgabe: None,
grundpreis: None,
tariff_sheet_id: None,
sparte: crate::Sparte::Strom,
}
}
#[test]
fn the_tax_block_reaches_the_rechnung() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let netto = settlement.total_eur;
let rechnung = into_rechnung(&as_document(settlement));
let wert = |b: &Option<Betrag>| b.as_ref().and_then(|b| b.wert).expect("amount");
let steuer = wert(&rechnung.gesamtsteuer);
assert_eq!(wert(&rechnung.gesamtnetto), netto);
assert_eq!(
steuer,
(netto * rust_decimal::Decimal::from(19) / rust_decimal::Decimal::from(100))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
);
assert_eq!(wert(&rechnung.gesamtbrutto), netto + steuer);
assert_eq!(
wert(&rechnung.zu_zahlen),
netto + steuer,
"what is actually owed is the gross"
);
let breakdown = rechnung.steuerbetraege.expect("a VAT breakdown");
let entry = breakdown.first().expect("one entry");
assert_eq!(entry.steuerart, Some(Steuerart::Ust));
assert_eq!(entry.steuersatz, Some(rust_decimal::Decimal::from(19)));
assert_eq!(entry.basiswert, Some(netto));
assert_eq!(entry.steuerwert, Some(steuer));
}
#[test]
fn a_self_issued_mehrmenge_renders_as_selbstausgestellt_under_31006() {
let settlement = crate::settle_mmm(&crate::MmmInput {
malo_id: "51238696012".to_owned(),
nb_mp_id: "9900357000004".to_owned(),
lf_mp_id: "9900012345678".to_owned(),
period: crate::SettlementPeriod::new(
time::macros::date!(2026 - 01 - 01),
time::macros::date!(2026 - 01 - 31),
)
.expect("valid period"),
sparte: crate::Sparte::Strom,
actual_kwh: rust_decimal::Decimal::from(1000),
profil_kwh: rust_decimal::Decimal::from(1200),
mehr_preis_ct_per_kwh: rust_decimal::Decimal::from(5),
minder_preis_ct_per_kwh: rust_decimal::Decimal::from(4),
wiederverkaeufer: crate::Wiederverkaeuferstatus::KEINER,
selbstausgestellt: true,
})
.expect("settle");
assert_eq!(
settlement.settlement_type,
crate::SettlementType::MmmSelbstausstellt
);
assert_eq!(settlement.settlement_type.default_pid(), 31006);
assert_eq!(
netznutzungrechnungsart_for(settlement.settlement_type),
Some(NetznutzungRechnungsart::Selbstausgestellt),
"PID 31006 is the invoice the receiving party writes"
);
assert_eq!(
netznutzungrechnungstyp_for(settlement.settlement_type, None),
Some(NetznutzungRechnungstyp::Mehrmindermengenrechnung),
"the type follows from the settlement, not from a billing cadence"
);
}
#[test]
fn a_reverse_charged_supply_states_no_tax_and_says_why() {
let settlement = crate::settle_mmm(&crate::MmmInput {
malo_id: "51238696012".to_owned(),
nb_mp_id: "9900357000004".to_owned(),
lf_mp_id: "9900012345678".to_owned(),
period: crate::SettlementPeriod::new(
time::macros::date!(2026 - 01 - 01),
time::macros::date!(2026 - 01 - 31),
)
.expect("valid period"),
sparte: crate::Sparte::Strom,
actual_kwh: rust_decimal::Decimal::from(1200),
profil_kwh: rust_decimal::Decimal::from(1000),
mehr_preis_ct_per_kwh: rust_decimal::Decimal::from(5),
minder_preis_ct_per_kwh: rust_decimal::Decimal::from(4),
wiederverkaeufer: crate::Wiederverkaeuferstatus::BEIDE,
selbstausgestellt: false,
})
.expect("settle");
let netto = settlement.total_eur;
let rechnung = into_rechnung(&as_document(settlement));
let wert = |b: &Option<Betrag>| b.as_ref().and_then(|b| b.wert).expect("amount");
assert_eq!(wert(&rechnung.gesamtsteuer), rust_decimal::Decimal::ZERO);
assert_eq!(
wert(&rechnung.gesamtbrutto),
netto,
"under a reverse charge the gross is the net"
);
let breakdown = rechnung.steuerbetraege.expect("a VAT breakdown");
let entry = breakdown.first().expect("one entry");
assert_eq!(entry.steuerart, Some(Steuerart::Rcv));
assert_eq!(entry.steuerwert, Some(rust_decimal::Decimal::ZERO));
let note = entry
.zusatz_attribute
.as_ref()
.expect("the entry carries its note")
.iter()
.find(|a| a.name.as_deref() == Some("mako:umsatzsteuer_hinweis"))
.and_then(|a| a.wert.as_ref())
.and_then(serde_json::Value::as_str)
.expect("the §14a Abs. 5 wording");
assert_eq!(note, crate::umsatzsteuer::HINWEIS_REVERSE_CHARGE);
}
#[test]
fn a_reversal_mirrors_the_tax_it_cancels() {
let original = crate::settle_nne(&sample_nne()).expect("settle");
let reversal = crate::reverse(&original, crate::KorrekturGrund::Messwertkorrektur);
assert_eq!(reversal.steuer.steuer_eur, -original.steuer.steuer_eur);
assert_eq!(reversal.steuer.brutto_eur(), -original.steuer.brutto_eur());
assert_eq!(reversal.steuer.kategorie, original.steuer.kategorie);
assert_eq!(
original.total_eur + reversal.total_eur,
rust_decimal::Decimal::ZERO
);
assert_eq!(
original.steuer.steuer_eur + reversal.steuer.steuer_eur,
rust_decimal::Decimal::ZERO
);
}
#[test]
fn an_abschlagsrechnung_is_exactly_one_position() {
let settlement = crate::settle_abschlag(&crate::AbschlagInput {
malo_id: "51238696012".to_owned(),
nb_mp_id: "9900357000004".to_owned(),
lf_mp_id: "9900012345678".to_owned(),
period: crate::SettlementPeriod::new(
time::macros::date!(2026 - 01 - 01),
time::macros::date!(2026 - 01 - 31),
)
.expect("valid period"),
sparte: crate::Sparte::Strom,
betrag_netto_eur: rust_decimal::Decimal::from(1000),
grundlage: crate::AbschlagGrundlage::Vorjahresverbrauch,
})
.expect("settle");
assert_eq!(settlement.settlement_type.default_pid(), 31001);
assert_eq!(settlement.positions.len(), 1);
assert_eq!(settlement.total_eur, rust_decimal::Decimal::from(1000));
let rechnung = into_rechnung(&as_document(settlement));
let positions = rechnung.rechnungspositionen.expect("positions");
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].positionsnummer, Some(1));
assert_eq!(
rechnung.netznutzungrechnungstyp,
Some(NetznutzungRechnungstyp::Abschlagsrechnung)
);
assert_eq!(
rechnung.gesamtsteuer.as_ref().and_then(|b| b.wert),
Some(rust_decimal::Decimal::from(190))
);
}
#[test]
fn abschlaege_reduce_what_is_owed_not_what_was_supplied() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let netto = settlement.total_eur;
let steuer = settlement.steuer.steuer_eur;
let brutto = settlement.steuer.brutto_eur();
let mut document = as_document(settlement);
document.cadence = Some(crate::Rechnungscharakter::Abschlussrechnung);
document.abschlaege = vec![
crate::Abschlagsverrechnung {
rechnungsnummer: "ABS-2026-000001".to_owned(),
rechnungsdatum: time::macros::date!(2026 - 01 - 05),
betrag_brutto_eur: rust_decimal::Decimal::from(100),
},
crate::Abschlagsverrechnung {
rechnungsnummer: "ABS-2026-000002".to_owned(),
rechnungsdatum: time::macros::date!(2026 - 02 - 05),
betrag_brutto_eur: rust_decimal::Decimal::from(50),
},
];
let rechnung = into_rechnung(&document);
let wert = |b: &Option<Betrag>| b.as_ref().and_then(|b| b.wert).expect("amount");
assert_eq!(
wert(&rechnung.gesamtnetto),
netto,
"the supply is unchanged"
);
assert_eq!(wert(&rechnung.gesamtsteuer), steuer, "and so is its tax");
assert_eq!(wert(&rechnung.gesamtbrutto), brutto);
assert_eq!(
wert(&rechnung.zu_zahlen),
brutto - rust_decimal::Decimal::from(150),
"only what is owed moves"
);
assert_eq!(
rechnung.netznutzungrechnungstyp,
Some(NetznutzungRechnungstyp::Abschlussrechnung)
);
let vz = rechnung.vorauszahlungen.expect("prepayments");
assert_eq!(vz.len(), 2);
assert_eq!(vz[0].referenz.as_deref(), Some("ABS-2026-000001"));
assert_eq!(vz[1].referenz.as_deref(), Some("ABS-2026-000002"));
}
#[test]
fn without_abschlaege_what_is_owed_is_the_gross() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let brutto = settlement.steuer.brutto_eur();
let rechnung = into_rechnung(&as_document(settlement));
assert_eq!(
rechnung.zu_zahlen.as_ref().and_then(|b| b.wert),
Some(brutto)
);
assert!(rechnung.vorauszahlungen.is_none());
}
#[test]
fn the_sparte_reaches_the_rechnung() {
for (sparte, want) in [
(crate::Sparte::Strom, rubo4e::current::Sparte::Strom),
(crate::Sparte::Gas, rubo4e::current::Sparte::Gas),
] {
let mut input = sample_nne();
input.sparte = sparte;
let settlement = crate::settle_nne(&input).expect("settle");
let rechnung = into_rechnung(&as_document(settlement));
assert_eq!(rechnung.sparte, Some(want));
}
}
#[test]
fn a_reversal_renders_as_a_stornorechnung() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let reversal = crate::reverse(&settlement, crate::KorrekturGrund::Messwertkorrektur);
let mut document = as_document(reversal);
document.rechnungsnummer = "NNE-2026-002".to_owned();
document.correction_of = Some("NNE-2026-001".to_owned());
let rechnung = into_rechnung(&document);
assert_eq!(rechnung.ist_storno, Some(true));
assert_eq!(
rechnung.original_rechnungsnummer.as_deref(),
Some("NNE-2026-001")
);
let total = rechnung
.gesamtnetto
.as_ref()
.and_then(|b| b.wert)
.expect("total");
assert!(total < rust_decimal::Decimal::ZERO, "storno total {total}");
}
#[test]
fn an_initial_settlement_is_not_a_storno() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let rechnung = into_rechnung(&as_document(settlement));
assert_eq!(rechnung.ist_storno, None);
assert_eq!(rechnung.original_rechnungsnummer, None);
}
#[test]
fn the_calculation_trace_reaches_the_rechnung() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let rechnung = into_rechnung(&as_document(settlement));
let positions = rechnung.rechnungspositionen.expect("positions");
let first = positions.first().expect("at least one position");
let attrs = first
.zusatz_attribute
.as_ref()
.expect("position carries its trace");
let trace = attrs
.iter()
.find(|a| a.name.as_deref() == Some("mako:calculation_trace"))
.and_then(|a| a.wert.as_ref())
.expect("mako:calculation_trace present");
assert!(trace.get("explanation").is_some(), "{trace}");
assert!(trace.get("legal_refs").is_some(), "{trace}");
assert!(trace.get("input_quantity").is_some(), "{trace}");
assert!(trace.get("gross_eur").is_some(), "{trace}");
}
#[test]
fn the_legal_references_reach_the_rechnung() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
let rechnung = into_rechnung(&as_document(settlement));
let refs = rechnung
.zusatz_attribute
.as_ref()
.expect("settlement attributes")
.iter()
.find(|a| a.name.as_deref() == Some("mako:legal_references"))
.and_then(|a| a.wert.as_ref())
.expect("mako:legal_references present");
let list = refs.as_array().expect("an array of citations");
assert!(!list.is_empty(), "a settlement always rests on something");
}
#[test]
fn rechnungsnummer_and_warnings_are_both_present() {
let mut settlement = crate::settle_nne(&sample_nne()).expect("settle");
settlement.warnings.push(crate::SettlementWarning {
severity: crate::WarningSeverity::Warning,
code: "TEST_WARNING",
message: "levy omitted for want of a published rate".to_owned(),
});
let rechnung = into_rechnung(&as_document(settlement));
assert_eq!(
rechnung.rechnungsnummer.as_deref(),
Some("NNE-2026-001"),
"the document's rechnungsnummer must reach the Rechnung"
);
let warnings = rechnung
.zusatz_attribute
.as_ref()
.expect("settlement attributes")
.iter()
.find(|a| a.name.as_deref() == Some("mako:settlement_warnings"))
.and_then(|a| a.wert.as_ref())
.expect("mako:settlement_warnings present");
let list = warnings.as_array().expect("an array of warnings");
assert_eq!(list.len(), 1);
assert_eq!(
list[0].get("code").and_then(|c| c.as_str()),
Some("TEST_WARNING")
);
}
#[test]
fn no_warnings_attribute_when_clean() {
let settlement = crate::settle_nne(&sample_nne()).expect("settle");
assert!(settlement.warnings.is_empty(), "fixture must be clean");
let rechnung = into_rechnung(&as_document(settlement));
let has_warnings = rechnung
.zusatz_attribute
.as_ref()
.expect("settlement attributes")
.iter()
.any(|a| a.name.as_deref() == Some("mako:settlement_warnings"));
assert!(!has_warnings);
}
#[test]
fn every_emitted_rechnung_is_valid_bo4e() {
let mut cases: Vec<(&str, crate::InvoiceDocument)> = Vec::new();
let strom = crate::settle_nne(&sample_nne()).expect("settle strom");
cases.push(("nne strom", as_document(strom.clone())));
let mut gas_input = sample_nne();
gas_input.sparte = crate::Sparte::Gas;
let gas = crate::settle_nne(&gas_input).expect("settle gas");
cases.push(("nne gas", as_document(gas)));
let mut storno = as_document(strom);
storno.correction_of = Some("NNE-2026-000".to_owned());
cases.push(("storno", storno));
for (label, doc) in cases {
let rechnung = into_rechnung(&doc);
mako_markt::bo4e::ensure_conformant(&rechnung)
.unwrap_or_else(|e| panic!("{label}: emitted a Rechnung mako would refuse: {e}"));
let json = serde_json::to_value(&rechnung)
.unwrap_or_else(|e| panic!("{label}: not serialisable: {e}"));
let back: Rechnung = serde_json::from_value(json)
.unwrap_or_else(|e| panic!("{label}: does not round-trip: {e}"));
mako_markt::bo4e::ensure_conformant(&back)
.unwrap_or_else(|e| panic!("{label}: the JSON form would be refused: {e}"));
}
}
}
#[cfg(test)]
mod artikelnummer_bridge_tests {
use crate::{BillingPositionKind as K, SettlementType as ST};
#[test]
fn every_emitted_codelist_name_parses() {
let kinds = [
K::NneArbeit,
K::NneArbeitHt,
K::NneArbeitNt,
K::NneArbeitModul1,
K::NneArbeitModul3,
K::NneLeistung,
K::NneGasGrundpreis,
K::Konzessionsabgabe,
K::Mehrmenge,
K::Mindermenge,
K::MsbGrundgebuehr,
K::Messdienstleistung,
K::GasAwhSperrung,
K::GasAwhEntsprrung,
K::GasAwhSonstige,
K::Blindmehrarbeit,
K::Sect19StromNevUmlage,
K::OffshoreNetzumlage,
K::KwkgUmlage,
K::DezentraleEinspeisung,
K::Sect19IndividuellesEntgelt,
K::GasKapazitaetsentgelt,
];
let types = [
ST::NneStrom,
ST::NneGas,
ST::MmmStrom,
ST::MmmGas,
ST::MsbRechnung,
ST::GasAwhSperrung,
ST::DezentraleEinspeisung,
];
for kind in kinds {
for st in types {
let Some(name) = kind.artikelnummer(st) else {
continue; };
assert!(
super::kind_to_artikelnummer(kind, st).is_some(),
"grid-billing emits {name:?} for {kind:?}/{st:?}, \
but rubo4e cannot parse it"
);
}
}
}
#[test]
fn strom_and_gas_nne_are_coded_differently() {
assert_eq!(K::NneArbeit.artikelnummer(ST::NneGas), Some("WIRKARBEIT"));
assert_eq!(K::NneArbeit.artikelnummer(ST::NneStrom), None);
}
}
#[cfg(test)]
mod rechnungstyp_tests {
use super::*;
use crate::SettlementType as S;
#[test]
fn grid_usage_and_mmm_are_netznutzungsrechnungen() {
for st in [
S::NneStrom,
S::NneGas,
S::MmmStrom,
S::MmmGas,
S::MmmSelbstausstellt,
] {
assert_eq!(
rechnungstyp_for(st),
Some(Rechnungstyp::Netznutzungsrechnung),
"{st:?} bills network use and must be typed as such"
);
}
}
#[test]
fn non_grid_settlements_are_left_untyped() {
for st in [
S::MsbRechnung,
S::GasAwhSperrung,
S::RedispatchKostenblatt,
S::DezentraleEinspeisung,
] {
assert_eq!(
rechnungstyp_for(st),
None,
"{st:?} is not a Netznutzungsrechnung"
);
assert_eq!(netznutzungrechnungsart_for(st), None);
assert_eq!(netznutzungrechnungstyp_for(st, None), None);
}
}
#[test]
fn only_pid_31006_is_self_issued() {
assert_eq!(
netznutzungrechnungsart_for(S::MmmSelbstausstellt),
Some(NetznutzungRechnungsart::Selbstausgestellt)
);
for st in [S::NneStrom, S::NneGas, S::MmmStrom, S::MmmGas] {
assert_eq!(
netznutzungrechnungsart_for(st),
Some(NetznutzungRechnungsart::Handelsrechnung),
"{st:?} is issued by the network operator"
);
}
}
#[test]
fn the_cadence_field_is_only_set_where_it_is_known() {
for st in [S::MmmStrom, S::MmmGas, S::MmmSelbstausstellt] {
assert_eq!(
netznutzungrechnungstyp_for(st, None),
Some(NetznutzungRechnungstyp::Mehrmindermengenrechnung)
);
}
for st in [S::NneStrom, S::NneGas] {
assert_eq!(
netznutzungrechnungstyp_for(st, None),
None,
"{st:?} has no cadence to state"
);
}
}
}