use rust_decimal::Decimal;
use rust_decimal_macros::dec;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NettingResult {
pub net_vergütung_eur: Decimal,
pub residual_pflichtzahlung_eur: Decimal,
pub netting_applied: bool,
}
#[must_use]
pub fn apply_sect52_netting(vergütung_eur: Decimal, pflichtzahlung_eur: Decimal) -> NettingResult {
if pflichtzahlung_eur.is_zero() {
return NettingResult {
net_vergütung_eur: vergütung_eur,
residual_pflichtzahlung_eur: Decimal::ZERO,
netting_applied: false,
};
}
let net_vergütung = (vergütung_eur - pflichtzahlung_eur).max(Decimal::ZERO);
let residual = (pflichtzahlung_eur - vergütung_eur).max(Decimal::ZERO);
NettingResult {
net_vergütung_eur: net_vergütung,
residual_pflichtzahlung_eur: residual,
netting_applied: true,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Sect53cReduction {
pub regional_factor: Decimal,
pub certificate_reference: String,
}
impl Sect53cReduction {
#[must_use]
pub fn compute_reduction(&self, vergütung_eur: Decimal) -> Decimal {
(vergütung_eur * self.regional_factor).round_dp(5)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Sect54Reduction {
pub deduction_ct_kwh: Decimal,
pub bnetza_notification_ref: String,
pub effective_from: time::Date,
}
impl Sect54Reduction {
#[must_use]
pub fn effective_aw(&self, awarded_aw_ct: Decimal) -> Decimal {
(awarded_aw_ct - self.deduction_ct_kwh).max(Decimal::ZERO)
}
#[must_use]
pub fn is_active_on(&self, billing_date: time::Date) -> bool {
billing_date >= self.effective_from
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ReductionPipeline {
pub pflichtzahlung_eur: Option<Decimal>,
pub apply_sect52_netting: bool,
pub sect53b_ct_kwh: Option<Decimal>,
pub sect53c: Option<Sect53cReduction>,
pub sect54: Option<Sect54Reduction>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ReductionPipelineResult {
pub net_vergütung_eur: Decimal,
pub residual_pflichtzahlung_eur: Decimal,
pub total_reductions_eur: Decimal,
}
impl ReductionPipeline {
#[must_use]
pub fn none() -> Self {
Self {
pflichtzahlung_eur: None,
apply_sect52_netting: false,
sect53b_ct_kwh: None,
sect53c: None,
sect54: None,
}
}
#[must_use]
pub fn apply_with_kwh(
&self,
gross_settlement_eur: Decimal,
eligible_kwh: Decimal,
) -> ReductionPipelineResult {
let mut remaining = gross_settlement_eur;
let mut total_reductions = Decimal::ZERO;
if let Some(sect53b_ct) = self.sect53b_ct_kwh.filter(|ct| *ct > Decimal::ZERO) {
let reduction = (eligible_kwh * sect53b_ct / dec!(100)).round_dp(5);
remaining -= reduction;
total_reductions += reduction;
}
if let Some(ref sect53c) = self.sect53c {
let reduction = sect53c.compute_reduction(remaining);
remaining -= reduction;
total_reductions += reduction;
}
let (net, residual) = if self.apply_sect52_netting {
if let Some(pz) = self.pflichtzahlung_eur.filter(|p| *p > Decimal::ZERO) {
let netting = apply_sect52_netting(remaining, pz);
total_reductions += pz - netting.residual_pflichtzahlung_eur;
(
netting.net_vergütung_eur,
netting.residual_pflichtzahlung_eur,
)
} else {
(remaining, Decimal::ZERO)
}
} else {
(remaining, self.pflichtzahlung_eur.unwrap_or(Decimal::ZERO))
};
ReductionPipelineResult {
net_vergütung_eur: net,
residual_pflichtzahlung_eur: residual,
total_reductions_eur: total_reductions,
}
}
#[must_use]
pub fn apply(&self, gross_settlement_eur: Decimal) -> ReductionPipelineResult {
self.apply_with_kwh(gross_settlement_eur, Decimal::ZERO)
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
#[test]
fn netting_normal_case() {
let result = apply_sect52_netting(dec!(42.55), dec!(10.00));
assert_eq!(result.net_vergütung_eur, dec!(32.55));
assert_eq!(result.residual_pflichtzahlung_eur, dec!(0));
assert!(result.netting_applied);
}
#[test]
fn netting_penalty_exceeds_vergutung() {
let result = apply_sect52_netting(dec!(30.00), dec!(50.00));
assert_eq!(result.net_vergütung_eur, dec!(0));
assert_eq!(result.residual_pflichtzahlung_eur, dec!(20.00));
}
#[test]
fn netting_zero_penalty_no_change() {
let result = apply_sect52_netting(dec!(42.55), dec!(0));
assert_eq!(result.net_vergütung_eur, dec!(42.55));
assert!(!result.netting_applied);
}
#[test]
fn sect53c_compute_reduction() {
let r = Sect53cReduction {
regional_factor: dec!(0.10),
certificate_reference: "TEST-001".into(),
};
assert_eq!(r.compute_reduction(dec!(100.00)), dec!(10.00000));
}
#[test]
fn sect54_effective_aw_not_negative() {
let r = Sect54Reduction {
deduction_ct_kwh: dec!(7.00),
bnetza_notification_ref: "TEST-001".into(),
effective_from: date!(2026 - 01 - 01),
};
assert_eq!(r.effective_aw(dec!(5.00)), dec!(0));
assert_eq!(r.effective_aw(dec!(6.50)), dec!(0));
assert_eq!(r.effective_aw(dec!(8.00)), dec!(1.00));
}
#[test]
fn sect54_active_on_check() {
let r = Sect54Reduction {
deduction_ct_kwh: dec!(0.5),
bnetza_notification_ref: "TEST-001".into(),
effective_from: date!(2026 - 03 - 01),
};
assert!(!r.is_active_on(date!(2026 - 02 - 28)));
assert!(r.is_active_on(date!(2026 - 03 - 01)));
assert!(r.is_active_on(date!(2027 - 01 - 01)));
}
#[test]
fn pipeline_none_no_change() {
let result = ReductionPipeline::none().apply_with_kwh(dec!(42.55), dec!(500));
assert_eq!(result.net_vergütung_eur, dec!(42.55));
assert_eq!(result.total_reductions_eur, dec!(0));
}
#[test]
fn pipeline_sect52_netting() {
let pipeline = ReductionPipeline {
pflichtzahlung_eur: Some(dec!(10.00)),
apply_sect52_netting: true,
..ReductionPipeline::none()
};
let result = pipeline.apply(dec!(42.55));
assert_eq!(result.net_vergütung_eur, dec!(32.55));
assert_eq!(result.residual_pflichtzahlung_eur, dec!(0));
}
#[test]
fn pipeline_sect53b_plus_netting() {
let pipeline = ReductionPipeline {
pflichtzahlung_eur: Some(dec!(5.00)),
apply_sect52_netting: true,
sect53b_ct_kwh: Some(dec!(0.5)),
..ReductionPipeline::none()
};
let result = pipeline.apply_with_kwh(dec!(42.55), dec!(500));
assert_eq!(result.net_vergütung_eur, dec!(35.05));
assert!(result.total_reductions_eur > dec!(0));
}
#[test]
fn pipeline_netting_without_applying() {
let pipeline = ReductionPipeline {
pflichtzahlung_eur: Some(dec!(10.00)),
apply_sect52_netting: false,
..ReductionPipeline::none()
};
let result = pipeline.apply(dec!(42.55));
assert_eq!(result.net_vergütung_eur, dec!(42.55));
assert_eq!(result.residual_pflichtzahlung_eur, dec!(10.00));
}
}