use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::Serialize;
use crate::context::BillingContext;
use crate::position::{BillingPosition, PositionCategory};
#[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 zahlbetrag_eur: Decimal,
pub billing_run_id: Option<String>,
}
impl Invoice {
#[must_use]
pub fn from_positions(context: BillingContext, positions: Vec<BillingPosition>) -> 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 abschlag_total_eur: Decimal = positions
.iter()
.filter(|p| p.category == PositionCategory::Abschlag)
.map(|p| p.net_eur.abs())
.sum();
let zahlbetrag_eur = brutto_eur - abschlag_total_eur;
let billing_run_id = context.billing_run_id.clone();
Self {
context,
positions,
netto_eur,
mwst_eur,
brutto_eur,
abschlag_total_eur,
zahlbetrag_eur,
billing_run_id,
}
}
#[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 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_dp(4))
}
#[must_use]
pub fn to_rechnung_json(&self) -> serde_json::Value {
let ctx = &self.context;
let pos_json: Vec<serde_json::Value> = self
.positions
.iter()
.enumerate()
.map(|(i, p)| {
serde_json::json!({
"_typ": "RECHNUNGSPOSITION",
"positionsnummer": i + 1,
"positionstext": p.description,
"rechtlicheGrundlage": p.legal_basis,
"positionsMenge": {
"_typ": "MENGE",
"wert": p.quantity.to_string(),
"einheit": p.unit
},
"einzelpreis": {
"_typ": "PREIS",
"wert": p.unit_price_eur.to_string(),
"einheit": "EUR"
},
"gesamtpreis": {
"_typ": "BETRAG",
"wert": p.net_eur.to_string(),
"waehrung": "EUR"
},
"positionstyp": p.tags.first().map(String::as_str).unwrap_or("POSITION"),
"kategorie": format!("{:?}", p.category),
})
})
.collect();
let zahlungsziel = ctx.period_to + time::Duration::days(14);
let mut zusatz_attribute: Vec<serde_json::Value> = self
.positions
.iter()
.filter(|p| p.has_tag("gasqualitaet") && p.category == PositionCategory::Info)
.map(|p| {
serde_json::json!({
"_typ": "ZUSATZ_ATTRIBUT",
"name": "gasqualitaet",
"wert": p.legal_basis.as_deref().unwrap_or("")
})
})
.collect();
if let Some(mix) = &ctx.energiemix {
zusatz_attribute.push(serde_json::json!({
"_typ": "ZUSATZ_ATTRIBUT",
"name": "energiemix",
"wert": mix
}));
}
if let Some(vh) = &ctx.verbrauchshistorie {
if let Some(vj) = vh.vorjahr_kwh {
zusatz_attribute.push(serde_json::json!({
"_typ": "ZUSATZ_ATTRIBUT",
"name": "verbrauchVorjahr",
"wert": vj.to_string()
}));
}
if let Some(avg) = vh.bundesdurchschnitt_kwh {
zusatz_attribute.push(serde_json::json!({
"_typ": "ZUSATZ_ATTRIBUT",
"name": "verbrauchBundesdurchschnitt",
"wert": avg.to_string()
}));
}
}
if let Some(run_id) = &self.billing_run_id {
zusatz_attribute.push(serde_json::json!({
"_typ": "ZUSATZ_ATTRIBUT",
"name": "billingRunId",
"wert": run_id
}));
}
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
};
serde_json::json!({
"_typ": "RECHNUNG",
"rechnungsnummer": ctx.rechnungsnummer,
"rechnungsart": ctx.invoice_type.rechnungsart(),
"rechnungsdatum": ctx.period_to.to_string(), "originalRechnungsId": ctx.invoice_type.original_invoice_id(),
"marktlokationsId": ctx.malo_id,
"zaehlerIdLieferstelle": ctx.zaehler_id,
"herausgeber": {
"_typ": "MARKTTEILNEHMER",
"marktpartnercode": ctx.lf_mp_id
},
"netzbetreiber": ctx.nb_mp_id.as_deref().map(|id| serde_json::json!({
"_typ": "MARKTTEILNEHMER",
"marktpartnercode": id
})),
"vertragsId": ctx.contract_id,
"rechnungsperiode": {
"_typ": "ZEITRAUM",
"startdatum": ctx.period_from.to_string(),
"enddatum": ctx.period_to.to_string()
},
"rechnungspositionen": pos_json,
"zusatzAttribute": if zusatz_attribute.is_empty() { serde_json::Value::Null } else { serde_json::json!(zusatz_attribute) },
"gesamtnetto": { "_typ": "BETRAG", "wert": self.netto_eur.to_string(), "waehrung": "EUR" },
"gesamtsteuer": { "_typ": "BETRAG", "wert": self.mwst_eur.to_string(), "waehrung": "EUR" },
"gesamtbrutto": { "_typ": "BETRAG", "wert": self.brutto_eur.to_string(), "waehrung": "EUR" },
"abschlagTotal": if self.abschlag_total_eur > Decimal::ZERO { serde_json::json!({ "_typ": "BETRAG", "wert": self.abschlag_total_eur.to_string(), "waehrung": "EUR" }) } else { serde_json::Value::Null },
"zahlbetrag": { "_typ": "BETRAG", "wert": self.zahlbetrag_eur.to_string(), "waehrung": "EUR" },
"kilowattstundenpreisGesamt": kilowattstundenpreis_ct.map(|ct| serde_json::json!({
"_typ": "PREIS",
"wert": ct.to_string(),
"einheit": "ct/kWh",
"bezugswert": "KWH",
"rechtlicheGrundlage": "§40a EnWG"
})),
"preisvergleichsdaten": {
"_typ": "PREISVERGLEICH",
"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!({ "_typ": "BETRAG", "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_dp(4))
.next()
.map(|ct| ct.to_string()),
"gesamtpreisCtProKwh": kilowattstundenpreis_ct.map(|ct| ct.to_string()),
"rechtlicheGrundlage": "§40b EnWG"
},
"rechnungsempfaenger": {
"_typ": "MARKTTEILNEHMER",
"externeKundenId": ctx.malo_id
},
"zahlungsziel": zahlungsziel.to_string()
})
}
#[cfg(feature = "bo4e")]
#[must_use]
pub fn to_bo4e_rechnung(&self) -> rubo4e::current::Rechnung {
use rubo4e::current::{
Betrag, Rechnung, Rechnungstyp, Waehrungscode, Zeitraum, ZusatzAttribut,
};
let ctx = &self.context;
let (rechnungstyp, ist_storno, original_rechnungsnummer) = match &ctx.invoice_type {
crate::context::InvoiceType::Initial => {
(Some(Rechnungstyp::Turnusrechnung), None, None)
}
crate::context::InvoiceType::AdvancePayment => {
(Some(Rechnungstyp::Abschlagsrechnung), None, None)
}
crate::context::InvoiceType::Final => {
(Some(Rechnungstyp::Abschlussrechnung), None, None)
}
crate::context::InvoiceType::PartialInvoice => {
(Some(Rechnungstyp::Zwischenrechnung), None, None)
}
crate::context::InvoiceType::CreditNote => {
(Some(Rechnungstyp::Turnusrechnung), Some(false), None)
}
crate::context::InvoiceType::Cancellation {
original_invoice_id,
} => (None, Some(true), Some(original_invoice_id.clone())),
crate::context::InvoiceType::Correction {
original_invoice_id,
..
} => (
Some(Rechnungstyp::Turnusrechnung),
None,
Some(original_invoice_id.clone()),
),
};
let eur = |wert: Decimal| Betrag {
id: None,
typ: None,
version: None,
waehrung: Some(Waehrungscode::Eur),
wert: Some(wert),
zusatz_attribute: None,
_additional: Default::default(),
};
let mut attrs: Vec<ZusatzAttribut> = vec![ZusatzAttribut {
name: Some("lf_mp_id".to_owned()),
wert: Some(serde_json::json!(ctx.lf_mp_id)),
_additional: Default::default(),
}];
if let Some(nb) = &ctx.nb_mp_id {
attrs.push(ZusatzAttribut {
name: Some("nb_mp_id".to_owned()),
wert: Some(serde_json::json!(nb)),
_additional: Default::default(),
});
}
if let Some(run_id) = &self.billing_run_id {
attrs.push(ZusatzAttribut {
name: Some("billingRunId".to_owned()),
wert: Some(serde_json::json!(run_id)),
_additional: Default::default(),
});
}
if let Some(malo) = Some(&ctx.malo_id).filter(|s| !s.is_empty()) {
attrs.push(ZusatzAttribut {
name: Some("malo_id".to_owned()),
wert: Some(serde_json::json!(malo)),
_additional: Default::default(),
});
}
Rechnung {
id: self.billing_run_id.clone(),
rechnungsnummer: Some(ctx.rechnungsnummer.clone()),
rechnungstyp,
ist_storno,
original_rechnungsnummer,
rechnungsperiode: Some(Zeitraum {
startdatum: Some(ctx.period_from),
enddatum: Some(ctx.period_to),
..Default::default()
}),
gesamtnetto: Some(eur(self.netto_eur)),
gesamtsteuer: Some(eur(self.mwst_eur)),
gesamtbrutto: Some(eur(self.brutto_eur)),
zu_zahlen: Some(eur(self.zahlbetrag_eur)),
faelligkeitsdatum: Some(ctx.period_to + time::Duration::days(14)),
zusatz_attribute: Some(attrs),
..Default::default()
}
}
#[must_use]
pub fn merge(self, other: Invoice) -> Invoice {
let mut ctx = self.context;
if other.context.period_to > ctx.period_to {
ctx.period_to = other.context.period_to;
}
let mut positions = self.positions;
positions.extend(other.positions);
Invoice::from_positions(ctx, positions)
}
pub fn allocate_proportionally(
self,
fractions: &[Decimal],
contexts: Vec<crate::context::BillingContext>,
) -> Result<Vec<Invoice>, billing::BillingError> {
if fractions.len() != contexts.len() || fractions.is_empty() {
return Err(billing::BillingError::InvalidInput {
reason: format!(
"fractions.len() ({}) must equal contexts.len() ({})",
fractions.len(),
contexts.len()
),
});
}
let n = fractions.len();
let mut recipient_positions: Vec<Vec<crate::position::BillingPosition>> =
(0..n).map(|_| Vec::new()).collect();
for pos in &self.positions {
let (abs_eur, sign) = if pos.net_eur < Decimal::ZERO {
(-pos.net_eur, -Decimal::ONE)
} else {
(pos.net_eur, Decimal::ONE)
};
let splits = billing::proportional_split(abs_eur, fractions, 5)?;
for (i, split_abs) in splits.iter().enumerate() {
let split_amount = sign * split_abs;
let split_qty = if pos.quantity.is_zero() || fractions.len() <= 1 {
pos.quantity
} else {
let total_frac: Decimal = fractions.iter().sum();
if total_frac.is_zero() {
pos.quantity
} else {
(pos.quantity * fractions[i] / total_frac).round_dp(4)
}
};
let mut split_pos = pos.clone();
split_pos.net_eur = split_amount;
split_pos.quantity = split_qty;
recipient_positions[i].push(split_pos);
}
}
Ok(recipient_positions
.into_iter()
.zip(contexts)
.map(|(positions, ctx)| Invoice::from_positions(ctx, positions))
.collect())
}
}
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),
);
obj.insert(
"rechnungsart".to_owned(),
serde_json::json!("KORREKTURRECHNUNG"),
);
negate_betrag_in_obj(obj, "gesamtbrutto");
negate_betrag_in_obj(obj, "gesamtnetto");
negate_betrag_in_obj(obj, "gesamtsteuer");
negate_betrag_in_obj(obj, "abschlagTotal");
negate_betrag_in_obj(obj, "zahlbetrag");
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 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_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()
.map(|d| serde_json::json!((-d).to_string())),
serde_json::Value::Number(n) => n.as_f64().map(|f| serde_json::json!(-f)),
_ => None,
};
if let Some(neg) = negated {
obj.insert("wert".to_owned(), neg);
}
}
}