use rust_decimal::Decimal;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Sparte {
#[default]
Strom,
Gas,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KaKlasse {
TarifkundeLow,
TarifkundeMedium,
SonderkundeHigh,
Exempt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuantityUnit {
Kwh,
Kw,
Kvarh,
Kvar,
Monat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sect14aModule {
Modul1,
Modul2,
Modul3,
}
impl Sect14aModule {
#[must_use]
pub fn bnentza_reference(self) -> &'static str {
"BK6-22-300"
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Modul1 => "§14a EnWG Modul 1 (pauschale Reduzierung)",
Self::Modul2 => "§14a EnWG Modul 2 (HT/NT variable)",
Self::Modul3 => "§14a EnWG Modul 3 (Spotpreis)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettlementType {
NneStrom,
NneGas,
NneSelbstausstellt,
MmmStrom,
MmmGas,
MsbRechnung,
GasAwhSperrung,
RedispatchKostenblatt,
}
impl SettlementType {
#[must_use]
pub fn default_pid(self) -> u32 {
match self {
Self::NneStrom => 31001,
Self::NneGas => 31005,
Self::NneSelbstausstellt => 31006,
Self::MmmStrom => 31002,
Self::MmmGas => 31002,
Self::MsbRechnung => 31009,
Self::GasAwhSperrung => 31011,
Self::RedispatchKostenblatt => 0, }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettlementStatus {
Initial,
Correction,
Reversal,
Final,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LegalReference {
StromNev {
paragraph: &'static str,
},
GasNev {
paragraph: &'static str,
},
Kav {
paragraph: &'static str,
},
Sect14aEnwg {
module: Sect14aModule,
},
MessZv {
paragraph: &'static str,
},
MsbG {
paragraph: &'static str,
},
BnetzaDecision {
reference: &'static str,
},
BdewAhb {
reference: &'static str,
},
StromNzv {
paragraph: &'static str,
},
GasNzv {
paragraph: &'static str,
},
Enwg {
paragraph: &'static str,
},
ARegV {
paragraph: &'static str,
},
}
impl LegalReference {
#[must_use]
pub fn citation(&self) -> String {
match self {
Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
Self::Kav { paragraph } => format!("KAV {paragraph}"),
Self::Sect14aEnwg { module } => format!("§14a EnWG {}", module.label()),
Self::MessZv { paragraph } => format!("MessZV {paragraph}"),
Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
Self::BdewAhb { reference } => format!("BDEW {reference}"),
Self::StromNzv { paragraph } => format!("StromNZV {paragraph}"),
Self::GasNzv { paragraph } => format!("GasNZV {paragraph}"),
Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TariffSource {
PublishedTariffSheet {
sheet_id: String,
},
HistoricalTariff {
valid_from: time::Date,
},
RegulatoryTariff {
decision_ref: &'static str,
},
ContractTariff {
contract_ref: String,
},
ManualOverride {
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct CalculationTrace {
pub explanation: String,
pub input_quantity: Decimal,
pub input_unit_price_eur: Decimal,
pub gross_eur: Decimal,
pub legal_refs: Vec<LegalReference>,
pub tariff_source: Option<TariffSource>,
pub regulatory_reduction_factor: Option<Decimal>,
pub rounding_note: Option<&'static str>,
}
#[derive(Debug, Clone)]
pub struct SettlementWarning {
pub severity: WarningSeverity,
pub code: &'static str,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum WarningSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BillingPositionKind {
NneArbeit,
NneArbeitHt,
NneArbeitNt,
NneArbeitModul1,
NneArbeitModul3,
NneLeistung,
NneGasGrundpreis,
Konzessionsabgabe,
Mehrmenge,
Mindermenge,
MsbGrundgebuehr,
Messdienstleistung,
GasAwhSperrung,
GasAwhEntsprrung,
GasAwhSonstige,
Blindmehrarbeit,
}
#[derive(Debug, Clone)]
pub struct InvoicePosition {
pub number: u32,
pub text: String,
pub kind: BillingPositionKind,
pub quantity: Decimal,
pub unit: QuantityUnit,
pub unit_price_eur: Decimal,
pub net_eur: Decimal,
pub artikel_id: Option<String>,
pub lastvariable_preisposition_json: Option<serde_json::Value>,
pub trace: CalculationTrace,
}
#[derive(Debug, Clone)]
pub struct GridSettlement {
pub pid: u32,
pub settlement_type: SettlementType,
pub status: SettlementStatus,
pub rechnungsnummer: String,
pub correction_of: Option<String>,
pub invoice_date: time::Date,
pub due_date: time::Date,
pub period_from: time::Date,
pub period_to: time::Date,
pub nb_mp_id: String,
pub counterparty_mp_id: String,
pub positions: Vec<InvoicePosition>,
pub total_eur: Decimal,
pub warnings: Vec<SettlementWarning>,
}
pub type GridInvoice = GridSettlement;
impl GridSettlement {
#[must_use]
pub fn positions_count(&self) -> usize {
self.positions.len()
}
#[must_use]
pub fn is_clean(&self) -> bool {
!self
.warnings
.iter()
.any(|w| w.severity >= WarningSeverity::Warning)
}
#[must_use]
pub fn all_legal_refs(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
self.positions
.iter()
.flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
.filter(|c| seen.insert(c.clone()))
.collect()
}
#[must_use]
pub fn recomputed_total(&self) -> Decimal {
self.positions
.iter()
.map(|p| p.net_eur)
.sum::<Decimal>()
.round_dp(2)
}
}
#[derive(Debug, Clone)]
pub struct NneInput {
pub malo_id: String,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub rechnungsnummer: String,
pub period_from: time::Date,
pub period_to: time::Date,
pub invoice_date: time::Date,
pub due_date: time::Date,
pub arbeitsmenge_kwh: Decimal,
pub arbeitspreis_ct_per_kwh: Decimal,
pub arbeitsmenge_ht_kwh: Option<Decimal>,
pub arbeitspreis_ht_ct_per_kwh: Option<Decimal>,
pub arbeitsmenge_nt_kwh: Option<Decimal>,
pub arbeitspreis_nt_ct_per_kwh: Option<Decimal>,
pub spitzenleistung_kw: Option<Decimal>,
pub leistungspreis_eur_per_kw: Option<Decimal>,
pub ka_satz_ct_per_kwh: Option<Decimal>,
pub sect14a_modul1_reduction_factor: Option<Decimal>,
pub nne_grundpreis_eur_per_month: Option<Decimal>,
pub nne_grundpreis_months: Option<u32>,
pub tariff_sheet_id: Option<String>,
pub sparte: Sparte,
pub ka_klasse: Option<KaKlasse>,
#[doc = "§14a Modul 3 per-interval input data."]
pub sect14a_modul3_intervals: Vec<Sect14aModul3Interval>,
}
#[derive(Debug, Clone)]
pub struct Sect14aModul3Interval {
pub period_from: time::OffsetDateTime,
pub period_to: time::OffsetDateTime,
pub menge_kwh: Decimal,
pub nne_rate_ct_per_kwh: Decimal,
pub epex_spot_ct_per_kwh: Option<Decimal>,
}
#[derive(Debug, Clone)]
pub struct MmmInput {
pub malo_id: String,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub rechnungsnummer: String,
pub period_from: time::Date,
pub period_to: time::Date,
pub invoice_date: time::Date,
pub due_date: time::Date,
pub sparte: Sparte,
pub actual_kwh: Decimal,
pub profil_kwh: Decimal,
pub mehr_preis_ct_per_kwh: Decimal,
pub minder_preis_ct_per_kwh: Decimal,
}
#[derive(Debug, Clone)]
pub struct MsbInput {
pub malo_id: String,
pub nb_mp_id: String,
pub msb_mp_id: String,
pub rechnungsnummer: String,
pub period_from: time::Date,
pub period_to: time::Date,
pub invoice_date: time::Date,
pub due_date: time::Date,
pub grundgebuehr_eur_per_month: Decimal,
pub billing_months: u32,
pub messdienstleistung_eur: Option<Decimal>,
}
#[derive(Debug, Clone)]
pub struct GasAwhInput {
pub malo_id: String,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub rechnungsnummer: String,
pub period_from: time::Date,
pub period_to: time::Date,
pub invoice_date: time::Date,
pub due_date: time::Date,
pub tariff_sheet_id: Option<String>,
pub awh_positionen: Vec<AwhPositionInput>,
}
#[derive(Debug, Clone)]
pub struct AwhPositionInput {
pub beschreibung: String,
pub anzahl: u32,
pub preis_eur: Decimal,
pub artikel_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub warnings: Vec<SettlementWarning>,
}
impl ValidationResult {
#[must_use]
pub fn ok() -> Self {
Self {
is_valid: true,
warnings: Vec::new(),
}
}
pub fn push(&mut self, w: SettlementWarning) {
if w.severity == WarningSeverity::Error {
self.is_valid = false;
}
self.warnings.push(w);
}
}
#[must_use]
pub fn validate_nne_input(input: &NneInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period_from >= input.period_to {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.arbeitsmenge_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "NEGATIVE_CONSUMPTION",
message: format!("arbeitsmenge_kwh is negative: {}", input.arbeitsmenge_kwh),
});
}
if input.arbeitspreis_ct_per_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "NEGATIVE_ARBEITSPREIS",
message: format!(
"arbeitspreis_ct_per_kwh is negative: {}",
input.arbeitspreis_ct_per_kwh
),
});
}
if input.spitzenleistung_kw.is_some() != input.leistungspreis_eur_per_kw.is_some() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "MISMATCHED_RLM_FIELDS",
message:
"spitzenleistung_kw and leistungspreis_eur_per_kw must both be set or both absent"
.to_owned(),
});
}
if input.sparte == Sparte::Gas && input.spitzenleistung_kw.is_some() {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "GAS_WITH_LEISTUNG",
message: "Gas NNE typically does not use Leistungspreis — verify tariff configuration"
.to_owned(),
});
}
let tou_count = [
input.arbeitsmenge_ht_kwh.is_some(),
input.arbeitspreis_ht_ct_per_kwh.is_some(),
input.arbeitsmenge_nt_kwh.is_some(),
input.arbeitspreis_nt_ct_per_kwh.is_some(),
]
.iter()
.filter(|&&b| b)
.count();
if tou_count > 0 && tou_count < 4 {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "PARTIAL_TOU_FIELDS",
message: format!(
"§14a Modul 2 ToU: {tou_count} of 4 HT/NT fields set — all four must be provided or all absent"
),
});
}
if input.sect14a_modul1_reduction_factor.is_some() && tou_count == 4 {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "MODUL1_AND_MODUL2_CONFLICT",
message:
"sect14a_modul1_reduction_factor (Modul 1) cannot be combined with HT/NT ToU fields (Modul 2)"
.to_owned(),
});
}
if input.sect14a_modul1_reduction_factor.is_some() && !input.sect14a_modul3_intervals.is_empty()
{
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "MODUL1_AND_MODUL3_CONFLICT",
message:
"sect14a_modul1_reduction_factor (Modul 1) cannot be combined with sect14a_modul3_intervals (Modul 3)"
.to_owned(),
});
}
for (i, interval) in input.sect14a_modul3_intervals.iter().enumerate() {
if interval.period_from >= interval.period_to {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "MODUL3_INTERVAL_PERIOD_INVALID",
message: format!(
"sect14a_modul3_intervals[{i}]: period_from must be before period_to"
),
});
}
if interval.menge_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "MODUL3_INTERVAL_NEGATIVE_MENGE",
message: format!(
"sect14a_modul3_intervals[{i}]: menge_kwh is negative ({}) — verify Lastgang data",
interval.menge_kwh
),
});
}
if interval.nne_rate_ct_per_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "MODUL3_INTERVAL_NEGATIVE_RATE",
message: format!(
"sect14a_modul3_intervals[{i}]: nne_rate_ct_per_kwh is negative ({}) — \
Modul 3 rates should be ≥ 0; verify spot formula",
interval.nne_rate_ct_per_kwh
),
});
}
}
if let Some(factor) = input.sect14a_modul1_reduction_factor
&& (factor <= Decimal::ZERO || factor > Decimal::ONE)
{
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_MODUL1_FACTOR",
message: format!("sect14a_modul1_reduction_factor must be in (0, 1], got {factor}"),
});
}
if input.nne_grundpreis_eur_per_month.is_some() != input.nne_grundpreis_months.is_some() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "GRUNDPREIS_MONTHS_MISMATCH",
message:
"nne_grundpreis_eur_per_month and nne_grundpreis_months must both be set or both absent"
.to_owned(),
});
}
r
}
#[must_use]
pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period_from >= input.period_to {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "NEGATIVE_MEHR_PREIS",
message: format!(
"mehr_preis_ct_per_kwh is negative: {}",
input.mehr_preis_ct_per_kwh
),
});
}
if input.minder_preis_ct_per_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "NEGATIVE_MINDER_PREIS",
message: format!(
"minder_preis_ct_per_kwh is negative: {}",
input.minder_preis_ct_per_kwh
),
});
}
r
}
#[must_use]
pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period_from >= input.period_to {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.grundgebuehr_eur_per_month < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "NEGATIVE_GRUNDGEBUEHR",
message: format!(
"grundgebuehr_eur_per_month is negative: {}",
input.grundgebuehr_eur_per_month
),
});
}
if input.billing_months == 0 {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "ZERO_BILLING_MONTHS",
message: "billing_months must be at least 1".to_owned(),
});
}
r
}
#[must_use]
pub fn validate_gas_awh_input(input: &GasAwhInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period_from >= input.period_to {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.awh_positionen.is_empty() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "EMPTY_AWH_POSITIONEN",
message: "awh_positionen must contain at least one position".to_owned(),
});
}
for (i, awh) in input.awh_positionen.iter().enumerate() {
if awh.anzahl == 0 {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "ZERO_AWH_ANZAHL",
message: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
});
}
if awh.preis_eur < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "NEGATIVE_AWH_PREIS",
message: format!(
"awh_positionen[{i}].preis_eur must be non-negative, got {}",
awh.preis_eur
),
});
}
}
r
}