use crate::EuroAmount;
use crate::rates::RoundMoney;
use rust_decimal::Decimal;
use rust_decimal::dec;
use serde::Serialize;
use crate::context::{AbschlagDeduction, BillingContext, Rechnungsempfaenger};
use crate::error::EngineError;
use crate::position::{BillingPosition, BillingWarning, PositionCategory};
fn normalise_weights(weights: &[Decimal]) -> Result<Vec<Decimal>, EngineError> {
let mut sum = Decimal::ZERO;
for &w in weights {
if w < Decimal::ZERO {
return Err(EngineError::AllocationWeightsInvalid { sum: w });
}
sum += w;
}
if sum <= Decimal::ZERO {
return Err(EngineError::AllocationWeightsInvalid { sum });
}
let mut shares: Vec<Decimal> = weights.iter().map(|&w| w / sum).collect();
let last = shares.len() - 1;
let head: Decimal = shares[..last].iter().sum();
shares[last] = Decimal::ONE - head;
Ok(shares)
}
fn is_same_abschlag(a: &AbschlagDeduction, b: &AbschlagDeduction) -> bool {
a.datum == b.datum
&& a.betrag_eur == b.betrag_eur
&& a.ust_satz == b.ust_satz
&& a.beschreibung == b.beschreibung
}
fn signed_split(
total: Decimal,
fractions: &[Decimal],
scale: u32,
) -> Result<Vec<Decimal>, EngineError> {
let negative = total < Decimal::ZERO;
let magnitude = if negative { -total } else { total };
let mut shares = billing::proportional_split(magnitude, fractions, scale)?;
if negative {
for share in &mut shares {
*share = -*share;
}
}
Ok(shares)
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Invoice {
pub context: BillingContext,
pub positions: Vec<BillingPosition>,
pub netto_eur: Decimal,
pub mwst_eur: Decimal,
pub brutto_eur: Decimal,
pub abschlag_total_eur: Decimal,
pub abschlag_ust_eur: Decimal,
pub zahlbetrag_eur: Decimal,
pub billing_run_id: Option<String>,
pub warnings: Vec<BillingWarning>,
}
pub(crate) const RECHNUNGSART_ATTRIBUT: &str = "mako:rechnungsart";
impl Invoice {
#[must_use]
pub fn tax_subtotals(&self, default_rate: Decimal) -> Vec<TaxSubtotal> {
tax_subtotals_of(&self.positions, default_rate)
}
pub fn advance_payments(&self) -> Result<Vec<billing::AdvancePayment>, EngineError> {
self.context
.abschlage
.iter()
.map(AbschlagDeduction::to_advance_payment)
.collect()
}
pub fn prepayment(&self) -> Result<billing::Prepayment, EngineError> {
let advances = self.advance_payments()?;
if advances.is_empty() {
return Ok(billing::Prepayment::None);
}
Ok(billing::Prepayment::itemised(advances)?)
}
pub fn residual_breakdown(
&self,
default_rate: Decimal,
) -> Result<Vec<billing::TaxBreakdownEntry>, EngineError> {
let full: Vec<billing::TaxBreakdownEntry> = self
.tax_subtotals(default_rate)
.iter()
.map(TaxSubtotal::to_breakdown_entry)
.collect::<Result<_, _>>()?;
let advances = self.advance_payments()?;
if advances.is_empty() {
return Ok(full);
}
Ok(billing::advance::residual_breakdown(&full, &advances)?)
}
#[must_use]
pub fn from_positions(
context: BillingContext,
positions: Vec<BillingPosition>,
warnings: Vec<BillingWarning>,
) -> Self {
let netto_eur: Decimal = positions
.iter()
.filter(|p| {
p.category != PositionCategory::Tax && p.category != PositionCategory::Abschlag
})
.map(|p| p.net_eur)
.sum();
let mwst_eur: Decimal = positions
.iter()
.filter(|p| p.category == PositionCategory::Tax)
.map(|p| p.net_eur)
.sum();
let brutto_eur = netto_eur + mwst_eur;
let reversal_sign = if context.invoice_type.is_reversal() {
Decimal::NEGATIVE_ONE
} else {
Decimal::ONE
};
let abschlag_total_eur: Decimal = if context.invoice_type.settles_advances() {
context
.abschlage
.iter()
.map(|a| a.betrag_eur)
.sum::<Decimal>()
* reversal_sign
} else {
Decimal::ZERO
};
let zahlbetrag_eur = brutto_eur - abschlag_total_eur;
let abschlag_ust_eur: Decimal = if context.invoice_type.settles_advances() {
context
.abschlage
.iter()
.map(AbschlagDeduction::ust_eur)
.sum::<Decimal>()
* reversal_sign
} else {
Decimal::ZERO
};
let billing_run_id = context.billing_run_id.clone();
Self {
context,
positions,
netto_eur,
mwst_eur,
brutto_eur,
abschlag_total_eur,
abschlag_ust_eur,
zahlbetrag_eur,
billing_run_id,
warnings,
}
}
#[must_use]
pub fn total_by_tag(&self, tag: &str) -> Decimal {
BillingPosition::total_by_tag(&self.positions, tag)
}
pub fn positions_by_tag<'a>(
&'a self,
tag: &'a str,
) -> impl Iterator<Item = &'a BillingPosition> {
self.positions.iter().filter(move |p| p.has_tag(tag))
}
pub fn set_rechnungsempfaenger(&mut self, empfaenger: Option<Rechnungsempfaenger>) {
self.context.rechnungsempfaenger = empfaenger;
}
pub fn assert_valid(&self) {
let expected = self.netto_eur + self.mwst_eur;
let diff = (self.brutto_eur - expected).abs();
assert!(
diff < dec!(0.001),
"Invoice invariant violated: netto {:.5} + mwst {:.5} = {:.5} != brutto {:.5}",
self.netto_eur,
self.mwst_eur,
expected,
self.brutto_eur
);
let zahlbetrag_expected = self.brutto_eur - self.abschlag_total_eur;
let zdiff = (self.zahlbetrag_eur - zahlbetrag_expected).abs();
assert!(
zdiff < dec!(0.001),
"Invoice invariant violated: zahlbetrag {:.5} != brutto {:.5} - abschlag {:.5}",
self.zahlbetrag_eur,
self.brutto_eur,
self.abschlag_total_eur,
);
}
#[must_use]
pub fn kilowattstundenpreis_brutto_ct(&self, total_kwh: Decimal) -> Option<Decimal> {
if total_kwh <= Decimal::ZERO {
return None;
}
Some((self.brutto_eur / total_kwh * dec!(100)).round_kfm(4))
}
#[must_use]
#[allow(clippy::too_many_lines)]
#[cfg(feature = "bo4e")]
pub fn to_rechnung(&self) -> rubo4e::current::Rechnung {
use rubo4e::current as bo;
let ctx = &self.context;
let betrag_eur = |wert: Decimal| bo::Betrag {
wert: Some(wert),
waehrung: Some(bo::Waehrungscode::Eur),
..Default::default()
};
let steuerbetraege: Vec<bo::Steuerbetrag> = self
.tax_subtotals(ctx.regulatory_rates.mwst_rate)
.iter()
.map(TaxSubtotal::to_bo4e)
.collect();
let vorauszahlungen: Vec<bo::Vorauszahlung> = ctx
.abschlage
.iter()
.map(|a| bo::Vorauszahlung {
betrag: Some(betrag_eur(a.betrag_eur)),
datum: Some(a.datum.midnight().assume_utc()),
referenz: a.beschreibung.clone(),
..Default::default()
})
.collect();
let rechnungspositionen: Vec<bo::Rechnungsposition> = self
.positions
.iter()
.filter(|p| p.is_rechnungsposition())
.enumerate()
.map(|(i, p)| {
let einheit = mengeneinheit_of(&p.unit);
let mut attrs: Vec<bo::ZusatzAttribut> = Vec::new();
if let Ok(t) = serde_json::to_value(&p.trace) {
attrs.push(zusatz_attribut("mako:calculation_trace", t));
}
if einheit.is_none() && !p.unit.is_empty() {
attrs.push(zusatz_attribut(
"mako:einheit",
serde_json::Value::String(p.unit.clone()),
));
}
let mut pos = bo::Rechnungsposition {
positionsnummer: Some((i + 1) as i64),
positionstext: Some(p.description.clone()),
positions_menge: Some(bo::Menge {
wert: Some(p.quantity),
einheit,
..Default::default()
}),
einzelpreis: Some(bo::Preis {
wert: Some(p.unit_price_eur),
einheit: Some(bo::Waehrungseinheit::Eur),
..Default::default()
}),
gesamtpreis: Some(betrag_eur(p.net_eur)),
..Default::default()
};
let mut attrs = attrs;
if let Some(lb) = &p.legal_basis {
attrs.push(zusatz_attribut(
"mako:rechtliche_grundlage",
serde_json::json!(lb),
));
}
attrs.push(zusatz_attribut(
"mako:positionstyp",
serde_json::json!(p.tags.first().map(String::as_str).unwrap_or("POSITION")),
));
attrs.push(zusatz_attribut(
"mako:positionskategorie",
serde_json::json!(format!("{:?}", p.category)),
));
pos.zusatz_attribute = Some(attrs);
pos
})
.collect();
let faelligkeitsdatum = ctx.faelligkeitsdatum();
let mut zusatz_attribute: Vec<bo::ZusatzAttribut> = self
.positions
.iter()
.filter(|p| p.has_tag("gasqualitaet") && p.category == PositionCategory::Info)
.map(|p| {
zusatz_attribut(
"mako:gasqualitaet",
serde_json::json!(p.legal_basis.as_deref().unwrap_or("")),
)
})
.collect();
if let Some(vi) = &ctx.vertragsinformationen {
for (name, wert) in [
("mako:vertragsdauer", vi.vertragsdauer.clone()),
("mako:kuendigungsfrist", vi.kuendigungsfrist.clone()),
(
"mako:naechstmoeglicher_kuendigungstermin",
vi.naechstmoeglicher_kuendigungstermin
.map(|d| d.to_string()),
),
(
"mako:naechster_abrechnungstermin",
vi.naechster_abrechnungstermin.map(|d| d.to_string()),
),
] {
if let Some(wert) = wert {
zusatz_attribute.push(zusatz_attribut(name, serde_json::json!(wert)));
}
}
}
if let Some(quellen) = &ctx.energiequellen
&& let Ok(wert) = serde_json::to_value(quellen)
{
zusatz_attribute.push(zusatz_attribut("mako:stromkennzeichnung", wert));
}
if let Some(vh) = &ctx.verbrauchshistorie {
if let Some(vj) = vh.vorjahr_kwh {
zusatz_attribute.push(zusatz_attribut(
"mako:verbrauch_vorjahr",
serde_json::json!(vj.to_string()),
));
}
if let Some(avg) = vh.bundesdurchschnitt_kwh {
zusatz_attribute.push(zusatz_attribut(
"mako:verbrauch_bundesdurchschnitt",
serde_json::json!(avg.to_string()),
));
}
}
if let Some(run_id) = &self.billing_run_id {
zusatz_attribute.push(zusatz_attribut(
"mako:billing_run_id",
serde_json::json!(run_id),
));
}
if let Some(g) = self.guthabenerstattung() {
zusatz_attribute.push(zusatz_attribut(
"mako:guthabenerstattung",
serde_json::json!({
"betragEur": g.betrag_eur.to_string(),
"spaetestens": g.spaetestens.to_string(),
"verrechnungZulaessig": g.verrechnung_zulaessig,
"rechtlicheGrundlage": g.rechtsgrundlage,
}),
));
}
zusatz_attribute.push(zusatz_attribut(
"mako:kundenkategorie",
serde_json::json!(format!("{:?}", ctx.kundenkategorie)),
));
zusatz_attribute.push(zusatz_attribut(
"mako:vertragsart",
serde_json::json!(ctx.vertragsart.label()),
));
if ctx.invoice_type.rechnungstyp().is_none()
|| ctx.invoice_type == crate::context::InvoiceType::PartialInvoice
{
zusatz_attribute.push(zusatz_attribut(
"mako:rechnungsart",
serde_json::json!(ctx.invoice_type.rechnungsart()),
));
}
let total_kwh_positions: Decimal = self
.positions
.iter()
.filter(|p| {
p.category == PositionCategory::Commodity
&& (p.has_tag("strom") || p.has_tag("arbeitspreis"))
&& p.unit == "kWh"
&& p.quantity > Decimal::ZERO
})
.map(|p| p.quantity)
.sum();
let kilowattstundenpreis_ct = if total_kwh_positions > Decimal::ZERO {
self.kilowattstundenpreis_brutto_ct(total_kwh_positions)
} else {
None
};
if let Some(ct) = kilowattstundenpreis_ct {
zusatz_attribute.push(zusatz_attribut(
"mako:kilowattstundenpreis_gesamt",
serde_json::json!({
"wert": ct.to_string(),
"einheit": "ct/kWh",
"bezugswert": "KWH",
"rechtlicheGrundlage": "§40 EnWG"
}),
));
}
zusatz_attribute.push(zusatz_attribut(
"mako:preisvergleichsdaten",
serde_json::json!({
"grundpreisEurProJahr": self.positions.iter()
.filter(|p| p.has_tag("commodity") && p.unit == "Tage")
.map(|p| p.unit_price_eur * dec!(365))
.next()
.map(|eur_year| serde_json::json!({ "wert": eur_year.to_string(), "waehrung": "EUR" })),
"arbeitspreisCtProKwh": self.positions.iter()
.filter(|p| (p.has_tag("strom") || p.has_tag("gas")) && p.category == crate::position::PositionCategory::Commodity && p.unit.starts_with("kWh"))
.map(|p| (p.unit_price_eur * dec!(100)).round_kfm(4))
.next()
.map(|ct| ct.to_string()),
"gesamtpreisCtProKwh": kilowattstundenpreis_ct.map(|ct| ct.to_string()),
"rechtlicheGrundlage": "§40b EnWG"
}),
));
if let Ok(vi) =
serde_json::to_value(ctx.verbraucherinformationen.clone().unwrap_or_default())
{
zusatz_attribute.push(zusatz_attribut("mako:verbraucherinformationen", vi));
}
let netzbetreiber = ctx.nb_mp_id.as_deref().map(|id| {
let rollencodenummer = rubo4e::identifiers::MarktpartnerId::new(id).ok();
let zusatz_attribute = rollencodenummer.is_none().then(|| {
vec![zusatz_attribut(
"mako:marktpartnercode",
serde_json::json!(id),
)]
});
Box::new(bo::Marktteilnehmer {
rollencodenummer,
zusatz_attribute,
..Default::default()
})
});
bo::Rechnung {
rechnungsnummer: Some(ctx.rechnungsnummer.clone()),
rechnungstyp: ctx.invoice_type.rechnungstyp(),
ist_storno: ctx.invoice_type.is_reversal().then_some(true),
original_rechnungsnummer: ctx.invoice_type.original_invoice_id().map(str::to_owned),
rechnungsdatum: as_bo4e_timestamp(ctx.ausstellungsdatum()),
faelligkeitsdatum: as_bo4e_timestamp(faelligkeitsdatum),
marktlokation: (!ctx.malo_id.is_empty()).then(|| {
Box::new(bo::Marktlokation {
id: Some(ctx.malo_id.clone()),
marktlokations_id: rubo4e::identifiers::MaloId::new(&ctx.malo_id).ok(),
..Default::default()
})
}),
zaehler: ctx.zaehler_id.as_ref().map(|z| {
vec![Box::new(bo::Zaehler {
zaehlernummer: Some(z.clone()),
..Default::default()
})]
}),
rechnungsersteller: Some(Box::new(bo::Geschaeftspartner {
organisationsname: ctx
.verbraucherinformationen
.as_ref()
.and_then(|vi| vi.lieferant_name.clone()),
zusatz_attribute: Some(vec![zusatz_attribut(
"mako:marktpartnercode",
serde_json::json!(ctx.lf_mp_id),
)]),
..Default::default()
})),
netzbetreiber,
vertrag: ctx.contract_id.as_ref().map(|c| {
Box::new(bo::Vertrag {
vertragsnummer: Some(c.clone()),
..Default::default()
})
}),
rechnungsperiode: Some(bo::Zeitraum {
startdatum: Some(ctx.period_from()),
enddatum: Some(ctx.period_to()),
..Default::default()
}),
rechnungspositionen: Some(rechnungspositionen),
zusatz_attribute: (!zusatz_attribute.is_empty()).then_some(zusatz_attribute),
gesamtnetto: Some(betrag_eur(self.netto_eur.round_kfm(2))),
gesamtsteuer: Some(betrag_eur(self.mwst_eur.round_kfm(2))),
gesamtbrutto: Some(betrag_eur(self.brutto_eur.round_kfm(2))),
steuerbetraege: Some(steuerbetraege),
vorauszahlungen: Some(vorauszahlungen),
zu_zahlen: Some(betrag_eur(self.zahlbetrag_eur.round_kfm(2))),
rechnungsempfaenger: Some(Box::new(rechnungsempfaenger(ctx))),
..Default::default()
}
}
#[must_use]
#[cfg(feature = "bo4e")]
pub fn to_rechnung_json(&self) -> serde_json::Value {
serde_json::to_value(self.to_rechnung())
.expect("a Rechnung is always serialisable; see the note on this method")
}
#[must_use]
pub fn merge(self, other: Invoice) -> Invoice {
let mut ctx = self.context;
if other.context.period_to() > ctx.period_to() {
ctx.period = crate::BillingPeriod::new(ctx.period_from(), other.context.period_to())
.expect("extending the end of a valid period keeps from <= to");
}
let mut positions = self.positions;
positions.extend(other.positions);
let mut all_warnings = self.warnings;
all_warnings.extend(other.warnings);
let mut doppelt: Vec<String> = Vec::new();
for abschlag in other.context.abschlage {
if ctx
.abschlage
.iter()
.any(|kept| is_same_abschlag(kept, &abschlag))
{
doppelt.push(format!(
"{} über {} EUR",
abschlag.datum, abschlag.betrag_eur
));
continue;
}
ctx.abschlage.push(abschlag);
}
if !doppelt.is_empty() {
all_warnings.push(BillingWarning {
code: "ABSCHLAG_DOPPELT",
severity: crate::position::WarningSeverity::Warning,
message: format!(
"beide Teilrechnungen führen dieselbe Anzahlung: {} — je Zahlung wird \
nach § 40 Abs. 1 EnWG genau ein Abzug ausgewiesen",
doppelt.join(", ")
),
});
}
Invoice::from_positions(ctx, positions, all_warnings)
}
pub fn allocate_proportionally(
self,
fractions: &[Decimal],
contexts: Vec<crate::context::BillingContext>,
) -> Result<Vec<Invoice>, EngineError> {
if fractions.len() != contexts.len() || fractions.is_empty() {
return Err(EngineError::AllocationMismatch {
fractions: fractions.len(),
contexts: contexts.len(),
});
}
let n = fractions.len();
let shares = normalise_weights(fractions)?;
let mut recipient_positions: Vec<Vec<crate::position::BillingPosition>> =
(0..n).map(|_| Vec::new()).collect();
for pos in &self.positions {
let split_amounts = signed_split(pos.net_eur, &shares, 5)?;
let split_quantities = signed_split(pos.quantity, &shares, 4)?;
for i in 0..n {
let mut split_pos = pos.clone();
split_pos.net_eur = split_amounts[i];
split_pos.quantity = split_quantities[i];
recipient_positions[i].push(split_pos);
}
}
let mut recipient_advances: Vec<Vec<AbschlagDeduction>> =
(0..n).map(|_| Vec::new()).collect();
for advance in &self.context.abschlage {
let split = signed_split(advance.betrag_eur, &shares, 2)?;
for (i, betrag_eur) in split.into_iter().enumerate() {
recipient_advances[i].push(AbschlagDeduction {
betrag_eur,
..advance.clone()
});
}
}
Ok(recipient_positions
.into_iter()
.zip(contexts)
.zip(recipient_advances)
.map(|((positions, mut ctx), abschlage)| {
ctx.abschlage = abschlage;
Invoice::from_positions(ctx, positions, self.warnings.clone())
})
.collect())
}
#[must_use]
pub fn has_errors(&self) -> bool {
use crate::position::WarningSeverity;
self.warnings
.iter()
.any(|w| w.severity == WarningSeverity::Error)
}
#[must_use]
pub fn has_warnings(&self) -> bool {
use crate::position::WarningSeverity;
self.warnings
.iter()
.any(|w| w.severity >= WarningSeverity::Warning)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Guthabenerstattung {
pub betrag_eur: Decimal,
pub spaetestens: time::Date,
pub verrechnung_zulaessig: bool,
pub rechtsgrundlage: &'static str,
}
impl Invoice {
#[must_use]
pub fn guthabenerstattung(&self) -> Option<Guthabenerstattung> {
if self.zahlbetrag_eur >= Decimal::ZERO {
return None;
}
let ist_schlussrechnung = self.context.invoice_type == crate::context::InvoiceType::Final;
Some(Guthabenerstattung {
betrag_eur: -self.zahlbetrag_eur,
spaetestens: self.context.faelligkeitsdatum(),
verrechnung_zulaessig: !ist_schlussrechnung,
rechtsgrundlage: if ist_schlussrechnung {
"§ 40c Abs. 3 Satz 2 EnWG"
} else {
"§ 40c Abs. 3 Satz 1 EnWG"
},
})
}
}
#[cfg(feature = "bo4e")]
fn as_bo4e_timestamp(date: time::Date) -> Option<time::OffsetDateTime> {
(0..=9999)
.contains(&date.year())
.then(|| date.midnight().assume_utc())
}
#[cfg(feature = "bo4e")]
fn rechnungsempfaenger(ctx: &BillingContext) -> rubo4e::current::Geschaeftspartner {
use rubo4e::current as bo;
let mut zusatz = vec![zusatz_attribut(
"mako:externe_kunden_id",
serde_json::json!(ctx.malo_id),
)];
let Some(e) = ctx
.rechnungsempfaenger
.as_ref()
.filter(|e| e.names_somebody())
else {
return bo::Geschaeftspartner {
zusatz_attribute: Some(zusatz),
..Default::default()
};
};
let adresse = match (
e.line1.as_deref(),
e.post_code.as_deref(),
e.city.as_deref(),
) {
(Some(line1), Some(plz), Some(ort)) => Some(bo::Adresse {
strasse: Some(line1.to_owned()),
postleitzahl: Some(plz.to_owned()),
ort: Some(ort.to_owned()),
landescode: e
.country
.as_deref()
.and_then(|c| bo::Landescode::from_wire(c).ok())
.or(Some(bo::Landescode::De)),
..Default::default()
}),
_ => {
zusatz.push(zusatz_attribut(
"mako:adresse_unvollstaendig",
serde_json::json!(true),
));
None
}
};
bo::Geschaeftspartner {
organisationsname: e.name.clone(),
adresse,
umsatzsteuer_id: e.vat_id.clone(),
zusatz_attribute: Some(zusatz),
..Default::default()
}
}
#[cfg(feature = "bo4e")]
fn zusatz_attribut(name: &str, wert: serde_json::Value) -> rubo4e::current::ZusatzAttribut {
rubo4e::current::ZusatzAttribut {
name: Some(name.to_owned()),
wert: Some(wert),
..Default::default()
}
}
#[cfg(feature = "bo4e")]
fn mengeneinheit_of(unit: &str) -> Option<rubo4e::current::Mengeneinheit> {
use rubo4e::current::Mengeneinheit as M;
match unit {
"kWh" | "kWh_Hs" => Some(M::Kwh),
"MWh" => Some(M::Mwh),
"kW" => Some(M::Kw),
"Tag" | "Tage" => Some(M::Tag),
"Woche" | "Wochen" => Some(M::Woche),
"Monat" | "Monate" => Some(M::Monat),
"Jahr" | "Jahre" => Some(M::Jahr),
"h" | "Stunde" | "Stunden" => Some(M::Stunde),
"m³" | "m3" => Some(M::Kubikmeter),
"%" => Some(M::Prozent),
"Stück" => Some(M::Stueck),
_ => None,
}
}
pub fn negate_rechnung_json_for_correction(
original: &serde_json::Value,
original_rechnungsnummer: &str,
new_rechnungsnummer: &str,
) -> serde_json::Value {
let mut corrected = original.clone();
if let Some(obj) = corrected.as_object_mut() {
obj.insert("istOriginal".to_owned(), serde_json::json!(false));
obj.insert(
"originalRechnungsnummer".to_owned(),
serde_json::json!(original_rechnungsnummer),
);
obj.insert(
"rechnungsnummer".to_owned(),
serde_json::json!(new_rechnungsnummer),
);
upsert_rechnungsart_attribut(obj, "KORREKTURRECHNUNG");
negate_betrag_in_obj(obj, "gesamtbrutto");
negate_betrag_in_obj(obj, "gesamtnetto");
negate_betrag_in_obj(obj, "gesamtsteuer");
negate_betrag_in_obj(obj, "zuZahlen");
if let Some(serde_json::Value::Array(steuerbetraege)) = obj.get_mut("steuerbetraege") {
for entry in steuerbetraege.iter_mut() {
if let Some(e) = entry.as_object_mut() {
negate_decimal_field(e, "basiswert");
negate_decimal_field(e, "steuerwert");
}
}
}
if let Some(serde_json::Value::Array(vorauszahlungen)) = obj.get_mut("vorauszahlungen") {
for entry in vorauszahlungen.iter_mut() {
if let Some(e) = entry.as_object_mut() {
negate_betrag_in_obj(e, "betrag");
}
}
}
if let Some(serde_json::Value::Array(positionen)) = obj.get_mut("rechnungspositionen") {
for pos in positionen.iter_mut() {
if let Some(pos_obj) = pos.as_object_mut() {
negate_betrag_in_obj(pos_obj, "gesamtpreis");
if let Some(serde_json::Value::Object(ep)) = pos_obj.get_mut("einzelpreis") {
negate_wert_field(ep);
}
}
}
}
}
corrected
}
fn upsert_rechnungsart_attribut(obj: &mut serde_json::Map<String, serde_json::Value>, label: &str) {
let attrs = obj
.entry("zusatzAttribute")
.or_insert_with(|| serde_json::json!([]));
if !attrs.is_array() {
*attrs = serde_json::json!([]);
}
if let Some(arr) = attrs.as_array_mut() {
if let Some(existing) = arr
.iter_mut()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(RECHNUNGSART_ATTRIBUT))
{
existing["wert"] = serde_json::json!(label);
} else {
arr.push(serde_json::json!({ "name": RECHNUNGSART_ATTRIBUT, "wert": label }));
}
}
}
fn negate_betrag_in_obj(obj: &mut serde_json::Map<String, serde_json::Value>, key: &str) {
if let Some(serde_json::Value::Object(betrag)) = obj.get_mut(key) {
negate_wert_field(betrag);
}
}
fn negate_decimal_field(obj: &mut serde_json::Map<String, serde_json::Value>, key: &str) {
let Some(v) = obj.get(key) else { return };
let negated = match v {
serde_json::Value::String(s) => s
.parse::<Decimal>()
.ok()
.map(|d| serde_json::json!((-d).to_string())),
serde_json::Value::Number(n) => n
.to_string()
.parse::<Decimal>()
.ok()
.map(|d| serde_json::json!((-d).to_string())),
_ => None,
};
if let Some(neg) = negated {
obj.insert(key.to_owned(), neg);
}
}
fn negate_wert_field(obj: &mut serde_json::Map<String, serde_json::Value>) {
if let Some(v) = obj.get("wert") {
let negated = match v {
serde_json::Value::String(s) => s.parse::<Decimal>().ok(),
serde_json::Value::Number(n) => n.to_string().parse::<Decimal>().ok(),
_ => None,
};
if let Some(neg) = negated {
obj.insert("wert".to_owned(), serde_json::json!((-neg).to_string()));
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum VatCategory {
Standard,
ZeroRated,
ReverseCharge,
Exempt,
OutOfScope,
}
impl VatCategory {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Standard => "S",
Self::ZeroRated => "Z",
Self::ReverseCharge => "AE",
Self::Exempt => "E",
Self::OutOfScope => "O",
}
}
#[must_use]
pub const fn is_exclusive(self) -> bool {
matches!(self, Self::OutOfScope)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TaxSubtotal {
pub category: VatCategory,
pub rate_percent: Decimal,
pub taxable_base_eur: Decimal,
pub tax_amount_eur: Decimal,
}
impl TaxSubtotal {
pub fn to_breakdown_entry(&self) -> Result<billing::TaxBreakdownEntry, EngineError> {
Ok(billing::TaxBreakdownEntry::new(
match self.category {
VatCategory::Standard => billing::TaxCategory::Standard,
VatCategory::ZeroRated => billing::TaxCategory::ZeroRated,
VatCategory::ReverseCharge => billing::TaxCategory::ReverseCharge,
VatCategory::Exempt => billing::TaxCategory::Exempt,
VatCategory::OutOfScope => billing::TaxCategory::OutOfScope,
},
self.rate_percent / Decimal::ONE_HUNDRED,
EuroAmount::checked_from_decimal(self.taxable_base_eur)?,
EuroAmount::checked_from_decimal(self.tax_amount_eur)?,
))
}
#[must_use]
#[cfg(feature = "bo4e")]
pub fn to_bo4e(&self) -> rubo4e::current::Steuerbetrag {
rubo4e::current::Steuerbetrag {
basiswert: Some(self.taxable_base_eur),
steuerwert: Some(self.tax_amount_eur),
steuersatz: Some(self.rate_percent),
steuerart: Some(match self.category {
VatCategory::ReverseCharge => rubo4e::current::Steuerart::Rcv,
_ => rubo4e::current::Steuerart::Ust,
}),
waehrungscode: Some(rubo4e::current::Waehrungscode::Eur),
..Default::default()
}
}
}
#[must_use]
pub fn tax_subtotals_of(positions: &[BillingPosition], default_rate: Decimal) -> Vec<TaxSubtotal> {
use std::collections::BTreeMap;
let mut buckets: BTreeMap<(VatCategory, String), (Decimal, Decimal)> = BTreeMap::new();
for p in positions {
if matches!(
p.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
) {
continue;
}
let rate = p.applicable_tax_rate.unwrap_or(default_rate).normalize();
let cat = vat_category_of(p, rate);
let entry = buckets
.entry((cat, rate.to_string()))
.or_insert((rate, Decimal::ZERO));
entry.1 += p.net_eur;
}
buckets
.into_iter()
.map(|((category, _), (rate, base))| TaxSubtotal {
category,
rate_percent: (rate * Decimal::ONE_HUNDRED).normalize(),
taxable_base_eur: base.round_kfm(2),
tax_amount_eur: (base * rate).round_kfm(2),
})
.collect()
}
#[must_use]
pub fn vat_category_of(position: &BillingPosition, effective_rate: Decimal) -> VatCategory {
if position.is_out_of_scope() {
VatCategory::OutOfScope
} else if position.is_reverse_charge() {
VatCategory::ReverseCharge
} else if effective_rate.is_zero() {
VatCategory::ZeroRated
} else {
VatCategory::Standard
}
}
#[cfg(all(test, feature = "bo4e"))]
mod tax_subtotal_tests {
use super::*;
use crate::position::PositionCategory;
use rust_decimal::dec;
fn pos(net: Decimal, rate: Option<Decimal>, cat: PositionCategory) -> BillingPosition {
let mut p = BillingPosition::debit("x", Decimal::ONE, "kWh", net, cat);
p.applicable_tax_rate = rate;
p
}
#[test]
fn mixed_rates_produce_one_subtotal_each() {
let positions = vec![
pos(dec!(1000), None, PositionCategory::Commodity),
pos(dec!(500), Some(dec!(0.07)), PositionCategory::Commodity),
];
let subs = tax_subtotals_of(&positions, dec!(0.19));
assert_eq!(subs.len(), 2, "one entry per rate: {subs:?}");
let standard = subs.iter().find(|s| s.rate_percent == dec!(19)).unwrap();
assert_eq!(standard.taxable_base_eur, dec!(1000));
assert_eq!(standard.tax_amount_eur, dec!(190));
assert_eq!(standard.category, VatCategory::Standard);
let reduced = subs.iter().find(|s| s.rate_percent == dec!(7)).unwrap();
assert_eq!(reduced.taxable_base_eur, dec!(500));
assert_eq!(reduced.tax_amount_eur, dec!(35));
}
#[test]
fn zero_rated_positions_still_get_a_subtotal() {
let positions = vec![
pos(dec!(1000), None, PositionCategory::Commodity),
pos(dec!(250), Some(Decimal::ZERO), PositionCategory::Commodity),
];
let subs = tax_subtotals_of(&positions, dec!(0.19));
let zero = subs
.iter()
.find(|s| s.rate_percent.is_zero())
.expect("zero-rated subtotal must be present");
assert_eq!(zero.taxable_base_eur, dec!(250));
assert_eq!(zero.tax_amount_eur, Decimal::ZERO);
assert_eq!(zero.category, VatCategory::ZeroRated);
let base_sum: Decimal = subs.iter().map(|s| s.taxable_base_eur).sum();
assert_eq!(base_sum, dec!(1250));
}
#[test]
fn reverse_charge_is_a_distinct_ae_subtotal() {
let positions = vec![
pos(dec!(1000), None, PositionCategory::Commodity),
pos(dec!(250), Some(Decimal::ZERO), PositionCategory::Commodity),
BillingPosition::debit(
"Reststrom Wiederverkäufer",
dec!(5000),
"kWh",
dec!(1),
PositionCategory::Commodity,
)
.with_reverse_charge(),
];
let subs = tax_subtotals_of(&positions, dec!(0.19));
let ae = subs
.iter()
.find(|s| s.category == VatCategory::ReverseCharge)
.expect("a reverse-charge (AE) subtotal must be present");
assert_eq!(ae.taxable_base_eur, dec!(5000));
assert_eq!(ae.tax_amount_eur, Decimal::ZERO, "supplier charges no VAT");
assert!(ae.rate_percent.is_zero());
let zero = subs
.iter()
.find(|s| s.category == VatCategory::ZeroRated)
.expect("zero-rated (Z) subtotal must remain distinct from AE");
assert_eq!(zero.taxable_base_eur, dec!(250));
assert_eq!(subs.len(), 3, "S/Z/AE must not merge: {subs:?}");
}
#[test]
fn non_supply_positions_are_excluded_from_the_base() {
let positions = vec![
pos(dec!(1000), None, PositionCategory::Commodity),
pos(dec!(190), None, PositionCategory::Tax),
pos(dec!(-300), None, PositionCategory::Abschlag),
pos(dec!(99), None, PositionCategory::Info),
];
let subs = tax_subtotals_of(&positions, dec!(0.19));
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].taxable_base_eur, dec!(1000));
assert_eq!(subs[0].tax_amount_eur, dec!(190));
}
#[test]
fn credit_positions_yield_negative_tax() {
let positions = vec![pos(dec!(-500), None, PositionCategory::Commodity)];
let subs = tax_subtotals_of(&positions, dec!(0.19));
assert_eq!(subs[0].taxable_base_eur, dec!(-500));
assert_eq!(subs[0].tax_amount_eur, dec!(-95));
}
#[test]
fn equivalent_rate_spellings_group_together() {
let positions = vec![
pos(dec!(100), Some(dec!(0.19)), PositionCategory::Commodity),
pos(dec!(100), Some(dec!(0.190)), PositionCategory::Commodity),
];
let subs = tax_subtotals_of(&positions, dec!(0.19));
assert_eq!(subs.len(), 1, "0.19 and 0.190 are one rate: {subs:?}");
assert_eq!(subs[0].taxable_base_eur, dec!(200));
}
#[test]
fn bo4e_projection_uses_percent_and_eur() {
let sub = TaxSubtotal {
category: VatCategory::Standard,
rate_percent: dec!(19),
taxable_base_eur: dec!(1000),
tax_amount_eur: dec!(190),
};
let bo = sub.to_bo4e();
assert_eq!(bo.steuersatz, Some(dec!(19)));
assert_eq!(bo.basiswert, Some(dec!(1000)));
assert_eq!(bo.steuerwert, Some(dec!(190)));
assert_eq!(bo.waehrungscode, Some(rubo4e::current::Waehrungscode::Eur));
assert_eq!(bo.steuerart, Some(rubo4e::current::Steuerart::Ust));
}
#[test]
fn reverse_charge_maps_to_rcv_and_ae() {
let sub = TaxSubtotal {
category: VatCategory::ReverseCharge,
rate_percent: Decimal::ZERO,
taxable_base_eur: dec!(1000),
tax_amount_eur: Decimal::ZERO,
};
assert_eq!(sub.category.code(), "AE");
assert_eq!(
sub.to_bo4e().steuerart,
Some(rubo4e::current::Steuerart::Rcv)
);
}
}
#[cfg(all(test, feature = "bo4e"))]
mod rechnung_json_tests {
use super::*;
use crate::context::AbschlagDeduction;
use crate::position::PositionCategory;
use rust_decimal::dec;
use time::macros::date;
fn invoice_with_advance() -> Invoice {
let ctx = BillingContext {
invoice_type: crate::context::InvoiceType::Final,
abschlage: vec![AbschlagDeduction {
datum: date!(2026 - 01 - 15),
betrag_eur: dec!(119.00),
ust_satz: dec!(0.19),
beschreibung: Some("Abschlag Januar 2026".to_owned()),
}],
..BillingContext::default()
};
let positions = vec![
BillingPosition::debit(
"Arbeitspreis",
dec!(1000),
"kWh",
dec!(0.30),
PositionCategory::Commodity,
),
BillingPosition::debit(
"MwSt 19 %",
Decimal::ONE,
"EUR",
dec!(57.00),
PositionCategory::Tax,
),
];
Invoice::from_positions(ctx, positions, vec![])
}
#[cfg(feature = "bo4e")]
#[test]
fn rechnung_json_uses_real_bo4e_field_names() {
let json = invoice_with_advance().to_rechnung_json();
let rechnung: rubo4e::current::Rechnung =
serde_json::from_value(json).expect("emitted JSON is a BO4E Rechnung");
let steuerbetraege = rechnung
.steuerbetraege
.expect("steuerbetraege must be populated, not routed to the extension map");
assert_eq!(steuerbetraege.len(), 1);
assert_eq!(steuerbetraege[0].basiswert, Some(dec!(300.00)));
assert_eq!(steuerbetraege[0].steuerwert, Some(dec!(57.00)));
assert_eq!(steuerbetraege[0].steuersatz, Some(dec!(19)));
let vorauszahlungen = rechnung
.vorauszahlungen
.expect("vorauszahlungen must be populated, not routed to the extension map");
assert_eq!(vorauszahlungen.len(), 1);
assert_eq!(
vorauszahlungen[0].betrag.as_ref().and_then(|b| b.wert),
Some(dec!(119.00))
);
}
#[test]
fn every_sect40_pflichtangabe_survives_the_typed_migration() {
use time::macros::date;
let ctx = BillingContext {
malo_id: "51238696012".to_owned(), lf_mp_id: "9900000000001".to_owned(),
rechnungsnummer: "R40-PFLICHT-1".to_owned(),
period: crate::BillingPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31))
.unwrap(),
zaehler_id: Some("1EFW1234567".to_owned()),
nb_mp_id: Some("9900357000004".to_owned()),
contract_id: Some("V-2026-042".to_owned()),
billing_run_id: Some("run-1".to_owned()),
verbrauchshistorie: Some(crate::context::Verbrauchshistorie {
vorjahr_kwh: Some(dec!(5800)),
bundesdurchschnitt_kwh: Some(dec!(3500)),
kundengruppe: None,
}),
vertragsinformationen: Some(crate::context::Vertragsinformationen {
vertragsdauer: Some("24 Monate".to_owned()),
kuendigungsfrist: Some("6 Wochen".to_owned()),
naechstmoeglicher_kuendigungstermin: Some(date!(2026 - 12 - 31)),
naechster_abrechnungstermin: Some(date!(2027 - 01 - 31)),
}),
energiequellen: Some(crate::tariff::EnergieQuellen {
erneuerbar_pct: dec!(100),
co2_g_per_kwh: Decimal::ZERO,
hkn_certified: true,
..Default::default()
}),
..BillingContext::default()
};
let positions = vec![
{
let mut p = BillingPosition::debit(
"Arbeitspreis",
dec!(500),
"kWh",
dec!(0.30),
PositionCategory::Commodity,
);
p.tags.push("strom".to_owned());
p.tags.push("arbeitspreis".to_owned());
p
},
BillingPosition::debit(
"MwSt 19 %",
Decimal::ONE,
"EUR",
dec!(28.50),
PositionCategory::Tax,
),
];
let invoice = Invoice::from_positions(ctx, positions, vec![]);
let rechnung = invoice.to_rechnung();
assert_eq!(
rechnung
.zaehler
.as_ref()
.and_then(|z| z[0].zaehlernummer.clone()),
Some("1EFW1234567".to_owned()),
"§41 Abs. 1 Nr. 6 — Zählernummer"
);
assert_eq!(
rechnung
.netzbetreiber
.as_ref()
.and_then(|nb| nb.rollencodenummer.as_ref())
.map(|id| id.as_ref().to_owned()),
Some("9900357000004".to_owned()),
"§41 Abs. 1 Nr. 5 — Netzbetreiber"
);
assert_eq!(
rechnung
.marktlokation
.as_ref()
.and_then(|m| m.marktlokations_id.as_ref())
.map(|id| id.as_ref().to_owned()),
Some("51238696012".to_owned()),
"checksum-valid MaLo lands in the typed field"
);
assert_eq!(
rechnung
.vertrag
.as_ref()
.and_then(|v| v.vertragsnummer.clone()),
Some("V-2026-042".to_owned())
);
assert_eq!(
rechnung.faelligkeitsdatum_date(),
Some(date!(2026 - 02 - 14)),
"the schema types this as date-time; the calendar date must survive the promotion"
);
let periode = rechnung
.rechnungsperiode
.as_ref()
.expect("rechnungsperiode");
assert_eq!(periode.startdatum, Some(date!(2026 - 01 - 01)));
assert_eq!(periode.enddatum, Some(date!(2026 - 01 - 31)));
let attrs = rechnung.zusatz_attribute.as_ref().expect("zusatzAttribute");
let names: Vec<&str> = attrs.iter().filter_map(|a| a.name.as_deref()).collect();
for required in [
"mako:vertragsdauer", "mako:kuendigungsfrist", "mako:naechstmoeglicher_kuendigungstermin", "mako:naechster_abrechnungstermin", "mako:verbraucherinformationen", "mako:kilowattstundenpreis_gesamt", "mako:preisvergleichsdaten", "mako:verbrauch_vorjahr", "mako:verbrauch_bundesdurchschnitt", "mako:stromkennzeichnung", "mako:billing_run_id", "mako:kundenkategorie", "mako:vertragsart", ] {
assert!(
names.contains(&required),
"Pflichtangabe {required:?} missing; present: {names:?}"
);
}
let vi = attrs
.iter()
.find(|a| a.name.as_deref() == Some("mako:verbraucherinformationen"))
.and_then(|a| a.wert.clone())
.expect("verbraucherinformationen wert");
for key in [
"schlichtungsstelle",
"bnetza_verbraucherservice",
"energieberatung",
"wechselhinweis",
] {
assert!(
vi[key].as_str().is_some_and(|s| !s.is_empty()),
"§40 Abs. 2 hint {key:?} must be non-empty"
);
}
}
#[cfg(feature = "bo4e")]
#[test]
fn money_round_trips_decimal_exact() {
let ctx = BillingContext {
abschlage: vec![AbschlagDeduction {
datum: date!(2026 - 03 - 15),
betrag_eur: dec!(119.01),
ust_satz: dec!(0.19),
beschreibung: None,
}],
invoice_type: crate::context::InvoiceType::Final,
..BillingContext::default()
};
let positions = vec![
BillingPosition::debit(
"Arbeitspreis",
dec!(1111.1),
"kWh",
dec!(0.30003),
PositionCategory::Commodity,
),
BillingPosition::debit(
"MwSt 19 %",
Decimal::ONE,
"EUR",
dec!(63.33),
PositionCategory::Tax,
),
];
let invoice = Invoice::from_positions(ctx, positions, vec![]);
let json = invoice.to_rechnung_json();
let back: rubo4e::current::Rechnung =
serde_json::from_value(json).expect("typed round-trip");
let wert = |b: &Option<rubo4e::current::Betrag>| b.as_ref().and_then(|b| b.wert);
assert_eq!(
wert(&back.gesamtnetto),
Some(invoice.netto_eur.round_kfm(2))
);
assert_eq!(
wert(&back.gesamtsteuer),
Some(invoice.mwst_eur.round_kfm(2))
);
assert_eq!(
wert(&back.gesamtbrutto),
Some(invoice.brutto_eur.round_kfm(2))
);
assert_eq!(
wert(&back.zu_zahlen),
Some(invoice.zahlbetrag_eur.round_kfm(2))
);
assert_eq!(
back.vorauszahlungen.as_ref().unwrap()[0]
.betrag
.as_ref()
.and_then(|b| b.wert),
Some(dec!(119.01)),
"advance gross survives to the exact cent"
);
let pos = &back.rechnungspositionen.as_ref().unwrap()[0];
assert_eq!(
pos.gesamtpreis.as_ref().and_then(|b| b.wert),
Some(invoice.positions[0].net_eur)
);
assert_eq!(invoice.positions[0].net_eur, dec!(333.36333));
assert_eq!(
pos.einzelpreis.as_ref().and_then(|p| p.wert),
Some(dec!(0.30003))
);
}
#[test]
fn steuerbetraege_sum_to_gesamtsteuer() {
let invoice = invoice_with_advance();
let sum: Decimal = invoice
.tax_subtotals(invoice.context.regulatory_rates.mwst_rate)
.iter()
.map(|s| s.tax_amount_eur)
.sum();
assert_eq!(sum, invoice.mwst_eur);
}
}
#[cfg(test)]
mod guthaben_tests {
use super::*;
use crate::context::{BillingPeriod, InvoiceType};
use rust_decimal::dec;
use time::macros::date;
fn invoice(zahlbetrag: Decimal, typ: InvoiceType) -> Invoice {
let context = BillingContext {
period: BillingPeriod::new(date!(2026 - 01 - 01), date!(2026 - 12 - 31))
.expect("period"),
issue_date: Some(date!(2027 - 01 - 15)),
invoice_type: typ,
..Default::default()
};
Invoice {
context,
positions: Vec::new(),
netto_eur: Decimal::ZERO,
mwst_eur: Decimal::ZERO,
brutto_eur: Decimal::ZERO,
abschlag_total_eur: Decimal::ZERO,
abschlag_ust_eur: Decimal::ZERO,
zahlbetrag_eur: zahlbetrag,
billing_run_id: None,
warnings: Vec::new(),
}
}
#[test]
fn an_invoice_the_customer_owes_creates_no_refund_obligation() {
assert!(
invoice(dec!(240), InvoiceType::Final)
.guthabenerstattung()
.is_none()
);
}
#[test]
fn a_credit_on_an_ordinary_settlement_may_be_offset_or_paid_out() {
let g = invoice(dec!(-180.50), InvoiceType::Initial)
.guthabenerstattung()
.expect("a negative balance is a credit");
assert_eq!(g.betrag_eur, dec!(180.50), "reported positive, as owed");
assert!(g.verrechnung_zulaessig);
assert_eq!(g.spaetestens, date!(2027 - 01 - 29));
assert_eq!(g.rechtsgrundlage, "§ 40c Abs. 3 Satz 1 EnWG");
}
#[test]
fn a_credit_on_a_schlussrechnung_has_to_be_paid_out() {
let g = invoice(dec!(-90), InvoiceType::Final)
.guthabenerstattung()
.expect("credit");
assert!(!g.verrechnung_zulaessig);
assert_eq!(g.rechtsgrundlage, "§ 40c Abs. 3 Satz 2 EnWG");
}
}
#[cfg(test)]
mod settlement_tests {
use super::*;
use crate::context::{AbschlagDeduction, InvoiceType};
use crate::position::PositionCategory;
use rust_decimal::dec;
use time::macros::date;
fn jahresabrechnung() -> Invoice {
let ctx = BillingContext {
invoice_type: InvoiceType::Final,
abschlage: vec![AbschlagDeduction {
datum: date!(2026 - 06 - 15),
betrag_eur: dec!(892.50), ust_satz: dec!(0.19),
beschreibung: Some("Abschläge 2026".to_owned()),
}],
..BillingContext::default()
};
let positions = vec![
BillingPosition::debit(
"Arbeitspreis",
dec!(1000),
"kWh",
dec!(1.00),
PositionCategory::Commodity,
),
BillingPosition::debit(
"MwSt 19 %",
Decimal::ONE,
"EUR",
dec!(190.00),
PositionCategory::Tax,
),
];
Invoice::from_positions(ctx, positions, vec![])
}
#[test]
fn advance_carries_its_own_tax_across_the_boundary() {
let advances = jahresabrechnung().advance_payments().unwrap();
assert_eq!(advances.len(), 1);
assert_eq!(
advances[0].net(),
billing::Amount::parse("750.00000").unwrap()
);
assert_eq!(
advances[0].tax_total(),
billing::Amount::parse("142.50000").unwrap()
);
assert_eq!(
advances[0].gross(),
billing::Amount::parse("892.50000").unwrap()
);
}
#[test]
fn prepayment_is_itemised_when_advances_exist() {
assert!(matches!(
jahresabrechnung().prepayment().unwrap(),
billing::Prepayment::Itemised(_)
));
let no_advances = Invoice::from_positions(BillingContext::default(), vec![], vec![]);
assert!(matches!(
no_advances.prepayment().unwrap(),
billing::Prepayment::None
));
}
#[test]
fn residual_breakdown_bills_only_the_remainder() {
let residual = jahresabrechnung().residual_breakdown(dec!(0.19)).unwrap();
assert_eq!(residual.len(), 1);
assert_eq!(
residual[0].taxable_base,
billing::Amount::parse("250.00000").unwrap()
);
assert_eq!(
residual[0].tax_amount,
billing::Amount::parse("47.50000").unwrap()
);
}
#[test]
fn advances_exceeding_the_supply_are_refused() {
let mut invoice = jahresabrechnung();
invoice.context.abschlage[0].betrag_eur = dec!(2000.00);
assert!(invoice.residual_breakdown(dec!(0.19)).is_err());
}
}
#[cfg(test)]
mod correction_tests {
use super::*;
use crate::context::{AbschlagDeduction, InvoiceType};
use crate::position::PositionCategory;
use rust_decimal::dec;
use time::macros::date;
#[cfg(feature = "bo4e")]
#[test]
fn correction_negates_the_vat_breakdown_with_the_total() {
let ctx = BillingContext {
invoice_type: InvoiceType::Final,
abschlage: vec![AbschlagDeduction {
datum: date!(2026 - 01 - 15),
betrag_eur: dec!(119.00),
ust_satz: dec!(0.19),
beschreibung: None,
}],
..BillingContext::default()
};
let positions = vec![
BillingPosition::debit(
"Arbeitspreis",
dec!(1000),
"kWh",
dec!(0.30),
PositionCategory::Commodity,
),
BillingPosition::debit(
"MwSt 19 %",
Decimal::ONE,
"EUR",
dec!(57.00),
PositionCategory::Tax,
),
];
let json = Invoice::from_positions(ctx, positions, vec![]).to_rechnung_json();
let corrected = negate_rechnung_json_for_correction(&json, "ORIG-1", "KORR-1");
let steuer = &corrected["steuerbetraege"][0];
assert_eq!(steuer["basiswert"], serde_json::json!("-300.00"));
assert_eq!(steuer["steuerwert"], serde_json::json!("-57.00"));
assert_eq!(
corrected["gesamtsteuer"]["wert"],
serde_json::json!("-57.00")
);
assert_eq!(
corrected["vorauszahlungen"][0]["betrag"]["wert"],
serde_json::json!("-119.00")
);
}
}
#[cfg(test)]
mod trace_emission_tests {
use super::*;
use crate::position::PositionCategory;
use rust_decimal::dec;
#[cfg(feature = "bo4e")]
#[test]
fn the_position_trace_reaches_the_stored_rechnung() {
let mut pos = BillingPosition::debit(
"Arbeitspreis",
dec!(1000),
"kWh",
dec!(0.30),
PositionCategory::Commodity,
);
pos.trace.formula = "1000 kWh x 0.30 EUR/kWh".to_owned();
pos.trace.regulatory_basis = vec!["§40 EnWG".to_owned()];
let invoice = Invoice::from_positions(BillingContext::default(), vec![pos], vec![]);
let json = invoice.to_rechnung_json();
let trace = json["rechnungspositionen"][0]["zusatzAttribute"]
.as_array()
.expect("position carries attributes")
.iter()
.find(|a| a["name"] == "mako:calculation_trace")
.and_then(|a| a.get("wert"))
.expect("mako:calculation_trace present");
assert_eq!(trace["formula"], "1000 kWh x 0.30 EUR/kWh");
assert_eq!(trace["regulatory_basis"][0], "§40 EnWG");
assert!(trace.get("input_quantity").is_some(), "{trace}");
}
}