use crate::{InvoiceDocument, QuantityUnit, SettlementResult};
use rubo4e::current::{
Betrag, Menge, Mengeneinheit, Preis, Rechnung, Rechnungsposition, Zeitraum, ZusatzAttribut,
};
#[must_use]
pub fn kind_to_artikelnummer(
kind: crate::BillingPositionKind,
settlement_type: crate::SettlementType,
) -> Option<rubo4e::current::BdewArtikelnummer> {
use std::str::FromStr as _;
kind.artikelnummer(settlement_type)
.and_then(|name| rubo4e::current::BdewArtikelnummer::from_str(name).ok())
}
#[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();
Rechnung::builder()
.rechnungsnummer(document.rechnungsnummer.clone())
.rechnungsdatum(document.invoice_date)
.faelligkeitsdatum(document.due_date)
.rechnungsperiode(lz)
.gesamtnetto(Betrag::builder().wert(invoice.total_eur).build())
.rechnungspositionen(positions)
.zusatz_attribute(settlement_attributes(invoice))
.build()
}
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),
}
}
fn sample_nne() -> crate::NneInput {
crate::NneInput {
malo_id: "51238696780".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_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);
}
}
#[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);
}
}