use crate::EuroAmount;
use crate::rounding::RoundMoney;
use rust_decimal::Decimal;
use rust_decimal::dec;
use crate::model::{SettleInput, SettleOutput, SettlePosition, SettlementStatus};
use crate::scheme::SettlementScheme;
fn validated_eur(d: Decimal) -> Decimal {
EuroAmount::from_decimal_rounded(d, billing::RoundingStrategy::MidpointAwayFromZero)
.map(crate::EuroAmount::into_decimal)
.unwrap_or_else(|_| panic!("settlement amount {d} EUR exceeds the EuroAmount range"))
}
fn pos(
description: impl Into<String>,
legal_basis: impl Into<String>,
kwh: Decimal,
rate_ct_kwh: Decimal,
) -> SettlePosition {
let eur = validated_eur(kwh * rate_ct_kwh / Decimal::from(100));
SettlePosition {
description: description.into(),
legal_basis: legal_basis.into(),
kwh,
rate_ct_kwh,
eur,
}
}
fn total(positions: &[SettlePosition]) -> Option<Decimal> {
if positions.is_empty() {
None
} else {
Some(positions.iter().map(|p| p.eur).sum())
}
}
fn sect51b_verdraengt_sect51(input: &SettleInput) -> bool {
input.tariff_source.is_biogas_sect51b() && crate::version::sect51b_anwendbar(input.billing_date)
}
fn sect51_greift(
kwh_during_negative_epex: Option<Decimal>,
leistung_kwp: Option<Decimal>,
regime: crate::negativpreis::NegativpreisRegime,
erzeugungsart: Option<crate::technology::ErzeugungsArt>,
has_imesys: bool,
ist_pilotwindanlage: bool,
ist_biogas_sect51b: bool,
) -> bool {
if ist_biogas_sect51b {
return false;
}
if kwh_during_negative_epex.is_none_or(|k| k <= Decimal::ZERO) {
return false;
}
!regime.ist_befreit(leistung_kwp, erzeugungsart, has_imesys, ist_pilotwindanlage)
}
fn sect52_abs5_gedeckelt(verstoesse: &[&crate::model::Pflichtverstoss]) -> Decimal {
use std::collections::BTreeMap;
let leistung_kw = verstoesse
.iter()
.map(|v| v.leistung_kw)
.fold(Decimal::ZERO, Decimal::max);
let monatsdeckel = dec!(10) * leistung_kw;
let monatsschluessel = |d: time::Date| d.year() * 12 + (d.month() as i32 - 1);
let erster_datierter = verstoesse
.iter()
.filter_map(|v| v.beginn)
.map(monatsschluessel)
.min()
.unwrap_or(0);
let mut je_monat: BTreeMap<i32, Decimal> = BTreeMap::new();
for v in verstoesse {
let (satz_eur, monate) = crate::foerderdauer::pflichtzahlung_je_monat(v);
if monate == 0 {
continue;
}
let start = v.beginn.map_or(erster_datierter, monatsschluessel);
for offset in 0..i32::try_from(monate).unwrap_or(i32::MAX) {
*je_monat.entry(start + offset).or_default() += satz_eur;
}
}
je_monat
.into_values()
.map(|monat| monat.min(monatsdeckel))
.sum()
}
fn apply_negativpreis(kwh: Decimal, negative_kwh: Decimal) -> Decimal {
(kwh - negative_kwh).max(Decimal::ZERO)
}
fn aggregierte_leistung_kwp(input: &SettleInput) -> Option<Decimal> {
if input.capacity_blocks.is_empty() {
return input.leistung_kwp;
}
let blocks: Decimal = input.capacity_blocks.iter().map(|b| b.leistung_kwp).sum();
Some(input.leistung_kwp.unwrap_or(Decimal::ZERO) + blocks)
}
fn resolve_wind_korrekturfaktor(
wind_korrekturfaktor: Option<Decimal>,
wind_standort: Option<&crate::wind::WindStandort>,
) -> Option<Decimal> {
wind_korrekturfaktor.or_else(|| wind_standort.map(|ws| ws.korrekturfaktor))
}
fn calculate_with_capacity_blocks(input: &SettleInput, total_kwh: Decimal) -> SettleOutput {
let billing_date = input.billing_date;
let Some(primary_kwp) = input.leistung_kwp.filter(|kw| *kw > Decimal::ZERO) else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![crate::model::SettlePosition {
description: "§24 Konfigurationsfehler: leistung_kwp fehlt oder ist null"
.to_owned(),
legal_basis: "§24 EEG 2023".to_owned(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::NoData,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let additional_total_kwp: Decimal = input.capacity_blocks.iter().map(|b| b.leistung_kwp).sum();
let total_kwp = primary_kwp + additional_total_kwp;
let Some(primary_rate) = input.scheme.verguetungssatz_ct() else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![crate::model::SettlePosition {
description: "§24 Anlagenerweiterung: das Abrechnungsmodell führt keinen \
Vergütungssatz je Block (Marktprämie/Post-EEG) — kein Preis ermittelbar"
.to_owned(),
legal_basis: "§24 EEG 2023".to_owned(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::PriceMissing,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let mut positions: Vec<SettlePosition> = Vec::new();
let mut total_eligible = Decimal::ZERO;
let shares: Vec<Decimal> = std::iter::once(primary_kwp)
.chain(input.capacity_blocks.iter().map(|b| b.leistung_kwp))
.map(|kwp| kwp / total_kwp)
.collect();
let split = |quantity: Decimal| billing::proportional_split(quantity, &shares, 3);
let (Ok(block_kwh_split), Ok(negative_kwh_split)) = (
split(total_kwh),
input
.kwh_during_negative_epex
.map_or_else(|| Ok(vec![Decimal::ZERO; shares.len()]), split),
) else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![crate::model::SettlePosition {
description: format!(
"§24 Anlagenerweiterung: {total_kwh} kWh lassen sich nicht \
verteilungsgenau auf die Leistungsblöcke aufteilen"
),
legal_basis: "§24 EEG 2023".to_owned(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::NoData,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let primary_expired =
billing_date.is_some_and(|d| input.foerderendedatum.is_some_and(|fed| d > fed));
if !primary_expired {
let mut block_kwh = block_kwh_split[0];
if sect51_greift(
input.kwh_during_negative_epex,
Some(total_kwp),
input.negativpreis_regime(),
input.erzeugungsart,
input.has_imesys,
input.ist_pilotwindanlage,
sect51b_verdraengt_sect51(input),
) {
let neg_share = negative_kwh_split[0];
block_kwh = apply_negativpreis(block_kwh, neg_share);
}
if block_kwh > Decimal::ZERO || primary_rate != Decimal::ZERO {
let ibn_label = input
.inbetriebnahme
.map(|d| format!(" (IBN {d})"))
.unwrap_or_default();
positions.push(pos(
format!("Einspeiseverg\u{00fc}tung {primary_kwp}\u{202f}kWp-Block{ibn_label}"),
"\u{00a7}21 EEG",
block_kwh,
primary_rate,
));
}
total_eligible += block_kwh;
}
for (idx, block) in input.capacity_blocks.iter().enumerate() {
let block_expired = billing_date.is_some_and(|d| d > block.foerderendedatum);
if block_expired {
continue;
}
let mut block_kwh = block_kwh_split[idx + 1];
if sect51_greift(
input.kwh_during_negative_epex,
Some(total_kwp),
crate::negativpreis::NegativpreisRegime::fuer_inbetriebnahme(block.inbetriebnahme),
input.erzeugungsart,
input.has_imesys,
input.ist_pilotwindanlage,
sect51b_verdraengt_sect51(input),
) {
let neg_share = negative_kwh_split[idx + 1];
block_kwh = apply_negativpreis(block_kwh, neg_share);
}
let block_num = idx + 1;
if block_kwh > Decimal::ZERO || block.verguetungssatz_ct != Decimal::ZERO {
positions.push(pos(
format!(
"Einspeiseverg\u{00fc}tung {}\u{202f}kWp-Block\u{202f}{} (IBN {})",
block.leistung_kwp, block_num, block.inbetriebnahme
),
"\u{00a7}21 EEG",
block_kwh,
block.verguetungssatz_ct,
));
}
total_eligible += block_kwh;
}
let is_empty = positions.is_empty();
let settlement_eur = total(&positions);
SettleOutput {
settlement_eur,
eligible_kwh: Some(total_eligible),
positions,
status: if is_empty {
SettlementStatus::FoerderungBeendet
} else {
SettlementStatus::Calculated
},
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
pub fn calculate_settlement(input: &SettleInput) -> SettleOutput {
let deduplicated_pflichtverstoss: Vec<_> = {
use std::collections::HashMap;
let mut by_typ: HashMap<u8, &crate::model::Pflichtverstoss> = HashMap::new();
for v in &input.pflichtverstoss {
let key = v.typ as u8;
let entry = by_typ.entry(key).or_insert(v);
if v.monate_des_verstosses > entry.monate_des_verstosses {
*entry = v;
}
}
by_typ.into_values().collect()
};
let pflichtzahlung_eur = if deduplicated_pflichtverstoss.is_empty() {
None
} else {
Some(sect52_abs5_gedeckelt(&deduplicated_pflichtverstoss))
};
let mut result = calculate_settlement_inner(input);
result.pflichtzahlung_eur = pflichtzahlung_eur;
if result
.pflichtzahlung_eur
.is_some_and(|p| p > rust_decimal::Decimal::ZERO)
{
result.pflichtzahlung_faelligkeitsdatum = result.faelligkeitsdatum; }
if !sect51b_verdraengt_sect51(input)
&& let Some(lost_qh) = input.negative_price_quarter_hours.filter(|&q| q > 0)
&& input.scheme.negativpreis_rule_applicable()
&& input
.negativpreis_regime()
.verlaengerungsanspruch(input.tariff_source.is_auction())
&& sect51_greift(
input.kwh_during_negative_epex,
aggregierte_leistung_kwp(input),
input.negativpreis_regime(),
input.erzeugungsart,
input.has_imesys,
input.ist_pilotwindanlage,
sect51b_verdraengt_sect51(input),
)
{
let is_solar = input.erzeugungsart.is_some_and(|a| a.is_solar());
result.verlaengerungsanspruch_qh =
crate::foerderdauer::verguetungszeitraum_verlaengerung_qh(lost_qh, is_solar);
}
if matches!(input.scheme, SettlementScheme::TemporaryFeedInTariff { .. })
&& input.sect51_abs3_unreported_days > 0
&& !matches!(
result.status,
SettlementStatus::NoData | SettlementStatus::PriceMissing
)
{
let tage = Decimal::from(input.sect51_abs3_unreported_days);
let faktor = (Decimal::ONE - dec!(0.05) * tage).max(Decimal::ZERO);
if let Some(eur) = result.settlement_eur {
let gekuerzt = validated_eur(eur * faktor);
result.positions.push(crate::model::SettlePosition {
description: format!(
"\u{00a7}51 Abs. 3 EEG: Meldung der Einspeisung w\u{00e4}hrend negativer Preise unterblieben \u{2014} {} % K\u{00fc}rzung ({tage} Kalendertage)",
(Decimal::ONE - faktor) * Decimal::from(100)
),
legal_basis: "\u{00a7}51 Abs. 3 EEG".to_owned(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: gekuerzt - eur,
});
result.settlement_eur = Some(gekuerzt);
}
}
let billing_days_fraction = input.billing_days_fraction.or_else(|| {
crate::foerderdauer::compute_billing_days_fraction(
input.foerderendedatum,
input.billing_date,
)
});
if let Some(fraction) =
billing_days_fraction.filter(|&f| f > Decimal::ZERO && f < rust_decimal::Decimal::ONE)
{
if let Some(eur) = result.settlement_eur {
result.settlement_eur = Some(validated_eur(eur * fraction));
}
if let Some(kwh) = result.eligible_kwh {
result.eligible_kwh = Some((kwh * fraction).round_kfm(3));
}
for p in &mut result.positions {
p.eur = validated_eur(p.eur * fraction);
p.kwh = (p.kwh * fraction).round_kfm(3);
}
}
result.billing_days_fraction_applied =
billing_days_fraction.filter(|&f| f > Decimal::ZERO && f < rust_decimal::Decimal::ONE);
if let Some(einsman_kwh) = input.einspeisemanagement_kwh.filter(|k| *k > Decimal::ZERO)
&& !matches!(
result.status,
SettlementStatus::NoData | SettlementStatus::PriceMissing
)
&& !matches!(
input.scheme,
crate::scheme::SettlementScheme::Eigenverbrauch
)
{
let comp_rate_ct = match &input.scheme {
crate::scheme::SettlementScheme::MarketPremium {
direktverm_aw_ct,
wind_korrekturfaktor,
wind_standort,
..
} => {
let raw_aw = *direktverm_aw_ct;
if let Some(k) =
resolve_wind_korrekturfaktor(*wind_korrekturfaktor, wind_standort.as_ref())
{
(raw_aw * k).round_kfm(5)
} else {
raw_aw
}
}
_ => {
let Some(rate) = input.scheme.verguetungssatz_ct() else {
result.status = SettlementStatus::PriceMissing;
return result;
};
rate
}
};
let comp_eur = validated_eur(einsman_kwh * comp_rate_ct / Decimal::from(100));
result.positions.push(crate::model::SettlePosition {
description: format!("Einspeisemanagement-Ausfall §13a EnWG ({einsman_kwh} kWh)"),
legal_basis: "§13a EnWG (Redispatch 2.0)".to_owned(),
kwh: einsman_kwh,
rate_ct_kwh: comp_rate_ct,
eur: comp_eur,
});
result.settlement_eur = Some(result.settlement_eur.unwrap_or(Decimal::ZERO) + comp_eur);
result.eligible_kwh = Some(result.eligible_kwh.unwrap_or(Decimal::ZERO) + einsman_kwh);
}
if let Some(bd) = input.billing_date {
let m = bd.month();
let y = bd.year();
let (next_year, next_month) = if m == time::Month::December {
(y + 1, time::Month::January)
} else {
(y, m.next())
};
result.faelligkeitsdatum = time::Date::from_calendar_date(next_year, next_month, 15).ok();
}
if result
.pflichtzahlung_eur
.is_some_and(|p| p > rust_decimal::Decimal::ZERO)
{
result.dezentrale_einspeisung_anspruch_verloren = true;
}
if let Some(sect44b_eligible) = input.biogas_sect44b_eligible_kwh {
let effective_kwh = result.eligible_kwh.unwrap_or(rust_decimal::Decimal::ZERO);
if effective_kwh > rust_decimal::Decimal::ZERO && sect44b_eligible < effective_kwh {
let excess_kwh = effective_kwh - sect44b_eligible;
let ratio = (sect44b_eligible / effective_kwh).min(rust_decimal::Decimal::ONE);
if let Some(eur) = result.settlement_eur {
result.settlement_eur = Some(validated_eur(eur * ratio));
}
for p in &mut result.positions {
p.eur = validated_eur(p.eur * ratio);
p.kwh = (p.kwh * ratio).round_kfm(3);
}
match &input.scheme {
crate::scheme::SettlementScheme::FeedInTariff { .. }
| crate::scheme::SettlementScheme::TenantElectricity { .. }
| crate::scheme::SettlementScheme::TemporaryFeedInTariff { .. }
| crate::scheme::SettlementScheme::FlexibilityPremium { .. } => {
let Some(excess_rate) = input.marktwert_ct_kwh else {
result.status = SettlementStatus::PriceMissing;
return result;
};
let excess_eur =
validated_eur(excess_kwh * excess_rate / rust_decimal::Decimal::from(100));
result.positions.push(crate::model::SettlePosition {
description: format!("§44b Abs. 1 Überschuss Marktwert ({excess_kwh} kWh)"),
legal_basis: "§44b Abs. 1 EEG 2023".to_owned(),
kwh: excess_kwh,
rate_ct_kwh: excess_rate,
eur: excess_eur,
});
result.settlement_eur = Some(
result.settlement_eur.unwrap_or(rust_decimal::Decimal::ZERO) + excess_eur,
);
}
crate::scheme::SettlementScheme::MarketPremium { .. } => {
result.positions.push(crate::model::SettlePosition {
description: format!("§44b Abs. 1 Überschuss (AW = 0, {excess_kwh} kWh)"),
legal_basis: "§44b Abs. 1 EEG 2023".to_owned(),
kwh: excess_kwh,
rate_ct_kwh: rust_decimal::Decimal::ZERO,
eur: rust_decimal::Decimal::ZERO,
});
}
_ => {} }
}
}
result
}
fn calculate_settlement_inner(input: &SettleInput) -> SettleOutput {
use crate::model::SanktionAlt;
if let Some(biomasse) = &input.biomasse
&& !biomasse.sect39i_eingehalten()
{
let grenze =
(biomasse.sect39i_hoechstanteil().unwrap_or(Decimal::ZERO) * dec!(100)).normalize();
return SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: input.einspeisemenge_kwh,
positions: vec![crate::model::SettlePosition {
description: format!(
"§39i Abs. 1 EEG 2023: Anteil von Getreidekorn und Mais über \
{grenze} Masseprozent — kein Anspruch nach §19 Abs. 1"
),
legal_basis: "§39i Abs. 1 EEG 2023".to_owned(),
kwh: input.einspeisemenge_kwh.unwrap_or(Decimal::ZERO),
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::KeinAnspruch,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
match input.sanktion {
Some(SanktionAlt::VerguetungAufNull) => {
return SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: input.einspeisemenge_kwh,
positions: vec![pos(
"Verg\u{00fc}tung auf null verringert \u{2014} \u{00a7}52 Abs. 1 EEG 2021",
"\u{00a7}52 Abs. 1 EEG 2021",
input.einspeisemenge_kwh.unwrap_or(Decimal::ZERO),
Decimal::ZERO,
)],
status: SettlementStatus::Sanctioned,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
Some(SanktionAlt::VerguetungAufMarktwert) => {
let Some(epex_ct) = input.marktwert_ct_kwh else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![],
status: SettlementStatus::PriceMissing,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let Some(kwh) = input.einspeisemenge_kwh else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![],
status: SettlementStatus::NoData,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let positions = vec![pos(
"Einspeisevergütung §52 Abs. 2 EEG (auf Marktwert verringert)",
"§52 Abs. 2 EEG",
kwh,
epex_ct,
)];
return SettleOutput {
settlement_eur: total(&positions),
eligible_kwh: Some(kwh),
positions,
status: SettlementStatus::Sanctioned,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
Some(SanktionAlt::VerguetungReduziert20Prozent) => {
let base = settle_normal_body(input);
let positions: Vec<crate::model::SettlePosition> = base
.positions
.into_iter()
.map(|p| {
let rate = p.rate_ct_kwh * dec!(0.80);
crate::model::SettlePosition {
rate_ct_kwh: rate,
eur: validated_eur(p.kwh * rate / dec!(100)),
..p
}
})
.collect();
let reduced_eur = base.settlement_eur.map(|e| (e * dec!(0.80)).round_kfm(2));
return SettleOutput {
settlement_eur: reduced_eur,
eligible_kwh: base.eligible_kwh,
positions,
status: SettlementStatus::Sanctioned,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
None => {} }
settle_normal_body(input)
}
fn apply_aw_cuts(
aw_ct: Decimal,
input: &SettleInput,
) -> (Decimal, Vec<crate::aw_reductions::AwReductionApplied>) {
let aw_ct = if sect51_optin_active(input) {
aw_ct + crate::negativpreis::SECT51_OPTIN_ZUSCHLAG_CT_KWH
} else {
aw_ct
};
if input.aw_reductions.is_empty() {
return (aw_ct, Vec::new());
}
crate::aw_reductions::apply_aw_reductions(
aw_ct,
&input.aw_reductions,
&input.tariff_source,
input.erzeugungsart.unwrap_or_default(),
)
}
fn sect51_optin_active(input: &SettleInput) -> bool {
matches!(
(input.sect51_optin_wirksam_ab, input.billing_date),
(Some(ab), Some(bd)) if bd >= ab
)
}
fn aw_cut_positions(
cuts: &[crate::aw_reductions::AwReductionApplied],
kwh: Decimal,
) -> Vec<crate::model::SettlePosition> {
cuts.iter()
.map(|c| crate::model::SettlePosition {
description: c.description.clone(),
legal_basis: c.legal_basis.clone(),
kwh,
rate_ct_kwh: -c.deduction_ct_kwh,
eur: Decimal::ZERO,
})
.collect()
}
fn satz_fehlt(description: impl Into<String>, legal_basis: impl Into<String>) -> SettleOutput {
SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![SettlePosition {
description: description.into(),
legal_basis: legal_basis.into(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::PriceMissing,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
fn sect50a_ohne_leistung() -> SettleOutput {
SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![SettlePosition {
description: "§50a Flexibilitätszuschlag: leistung_kwp (flexible Leistung) fehlt — \
keine Bemessungsgrundlage"
.to_owned(),
legal_basis: "§50a EEG 2023".to_owned(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::NoData,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
fn settle_normal_body(input: &SettleInput) -> SettleOutput {
if input.capacity_blocks.is_empty()
&& let (Some(billing), Some(fed)) = (input.billing_date, input.foerderendedatum)
&& billing > fed
{
return SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: input.einspeisemenge_kwh,
positions: vec![],
status: SettlementStatus::FoerderungBeendet,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
let Some(kwh) = input.einspeisemenge_kwh else {
if let SettlementScheme::FlexibilitySurcharge {
rate_eur_per_kw_year,
} = &input.scheme
{
let Some(kw) = input.leistung_kwp else {
return sect50a_ohne_leistung();
};
let rate_eur_per_kw_year = *rate_eur_per_kw_year;
let monthly_eur = validated_eur(kw * rate_eur_per_kw_year / dec!(12));
let positions = vec![SettlePosition {
description: format!(
"Flexibilit\u{00e4}tszuschlag \u{00a7}50a EEG 2023 \
({kw}\u{202f}kW \u{00d7} {rate_eur_per_kw_year}\u{202f}EUR/kW/Jahr \u{00f7} 12)"
),
legal_basis: "\u{00a7}50a EEG 2023".to_owned(),
kwh: kw,
rate_ct_kwh: rate_eur_per_kw_year,
eur: monthly_eur,
}];
return SettleOutput {
settlement_eur: Some(monthly_eur),
eligible_kwh: Some(kw),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![],
status: SettlementStatus::NoData,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
if !input.capacity_blocks.is_empty() {
return calculate_with_capacity_blocks(input, kwh);
}
let apply_neg = input.scheme.negativpreis_rule_applicable()
&& sect51_greift(
input.kwh_during_negative_epex,
aggregierte_leistung_kwp(input),
input.negativpreis_regime(),
input.erzeugungsart,
input.has_imesys,
input.ist_pilotwindanlage,
sect51b_verdraengt_sect51(input),
);
let neg_kwh = if apply_neg {
input.kwh_during_negative_epex
} else {
None
};
match &input.scheme {
SettlementScheme::Eigenverbrauch => SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: Some(kwh),
positions: vec![],
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
},
SettlementScheme::SonstigeDirektvermarktung => SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: Some(kwh),
positions: vec![crate::model::SettlePosition {
description:
"Sonstige Direktvermarktung \u{00a7}21a EEG 2023 (kein EEG-Zahlungsanspruch)"
.to_owned(),
legal_basis: "\u{00a7}21a EEG 2023".to_owned(),
kwh,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
},
SettlementScheme::TemporaryFeedInTariff { verguetungssatz_ct }
| SettlementScheme::FeedInTariff { verguetungssatz_ct } => {
let ist_ausfallverguetung =
matches!(input.scheme, SettlementScheme::TemporaryFeedInTariff { .. });
let effective = match neg_kwh {
Some(n) => apply_negativpreis(kwh, n),
None => kwh,
};
let rate_ct = if input.aw_is_gross
&& !ist_ausfallverguetung
&& let Some(art) = input.erzeugungsart
{
(*verguetungssatz_ct - crate::rates::sect53_deduction(art)).max(Decimal::ZERO)
} else {
*verguetungssatz_ct
};
let rate_ct = if ist_ausfallverguetung {
(rate_ct * dec!(0.8)).round_kfm(2)
} else {
rate_ct
};
let (rate_ct, aw_cuts) = apply_aw_cuts(rate_ct, input);
let (desc, basis) = match (ist_ausfallverguetung, neg_kwh.is_some()) {
(true, true) => (
"Ausfallverg\u{00fc}tung \u{00a7}21 Abs. 1 Satz 1 Nr. 3 EEG (\u{2212}20 % nach \u{00a7}53 Abs. 3; \u{00a7}51 Negativpreisregel angewendet)",
"\u{00a7}21 Abs. 1 Satz 1 Nr. 3 EEG 2023",
),
(true, false) => (
"Ausfallverg\u{00fc}tung \u{00a7}21 Abs. 1 Satz 1 Nr. 3 EEG (\u{2212}20 % nach \u{00a7}53 Abs. 3)",
"\u{00a7}21 Abs. 1 Satz 1 Nr. 3 EEG 2023",
),
(false, true) => (
"Einspeiseverg\u{00fc}tung \u{00a7}21 EEG (\u{00a7}51 Negativpreisregel angewendet)",
"\u{00a7}21 EEG 2023",
),
(false, false) => (
"Einspeiseverg\u{00fc}tung \u{00a7}21 EEG",
"\u{00a7}21 EEG 2023",
),
};
let mut positions = vec![pos(desc, basis, effective, rate_ct)];
positions.extend(aw_cut_positions(&aw_cuts, effective));
SettleOutput {
settlement_eur: total(&positions),
eligible_kwh: Some(effective),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
SettlementScheme::TenantElectricity {
verguetungssatz_ct,
mieter_zuschlag_ct,
} => {
let effective = match neg_kwh {
Some(n) => apply_negativpreis(kwh, n),
None => kwh,
};
let Some(zuschlag) = *mieter_zuschlag_ct else {
return satz_fehlt(
"§21 Abs. 3 Mieterstrom: mieter_zuschlag_ct fehlt — der Zuschlag ist \
Bestandteil des Anspruchs, kein Aufschlag nach Ermessen",
"§21 Abs. 3 EEG 2023",
);
};
let base_desc = if neg_kwh.is_some() {
"Einspeiseverg\u{00fc}tung \u{00a7}21 EEG (\u{00a7}51 Negativpreisregel angewendet)"
} else {
"Einspeiseverg\u{00fc}tung \u{00a7}21 EEG"
};
let mut positions = vec![pos(
base_desc,
"\u{00a7}21 EEG 2023",
effective,
*verguetungssatz_ct,
)];
if zuschlag != Decimal::ZERO {
positions.push(pos(
"Mieterstrom-Zuschlag \u{00a7}21 Abs. 3 EEG 2023",
"\u{00a7}21 Abs. 3 EEG 2023",
effective,
zuschlag,
));
}
SettleOutput {
settlement_eur: total(&positions),
eligible_kwh: Some(effective),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
SettlementScheme::MarketPremium {
direktverm_aw_ct,
wind_korrekturfaktor,
wind_standort,
} => {
let effective = match neg_kwh {
Some(n) => apply_negativpreis(kwh, n),
None => kwh,
};
let epex_source = input.marktwert_ct_kwh;
let Some(epex_ct) = epex_source else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![],
status: SettlementStatus::PriceMissing,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let raw_aw_ct = *direktverm_aw_ct;
if raw_aw_ct <= Decimal::ZERO {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![crate::model::SettlePosition {
description: "Marktpr\u{00e4}mie ohne anzulegenden Wert: direktverm_aw_ct fehlt oder ist null"
.to_owned(),
legal_basis: "\u{00a7}20 EEG 2023 i.V.m. Anlage 1".to_owned(),
kwh: Decimal::ZERO,
rate_ct_kwh: Decimal::ZERO,
eur: Decimal::ZERO,
}],
status: SettlementStatus::PriceMissing,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
if input.tariff_source.is_biogas_sect51b()
&& crate::version::sect51b_anwendbar(input.billing_date)
&& epex_ct <= dec!(2)
{
return SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: input.einspeisemenge_kwh,
positions: vec![pos(
"\u{00a7}51b EEG 2023 Biogasanlage Ausschreibung \
(Spotmarktpreis \u{2264} 2\u{202f}ct/kWh \u{2192} AW = 0)",
"\u{00a7}51b EEG 2023",
kwh,
Decimal::ZERO,
)],
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
let aw_ct = if let Some(k) =
resolve_wind_korrekturfaktor(*wind_korrekturfaktor, wind_standort.as_ref())
{
(raw_aw_ct * k).round_kfm(5)
} else {
raw_aw_ct
};
let (aw_ct, aw_cuts) = apply_aw_cuts(aw_ct, input);
if input.tariff_source.is_innovation_auction() {
let feste_praemie_eur = validated_eur(effective * aw_ct / Decimal::from(100));
let mut positions = vec![pos(
"Feste Marktpr\u{00e4}mie \u{00a7}39n EEG 2023 (Innovationsausschreibung, \u{00a7}3 InnAusV)",
"\u{00a7}39n EEG 2023 i.V.m. \u{00a7}3 InnAusV",
effective,
aw_ct,
)];
positions.extend(aw_cut_positions(&aw_cuts, effective));
return SettleOutput {
settlement_eur: Some(feste_praemie_eur),
eligible_kwh: Some(effective),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
let praemie_ct = (aw_ct - epex_ct).max(Decimal::ZERO);
let (praemie_desc, praemie_basis) = if input.tariff_source.is_auction() {
(
"Gleitende Marktpr\u{00e4}mie \u{00a7}\u{00a7}22a,28 EEG 2023 (Ausschreibung)",
"\u{00a7}\u{00a7}22a,28 EEG 2023",
)
} else if neg_kwh.is_some() {
(
"Gleitende Marktpr\u{00e4}mie \u{00a7}23a EEG 2023 (\u{00a7}51 Negativpreisregel angewendet)",
"\u{00a7}23a EEG 2023 i.V.m. Anlage 1",
)
} else {
(
"Gleitende Marktpr\u{00e4}mie \u{00a7}23a EEG 2023",
"\u{00a7}23a EEG 2023 i.V.m. Anlage 1",
)
};
let mut positions = vec![pos(praemie_desc, praemie_basis, effective, praemie_ct)];
positions.extend(aw_cut_positions(&aw_cuts, effective));
let total_eur = positions.iter().map(|p| p.eur).sum();
SettleOutput {
settlement_eur: Some(total_eur),
eligible_kwh: Some(effective),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
SettlementScheme::PostEeg { price_floor } => {
let Some(epex_ct) = input.marktwert_ct_kwh else {
return SettleOutput {
settlement_eur: None,
eligible_kwh: None,
positions: vec![],
status: SettlementStatus::PriceMissing,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
};
let epex_floored = if let Some(floor) = *price_floor {
epex_ct.max(floor)
} else {
epex_ct
};
let effective_ct = if epex_floored > dec!(10) {
dec!(10)
} else {
epex_floored
};
let was_capped = epex_floored > dec!(10);
let desc = if was_capped {
format!(
"Einspeiseverg\u{00fc}tung Post-EEG Spot \
(\u{00a7}23b Jahresmarktwert-Deckel: EPEX {:.2}\u{202f}ct \u{2192} 10\u{202f}ct)",
epex_ct
)
} else {
"Einspeiseverg\u{00fc}tung Post-EEG Spot (\u{00a7}21 EEG, nach F\u{00f6}rderungsende)".to_owned()
};
let positions = vec![pos(
desc,
"\u{00a7}21 EEG (post-F\u{00f6}rderung)",
kwh,
effective_ct,
)];
SettleOutput {
settlement_eur: total(&positions),
eligible_kwh: Some(kwh),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
SettlementScheme::KwkSurcharge {
verguetungssatz_ct,
kwh_paid_gesamt,
max_kwh,
jahres_restkontingent_kwh,
} => {
use crate::foerderdauer::kwk_eligible_kwh;
let (eligible, limit_reached) = match (*kwh_paid_gesamt, *max_kwh) {
(Some(paid), Some(max)) => kwk_eligible_kwh(kwh, paid, max),
_ => (kwh, false),
};
let nach_lifetime = eligible;
let eligible = match *jahres_restkontingent_kwh {
Some(rest) => eligible.min(rest.max(Decimal::ZERO)),
None => eligible,
};
let jahreskontingent_erschoepft =
eligible <= Decimal::ZERO && nach_lifetime > Decimal::ZERO;
if eligible <= Decimal::ZERO {
return SettleOutput {
settlement_eur: Some(Decimal::ZERO),
eligible_kwh: Some(Decimal::ZERO),
positions: vec![],
status: if jahreskontingent_erschoepft {
SettlementStatus::JahreskontingentErschoepft
} else {
SettlementStatus::FoerderungBeendet
},
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
};
}
let desc = if limit_reached {
format!(
"KWK-Zuschlag \u{00a7}7 KWKG 2023 (F\u{00f6}rderdauer-Endabrechnung: {eligible} von {kwh} kWh)"
)
} else {
"KWK-Zuschlag \u{00a7}7 KWKG 2023".to_owned()
};
let positions = vec![pos(
desc,
"\u{00a7}7 KWKG 2023",
eligible,
*verguetungssatz_ct,
)];
let status = if limit_reached {
SettlementStatus::FoerderungBeendet
} else {
SettlementStatus::Calculated
};
SettleOutput {
settlement_eur: total(&positions),
eligible_kwh: Some(eligible),
positions,
status,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
SettlementScheme::FlexibilityPremium {
verguetungssatz_ct,
flex_praemie_ct_kwh,
} => {
let effective = match neg_kwh {
Some(n) => apply_negativpreis(kwh, n),
None => kwh,
};
let Some(flex_ct) = *flex_praemie_ct_kwh else {
return satz_fehlt(
"§50b Flexibilitätsprämie: flex_praemie_ct_kwh fehlt — die Prämie ist \
Bestandteil des Anspruchs, kein Aufschlag nach Ermessen",
"§50b EEG 2023",
);
};
let base_desc = if neg_kwh.is_some() {
"Einspeiseverg\u{00fc}tung \u{00a7}21 EEG (\u{00a7}51 Negativpreisregel angewendet)"
} else {
"Einspeiseverg\u{00fc}tung \u{00a7}21 EEG"
};
let mut positions = vec![pos(
base_desc,
"\u{00a7}21 EEG 2023",
effective,
*verguetungssatz_ct,
)];
if flex_ct != Decimal::ZERO {
positions.push(pos(
"Flexibilit\u{00e4}tspr\u{00e4}mie \u{00a7}50b EEG 2023 (bestehende Anlage)",
"\u{00a7}50b EEG 2023",
effective,
flex_ct,
));
}
SettleOutput {
settlement_eur: total(&positions),
eligible_kwh: Some(effective),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
SettlementScheme::FlexibilitySurcharge {
rate_eur_per_kw_year,
} => {
let Some(kw) = input.leistung_kwp else {
return sect50a_ohne_leistung();
};
let rate_eur_per_kw_year = *rate_eur_per_kw_year;
let monthly_eur = validated_eur(kw * rate_eur_per_kw_year / dec!(12));
let positions = vec![SettlePosition {
description: format!(
"Flexibilit\u{00e4}tszuschlag \u{00a7}50a EEG 2023 \
({kw}\u{202f}kW \u{00d7} {rate_eur_per_kw_year}\u{202f}EUR/kW/Jahr \u{00f7} 12)"
),
legal_basis: "\u{00a7}50a EEG 2023".to_owned(),
kwh: kw, rate_ct_kwh: rate_eur_per_kw_year, eur: monthly_eur,
}];
SettleOutput {
settlement_eur: Some(monthly_eur),
eligible_kwh: Some(kw),
positions,
status: SettlementStatus::Calculated,
pflichtzahlung_eur: None,
pflichtzahlung_faelligkeitsdatum: None,
verlaengerungsanspruch_qh: 0,
dezentrale_einspeisung_anspruch_verloren: false,
billing_days_fraction_applied: None,
faelligkeitsdatum: None,
}
}
}
}
#[cfg(test)]
mod refusal_tests {
use super::*;
use crate::model::SettleInput;
#[test]
fn sect50a_ohne_leistung_kwp_verweigert_statt_null_euro() {
for einspeisemenge_kwh in [None, Some(dec!(1000))] {
let out = crate::calculate_settlement(&SettleInput {
scheme: SettlementScheme::FlexibilitySurcharge {
rate_eur_per_kw_year: dec!(100),
},
leistung_kwp: None,
einspeisemenge_kwh,
..SettleInput::default()
});
assert_eq!(out.status, SettlementStatus::NoData);
assert_eq!(out.settlement_eur, None);
assert!(out.positions[0].legal_basis.contains("50a"));
}
}
#[test]
fn sect50a_mit_leistung_kwp_rechnet() {
let out = crate::calculate_settlement(&SettleInput {
scheme: SettlementScheme::FlexibilitySurcharge {
rate_eur_per_kw_year: dec!(120),
},
leistung_kwp: Some(dec!(200)),
..SettleInput::default()
});
assert_eq!(out.status, SettlementStatus::Calculated);
assert_eq!(out.settlement_eur, Some(dec!(2000)));
}
#[test]
fn mieterstrom_ohne_zuschlagssatz_verweigert() {
let out = crate::calculate_settlement(&SettleInput {
scheme: SettlementScheme::TenantElectricity {
verguetungssatz_ct: dec!(8.11),
mieter_zuschlag_ct: None,
},
einspeisemenge_kwh: Some(dec!(500)),
..SettleInput::default()
});
assert_eq!(out.status, SettlementStatus::PriceMissing);
assert_eq!(out.settlement_eur, None);
}
#[test]
fn flexibilitaetspraemie_ohne_satz_verweigert() {
let out = crate::calculate_settlement(&SettleInput {
scheme: SettlementScheme::FlexibilityPremium {
verguetungssatz_ct: dec!(18.4),
flex_praemie_ct_kwh: None,
},
einspeisemenge_kwh: Some(dec!(500)),
..SettleInput::default()
});
assert_eq!(out.status, SettlementStatus::PriceMissing);
assert_eq!(out.settlement_eur, None);
}
#[test]
fn einspeisemanagement_ohne_satz_verweigert() {
for scheme in [
SettlementScheme::PostEeg { price_floor: None },
SettlementScheme::SonstigeDirektvermarktung,
] {
let out = crate::calculate_settlement(&SettleInput {
scheme,
einspeisemenge_kwh: Some(dec!(1000)),
marktwert_ct_kwh: Some(dec!(6.0)),
einspeisemanagement_kwh: Some(dec!(300)),
..SettleInput::default()
});
assert_eq!(out.status, SettlementStatus::PriceMissing);
assert!(
out.positions
.iter()
.all(|p| !p.legal_basis.contains("13a") || p.rate_ct_kwh != Decimal::ZERO),
"no §13a position may be booked at 0 ct/kWh"
);
}
}
}