use rust_decimal::Decimal;
use rust_decimal::dec;
use serde::{Deserialize, Serialize};
#[must_use]
pub fn round_money(value: Decimal, dp: u32) -> Decimal {
value.round_dp_with_strategy(dp, billing::RoundingStrategy::MidpointAwayFromZero.into())
}
pub trait RoundMoney {
#[must_use]
fn round_kfm(&self, dp: u32) -> Decimal;
}
impl RoundMoney for Decimal {
fn round_kfm(&self, dp: u32) -> Decimal {
round_money(*self, dp)
}
}
const BEHG_EUR_PER_T: &[(i32, u32)] = &[
(2021, 25),
(2022, 30),
(2023, 30),
(2024, 45),
(2025, 55),
(2026, 65),
];
pub const ERDGAS_UMRECHNUNGSFAKTOR_GJ_PER_MWH: Decimal = dec!(3.2508);
const ERDGAS_EMISSIONSFAKTOR_T_PER_GJ: &[(i32, Decimal)] = &[
(2021, dec!(0.056)),
(2022, dec!(0.056)),
(2023, dec!(0.0558)),
(2024, dec!(0.0558)),
(2025, dec!(0.0558)),
(2026, dec!(0.0558)),
(2027, dec!(0.0558)),
(2028, dec!(0.0558)),
(2029, dec!(0.0558)),
(2030, dec!(0.0558)),
];
#[must_use]
pub fn erdgas_emissionsfaktor_kg_per_kwh(year: i32) -> Option<Decimal> {
ERDGAS_EMISSIONSFAKTOR_T_PER_GJ
.iter()
.find(|(y, _)| *y == year)
.map(|(_, t_per_gj)| ERDGAS_UMRECHNUNGSFAKTOR_GJ_PER_MWH * t_per_gj)
}
pub const STROMSTEUER_REGELSATZ_CT_PER_KWH: Decimal = dec!(2.05);
const STROMSTEUER_SINCE: i32 = 2003;
#[must_use]
pub fn stromsteuer_for_year(year: i32) -> Option<Decimal> {
(year >= STROMSTEUER_SINCE).then_some(STROMSTEUER_REGELSATZ_CT_PER_KWH)
}
pub const ENERGIESTEUER_GAS_CT_PER_KWH: Decimal = dec!(0.55);
const ENERGIESTEUER_SINCE: i32 = 2006;
#[must_use]
pub fn energiesteuer_gas_for_year(year: i32) -> Option<Decimal> {
(year >= ENERGIESTEUER_SINCE).then_some(ENERGIESTEUER_GAS_CT_PER_KWH)
}
#[must_use]
pub fn behg_ct_per_kwh_for_year(year: i32) -> Option<Decimal> {
let eur_per_t = BEHG_EUR_PER_T
.iter()
.find(|(y, _)| *y == year)
.map(|(_, eur_per_t)| Decimal::from(*eur_per_t))?;
behg_ct_per_kwh_from_price(eur_per_t, year)
}
#[must_use]
pub fn behg_ct_per_kwh_from_price(eur_per_t: Decimal, year: i32) -> Option<Decimal> {
erdgas_emissionsfaktor_kg_per_kwh(year).map(|factor| eur_per_t * factor / dec!(10))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegulatoryRates {
pub stromsteuer_ct_per_kwh: Decimal,
pub energiesteuer_gas_ct_per_kwh: Decimal,
pub behg_gas_ct_per_kwh: Decimal,
pub mwst_rate: Decimal,
#[serde(default = "default_mwst_reduced")]
pub mwst_rate_reduced: Decimal,
}
fn default_mwst_reduced() -> Decimal {
dec!(0.07)
}
impl Default for RegulatoryRates {
fn default() -> Self {
Self {
stromsteuer_ct_per_kwh: dec!(2.05),
energiesteuer_gas_ct_per_kwh: dec!(0.55),
behg_gas_ct_per_kwh: dec!(1.17906516),
mwst_rate: dec!(0.19),
mwst_rate_reduced: dec!(0.07),
}
}
}
impl RegulatoryRates {
#[must_use]
pub fn effective_mwst_electricity(&self, p: &crate::tariff::ElectricityProduct) -> Decimal {
p.mwst_rate_override.unwrap_or(self.mwst_rate)
}
#[must_use]
pub fn effective_mwst_solar(&self, p: &crate::tariff::SolarProduct) -> Decimal {
if let Some(r) = p.mwst_rate_override {
return r;
}
if p.kleinunternehmer_19_ustg {
return Decimal::ZERO;
}
self.mwst_rate
}
#[must_use]
pub fn effective_mwst_eeg(&self, p: &crate::tariff::EegProduct) -> Decimal {
if let Some(r) = p.mwst_rate_override {
return r;
}
if p.kleinunternehmer_19_ustg {
return Decimal::ZERO;
}
self.mwst_rate
}
#[must_use]
pub fn effective_stromsteuer(&self, override_ct: Option<Decimal>) -> Decimal {
override_ct.unwrap_or(self.stromsteuer_ct_per_kwh)
}
#[must_use]
pub fn effective_energiesteuer_gas(&self, override_ct: Option<Decimal>) -> Decimal {
override_ct.unwrap_or(self.energiesteuer_gas_ct_per_kwh)
}
#[must_use]
pub fn effective_behg_gas(&self, override_ct: Option<Decimal>) -> Decimal {
override_ct.unwrap_or(self.behg_gas_ct_per_kwh)
}
#[must_use]
pub fn effective_behg_gas_for_year(&self, override_ct: Option<Decimal>, year: i32) -> Decimal {
if let Some(o) = override_ct {
return o;
}
behg_ct_per_kwh_for_year(year).unwrap_or(self.behg_gas_ct_per_kwh)
}
#[must_use]
pub fn effective_stromsteuer_for_year(
&self,
override_ct: Option<Decimal>,
year: i32,
) -> Decimal {
if let Some(o) = override_ct {
return o;
}
stromsteuer_for_year(year).unwrap_or(self.stromsteuer_ct_per_kwh)
}
#[must_use]
pub fn effective_energiesteuer_gas_for_year(
&self,
override_ct: Option<Decimal>,
year: i32,
) -> Decimal {
if let Some(o) = override_ct {
return o;
}
energiesteuer_gas_for_year(year).unwrap_or(self.energiesteuer_gas_ct_per_kwh)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn behg_year_table_2026_matches_expected() {
let ct = behg_ct_per_kwh_for_year(2026).unwrap();
let expected = dec!(65) * dec!(0.18139464) / dec!(10);
assert_eq!(ct, expected);
assert_eq!(ct, dec!(1.17906516));
}
#[test]
fn behg_year_table_2024_matches_expected() {
let ct = behg_ct_per_kwh_for_year(2024).unwrap();
let expected = dec!(45) * dec!(0.18139464) / dec!(10);
assert_eq!(ct, expected);
}
#[test]
fn behg_unknown_year_returns_none() {
assert!(behg_ct_per_kwh_for_year(2020).is_none());
assert!(behg_ct_per_kwh_for_year(2031).is_none());
}
#[test]
fn the_emission_factor_is_brennwert_based() {
let f = erdgas_emissionsfaktor_kg_per_kwh(2026).expect("2026 is tabled");
assert_eq!(f, dec!(3.2508) * dec!(0.0558));
assert_eq!(f, dec!(0.18139464));
let heizwert_basis = dec!(3.6) * dec!(0.0558);
assert!(
heizwert_basis > f,
"the Heizwert basis is the larger number — it is not what is billed"
);
}
#[test]
fn the_factor_steps_when_the_ordinance_does() {
assert_eq!(
erdgas_emissionsfaktor_kg_per_kwh(2022),
Some(dec!(3.2508) * dec!(0.056))
);
assert_eq!(
erdgas_emissionsfaktor_kg_per_kwh(2023),
Some(dec!(3.2508) * dec!(0.0558))
);
assert_ne!(
erdgas_emissionsfaktor_kg_per_kwh(2022),
erdgas_emissionsfaktor_kg_per_kwh(2023)
);
assert!(erdgas_emissionsfaktor_kg_per_kwh(2031).is_none());
}
#[test]
fn there_is_one_erdgas_factor_for_the_whole_market() {
assert_eq!(
behg_ct_per_kwh_from_price(dec!(65), 2026),
Some(dec!(65) * dec!(0.18139464) / dec!(10))
);
assert!(behg_ct_per_kwh_from_price(dec!(65), 2031).is_none());
}
#[test]
fn effective_behg_for_year_prefers_override() {
let rates = RegulatoryRates::default();
assert_eq!(
rates.effective_behg_gas_for_year(Some(dec!(0.99)), 2024),
dec!(0.99)
);
let ct = rates.effective_behg_gas_for_year(None, 2024);
assert_eq!(ct, dec!(45) * dec!(0.18139464) / dec!(10));
}
}
#[must_use]
pub fn mwst_rate_for_period(from: time::Date, to: time::Date) -> Option<Decimal> {
const SENKUNG_VON: time::Date = time::macros::date!(2020 - 07 - 01);
const SENKUNG_BIS: time::Date = time::macros::date!(2020 - 12 - 31);
let inside = from >= SENKUNG_VON && to <= SENKUNG_BIS;
let outside = to < SENKUNG_VON || from > SENKUNG_BIS;
match (inside, outside) {
(true, _) => Some(dec!(0.16)),
(_, true) => Some(dec!(0.19)),
_ => None, }
}
#[must_use]
pub fn mwst_rate_for_gas_waerme_period(from: time::Date, to: time::Date) -> Option<Decimal> {
const WINDOWS: &[(time::Date, time::Date, Decimal)] = &[
(
time::macros::date!(2020 - 07 - 01),
time::macros::date!(2020 - 12 - 31),
dec!(0.16),
),
(
time::macros::date!(2022 - 10 - 01),
time::macros::date!(2024 - 03 - 31),
dec!(0.07),
),
];
for (von, bis, rate) in WINDOWS {
let inside = from >= *von && to <= *bis;
let overlaps = from <= *bis && to >= *von;
if inside {
return Some(*rate);
}
if overlaps {
return None; }
}
Some(dec!(0.19))
}
#[cfg(test)]
mod mwst_period_tests {
use super::*;
use time::macros::date;
#[test]
fn the_covid_window_and_its_edges() {
assert_eq!(
mwst_rate_for_period(date!(2020 - 07 - 01), date!(2020 - 12 - 31)),
Some(dec!(0.16))
);
assert_eq!(
mwst_rate_for_period(date!(2020 - 01 - 01), date!(2020 - 06 - 30)),
Some(dec!(0.19))
);
assert_eq!(
mwst_rate_for_period(date!(2026 - 01 - 01), date!(2026 - 01 - 31)),
Some(dec!(0.19))
);
}
#[test]
fn a_straddling_period_yields_none() {
assert_eq!(
mwst_rate_for_period(date!(2020 - 06 - 15), date!(2020 - 07 - 15)),
None
);
assert_eq!(
mwst_rate_for_period(date!(2020 - 12 - 15), date!(2021 - 01 - 15)),
None
);
}
#[test]
fn the_gas_waerme_seven_percent_window() {
assert_eq!(
mwst_rate_for_gas_waerme_period(date!(2022 - 10 - 01), date!(2023 - 09 - 30)),
Some(dec!(0.07))
);
assert_eq!(
mwst_rate_for_gas_waerme_period(date!(2024 - 01 - 01), date!(2024 - 03 - 31)),
Some(dec!(0.07))
);
assert_eq!(
mwst_rate_for_gas_waerme_period(date!(2020 - 07 - 01), date!(2020 - 12 - 31)),
Some(dec!(0.16))
);
assert_eq!(
mwst_rate_for_gas_waerme_period(date!(2026 - 01 - 01), date!(2026 - 12 - 31)),
Some(dec!(0.19))
);
assert_eq!(
mwst_rate_for_gas_waerme_period(date!(2024 - 03 - 01), date!(2024 - 04 - 30)),
None
);
assert_eq!(
mwst_rate_for_gas_waerme_period(date!(2022 - 09 - 01), date!(2023 - 08 - 31)),
None
);
}
}
#[must_use]
pub fn steuer_stichtage_im_zeitraum(
category: &str,
from: time::Date,
to: time::Date,
) -> Vec<time::Date> {
let mut stichtage = Vec::new();
if from > to {
return stichtage;
}
let gas_or_waerme = matches!(category, "GAS" | "WAERME");
let mut ust: Vec<time::Date> = vec![
time::macros::date!(2020 - 07 - 01),
time::macros::date!(2021 - 01 - 01),
];
if gas_or_waerme {
ust.push(time::macros::date!(2022 - 10 - 01));
ust.push(time::macros::date!(2024 - 04 - 01));
}
for d in ust {
if d > from && d <= to {
stichtage.push(d);
}
}
if category == "GAS" {
for year in (from.year() + 1)..=to.year() {
let Ok(jan1) = time::Date::from_calendar_date(year, time::Month::January, 1) else {
continue;
};
if jan1 <= from || jan1 > to {
continue;
}
let (Some(before), Some(after)) = (
behg_ct_per_kwh_for_year(year - 1),
behg_ct_per_kwh_for_year(year),
) else {
continue;
};
if before != after {
stichtage.push(jan1);
}
}
}
stichtage.sort_unstable();
stichtage.dedup();
stichtage
}
#[cfg(test)]
mod stichtag_tests {
use super::*;
use time::macros::date;
#[test]
fn a_uniform_period_has_no_stichtag() {
assert!(
steuer_stichtage_im_zeitraum("STROM", date!(2026 - 01 - 01), date!(2026 - 01 - 31))
.is_empty()
);
assert!(
steuer_stichtage_im_zeitraum("GAS", date!(2026 - 05 - 01), date!(2026 - 05 - 31))
.is_empty()
);
}
#[test]
fn the_gas_vat_window_end_splits_a_gas_period() {
assert_eq!(
steuer_stichtage_im_zeitraum("GAS", date!(2024 - 03 - 01), date!(2024 - 04 - 30)),
vec![date!(2024 - 04 - 01)]
);
assert_eq!(
steuer_stichtage_im_zeitraum("WAERME", date!(2024 - 03 - 01), date!(2024 - 04 - 30)),
vec![date!(2024 - 04 - 01)]
);
}
#[test]
fn electricity_does_not_see_the_gas_window() {
assert!(
steuer_stichtage_im_zeitraum("STROM", date!(2022 - 09 - 01), date!(2022 - 10 - 31))
.is_empty()
);
assert!(
steuer_stichtage_im_zeitraum("STROM", date!(2024 - 03 - 01), date!(2024 - 04 - 30))
.is_empty()
);
}
#[test]
fn the_covid_window_splits_any_commodity() {
assert_eq!(
steuer_stichtage_im_zeitraum("STROM", date!(2020 - 06 - 01), date!(2020 - 07 - 31)),
vec![date!(2020 - 07 - 01)]
);
assert_eq!(
steuer_stichtage_im_zeitraum("STROM", date!(2020 - 12 - 01), date!(2021 - 01 - 31)),
vec![date!(2021 - 01 - 01)]
);
}
#[test]
fn a_period_starting_on_the_stichtag_is_uniform() {
assert!(
steuer_stichtage_im_zeitraum("GAS", date!(2024 - 04 - 01), date!(2024 - 04 - 30))
.is_empty()
);
}
#[test]
fn a_year_crossing_gas_period_splits_on_the_behg_step() {
let s = steuer_stichtage_im_zeitraum("GAS", date!(2023 - 12 - 01), date!(2024 - 01 - 31));
assert!(
s.contains(&date!(2024 - 01 - 01)),
"the BEHG CO₂ price changes at the year boundary: {s:?}"
);
}
#[test]
fn a_year_crossing_electricity_period_does_not_split() {
assert!(
steuer_stichtage_im_zeitraum("STROM", date!(2025 - 12 - 01), date!(2026 - 01 - 31))
.is_empty()
);
}
#[test]
fn a_year_crossing_waerme_period_does_not_split() {
assert!(
steuer_stichtage_im_zeitraum("WAERME", date!(2025 - 12 - 01), date!(2026 - 01 - 31))
.is_empty(),
"a heat period has one CO₂ rate on both sides of 1 January"
);
assert!(
steuer_stichtage_im_zeitraum("WAERME", date!(2025 - 07 - 01), date!(2026 - 06 - 30))
.is_empty(),
"the ordinary Fernwärme Jahresabrechnung is billable whole"
);
}
#[test]
fn a_year_pair_the_behg_table_does_not_cover_is_no_stichtag() {
let last_tabled = BEHG_EUR_PER_T
.iter()
.map(|(y, _)| *y)
.max()
.expect("the § 10 BEHG table is not empty");
let von = time::Date::from_calendar_date(last_tabled, time::Month::December, 1)
.expect("1 December exists");
let bis = time::Date::from_calendar_date(last_tabled + 1, time::Month::January, 31)
.expect("31 January exists");
assert!(
steuer_stichtage_im_zeitraum("GAS", von, bis).is_empty(),
"an untabled year is left to the operator, not split at a rate that does not exist"
);
}
#[test]
fn several_stichtage_come_back_sorted_and_unique() {
let s = steuer_stichtage_im_zeitraum("GAS", date!(2023 - 11 - 01), date!(2024 - 04 - 30));
assert!(
s.contains(&date!(2024 - 01 - 01)) && s.contains(&date!(2024 - 04 - 01)),
"{s:?}"
);
let mut sorted = s.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(s, sorted, "must be sorted and deduplicated");
}
#[test]
fn a_reversed_period_yields_nothing_rather_than_panicking() {
assert!(
steuer_stichtage_im_zeitraum("GAS", date!(2024 - 04 - 30), date!(2024 - 03 - 01))
.is_empty()
);
}
}