use crate::EuroAmount;
use crate::rates::RoundMoney;
use rust_decimal::Decimal;
use crate::rates::RegulatoryRates;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Verbrauchshistorie {
#[serde(default)]
pub vorjahr_kwh: Option<Decimal>,
#[serde(default)]
pub bundesdurchschnitt_kwh: Option<Decimal>,
#[serde(default)]
pub kundengruppe: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct Vertragsinformationen {
#[serde(default)]
pub vertragsdauer: Option<String>,
#[serde(default)]
pub kuendigungsfrist: Option<String>,
#[serde(default)]
pub naechstmoeglicher_kuendigungstermin: Option<time::Date>,
#[serde(default)]
pub naechster_abrechnungstermin: Option<time::Date>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Verbraucherinformationen {
#[serde(default)]
pub lieferant_name: Option<String>,
#[serde(default)]
pub lieferant_anschrift: Option<String>,
#[serde(default)]
pub lieferant_kontakt: Option<String>,
pub schlichtungsstelle: String,
pub bnetza_verbraucherservice: String,
pub energieberatung: String,
pub wechselhinweis: String,
}
impl Default for Verbraucherinformationen {
fn default() -> Self {
Self {
lieferant_name: None,
lieferant_anschrift: None,
lieferant_kontakt: None,
schlichtungsstelle: "Bei Streitigkeiten können Sie die Schlichtungsstelle Energie e.V. \
anrufen (§111b EnWG): Friedrichstraße 133, 10117 Berlin, \
Tel. 030 2757240-0, info@schlichtungsstelle-energie.de, \
www.schlichtungsstelle-energie.de. Voraussetzung ist, dass der \
Lieferant Ihrer Beschwerde nicht binnen vier Wochen abgeholfen hat."
.to_owned(),
bnetza_verbraucherservice: "Verbraucherservice der Bundesnetzagentur für den Bereich Elektrizität \
und Gas: Postfach 8001, 53105 Bonn, Tel. 030 22480-500, \
verbraucherservice-energie@bnetza.de."
.to_owned(),
energieberatung: "Unabhängige Energieberatung erhalten Sie bei der \
Energieberatung der Verbraucherzentrale, www.verbraucherzentrale-energieberatung.de."
.to_owned(),
wechselhinweis: "Informationen zum Lieferantenwechsel und behördlich zugelassene \
Preisvergleichsinstrumente (§41c EnWG) finden Sie unter \
www.bundesnetzagentur.de."
.to_owned(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rechnungsempfaenger {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub line1: Option<String>,
#[serde(default)]
pub post_code: Option<String>,
#[serde(default)]
pub city: Option<String>,
#[serde(default)]
pub country: Option<String>,
#[serde(default)]
pub vat_id: Option<String>,
}
impl Rechnungsempfaenger {
#[must_use]
pub fn names_somebody(&self) -> bool {
self.name.as_deref().is_some_and(|n| !n.trim().is_empty())
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum InvoiceType {
Initial,
CreditNote,
Correction {
original_invoice_id: String,
reason: Option<String>,
},
Cancellation {
original_invoice_id: String,
},
Final,
AdvancePayment,
PartialInvoice,
}
impl InvoiceType {
#[must_use]
#[cfg(feature = "bo4e")]
pub fn rechnungstyp(&self) -> Option<rubo4e::current::Rechnungstyp> {
use rubo4e::current::Rechnungstyp as R;
match self {
Self::Initial => Some(R::Endkundenrechnung),
Self::AdvancePayment => Some(R::Abschlagsrechnung),
Self::Final => Some(R::Abschlussrechnung),
Self::PartialInvoice => Some(R::Zwischenrechnung),
Self::CreditNote | Self::Correction { .. } | Self::Cancellation { .. } => None,
}
}
#[must_use]
pub fn rechnungsart(&self) -> &'static str {
match self {
Self::Initial => "RECHNUNG",
Self::AdvancePayment => "ABSCHLAGSRECHNUNG",
Self::CreditNote => "GUTSCHRIFT",
Self::Correction { .. } => "KORREKTURRECHNUNG",
Self::Cancellation { .. } => "STORNORECHNUNG",
Self::Final => "SCHLUSSRECHNUNG",
Self::PartialInvoice => "TEILRECHNUNG",
}
}
#[must_use]
pub fn original_invoice_id(&self) -> Option<&str> {
match self {
Self::Correction {
original_invoice_id,
..
}
| Self::Cancellation {
original_invoice_id,
} => Some(original_invoice_id),
_ => None,
}
}
#[must_use]
pub fn is_reversal(&self) -> bool {
matches!(self, Self::Cancellation { .. })
}
#[must_use]
pub fn settles_advances(&self) -> bool {
!matches!(self, Self::AdvancePayment)
}
}
#[allow(clippy::derivable_impls)]
impl Default for InvoiceType {
fn default() -> Self {
Self::Initial
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CustomerKategorie {
#[default]
Haushalt,
Gewerbe,
Industrie,
Landwirtschaft,
OeffentlicheEinrichtung,
}
impl CustomerKategorie {
#[must_use]
pub fn is_slp_customer(self) -> bool {
matches!(self, Self::Haushalt | Self::Gewerbe)
}
#[must_use]
pub fn requires_verbrauchshistorie(self) -> bool {
matches!(self, Self::Haushalt)
}
#[must_use]
pub fn requires_kilowattstundenpreis(self) -> bool {
!matches!(self, Self::Industrie)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AbschlagDeduction {
pub datum: time::Date,
pub betrag_eur: Decimal,
pub ust_satz: Decimal,
#[serde(default)]
pub beschreibung: Option<String>,
}
impl AbschlagDeduction {
#[must_use]
pub fn netto_eur(&self) -> Decimal {
if self.ust_satz.is_zero() {
return self.betrag_eur;
}
(self.betrag_eur / (Decimal::ONE + self.ust_satz)).round_kfm(2)
}
#[must_use]
pub fn ust_eur(&self) -> Decimal {
self.betrag_eur - self.netto_eur()
}
pub fn to_advance_payment(&self) -> Result<billing::AdvancePayment, crate::EngineError> {
let category = if self.ust_satz.is_zero() {
billing::TaxCategory::ZeroRated
} else {
billing::TaxCategory::Standard
};
let entry = billing::TaxBreakdownEntry::new(
category,
self.ust_satz,
EuroAmount::checked_from_decimal(self.netto_eur())?,
EuroAmount::checked_from_decimal(self.ust_eur())?,
);
let advance =
billing::AdvancePayment::new(vec![entry])?.with_received_on(self.datum.to_string());
Ok(match &self.beschreibung {
Some(r) => advance.with_reference(r.clone()),
None => advance,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SettlementForm {
#[default]
Endrechnung,
Restrechnung,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "PeriodEndpoints", into = "PeriodEndpoints")]
pub struct BillingPeriod {
from: time::Date,
to: time::Date,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PeriodEndpoints {
from: time::Date,
to: time::Date,
}
impl TryFrom<PeriodEndpoints> for BillingPeriod {
type Error = crate::EngineError;
fn try_from(p: PeriodEndpoints) -> Result<Self, Self::Error> {
Self::new(p.from, p.to)
}
}
impl From<BillingPeriod> for PeriodEndpoints {
fn from(p: BillingPeriod) -> Self {
Self {
from: p.from,
to: p.to,
}
}
}
impl BillingPeriod {
pub fn new(from: time::Date, to: time::Date) -> Result<Self, crate::EngineError> {
if from > to {
return Err(crate::EngineError::InvalidPeriod { from, to });
}
Ok(Self { from, to })
}
#[must_use]
pub const fn from(self) -> time::Date {
self.from
}
#[must_use]
pub const fn to(self) -> time::Date {
self.to
}
#[must_use]
pub fn days(self) -> i64 {
(self.to - self.from).whole_days() + 1
}
#[must_use]
pub fn contains(self, date: time::Date) -> bool {
self.from <= date && date <= self.to
}
}
impl Default for BillingPeriod {
fn default() -> Self {
Self {
from: time::Date::MIN,
to: time::Date::MIN,
}
}
}
impl std::fmt::Display for BillingPeriod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}..{}", self.from, self.to)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Vertragsart {
#[default]
Sondervertrag,
Grundversorgung,
Ersatzversorgung,
}
impl Vertragsart {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Sondervertrag => "SONDERVERTRAG",
Self::Grundversorgung => "GRUNDVERSORGUNG",
Self::Ersatzversorgung => "ERSATZVERSORGUNG",
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BillingContext {
pub malo_id: String,
pub lf_mp_id: String,
pub rechnungsnummer: String,
pub period: BillingPeriod,
pub invoice_type: InvoiceType,
#[serde(default)]
pub vertragsart: Vertragsart,
#[serde(default)]
pub settlement_form: SettlementForm,
#[serde(default)]
pub minimum_invoice_mwst_rate: Option<Decimal>,
pub regulatory_rates: RegulatoryRates,
#[serde(default)]
pub issue_date: Option<time::Date>,
#[serde(default)]
pub rechnungsempfaenger: Option<Rechnungsempfaenger>,
#[serde(default)]
pub contract_id: Option<String>,
#[serde(default)]
pub vertragsbeginn: Option<time::Date>,
#[serde(default)]
pub vertragsende: Option<time::Date>,
#[serde(default)]
pub zaehler_id: Option<String>,
#[serde(default)]
pub abschlage: Vec<AbschlagDeduction>,
#[serde(default)]
pub verbrauchshistorie: Option<Verbrauchshistorie>,
#[serde(default)]
pub vertragsinformationen: Option<Vertragsinformationen>,
pub verbraucherinformationen: Option<Verbraucherinformationen>,
#[serde(default)]
pub energiequellen: Option<crate::tariff::EnergieQuellen>,
#[serde(default)]
pub minimum_invoice_eur_brutto: Option<Decimal>,
#[serde(default)]
pub nb_mp_id: Option<String>,
#[serde(default)]
pub billing_run_id: Option<String>,
#[serde(default)]
pub kundenkategorie: CustomerKategorie,
#[serde(default)]
pub reverse_charge: bool,
}
impl BillingContext {
#[must_use]
pub const fn period_from(&self) -> time::Date {
self.period.from()
}
#[must_use]
pub const fn period_to(&self) -> time::Date {
self.period.to()
}
#[must_use]
pub fn ausstellungsdatum(&self) -> time::Date {
match self.issue_date {
Some(d) => d,
None => self.period.to(),
}
}
#[must_use]
pub fn faelligkeitsdatum(&self) -> time::Date {
self.ausstellungsdatum()
.saturating_add(time::Duration::days(14))
}
#[must_use]
pub fn days(&self) -> i64 {
self.period.days()
}
#[must_use]
pub fn billing_days_fraction(&self) -> Option<Decimal> {
let period_days = self.days();
if period_days <= 0 {
return None;
}
let effective_from = match self.vertragsbeginn {
Some(vb) if vb > self.period_from() => vb,
_ => self.period_from(),
};
let effective_to = match self.vertragsende {
Some(ve) if ve < self.period_to() => ve,
_ => self.period_to(),
};
let billable = (effective_to - effective_from).whole_days() + 1;
if billable <= 0 {
return None;
}
if billable >= period_days {
return None; }
let frac = Decimal::from(billable) / Decimal::from(period_days);
Some(frac.round_kfm(6))
}
#[must_use]
pub fn total_abschlage_eur(&self) -> Decimal {
self.abschlage.iter().map(|a| a.betrag_eur).sum()
}
#[must_use]
pub fn prorate_days(&self) -> (u32, u32) {
let total = self.days().max(0) as u32;
if total == 0 {
return (0, 1);
}
let effective_from = self
.vertragsbeginn
.filter(|&vb| vb > self.period_from())
.unwrap_or(self.period_from());
let effective_to = self
.vertragsende
.filter(|&ve| ve < self.period_to())
.unwrap_or(self.period_to());
let active = ((effective_to - effective_from).whole_days() + 1).max(0) as u32;
(active.min(total), total)
}
#[must_use]
pub fn active_window(&self) -> Option<(time::Date, time::Date)> {
let from = self
.vertragsbeginn
.filter(|&vb| vb > self.period_from())
.unwrap_or(self.period_from());
let to = self
.vertragsende
.filter(|&ve| ve < self.period_to())
.unwrap_or(self.period_to());
(from <= to).then_some((from, to))
}
#[must_use]
pub fn billed_months(&self) -> rust_decimal::Decimal {
use rust_decimal::Decimal;
let Some((from, to)) = self.active_window() else {
return Decimal::ZERO;
};
let mut months = Decimal::ZERO;
let mut cursor = from;
while cursor <= to {
let len = time::util::days_in_month(cursor.month(), cursor.year());
let month_end = time::Date::from_calendar_date(cursor.year(), cursor.month(), len)
.expect("last day of the month is a valid date");
let slice_end = month_end.min(to);
let days = (slice_end - cursor).whole_days() + 1;
months += Decimal::from(days) / Decimal::from(len);
let Some(next) = month_end.next_day() else {
break;
};
cursor = next;
}
months
}
#[must_use]
pub fn billed_years(&self) -> rust_decimal::Decimal {
use rust_decimal::Decimal;
let Some((from, to)) = self.active_window() else {
return Decimal::ZERO;
};
let mut years = Decimal::ZERO;
let mut cursor = from;
while cursor <= to {
let len = time::util::days_in_year(cursor.year());
let year_end = time::Date::from_calendar_date(cursor.year(), time::Month::December, 31)
.expect("31 December is a valid date");
let slice_end = year_end.min(to);
let days = (slice_end - cursor).whole_days() + 1;
years += Decimal::from(days) / Decimal::from(len);
let Some(next) = year_end.next_day() else {
break;
};
cursor = next;
}
years
}
}
#[cfg(test)]
mod period_fraction_tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
fn ctx(from: time::Date, to: time::Date) -> BillingContext {
BillingContext {
period: BillingPeriod::new(from, to).expect("period"),
..Default::default()
}
}
#[test]
fn calendar_aligned_periods_are_exact() {
assert_eq!(
ctx(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).billed_months(),
dec!(1)
);
assert_eq!(
ctx(date!(2026 - 02 - 01), date!(2026 - 02 - 28)).billed_months(),
dec!(1)
);
assert_eq!(
ctx(date!(2026 - 01 - 01), date!(2026 - 12 - 31)).billed_months(),
dec!(12)
);
assert_eq!(
ctx(date!(2024 - 01 - 01), date!(2024 - 12 - 31)).billed_months(),
dec!(12)
);
assert_eq!(
ctx(date!(2024 - 01 - 01), date!(2024 - 12 - 31)).billed_years(),
dec!(1)
);
}
#[test]
fn a_partial_month_is_that_months_own_fraction() {
let c = BillingContext {
period: BillingPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap(),
vertragsbeginn: Some(date!(2026 - 01 - 16)),
..Default::default()
};
assert_eq!(c.billed_months(), dec!(16) / dec!(31));
let c = ctx(date!(2026 - 02 - 16), date!(2026 - 02 - 28));
assert_eq!(c.billed_months(), dec!(13) / dec!(28));
}
#[test]
fn a_closed_contract_bills_no_months() {
let c = BillingContext {
period: BillingPeriod::new(date!(2026 - 03 - 01), date!(2026 - 03 - 31)).unwrap(),
vertragsende: Some(date!(2026 - 02 - 10)),
..Default::default()
};
assert_eq!(c.active_window(), None);
assert_eq!(c.billed_months(), rust_decimal::Decimal::ZERO);
assert_eq!(c.billed_years(), rust_decimal::Decimal::ZERO);
}
}
#[cfg(test)]
mod faelligkeit_tests {
use super::*;
use time::macros::date;
fn ctx(period_to: time::Date, issue: Option<time::Date>) -> BillingContext {
BillingContext {
period: BillingPeriod::new(date!(2026 - 01 - 01), period_to).expect("period"),
issue_date: issue,
..Default::default()
}
}
#[test]
fn without_a_clock_the_period_end_stands_in_for_the_issue_date() {
let c = ctx(date!(2026 - 01 - 31), None);
assert_eq!(c.ausstellungsdatum(), date!(2026 - 01 - 31));
assert_eq!(c.faelligkeitsdatum(), date!(2026 - 02 - 14));
}
#[test]
fn the_due_date_runs_from_the_issue_date_not_the_period_end() {
let c = ctx(date!(2026 - 01 - 31), Some(date!(2026 - 06 - 10)));
assert_eq!(c.ausstellungsdatum(), date!(2026 - 06 - 10));
assert_eq!(c.faelligkeitsdatum(), date!(2026 - 06 - 24));
assert!(
c.faelligkeitsdatum() > c.ausstellungsdatum(),
"an invoice is never due before it is issued"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
fn base_ctx() -> BillingContext {
BillingContext {
period: BillingPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap(),
..Default::default()
}
}
#[test]
fn days_full_january() {
assert_eq!(base_ctx().days(), 31);
}
#[test]
fn billing_days_fraction_no_pro_rata_returns_none() {
assert!(base_ctx().billing_days_fraction().is_none());
}
#[test]
fn billing_days_fraction_mid_month_start() {
let ctx = BillingContext {
vertragsbeginn: Some(date!(2026 - 01 - 16)),
..base_ctx()
};
let frac = ctx.billing_days_fraction().unwrap();
let expected = Decimal::from(16) / Decimal::from(31);
assert_eq!(frac, expected.round_kfm(6));
}
#[test]
fn billing_days_fraction_mid_month_end() {
let ctx = BillingContext {
vertragsende: Some(date!(2026 - 01 - 15)),
..base_ctx()
};
let frac = ctx.billing_days_fraction().unwrap();
let expected = Decimal::from(15) / Decimal::from(31);
assert_eq!(frac, expected.round_kfm(6));
}
#[test]
fn total_abschlage_sums_correctly() {
let ctx = BillingContext {
abschlage: vec![
AbschlagDeduction {
datum: date!(2026 - 01 - 15),
betrag_eur: dec!(100.00),
ust_satz: dec!(0.19),
beschreibung: None,
},
AbschlagDeduction {
datum: date!(2026 - 02 - 15),
betrag_eur: dec!(120.00),
ust_satz: dec!(0.19),
beschreibung: None,
},
],
..base_ctx()
};
assert_eq!(ctx.total_abschlage_eur(), dec!(220.00));
}
}