1use crate::rates::RoundMoney;
8use billing::{Currency, DynamicPricing, RateBand, RateSchedule, TimeBand, TimeOfUsePricing};
9use rust_decimal::Decimal;
10use rust_decimal::dec;
11
12use crate::context::BillingContext;
13use crate::error::EngineError;
14use crate::position::{
15 BillingPosition, BillingWarning, PositionCategory, WarningSeverity, arbeitspreis_position,
16 grundpreis_position, levy_position, validated_eur,
17};
18use crate::provider::BillingProvider;
19use crate::quantities::{GridInput, Quantities};
20use crate::tariff::{
21 AbwasserRegime, ControllableLoadProduct, EegProduct, EinspeisungProduct, ElectricityProduct,
22 EmobilityProduct, GasProduct, HeatProduct, HemsProduct, ServiceProduct, SharingProduct,
23 SolarProduct, WaterProduct,
24};
25
26pub struct ElectricityProvider {
34 product: ElectricityProduct,
35 grid: GridInput,
36}
37
38impl ElectricityProvider {
39 #[must_use]
40 pub fn new(product: ElectricityProduct, grid: GridInput) -> Self {
41 Self { product, grid }
42 }
43
44 #[must_use]
50 pub fn from_product(product: &crate::tariff::Product, grid: GridInput) -> Self {
51 use crate::tariff::Product;
52 match product {
53 Product::Strom(p) => Self::new(p.clone(), grid),
54 Product::Waermepumpe(c) | Product::Wallbox(c) => Self::new(c.base.clone(), grid),
55 Product::Sharing(s) => Self::new(s.electricity.clone(), grid),
56 other => panic!(
57 "ElectricityProvider::from_product: incompatible product category '{}'",
58 other.category_str()
59 ),
60 }
61 }
62}
63
64impl BillingProvider for ElectricityProvider {
65 fn validate_warnings(
66 &self,
67 ctx: &BillingContext,
68 quantities: &Quantities,
69 ) -> Vec<BillingWarning> {
70 let mut w = Vec::new();
71 let meter = quantities.electricity.as_ref();
72
73 let has_any_work_price = self.product.arbeitspreis_ct_per_kwh.is_some()
93 || self.product.arbeitspreis_ht_ct_per_kwh.is_some()
94 || self.product.arbeitspreis_nt_ct_per_kwh.is_some()
95 || self.product.dynamic_epex
96 || self
97 .product
98 .indexed_price
99 .as_ref()
100 .is_some_and(|i| i.effective_ct_per_kwh().is_some())
101 || self
102 .product
103 .seasonal_prices
104 .as_ref()
105 .is_some_and(|s| !s.is_empty())
106 || self
107 .product
108 .block_tiers
109 .as_ref()
110 .is_some_and(|t| !t.is_empty());
111 w.extend(indexwert_warning(
112 self.product.indexed_price.as_ref(),
113 has_any_work_price,
114 ));
115 if !has_any_work_price {
116 w.push(BillingWarning {
117 code: "KEIN_ARBEITSPREIS",
118 severity: WarningSeverity::Error,
119 message: "the product carries no Arbeitspreis in any form (Eintarif, HT/NT, \
120 dynamic, indexed, seasonal or tiered) — the invoice would charge \
121 the Stromsteuer and nothing for the electricity. Check the \
122 productd product's price positions."
123 .to_owned(),
124 });
125 }
126
127 if meter.is_some_and(|m| {
132 (m.zaehlerstand_von.is_some() || m.zaehlerstand_bis.is_some())
133 && m.ablesungsart == crate::quantities::Ablesungsart::Unbekannt
134 }) {
135 w.push(BillingWarning {
136 code: "ABLESUNGSART_FEHLT",
137 severity: WarningSeverity::Warning,
138 message: "§ 40 Abs. 2 Nr. 6 EnWG verlangt die Angabe, wie der Zählerstand \
139 ermittelt wurde — `ablesungsart` ist nicht gesetzt, die Rechnung \
140 nennt sie daher nicht"
141 .to_owned(),
142 });
143 }
144
145 if meter.is_some_and(|m| m.is_estimated) {
151 w.push(BillingWarning {
152 code: "ESTIMATED_READING",
153 severity: WarningSeverity::Warning,
154 message: "billed on an estimated reading (§ 40a Abs. 2 EnWG) — \
155 expect a correction when the real reading arrives"
156 .to_owned(),
157 });
158 }
159
160 if let Some(bis) = self.product.preisgarantie_bis
163 && bis <= ctx.period_to() + time::Duration::days(30)
164 {
165 w.push(BillingWarning {
166 code: "PREISGARANTIE_ENDET",
167 severity: WarningSeverity::Warning,
168 message: format!(
169 "the price guarantee ends {bis}, within 30 days of the billed \
170 period — verify the follow-on price was communicated"
171 ),
172 });
173 }
174
175 if let (Some(m), Some(vh)) = (meter, ctx.verbrauchshistorie.as_ref())
179 && let Some(vorjahr) = vh.vorjahr_kwh
180 && vorjahr > Decimal::ZERO
181 {
182 let deviation = ((m.arbeitsmenge_kwh - vorjahr) / vorjahr).abs();
183 if deviation > dec!(0.5) {
184 w.push(BillingWarning {
185 code: "VERBRAUCH_ABWEICHUNG_50PCT",
186 severity: WarningSeverity::Warning,
187 message: format!(
188 "consumption {} kWh deviates {:.0}% from the prior year's \
189 {vorjahr} kWh — verify the reading before dispatch",
190 m.arbeitsmenge_kwh,
191 deviation * dec!(100)
192 ),
193 });
194 }
195 }
196
197 let p = &self.product;
209 let prices_only_ht_nt = p.arbeitspreis_ct_per_kwh.is_none()
210 && (p.arbeitspreis_ht_ct_per_kwh.is_some() || p.arbeitspreis_nt_ct_per_kwh.is_some())
211 && !p.dynamic_epex
212 && p.block_tiers.as_ref().is_none_or(|t| t.is_empty())
213 && p.seasonal_prices.as_ref().is_none_or(|s| s.is_empty())
214 && p.indexed_price.is_none();
215 let has_split = meter
216 .is_some_and(|m| m.arbeitsmenge_ht_kwh.is_some() && m.arbeitsmenge_nt_kwh.is_some());
217 let has_quantity = meter.is_some_and(|m| m.billable_kwh() > Decimal::ZERO);
218 if prices_only_ht_nt && !has_split && has_quantity {
219 w.push(BillingWarning {
220 code: "ZWEITARIF_OHNE_HT_NT_AUFTEILUNG",
221 severity: WarningSeverity::Error,
222 message: "the product prices only HT and NT, and the meter reports a single \
223 total with no HT/NT split — the consumption cannot be priced at \
224 all, and the invoice would carry the levies and nothing for the \
225 electricity. Supply arbeitsmenge_ht_kwh/arbeitsmenge_nt_kwh, or \
226 give the product an Eintarif Arbeitspreis."
227 .to_owned(),
228 });
229 }
230
231 let ht_priced = p.arbeitspreis_ht_ct_per_kwh.is_some();
235 let nt_priced = p.arbeitspreis_nt_ct_per_kwh.is_some();
236 if ht_priced != nt_priced {
237 w.push(BillingWarning {
238 code: "ZWEITARIF_UNVOLLSTAENDIG",
239 severity: WarningSeverity::Error,
240 message: format!(
241 "the product prices the {} band and not the {} one — a Zweitarif needs \
242 both, and neither inventing the missing price nor dropping the band is \
243 a lawful reading",
244 if ht_priced { "HT" } else { "NT" },
245 if ht_priced { "NT" } else { "HT" },
246 ),
247 });
248 }
249
250 if let Some(m) = meter
253 && let (Some(ht), Some(nt)) = (m.arbeitsmenge_ht_kwh, m.arbeitsmenge_nt_kwh)
254 && m.arbeitsmenge_kwh > Decimal::ZERO
255 {
256 let split = ht + nt;
257 let gap = (split - m.arbeitsmenge_kwh).abs();
258 if gap > dec!(0.1) {
261 w.push(BillingWarning {
262 code: "HT_NT_SUMME_WEICHT_AB",
263 severity: WarningSeverity::Error,
264 message: format!(
265 "HT ({ht}) + NT ({nt}) = {split} kWh does not match the stated total \
266 {} kWh — one of the registers is wrong and the invoice would bill \
267 the difference at whichever rate happens to apply",
268 m.arbeitsmenge_kwh
269 ),
270 });
271 }
272 }
273
274 if crate::rates::mwst_rate_for_period(ctx.period_from(), ctx.period_to()).is_none() {
278 w.push(BillingWarning {
279 code: "MWST_STICHTAG_IM_ZEITRAUM",
280 severity: WarningSeverity::Warning,
281 message: "Abrechnungszeitraum überschreitet eine USt-Satzgrenze für Strom \
282 (§28 UStG) — am Stichtag splitten und Teilrechnungen zusammenführen"
283 .to_owned(),
284 });
285 }
286 w
287 }
288
289 fn bill(
290 &self,
291 ctx: &BillingContext,
292 quantities: &Quantities,
293 _prior: &[BillingPosition],
294 ) -> Result<Vec<BillingPosition>, EngineError> {
295 let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
296 let kwh = meter.billable_kwh();
300 let product = &self.product;
301 let grid = &self.grid;
302 let rates = &ctx.regulatory_rates;
303 let mut positions: Vec<BillingPosition> = Vec::new();
304
305 let billing_month = ctx.period_from().month() as u8;
309 let seasonal_arbeitspreis = product.seasonal_prices.as_ref().and_then(|seasons| {
310 seasons
311 .iter()
312 .find(|s| s.contains_month(billing_month))
313 .and_then(|s| s.arbeitspreis_ct_per_kwh)
314 });
315
316 if let Some(p) = &quantities.prosumer {
321 return self.bill_prosumer(ctx, p, product, grid, rates, seasonal_arbeitspreis);
322 }
323
324 if let Some(gp_ct_day) = product.grundpreis_ct_per_day {
326 positions.push(
327 grundpreis_position(
328 "Grundpreis",
329 gp_ct_day / dec!(100),
330 ctx.prorate_days().0 as i64,
331 "§41 EnWG",
332 &["strom"],
333 )
334 .with_tag("strom"),
335 );
336 }
337
338 if meter.billable_kwh() > Decimal::ZERO {
344 if let Some(tiers) = product.block_tiers.as_ref().filter(|t| !t.is_empty()) {
345 positions.extend(build_block_tariff_positions(tiers, kwh, &[])?);
349 } else if let (Some(ht), Some(nt), true) = (
350 meter.arbeitsmenge_ht_kwh,
351 meter.arbeitsmenge_nt_kwh,
352 product.arbeitspreis_ht_ct_per_kwh.is_some()
359 && product.arbeitspreis_nt_ct_per_kwh.is_some(),
360 ) {
361 let mut bands = Vec::new();
364 if let Some(ap_ht) = product.arbeitspreis_ht_ct_per_kwh {
365 let price = billing::Amount::<5>::try_from((ap_ht / dec!(100)).round_kfm(5))
366 .map_err(|_| EngineError::PriceOutOfRange {
367 field: "arbeitspreis_ht_ct_per_kwh".to_owned(),
368 value: ap_ht,
369 })?;
370 bands.push(TimeBand::new("HT", price));
371 }
372 if let Some(ap_nt) = product.arbeitspreis_nt_ct_per_kwh {
373 let price = billing::Amount::<5>::try_from((ap_nt / dec!(100)).round_kfm(5))
374 .map_err(|_| EngineError::PriceOutOfRange {
375 field: "arbeitspreis_nt_ct_per_kwh".to_owned(),
376 value: ap_nt,
377 })?;
378 bands.push(TimeBand::new("NT", price));
379 }
380 if !bands.is_empty() {
381 let items = TimeOfUsePricing::builder()
382 .bands(bands)
383 .unit("kWh")
384 .currency(Currency::EUR)
385 .build()?
386 .calculate(&[("HT", ht), ("NT", nt)])?;
387 for item in items {
388 let is_ht = item.has_tag("HT");
389 let label = if is_ht {
390 "Arbeitspreis Hochtarif (HT)"
391 } else {
392 "Arbeitspreis Niedertarif (NT)"
393 };
394 let band_tag = if is_ht { "ht" } else { "nt" };
395 let mut pos = billing_item_to_position(
396 item,
397 PositionCategory::Commodity,
398 "§41 EnWG",
399 &["strom", "arbeitspreis"],
400 );
401 pos.description = label.to_owned();
402 pos.tags.push(band_tag.to_owned());
403 positions.push(pos);
404 }
405 }
406 } else if let Some((effective_ct, idx)) = product
407 .indexed_price
408 .as_ref()
409 .and_then(|idx| idx.effective_ct_per_kwh().map(|ct| (ct, idx)))
410 {
411 positions.push(
420 arbeitspreis_position(
421 idx.position_description(),
422 kwh,
423 effective_ct,
424 "kWh",
425 "§41 EnWG",
426 &["strom", "indexed"],
427 )
428 .with_tag("strom")
429 .with_tag("indexed_price"),
430 );
431 } else if let Some(ap_ct) = seasonal_arbeitspreis.or(product.arbeitspreis_ct_per_kwh) {
432 let label = if seasonal_arbeitspreis.is_some() {
434 product
435 .seasonal_prices
436 .as_ref()
437 .and_then(|s| s.iter().find(|p| p.contains_month(billing_month)))
438 .and_then(|s| s.label.as_deref())
439 .map(|l| format!("Arbeitspreis Strom ({l})"))
440 .unwrap_or_else(|| "Arbeitspreis Strom (Saisontarif)".to_owned())
441 } else {
442 "Arbeitspreis Strom".to_owned()
443 };
444 positions.push(
445 arbeitspreis_position(label, kwh, ap_ct, "kWh", "§41 EnWG", &["strom"])
446 .with_tag("strom"),
447 );
448 }
449 }
450
451 if let Some(eeg_ct) = quantities.eeg_gutschrift_eur
457 && eeg_ct != Decimal::ZERO
458 {
459 let mut p = BillingPosition::credit(
460 "EEG-Gutschrift (Photovoltaik)",
461 Decimal::ONE,
462 "EUR",
463 eeg_ct.abs(),
464 PositionCategory::Credit,
465 )
466 .with_legal_basis("§19 Abs. 1 EEG 2023")
475 .with_tag("eeg_gutschrift")
476 .with_tag("solar");
477 if product.eeg_gutschrift_kleinunternehmer_19_ustg {
478 p = p.with_tax_rate(Decimal::ZERO);
479 }
480 positions.push(p);
481 }
482
483 let kwh_for_grid = kwh;
485 if let Some(nne_gp) = grid.nne_grundpreis_eur_per_year {
486 let daily = nne_gp / Decimal::from(time::util::days_in_year(ctx.period_from().year()));
489 positions.push(
494 BillingPosition::debit(
495 "Netznutzungsentgelt Grundpreis",
496 Decimal::from(ctx.prorate_days().0),
497 "Tage",
498 daily,
499 PositionCategory::GridCharge,
500 )
501 .with_legal_basis("StromNEV")
502 .with_tag("nne_grundpreis")
503 .with_tag("nne"),
504 );
505 }
506 if let Some(nne_ap_ct) = grid.nne_arbeitspreis_ct_per_kwh {
507 positions.push(
508 BillingPosition::debit(
509 "Netznutzungsentgelt Arbeitspreis",
510 kwh_for_grid,
511 "kWh",
512 nne_ap_ct / dec!(100),
513 PositionCategory::GridCharge,
514 )
515 .with_legal_basis("StromNEV")
516 .with_tag("nne_arbeitspreis")
517 .with_tag("nne"),
518 );
519 }
520 if let (Some(nne_lp), Some(kw)) = (
521 grid.nne_leistungspreis_eur_per_kw_year,
522 meter.spitzenleistung_kw,
523 ) {
524 positions.push(
525 BillingPosition::debit(
526 "Netznutzungsentgelt Leistungspreis",
527 kw,
528 "kW",
529 nne_lp * ctx.billed_years(),
530 PositionCategory::GridCharge,
531 )
532 .with_legal_basis("StromNEV")
533 .with_tag("nne_leistungspreis")
534 .with_tag("nne"),
535 );
536 }
537 if let Some(ka_ct) = grid.ka_ct_per_kwh {
538 positions.push(
539 BillingPosition::debit(
540 "Konzessionsabgabe",
541 kwh_for_grid,
542 "kWh",
543 ka_ct / dec!(100),
544 PositionCategory::GridCharge,
545 )
546 .with_legal_basis("KAV §2")
547 .with_tag("konzessionsabgabe")
548 .with_tag("nne"),
549 );
550 }
551
552 if let (Some(lp_ct_per_kw_month), Some(kw)) = (
563 product.leistungspreis_strom_ct_per_kw_month,
564 meter.spitzenleistung_kw.filter(|kw| *kw > Decimal::ZERO),
565 ) {
566 positions.push(
567 BillingPosition::debit(
568 "Leistungspreis",
569 kw,
570 "kW",
571 lp_ct_per_kw_month / dec!(100) * ctx.billed_months(),
572 PositionCategory::Commodity,
573 )
574 .with_legal_basis("§41 EnWG")
575 .with_tag("leistungspreis")
576 .with_tag("rlm"),
577 );
578 }
579
580 positions.extend(stromsteuer_positions(
582 product.stromsteuer_tarif,
583 kwh,
584 rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override),
585 &["strom"],
586 ));
587 positions.extend(entlastungs_hinweise(
590 &product.steuerentlastungen,
591 &positions,
592 ));
593
594 if let Some(aa_ct) = product
598 .auf_abschlag_ct_per_kwh
599 .filter(|v| *v != Decimal::ZERO)
600 && kwh > Decimal::ZERO
601 {
602 let (label, cat) = if aa_ct < Decimal::ZERO {
603 ("Rabatt (Arbeitspreis)", PositionCategory::Discount)
604 } else {
605 ("Aufschlag (Arbeitspreis)", PositionCategory::Levy)
606 };
607 positions.push(
608 BillingPosition::debit(
609 label,
610 kwh,
611 "kWh",
612 aa_ct / dec!(100), cat,
614 )
615 .with_tag("auf_abschlag"),
616 );
617 }
618 if let Some(aa_month) = product
619 .auf_abschlag_eur_per_month
620 .filter(|v| *v != Decimal::ZERO)
621 {
622 let months_frac = ctx.billed_months();
623 let eur = crate::position::validated_eur(aa_month * months_frac);
624 let (label, cat) = if aa_month < Decimal::ZERO {
625 (
626 "Rabatt (monatlicher Festbetrag)",
627 PositionCategory::Discount,
628 )
629 } else {
630 ("Aufschlag (monatlicher Festbetrag)", PositionCategory::Levy)
631 };
632 positions.push(BillingPosition {
638 description: label.to_owned(),
639 legal_basis: None,
640 quantity: months_frac,
641 unit: "Monat".to_owned(),
642 unit_price_eur: aa_month,
643 net_eur: eur,
644 category: cat,
645 tags: vec!["auf_abschlag".to_owned()],
646 applicable_tax_rate: None,
647 trace: crate::position::PositionTrace::default(),
648 });
649 }
650
651 positions.extend(electricity_common_positions(ctx, product, &meter));
652
653 if let Some(rate) = product.mwst_rate_override {
657 for pos in &mut positions {
658 if pos.applicable_tax_rate.is_none()
659 && !matches!(
660 pos.category,
661 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
662 )
663 {
664 pos.applicable_tax_rate = Some(rate);
665 }
666 }
667 }
668
669 Ok(positions)
670 }
671}
672
673impl ElectricityProvider {
674 fn bill_prosumer(
679 &self,
680 ctx: &BillingContext,
681 prosumer: &crate::quantities::ProsumerMeterInput,
682 product: &ElectricityProduct,
683 grid: &GridInput,
684 rates: &crate::rates::RegulatoryRates,
685 seasonal_arbeitspreis: Option<Decimal>,
686 ) -> Result<Vec<BillingPosition>, EngineError> {
687 let mut positions: Vec<BillingPosition> = Vec::new();
688 let grid_kwh = prosumer.grid_consumption_kwh;
689 let self_kwh = prosumer.self_consumption_kwh;
690
691 if let Some(gp_ct_day) = product.grundpreis_ct_per_day {
694 positions.push(
695 grundpreis_position(
696 "Grundpreis",
697 gp_ct_day / dec!(100),
698 ctx.prorate_days().0 as i64,
699 "§41 EnWG",
700 &["strom"],
701 )
702 .with_tag("strom"),
703 );
704 }
705
706 if grid_kwh > Decimal::ZERO {
708 if let Some(ap_ct) = seasonal_arbeitspreis.or(product.arbeitspreis_ct_per_kwh) {
709 let label = if seasonal_arbeitspreis.is_some() {
710 "Arbeitspreis Strom Netzbezug (Saisontarif)".to_owned()
711 } else {
712 "Arbeitspreis Strom (Netzbezug)".to_owned()
713 };
714 positions.push(
715 arbeitspreis_position(label, grid_kwh, ap_ct, "kWh", "§41 EnWG", &["strom"])
716 .with_tag("strom"),
717 );
718 }
719 if let Some(nne_ap_ct) = grid.nne_arbeitspreis_ct_per_kwh {
721 positions.push(
722 BillingPosition::debit(
723 "Netznutzungsentgelt Arbeitspreis (Netzbezug)",
724 grid_kwh,
725 "kWh",
726 nne_ap_ct / dec!(100),
727 PositionCategory::GridCharge,
728 )
729 .with_legal_basis("StromNEV")
730 .with_tag("nne_arbeitspreis")
731 .with_tag("nne"),
732 );
733 }
734 let st_rate = rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override);
737 if st_rate > Decimal::ZERO {
738 positions.push(
739 levy_position(
740 "Stromsteuer (Netzbezug)",
741 grid_kwh,
742 "kWh",
743 st_rate,
744 "§3 StromStG",
745 "stromsteuer",
746 )
747 .with_tag("strom"),
748 );
749 }
750 }
751
752 if self_kwh > Decimal::ZERO {
754 let self_supply_pct = (prosumer.self_supply_ratio() * dec!(100)).round_kfm(1);
755 positions.push(BillingPosition {
756 description: format!(
757 "Eigenverbrauch PV: {self_kwh:.3}\u{202f}kWh (Selbstversorgungsgrad {self_supply_pct:.1}\u{202f}%)",
758 ),
759 legal_basis: Some(
764 "\u{a7} 9 Abs. 1 Nr. 3 StromStG (Stromsteuerfreiheit Eigenverbrauch, \
765 Anlage bis 2\u{202f}MW)"
766 .to_owned(),
767 ),
768 quantity: self_kwh,
769 unit: "kWh".to_owned(),
770 unit_price_eur: Decimal::ZERO,
771 net_eur: Decimal::ZERO,
772 category: PositionCategory::Info,
773 tags: vec!["eigenverbrauch".to_owned(), "prosumer".to_owned()],
774 applicable_tax_rate: None,
775 trace: crate::position::PositionTrace::default(),
776 });
777 }
778 if let Some(export) = prosumer.export_kwh.filter(|&e| e > Decimal::ZERO) {
779 positions.push(BillingPosition {
780 description: format!("Netzeinspeisung PV: {export:.3}\u{202f}kWh (Abrechnung via EEG-Vergütung separat)"),
781 legal_basis: Some("\u{a7}41 EnWG".to_owned()),
782 quantity: export,
783 unit: "kWh".to_owned(),
784 unit_price_eur: Decimal::ZERO,
785 net_eur: Decimal::ZERO,
786 category: PositionCategory::Info,
787 tags: vec!["einspeisung".to_owned(), "prosumer".to_owned()],
788 applicable_tax_rate: None,
789 trace: crate::position::PositionTrace::default(),
790 });
791 }
792
793 if let Some(rate) = product.mwst_rate_override {
795 for pos in &mut positions {
796 if pos.applicable_tax_rate.is_none()
797 && !matches!(
798 pos.category,
799 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
800 )
801 {
802 pos.applicable_tax_rate = Some(rate);
803 }
804 }
805 }
806 Ok(positions)
807 }
808}
809
810pub struct ControllableLoadProvider {
828 product: ControllableLoadProduct,
829 grid: GridInput,
830}
831
832impl ControllableLoadProvider {
833 #[must_use]
834 pub fn new(product: ControllableLoadProduct, grid: GridInput) -> Self {
835 Self { product, grid }
836 }
837
838 fn modul3_baender(&self) -> [(&'static str, Option<Decimal>); 3] {
840 [
841 ("HT", self.product.sect14a_modul3_nne_ht_ct_per_kwh),
842 ("ST", self.product.sect14a_modul3_nne_st_ct_per_kwh),
843 ("NT", self.product.sect14a_modul3_nne_nt_ct_per_kwh),
844 ]
845 }
846
847 fn modul3_konfiguriert(&self) -> bool {
856 self.modul3_baender().iter().any(|(_, ct)| ct.is_some())
857 }
858}
859
860impl BillingProvider for ControllableLoadProvider {
861 fn validate_warnings(
862 &self,
863 ctx: &BillingContext,
864 quantities: &Quantities,
865 ) -> Vec<BillingWarning> {
866 let base = ElectricityProvider::new(self.product.base.clone(), self.grid.clone());
868 let mut w = base.validate_warnings(ctx, quantities);
869
870 if self.product.sect14a_modul1_pauschale_eur_per_year.is_some()
876 && self
877 .product
878 .sect14a_modul2_nne_reduktion_ct_per_kwh
879 .is_some()
880 {
881 w.push(BillingWarning {
882 code: "MODUL1_AND_MODUL2",
883 severity: WarningSeverity::Error,
884 message: "§14a EnWG Modul 1 (pauschale Reduzierung) and Modul 2 \
885 (prozentuale Arbeitspreisreduzierung) are both configured — \
886 BK8-22/010-A offers them as alternative base modules, so the \
887 Anschlussnutzer holds one. Billing both grants the same \
888 Steuerbarkeit two reductions."
889 .to_owned(),
890 });
891 }
892
893 if self
894 .product
895 .sect14a_modul2_nne_reduktion_ct_per_kwh
896 .is_some()
897 && self.modul3_konfiguriert()
898 {
899 w.push(BillingWarning {
900 code: "MODUL2_AND_MODUL3",
901 severity: WarningSeverity::Error,
902 message: "§14a EnWG Modul 2 (prozentuale Arbeitspreisreduzierung) and \
903 Modul 3 (zeitvariable Netzentgelte) are both configured — \
904 BK8-22/010-A makes them mutually exclusive; both would reduce \
905 the same network usage twice"
906 .to_owned(),
907 });
908 }
909
910 if self.modul3_konfiguriert() && self.grid.nne_arbeitspreis_ct_per_kwh.is_some() {
913 w.push(BillingWarning {
914 code: "MODUL3_AND_FLAT_NNE",
915 severity: WarningSeverity::Error,
916 message: "§14a Modul 3 band rates are set alongside a flat NNE \
917 Arbeitspreis — the bands replace it; billing both charges \
918 the network usage twice"
919 .to_owned(),
920 });
921 }
922
923 if self.modul3_konfiguriert()
928 && self.product.sect14a_modul1_pauschale_eur_per_year.is_none()
929 {
930 w.push(BillingWarning {
931 code: "MODUL3_OHNE_MODUL1",
932 severity: WarningSeverity::Error,
933 message: "§14a EnWG Modul 3 (zeitvariable Netzentgelte) is configured \
934 without Modul 1 — BK8-22/010-A offers Modul 3 only in combination \
935 with Modul 1, so this prices a tariff the Netzbetreiber does \
936 not offer and drops the Modul 1 reduction the customer is due"
937 .to_owned(),
938 });
939 }
940
941 if self.modul3_konfiguriert()
946 && quantities
947 .electricity
948 .as_ref()
949 .is_some_and(|m| m.metering_mode != crate::quantities::MeteringMode::Imsys)
950 {
951 w.push(BillingWarning {
952 code: "MODUL3_IMSYS_REQUIRED",
953 severity: WarningSeverity::Error,
954 message: "§14a EnWG Modul 3 requires an intelligentes Messsystem \
955 (BK8-22/010-A) — the metering point reports SLP or RLM. The \
956 time bands cannot be measured, so the reduction cannot be \
957 billed against them."
958 .to_owned(),
959 });
960 }
961
962 if self.modul3_konfiguriert() {
970 let fehlend: Vec<&str> = self
971 .modul3_baender()
972 .iter()
973 .filter(|(_, ct)| ct.is_none())
974 .map(|(label, _)| *label)
975 .collect();
976 if !fehlend.is_empty() {
977 w.push(BillingWarning {
978 code: "MODUL3_BAND_UNVOLLSTAENDIG",
979 severity: WarningSeverity::Error,
980 message: format!(
981 "§14a EnWG Modul 3 is configured but the Tarifstufe(n) {} carry no \
982 rate — the bands are one tariff and replace the flat NNE \
983 Arbeitspreis, so the unpriced band's kWh would carry no network \
984 charge at all. Price every band, or 0.0 where the Netzbetreiber \
985 charges nothing.",
986 fehlend.join(", ")
987 ),
988 });
989 }
990 }
991
992 if quantities.electricity.as_ref().is_some_and(|m| {
997 m.steuerung_stunden.is_some_and(|h| h > Decimal::ZERO) && m.spitzenleistung_kw.is_none()
998 }) && (self
999 .product
1000 .sect14a_steuerungsentschaedigung_eur_per_kw_year
1001 .is_some()
1002 || self
1003 .product
1004 .sect14a_steuerungsentschaedigung_ct_per_kwh
1005 .is_some())
1006 {
1007 w.push(BillingWarning {
1008 code: "STEUERUNGSENTSCHAEDIGUNG_OHNE_SPITZENLEISTUNG",
1009 severity: WarningSeverity::Error,
1010 message: "§14a EnWG Steuerungsentschädigung is configured and the meter \
1011 reports dimming hours, but no Spitzenleistung — both rate bases \
1012 price the dimmed capacity, so the compensation would silently \
1013 not be credited. Supply spitzenleistung_kw."
1014 .to_owned(),
1015 });
1016 }
1017
1018 if self
1022 .product
1023 .sect14a_steuerungsentschaedigung_eur_per_kw_year
1024 .is_some()
1025 && self
1026 .product
1027 .sect14a_steuerungsentschaedigung_ct_per_kwh
1028 .is_some()
1029 {
1030 w.push(BillingWarning {
1031 code: "STEUERUNGSENTSCHAEDIGUNG_DOPPELT",
1032 severity: WarningSeverity::Error,
1033 message: "§14a EnWG Steuerungsentschädigung is configured both per \
1034 kW/Jahr and per kWh — the two are alternative rate bases \
1035 for the same compensation; billing both pays it twice"
1036 .to_owned(),
1037 });
1038 }
1039
1040 w
1041 }
1042
1043 fn bill(
1044 &self,
1045 ctx: &BillingContext,
1046 quantities: &Quantities,
1047 prior: &[BillingPosition],
1048 ) -> Result<Vec<BillingPosition>, EngineError> {
1049 let ep = ElectricityProvider::new(self.product.base.clone(), self.grid.clone());
1051 let mut positions = ep.bill(ctx, quantities, prior)?;
1052
1053 let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
1055 let kwh = meter.arbeitsmenge_kwh;
1056 let p = &self.product;
1057
1058 if let (Some(ht), Some(st), Some(nt)) = (
1065 p.sect14a_modul3_nne_ht_ct_per_kwh,
1066 p.sect14a_modul3_nne_st_ct_per_kwh,
1067 p.sect14a_modul3_nne_nt_ct_per_kwh,
1068 ) {
1069 let verbrauch = quantities.sect14a_modul3.unwrap_or_default();
1070 for (label, band_kwh, rate_ct) in [
1071 ("Netzentgelt §14a Modul 3 HT", verbrauch.ht_kwh, ht),
1072 ("Netzentgelt §14a Modul 3 ST", verbrauch.st_kwh, st),
1073 ("Netzentgelt §14a Modul 3 NT", verbrauch.nt_kwh, nt),
1074 ] {
1075 let mut pos = BillingPosition::debit(
1076 label,
1077 band_kwh,
1078 "kWh",
1079 rate_ct / dec!(100),
1080 PositionCategory::GridCharge,
1081 );
1082 pos.trace = crate::position::PositionTrace::commodity(
1083 band_kwh,
1084 "kWh",
1085 rate_ct / dec!(100),
1086 "§14a EnWG, BK8-22/010-A Tenor 3.",
1087 );
1088 positions.push(
1089 pos.with_legal_basis("§14a EnWG")
1090 .with_tag("§14a")
1091 .with_tag("modul3")
1092 .with_tag("nne"),
1093 );
1094 }
1095 }
1096
1097 if let Some(sect14a_m1_ct) = p.sect14a_modul2_nne_reduktion_ct_per_kwh
1099 && sect14a_m1_ct > Decimal::ZERO
1100 && kwh > Decimal::ZERO
1101 {
1102 positions.push(
1103 BillingPosition::credit(
1104 "§14a EnWG Modul 2 — Arbeitspreisreduzierung",
1105 kwh,
1106 "kWh",
1107 sect14a_m1_ct / dec!(100),
1108 PositionCategory::Credit,
1109 )
1110 .with_legal_basis("§14a EnWG")
1111 .with_tag("§14a")
1112 .with_tag("sect14a_modul2"),
1113 );
1114 }
1115
1116 if let Some(m1_year) = p.sect14a_modul1_pauschale_eur_per_year
1121 && m1_year > Decimal::ZERO
1122 {
1123 positions.push(
1124 BillingPosition::credit(
1125 "§14a EnWG Modul 1 — pauschale Reduzierung",
1126 Decimal::ONE,
1127 "Jahr",
1128 m1_year * ctx.billed_years(),
1129 PositionCategory::Credit,
1130 )
1131 .with_legal_basis("§14a EnWG")
1132 .with_tag("§14a")
1133 .with_tag("sect14a_modul1"),
1134 );
1135 }
1136
1137 if let (Some(m3_year), Some(kw), Some(steuerung_h)) = (
1139 p.sect14a_steuerungsentschaedigung_eur_per_kw_year,
1140 meter.spitzenleistung_kw,
1141 meter.steuerung_stunden,
1142 ) && m3_year > Decimal::ZERO
1143 && kw > Decimal::ZERO
1144 && steuerung_h > Decimal::ZERO
1145 {
1146 positions.push(
1147 BillingPosition::credit(
1148 "§14a EnWG Steuerungsentschädigung",
1149 kw,
1150 "kW",
1151 m3_year * (steuerung_h / dec!(8760)),
1152 PositionCategory::Credit,
1153 )
1154 .with_legal_basis("§14a EnWG")
1155 .with_tag("§14a")
1156 .with_tag("sect14a_steuerungsentschaedigung"),
1157 );
1158 }
1159
1160 if let (Some(modul3_ct), Some(steuerung_h)) = (
1162 p.sect14a_steuerungsentschaedigung_ct_per_kwh,
1163 meter.steuerung_stunden,
1164 ) {
1165 let kw = meter.spitzenleistung_kw.unwrap_or_default();
1169 if modul3_ct > Decimal::ZERO && steuerung_h > Decimal::ZERO && kw > Decimal::ZERO {
1170 let steuerung_kwh = kw * steuerung_h;
1171 positions.push(
1172 BillingPosition::credit(
1173 "§14a EnWG Steuerungsentschädigung",
1174 steuerung_kwh,
1175 "kWh",
1176 modul3_ct / dec!(100),
1177 PositionCategory::Credit,
1178 )
1179 .with_legal_basis("§14a EnWG")
1180 .with_tag("§14a")
1181 .with_tag("sect14a_steuerungsentschaedigung"),
1182 );
1183 }
1184 }
1185
1186 Ok(positions)
1187 }
1188}
1189
1190pub struct GasProvider {
1197 product: GasProduct,
1198 grid: GridInput,
1199}
1200
1201impl GasProvider {
1202 pub fn new(product: GasProduct, grid: GridInput) -> Self {
1203 Self { product, grid }
1204 }
1205 pub fn from_product(product: &crate::tariff::Product, grid: GridInput) -> Self {
1206 match product {
1207 crate::tariff::Product::Gas(p) => Self::new(p.clone(), grid),
1208 other => panic!(
1209 "GasProvider::from_product: got '{}', expected Gas",
1210 other.category_str()
1211 ),
1212 }
1213 }
1214}
1215
1216impl BillingProvider for GasProvider {
1217 fn validate_warnings(
1218 &self,
1219 ctx: &BillingContext,
1220 quantities: &Quantities,
1221 ) -> Vec<BillingWarning> {
1222 let mut w = Vec::new();
1223
1224 let has_gas_work_price = self.product.gas_arbeitspreis_ct_per_kwh_hs.is_some()
1228 || self
1229 .product
1230 .gas_indexed_price
1231 .as_ref()
1232 .is_some_and(|i| i.effective_ct_per_kwh().is_some())
1233 || self
1234 .product
1235 .seasonal_prices
1236 .as_ref()
1237 .is_some_and(|s| !s.is_empty());
1238 if !has_gas_work_price {
1239 w.push(BillingWarning {
1240 code: "KEIN_ARBEITSPREIS",
1241 severity: WarningSeverity::Error,
1242 message: "the gas product carries no Arbeitspreis in any form (kWh_Hs, \
1243 indexed or seasonal) — the invoice would charge the Energiesteuer \
1244 and the BEHG levy and nothing for the gas. Check the productd \
1245 product's price positions."
1246 .to_owned(),
1247 });
1248 }
1249 w.extend(indexwert_warning(
1250 self.product.gas_indexed_price.as_ref(),
1251 has_gas_work_price,
1252 ));
1253
1254 if quantities.gas.as_ref().is_some_and(|m| m.is_estimated) {
1258 w.push(BillingWarning {
1259 code: "ESTIMATED_READING",
1260 severity: WarningSeverity::Warning,
1261 message: "billed on an estimated gas reading (§ 40a Abs. 2 EnWG) — \
1262 expect a correction when the real reading arrives"
1263 .to_owned(),
1264 });
1265 }
1266 if quantities
1270 .gas
1271 .as_ref()
1272 .is_some_and(|m| m.kwh_hs.is_none() && m.zustandszahl.is_none())
1273 {
1274 w.push(BillingWarning {
1275 code: "ZUSTANDSZAHL_FEHLT",
1276 severity: WarningSeverity::Warning,
1277 message: "keine Zustandszahl übergeben — die Mengenumwertung rechnet mit \
1278 z = 1,0 (§25 Nr. 4 MessEV, DVGW G 685); reale Werte liegen bei \
1279 etwa 0,95, die Abrechnung überschätzt kWh_Hs entsprechend"
1280 .to_owned(),
1281 });
1282 }
1283 if crate::rates::mwst_rate_for_gas_waerme_period(ctx.period_from(), ctx.period_to())
1287 .is_none()
1288 {
1289 w.push(BillingWarning {
1290 code: "MWST_STICHTAG_IM_ZEITRAUM",
1291 severity: WarningSeverity::Warning,
1292 message: "Abrechnungszeitraum überschreitet eine USt-Satzgrenze für Gas \
1293 (§28 Abs. 5 UStG) — am Stichtag splitten und Teilrechnungen \
1294 zusammenführen"
1295 .to_owned(),
1296 });
1297 }
1298 if ctx.period_from().year() != ctx.period_to().year()
1302 && crate::rates::behg_ct_per_kwh_for_year(ctx.period_from().year())
1303 != crate::rates::behg_ct_per_kwh_for_year(ctx.period_to().year())
1304 {
1305 w.push(BillingWarning {
1306 code: "BEHG_JAHRESGRENZE_IM_ZEITRAUM",
1307 severity: WarningSeverity::Warning,
1308 message: "Abrechnungszeitraum überschreitet eine BEHG-Jahresgrenze \
1309 (§10 BEHG, CO₂-Preis steigt zum Jahreswechsel) — am 31.12. \
1310 splitten und je Teilzeitraum den Jahressatz anwenden"
1311 .to_owned(),
1312 });
1313 }
1314 w
1315 }
1316
1317 fn bill(
1318 &self,
1319 ctx: &BillingContext,
1320 quantities: &Quantities,
1321 _prior: &[BillingPosition],
1322 ) -> Result<Vec<BillingPosition>, EngineError> {
1323 let meter = quantities.gas.as_ref().cloned().unwrap_or_default();
1324 let product = &self.product;
1325 let grid = &self.grid;
1326 let rates = &ctx.regulatory_rates;
1327
1328 let billing_month = ctx.period_from().month() as u8;
1330 let seasonal_gas_ap = product.seasonal_prices.as_ref().and_then(|seasons| {
1331 seasons
1332 .iter()
1333 .find(|s| s.contains_month(billing_month))
1334 .and_then(|s| s.gas_arbeitspreis_ct_per_kwh_hs)
1335 });
1336
1337 let kwh_hs = if let Some(kwh) = meter.kwh_hs {
1339 kwh
1340 } else {
1341 let hs = meter.brennwert_kwh_per_qm3.unwrap_or(dec!(10.55));
1342 let z = meter.zustandszahl.unwrap_or(dec!(1.0));
1343 (meter.messung_qm3 * hs * z).round_kfm(3)
1344 };
1345
1346 let mut positions: Vec<BillingPosition> = Vec::new();
1347
1348 if meter.kwh_hs.is_none() && meter.brennwert_kwh_per_qm3.is_some() {
1350 let hs = meter.brennwert_kwh_per_qm3.unwrap_or(dec!(10.55));
1351 let z = meter.zustandszahl.unwrap_or(dec!(1.0));
1352 positions.push(BillingPosition {
1353 description: format!(
1354 "Brennwertkorrektur: {:.4} kWh/m³ × {:.4} = {:.3} kWh_Hs",
1355 hs, z, kwh_hs
1356 ),
1357 legal_basis: Some("§25 Nr. 4 MessEV / DVGW G 685".to_owned()),
1358 quantity: meter.messung_qm3,
1359 unit: "m³".to_owned(),
1360 unit_price_eur: Decimal::ZERO,
1361 net_eur: Decimal::ZERO,
1362 category: PositionCategory::Info,
1363 tags: vec!["brennwertkorrektur".to_owned(), "info".to_owned()],
1364 applicable_tax_rate: None,
1365 trace: crate::position::PositionTrace::default(),
1366 });
1367 }
1368
1369 if let Some(ref gq) = meter.gasqualitaet {
1374 positions.push(BillingPosition {
1375 description: format!("Gasqualität: {gq} (§ DVGW G 260)"),
1376 legal_basis: Some(gq.clone()),
1378 quantity: Decimal::ZERO,
1379 unit: "".to_owned(),
1380 unit_price_eur: Decimal::ZERO,
1381 net_eur: Decimal::ZERO,
1382 category: PositionCategory::Info,
1383 tags: vec!["gasqualitaet".to_owned(), "info".to_owned()],
1384 applicable_tax_rate: None,
1385 trace: crate::position::PositionTrace::default(),
1386 });
1387 }
1388
1389 if let Some(gp_ct_day) = product.gas_grundpreis_ct_per_day {
1391 positions.push(
1392 grundpreis_position(
1393 "Grundpreis Gas",
1394 gp_ct_day / dec!(100),
1395 ctx.prorate_days().0 as i64,
1396 "§41 EnWG",
1397 &["gas"],
1398 )
1399 .with_tag("gas"),
1400 );
1401 }
1402
1403 if let Some(nne_gp) = grid.gas_nne_grundpreis_eur_per_year {
1410 let daily = nne_gp / Decimal::from(time::util::days_in_year(ctx.period_from().year()));
1413 positions.push(
1415 BillingPosition::debit(
1416 "Gasnetznutzungsentgelt Grundpreis",
1417 Decimal::from(ctx.prorate_days().0),
1418 "Tage",
1419 daily,
1420 PositionCategory::GridCharge,
1421 )
1422 .with_legal_basis("GasNEV")
1423 .with_tag("gas_nne_grundpreis")
1424 .with_tag("nne"),
1425 );
1426 }
1427
1428 if kwh_hs > Decimal::ZERO {
1430 let active_indexed = product.gas_indexed_price.as_ref();
1432 let gas_ap_ct = if let Some(idx) = active_indexed {
1433 idx.effective_ct_per_kwh()
1435 .or(seasonal_gas_ap)
1436 .or(product.gas_arbeitspreis_ct_per_kwh_hs)
1437 } else {
1438 seasonal_gas_ap.or(product.gas_arbeitspreis_ct_per_kwh_hs)
1439 };
1440 if let Some(ap_ct) = gas_ap_ct {
1441 let (label, legal_basis) = if active_indexed.is_some() {
1442 (
1443 active_indexed
1444 .and_then(|idx| {
1445 if idx.index_value.is_some() {
1446 Some(idx.position_description())
1447 } else {
1448 None
1449 }
1450 })
1451 .unwrap_or_else(|| "Arbeitspreis Gas".to_owned()),
1452 "§41 EnWG",
1453 )
1454 } else if seasonal_gas_ap.is_some() {
1455 let season_label = product
1456 .seasonal_prices
1457 .as_ref()
1458 .and_then(|s| s.iter().find(|p| p.contains_month(billing_month)))
1459 .and_then(|s| s.label.as_deref())
1460 .map(|l| format!("Arbeitspreis Gas ({l})"))
1461 .unwrap_or_else(|| "Arbeitspreis Gas (Saisontarif)".to_owned());
1462 (season_label, "§41 EnWG")
1463 } else {
1464 ("Arbeitspreis Gas".to_owned(), "§41 EnWG")
1465 };
1466 positions.push(
1467 arbeitspreis_position(label, kwh_hs, ap_ct, "kWh_Hs", legal_basis, &["gas"])
1468 .with_tag("gas")
1469 .with_tag(if active_indexed.is_some() {
1470 "indexed_price"
1471 } else if seasonal_gas_ap.is_some() {
1472 "seasonal"
1473 } else {
1474 "gas"
1475 }),
1476 );
1477 }
1478
1479 if let (Some(lp_ct_per_kw_month), Some(kw)) = (
1486 product.gas_leistungspreis_ct_per_kw_month,
1487 meter.spitzenleistung_kw.filter(|kw| *kw > Decimal::ZERO),
1488 ) {
1489 let months_frac = ctx.billed_months();
1490 positions.push(
1491 BillingPosition::debit(
1492 "Leistungspreis Gas",
1493 kw,
1494 "kW",
1495 lp_ct_per_kw_month / dec!(100) * months_frac,
1496 PositionCategory::Commodity,
1497 )
1498 .with_legal_basis("§41 EnWG")
1499 .with_tag("gas_leistungspreis")
1500 .with_tag("gas")
1501 .with_tag("rlm"),
1502 );
1503 }
1504
1505 if let Some(nne_ap_ct) = grid.gas_nne_arbeitspreis_ct_per_kwh {
1509 positions.push(
1510 BillingPosition::debit(
1511 "Gasnetznutzungsentgelt Arbeitspreis",
1512 kwh_hs,
1513 "kWh_Hs",
1514 nne_ap_ct / dec!(100),
1515 PositionCategory::GridCharge,
1516 )
1517 .with_legal_basis("GasNEV")
1518 .with_tag("gas_nne_arbeitspreis")
1519 .with_tag("nne"),
1520 );
1521 }
1522 if let Some(ka_ct) = grid.gas_ka_ct_per_kwh {
1523 positions.push(
1524 BillingPosition::debit(
1525 "Konzessionsabgabe Gas",
1526 kwh_hs,
1527 "kWh_Hs",
1528 ka_ct / dec!(100),
1529 PositionCategory::GridCharge,
1530 )
1531 .with_legal_basis("KAV §2")
1532 .with_tag("gas_konzessionsabgabe")
1533 .with_tag("nne"),
1534 );
1535 }
1536 if let Some(bilu_ct) = grid.gas_bilanzierungsumlage_ct_per_kwh {
1537 positions.push(
1538 BillingPosition::debit(
1539 "Bilanzierungsumlage Gas",
1540 kwh_hs,
1541 "kWh_Hs",
1542 bilu_ct / dec!(100),
1543 PositionCategory::GridCharge,
1544 )
1545 .with_legal_basis("GaBi Gas 2.1 (BK7-24-01-008)")
1546 .with_tag("gas_bilanzierungsumlage")
1547 .with_tag("nne"),
1548 );
1549 }
1550
1551 positions.extend(energiesteuer_positions(
1553 product.energiesteuer_tarif,
1554 kwh_hs,
1555 rates.effective_energiesteuer_gas(product.energiesteuer_gas_ct_per_kwh_override),
1556 ));
1557
1558 let behg_rate = rates.effective_behg_gas(product.behg_gas_ct_per_kwh_override);
1560 if behg_rate > Decimal::ZERO {
1561 positions.push(
1562 levy_position(
1563 "CO₂-Abgabe BEHG",
1564 kwh_hs,
1565 "kWh_Hs",
1566 behg_rate,
1567 "BEHG",
1568 "behg",
1569 )
1570 .with_tag("gas"),
1571 );
1572 if let Some(faktor) =
1576 crate::rates::erdgas_emissionsfaktor_kg_per_kwh(ctx.period_from().year())
1577 {
1578 positions.extend(crate::position::co2kostaufg_disclosures(
1579 kwh_hs, "kWh_Hs", faktor, "gas",
1580 ));
1581 }
1582 }
1583 }
1584
1585 if let Some(aa_ct) = product
1587 .auf_abschlag_ct_per_kwh
1588 .filter(|v| *v != Decimal::ZERO)
1589 {
1590 let kwh_total = meter.kwh_hs.unwrap_or_else(|| {
1591 let bw = meter.brennwert_kwh_per_qm3.unwrap_or(dec!(10.55));
1594 let zz = meter.zustandszahl.unwrap_or(dec!(1.0));
1595 meter.messung_qm3 * bw * zz
1596 });
1597 if kwh_total > Decimal::ZERO {
1598 let (label, cat) = if aa_ct < Decimal::ZERO {
1599 ("Rabatt Gas (Arbeitspreis)", PositionCategory::Discount)
1600 } else {
1601 ("Aufschlag Gas (Arbeitspreis)", PositionCategory::Levy)
1602 };
1603 positions.push(
1604 BillingPosition::debit(label, kwh_total, "kWh", aa_ct / dec!(100), cat)
1605 .with_tag("auf_abschlag")
1606 .with_tag("gas"),
1607 );
1608 }
1609 }
1610 if let Some(aa_month) = product
1611 .auf_abschlag_eur_per_month
1612 .filter(|v| *v != Decimal::ZERO)
1613 {
1614 let months_frac = ctx.billed_months();
1615 let (label, cat) = if aa_month < Decimal::ZERO {
1616 ("Rabatt Gas (Festbetrag)", PositionCategory::Discount)
1617 } else {
1618 ("Aufschlag Gas (Festbetrag)", PositionCategory::Levy)
1619 };
1620 positions.push(BillingPosition {
1621 description: label.to_owned(),
1622 legal_basis: None,
1623 quantity: months_frac,
1624 unit: "Monat".to_owned(),
1625 unit_price_eur: aa_month,
1626 net_eur: crate::position::validated_eur(aa_month * months_frac),
1627 category: cat,
1628 tags: vec!["auf_abschlag".to_owned(), "gas".to_owned()],
1629 applicable_tax_rate: None,
1630 trace: crate::position::PositionTrace::default(),
1631 });
1632 }
1633
1634 if meter.zaehlerstand_von.is_some() || meter.zaehlerstand_bis.is_some() {
1638 let label = format!(
1640 "Zählerstand: {} – {} m³{}",
1641 meter
1642 .zaehlerstand_von
1643 .map(|v| v.to_string())
1644 .unwrap_or_else(|| "-".to_owned()),
1645 meter
1646 .zaehlerstand_bis
1647 .map(|v| v.to_string())
1648 .unwrap_or_else(|| "-".to_owned()),
1649 meter
1650 .ablesungsart
1651 .label()
1652 .map(|l| format!(" ({l})"))
1653 .unwrap_or_default(),
1654 );
1655 let zid = meter
1656 .zaehlernummer
1657 .as_deref()
1658 .or(ctx.zaehler_id.as_deref())
1659 .unwrap_or("-");
1660 positions.push(BillingPosition {
1661 description: label,
1662 legal_basis: Some("§40 Abs. 2 Nr. 6 EnWG".to_owned()),
1663 quantity: Decimal::ZERO,
1664 unit: "m³".to_owned(),
1665 unit_price_eur: Decimal::ZERO,
1666 net_eur: Decimal::ZERO,
1667 category: PositionCategory::Info,
1668 tags: vec!["zaehlerstand".to_owned(), zid.to_owned()],
1669 applicable_tax_rate: None,
1670 trace: crate::position::PositionTrace::default(),
1671 });
1672 }
1673
1674 if meter.is_estimated {
1677 positions.push(BillingPosition {
1678 description: "Abrechnungswert: Schätzung gemäß § 40a Abs. 2 EnWG — \
1679 auf Wunsch Korrektur nach realer Ablesung"
1680 .to_owned(),
1681 legal_basis: Some("§40a EnWG".to_owned()),
1682 quantity: Decimal::ZERO,
1683 unit: String::new(),
1684 unit_price_eur: Decimal::ZERO,
1685 net_eur: Decimal::ZERO,
1686 category: PositionCategory::Info,
1687 tags: vec!["schatzwert".to_owned(), "ersatzwert".to_owned()],
1688 applicable_tax_rate: None,
1689 trace: crate::position::PositionTrace::default(),
1690 });
1691 }
1692
1693 let hinweise = entlastungs_hinweise(&product.steuerentlastungen, &positions);
1696 positions.extend(hinweise);
1697
1698 if let Some(rate) = product.mwst_rate_override {
1702 for pos in &mut positions {
1703 if pos.applicable_tax_rate.is_none()
1704 && !matches!(
1705 pos.category,
1706 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
1707 )
1708 {
1709 pos.applicable_tax_rate = Some(rate);
1710 }
1711 }
1712 }
1713 Ok(positions)
1714 }
1715}
1716
1717pub struct HeatProvider {
1721 product: HeatProduct,
1722}
1723
1724impl HeatProvider {
1725 pub fn new(product: HeatProduct) -> Self {
1726 Self { product }
1727 }
1728 pub fn from_product(product: &crate::tariff::Product) -> Self {
1729 match product {
1730 crate::tariff::Product::Waerme(p) => Self::new(p.clone()),
1731 other => panic!(
1732 "HeatProvider::from_product: got '{}', expected Waerme",
1733 other.category_str()
1734 ),
1735 }
1736 }
1737}
1738
1739impl BillingProvider for HeatProvider {
1740 fn validate_warnings(
1741 &self,
1742 ctx: &BillingContext,
1743 _quantities: &Quantities,
1744 ) -> Vec<BillingWarning> {
1745 let mut w = Vec::new();
1746
1747 let has_heat_work_price = self.product.waerme_arbeitspreis_ct_per_kwh.is_some()
1751 || self
1752 .product
1753 .waerme_indexed_price
1754 .as_ref()
1755 .is_some_and(|i| i.effective_ct_per_kwh().is_some());
1756 if !has_heat_work_price {
1757 w.push(BillingWarning {
1758 code: "KEIN_ARBEITSPREIS",
1759 severity: WarningSeverity::Error,
1760 message: "the Fernwärme product carries no Arbeitspreis — the invoice would \
1761 bill the standing and demand charges and nothing for the delivered \
1762 heat. Check the productd product's price positions."
1763 .to_owned(),
1764 });
1765 }
1766 w.extend(indexwert_warning(
1771 self.product.waerme_indexed_price.as_ref(),
1772 self.product.waerme_arbeitspreis_ct_per_kwh.is_some(),
1773 ));
1774
1775 if crate::rates::mwst_rate_for_gas_waerme_period(ctx.period_from(), ctx.period_to())
1778 .is_none()
1779 {
1780 w.push(BillingWarning {
1781 code: "MWST_STICHTAG_IM_ZEITRAUM",
1782 severity: WarningSeverity::Warning,
1783 message: "Abrechnungszeitraum überschreitet eine USt-Satzgrenze für \
1784 Fernwärme (§28 Abs. 6 UStG) — am Stichtag splitten und \
1785 Teilrechnungen zusammenführen"
1786 .to_owned(),
1787 });
1788 }
1789 w
1790 }
1791
1792 fn bill(
1793 &self,
1794 ctx: &BillingContext,
1795 quantities: &Quantities,
1796 _prior: &[BillingPosition],
1797 ) -> Result<Vec<BillingPosition>, EngineError> {
1798 let meter = quantities.heat.as_ref().cloned().unwrap_or_default();
1799 let product = &self.product;
1800 let mut positions: Vec<BillingPosition> = Vec::new();
1801 let months = meter.months.unwrap_or_else(|| ctx.billed_months());
1807
1808 if let Some(gp) = product.waerme_grundpreis_eur_per_month {
1809 positions.push(
1810 BillingPosition::debit(
1811 "Grundpreis Fernwärme",
1812 months,
1813 "Monate",
1814 gp,
1815 PositionCategory::Commodity,
1816 )
1817 .with_tag("commodity")
1818 .with_tag("waerme"),
1819 );
1820 }
1821 if let (Some(lp), Some(kw)) = (
1822 product.waerme_leistungspreis_eur_per_kw_year.or_else(|| {
1823 product
1824 .waerme_leistungspreis_eur_per_kw_month
1825 .map(|m| m * dec!(12))
1826 }),
1827 meter.spitzenleistung_kw,
1828 ) {
1829 positions.push(
1830 BillingPosition::debit(
1831 "Leistungspreis Fernwärme",
1832 kw,
1833 "kW",
1834 lp / dec!(12) * months,
1835 PositionCategory::Commodity,
1836 )
1837 .with_tag("commodity")
1838 .with_tag("waerme"),
1839 );
1840 }
1841 let (waerme_ap_ct, ap_basis) = match product
1844 .waerme_indexed_price
1845 .as_ref()
1846 .and_then(|idx| idx.effective_ct_per_kwh())
1847 {
1848 Some(idx_ct) => (Some(idx_ct), "AVBFernwärmeV §24 Abs. 4"),
1849 None => (product.waerme_arbeitspreis_ct_per_kwh, "§41 EnWG"),
1850 };
1851 if let Some(ap_ct) = waerme_ap_ct
1852 && meter.kwh_waerme > Decimal::ZERO
1853 {
1854 positions.push(
1855 arbeitspreis_position(
1856 "Arbeitspreis Fernwärme",
1857 meter.kwh_waerme,
1858 ap_ct,
1859 "kWh_th",
1860 ap_basis,
1861 &["waerme"],
1862 )
1863 .with_tag("waerme"),
1864 );
1865 }
1866 if let Some(co2_ct) = product.waerme_co2_kosten_ct_per_kwh
1874 && meter.kwh_waerme > Decimal::ZERO
1875 && co2_ct > Decimal::ZERO
1876 {
1877 positions.push(
1878 levy_position(
1879 "CO₂-Kosten (BEHG)",
1880 meter.kwh_waerme,
1881 "kWh_th",
1882 co2_ct,
1883 "CO2KostAufG § 3",
1884 "behg",
1885 )
1886 .with_tag("waerme"),
1887 );
1888 }
1889 if let Some(g_per_kwh) = product.waerme_co2_emission_g_per_kwh {
1898 positions.extend(crate::position::co2kostaufg_disclosures(
1899 meter.kwh_waerme,
1900 "kWh_th",
1901 g_per_kwh / dec!(1000),
1902 "waerme",
1903 ));
1904 }
1905 if let Some(pct) = product.waerme_erneuerbar_anteil_pct {
1907 positions.push(BillingPosition {
1908 description: format!(
1909 "Anteil erneuerbarer Energien an der Wärmelieferung: {pct}\u{202f}%"
1910 ),
1911 legal_basis: Some("§ 14 WPG".to_owned()),
1912 quantity: pct,
1913 unit: "%".to_owned(),
1914 unit_price_eur: Decimal::ZERO,
1915 net_eur: Decimal::ZERO,
1916 category: PositionCategory::Info,
1917 tags: vec!["erneuerbar_anteil".to_owned(), "waerme".to_owned()],
1918 applicable_tax_rate: None,
1919 trace: crate::position::PositionTrace::default(),
1920 });
1921 }
1922
1923 if let Some(rate) = product.mwst_rate_override {
1930 for pos in &mut positions {
1931 if pos.applicable_tax_rate.is_none()
1932 && !matches!(
1933 pos.category,
1934 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
1935 )
1936 {
1937 pos.applicable_tax_rate = Some(rate);
1938 }
1939 }
1940 }
1941 Ok(positions)
1942 }
1943}
1944
1945pub struct WaterProvider {
1963 product: WaterProduct,
1964}
1965
1966impl WaterProvider {
1967 pub fn new(product: WaterProduct) -> Self {
1968 Self { product }
1969 }
1970}
1971
1972impl BillingProvider for WaterProvider {
1973 fn validate_warnings(
1974 &self,
1975 _ctx: &BillingContext,
1976 quantities: &Quantities,
1977 ) -> Vec<BillingWarning> {
1978 let mut w = Vec::new();
1979 let meter = quantities.wasser.clone().unwrap_or_default();
1980 let p = &self.product;
1981
1982 if meter.frischwasser_m3 > Decimal::ZERO
1989 && p.wasser_mengenpreis_eur_per_m3.is_none()
1990 && p.wasser_grundpreis_eur_per_month.is_none()
1991 {
1992 w.push(BillingWarning {
1993 code: "KEIN_TRINKWASSERPREIS",
1994 severity: WarningSeverity::Error,
1995 message: format!(
1996 "der Wassertarif nennt weder Grund- noch Mengenpreis für Trinkwasser, \
1997 es wurden aber {} m³ geliefert — die Rechnung enthielte allein die \
1998 Abwassergebühr und nichts für das Wasser. Preispositionen des \
1999 productd-Produkts prüfen.",
2000 meter.frischwasser_m3
2001 ),
2002 });
2003 }
2004
2005 if !meter.absetzungen.is_empty() && p.schmutzwasser_eur_per_m3.is_none() {
2006 w.push(BillingWarning {
2007 code: "ABSETZUNG_OHNE_SCHMUTZWASSERPREIS",
2008 severity: WarningSeverity::Warning,
2009 message: "Absetzungen übermittelt, aber kein Schmutzwasserpreis im Tarif — \
2010 die Absetzung hat keine Wirkung"
2011 .to_owned(),
2012 });
2013 }
2014 if p.niederschlagswasser_eur_per_m2_year.is_some() && meter.versiegelte_flaeche_m2.is_none()
2015 {
2016 w.push(BillingWarning {
2017 code: "NIEDERSCHLAGSWASSER_OHNE_FLAECHE",
2018 severity: WarningSeverity::Warning,
2019 message: "Niederschlagswasserpreis im Tarif, aber keine versiegelte Fläche \
2020 übermittelt — gesplittete Abwassergebühr unvollständig"
2021 .to_owned(),
2022 });
2023 }
2024 let has_public_law_fee = p.abwasser_regime == AbwasserRegime::PublicLawFee
2031 && (p.schmutzwasser_eur_per_m3.is_some()
2032 || p.niederschlagswasser_eur_per_m2_year.is_some());
2033 let has_taxable_supply = p.wasser_grundpreis_eur_per_month.is_some()
2034 || p.wasser_mengenpreis_eur_per_m3.is_some();
2035 if has_public_law_fee && has_taxable_supply {
2036 w.push(BillingWarning {
2037 code: "GEBUEHR_UND_ENTGELT_AUF_EINEM_BELEG",
2038 severity: WarningSeverity::Warning,
2043 message: "die öffentlich-rechtliche Abwassergebühr ist nicht steuerbar \
2044 (EN 16931 Kategorie O) und darf nach BR-O-11 ff. nicht mit \
2045 umsatzsteuerpflichtigen Trinkwasserpositionen auf einem Beleg \
2046 stehen — Gebührenbescheid und Trinkwasserrechnung getrennt \
2047 erstellen, oder abwasser_regime auf PRIVATE_LAW_CHARGE setzen, \
2048 wenn privatrechtlich abgerechnet wird"
2049 .to_owned(),
2050 });
2051 }
2052
2053 if meter.absetzung_total_m3() > meter.frischwasser_m3 {
2054 w.push(BillingWarning {
2055 code: "ABSETZUNG_UEBERSTEIGT_FRISCHWASSER",
2056 severity: WarningSeverity::Error,
2057 message: format!(
2058 "Absetzungen ({} m³) übersteigen den Frischwasserbezug ({} m³) — \
2059 Zählerstände prüfen",
2060 meter.absetzung_total_m3(),
2061 meter.frischwasser_m3
2062 ),
2063 });
2064 }
2065 w
2066 }
2067
2068 fn bill(
2069 &self,
2070 ctx: &BillingContext,
2071 quantities: &Quantities,
2072 _prior: &[BillingPosition],
2073 ) -> Result<Vec<BillingPosition>, EngineError> {
2074 let meter = quantities.wasser.clone().unwrap_or_default();
2075 let p = &self.product;
2076 let mut positions: Vec<BillingPosition> = Vec::new();
2077 let months = meter.months.unwrap_or_else(|| ctx.billed_months());
2080
2081 let absetzung_m3 = meter.absetzung_total_m3();
2082 if absetzung_m3 > meter.frischwasser_m3 {
2083 return Err(EngineError::ValidationBlocked {
2084 warnings: self.validate_warnings(ctx, quantities),
2085 });
2086 }
2087
2088 let trinkwasser_rate = p
2093 .mwst_rate_override
2094 .unwrap_or(ctx.regulatory_rates.mwst_rate_reduced);
2095 let public_law = p.abwasser_regime == AbwasserRegime::PublicLawFee;
2100 let abwasser_rate = if public_law {
2101 Decimal::ZERO
2102 } else {
2103 ctx.regulatory_rates.mwst_rate
2104 };
2105
2106 if let Some(gp) = p.wasser_grundpreis_eur_per_month {
2107 let mut pos = BillingPosition::debit(
2108 "Grundpreis Trinkwasser",
2109 months,
2110 "Monate",
2111 gp,
2112 PositionCategory::Commodity,
2113 )
2114 .with_legal_basis("AVBWasserV")
2115 .with_tag("wasser");
2116 pos.applicable_tax_rate = Some(trinkwasser_rate);
2117 positions.push(pos);
2118 }
2119
2120 if let Some(mp) = p.wasser_mengenpreis_eur_per_m3
2121 && meter.frischwasser_m3 > Decimal::ZERO
2122 {
2123 let mut pos = BillingPosition::debit(
2124 "Mengenpreis Trinkwasser",
2125 meter.frischwasser_m3,
2126 "m³",
2127 mp,
2128 PositionCategory::Commodity,
2129 )
2130 .with_legal_basis("§12 Abs. 2 Nr. 1 UStG i. V. m. Anlage 2 Nr. 34 (7 % USt)")
2131 .with_tag("wasser");
2132 pos.applicable_tax_rate = Some(trinkwasser_rate);
2133 positions.push(pos);
2134 }
2135
2136 if let Some(sw) = p.schmutzwasser_eur_per_m3 {
2137 let schmutzwasser_m3 = meter.frischwasser_m3 - absetzung_m3;
2138 if schmutzwasser_m3 > Decimal::ZERO {
2139 let mut pos = BillingPosition::debit(
2140 "Schmutzwassergebühr",
2141 schmutzwasser_m3,
2142 "m³",
2143 sw,
2144 PositionCategory::Fee,
2145 )
2146 .with_legal_basis("Gesplittete Abwassergebühr (KAG-Satzung, Frischwassermaßstab)")
2147 .with_tag("wasser")
2148 .with_tag("abwasser");
2149 pos.applicable_tax_rate = Some(abwasser_rate);
2150 if public_law {
2151 pos = pos.with_out_of_scope();
2152 }
2153 pos.trace.formula = format!(
2154 "({} m³ Frischwasser − {} m³ Absetzungen) × {} EUR/m³",
2155 meter.frischwasser_m3, absetzung_m3, sw
2156 );
2157 positions.push(pos);
2158 }
2159
2160 for a in &meter.absetzungen {
2162 let mut pos = BillingPosition::debit(
2163 format!("Absetzung {} (nicht eingeleitet)", a.grund.label()),
2164 a.m3,
2165 "m³",
2166 Decimal::ZERO,
2167 PositionCategory::Info,
2168 )
2169 .with_legal_basis("Absetzung nicht eingeleiteter Wassermengen (KAG-Satzung)")
2170 .with_tag("wasser")
2171 .with_tag("abwasser");
2172 pos.applicable_tax_rate = Some(Decimal::ZERO);
2173 positions.push(pos);
2174 }
2175 }
2176
2177 if let (Some(nsw), Some(flaeche)) = (
2178 p.niederschlagswasser_eur_per_m2_year,
2179 meter.versiegelte_flaeche_m2,
2180 ) && flaeche > Decimal::ZERO
2181 {
2182 let mut pos = BillingPosition::debit(
2183 "Niederschlagswassergebühr",
2184 flaeche,
2185 "m²",
2186 nsw / dec!(12) * months,
2187 PositionCategory::Fee,
2188 )
2189 .with_legal_basis("Gesplittete Abwassergebühr (KAG-Satzung, Flächenmaßstab)")
2190 .with_tag("wasser")
2191 .with_tag("abwasser");
2192 pos.applicable_tax_rate = Some(abwasser_rate);
2193 if public_law {
2194 pos = pos.with_out_of_scope();
2195 }
2196 pos.trace.formula =
2197 format!("{flaeche} m² versiegelte Fläche × {nsw} EUR/m²/a × {months}/12 Monate");
2198 positions.push(pos);
2199 }
2200
2201 Ok(positions)
2202 }
2203}
2204
2205pub struct SolarProvider {
2209 product: SolarProduct,
2210}
2211
2212impl SolarProvider {
2213 pub fn new(product: SolarProduct) -> Self {
2214 Self { product }
2215 }
2216}
2217
2218impl BillingProvider for SolarProvider {
2219 fn validate_warnings(
2220 &self,
2221 _ctx: &BillingContext,
2222 _quantities: &Quantities,
2223 ) -> Vec<BillingWarning> {
2224 let mut w = Vec::new();
2225 let p = &self.product;
2226
2227 if p.solar_arbeitspreis_ct_per_kwh.is_none() && p.arbeitspreis_ct_per_kwh.is_none() {
2231 w.push(BillingWarning {
2232 code: "KEIN_ARBEITSPREIS",
2233 severity: WarningSeverity::Error,
2234 message: "the solar product carries neither solar_arbeitspreis_ct_per_kwh nor \
2235 arbeitspreis_ct_per_kwh — the invoice would price no electricity at \
2236 all. Check the productd product's price positions."
2237 .to_owned(),
2238 });
2239 }
2240
2241 if let (Some(gv_ct), Some(ms_ct)) = (
2245 p.grundversorgung_arbeitspreis_ct_per_kwh,
2246 p.solar_arbeitspreis_ct_per_kwh,
2247 ) {
2248 let cap = (gv_ct * dec!(0.9)).round_kfm(4);
2249 if ms_ct > cap {
2250 w.push(BillingWarning {
2251 code: "MIETERSTROM_UEBER_90PCT_GRUNDVERSORGUNG",
2252 severity: WarningSeverity::Error,
2253 message: format!(
2254 "\u{a7} 42a Abs. 4 EnWG: der Mieterstrom-Arbeitspreis {ms_ct} ct/kWh \
2255 \u{fc}berschreitet 90\u{202f}% des Grundversorgungstarifs \
2256 ({gv_ct} ct/kWh \u{2192} {cap} ct/kWh)"
2257 ),
2258 });
2259 }
2260 }
2261 w
2262 }
2263
2264 fn bill(
2265 &self,
2266 ctx: &BillingContext,
2267 quantities: &Quantities,
2268 _prior: &[BillingPosition],
2269 ) -> Result<Vec<BillingPosition>, EngineError> {
2270 let product = &self.product;
2271 let mut positions: Vec<BillingPosition> = Vec::new();
2272
2273 if let Some(ggv) = &quantities.ggv_solar {
2278 let pv_kwh = ggv.pv_delivered_kwh();
2279 let grid_kwh = ggv.grid_kwh();
2280
2281 if pv_kwh > Decimal::ZERO {
2283 if let Some(ap_ct) = product.solar_arbeitspreis_ct_per_kwh {
2284 positions.push(
2285 arbeitspreis_position(
2286 format!("Arbeitspreis Solarstrom GGV ({pv_kwh:.3}\u{202f}kWh)"),
2287 pv_kwh,
2288 ap_ct,
2289 "kWh",
2290 "\u{a7}42b EnWG",
2291 &["solar", "ggv_pv"],
2292 )
2293 .with_tag("solar")
2294 .with_tag("ggv_pv"),
2295 );
2296 }
2297 if let Some(rabatt_ct) = product.gemeinschaft_rabatt_ct_per_kwh {
2299 positions.push(
2300 BillingPosition::credit(
2301 "GGV-Rabatt Solarstrom (\u{a7}42b EnWG)",
2302 pv_kwh,
2303 "kWh",
2304 rabatt_ct / dec!(100),
2305 PositionCategory::Discount,
2306 )
2307 .with_legal_basis("\u{a7}42b EnWG Abs.\u{202f}3")
2308 .with_tag("gemeinschaft_rabatt")
2309 .with_tag("solar")
2310 .with_tag("ggv_pv"),
2311 );
2312 }
2313 positions.extend(stromsteuer_positions(
2319 product.stromsteuer_tarif,
2320 pv_kwh,
2321 ctx.regulatory_rates.effective_stromsteuer(None),
2322 &["solar", "ggv_pv"],
2323 ));
2324 }
2325
2326 if grid_kwh > Decimal::ZERO {
2331 let grid_rate = product
2332 .arbeitspreis_ct_per_kwh
2333 .or(product.solar_arbeitspreis_ct_per_kwh);
2334 if let Some(ap_ct) = grid_rate {
2335 positions.push(
2336 arbeitspreis_position(
2337 format!("Arbeitspreis Reststrom Netz ({grid_kwh:.3}\u{202f}kWh)"),
2338 grid_kwh,
2339 ap_ct,
2340 "kWh",
2341 "\u{a7}41 EnWG",
2342 &["strom", "ggv_grid"],
2343 )
2344 .with_tag("strom")
2345 .with_tag("ggv_grid"),
2346 );
2347 }
2348 let st_rate = ctx.regulatory_rates.effective_stromsteuer(None);
2350 if st_rate > Decimal::ZERO {
2351 positions.push(
2352 levy_position(
2353 "Stromsteuer (Reststrom Netz)",
2354 grid_kwh,
2355 "kWh",
2356 st_rate,
2357 "\u{a7}3 StromStG",
2358 "stromsteuer",
2359 )
2360 .with_tag("strom")
2361 .with_tag("ggv_grid"),
2362 );
2363 }
2364 }
2365
2366 let ratio_pct = (ggv.pv_coverage_ratio() * dec!(100)).round_kfm(1);
2368 positions.push(BillingPosition {
2369 description: format!(
2370 "GGV Solarstromanteil: {ratio_pct}\u{202f}% ({pv_kwh:.3}\u{202f}kWh von {:.3}\u{202f}kWh)",
2371 ggv.actual_consumption_kwh
2372 ),
2373 legal_basis: Some("\u{a7}42b EnWG (Solarpaket I)".to_owned()),
2374 quantity: ggv.pv_coverage_ratio(),
2375 unit: "%".to_owned(),
2376 unit_price_eur: Decimal::ZERO,
2377 net_eur: Decimal::ZERO,
2378 category: PositionCategory::Info,
2379 tags: vec!["ggv_coverage".to_owned(), "solar".to_owned()],
2380 applicable_tax_rate: None,
2381 trace: crate::position::PositionTrace::default(),
2382 });
2383
2384 if let Some(rate) = product.mwst_rate_override {
2386 for pos in &mut positions {
2387 if pos.applicable_tax_rate.is_none()
2388 && !matches!(
2389 pos.category,
2390 PositionCategory::Tax
2391 | PositionCategory::Abschlag
2392 | PositionCategory::Info
2393 )
2394 {
2395 pos.applicable_tax_rate = Some(rate);
2396 }
2397 }
2398 }
2399 return Ok(positions);
2400 }
2401
2402 let meter = quantities.solar.as_ref().cloned().unwrap_or_default();
2404 let kwh = meter.eigenverbrauch_kwh;
2405
2406 if let Some(ap_ct) = product.solar_arbeitspreis_ct_per_kwh {
2407 positions.push(
2408 arbeitspreis_position(
2409 "Arbeitspreis Solarstrom (Eigenverbrauch)",
2410 kwh,
2411 ap_ct,
2412 "kWh",
2413 "\u{a7}42b EnWG",
2414 &["solar"],
2415 )
2416 .with_tag("solar"),
2417 );
2418 }
2419 if let Some(gv_ct) = product.grundversorgung_arbeitspreis_ct_per_kwh {
2425 positions.push(BillingPosition {
2426 description: format!(
2427 "Mieterstrom-Preisobergrenze (\u{a7} 42a Abs. 4 EnWG): 90\u{202f}% von {gv_ct:.4}\u{202f}ct/kWh = {:.4}\u{202f}ct/kWh",
2428 gv_ct * dec!(0.9)
2429 ),
2430 legal_basis: Some("\u{a7} 42a Abs. 4 EnWG".to_owned()),
2431 quantity: Decimal::ZERO,
2432 unit: "ct/kWh".to_owned(),
2433 unit_price_eur: Decimal::ZERO,
2434 net_eur: Decimal::ZERO,
2435 category: PositionCategory::Info,
2436 tags: vec!["mieterstrom".to_owned(), "preisobergrenze".to_owned()],
2437 applicable_tax_rate: None,
2438 trace: crate::position::PositionTrace::default(),
2439 });
2440 }
2441 if let Some(rabatt_ct) = product.gemeinschaft_rabatt_ct_per_kwh {
2442 positions.push(
2443 BillingPosition::credit(
2444 "Rabatt Gemeinschaftliche Geb\u{e4}udeversorgung (\u{a7}42b EnWG)",
2445 kwh,
2446 "kWh",
2447 rabatt_ct / dec!(100),
2448 PositionCategory::Discount,
2449 )
2450 .with_legal_basis("\u{a7}42b EnWG")
2451 .with_tag("gemeinschaft_rabatt")
2452 .with_tag("solar"),
2453 );
2454 }
2455 positions.extend(stromsteuer_positions(
2459 product.stromsteuer_tarif,
2460 kwh,
2461 ctx.regulatory_rates.effective_stromsteuer(None),
2462 &["solar"],
2463 ));
2464 if let Some(rate) = product.mwst_rate_override {
2468 for pos in &mut positions {
2469 if pos.applicable_tax_rate.is_none()
2470 && !matches!(
2471 pos.category,
2472 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
2473 )
2474 {
2475 pos.applicable_tax_rate = Some(rate);
2476 }
2477 }
2478 }
2479 Ok(positions)
2480 }
2481}
2482
2483pub struct EegProvider {
2501 product: EegProduct,
2502}
2503
2504impl EegProvider {
2505 pub fn new(product: EegProduct) -> Self {
2506 Self { product }
2507 }
2508}
2509
2510impl BillingProvider for EegProvider {
2511 fn validate_warnings(
2512 &self,
2513 _ctx: &BillingContext,
2514 quantities: &Quantities,
2515 ) -> Vec<BillingWarning> {
2516 #[cfg(feature = "eeg")]
2531 if quantities.eeg_full.is_some() {
2532 return Vec::new();
2534 }
2535 let kwh = quantities
2536 .eeg
2537 .as_ref()
2538 .map_or(Decimal::ZERO, |m| m.einspeisung_kwh);
2539 let p = &self.product;
2540 let has_verguetung = p.eeg_verguetungssatz_ct_per_kwh.is_some()
2541 || p.eeg_marktpraemie_ct_per_kwh.is_some()
2542 || p.kwkg_zuschlag_ct_per_kwh.is_some();
2543 if kwh > Decimal::ZERO && !has_verguetung {
2544 return vec![BillingWarning {
2545 code: "KEIN_VERGUETUNGSSATZ",
2546 severity: WarningSeverity::Error,
2547 message: format!(
2548 "es wurden {kwh} kWh eingespeist, das Produkt nennt aber weder \
2549 Einspeisevergütung (eeg_verguetungssatz_ct_per_kwh) noch Marktprämie \
2550 (eeg_marktpraemie_ct_per_kwh) noch KWKG-Zuschlag — die Gutschrift \
2551 enthielte keine einzige Vergütungsposition. Satz hinterlegen, oder \
2552 0.0 setzen, wenn für diesen Zeitraum tatsächlich nichts zu vergüten ist."
2553 ),
2554 }];
2555 }
2556 Vec::new()
2557 }
2558
2559 #[cfg_attr(not(feature = "eeg"), allow(unused_variables))]
2561 fn bill(
2562 &self,
2563 ctx: &BillingContext,
2564 quantities: &Quantities,
2565 _prior: &[BillingPosition],
2566 ) -> Result<Vec<BillingPosition>, EngineError> {
2567 #[cfg(feature = "eeg")]
2570 if let Some(eeg_full) = &quantities.eeg_full {
2571 return bill_eeg_full(eeg_full, ctx);
2572 }
2573
2574 let meter = quantities.eeg.as_ref().cloned().unwrap_or_default();
2576 let product = &self.product;
2577 let kwh = meter.einspeisung_kwh;
2578
2579 let billable_kwh = meter
2580 .kwh_during_negative_epex
2581 .map(|neg| (kwh - neg).max(Decimal::ZERO))
2582 .unwrap_or(kwh);
2583
2584 let suspended_kwh = kwh - billable_kwh;
2585 let mut positions: Vec<BillingPosition> = Vec::new();
2586
2587 if suspended_kwh > Decimal::ZERO {
2588 positions.push(BillingPosition {
2589 description: "Keine Vergütung (§51 EEG Negativpreisregel)".to_owned(),
2590 legal_basis: Some("§51 EEG 2023".to_owned()),
2591 quantity: suspended_kwh,
2592 unit: "kWh".to_owned(),
2593 unit_price_eur: Decimal::ZERO,
2594 net_eur: Decimal::ZERO,
2595 category: PositionCategory::Info,
2596 tags: vec!["eeg_negativpreis_suspension".to_owned(), "info".to_owned()],
2597 applicable_tax_rate: None,
2598 trace: crate::position::PositionTrace::default(),
2599 });
2600 }
2601 if let Some(vg_ct) = product.eeg_verguetungssatz_ct_per_kwh {
2602 positions.push(
2603 BillingPosition::debit(
2604 "EEG Einspeisevergütung",
2605 billable_kwh,
2606 "kWh",
2607 vg_ct / dec!(100),
2608 PositionCategory::Credit,
2609 )
2610 .with_legal_basis("§21 EEG 2023")
2611 .with_tag("eeg_verguetung")
2612 .with_tag("eeg"),
2613 );
2614 }
2615 if let Some(mp_ct) = product.eeg_marktpraemie_ct_per_kwh {
2622 positions.push(
2623 BillingPosition::debit(
2624 "EEG Marktprämie",
2625 billable_kwh,
2626 "kWh",
2627 mp_ct / dec!(100),
2628 PositionCategory::Credit,
2629 )
2630 .with_legal_basis("§20 EEG 2023")
2631 .with_tag("eeg_marktpraemie")
2632 .with_tag("eeg"),
2633 );
2634 }
2635 if let Some(mgp_ct) = product.eeg_managementpraemie_ct_per_kwh {
2641 positions.push(
2642 BillingPosition::debit(
2643 "Managementprämie Direktvermarktung",
2644 kwh,
2645 "kWh",
2646 mgp_ct / dec!(100),
2647 PositionCategory::Credit,
2648 )
2649 .with_legal_basis("Direktvermarktungsvertrag")
2650 .with_tag("eeg_managementpraemie")
2651 .with_tag("eeg"),
2652 );
2653 }
2654 if let Some(kwkg_ct) = product.kwkg_zuschlag_ct_per_kwh {
2655 positions.push(
2656 BillingPosition::debit(
2657 "KWKG Zuschlag",
2658 kwh,
2659 "kWh",
2660 kwkg_ct / dec!(100),
2661 PositionCategory::Credit,
2662 )
2663 .with_legal_basis("§7 KWKG 2023")
2664 .with_tag("kwkg_zuschlag")
2665 .with_tag("kwkg"),
2666 );
2667 }
2668 if let Some(rate) = product.mwst_rate_override {
2672 for pos in &mut positions {
2673 if pos.applicable_tax_rate.is_none()
2674 && !matches!(
2675 pos.category,
2676 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
2677 )
2678 {
2679 pos.applicable_tax_rate = Some(rate);
2680 }
2681 }
2682 }
2683 Ok(positions)
2684 }
2685}
2686
2687#[cfg(feature = "eeg")]
2693fn bill_eeg_full(
2694 settle_input: &eeg_billing::SettleInput,
2695 _ctx: &BillingContext,
2696) -> Result<Vec<BillingPosition>, EngineError> {
2697 let output = eeg_billing::calculate_settlement(settle_input);
2698 let positions = output
2699 .positions
2700 .into_iter()
2701 .map(|p| BillingPosition {
2702 description: p.description,
2703 legal_basis: Some(p.legal_basis),
2704 quantity: p.kwh,
2705 unit: "kWh".to_owned(),
2706 unit_price_eur: p.rate_ct_kwh / dec!(100),
2707 net_eur: validated_eur(p.eur),
2709 category: PositionCategory::Credit,
2710 tags: vec!["eeg".to_owned(), "eeg_full".to_owned()],
2711 applicable_tax_rate: None,
2712 trace: crate::position::PositionTrace::default(),
2713 })
2714 .collect();
2715 Ok(positions)
2716}
2717
2718pub struct EinspeisungProvider {
2722 product: EinspeisungProduct,
2723}
2724
2725impl EinspeisungProvider {
2726 pub fn new(product: EinspeisungProduct) -> Self {
2727 Self { product }
2728 }
2729}
2730
2731impl BillingProvider for EinspeisungProvider {
2732 fn validate_warnings(
2733 &self,
2734 _ctx: &BillingContext,
2735 quantities: &Quantities,
2736 ) -> Vec<BillingWarning> {
2737 let kwh = quantities
2747 .einspeisung
2748 .as_ref()
2749 .map_or(Decimal::ZERO, |m| m.einspeisung_kwh);
2750 if kwh > Decimal::ZERO && self.product.marktwert_ct_per_kwh.is_none() {
2751 return vec![BillingWarning {
2752 code: "KEIN_MARKTWERT",
2753 severity: WarningSeverity::Error,
2754 message: format!(
2755 "es wurden {kwh} kWh eingespeist, das Produkt nennt aber keinen \
2756 Marktwert (marktwert_ct_per_kwh) — die Gutschrift enthielte allein \
2757 die Vermarktungsgebühr und nichts für die eingespeiste Energie. \
2758 Monatsmarktwert hinterlegen, oder 0.0 setzen, wenn er für diesen \
2759 Zeitraum tatsächlich null ist."
2760 ),
2761 }];
2762 }
2763 Vec::new()
2764 }
2765
2766 fn bill(
2767 &self,
2768 _ctx: &BillingContext,
2769 quantities: &Quantities,
2770 _prior: &[BillingPosition],
2771 ) -> Result<Vec<BillingPosition>, EngineError> {
2772 let meter = quantities.einspeisung.as_ref().cloned().unwrap_or_default();
2773 let product = &self.product;
2774 let kwh = meter.einspeisung_kwh;
2775 let mut positions: Vec<BillingPosition> = Vec::new();
2776
2777 if let Some(mv_ct) = product.marktwert_ct_per_kwh {
2778 positions.push(
2779 BillingPosition::debit(
2780 "Marktwert Strom (EPEX Spot Monatsmarktwert)",
2781 kwh,
2782 "kWh",
2783 mv_ct / dec!(100),
2784 PositionCategory::Credit,
2785 )
2786 .with_legal_basis("§20 EEG 2023")
2787 .with_tag("marktwert")
2788 .with_tag("einspeisung"),
2789 );
2790 }
2791 if let Some(vm_ct) = product.vermarktungsgebuehr_ct_per_kwh {
2792 positions.push(
2794 BillingPosition::debit(
2795 "Vermarktungsgebühr Direktvermarktung",
2796 kwh,
2797 "kWh",
2798 -(vm_ct / dec!(100)), PositionCategory::Fee,
2800 )
2801 .with_tag("vermarktungsgebuehr")
2802 .with_tag("einspeisung"),
2803 );
2804 }
2805 if let Some(rate) = product.mwst_rate_override {
2809 for pos in &mut positions {
2810 if pos.applicable_tax_rate.is_none()
2811 && !matches!(
2812 pos.category,
2813 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
2814 )
2815 {
2816 pos.applicable_tax_rate = Some(rate);
2817 }
2818 }
2819 }
2820 Ok(positions)
2821 }
2822}
2823
2824pub struct HemsProvider {
2828 product: HemsProduct,
2829}
2830
2831impl HemsProvider {
2832 pub fn new(product: HemsProduct) -> Self {
2833 Self { product }
2834 }
2835}
2836
2837impl BillingProvider for HemsProvider {
2838 fn validate_warnings(
2839 &self,
2840 _ctx: &BillingContext,
2841 quantities: &Quantities,
2842 ) -> Vec<BillingWarning> {
2843 let usage = quantities.hems.as_ref();
2850 let p = &self.product;
2851 let mut w = Vec::new();
2852 let fehlend = [
2853 (
2854 usage.and_then(|u| u.optimization_events).unwrap_or(0),
2855 p.hems_optimization_event_eur,
2856 "Optimierungsereignisse",
2857 "hems_optimization_event_eur",
2858 ),
2859 (
2860 usage.and_then(|u| u.readout_events).unwrap_or(0),
2861 p.hems_readout_event_eur,
2862 "Ablesungen",
2863 "hems_readout_event_eur",
2864 ),
2865 ];
2866 for (count, preis, was, feld) in fehlend {
2867 if count > 0 && preis.is_none() {
2868 w.push(BillingWarning {
2869 code: "KEIN_HEMS_EREIGNISPREIS",
2870 severity: WarningSeverity::Error,
2871 message: format!(
2872 "es wurden {count} {was} erfasst, das Produkt nennt aber keinen \
2873 Preis ({feld}) — die Positionen fielen ersatzlos aus der Rechnung. \
2874 Preis hinterlegen, oder 0.0 setzen, wenn sie in der Grundgebühr \
2875 enthalten sind."
2876 ),
2877 });
2878 }
2879 }
2880 if p.hems_subscription_eur_per_month.is_none()
2881 && p.hems_optimization_event_eur.is_none()
2882 && p.hems_readout_event_eur.is_none()
2883 {
2884 w.push(BillingWarning {
2885 code: "KEIN_HEMS_PREIS",
2886 severity: WarningSeverity::Error,
2887 message: "das HEMS-Produkt nennt weder Grundgebühr noch Ereignispreise — \
2888 die Rechnung enthielte keine einzige Position für die Leistung. \
2889 Preise hinterlegen, oder 0.0 setzen, wenn sie unentgeltlich ist."
2890 .to_owned(),
2891 });
2892 }
2893 w
2894 }
2895
2896 fn bill(
2897 &self,
2898 _ctx: &BillingContext,
2899 quantities: &Quantities,
2900 _prior: &[BillingPosition],
2901 ) -> Result<Vec<BillingPosition>, EngineError> {
2902 let usage = quantities.hems.as_ref().cloned().unwrap_or_default();
2903 let product = &self.product;
2904 let months = usage.months.unwrap_or(dec!(1));
2905 let mut positions: Vec<BillingPosition> = Vec::new();
2906
2907 let sub_eur = product.hems_subscription_eur_per_month;
2908
2909 if let Some(sub_eur) = sub_eur {
2910 positions.push(
2911 BillingPosition::debit(
2912 "HEMS Grundgebühr",
2913 months,
2914 "Monate",
2915 sub_eur,
2916 PositionCategory::Fee,
2917 )
2918 .with_tag("hems_subscription")
2919 .with_tag("hems"),
2920 );
2921 }
2922 if let (Some(events), Some(event_eur)) = (
2923 usage.optimization_events,
2924 product.hems_optimization_event_eur,
2925 ) && events > 0
2926 {
2927 positions.push(
2928 BillingPosition::debit(
2929 "HEMS Optimierungsereignisse",
2930 Decimal::from(events),
2931 "Ereignisse",
2932 event_eur,
2933 PositionCategory::Fee,
2934 )
2935 .with_tag("hems_events")
2936 .with_tag("hems"),
2937 );
2938 }
2939 if let (Some(reads), Some(read_eur)) =
2940 (usage.readout_events, product.hems_readout_event_eur)
2941 && reads > 0
2942 {
2943 positions.push(
2944 BillingPosition::debit(
2945 "HEMS Smart Meter Ablesungen",
2946 Decimal::from(reads),
2947 "Ablesungen",
2948 read_eur,
2949 PositionCategory::Fee,
2950 )
2951 .with_tag("hems_readouts")
2952 .with_tag("hems"),
2953 );
2954 }
2955 if let Some(rate) = product.mwst_rate_override {
2959 for pos in &mut positions {
2960 if pos.applicable_tax_rate.is_none()
2961 && !matches!(
2962 pos.category,
2963 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
2964 )
2965 {
2966 pos.applicable_tax_rate = Some(rate);
2967 }
2968 }
2969 }
2970 Ok(positions)
2971 }
2972}
2973
2974pub struct EmobilityProvider {
2978 product: EmobilityProduct,
2979}
2980
2981impl EmobilityProvider {
2982 pub fn new(product: EmobilityProduct) -> Self {
2983 Self { product }
2984 }
2985}
2986
2987impl BillingProvider for EmobilityProvider {
2988 fn validate_warnings(
2989 &self,
2990 _ctx: &BillingContext,
2991 quantities: &Quantities,
2992 ) -> Vec<BillingWarning> {
2993 let charged = quantities
3002 .emobility
3003 .as_ref()
3004 .and_then(|u| u.kwh_charged)
3005 .unwrap_or(Decimal::ZERO);
3006 if charged > Decimal::ZERO && self.product.emobility_kwh_price_ct.is_none() {
3007 return vec![BillingWarning {
3008 code: "KEIN_LADEPREIS",
3009 severity: WarningSeverity::Error,
3010 message: format!(
3011 "es wurden {charged} kWh geladen, das Produkt nennt aber keinen \
3012 Arbeitspreis (emobility_kwh_price_ct) — die Rechnung enthielte allein \
3013 die Service- und Sessiongebühren und nichts für die Ladeenergie. Preis \
3014 hinterlegen, oder 0.0 setzen, wenn das Laden in der Grundgebühr \
3015 enthalten ist."
3016 ),
3017 }];
3018 }
3019 Vec::new()
3020 }
3021
3022 fn bill(
3023 &self,
3024 _ctx: &BillingContext,
3025 quantities: &Quantities,
3026 _prior: &[BillingPosition],
3027 ) -> Result<Vec<BillingPosition>, EngineError> {
3028 let usage = quantities.emobility.as_ref().cloned().unwrap_or_default();
3029 let product = &self.product;
3030 let months = usage.months.unwrap_or(dec!(1));
3031 let mut positions: Vec<BillingPosition> = Vec::new();
3032
3033 let svc_eur = product.emobility_service_fee_eur;
3034 let kwh_price = product.emobility_kwh_price_ct;
3035
3036 if let Some(svc_eur) = svc_eur {
3037 positions.push(
3038 BillingPosition::debit(
3039 "E-Mobility Servicegebühr",
3040 months,
3041 "Monate",
3042 svc_eur,
3043 PositionCategory::Fee,
3044 )
3045 .with_tag("emobility_service")
3046 .with_tag("emobility"),
3047 );
3048 }
3049 if let (Some(kwh), Some(kwh_price_ct)) = (usage.kwh_charged, kwh_price)
3050 && kwh > Decimal::ZERO
3051 {
3052 positions.push(
3053 arbeitspreis_position(
3054 "E-Mobility Ladeenergie",
3055 kwh,
3056 kwh_price_ct,
3057 "kWh",
3058 "§41a EnWG",
3059 &["emobility"],
3060 )
3061 .with_tag("emobility"),
3062 );
3063 }
3064 if let (Some(sessions), Some(session_eur)) =
3065 (usage.sessions, product.emobility_session_fee_eur)
3066 && sessions > 0
3067 {
3068 positions.push(
3069 BillingPosition::debit(
3070 "E-Mobility Ladesessionsgebühr",
3071 Decimal::from(sessions),
3072 "Sessionen",
3073 session_eur,
3074 PositionCategory::Fee,
3075 )
3076 .with_tag("emobility_sessions")
3077 .with_tag("emobility"),
3078 );
3079 }
3080 if let (Some(roaming), Some(roaming_eur)) =
3081 (usage.roaming_sessions, product.emobility_roaming_fee_eur)
3082 && roaming > 0
3083 {
3084 positions.push(
3085 BillingPosition::debit(
3086 "E-Mobility Roaming-Gebühr",
3087 Decimal::from(roaming),
3088 "Sessionen",
3089 roaming_eur,
3090 PositionCategory::Fee,
3091 )
3092 .with_tag("emobility_roaming")
3093 .with_tag("emobility"),
3094 );
3095 }
3096 if let Some(rate) = product.mwst_rate_override {
3100 for pos in &mut positions {
3101 if pos.applicable_tax_rate.is_none()
3102 && !matches!(
3103 pos.category,
3104 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
3105 )
3106 {
3107 pos.applicable_tax_rate = Some(rate);
3108 }
3109 }
3110 }
3111 Ok(positions)
3112 }
3113}
3114
3115pub struct ServiceProvider {
3119 product: ServiceProduct,
3120}
3121
3122impl ServiceProvider {
3123 pub fn new(product: ServiceProduct) -> Self {
3124 Self { product }
3125 }
3126}
3127
3128impl BillingProvider for ServiceProvider {
3129 fn validate_warnings(
3130 &self,
3131 _ctx: &BillingContext,
3132 quantities: &Quantities,
3133 ) -> Vec<BillingWarning> {
3134 let events = quantities
3143 .service
3144 .as_ref()
3145 .and_then(|u| u.event_count)
3146 .unwrap_or(0);
3147 let priced = quantities
3148 .service
3149 .as_ref()
3150 .and_then(|u| u.event_price_eur)
3151 .or(self.product.service_event_price_eur)
3152 .is_some();
3153 if events > 0 && !priced {
3154 return vec![BillingWarning {
3155 code: "KEIN_EREIGNISPREIS",
3156 severity: WarningSeverity::Warning,
3157 message: format!(
3158 "{events} abrechenbare Ereignisse übermittelt, aber weder \
3159 `event_price_eur` noch `service_event_price_eur` gesetzt — sie \
3160 erscheinen nicht auf der Rechnung. Preis hinterlegen, oder 0.0 \
3161 setzen, wenn die Ereignisse durch die Grundgebühr abgegolten sind."
3162 ),
3163 }];
3164 }
3165 Vec::new()
3166 }
3167
3168 fn bill(
3169 &self,
3170 _ctx: &BillingContext,
3171 quantities: &Quantities,
3172 _prior: &[BillingPosition],
3173 ) -> Result<Vec<BillingPosition>, EngineError> {
3174 let usage = quantities.service.as_ref().cloned().unwrap_or_default();
3175 let product = &self.product;
3176 let months = usage.months.unwrap_or(dec!(1));
3177 let mut positions: Vec<BillingPosition> = Vec::new();
3178
3179 if let Some(fee_eur) = product.service_fee_eur {
3180 positions.push(
3181 BillingPosition::debit(
3182 "Energiedienstleistung Grundgebühr",
3183 months,
3184 "Monate",
3185 fee_eur,
3186 PositionCategory::Fee,
3187 )
3188 .with_tag("service"),
3189 );
3190 }
3191 let event_price = usage.event_price_eur.or(product.service_event_price_eur);
3192 if let (Some(events), Some(event_eur)) = (usage.event_count, event_price)
3193 && events > 0
3194 {
3195 positions.push(
3196 BillingPosition::debit(
3197 "Energiedienstleistung Ereignisgebühr",
3198 Decimal::from(events),
3199 "Ereignisse",
3200 event_eur,
3201 PositionCategory::Fee,
3202 )
3203 .with_tag("service_events")
3204 .with_tag("service"),
3205 );
3206 }
3207 if let Some(rate) = product.mwst_rate_override {
3211 for pos in &mut positions {
3212 if pos.applicable_tax_rate.is_none()
3213 && !matches!(
3214 pos.category,
3215 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
3216 )
3217 {
3218 pos.applicable_tax_rate = Some(rate);
3219 }
3220 }
3221 }
3222 Ok(positions)
3223 }
3224}
3225
3226pub struct DynamicElectricityProvider {
3234 product: ElectricityProduct,
3235 grid: GridInput,
3236}
3237
3238impl DynamicElectricityProvider {
3239 #[must_use]
3240 pub fn new(product: ElectricityProduct, grid: GridInput) -> Self {
3241 Self { product, grid }
3242 }
3243}
3244
3245impl BillingProvider for DynamicElectricityProvider {
3246 fn validate_warnings(
3247 &self,
3248 _ctx: &BillingContext,
3249 quantities: &Quantities,
3250 ) -> Vec<BillingWarning> {
3251 let is_non_imsys = quantities
3255 .electricity
3256 .as_ref()
3257 .is_some_and(|m| m.metering_mode != crate::quantities::MeteringMode::Imsys);
3258 if is_non_imsys {
3259 return vec![BillingWarning {
3260 code: "SECT41A_IMSYS_REQUIRED",
3261 severity: WarningSeverity::Error,
3262 message: "§41a Abs. 1 EnWG: dynamic tariffs require an intelligent \
3263 metering system (iMSys / Smart Meter Gateway). The meter point \
3264 has MeteringMode::Slp or MeteringMode::Rlm. Update metering mode \
3265 to MeteringMode::Imsys or switch the customer to a fixed-price product."
3266 .to_owned(),
3267 }];
3268 }
3269
3270 let mut w = Vec::new();
3271 let stated_kwh = quantities
3282 .electricity
3283 .as_ref()
3284 .map_or(Decimal::ZERO, crate::quantities::MeterInput::billable_kwh);
3285 if stated_kwh > Decimal::ZERO {
3286 let interval_kwh: Decimal = quantities.dynamic_intervals.iter().map(|i| i.kwh).sum();
3287 if quantities.dynamic_intervals.is_empty() {
3288 w.push(BillingWarning {
3289 code: "SECT41A_KEINE_INTERVALLE",
3290 severity: WarningSeverity::Error,
3291 message: format!(
3292 "§ 41a EnWG: der Zähler meldet {stated_kwh} kWh, es liegt aber keine \
3293 Viertelstunden-Zeitreihe vor. Auf dem dynamischen Pfad ist die \
3294 Zeitreihe die Abrechnungsmenge — ohne sie entstünde eine Rechnung \
3295 über den Grundpreis und keine einzige kWh. Zeitreihe aus edmd \
3296 nachladen oder den Kunden über einen Festpreistarif abrechnen."
3297 ),
3298 });
3299 } else {
3300 let tolerance = (stated_kwh * dec!(0.005)).max(Decimal::ONE);
3306 let gap = (interval_kwh - stated_kwh).abs();
3307 if gap > tolerance {
3308 w.push(BillingWarning {
3309 code: "SECT41A_INTERVALLSUMME_WEICHT_AB",
3310 severity: WarningSeverity::Error,
3311 message: format!(
3312 "§ 41a EnWG: die Summe der Viertelstundenwerte ({interval_kwh} kWh) \
3313 weicht um {gap} kWh vom gemeldeten Zählerverbrauch \
3314 ({stated_kwh} kWh) ab (Toleranz {tolerance} kWh). Abgerechnet \
3315 würde die Zeitreihe — Arbeitspreis, Netzentgelt und Stromsteuer \
3316 also auf der niedrigeren Menge. Zeitreihe vervollständigen oder \
3317 den Zählerstand korrigieren."
3318 ),
3319 });
3320 }
3321 }
3322 }
3323 w
3324 }
3325
3326 fn bill(
3327 &self,
3328 ctx: &BillingContext,
3329 quantities: &Quantities,
3330 _prior: &[BillingPosition],
3331 ) -> Result<Vec<BillingPosition>, EngineError> {
3332 let product = &self.product;
3333 let grid = &self.grid;
3334 let rates = &ctx.regulatory_rates;
3335 let floor_ct = product.dynamic_epex_floor_ct_kwh;
3336 let cap_ct = product.dynamic_epex_cap_ct_kwh;
3337 let aufschlag_ct = product
3347 .dynamic_aufschlag_ct_per_kwh
3348 .unwrap_or(Decimal::ZERO);
3349 let source_name = product
3350 .dynamic_price_source
3351 .clone()
3352 .unwrap_or_else(|| "EPEX Spot Day-Ahead".to_owned());
3353 let mut positions: Vec<BillingPosition> = Vec::new();
3354
3355 if let Some(gp_ct_day) = product.grundpreis_ct_per_day {
3358 positions.push(
3359 grundpreis_position(
3360 "Grundpreis Strom (§41a)",
3361 gp_ct_day / dec!(100),
3362 ctx.prorate_days().0 as i64,
3363 "§41a EnWG",
3364 &["strom"],
3365 )
3366 .with_tag("strom"),
3367 );
3368 }
3369
3370 let mut missing_price_intervals: u32 = 0;
3383 let mut missing_price_kwh = Decimal::ZERO;
3384 let mut priced_pairs: Vec<(Decimal, billing::Amount<5>)> =
3385 Vec::with_capacity(quantities.dynamic_intervals.len());
3386
3387 for interval in &quantities.dynamic_intervals {
3388 let key = crate::provider::mtu_start(interval.timestamp_utc);
3390 let price_ct = quantities.dynamic_epex_prices.get(&key).copied();
3391
3392 let Some(price_ct) = price_ct else {
3393 missing_price_intervals += 1;
3394 missing_price_kwh += interval.kwh;
3398 continue;
3399 };
3400
3401 let mut spot_ct = price_ct;
3402 if let Some(floor) = floor_ct {
3403 spot_ct = spot_ct.max(floor);
3404 }
3405 if let Some(cap) = cap_ct {
3406 spot_ct = spot_ct.min(cap);
3407 }
3408 let effective_ct = spot_ct + aufschlag_ct;
3410
3411 let price_eur = billing::Amount::<5>::try_from((effective_ct / dec!(100)).round_kfm(5))
3420 .map_err(|_| EngineError::PriceOutOfRange {
3421 field: format!("spotpreis@{}", interval.timestamp_utc),
3422 value: effective_ct,
3423 })?;
3424 priced_pairs.push((interval.kwh, price_eur));
3425 }
3426
3427 if missing_price_kwh > Decimal::ZERO {
3434 return Err(EngineError::ValidationBlocked {
3435 warnings: vec![BillingWarning {
3436 code: "SECT41A_MISSING_EPEX_PRICES",
3437 severity: WarningSeverity::Error,
3438 message: format!(
3439 "§41a EnWG: {missing_price_intervals} interval(s) totalling \
3440 {missing_price_kwh} kWh have no EPEX Spot price. A dynamic \
3441 tariff cannot be billed on an incomplete price series — import \
3442 the missing prices (PUT /api/v1/epex-prices/{{date}}) or the \
3443 invoice would silently under-bill."
3444 ),
3445 }],
3446 });
3447 }
3448 if missing_price_intervals > 0 {
3451 tracing::warn!(
3452 missing_intervals = missing_price_intervals,
3453 total_intervals = quantities.dynamic_intervals.len(),
3454 "DynamicElectricityProvider: {missing_price_intervals} zero-consumption \
3455 interval(s) had no EPEX price."
3456 );
3457 }
3458
3459 if !priced_pairs.is_empty() {
3460 let item = DynamicPricing::builder()
3461 .intervals(priced_pairs)
3462 .unit("kWh")
3463 .currency(Currency::EUR)
3464 .build()
3465 .and_then(|dp| dp.calculate())?;
3466
3467 let total_kwh = item.quantity_value().unwrap_or_default();
3468 let total_eur = item.net_amount.into_decimal();
3469 let avg_eur = item.unit_price.as_ref().map_or(Decimal::ZERO, |p| p.value);
3484 let avg_ct = (avg_eur * dec!(100)).round_kfm(4);
3485 positions.push(BillingPosition {
3486 description: format!("Arbeitspreis {source_name} (∅ {avg_ct:.4} ct/kWh)",),
3487 legal_basis: Some("§41a EnWG".to_owned()),
3488 quantity: total_kwh,
3489 unit: "kWh".to_owned(),
3490 unit_price_eur: avg_eur,
3491 net_eur: total_eur,
3492 category: PositionCategory::Commodity,
3493 tags: vec![
3494 "commodity".to_owned(),
3495 "arbeitspreis".to_owned(),
3496 "strom".to_owned(),
3497 "§41a".to_owned(),
3498 ],
3499 applicable_tax_rate: None,
3500 trace: crate::position::PositionTrace::default(),
3501 });
3502
3503 if let Some(nne_ap_ct) = grid.nne_arbeitspreis_ct_per_kwh {
3505 positions.push(
3506 BillingPosition::debit(
3507 "Netznutzungsentgelt Arbeitspreis",
3508 total_kwh,
3509 "kWh",
3510 nne_ap_ct / dec!(100),
3511 PositionCategory::GridCharge,
3512 )
3513 .with_legal_basis("StromNEV")
3514 .with_tag("nne_arbeitspreis")
3515 .with_tag("nne"),
3516 );
3517 }
3518 if let Some(ka_ct) = grid.ka_ct_per_kwh {
3519 positions.push(
3520 BillingPosition::debit(
3521 "Konzessionsabgabe",
3522 total_kwh,
3523 "kWh",
3524 ka_ct / dec!(100),
3525 PositionCategory::GridCharge,
3526 )
3527 .with_legal_basis("KAV §2")
3528 .with_tag("konzessionsabgabe")
3529 .with_tag("nne"),
3530 );
3531 }
3532
3533 positions.extend(stromsteuer_positions(
3537 product.stromsteuer_tarif,
3538 total_kwh,
3539 rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override),
3540 &["strom"],
3541 ));
3542 }
3543
3544 if let Some(nne_gp) = grid.nne_grundpreis_eur_per_year {
3546 let daily = nne_gp / Decimal::from(time::util::days_in_year(ctx.period_from().year()));
3549 positions.push(
3554 BillingPosition::debit(
3555 "Netznutzungsentgelt Grundpreis",
3556 Decimal::from(ctx.prorate_days().0),
3557 "Tage",
3558 daily,
3559 PositionCategory::GridCharge,
3560 )
3561 .with_legal_basis("StromNEV")
3562 .with_tag("nne_grundpreis")
3563 .with_tag("nne"),
3564 );
3565 }
3566
3567 let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
3570 positions.extend(electricity_common_positions(ctx, product, &meter));
3571
3572 let hinweise = entlastungs_hinweise(&product.steuerentlastungen, &positions);
3574 positions.extend(hinweise);
3575
3576 if let Some(rate) = product.mwst_rate_override {
3580 for pos in &mut positions {
3581 if pos.applicable_tax_rate.is_none()
3582 && !matches!(
3583 pos.category,
3584 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
3585 )
3586 {
3587 pos.applicable_tax_rate = Some(rate);
3588 }
3589 }
3590 }
3591 if let Some(comp) = &quantities.sect41a_annual_comparison {
3593 let sign = if comp.savings_eur >= Decimal::ZERO {
3594 "Ersparnis"
3595 } else {
3596 "Mehrkosten"
3597 };
3598 positions.push(BillingPosition {
3599 description: format!(
3600 "§41a Abs. 6 EnWG Jahresvergleich: {:.2} EUR (Dynamisch) vs. {:.2} EUR (Festpreis {:.4} ct/kWh) -> {} {:.2} EUR",
3601 comp.actual_eur_brutto, comp.reference_eur_brutto,
3602 comp.reference_price_ct_per_kwh, sign, comp.savings_eur.abs(),
3603 ),
3604 legal_basis: Some("§41a Abs. 6 EnWG".to_owned()),
3605 quantity: comp.actual_kwh,
3606 unit: "kWh".to_owned(),
3607 unit_price_eur: Decimal::ZERO,
3608 net_eur: Decimal::ZERO,
3609 category: PositionCategory::Info,
3610 tags: vec!["sect41a_annual_comparison".to_owned()],
3611 applicable_tax_rate: None,
3612 trace: crate::position::PositionTrace::default(),
3613 });
3614 }
3615
3616 Ok(positions)
3617 }
3618}
3619
3620pub struct MwStProvider {
3637 rate: Decimal,
3639}
3640
3641impl MwStProvider {
3642 #[must_use]
3644 pub fn new(rate: Decimal) -> Self {
3645 Self { rate }
3646 }
3647}
3648
3649impl BillingProvider for MwStProvider {
3650 fn bill(
3651 &self,
3652 _ctx: &BillingContext,
3653 _quantities: &Quantities,
3654 prior: &[BillingPosition],
3655 ) -> Result<Vec<BillingPosition>, EngineError> {
3656 use std::collections::BTreeMap;
3657
3658 let mut rate_buckets: BTreeMap<String, (Decimal, Decimal)> = BTreeMap::new();
3661
3662 for p in prior {
3663 if matches!(
3664 p.category,
3665 PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
3666 ) {
3667 continue;
3668 }
3669 let effective_rate = p.applicable_tax_rate.unwrap_or(self.rate).normalize();
3670 if effective_rate.is_zero() {
3671 continue; }
3673 let key = effective_rate.to_string();
3678 let entry = rate_buckets
3679 .entry(key)
3680 .or_insert((effective_rate, Decimal::ZERO));
3681 entry.1 += p.net_eur;
3682 }
3683
3684 if rate_buckets.is_empty() {
3685 return Ok(vec![]);
3686 }
3687
3688 let mut tax_positions: Vec<BillingPosition> = Vec::with_capacity(rate_buckets.len());
3689 for (_key, (rate, net_base)) in rate_buckets {
3690 if net_base.is_zero() {
3691 continue;
3692 }
3693 let mwst_eur = validated_eur((net_base.abs() * rate).round_kfm(2));
3701 let mwst_eur = if net_base < Decimal::ZERO {
3703 -mwst_eur
3704 } else {
3705 mwst_eur
3706 };
3707 let pct = (rate * dec!(100)).normalize();
3708 tax_positions.push(BillingPosition {
3709 description: format!("Mehrwertsteuer {pct}\u{202f}%"),
3710 legal_basis: Some("\u{a7}12 UStG".to_owned()),
3711 quantity: Decimal::ONE,
3712 unit: "%".to_owned(),
3713 unit_price_eur: mwst_eur,
3714 net_eur: mwst_eur,
3715 category: PositionCategory::Tax,
3716 tags: vec!["mwst".to_owned(), "tax".to_owned()],
3717 applicable_tax_rate: Some(rate),
3718 trace: crate::position::PositionTrace::tax(rate, net_base, "§12 UStG"),
3719 });
3720 }
3721
3722 Ok(tax_positions)
3723 }
3724
3725 fn is_tax_pass(&self) -> bool {
3726 true
3727 }
3728
3729 fn charged_tax_rate(&self) -> Option<Decimal> {
3730 Some(self.rate)
3731 }
3732}
3733
3734fn electricity_common_positions(
3744 ctx: &BillingContext,
3745 product: &ElectricityProduct,
3746 meter: &crate::quantities::MeterInput,
3747) -> Vec<BillingPosition> {
3748 let mut positions: Vec<BillingPosition> = Vec::new();
3749 let days = ctx.prorate_days().0 as i64;
3750 if let Some(bonus) = product.sofortbonus_eur.filter(|v| *v > Decimal::ZERO) {
3755 positions.push(
3756 BillingPosition::debit(
3757 "Sofortbonus / Neukundenbonus",
3758 Decimal::ONE,
3759 "Bonus",
3760 -bonus,
3761 PositionCategory::Bonus,
3762 )
3763 .with_legal_basis("Vertraglich (§17 UStG Entgeltminderung)")
3764 .with_tag("bonus")
3765 .with_tag("sofortbonus"),
3766 );
3767 }
3768 if let Some(treue) = product
3769 .treuebonus_eur_per_year
3770 .filter(|v| *v > Decimal::ZERO)
3771 {
3772 let frac = ctx.billed_years().round_kfm(4);
3774 positions.push(
3775 BillingPosition::debit(
3776 "Treuebonus (anteilig)",
3777 frac,
3778 "Jahr",
3779 -treue,
3780 PositionCategory::Bonus,
3781 )
3782 .with_legal_basis("Vertraglich (§17 UStG Entgeltminderung)")
3783 .with_tag("bonus")
3784 .with_tag("treuebonus"),
3785 );
3786 }
3787 if let Some(msb_ct_day) = product
3791 .msb_gebuehr_ct_per_day
3792 .filter(|v| *v > Decimal::ZERO)
3793 {
3794 positions.push(
3795 BillingPosition::debit(
3796 "Messstellenbetrieb Grundgebühr",
3797 Decimal::from(days),
3798 "Tage",
3799 msb_ct_day / dec!(100),
3800 PositionCategory::Fee,
3801 )
3802 .with_legal_basis("MsbG")
3803 .with_tag("msb_gebuehr"),
3804 );
3805 }
3806
3807 if meter.zaehlerstand_von.is_some() || meter.zaehlerstand_bis.is_some() {
3809 let label = format!(
3815 "Zählerstand: {} – {}{}",
3816 meter
3817 .zaehlerstand_von
3818 .map(|v| v.to_string())
3819 .unwrap_or_else(|| "-".to_owned()),
3820 meter
3821 .zaehlerstand_bis
3822 .map(|v| v.to_string())
3823 .unwrap_or_else(|| "-".to_owned()),
3824 meter
3825 .ablesungsart
3826 .label()
3827 .map(|l| format!(" ({l})"))
3828 .unwrap_or_default(),
3829 );
3830 let zid = meter
3831 .zaehlernummer
3832 .as_deref()
3833 .or(ctx.zaehler_id.as_deref())
3834 .unwrap_or("-");
3835 positions.push(BillingPosition {
3836 description: label,
3837 legal_basis: Some("§40 Abs. 2 Nr. 6 EnWG".to_owned()),
3838 quantity: Decimal::ZERO,
3839 unit: "kWh".to_owned(),
3840 unit_price_eur: Decimal::ZERO,
3841 net_eur: Decimal::ZERO,
3842 category: PositionCategory::Info,
3843 tags: vec!["zaehlerstand".to_owned(), zid.to_owned()],
3844 applicable_tax_rate: None,
3845 trace: crate::position::PositionTrace::default(),
3846 });
3847 }
3848
3849 if let Some(vh) = &ctx.verbrauchshistorie {
3854 if let Some(vj_kwh) = vh.vorjahr_kwh {
3855 positions.push(BillingPosition {
3856 description: format!("Verbrauch Vorjahreszeitraum: {vj_kwh:.0} kWh"),
3857 legal_basis: Some("§40 Abs. 2 Nr. 7 EnWG".to_owned()),
3858 quantity: vj_kwh,
3859 unit: "kWh".to_owned(),
3860 unit_price_eur: Decimal::ZERO,
3861 net_eur: Decimal::ZERO,
3862 category: PositionCategory::Info,
3863 tags: vec!["verbrauchshistorie".to_owned(), "vorjahr".to_owned()],
3864 applicable_tax_rate: None,
3865 trace: crate::position::PositionTrace::default(),
3866 });
3867 }
3868 if let Some(avg_kwh) = vh.bundesdurchschnitt_kwh {
3869 let kundengruppe = vh.kundengruppe.as_deref().unwrap_or("Vergleichsgruppe");
3870 positions.push(BillingPosition {
3871 description: format!("Bundesdurchschnitt {kundengruppe}: {avg_kwh:.0} kWh"),
3872 legal_basis: Some("§40 Abs. 2 Nr. 8 EnWG".to_owned()),
3873 quantity: avg_kwh,
3874 unit: "kWh".to_owned(),
3875 unit_price_eur: Decimal::ZERO,
3876 net_eur: Decimal::ZERO,
3877 category: PositionCategory::Info,
3878 tags: vec![
3879 "verbrauchshistorie".to_owned(),
3880 "bundesdurchschnitt".to_owned(),
3881 ],
3882 applicable_tax_rate: None,
3883 trace: crate::position::PositionTrace::default(),
3884 });
3885 }
3886 }
3887
3888 if meter.is_estimated {
3894 positions.push(BillingPosition {
3895 description: "Abrechnungswert: Schätzung gemäß § 40a Abs. 2 EnWG — \
3896 auf Wunsch Korrektur nach realer Ablesung"
3897 .to_owned(),
3898 legal_basis: Some("§ 40a Abs. 2 EnWG".to_owned()),
3899 quantity: Decimal::ZERO,
3900 unit: String::new(),
3901 unit_price_eur: Decimal::ZERO,
3902 net_eur: Decimal::ZERO,
3903 category: PositionCategory::Info,
3904 tags: vec!["schatzwert".to_owned(), "ersatzwert".to_owned()],
3905 applicable_tax_rate: None,
3906 trace: crate::position::PositionTrace::default(),
3907 });
3908 }
3909
3910 if meter.zaehler_replaced {
3912 positions.push(BillingPosition {
3913 description: "Zählerwechsel innerhalb des Abrechnungszeitraums".to_owned(),
3914 legal_basis: Some("§40 Abs. 2 Nr. 6 EnWG".to_owned()),
3915 quantity: Decimal::ZERO,
3916 unit: String::new(),
3917 unit_price_eur: Decimal::ZERO,
3918 net_eur: Decimal::ZERO,
3919 category: PositionCategory::Info,
3920 tags: vec!["zaehlerwechsel".to_owned()],
3921 applicable_tax_rate: None,
3922 trace: crate::position::PositionTrace::default(),
3923 });
3924 }
3925
3926 if let Some(pg_bis) = product.preisgarantie_bis.filter(|d| *d >= ctx.period_to()) {
3928 positions.push(BillingPosition {
3929 description: format!("Preisgarantie gültig bis {pg_bis}"),
3930 legal_basis: Some("§41 Abs. 1 Nr. 4 EnWG".to_owned()),
3931 quantity: Decimal::ZERO,
3932 unit: String::new(),
3933 unit_price_eur: Decimal::ZERO,
3934 net_eur: Decimal::ZERO,
3935 category: PositionCategory::Info,
3936 tags: vec!["preisgarantie".to_owned()],
3937 applicable_tax_rate: None,
3938 trace: crate::position::PositionTrace::default(),
3939 });
3940 }
3941
3942 positions
3943}
3944
3945fn indexwert_warning(
3954 idx: Option<&crate::tariff::IndexedPriceConfig>,
3955 has_other_price: bool,
3956) -> Option<BillingWarning> {
3957 let idx = idx.filter(|i| i.index_value.is_none())?;
3958 Some(BillingWarning {
3959 code: "INDEXWERT_FEHLT",
3960 severity: if has_other_price {
3961 WarningSeverity::Warning
3962 } else {
3963 WarningSeverity::Error
3964 },
3965 message: format!(
3966 "der Indexwert für '{}' fehlt — der vertraglich vereinbarte Arbeitspreis \
3967 kann nicht bestimmt werden",
3968 idx.index_name
3969 ),
3970 })
3971}
3972
3973fn info_position(
3976 description: impl Into<String>,
3977 legal_basis: &'static str,
3978 tags: &[&'static str],
3979) -> BillingPosition {
3980 BillingPosition {
3981 description: description.into(),
3982 legal_basis: Some(legal_basis.to_owned()),
3983 quantity: Decimal::ZERO,
3984 unit: String::new(),
3985 unit_price_eur: Decimal::ZERO,
3986 net_eur: Decimal::ZERO,
3987 category: PositionCategory::Info,
3988 tags: tags.iter().map(|t| (*t).to_owned()).collect(),
3989 applicable_tax_rate: None,
3990 trace: crate::position::PositionTrace::default(),
3991 }
3992}
3993
3994fn stromsteuer_positions(
4001 tarif: crate::steuer::StromsteuerTarif,
4002 kwh: Decimal,
4003 regelsatz_ct: Decimal,
4004 extra_tags: &[&'static str],
4005) -> Vec<BillingPosition> {
4006 use crate::steuer::StromsteuerTarif;
4007 if kwh <= Decimal::ZERO {
4008 return Vec::new();
4009 }
4010 match tarif {
4011 StromsteuerTarif::Befreiung { grund } => vec![BillingPosition {
4012 description: grund.description().to_owned(),
4013 legal_basis: Some(grund.citation().to_owned()),
4014 quantity: kwh,
4015 unit: "kWh".to_owned(),
4016 unit_price_eur: Decimal::ZERO,
4017 net_eur: Decimal::ZERO,
4018 category: PositionCategory::Info,
4019 tags: vec!["stromsteuer_befreiung".to_owned()],
4020 applicable_tax_rate: None,
4021 trace: crate::position::PositionTrace::commodity(
4022 kwh,
4023 "kWh",
4024 Decimal::ZERO,
4025 grund.citation(),
4026 ),
4027 }],
4028 StromsteuerTarif::Ermaessigung { grund } => {
4029 let mut p = levy_position(
4033 grund.label(),
4034 kwh,
4035 "kWh",
4036 grund.rate_ct_per_kwh(),
4037 grund.citation(),
4038 "stromsteuer",
4039 );
4040 for t in extra_tags {
4041 p = p.with_tag(*t);
4042 }
4043 vec![p.with_tag("stromsteuer_ermaessigt")]
4044 }
4045 StromsteuerTarif::Regel => {
4046 if regelsatz_ct <= Decimal::ZERO {
4047 return Vec::new();
4048 }
4049 let mut p = levy_position(
4050 "Stromsteuer",
4051 kwh,
4052 "kWh",
4053 regelsatz_ct,
4054 "§ 3 StromStG",
4055 "stromsteuer",
4056 );
4057 for t in extra_tags {
4058 p = p.with_tag(*t);
4059 }
4060 vec![p]
4061 }
4062 }
4063}
4064
4065fn energiesteuer_positions(
4067 tarif: crate::steuer::EnergiesteuerTarif,
4068 kwh_hs: Decimal,
4069 regelsatz_ct: Decimal,
4070) -> Vec<BillingPosition> {
4071 use crate::steuer::EnergiesteuerTarif;
4072 if kwh_hs <= Decimal::ZERO {
4073 return Vec::new();
4074 }
4075 match tarif {
4076 EnergiesteuerTarif::Befreiung { grund } => vec![BillingPosition {
4077 description: grund.description().to_owned(),
4078 legal_basis: Some(grund.citation().to_owned()),
4079 quantity: kwh_hs,
4080 unit: "kWh_Hs".to_owned(),
4081 unit_price_eur: Decimal::ZERO,
4082 net_eur: Decimal::ZERO,
4083 category: PositionCategory::Info,
4084 tags: vec!["energiesteuer_gas_befreiung".to_owned(), "gas".to_owned()],
4085 applicable_tax_rate: None,
4086 trace: crate::position::PositionTrace::commodity(
4087 kwh_hs,
4088 "kWh_Hs",
4089 Decimal::ZERO,
4090 grund.citation(),
4091 ),
4092 }],
4093 EnergiesteuerTarif::Regel => {
4094 if regelsatz_ct <= Decimal::ZERO {
4095 return Vec::new();
4096 }
4097 vec![
4098 levy_position(
4099 "Energiesteuer Erdgas",
4100 kwh_hs,
4101 "kWh_Hs",
4102 regelsatz_ct,
4103 "§ 2 Abs. 3 Satz 1 Nr. 4 EnergieStG",
4106 "energiesteuer_gas",
4107 )
4108 .with_tag("gas"),
4109 ]
4110 }
4111 }
4112}
4113
4114fn entlastungs_hinweise(
4121 entlastungen: &[crate::steuer::Steuerentlastung],
4122 prior: &[BillingPosition],
4123) -> Vec<BillingPosition> {
4124 entlastungen
4125 .iter()
4126 .map(|e| {
4127 let levied = BillingPosition::total_by_tag(prior, e.levy_tag());
4128 BillingPosition {
4129 description: format!("{} (ausgewiesen: {levied:.2} EUR)", e.hinweis()),
4130 legal_basis: Some(e.citation().to_owned()),
4131 quantity: Decimal::ZERO,
4132 unit: String::new(),
4133 unit_price_eur: Decimal::ZERO,
4134 net_eur: Decimal::ZERO,
4135 category: PositionCategory::Info,
4136 tags: vec!["steuerentlastung".to_owned()],
4137 applicable_tax_rate: None,
4138 trace: crate::position::PositionTrace::default(),
4139 }
4140 })
4141 .collect()
4142}
4143
4144#[inline]
4153fn billing_item_to_position(
4154 item: billing::LineItem,
4155 category: PositionCategory,
4156 legal_basis: &str,
4157 tags: &[&str],
4158) -> BillingPosition {
4159 BillingPosition {
4160 description: item.description,
4161 legal_basis: Some(legal_basis.to_owned()),
4162 quantity: item.quantity.as_ref().map(|q| q.value).unwrap_or_default(),
4163 unit: item
4164 .quantity
4165 .as_ref()
4166 .map(|q| q.unit.clone())
4167 .unwrap_or_default(),
4168 unit_price_eur: item
4169 .unit_price
4170 .as_ref()
4171 .map(|p| p.value)
4172 .unwrap_or_default(),
4173 net_eur: item.net_amount.into_decimal(),
4174 category,
4175 tags: tags.iter().map(|s| s.to_string()).collect(),
4176 applicable_tax_rate: None,
4177 trace: crate::position::PositionTrace::default(),
4178 }
4179}
4180
4181fn build_block_tariff_positions(
4194 tiers: &[crate::tariff::BlockTierInput],
4195 kwh: Decimal,
4196 extra_tags: &[&str],
4197) -> Result<Vec<BillingPosition>, EngineError> {
4198 let mut builder = RateSchedule::graduated().unit("kWh");
4199 let mut prev: Option<Decimal> = None;
4200
4201 for (idx, tier) in tiers.iter().enumerate() {
4202 let price_eur =
4203 billing::Amount::<5>::try_from((tier.preis_ct_per_kwh / dec!(100)).round_kfm(5))
4204 .map_err(|_| EngineError::PriceOutOfRange {
4205 field: format!("blocktarif_stufe_{}_preis_ct_per_kwh", idx + 1),
4206 value: tier.preis_ct_per_kwh,
4207 })?;
4208 let desc = match tier.bis_kwh {
4209 Some(upper) => format!(
4210 "Arbeitspreis Strom Stufe {} (bis {upper}\u{202f}kWh)",
4211 idx + 1
4212 ),
4213 None => format!("Arbeitspreis Strom Stufe {}", idx + 1),
4214 };
4215 let band = match (prev, tier.bis_kwh) {
4216 (None, Some(upper)) => RateBand::up_to(upper, price_eur),
4217 (Some(lower), Some(upper)) => RateBand::between(lower, upper, price_eur),
4218 (lower, None) => RateBand::over(lower.unwrap_or(Decimal::ZERO), price_eur),
4219 }
4220 .with_description(desc);
4221 builder = builder.band(band);
4222 prev = tier.bis_kwh;
4223 }
4224
4225 let items = builder.build().and_then(|s| s.split(kwh))?;
4226 let mut tags: Vec<&str> = vec!["strom", "arbeitspreis", "block_tier"];
4227 tags.extend_from_slice(extra_tags);
4228 Ok(items
4229 .into_iter()
4230 .map(|item| billing_item_to_position(item, PositionCategory::Commodity, "§41 EnWG", &tags))
4231 .collect())
4232}
4233
4234pub struct EnergyShareProvider {
4266 product: SharingProduct,
4267}
4268
4269impl EnergyShareProvider {
4270 pub fn new(product: SharingProduct) -> Self {
4271 Self { product }
4272 }
4273}
4274
4275impl BillingProvider for EnergyShareProvider {
4276 fn validate_warnings(
4277 &self,
4278 _ctx: &crate::context::BillingContext,
4279 quantities: &crate::quantities::Quantities,
4280 ) -> Vec<BillingWarning> {
4281 let allocated = quantities
4290 .energy_share
4291 .as_ref()
4292 .map_or(Decimal::ZERO, |s| s.allocated_kwh);
4293 if allocated > Decimal::ZERO && self.product.sharing_credit_ct_per_kwh.is_none() {
4294 return vec![BillingWarning {
4295 code: "KEIN_SHARING_GUTSCHRIFTSATZ",
4296 severity: WarningSeverity::Error,
4297 message: format!(
4298 "es wurden {allocated} kWh aus der Energiegemeinschaft zugeteilt, das \
4299 Produkt nennt aber keinen Gutschriftsatz (sharing_credit_ct_per_kwh) — \
4300 die Rechnung enthielte den vollen Netzbezug ohne die §42c-Gutschrift. \
4301 Satz hinterlegen, oder 0.0 setzen, wenn tatsächlich nichts \
4302 gutgeschrieben wird."
4303 ),
4304 }];
4305 }
4306 Vec::new()
4307 }
4308
4309 fn bill(
4310 &self,
4311 _ctx: &crate::context::BillingContext,
4312 quantities: &crate::quantities::Quantities,
4313 _prior: &[BillingPosition],
4314 ) -> Result<Vec<BillingPosition>, EngineError> {
4315 let product = &self.product;
4316 let mut positions: Vec<BillingPosition> = Vec::new();
4317
4318 let Some(credit_rate_ct) = product.sharing_credit_ct_per_kwh else {
4322 return Ok(positions);
4323 };
4324 if credit_rate_ct.is_zero() {
4325 return Ok(positions);
4326 }
4327 let credit_rate_eur = credit_rate_ct / dec!(100);
4328
4329 let allocated_kwh = quantities
4331 .energy_share
4332 .as_ref()
4333 .map(|s| s.allocated_kwh)
4334 .unwrap_or(Decimal::ZERO);
4335 if allocated_kwh <= Decimal::ZERO {
4336 return Ok(positions);
4337 }
4338
4339 let description = product
4340 .sharing_description
4341 .clone()
4342 .unwrap_or_else(|| "Energiegemeinschaft Gutschrift (§42c EnWG)".to_owned());
4343
4344 let mut pos = BillingPosition::credit(
4345 description,
4346 allocated_kwh,
4347 "kWh",
4348 credit_rate_eur,
4349 PositionCategory::EnergyShare,
4350 )
4351 .with_legal_basis("§42c EnWG")
4352 .with_tag("sharing")
4353 .with_tag("strom");
4354 pos.trace = crate::position::PositionTrace::commodity(
4355 allocated_kwh,
4356 "kWh",
4357 -credit_rate_eur,
4358 "§42c EnWG",
4359 )
4360 .with_basis("Energiegemeinschaft-Liefervertrag");
4361 positions.push(pos);
4362
4363 let share = quantities.energy_share.as_ref();
4368 if let Some(id) = share.and_then(|s| s.gemeinschaft_id.as_deref()) {
4369 positions.push(info_position(
4370 format!("Energiegemeinschaft: {id}"),
4371 "§42c EnWG",
4372 &["sharing", "gemeinschaft"],
4373 ));
4374 }
4375 if let (Some(total), Some(fraction)) = (
4376 share.and_then(|s| s.total_plant_generation_kwh),
4377 share.and_then(|s| s.allocation_fraction),
4378 ) {
4379 positions.push(info_position(
4380 format!(
4381 "Zuteilung: {:.1}\u{202f}% von {total:.3}\u{202f}kWh Gemeinschaftserzeugung = {allocated_kwh:.3}\u{202f}kWh",
4382 fraction * dec!(100)
4383 ),
4384 "§42c EnWG",
4385 &["sharing", "zuteilung"],
4386 ));
4387 }
4388
4389 Ok(positions)
4390 }
4391}