use crate::rates::RoundMoney;
use rust_decimal::Decimal;
use std::collections::HashMap;
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MeteringMode {
#[default]
Slp,
Rlm,
Imsys,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Ablesungsart {
#[default]
Unbekannt,
Fernauslesung,
Abgelesen,
Kundenselbstablesung,
Rechnerisch,
}
impl Ablesungsart {
#[must_use]
pub const fn label(self) -> Option<&'static str> {
match self {
Self::Unbekannt => None,
Self::Fernauslesung => Some("ferngelesen"),
Self::Abgelesen => Some("abgelesen durch den Messstellenbetreiber"),
Self::Kundenselbstablesung => Some("Selbstablesung durch den Kunden"),
Self::Rechnerisch => Some("rechnerisch ermittelt (Schätzung)"),
}
}
#[must_use]
pub const fn is_estimate(self) -> bool {
matches!(self, Self::Rechnerisch)
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MeterInput {
#[serde(default)]
pub arbeitsmenge_kwh: Decimal,
#[serde(default)]
pub arbeitsmenge_ht_kwh: Option<Decimal>,
#[serde(default)]
pub arbeitsmenge_nt_kwh: Option<Decimal>,
#[serde(default)]
pub spitzenleistung_kw: Option<Decimal>,
#[serde(default)]
pub steuerung_stunden: Option<Decimal>,
#[serde(default)]
pub zaehlernummer: Option<String>,
#[serde(default)]
pub zaehlerstand_von: Option<Decimal>,
#[serde(default)]
pub zaehlerstand_bis: Option<Decimal>,
#[serde(default)]
pub metering_mode: MeteringMode,
#[serde(default)]
pub ablesungsart: Ablesungsart,
#[serde(default)]
pub is_estimated: bool,
#[serde(default)]
pub zaehler_replaced: bool,
#[serde(default)]
pub coverage_pct: Option<Decimal>,
}
impl MeterInput {
#[must_use]
pub fn billable_kwh(&self) -> Decimal {
if self.arbeitsmenge_kwh > Decimal::ZERO {
return self.arbeitsmenge_kwh;
}
self.arbeitsmenge_ht_kwh.unwrap_or(Decimal::ZERO)
+ self.arbeitsmenge_nt_kwh.unwrap_or(Decimal::ZERO)
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct GasMeterInput {
#[serde(default)]
pub coverage_pct: Option<Decimal>,
pub messung_qm3: Decimal,
#[serde(default)]
pub brennwert_kwh_per_qm3: Option<Decimal>,
#[serde(default)]
pub zustandszahl: Option<Decimal>,
#[serde(default)]
pub kwh_hs: Option<Decimal>,
#[serde(default)]
pub gasqualitaet: Option<String>,
#[serde(default)]
pub spitzenleistung_kw: Option<Decimal>,
#[serde(default)]
pub zaehlernummer: Option<String>,
#[serde(default)]
pub zaehlerstand_von: Option<Decimal>,
#[serde(default)]
pub zaehlerstand_bis: Option<Decimal>,
#[serde(default)]
pub ablesungsart: Ablesungsart,
#[serde(default)]
pub is_estimated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AbsetzungsGrund {
Gartenwasser,
Schleppwasser,
Verdunstung,
Produktionswasser,
Sonstige,
}
impl AbsetzungsGrund {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Gartenwasser => "Gartenwasser",
Self::Schleppwasser => "Schleppwasser",
Self::Verdunstung => "Verdunstung",
Self::Produktionswasser => "Produktionswasser",
Self::Sonstige => "sonstige Absetzung",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Absetzung {
pub m3: Decimal,
pub grund: AbsetzungsGrund,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct WasserMeterInput {
#[serde(default)]
pub frischwasser_m3: Decimal,
#[serde(default)]
pub absetzungen: Vec<Absetzung>,
#[serde(default)]
pub versiegelte_flaeche_m2: Option<Decimal>,
#[serde(default)]
pub months: Option<Decimal>,
}
impl WasserMeterInput {
#[must_use]
pub fn absetzung_total_m3(&self) -> Decimal {
self.absetzungen.iter().map(|a| a.m3).sum()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct WaermeMeterInput {
#[serde(default)]
pub kwh_waerme: Decimal,
#[serde(default)]
pub spitzenleistung_kw: Option<Decimal>,
#[serde(default)]
pub months: Option<Decimal>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SolarMeterInput {
pub eigenverbrauch_kwh: Decimal,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GgvNutzungsplanEntry {
pub malo_id: String,
pub fraction: Decimal,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GgvNutzungsplan(pub Vec<GgvNutzungsplanEntry>);
impl GgvNutzungsplan {
pub fn validate(&self) -> Result<(), String> {
use rust_decimal::dec;
if self.0.is_empty() {
return Err("GGV Nutzungsplan must have at least one entry".to_owned());
}
for e in &self.0 {
if e.fraction <= Decimal::ZERO {
return Err(format!(
"GGV Nutzungsplan: fraction for {} must be > 0, got {}",
e.malo_id, e.fraction
));
}
}
let total: Decimal = self.0.iter().map(|e| e.fraction).sum();
let diff = (total - Decimal::ONE).abs();
if diff > dec!(0.001) {
return Err(format!(
"GGV Nutzungsplan: fractions sum to {total}, must be 1.0 (±0.001)"
));
}
Ok(())
}
pub fn validate_covers<'a>(
&self,
tenants: impl IntoIterator<Item = &'a str>,
) -> Result<(), String> {
use std::collections::BTreeSet;
let mut planned: BTreeSet<&str> = BTreeSet::new();
for e in &self.0 {
if !planned.insert(e.malo_id.as_str()) {
return Err(format!(
"GGV Nutzungsplan: MaLo {} appears more than once",
e.malo_id
));
}
}
let tenants: BTreeSet<&str> = tenants.into_iter().collect();
let missing: Vec<&str> = tenants.difference(&planned).copied().collect();
if !missing.is_empty() {
return Err(format!(
"GGV Nutzungsplan: no entry for {} — every tenant must be allocated \
(§42b Abs. 1 EEG 2023)",
missing.join(", ")
));
}
let extra: Vec<&str> = planned.difference(&tenants).copied().collect();
if !extra.is_empty() {
return Err(format!(
"GGV Nutzungsplan: {} is allocated PV but is not a tenant of this run",
extra.join(", ")
));
}
Ok(())
}
pub fn allocate(
&self,
total_kwh: Decimal,
) -> Result<Vec<(String, Decimal)>, crate::EngineError> {
if self.0.is_empty() || total_kwh <= Decimal::ZERO {
return Ok(vec![]);
}
let fractions: Vec<Decimal> = self.0.iter().map(|e| e.fraction).collect();
let parts = billing::proportional_split(total_kwh, &fractions, 3).map_err(|_| {
crate::EngineError::NutzungsplanSharesInvalid {
sum: fractions.iter().copied().sum(),
}
})?;
Ok(self
.0
.iter()
.zip(parts)
.map(|(e, kwh)| (e.malo_id.clone(), kwh))
.collect())
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct EegMeterInput {
pub einspeisung_kwh: Decimal,
#[serde(default)]
pub kwh_during_negative_epex: Option<Decimal>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct HemsMeterInput {
#[serde(default)]
pub months: Option<Decimal>,
#[serde(default)]
pub optimization_events: Option<u32>,
#[serde(default)]
pub readout_events: Option<u32>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct EmobilityMeterInput {
#[serde(default)]
pub months: Option<Decimal>,
#[serde(default)]
pub kwh_charged: Option<Decimal>,
#[serde(default)]
pub sessions: Option<u32>,
#[serde(default)]
pub roaming_sessions: Option<u32>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ServiceMeterInput {
#[serde(default)]
pub months: Option<Decimal>,
#[serde(default)]
pub event_count: Option<u32>,
#[serde(default)]
pub event_price_eur: Option<Decimal>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DynamicInterval {
#[serde(with = "time::serde::rfc3339")]
pub timestamp_utc: OffsetDateTime,
pub kwh: Decimal,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Sect41aAnnualComparison {
pub actual_kwh: Decimal,
pub actual_eur_brutto: Decimal,
pub reference_price_ct_per_kwh: Decimal,
pub reference_eur_brutto: Decimal,
pub savings_eur: Decimal,
}
impl Sect41aAnnualComparison {
#[must_use]
pub fn compute(
actual_kwh: Decimal,
actual_eur_brutto: Decimal,
reference_price_ct_per_kwh: Decimal,
) -> Self {
use rust_decimal::dec;
let reference_eur_brutto =
(actual_kwh * reference_price_ct_per_kwh / dec!(100)).round_kfm(2);
let savings_eur = (reference_eur_brutto - actual_eur_brutto).round_kfm(2);
Self {
actual_kwh,
actual_eur_brutto,
reference_price_ct_per_kwh,
reference_eur_brutto,
savings_eur,
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct GridInput {
#[serde(default)]
pub nne_grundpreis_eur_per_year: Option<Decimal>,
#[serde(default)]
pub nne_arbeitspreis_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub nne_leistungspreis_eur_per_kw_year: Option<Decimal>,
#[serde(default)]
pub ka_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub gas_nne_grundpreis_eur_per_year: Option<Decimal>,
#[serde(default)]
pub gas_nne_arbeitspreis_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub gas_ka_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub gas_bilanzierungsumlage_ct_per_kwh: Option<Decimal>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct EnergyShareMeterInput {
pub allocated_kwh: Decimal,
#[serde(default)]
pub total_plant_generation_kwh: Option<Decimal>,
#[serde(default)]
pub allocation_fraction: Option<Decimal>,
#[serde(default)]
pub gemeinschaft_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DayApportionment {
days: Vec<Decimal>,
index: usize,
}
const QUANTITY_SCALE: u32 = 3;
impl DayApportionment {
#[must_use]
pub fn new(days: &[u32], index: usize) -> Self {
let total: u64 = days.iter().map(|d| u64::from(*d)).sum();
if index >= days.len() || total == 0 {
return Self::whole();
}
Self {
days: days.iter().map(|d| Decimal::from(*d)).collect(),
index,
}
}
#[must_use]
pub fn whole() -> Self {
Self {
days: vec![Decimal::ONE],
index: 0,
}
}
#[must_use]
pub fn is_whole(&self) -> bool {
self.days.len() <= 1
}
#[must_use]
pub fn share(&self, total: Decimal, scale: u32) -> Decimal {
if self.is_whole() || total.is_zero() {
return total;
}
let sum: Decimal = self.days.iter().copied().sum();
let last = self.days.len() - 1;
let mut fractions: Vec<Decimal> = self.days.iter().map(|d| *d / sum).collect();
let head: Decimal = fractions[..last].iter().copied().sum();
fractions[last] = Decimal::ONE - head;
let negative = total < Decimal::ZERO;
let magnitude = if negative { -total } else { total };
let part = billing::proportional_split(magnitude, &fractions, scale)
.ok()
.and_then(|parts| parts.get(self.index).copied())
.unwrap_or_else(|| crate::rates::round_money(magnitude * fractions[self.index], scale));
if negative { -part } else { part }
}
#[must_use]
pub fn quantity(&self, total: Decimal) -> Decimal {
self.share(total, QUANTITY_SCALE)
}
#[must_use]
pub fn opt_quantity(&self, total: Option<Decimal>) -> Option<Decimal> {
total.map(|t| self.quantity(t))
}
#[must_use]
pub fn count(&self, total: u32) -> u32 {
use rust_decimal::prelude::ToPrimitive as _;
self.share(Decimal::from(total), 0)
.to_u32()
.unwrap_or(total)
}
#[must_use]
pub fn opt_count(&self, total: Option<u32>) -> Option<u32> {
total.map(|t| self.count(t))
}
}
impl WaermeMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
kwh_waerme: a.quantity(self.kwh_waerme),
spitzenleistung_kw: self.spitzenleistung_kw,
months: a.opt_quantity(self.months),
}
}
}
impl WasserMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
frischwasser_m3: a.quantity(self.frischwasser_m3),
absetzungen: self
.absetzungen
.iter()
.map(|x| Absetzung {
m3: a.quantity(x.m3),
grund: x.grund,
})
.collect(),
versiegelte_flaeche_m2: self.versiegelte_flaeche_m2,
months: a.opt_quantity(self.months),
}
}
}
impl SolarMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
eigenverbrauch_kwh: a.quantity(self.eigenverbrauch_kwh),
}
}
}
impl EegMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
einspeisung_kwh: a.quantity(self.einspeisung_kwh),
kwh_during_negative_epex: a.opt_quantity(self.kwh_during_negative_epex),
}
}
}
impl HemsMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
months: a.opt_quantity(self.months),
optimization_events: a.opt_count(self.optimization_events),
readout_events: a.opt_count(self.readout_events),
}
}
}
impl EmobilityMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
months: a.opt_quantity(self.months),
kwh_charged: a.opt_quantity(self.kwh_charged),
sessions: a.opt_count(self.sessions),
roaming_sessions: a.opt_count(self.roaming_sessions),
}
}
}
impl ServiceMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
months: a.opt_quantity(self.months),
event_count: a.opt_count(self.event_count),
event_price_eur: self.event_price_eur,
}
}
}
impl EnergyShareMeterInput {
#[must_use]
pub fn apportioned(&self, a: &DayApportionment) -> Self {
Self {
allocated_kwh: a.quantity(self.allocated_kwh),
total_plant_generation_kwh: a.opt_quantity(self.total_plant_generation_kwh),
allocation_fraction: self.allocation_fraction,
gemeinschaft_id: self.gemeinschaft_id.clone(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Quantities {
pub electricity: Option<MeterInput>,
pub sect14a_modul3: Option<Sect14aModul3Verbrauch>,
pub gas: Option<GasMeterInput>,
pub heat: Option<WaermeMeterInput>,
pub wasser: Option<WasserMeterInput>,
pub solar: Option<SolarMeterInput>,
pub ggv_solar: Option<GgvSolarInput>,
pub eeg: Option<EegMeterInput>,
#[cfg(feature = "eeg")]
pub eeg_full: Option<eeg_billing::SettleInput>,
pub einspeisung: Option<EegMeterInput>,
pub hems: Option<HemsMeterInput>,
pub emobility: Option<EmobilityMeterInput>,
pub service: Option<ServiceMeterInput>,
pub dynamic_intervals: Vec<DynamicInterval>,
pub dynamic_epex_prices: HashMap<OffsetDateTime, Decimal>,
pub eeg_gutschrift_eur: Option<Decimal>,
pub prosumer: Option<ProsumerMeterInput>,
pub sect41a_annual_comparison: Option<Sect41aAnnualComparison>,
pub energy_share: Option<EnergyShareMeterInput>,
}
impl Quantities {
#[must_use]
pub fn empty_energy_sources(&self) -> Vec<&'static str> {
let mut supplied: Vec<(&'static str, bool)> = Vec::new();
if let Some(m) = &self.electricity {
supplied.push(("electricity", m.billable_kwh() > Decimal::ZERO));
}
if let Some(m) = &self.gas {
supplied.push((
"gas",
m.messung_qm3 > Decimal::ZERO || m.kwh_hs.unwrap_or_default() > Decimal::ZERO,
));
}
if let Some(m) = &self.heat {
supplied.push(("heat", m.kwh_waerme > Decimal::ZERO));
}
if let Some(m) = &self.wasser {
supplied.push(("wasser", m.frischwasser_m3 > Decimal::ZERO));
}
if let Some(m) = &self.solar {
supplied.push(("solar", m.eigenverbrauch_kwh > Decimal::ZERO));
}
if let Some(m) = &self.eeg {
supplied.push(("eeg", m.einspeisung_kwh > Decimal::ZERO));
}
if let Some(m) = &self.einspeisung {
supplied.push(("einspeisung", m.einspeisung_kwh > Decimal::ZERO));
}
if let Some(m) = &self.emobility {
supplied.push((
"emobility",
m.kwh_charged.unwrap_or_default() > Decimal::ZERO
|| m.sessions.unwrap_or_default() > 0,
));
}
if let Some(g) = &self.ggv_solar {
supplied.push((
"ggv_solar",
g.pv_allocated_kwh > Decimal::ZERO || g.actual_consumption_kwh > Decimal::ZERO,
));
}
if supplied.is_empty() || supplied.iter().any(|(_, has)| *has) {
return Vec::new();
}
supplied.into_iter().map(|(name, _)| name).collect()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ProsumerMeterInput {
pub grid_consumption_kwh: Decimal,
pub self_consumption_kwh: Decimal,
#[serde(default)]
pub export_kwh: Option<Decimal>,
}
impl ProsumerMeterInput {
#[must_use]
pub fn total_consumption_kwh(&self) -> Decimal {
self.grid_consumption_kwh + self.self_consumption_kwh
}
#[must_use]
pub fn self_supply_ratio(&self) -> Decimal {
let total = self.total_consumption_kwh();
if total.is_zero() {
Decimal::ZERO
} else {
(self.self_consumption_kwh / total).min(Decimal::ONE)
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GgvSolarInput {
pub pv_allocated_kwh: Decimal,
pub actual_consumption_kwh: Decimal,
}
impl GgvSolarInput {
#[must_use]
pub fn pv_delivered_kwh(&self) -> Decimal {
self.actual_consumption_kwh.min(self.pv_allocated_kwh)
}
#[must_use]
pub fn grid_kwh(&self) -> Decimal {
(self.actual_consumption_kwh - self.pv_allocated_kwh).max(Decimal::ZERO)
}
#[must_use]
pub fn pv_coverage_ratio(&self) -> Decimal {
if self.actual_consumption_kwh <= Decimal::ZERO {
return Decimal::ZERO;
}
(self.pv_delivered_kwh() / self.actual_consumption_kwh)
.min(Decimal::ONE)
.round_kfm(4)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
fn plan(fractions: &[(&str, &str)]) -> GgvNutzungsplan {
GgvNutzungsplan(
fractions
.iter()
.map(|(id, f)| GgvNutzungsplanEntry {
malo_id: (*id).to_owned(),
fraction: f.parse().unwrap(),
})
.collect(),
)
}
#[test]
fn validate_covers_rejects_a_tenant_missing_from_the_plan() {
let p = plan(&[("A", "0.5"), ("B", "0.3"), ("C", "0.2")]);
p.validate().expect("fractions sum to 1.0");
p.validate_covers(["A", "B", "C"]).expect("exact coverage");
let err = p
.validate_covers(["A", "B", "C", "D"])
.expect_err("D is not allocated");
assert!(err.contains('D'), "{err}");
let err = p
.validate_covers(["A", "B"])
.expect_err("C is not a tenant of this run");
assert!(err.contains('C'), "{err}");
}
#[test]
fn validate_covers_rejects_a_duplicated_malo() {
let p = plan(&[("A", "0.5"), ("A", "0.3"), ("B", "0.2")]);
let err = p.validate_covers(["A", "B"]).expect_err("A appears twice");
assert!(err.contains("more than once"), "{err}");
}
#[test]
fn allocate_sum_equals_total() {
let p = plan(&[("A", "0.333"), ("B", "0.333"), ("C", "0.334")]);
let total = dec!(100.000);
let allocs = p
.allocate(total)
.expect("the shares partition the generation");
let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
assert_eq!(sum, total, "sum must equal total exactly");
}
#[test]
fn allocate_lrm_distributes_evenly_not_just_last_entry() {
let p = plan(&[("A", "0.3333"), ("B", "0.3333"), ("C", "0.3334")]);
let total = dec!(100.000);
let allocs = p
.allocate(total)
.expect("the shares partition the generation");
for (id, kwh) in &allocs {
let fraction: Decimal = p.0.iter().find(|e| &e.malo_id == id).unwrap().fraction;
let exact = total * fraction;
let diff = (kwh - exact).abs();
assert!(
diff <= dec!(0.001),
"{id}: allocated {kwh}, exact {exact}, diff {diff} > 0.001"
);
}
let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
assert_eq!(sum, total);
}
#[test]
fn allocate_lrm_no_disproportionate_last_entry() {
let tenants: Vec<(String, String)> = (0..10)
.map(|i| (format!("T{i}"), "0.1".to_owned()))
.collect();
let p = GgvNutzungsplan(
tenants
.iter()
.map(|(id, f)| GgvNutzungsplanEntry {
malo_id: id.clone(),
fraction: f.parse().unwrap(),
})
.collect(),
);
let total = dec!(1000.001);
let allocs = p
.allocate(total)
.expect("the shares partition the generation");
let over_base: Vec<_> = allocs.iter().filter(|(_, k)| *k > dec!(100.000)).collect();
assert_eq!(
over_base.len(),
1,
"exactly 1 tenant should get the extra 0.001"
);
let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
assert_eq!(sum, total);
}
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
pub struct Sect14aModul3Verbrauch {
pub ht_kwh: Decimal,
pub st_kwh: Decimal,
pub nt_kwh: Decimal,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AbschlagsplanEntry {
pub faellig_am: time::Date,
pub betrag_eur: Decimal,
#[serde(default)]
pub beschreibung: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Abschlagsplan {
pub malo_id: String,
#[serde(default)]
pub contract_id: Option<String>,
pub entries: Vec<AbschlagsplanEntry>,
pub jahresverbrauch_schaetzung_kwh: Decimal,
pub jahreskosten_schaetzung_eur: Decimal,
}
impl Abschlagsplan {
#[must_use]
pub fn monthly_uniform(
malo_id: impl Into<String>,
start_date: time::Date,
months: u32,
annual_brutto_eur: Decimal,
jahresverbrauch_kwh: Decimal,
) -> Self {
use rust_decimal::dec;
let cycle: Vec<Decimal> = billing::Amount::<2>::checked_from_decimal(annual_brutto_eur)
.and_then(|a| a.distribute(12))
.map(|parts| {
parts
.into_iter()
.map(billing::Amount::into_decimal)
.collect()
})
.unwrap_or_else(|_| vec![(annual_brutto_eur / dec!(12)).round_kfm(2); 12]);
let entries = (0..months)
.filter_map(|i| {
let total_months = start_date.month() as u32 - 1 + i;
let year = start_date.year() + (total_months / 12) as i32;
let month_idx = (total_months % 12 + 1) as u8;
let month = time::Month::try_from(month_idx).ok()?;
let max_day = month.length(time::util::is_leap_year(year) as i32) as u8;
let day = start_date.day().min(max_day);
let date = time::Date::from_calendar_date(year, month, day).ok()?;
Some(AbschlagsplanEntry {
faellig_am: date,
betrag_eur: cycle[(i % 12) as usize],
beschreibung: Some(format!("Abschlag {:02}/{}", month as u8, year)),
})
})
.collect();
Self {
malo_id: malo_id.into(),
contract_id: None,
entries,
jahresverbrauch_schaetzung_kwh: jahresverbrauch_kwh,
jahreskosten_schaetzung_eur: annual_brutto_eur,
}
}
#[must_use]
pub fn total_eur(&self) -> Decimal {
self.entries.iter().map(|e| e.betrag_eur).sum()
}
}
#[cfg(test)]
mod abschlagsplan_tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
#[test]
fn monthly_uniform_12_months() {
let plan = Abschlagsplan::monthly_uniform(
"51238696781",
date!(2026 - 01 - 01),
12,
dec!(1440.00),
dec!(3600),
);
assert_eq!(plan.entries.len(), 12);
assert_eq!(plan.entries[0].betrag_eur, dec!(120.00));
assert_eq!(plan.entries[11].faellig_am.year(), 2026);
assert_eq!(plan.total_eur(), dec!(1440.00));
}
#[test]
fn monthly_uniform_distributes_indivisible_annual_exactly() {
let plan = Abschlagsplan::monthly_uniform(
"51238696781",
date!(2026 - 01 - 01),
12,
dec!(1000.00),
dec!(2500),
);
assert_eq!(plan.total_eur(), dec!(1000.00), "instalments sum exactly");
for e in &plan.entries {
assert!(
e.betrag_eur == dec!(83.33) || e.betrag_eur == dec!(83.34),
"uniform ± 1 ct, got {}",
e.betrag_eur
);
}
let two_years = Abschlagsplan::monthly_uniform(
"51238696781",
date!(2026 - 01 - 01),
24,
dec!(1000.00),
dec!(2500),
);
assert_eq!(two_years.total_eur(), dec!(2000.00));
}
#[test]
fn monthly_uniform_crosses_year_boundary() {
let plan = Abschlagsplan::monthly_uniform(
"51238696129",
date!(2025 - 07 - 01),
12,
dec!(1200.00),
dec!(3000),
);
assert_eq!(plan.entries.len(), 12);
assert_eq!(plan.entries[0].faellig_am.month(), time::Month::July);
assert_eq!(plan.entries[0].faellig_am.year(), 2025);
assert_eq!(plan.entries[5].faellig_am.month(), time::Month::December);
assert_eq!(plan.entries[6].faellig_am.month(), time::Month::January);
assert_eq!(plan.entries[6].faellig_am.year(), 2026);
}
}