Skip to main content

energy_billing/
providers.rs

1//! Concrete `BillingProvider` implementations for all product types.
2//!
3//! Each provider corresponds to one product category. Build providers from
4//! a `TariffInput` (the product definition from `productd`) and register them
5//! with `BillingEngine`.
6
7use 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
26// ── ElectricityProvider ───────────────────────────────────────────────────────
27
28/// STROM / WAERMEPUMPE / WALLBOX billing provider.
29///
30/// Produces commodity positions (Grundpreis, Arbeitspreis HT/NT, §14a credits).
31/// Does NOT include MwSt — add `MwStProvider` to the engine.
32/// Stromsteuer is included as a levy position.
33pub 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    /// Construct from a [`Product`](crate::Product) by extracting the electricity variant.
45    /// Accepts `Strom`, `Waermepumpe`, `Wallbox` (uses `.base`), and `Sharing` (uses `.electricity`).
46    ///
47    /// # Panics
48    /// Panics when the `Product` variant is not electricity-compatible.
49    #[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        // A commodity product must be able to price its commodity.
74        //
75        // Without this, a `StromProduct` carrying no Arbeitspreis at all — every
76        // price field `None` — billed 1000 kWh for €20.50: the Stromsteuer, and
77        // nothing for the electricity. No error, no warning, an invoice that
78        // looks ordinary. That is not a hypothetical: the price fields are
79        // populated by mapping `productd`'s `preistyp` strings onto struct
80        // fields, and a renamed or missing position maps to `None` in silence.
81        //
82        // Error severity, so `bill()` refuses. A product that genuinely charges
83        // no work price still states one (`0.0`); the missing case is a data
84        // defect, and a zero is how an operator says they mean it.
85        //
86        // An `indexed_price` counts only when its index value has actually
87        // arrived: `effective_ct_per_kwh()` returns `None` without one, the
88        // provider then adds no Arbeitspreis position, and the invoice looks
89        // exactly like the priceless-product case this guard exists to catch.
90        // Counting the *presence* of the config let a stale index feed produce
91        // a Grundpreis-only B2B invoice with a clean bill of health.
92        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        // § 40 Abs. 2 Nr. 6 EnWG requires the invoice to state *how* the reading
128        // was obtained. An unstated Ablesungsart leaves that sentence off the
129        // page — a Pflichtangabe missing, not a cosmetic gap — so it is flagged
130        // where a reading is actually being billed.
131        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        // An estimated reading is billable (§ 40a Abs. 2 EnWG), but the caller
146        // must know it happened: the customer can demand a corrected invoice
147        // once a real reading arrives, so dispatch systems treat it differently.
148        // A finding rather than an Info position alone, which paper shows and
149        // code cannot see.
150        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        // A price guarantee that ends inside or within 30 days of the billed
161        // period is something the operator wants to see before dispatch.
162        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        // A consumption deviation beyond 50 % of the prior year is the standard
176        // plausibility threshold before an invoice goes out: it usually means a
177        // meter fault, a reading transposition, or a tenant change nobody booked.
178        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        // A product must be able to price the quantities it is *given*, not just
198        // carry a price field.
199        //
200        // A `Zweitarif` product prices HT and NT and nothing else. Handed a
201        // meter that reports only a total — which is what `edmd` returns
202        // whenever the register split did not arrive — the HT/NT branch does not
203        // fire, no other branch matches, and the invoice bills 1000 kWh for
204        // €20.50: the Stromsteuer, and nothing for the electricity. That is the
205        // priceless-product defect exactly, reached from the other side, and
206        // `KEIN_ARBEITSPREIS` waves it through because `arbeitspreis_ht…` is
207        // populated.
208        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        // Half a Zweitarif prices one band and not the other. There is no
232        // sensible reading of that: billing the unpriced band at the other's
233        // rate invents a price, and dropping it under-bills.
234        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        // An HT/NT split that does not add up to the stated total prices one of
251        // the two figures wrongly, and which one is not knowable here.
252        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            // A tenth of a kWh over a billing period is measurement noise; more
259            // is a register that was not reconciled.
260            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        // Electricity was 16 % in H2/2020 (§28 Abs. 1 UStG a.F.), 19 % otherwise.
275        // A period straddling that boundary has no single correct rate — split at
276        // the Stichtag and merge, the same discipline the gas/heat providers apply.
277        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        // The consumption to bill: the stated total, or the HT/NT registers when
297        // the caller supplied only those. Everything downstream — NNE, KA,
298        // Stromsteuer — is charged on the same figure the Arbeitspreis is.
299        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        // ── Resolve seasonal arbeitspreis ──────────────────────────────────────
306        // When seasonal_prices is set, the price for the billing month is looked up.
307        // Uses ctx.period_from() month as the representative month for the period.
308        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        // ── Prosumer billing path ──────────────────────────────────────────────
317        // When prosumer meter data is provided, bill only grid_consumption.
318        // Self-consumed electricity is Stromsteuer-exempt (§ 9 Abs. 1 Nr. 3 StromStG)
319        // and does NOT attract NNE charges.
320        if let Some(p) = &quantities.prosumer {
321            return self.bill_prosumer(ctx, p, product, grid, rates, seasonal_arbeitspreis);
322        }
323
324        // ── Grundpreis ─────────────────────────────────────────────────────────
325        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        // ── Arbeitspreis ───────────────────────────────────────────────────────
339        // Any billable quantity opens the block, not the total alone: a caller
340        // that supplies the HT/NT registers and leaves `arbeitsmenge_kwh` at
341        // zero has still delivered electricity, and gating on the total billed
342        // them nothing for it.
343        if meter.billable_kwh() > Decimal::ZERO {
344            if let Some(tiers) = product.block_tiers.as_ref().filter(|t| !t.is_empty()) {
345                // Delegate to billing::RateSchedule for correct graduated pricing.
346                // Replaces manual tier iteration — gains contiguous-band validation
347                // and exact Amount<5> arithmetic. Legal basis: §41 EnWG.
348                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                // …and the *product* prices **both** bands. Selecting the arm
353                // on the meter alone would send a two-register meter on a
354                // single-rate tariff down it, where there are no band prices to
355                // build — leaving the electricity unbilled while the Stromsteuer
356                // is charged. A half-priced Zweitarif is refused earlier, by
357                // `validate_warnings`.
358                product.arbeitspreis_ht_ct_per_kwh.is_some()
359                    && product.arbeitspreis_nt_ct_per_kwh.is_some(),
360            ) {
361                // Zweitarif (HT/NT) — billing::TimeOfUsePricing for validated band arithmetic.
362                // Negative quantities return Err; zero quantities are skipped silently.
363                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                // ── Indexed price (B2B, §41 EnWG Sonderkundenvertrag) ─────────
412                // Effective price = base + spread + index_value × factor.
413                // Ahead of the static prices, not behind them: a product that
414                // carries both agreed the indexed one, and resolving to the
415                // static fallback would bill a price the contract does not
416                // contain. When the index has not arrived, `validate_warnings`
417                // has already refused the run (`INDEXWERT_FEHLT`) unless another
418                // price is contracted alongside.
419                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                // Use seasonal price when available, otherwise base tariff price.
433                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        // ── EEG-Gutschrift pass-through ────────────────────────────────────────
452        // The feed-in is a separate supply with its own USt status: for a §19
453        // Kleinunternehmer operator it carries 0 %, so the credit must not net
454        // against the standard-rate consumption base — that would understate the
455        // supplier's own output VAT by the standard rate on the credit.
456        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            // §19 Abs. 1 EEG 2023 is the Zahlungsanspruch itself. Which
467            // Veräußerungsform it takes — §20 Marktprämie or §21 Abs. 1
468            // Einspeisevergütung — is the plant's, and `einsd` decides it; this
469            // position is the pass-through of whatever einsd computed, so the
470            // anchor must be the entitlement rather than one of its two forms.
471            // (It read "§38 EEG 2023", which is Zahlungsberechtigung für
472            // Solaranlagen des ersten Segments — an auction provision that has
473            // nothing to do with a rooftop feed-in credit.)
474            .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        // ── Grid charges (NNE / KA) ────────────────────────────────────────────
484        let kwh_for_grid = kwh;
485        if let Some(nne_gp) = grid.nne_grundpreis_eur_per_year {
486            // Leap-aware: an EUR/year rate divides by that year's actual days
487            // (366 in 2024/2028), or the daily rate overstates the Grundpreis.
488            let daily = nne_gp / Decimal::from(time::util::days_in_year(ctx.period_from().year()));
489            // Active contract days, not the full billing period: the NNE
490            // Grundpreis accrues only while the contract supplies the MaLo, the
491            // same clipping the commodity Grundpreis applies. Billing the full
492            // period over-charged every mid-period move-in and move-out.
493            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        // ── RLM Leistungspreis (demand charge) ────────────────────────────────
553        // For large commercial customers on RLM metering (≥100 MWh/year) with
554        // a capacity-based Leistungspreis in the supply contract.
555        //
556        // Billed on Spitzenleistung (peak demand, kW). The rate is per kW *and
557        // month*, so it scales with the billed period's month fraction — an
558        // annual invoice owes twelve months of it, a half-month move-out half
559        // of one. Every capacity rate in the crate prorates to the period it is
560        // billed for; only the unit differs (the NNE Leistungspreis is per
561        // kW-year and scales in years).
562        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        // ── Stromsteuer ────────────────────────────────────────────────────────
581        positions.extend(stromsteuer_positions(
582            product.stromsteuer_tarif,
583            kwh,
584            rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override),
585            &["strom"],
586        ));
587        // A Steuerentlastung leaves the levy where it is and tells the customer
588        // what to file — see `crate::steuer`.
589        positions.extend(entlastungs_hinweise(
590            &product.steuerentlastungen,
591            &positions,
592        ));
593
594        // ── AufAbschlag / Rabatt ───────────────────────────────────────────────
595        // Per-unit discount or surcharge applied after all commodity positions.
596        // Negative value = customer discount; positive = surcharge.
597        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), // ct/kWh → EUR/kWh
613                    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            // `eur` already carries the sign of `aa_month`: a negative monthly
633            // amount is a Rabatt and must stay negative. Re-negating it billed
634            // every monthly discount as a surcharge of the same size — the
635            // label said "Rabatt" while the line added money. The gas provider
636            // never had the extra factor, which is why only electricity was hit.
637            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        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
654        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
655        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
656        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    /// Prosumer billing path — bills grid consumption only.
675    ///
676    /// Self-consumed energy is shown as an informational position (§41 EnWG transparency)
677    /// but does NOT attract commodity charges, NNE, or Stromsteuer.
678    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        // Grundpreis over the active contract days, independent of the
692        // consumption split — the same clipping the non-prosumer path applies.
693        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        // Arbeitspreis on grid consumption only
707        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            // NNE on grid consumption only
720            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            // Stromsteuer on grid consumption only (§ 9 Abs. 1 Nr. 3 StromStG:
735            // self-consumption exempt)
736            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        // Informational: self-consumption and energy balance
753        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                // § 9 Abs. 1 Nr. 3 StromStG — an installation up to 2 MW,
760                // consumed by the operator or drawn in the räumlicher
761                // Zusammenhang. Not § 9a, which is a Steuerentlastung for
762                // industrial processes.
763                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        // Wire tax rate (same as normal path)
794        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
810// ── ControllableLoadProvider ──────────────────────────────────────────────────
811
812/// §14a EnWG controllable load billing provider (WAERMEPUMPE / WALLBOX).
813///
814/// Delegates standard electricity billing to [`ElectricityProvider`] and then
815/// appends the §14a credits (Modul 1 pauschale Reduzierung, Modul 2 Arbeitspreis-
816/// reduzierung, Modul 3 zeitvariable Bänder, plus any Steuerungsentschädigung)
817/// credit positions.
818///
819/// ## Legal basis
820///
821/// §14a Abs. 1 EnWG, as BNetzA **BK8-22/010-A** implements it — the Netzentgelt
822/// modules; **BK6-22-300** is the companion Festlegung and governs the
823/// netzorientierte Steuerung a Betreiber must take part in to qualify:
824/// DSOs must offer controllable load (Steuerbare Verbrauchseinrichtungen)
825/// customers a reduced NNE (Modul 1, 2 or 3).
826/// The LF reflects this reduction as a credit on the retail invoice.
827pub 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    /// The §14a Modul 3 Tarifstufen, labelled, in HT/ST/NT order.
839    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    /// Whether this product is on §14a Modul 3 at all.
848    ///
849    /// Any one priced band is the statement that the device is billed on the
850    /// zeitvariables Netzentgelt: the three bands are one tariff, not three
851    /// independent options. Every Modul 3 precondition therefore keys on the
852    /// whole triple — keyed on HT alone, a product that prices ST and NT only
853    /// escapes all of them, including the one that forbids a flat NNE beside
854    /// the bands, and the invoice carries no network charge at all.
855    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        // The base electricity checks apply to the underlying supply.
867        let base = ElectricityProvider::new(self.product.base.clone(), self.grid.clone());
868        let mut w = base.validate_warnings(ctx, quantities);
869
870        // BK8-22/010-A offers one base module and one optional addition. Modul 1
871        // and Modul 2 are the two forms the base takes and the Anschlussnutzer
872        // picks one; Modul 3 adds to Modul 1 alone. So `Modul 1 + Modul 3` is
873        // the only pair, and the three other pairings each reduce the same
874        // network usage twice.
875        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        // The Modul 3 bands *replace* the flat NNE Arbeitspreis. Both at once
911        // bill the device's network usage twice.
912        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        // BK8-22/010-A: "Das Modul 3 kann nur in Kombination mit Modul 1
924        // ausgewählt werden." Modul 3 alone is not an offer the NB makes, so a
925        // product carrying only the bands prices a tariff that does not exist —
926        // and the customer loses the Modul 1 reduction they are entitled to.
927        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        // Modul 3 bills per time band, which needs a meter that resolves them:
942        // BK8-22/010-A makes an intelligentes Messsystem a precondition. The same
943        // guard §41a carries, for the same reason — a band-priced invoice off an
944        // SLP meter is priced against a profile, not against measurement.
945        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        // The three bands are one tariff. A product that prices some of them
963        // emits positions for those and silently drops the rest, and the
964        // dropped band's kWh carry no network charge at all — `MODUL3_AND_FLAT_NNE`
965        // forbids a flat NNE beside the bands, so nothing else picks them up.
966        // A band that genuinely costs nothing is priced `0.0`; absent, it is a
967        // mapping defect, and a rate band silently omitted from the invoice is
968        // indistinguishable from one that was never priced.
969        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        // The Steuerungsentschädigung per kW/Jahr and per kWh both price the
993        // dimmed capacity, so both need the Spitzenleistung the device was
994        // dimmed from. Without it neither branch fires and the compensation the
995        // Anschlussnutzer is owed for a measured dimming leaves no trace.
996        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        // One Steuerungsentschädigung, one rate basis. The per-kW-year and the
1019        // per-kWh variants describe the same compensation for the same dimming
1020        // hours; configured together they both fire and pay it twice.
1021        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        // ── Pass 1: standard electricity billing ─────────────────────────────
1050        let ep = ElectricityProvider::new(self.product.base.clone(), self.grid.clone());
1051        let mut positions = ep.bill(ctx, quantities, prior)?;
1052
1053        // ── Pass 2: §14a credit positions ────────────────────────────────────
1054        let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
1055        let kwh = meter.arbeitsmenge_kwh;
1056        let p = &self.product;
1057
1058        // ── §14a Modul 3 — zeitvariables Netzentgelt (BK8-22/010-A) ─────────────
1059        // Three Tarifstufen replace the flat NNE Arbeitspreis for the device.
1060        // A zero band still produces a position: a rate band silently omitted
1061        // from the invoice is indistinguishable from one that was never priced.
1062        // An incomplete triple never reaches here: `MODUL3_BAND_UNVOLLSTAENDIG`
1063        // refuses the run before any position is generated.
1064        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        // Modul 2 — prozentuale Arbeitspreisreduzierung, as a per-kWh credit
1098        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        // Modul 1 — a flat annual amount, prorated by the period. BK8-22/010-A
1117        // sets it as `80 EUR + 3 750 kWh × Arbeitspreis × 0,2`, so it carries no
1118        // per-kW component and needs no Spitzenleistung: that is what makes it
1119        // the module a household heat pump on an SLP meter can have at all.
1120        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        // Steuerungsentschädigung — annual capacity rate × hours actually dimmed
1138        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        // Steuerungsentschädigung — per kWh of dimmed energy
1161        if let (Some(modul3_ct), Some(steuerung_h)) = (
1162            p.sect14a_steuerungsentschaedigung_ct_per_kwh,
1163            meter.steuerung_stunden,
1164        ) {
1165            // An absent Spitzenleistung is refused by
1166            // `STEUERUNGSENTSCHAEDIGUNG_OHNE_SPITZENLEISTUNG` before this runs;
1167            // a zero here would price a measured dimming at nothing.
1168            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
1190// ── GasProvider ───────────────────────────────────────────────────────────────
1191
1192/// GAS billing provider.
1193///
1194/// Includes Brennwertkorrektur info, commodity positions, gas NNE,
1195/// Energiesteuer and BEHG CO₂ levy. Does NOT include MwSt.
1196pub 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        // Same invariant as electricity: a gas product must be able to price its
1225        // gas. A `GasProduct` with every work-price field `None` bills the
1226        // Energiesteuer and the BEHG levy and nothing for the gas itself.
1227        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        // § 40a Abs. 2 EnWG: an estimated reading is billable but
1255        // the caller must know it happened — dispatch systems treat it
1256        // differently and the customer can demand a corrected invoice.
1257        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        // §25 Nr. 4 MessEV / DVGW G 685: the Zustandszahl converts Betriebs- to
1267        // Normkubikmeter and is never 1 in practice (typically ≈ 0.95). Billing
1268        // a volume reading without it overstates kWh_Hs by 3–5 %.
1269        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        // Gas carried 7 % USt from 01.10.2022 to 31.03.2024 (§28 Abs. 5
1284        // UStG) and 16 % in H2/2020. A period straddling a window boundary
1285        // has no single correct rate — split at the Stichtag and merge.
1286        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        // The BEHG CO₂ price (§10 BEHG) steps at each calendar-year boundary. A
1299        // period spanning a year-end where the rate changes has no single correct
1300        // levy — split at 31.12./01.01. and bill each portion at its year's rate.
1301        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        // ── Seasonal gas price lookup ──────────────────────────────────────────
1329        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        // Compute kWh_Hs
1338        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        // ── Brennwertkorrektur (info position) ────────────────────────────────
1349        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        // ── Gas quality annotation (always added when set) ────────────────────
1370        // Carried as a tagged info position; to_rechnung_json() injects it as ZusatzAttribut.
1371        // Per DVGW G 260: the measured Brennwert already reflects the H2 blend —
1372        // this is a regulatory audit annotation, not a billing correction.
1373        if let Some(ref gq) = meter.gasqualitaet {
1374            positions.push(BillingPosition {
1375                description: format!("Gasqualität: {gq} (§ DVGW G 260)"),
1376                // Use legal_basis to carry the gasqualitaet value for to_rechnung_json()
1377                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        // ── Grundpreis ─────────────────────────────────────────────────────────
1390        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        // ── Gas NNE Grundpreis ─────────────────────────────────────────────────
1404        // A standing charge accrues per day of supply, not per kWh drawn: the
1405        // supplier owes the Netzbetreiber the GasNEV Grundpreis for a MaLo that
1406        // consumed nothing, exactly as it owes the commodity Grundpreis above.
1407        // Both therefore sit outside the consumption guard, as the electricity
1408        // path's NNE Grundpreis does.
1409        if let Some(nne_gp) = grid.gas_nne_grundpreis_eur_per_year {
1410            // Leap-aware: an EUR/year rate divides by that year's actual days
1411            // (366 in 2024/2028), or the daily rate overstates the Grundpreis.
1412            let daily = nne_gp / Decimal::from(time::util::days_in_year(ctx.period_from().year()));
1413            // Active contract days — see the Strom NNE Grundpreis.
1414            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        // ── Arbeitspreis ───────────────────────────────────────────────────────
1429        if kwh_hs > Decimal::ZERO {
1430            // Resolve effective gas price: gas_indexed_price > seasonal > direct.
1431            let active_indexed = product.gas_indexed_price.as_ref();
1432            let gas_ap_ct = if let Some(idx) = active_indexed {
1433                // Gas indexed price (TTF/NCG-linked, §41 EnWG Sonderkundenvertrag)
1434                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            // ── RLM Leistungspreis Gas (demand charge for large gas customers) ────
1480            // Applicable to RLM gas metering points with a capacity-based supply contract.
1481            // Triggered by gas_leistungspreis_ct_per_kw_month + GasMeterInput::spitzenleistung_kw.
1482            // The rate is per kW *and month*, so it scales with the billed
1483            // period's month fraction — the same treatment as the Strom and the
1484            // Fernwärme Leistungspreis.
1485            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            // ── Gas NNE Arbeitspreis, Konzessionsabgabe, Bilanzierungsumlage ──
1506            // All three are per-kWh pass-throughs, so they belong under the
1507            // consumption guard; the GasNEV Grundpreis above does not.
1508            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            // ── Energiesteuer ──────────────────────────────────────────────────
1552            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            // ── BEHG CO₂ ───────────────────────────────────────────────────────
1559            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                // The levy line is § 3 Abs. 1 Nr. 2. The statute asks for five
1573                // more figures beside it; Nr. 6 is the Vermieter's building
1574                // fact and belongs to their Abrechnung, not to this supply.
1575                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        // ── AufAbschlag / Rabatt (Gas) ─────────────────────────────────────────
1586        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                // Same default as the main kWh_Hs conversion — a diverging
1592                // fallback made the AufAbschlag quantity base inconsistent.
1593                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        // ── Zählerstand info position (§40 Abs. 2 Nr. 6 EnWG) ─────────────────
1635        // Meter identity + start/end readings in m³ — same display duty as
1636        // the electricity provider fulfils for kWh registers.
1637        if meter.zaehlerstand_von.is_some() || meter.zaehlerstand_bis.is_some() {
1638            // § 40 Abs. 2 Nr. 6 EnWG — the readings *and* how they were obtained.
1639            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        // ── § 40a Abs. 2 EnWG — estimated reading notice ─────────────────────
1675        // The estimation basis must carry an explicit, prominently marked hint.
1676        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        // A Steuerentlastung leaves the levy where it is and tells the customer
1694        // what to file — see `crate::steuer`.
1695        let hinweise = entlastungs_hinweise(&product.steuerentlastungen, &positions);
1696        positions.extend(hinweise);
1697
1698        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
1699        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
1700        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
1701        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
1717// ── HeatProvider ──────────────────────────────────────────────────────────────
1718
1719/// WAERME (Fernwärme) billing provider.
1720pub 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        // Same invariant as electricity and gas: heat must be priced. A
1748        // Fernwärme product with no Arbeitspreis bills the Grundpreis and the
1749        // Leistungspreis and nothing for the delivered heat.
1750        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        // AVBFernwärmeV § 24 Abs. 4: the Preisgleitklausel *is* the agreed price.
1767        // Falling back to the static Arbeitspreis when the index is missing bills
1768        // a figure the contract does not contain, so it is flagged rather than
1769        // substituted in silence.
1770        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        // Fernwärme carried 7 % USt from 01.10.2022 to 31.03.2024 (§28
1776        // Abs. 6 UStG) and 16 % in H2/2020 — same split discipline as gas.
1777        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        // The billed period decides the month count. `unwrap_or(1)` charged a
1802        // whole year of Fernwärme one month of Grundpreis whenever the caller
1803        // did not state `months` — silently, because a plausible amount came
1804        // out. An explicit `months` still wins: an operator billing on
1805        // Abrechnungsmonate rather than calendar days states them.
1806        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        // AVBFernwärmeV §24 Abs. 4 Preisänderungsklausel: an index-linked
1842        // Arbeitspreis resolves the effective ct/kWh and overrides the static one.
1843        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        // ── CO₂-Kosten (BEHG / CO2KostAufG § 3) ────────────────────────────────
1867        // A Wärmelieferung carries the CO₂ cost of the fuel burned to produce
1868        // it, and **CO2KostAufG § 3** obliges the supplier to state the cost it
1869        // actually bore. The rate is the heat product's own — the generator's
1870        // fuel mix and conversion losses sit between the gas BEHG rate and the
1871        // delivered kWh_th, so reusing the gas rate would be wrong in both
1872        // directions.
1873        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        // § 3 Abs. 1 CO2KostAufG — the five figures that accompany the cost.
1890        //
1891        // The product states the Emissionsfaktor in g/kWh, which is how heat
1892        // networks publish it; Nr. 3 asks for kg CO₂/kWh, so it is converted
1893        // for the statement rather than restated in the wrong unit.
1894        //
1895        // Emitted even where the cost is zero: a fully renewable network still
1896        // owes its customers the statement.
1897        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        // § 14 WPG — the renewable share of the delivered heat.
1906        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        // District heating is standard-rated. There is NO permanent reduced rate
1924        // (§12 Abs. 2 Nr. 1 UStG covers Anlage-2 goods, not heat); the 7 % on
1925        // gas/Fernwärme was the temporary §28 Abs. 5/6 UStG window and is expressed
1926        // via `mwst_rate_override`. When an override is set, stamp it on the heat
1927        // positions so a bundled multi-commodity invoice yields a separate tax
1928        // bucket; otherwise leave them for the engine's period-aware default rate.
1929        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
1945// ── WaterProvider ─────────────────────────────────────────────────────────────
1946
1947/// WASSER billing provider — Trinkwasser + gesplittete Abwassergebühr.
1948///
1949/// Positions and their USt treatment:
1950///
1951/// | Position | Base | USt |
1952/// |---|---|---|
1953/// | Grundpreis Trinkwasser | months × EUR/month | 7 % (§12 Abs. 2 Nr. 1 UStG, Anlage 2 Nr. 34) |
1954/// | Mengenpreis Trinkwasser | frischwasser m³ × EUR/m³ | 7 % |
1955/// | Schmutzwassergebühr | (frischwasser − Absetzungen) m³ × EUR/m³ | none (public-law fee) or 19 % (private charge) |
1956/// | Niederschlagswassergebühr | versiegelte Fläche m² × EUR/m²/a, pro-rated | same as Schmutzwasser |
1957///
1958/// Absetzungen (Gartenwasser, Schleppwasser, Verdunstung, …) reduce only the
1959/// Schmutzwasser volume — the drinking water that fed them was delivered and
1960/// stays billed. Each Absetzung is shown as a 0-EUR Info position so the
1961/// deduction is auditable on the invoice.
1962pub 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        // The `KEIN_ARBEITSPREIS` invariant, on the water side. A tariff that
1983        // prices only the Abwasser side still reaches `bill()`: the
1984        // Schmutzwassergebühr is charged on the Frischwassermaßstab, so the
1985        // invoice comes out with a full Gebühr and not one cent for the
1986        // drinking water that was actually delivered — and it looks complete,
1987        // because a plausible amount is on the page.
1988        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        // EN 16931 BR-O-11 … BR-O-14: a document carrying a "not subject to
2025        // VAT" line may carry nothing else. An öffentlich-rechtliche
2026        // Abwassergebühr is exactly that, and over 90 % of municipalities levy
2027        // one — so a combined Trinkwasser-plus-Abwasser invoice is not a valid
2028        // e-invoice, and the platform issued them silently. Municipalities do
2029        // not in fact combine them: the Gebühr goes out as a Bescheid.
2030        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                // A warning, not a refusal: a combined paper
2039                // Jahresverbrauchsabrechnung is lawful and common. What is not
2040                // possible is rendering it as an e-invoice, and that is refused
2041                // where it actually bites — `Invoice::to_en16931`.
2042                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        // Same reasoning as Fernwärme: an unstated month count is the billed
2078        // period, not one month.
2079        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        // § 12 Abs. 2 Nr. 1 UStG i. V. m. Anlage 2 Nr. 34 — Wasser is reduced-
2089        // rated. The reduced rate itself comes from the period-aware
2090        // `RegulatoryRates`, not a literal, so a statutory change reaches water
2091        // billing the same way it reaches everything else.
2092        let trinkwasser_rate = p
2093            .mwst_rate_override
2094            .unwrap_or(ctx.regulatory_rates.mwst_rate_reduced);
2095        // A public-law Gebühr is hoheitlich — outside the scope of the UStG, so
2096        // EN 16931 category `O`, not `Z`. Zero-rating asserts a taxable supply
2097        // at 0 %, which a Gebührenbescheid is not, and the two carry different
2098        // business rules on the receiving side.
2099        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            // One auditable 0-EUR Info position per Absetzung.
2161            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
2205// ── SolarProvider ─────────────────────────────────────────────────────────────
2206
2207/// SOLAR (Eigenverbrauch / Mieterstrom §21 Abs. 3 / §42b EnWG GGV) billing provider.
2208pub 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        // A commodity product must be able to price its commodity — the same
2228        // invariant electricity, gas and heat carry. Without it a solar product
2229        // billed the Stromsteuer and nothing for the kWh.
2230        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        // \u{a7} 42a Abs. 4 EnWG caps a Mieterstrompreis at 90\u{202f}% of the local
2242        // Grundversorgungstarif. It is a statutory ceiling, so exceeding it does
2243        // not produce a payable invoice — it blocks the run.
2244        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        // ── §42b EnWG (Solarpaket I) GGV hybrid billing ──────────────────
2274        // When GgvSolarInput is present, billing is split into two portions:
2275        // 1. PV portion: min(consumption, allocated_pv) at community solar rate
2276        // 2. Grid portion: max(0, consumption − allocated_pv) at electricity rate
2277        if let Some(ggv) = &quantities.ggv_solar {
2278            let pv_kwh = ggv.pv_delivered_kwh();
2279            let grid_kwh = ggv.grid_kwh();
2280
2281            // ── PV portion ──────────────────────────────────────────────────────
2282            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                // GGV Rabatt applies to the PV portion only
2298                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                // Stromsteuer on the PV portion, through the same § 9 StromStG
2314                // resolution the electricity provider uses — so a Befreiung is
2315                // *stated* on the page with its ground and citation instead of
2316                // the line merely being absent, which is all a bare
2317                // `solar_include_stromsteuer = false` produced.
2318                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            // ── Grid portion ────────────────────────────────────────────────────
2327            // Billed at the grid remainder rate (arbeitspreis_ct_per_kwh).
2328            // Falls back to solar_arbeitspreis_ct_per_kwh if not separately configured.
2329            // Stromsteuer always applies to grid electricity (§3 StromStG).
2330            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                // Stromsteuer on grid portion
2349                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            // Info position: PV coverage ratio (useful for §40a Kilowattstundenpreis reporting)
2367            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            // Wire tax rate for GGV hybrid positions too
2385            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        // ── Standard solar / Mieterstrom / simple GGV path ────────────────────
2403        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        // The Mieterstromzuschlag (\u{a7} 21 Abs. 3 EEG 2023) is deliberately absent
2420        // here: it is the Anlagenbetreiber's claim against the Netzbetreiber,
2421        // settled through `eeg-billing`'s `TenantElectricity` scheme. Billing it
2422        // as a surcharge on the tenant's invoice would charge the tenant for a
2423        // payment somebody else owes the landlord.
2424        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        // Mieterstrom and Eigenverbrauch are supplies like any other: either the
2456        // Stromsteuer is owed on them, or a ground exempts them and the invoice
2457        // says which. This path billed neither — no levy and no notice.
2458        positions.extend(stromsteuer_positions(
2459            product.stromsteuer_tarif,
2460            kwh,
2461            ctx.regulatory_rates.effective_stromsteuer(None),
2462            &["solar"],
2463        ));
2464        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
2465        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
2466        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
2467        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
2483// ── EegProvider ───────────────────────────────────────────────────────────────
2484
2485/// EEG feed-in settlement billing provider.
2486///
2487/// **Preferred path**: when `quantities.eeg_full` is set, delegates to
2488/// `eeg_billing::calculate_settlement()` for version-aware §51/§52/§44b rules.
2489///
2490/// **Fallback path**: when only `quantities.eeg` is set, uses the simplified
2491/// EEG credit note formula (Vergütung, Marktprämie, Managementprämie, KWKG).
2492/// This is suitable for LF-side Gutschrift documents where plant-specific
2493/// regulatory details (§52 sanctions, §44b biogas quota) are not relevant.
2494///
2495/// ## Recommended usage
2496///
2497/// - **NB-side settlement** (plant registry, MaStR compliance): use `einsd` + `eeg-billing`
2498/// - **LF-side credit notes** (monthly Gutschrift to generator): use `EegProvider`
2499///   with `eeg_full` when plant parameters are available, `eeg` otherwise
2500pub 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        // The `KEIN_ARBEITSPREIS` invariant on the paying side. A Gutschrift
2517        // settles a measured Einspeisung, and the rate it is settled at is
2518        // mapped from `productd`'s price positions the same way an Arbeitspreis
2519        // is — a renamed or missing position maps to `None` in silence. With no
2520        // rate the provider emits no credit position and the generator receives
2521        // a Rechnung over €0,00 that looks like a month with no production.
2522        //
2523        // Error severity, so `bill()` refuses: the consumption side of the same
2524        // defect blocks the run, and a settlement that underpays a generator is
2525        // not the milder case.
2526        //
2527        // The Managementprämie does not count — it is the contractual
2528        // Direktvermarktungsentgelt, a *deduction* dressed as a credit, and on
2529        // its own it settles no energy.
2530        #[cfg(feature = "eeg")]
2531        if quantities.eeg_full.is_some() {
2532            // The full path prices from `SettleInput`, not from the product.
2533            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    // `ctx` is consumed only by the eeg-feature path below.
2560    #[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        // ── Preferred path: delegate to eeg-billing for full regulatory accuracy ──
2568        // Only available when the `eeg` feature is enabled.
2569        #[cfg(feature = "eeg")]
2570        if let Some(eeg_full) = &quantities.eeg_full {
2571            return bill_eeg_full(eeg_full, ctx);
2572        }
2573
2574        // ── Fallback: simplified EEG credit note ──────────────────────────────
2575        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        // The Marktprämie is computed from the anzulegende Wert (§ 20 iVm
2616        // Anlage 1 EEG 2023), and § 51 Abs. 1 EEG 2023 reduces that value to
2617        // zero for the hours it applies to. So the suspension governs the
2618        // Marktprämie exactly as it governs the Einspeisevergütung: both are
2619        // paid on `billable_kwh`. Credited on the raw `kwh`, the Marktprämie
2620        // pays for the very hours the invoice prints as unremunerated.
2621        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        // A **contractual** Direktvermarktungsentgelt, not a statutory premium:
2636        // EEG 2023 knows no standalone Managementprämie — the management cost is
2637        // part of the anzulegende Wert the Marktprämie above is derived from.
2638        // Being contractual, it is owed on every delivered kWh and § 51 Abs. 1
2639        // EEG 2023, which reaches only the anzulegende Wert, does not touch it.
2640        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        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
2669        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
2670        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
2671        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/// Bridge from eeg-billing SettleOutput → Vec<BillingPosition>.
2688///
2689/// EEG settlements are positive values — the generator receives this amount.
2690///
2691/// Only compiled when the `eeg` feature is enabled.
2692#[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            // Positive: generator receives payment (credit note perspective)
2708            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
2718// ── EinspeisungProvider ───────────────────────────────────────────────────────
2719
2720/// Non-EEG Direktvermarktung feed-in settlement (EINSPEISUNG).
2721pub 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        // The `KEIN_ARBEITSPREIS` invariant on the paying side. The Marktwert is
2738        // the whole price of a Direktvermarktungs-Gutschrift: without it the
2739        // provider emits only the Vermarktungsgebühr — a settlement that charges
2740        // the generator a fee and pays nothing for the energy — or, with no fee
2741        // either, a Rechnung over €0,00 that reads like a month with no
2742        // production.
2743        //
2744        // Error severity, so `bill()` refuses, the same way the consumption side
2745        // refuses a product that cannot price its commodity.
2746        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            // Vermarktungsgebühr is a cost for the generator (reduces net payment)
2793            positions.push(
2794                BillingPosition::debit(
2795                    "Vermarktungsgebühr Direktvermarktung",
2796                    kwh,
2797                    "kWh",
2798                    -(vm_ct / dec!(100)), // negative: cost deducted from settlement
2799                    PositionCategory::Fee,
2800                )
2801                .with_tag("vermarktungsgebuehr")
2802                .with_tag("einspeisung"),
2803            );
2804        }
2805        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
2806        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
2807        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
2808        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
2824// ── HemsProvider ──────────────────────────────────────────────────────────────
2825
2826/// HEMS subscription + event billing provider.
2827pub 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        // The `KEIN_ARBEITSPREIS` invariant on the HEMS side. Events are counted
2844        // off the system and are as measured as a meter reading; priced at
2845        // nothing they drop out of the invoice without a trace, and a
2846        // subscription whose fee did not map bills an empty document.
2847        //
2848        // A plan that genuinely includes the events says so with a `0.0`.
2849        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        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
2956        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
2957        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
2958        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
2974// ── EmobilityProvider ─────────────────────────────────────────────────────────
2975
2976/// E-Mobility CPO/EMSP billing provider.
2977pub 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        // The `KEIN_ARBEITSPREIS` invariant on the charging side. Energy that
2994        // was demonstrably delivered — `kwh_charged` is a measured figure off
2995        // the charge point — against a product with no per-kWh price bills the
2996        // monthly Servicegebühr and nothing for the electricity.
2997        //
2998        // An EMSP whose tariff genuinely bundles charging into the flat fee
2999        // says so with a `0.0`, the same way every other product in this crate
3000        // distinguishes a decision from missing data.
3001        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        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
3097        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
3098        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
3099        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
3115// ── ServiceProvider ───────────────────────────────────────────────────────────
3116
3117/// Energiedienstleistung (MSB, EMS, maintenance) billing provider.
3118pub 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        // Billable events were counted and neither the usage nor the product
3135        // prices them, so they fall off the invoice silently.
3136        //
3137        // A Warning rather than an Error: unlike delivered energy, an event
3138        // count is also a legitimate *informational* figure — an operator may
3139        // report how many Störungseinsätze a maintenance flat rate covered
3140        // without charging per event. The count alone does not settle which was
3141        // meant, so this names the ambiguity instead of refusing the run.
3142        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        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
3208        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
3209        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
3210        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
3226// ── DynamicElectricityProvider ────────────────────────────────────────────────
3227
3228/// §41a EnWG dynamic electricity tariff — per-interval spot pricing.
3229///
3230/// Prices come from [`Quantities::dynamic_epex_prices`], keyed on each market
3231/// time unit's UTC start ([`mtu_start`](crate::mtu_start)). Also emits NNE,
3232/// Konzessionsabgabe, Stromsteuer and the § 40 Abs. 2 EnWG display positions.
3233pub 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        // §41a Abs. 1 EnWG — dynamic tariffs require iMSys (Smart Meter Gateway).
3252        // If the metering mode is explicitly set to SLP or RLM, this is a definite
3253        // regulatory violation that must block the billing run.
3254        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        // On this path the **interval series is the quantity**: every amount —
3272        // Arbeitspreis, NNE Arbeitspreis, Konzessionsabgabe, Stromsteuer — is
3273        // charged on the sum of the priced intervals, and nothing else reads
3274        // the meter total. So an absent or short series does not bill less
3275        // energy at the right price; it bills a Grundpreis-only invoice and
3276        // states no consumption at all, silently.
3277        //
3278        // The meter total is the independent witness that says whether that
3279        // happened. It is only a witness when it was supplied — a caller that
3280        // sends intervals alone is not making a claim to contradict.
3281        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                // Interval sums and register differences never agree to the
3301                // last digit — the series is per-quarter-hour rounded, the
3302                // total is a difference of two readings. Half a percent (with a
3303                // 1 kWh floor for small accounts) separates that from a series
3304                // that is genuinely missing days.
3305                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        // §41a EnWG: the customer's per-kWh price is the market spot price plus
3338        // the Lieferant's fixed Arbeitspreis-Aufschlag (margin). The floor caps
3339        // the spot component from below (protecting the Lieferant against negative
3340        // prices); an optional cap limits it from above (consumer protection); the
3341        // Aufschlag is then added on top.
3342        // The § 41a margin has its own field: sharing
3343        // `auf_abschlag_ct_per_kwh` with the static path's Rabatt/Aufschlag line
3344        // would make one number mean "margin on the spot price" here and
3345        // "discount off the work price" three providers away.
3346        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        // Grundpreis — active contract days, like every other Grundpreis in this
3356        // crate: a mid-period move-in must not be charged the full period.
3357        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        // Per-interval EPEX pricing via `billing::DynamicPricing`.
3371        //
3372        // `DynamicPricing` computes the weighted-average unit price using
3373        // `Amount<5>` arithmetic throughout — no intermediate Decimal accumulation.
3374        // We pass it (kwh, eur_per_kwh) pairs; it returns a single `LineItem` from
3375        // which we extract `net_amount` and `quantity_value` to build our own
3376        // `BillingPosition` (with energy-billing tags and legal basis).
3377        //
3378        // Primary price source: `self.spot_price_source` (live API / Tibber / NordPool).
3379        // Fallback: `quantities.dynamic_epex_prices` (pre-fetched map from billingd /
3380        // marktd). This is the typical production path when `build_engine()` creates
3381        // the provider before prices are known.
3382        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            // Floor the interval start to its 15-min MTU (DST-safe, UTC).
3389            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                // Consumption in an unpriced interval cannot be billed at all —
3395                // track it so an incomplete price series hard-blocks below
3396                // rather than silently under-billing.
3397                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            // §41a: market spot clamped into [floor, cap] + fixed Arbeitspreis-Aufschlag.
3409            let effective_ct = spot_ct + aufschlag_ct;
3410
3411            // ct/kWh → EUR/kWh as Amount<5>. Rounding kaufmännisch to 5 dp first
3412            // ensures the Decimal fits the target precision before conversion.
3413            // EPEX prices are typically 2 dp in ct/kWh → 4 dp after /100, so this
3414            // never loses precision in practice.
3415            //
3416            // A conversion that does not fit is an error, not a skip: silently
3417            // dropping the interval is the same under-bill the missing-price
3418            // guard below refuses, arrived at by a different route.
3419            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        // §41a EnWG requires a dynamic tariff to be billed on verifiable market
3428        // prices for the consumed energy. Consumption in an interval with no
3429        // EPEX price cannot be billed at all — dropping it would silently
3430        // under-bill and produce an unverifiable invoice. Any missing-price
3431        // interval that carries consumption hard-blocks the run, exactly like
3432        // the §41a iMSys guard, rather than degrading to a partial bill.
3433        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        // Missing prices only in zero-consumption intervals are harmless (no
3449        // money is at stake); note them for observability and continue.
3450        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            // The weighted-average unit price is the crate's, not ours:
3470            // `DynamicPricing::calculate` computes it while it sums and hands it
3471            // back as the `LineItem`'s `unit_price` in EUR/kWh. Re-deriving it
3472            // as `net ÷ quantity` would be a second division on the figure that
3473            // has to agree with the amount beside it.
3474            //
3475            // It is carried at **full precision**, and only the description
3476            // rounds. A weighted average is rarely representable, so a price
3477            // rounded for the page no longer multiplies out to its own line:
3478            // **PEPPOL-EN16931-R120** allows ±0.02 between `price × quantity`
3479            // and the amount, and four decimal places in ct drift past that at
3480            // industrial volumes — €2.42 over a spot-linked year at 2 MW. The
3481            // reader gets the rounded average in the description; the machine
3482            // field stays exact.
3483            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            // NNE + KA
3504            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            // Stromsteuer — through the same § 9 StromStG resolution the static
3534            // path uses, so a Befreiung or an ermäßigter Satz cannot apply to
3535            // one kind of electricity tariff and not the other.
3536            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        // NNE Grundpreis
3545        if let Some(nne_gp) = grid.nne_grundpreis_eur_per_year {
3546            // Leap-aware: an EUR/year rate divides by that year's actual days
3547            // (366 in 2024/2028), or the daily rate overstates the Grundpreis.
3548            let daily = nne_gp / Decimal::from(time::util::days_in_year(ctx.period_from().year()));
3549            // Active contract days, not the full billing period: the NNE
3550            // Grundpreis accrues only while the contract supplies the MaLo, the
3551            // same clipping the commodity Grundpreis applies. Billing the full
3552            // period over-charged every mid-period move-in and move-out.
3553            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        // The MSB fee, the bonuses and the § 40 Abs. 2 EnWG display duties are
3568        // the same on a dynamic invoice as on a static one.
3569        let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
3570        positions.extend(electricity_common_positions(ctx, product, &meter));
3571
3572        // A Steuerentlastung leaves the levy where it is — see `crate::steuer`.
3573        let hinweise = entlastungs_hinweise(&product.steuerentlastungen, &positions);
3574        positions.extend(hinweise);
3575
3576        // ── Wire per-position applicable_tax_rate from product.mwst_rate_override ──
3577        // Enables multi-rate MwSt: e.g. 7% Trinkwasser (§12 Abs. 2 Nr. 1 UStG, Anlage 2),
3578        // 0% for solar PV ≤30 kWp (§12 Abs. 3 UStG), etc.
3579        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        // ── §41a Abs. 6 EnWG — Annual savings comparison
3592        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
3620// ── MwStProvider ──────────────────────────────────────────────────────────────
3621
3622/// MwSt (Mehrwertsteuer / Umsatzsteuer) provider — supports **multi-rate VAT**.
3623///
3624/// **Must be registered last** — computes tax on the sum of ALL prior positions.
3625///
3626/// ## Multi-rate VAT (§12 UStG)
3627///
3628/// The provider groups prior positions by their `applicable_tax_rate`:
3629/// - `None` → uses the engine-wide default rate (passed to `new()`)
3630/// - `Some(dec!(0.19))` → standard rate
3631/// - `Some(dec!(0.07))` → reduced rate (§12 Abs. 2 Nr. 1 UStG for renewable Fernwärme)
3632/// - `Some(dec!(0.0))` → zero rate (§12 Abs. 3 UStG for solar PV ≤30 kWp since 01.01.2023)
3633///
3634/// One `Tax` position is generated per distinct rate group.
3635/// Groups with `rate = 0` produce no Tax position.
3636pub struct MwStProvider {
3637    /// Default MwSt rate for positions without an explicit `applicable_tax_rate`.
3638    rate: Decimal,
3639}
3640
3641impl MwStProvider {
3642    /// Construct with the engine-wide default MwSt rate (e.g. `dec!(0.19)`).
3643    #[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        // Group taxable positions by their effective MwSt rate.
3659        // Tax, Abschlag, Info positions are excluded from the tax base.
3660        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; // zero rate \u2192 no tax position
3672            }
3673            // Normalised, exactly as `tax_subtotals_of` groups the BG-23
3674            // breakdown: `0.19` and `0.190` are one rate, and bucketing them
3675            // apart rounded each half on its own, so `gesamtsteuer` could land
3676            // a cent away from `Σ steuerbetraege`.
3677            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            // Rounded to the cent per rate, not carried at 5 dp: the Steuerbetrag
3694            // is a document amount (§14 Abs. 4 Nr. 8 UStG) and the BO4E/EN 16931
3695            // per-rate breakdown states it to the cent. Summing 5-dp tax layers
3696            // and rounding once at the end can land a cent away from the sum of
3697            // the stated Steuerbeträge, which breaks the documented invariant
3698            // "Σ steuerbetraege == gesamtsteuer" (19 % 1.995 + 7 % 0.525 →
3699            // 2.00 + 0.53 = 2.53, not round2(2.52)).
3700            let mwst_eur = validated_eur((net_base.abs() * rate).round_kfm(2));
3701            // Sign follows the net base (credit invoices → negative MwSt)
3702            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
3734/// The positions every electricity invoice carries regardless of how the
3735/// Arbeitspreis was formed — the MSB fee, the contractual bonuses and the
3736/// § 40 Abs. 2 EnWG display duties.
3737///
3738/// Shared by the static and the § 41a dynamic providers. The dynamic path used
3739/// to emit none of them: a dynamic-tariff invoice went out with no Zählerstand
3740/// (Nr. 6), no Vorjahresvergleich (Nr. 7), no Vergleichsgruppe (Nr. 8) and no
3741/// estimation notice, while the identically-regulated static invoice carried
3742/// all four.
3743fn 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    // ── Boni (Neukunden-/Sofort-/Treuebonus) ───────────────────────────────
3751    // A contractual bonus is a Preisnachlass (§17 UStG Entgeltminderung): it
3752    // rides as a negative Bonus position that reduces the taxable base, so the
3753    // MwSt is computed on the net after the bonus (not a gross gift on top).
3754    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        // Pro-rate the annual loyalty bonus to the billed contract days.
3773        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    // ── MSB Grundgebühr ────────────────────────────────────────────────────
3788    // Messstellenbetreiber fee bundled into the retail invoice (MsbG 2016).
3789    // Itemised separately per §41 EnWG.
3790    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    // ── Zählerstand info positions (§40 Abs. 2 Nr. 6 EnWG) ─────────────────
3808    if meter.zaehlerstand_von.is_some() || meter.zaehlerstand_bis.is_some() {
3809        // § 40 Abs. 2 Nr. 6 EnWG names three things: the opening and closing
3810        // readings, the consumption derived from them, **and how the reading
3811        // was obtained**. The third is its own duty — a customer can act on a
3812        // self-reported figure differently from a remote read-out — and it was
3813        // absent from the page unless the reading happened to be an estimate.
3814        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    // ── §40 Abs. 2 EnWG — Verbrauchshistorie (consumption comparison) ──
3850    // Mandatory invoice display requirement: show prior-year and average.
3851    // These are informational positions (EUR 0) — they appear in the invoice
3852    // printout but do not affect the calculation.
3853    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    // ── § 40a Abs. 2 EnWG — estimated reading notice ─────────────────────
3889    // Satz 3 requires the estimate, the ground that makes it admissible and
3890    // the factors behind it to be stated „unter ausdrücklichem und optisch
3891    // besonders hervorgehobenem Hinweis", and Satz 1 measures it against the
3892    // customer's own prior period or a comparable customer.
3893    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    // ── Zählerwechsel notice ───────────────────────────────────────────────
3911    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    // ── Preisgarantie notice (§41 Abs. 1 Nr. 4 EnWG) ─────────────────────
3927    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
3945/// The `INDEXWERT_FEHLT` warning for an index-linked price whose index value
3946/// has not arrived.
3947///
3948/// `Error` when nothing else can price the commodity — an unresolvable index
3949/// otherwise produces an invoice with a standing charge and no work price, the
3950/// same silent zero `KEIN_ARBEITSPREIS` exists to refuse. `Warning` when a
3951/// static price can carry the invoice, because the operator still contracted an
3952/// indexed one and is about to bill a different number.
3953fn 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
3973/// A 0-EUR informational position — a statement the invoice must carry that is
3974/// not an amount.
3975fn 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
3994// ── Verbrauchsteuer helpers ───────────────────────────────────────────────────
3995
3996/// The Stromsteuer line for a supply — or the exemption notice standing in for it.
3997///
3998/// One place decides, so a Befreiung, an Ermäßigung and the Regelsatz cannot
3999/// diverge between the static, controllable-load and dynamic paths.
4000fn 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            // A reduction is still a levy line. Dropping it — the shape the old
4030            // `StromsteuerBefreiung::Bahnstrom` variant produced — left 1,142
4031            // ct/kWh of Fahrstrom tax off every invoice.
4032            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
4065/// The Energiesteuer line for a gas supply — or the exemption notice.
4066fn 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 is the Erdgas-als-Heizstoff rate.
4104                    // The old citation "§2 Nr. 3" names no provision at all.
4105                    "§ 2 Abs. 3 Satz 1 Nr. 4 EnergieStG",
4106                    "energiesteuer_gas",
4107                )
4108                .with_tag("gas"),
4109            ]
4110        }
4111    }
4112}
4113
4114/// One informational note per [`Steuerentlastung`](crate::steuer::Steuerentlastung),
4115/// quantifying the levy it may be claimed against.
4116///
4117/// Never an amount: an Entlastung is the customer's filing, and the supply on
4118/// this invoice was taxed in full. The note exists because the customer cannot
4119/// file without knowing the figure.
4120fn 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// ── billing crate bridge helpers ──────────────────────────────────────────────
4145
4146/// Convert a [`billing::LineItem`] to a [`BillingPosition`].
4147///
4148/// The `billing` crate is domain-agnostic; this adapter attaches energy-domain
4149/// metadata (`category`, `legal_basis`, `tags`) to the generic `LineItem`.
4150/// Used by [`build_block_tariff_positions`] and any other paths that delegate
4151/// to billing-crate primitives.
4152#[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
4181/// Build block tariff `BillingPosition`s using [`billing::RateSchedule`].
4182///
4183/// Replaces the manual tier-iteration loop with the well-tested graduated
4184/// schedule from the `billing` crate, gaining:
4185/// - Contiguous-band validation on construction (catches misconfigured tiers)
4186/// - Correct open-ended last-tier handling
4187/// - Exact `Amount<5>` arithmetic (no intermediate float money)
4188///
4189/// ## Legal basis
4190///
4191/// §41 EnWG — block tariffs (Blocktarif / Staffelpreis) are permissible for
4192/// electricity and gas supply contracts.
4193fn 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
4234// ── EnergyShareProvider ───────────────────────────────────────────────────────
4235
4236/// §42c EnWG Energy Sharing — community energy allocation credit provider.
4237///
4238/// Generates a credit position for the customer's share of locally produced
4239/// electricity from the community energy pool (Energiegemeinschaft). The credit
4240/// reduces the effective energy cost without affecting the grid-consumption billing
4241/// (which is handled by the `ElectricityProvider` in the same engine).
4242///
4243/// ## Legal basis
4244///
4245/// §42c EnWG (Energy Sharing, EnWG-Novelle BGBl. 2025 I Nr. 347; obligatory within
4246/// a single Bilanzkreis from 01.06.2026, extended to adjacent Bilanzkreise in the
4247/// same Regelzone from 01.06.2028): participants in a registered Energiegemeinschaft
4248/// may receive allocated shares of local generation.
4249/// The Lieferant bills full grid consumption (§41 EnWG) and separately credits the
4250/// sharing allocation at the contracted sharing rate.
4251///
4252/// ## §41a intersection
4253///
4254/// If the sharing tariff is combined with a dynamic tariff (`STROM` + dynamic EPEX
4255/// overlay), the credit is applied as a flat per-kWh reduction on the allocated amount.
4256/// For interval-resolved sharing under §42c, use `DynamicElectricityProvider` instead.
4257///
4258/// ## Integration
4259///
4260/// ```text
4261/// ElectricityProvider → full grid consumption (Arbeitspreis + Grundpreis + Stromsteuer)
4262/// EnergyShareProvider → credit for sharing allocation (negative net_eur)
4263/// MwStProvider        → MwSt on netto sum (sharing credit reduces the MwSt base)
4264/// ```
4265pub 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        // The `KEIN_ARBEITSPREIS` invariant on the §42c side. The allocated kWh
4282        // are computed from the community's metered generation, so they are a
4283        // measured figure; with no Gutschriftsatz the provider returns an empty
4284        // position list and the participant is billed full grid consumption with
4285        // the sharing credit missing entirely — an overcharge that leaves no
4286        // trace on the document.
4287        //
4288        // A community that genuinely credits nothing states `0.0`.
4289        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        // Sharing credit rate from tariff sheet. A declared `0.0` credits
4319        // nothing and needs no position; an absent rate is a data defect the
4320        // `KEIN_SHARING_GUTSCHRIFTSATZ` guard refuses before this runs.
4321        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        // Allocated kWh from quantities.
4330        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        // The three transparency terms the input carries. A participant cannot
4364        // check an allocation without the total it came out of and the fraction
4365        // it was taken at — the two figures whose product is the credited
4366        // quantity — so all three reach the invoice, not `allocated_kwh` alone.
4367        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}