Skip to main content

energy_billing/
quantities.rs

1//! `Quantities` — all metered quantities for one billing period.
2//!
3//! The single container for all product meter data. Replaces positional
4//! parameters passed to each `calculate_*` function.
5
6use crate::rates::RoundMoney;
7use rust_decimal::Decimal;
8use std::collections::HashMap;
9use time::OffsetDateTime;
10
11// ── Meter input types ─────────────────────────────────────────────────────────
12
13/// Metering mode of the delivery point (§3/§ 12 StromNZV, §41a EnWG).
14///
15/// Determines billing granularity, permissible tariff types, and substitution
16/// rules for missing interval data.
17///
18/// | Mode | Annual consumption | Billing basis | §41a dynamic tariff |
19/// |---|---|---|---|
20/// | `Slp` | < 100 MWh/year | Standard load profile (estimated) | ✗ |
21/// | `Rlm` | ≥ 100 MWh/year | Registered 15-min values | ✗ |
22/// | `Imsys` | ≥ 6 MWh/year (§31 MsbG) | Smart Meter Gateway | ✓ |
23#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Default)]
24#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
25pub enum MeteringMode {
26    /// Standard load profile (SLP) — estimated annual consumption billing.
27    /// Typical for residential and small commercial customers (< 100 MWh/year).
28    #[default]
29    Slp,
30    /// Registrierende Leistungsmessung (RLM) — measured 15-minute interval billing.
31    /// Required for customers ≥ 100 MWh/year (§ 12 StromNZV, §14 NAV).
32    Rlm,
33    /// Intelligentes Messsystem (iMSys) — Smart Meter Gateway.
34    /// Enables §41a EnWG dynamic tariffs. Required for > 6 MWh/year (§31 MsbG).
35    Imsys,
36}
37
38/// How the meter reading on the invoice was obtained.
39///
40/// **§ 40 Abs. 2 Nr. 6 EnWG** requires a consumption invoice to state the
41/// opening and closing readings, the consumption derived from them, *and* "die
42/// Art, wie der Zählerstand ermittelt wurde". The third of those is a distinct
43/// duty: a customer reading an invoice has to be able to tell a remote read-out
44/// from a self-reported figure from an estimate, because what they can do about
45/// a wrong number differs in each case.
46///
47/// `is_estimated` alone cannot carry it — it distinguishes an estimate from
48/// everything else and says nothing about what "everything else" was.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
50#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
51pub enum Ablesungsart {
52    /// Not stated. The invoice then omits the Nr. 6 sentence — which is a gap
53    /// in the data, not a shape the statute permits, so `billingd` warns.
54    #[default]
55    Unbekannt,
56    /// Ferngelesen — read out over the iMSys/Smart-Meter-Gateway.
57    Fernauslesung,
58    /// Read on site by the Messstellenbetreiber or their agent.
59    Abgelesen,
60    /// Self-reported by the customer (Selbstablesung).
61    Kundenselbstablesung,
62    /// Estimated under § 40a Abs. 2 EnWG, or an Ersatzwert taken over under
63    /// § 40a Abs. 1 Satz 1 Nr. 1 EnWG.
64    Rechnerisch,
65}
66
67impl Ablesungsart {
68    /// The wording that goes on the invoice, or `None` when unstated.
69    #[must_use]
70    pub const fn label(self) -> Option<&'static str> {
71        match self {
72            Self::Unbekannt => None,
73            Self::Fernauslesung => Some("ferngelesen"),
74            Self::Abgelesen => Some("abgelesen durch den Messstellenbetreiber"),
75            Self::Kundenselbstablesung => Some("Selbstablesung durch den Kunden"),
76            Self::Rechnerisch => Some("rechnerisch ermittelt (Schätzung)"),
77        }
78    }
79
80    /// `true` when the figure is not a measured reading.
81    #[must_use]
82    pub const fn is_estimate(self) -> bool {
83        matches!(self, Self::Rechnerisch)
84    }
85}
86
87/// Electricity meter data for one billing period.
88#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
89pub struct MeterInput {
90    /// Total energy in kWh (Arbeitsmenge).
91    #[serde(default)]
92    pub arbeitsmenge_kwh: Decimal,
93    /// High-tariff energy in kWh (HT, for Zweitarif). `None` = single tariff.
94    #[serde(default)]
95    pub arbeitsmenge_ht_kwh: Option<Decimal>,
96    /// Low-tariff energy in kWh (NT, for Zweitarif). `None` = single tariff.
97    #[serde(default)]
98    pub arbeitsmenge_nt_kwh: Option<Decimal>,
99    /// Peak demand in kW (Spitzenleistung, § 12 StromNZV).
100    #[serde(default)]
101    pub spitzenleistung_kw: Option<Decimal>,
102    /// §14a EnWG: hours the controllable device was under NB management.
103    #[serde(default)]
104    pub steuerung_stunden: Option<Decimal>,
105    /// Zählernummer (§41 EnWG — mandatory on electricity invoices).
106    ///
107    /// When set, appears as an informational position on the invoice.
108    /// Overrides `BillingContext::zaehler_id` for this specific meter.
109    #[serde(default)]
110    pub zaehlernummer: Option<String>,
111    /// Zählerstand at the start of the billing period.
112    ///
113    /// §41 EnWG: billing invoices must show the meter reading at period start.
114    #[serde(default)]
115    pub zaehlerstand_von: Option<Decimal>,
116    /// Zählerstand at the end of the billing period.
117    ///
118    /// §41 EnWG: billing invoices must show the meter reading at period end.
119    #[serde(default)]
120    pub zaehlerstand_bis: Option<Decimal>,
121
122    /// Metering mode — SLP, RLM, or iMSys (Smart Meter).
123    ///
124    /// Used to validate tariff compatibility (§41a requires `Imsys`) and to
125    /// label estimated readings correctly on the invoice.
126    #[serde(default)]
127    pub metering_mode: MeteringMode,
128
129    /// § 40 Abs. 2 Nr. 6 EnWG — how the reading was obtained.
130    ///
131    /// See also [`MeterInput::billable_kwh`], which is what decides whether
132    /// there is any consumption to price.
133    ///
134    /// Stated on the invoice beside the readings themselves. `Rechnerisch`
135    /// implies [`Self::is_estimated`]; the two are kept separate because a
136    /// caller that knows only "this is an estimate" can still say so.
137    #[serde(default)]
138    pub ablesungsart: Ablesungsart,
139
140    /// `true` when the consumption figure is an estimate rather than a reading —
141    /// either a § 40a Abs. 2 EnWG Verbrauchsschätzung or an Ersatzwert the
142    /// Messstellenbetreiber formed and passed on under § 40a Abs. 1 Satz 1
143    /// Nr. 1 EnWG.
144    ///
145    /// § 40a Abs. 2 Satz 3 EnWG has the invoice state the estimate, the ground
146    /// that makes it admissible and the factors behind it „unter ausdrücklichem
147    /// und optisch besonders hervorgehobenem Hinweis".
148    #[serde(default)]
149    pub is_estimated: bool,
150
151    /// `true` when the meter was replaced during this billing period (Zählerwechsel).
152    ///
153    /// When set, `zaehlerstand_von` / `zaehlerstand_bis` may relate to different
154    /// meter serial numbers. The invoice must note the meter exchange.
155    #[serde(default)]
156    pub zaehler_replaced: bool,
157
158    /// Share of the billing period covered by billable readings, 0–100.
159    ///
160    /// A sum over the readings that did arrive says nothing about the ones that
161    /// did not: a month delivered up to the 3rd sums to a plausible Arbeitsmenge
162    /// and bills as a complete month. Below 100 the invoice rests in part on a
163    /// § 40a Abs. 2 EnWG Verbrauchsschätzung, which the document has to say so
164    /// prominently — the `MENGE_UNVOLLSTAENDIG` finding carries that.
165    ///
166    /// `None` when the source states no coverage.
167    #[serde(default)]
168    pub coverage_pct: Option<Decimal>,
169}
170
171impl MeterInput {
172    /// The consumption there is to price, in kWh.
173    ///
174    /// `arbeitsmenge_kwh` where it is stated, otherwise the HT/NT registers.
175    /// A Zweitarif caller may legitimately supply only the split — the total is
176    /// its sum, not an independent fact — and gating the whole Arbeitspreis
177    /// block on the total alone billed such a customer nothing for their
178    /// electricity while still charging them the Stromsteuer.
179    #[must_use]
180    pub fn billable_kwh(&self) -> Decimal {
181        if self.arbeitsmenge_kwh > Decimal::ZERO {
182            return self.arbeitsmenge_kwh;
183        }
184        self.arbeitsmenge_ht_kwh.unwrap_or(Decimal::ZERO)
185            + self.arbeitsmenge_nt_kwh.unwrap_or(Decimal::ZERO)
186    }
187}
188
189/// Gas meter data for one billing period.
190#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
191pub struct GasMeterInput {
192    /// Share of the billing period covered by billable readings, 0–100.
193    ///
194    /// See [`MeterInput::coverage_pct`].
195    #[serde(default)]
196    pub coverage_pct: Option<Decimal>,
197    /// Volume at meter conditions (m³).
198    pub messung_qm3: Decimal,
199    /// Calorific value (Brennwert Ho/Hs) in kWh/m³.
200    #[serde(default)]
201    pub brennwert_kwh_per_qm3: Option<Decimal>,
202    /// Volume conversion factor (Zustandszahl, dimensionless).
203    #[serde(default)]
204    pub zustandszahl: Option<Decimal>,
205    /// Pre-computed kWh_Hs (takes precedence over Brennwert × Zustandszahl).
206    #[serde(default)]
207    pub kwh_hs: Option<Decimal>,
208    /// Gas quality annotation (e.g. `"H_GAS"`, `"L_GAS"`, `"H2_BLEND"`).
209    /// Informational only — billing always uses the measured Brennwert.
210    #[serde(default)]
211    pub gasqualitaet: Option<String>,
212    /// Peak demand in kW (Spitzenleistung) for RLM gas billing.
213    ///
214    /// Required when `TariffInput::gas_leistungspreis_ct_per_kw_month` is set.
215    /// Applicable to large gas customers with RLM metering (> 1.5 GWh/year).
216    #[serde(default)]
217    pub spitzenleistung_kw: Option<Decimal>,
218    /// Zählernummer (§40 Abs. 2 Nr. 6 EnWG — meter identity on the bill).
219    /// Overrides `BillingContext::zaehler_id` for this meter.
220    #[serde(default)]
221    pub zaehlernummer: Option<String>,
222    /// Meter reading at period start, in m³ (§40 Abs. 2 Nr. 6 EnWG).
223    #[serde(default)]
224    pub zaehlerstand_von: Option<Decimal>,
225    /// Meter reading at period end, in m³ (§40 Abs. 2 Nr. 6 EnWG).
226    #[serde(default)]
227    pub zaehlerstand_bis: Option<Decimal>,
228    /// § 40 Abs. 2 Nr. 6 EnWG — how the reading was obtained.
229    #[serde(default)]
230    pub ablesungsart: Ablesungsart,
231    /// Reading is an estimate / Ersatzwert (§ 40a Abs. 2 EnWG).
232    /// Must be prominently labeled on the bill; the customer may demand a
233    /// correction once a real reading arrives.
234    #[serde(default)]
235    pub is_estimated: bool,
236}
237
238/// Reason a metered water volume did not reach the sewer.
239///
240/// Absetzungen reduce the **Schmutzwasser** volume only (Frischwassermaßstab:
241/// every m³ of drinking water counts as sewage unless proven otherwise via a
242/// calibrated deduction meter) — the Trinkwasser delivery itself is always
243/// billed in full.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
245#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
246pub enum AbsetzungsGrund {
247    /// Garden irrigation via Gartenwasserzähler.
248    Gartenwasser,
249    /// Water carried away in products or processes (Schleppwasser).
250    Schleppwasser,
251    /// Evaporation losses (e.g. cooling towers).
252    Verdunstung,
253    /// Water bound in production output.
254    Produktionswasser,
255    /// Other municipally recognised deduction.
256    Sonstige,
257}
258
259impl AbsetzungsGrund {
260    /// German label for position texts.
261    #[must_use]
262    pub fn label(self) -> &'static str {
263        match self {
264            Self::Gartenwasser => "Gartenwasser",
265            Self::Schleppwasser => "Schleppwasser",
266            Self::Verdunstung => "Verdunstung",
267            Self::Produktionswasser => "Produktionswasser",
268            Self::Sonstige => "sonstige Absetzung",
269        }
270    }
271}
272
273/// One metered non-discharged water volume (Absetzung).
274///
275/// Municipal statutes require a separately installed, calibrated meter
276/// (geeichter Absetzungszähler) for each deduction.
277#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
278pub struct Absetzung {
279    /// Metered volume in m³.
280    pub m3: Decimal,
281    /// Why the volume never reached the sewer.
282    pub grund: AbsetzungsGrund,
283}
284
285/// Water / wastewater meter and property data (WASSER).
286#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
287pub struct WasserMeterInput {
288    /// Drinking water delivered in m³ — the basis for the Trinkwasser
289    /// Mengenpreis **and** (minus Absetzungen) the Schmutzwasser volume.
290    #[serde(default)]
291    pub frischwasser_m3: Decimal,
292    /// Metered non-discharged volumes, deducted from Schmutzwasser only.
293    #[serde(default)]
294    pub absetzungen: Vec<Absetzung>,
295    /// Sealed surface area (m²) draining into the sewer — the
296    /// Niederschlagswasser base of the gesplittete Abwassergebühr.
297    #[serde(default)]
298    pub versiegelte_flaeche_m2: Option<Decimal>,
299    /// Pro-rata months (defaults to 1 = one full billing month).
300    #[serde(default)]
301    pub months: Option<Decimal>,
302}
303
304impl WasserMeterInput {
305    /// Total metered Absetzung volume in m³.
306    #[must_use]
307    pub fn absetzung_total_m3(&self) -> Decimal {
308        self.absetzungen.iter().map(|a| a.m3).sum()
309    }
310}
311
312/// District heat meter data.
313#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
314pub struct WaermeMeterInput {
315    /// Thermal energy delivered (kWh_th).
316    #[serde(default)]
317    pub kwh_waerme: Decimal,
318    /// Peak demand in kW (for Leistungspreis billing).
319    #[serde(default)]
320    pub spitzenleistung_kw: Option<Decimal>,
321    /// Pro-rata months (defaults to 1 = one full billing month).
322    #[serde(default)]
323    pub months: Option<Decimal>,
324}
325
326/// Solar / Eigenverbrauch / Mieterstrom / GGV meter data.
327#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
328pub struct SolarMeterInput {
329    /// Metered self-consumption or locally delivered kWh.
330    pub eigenverbrauch_kwh: Decimal,
331}
332
333// ── GGV Nutzungsplan ──────────────────────────────────────────────────────────
334
335/// §42b EnWG — One entry in the GGV Nutzungsplan (tenant allocation table).
336///
337/// The Nutzungsplan distributes the plant's PV generation among participating
338/// building occupants (Teilnehmer). Each entry maps one Marktlokation (tenant
339/// delivery point) to its allocation fraction.
340///
341/// ## Legal basis
342///
343/// §42b Abs. 1 EEG 2023 (Solarpaket I): the Lieferant must maintain a Nutzungsplan
344/// for the duration of the GGV contract. The sum of all fractions must equal 1.0.
345///
346/// ## Storage
347///
348/// Stored as `ggv_nutzungsplan JSONB` on `eeg_anlagen` (migration 0009).
349/// Deserialize with `serde_json::from_value::<Vec<GgvNutzungsplanEntry>>(...)`.
350///
351/// ## Example
352///
353/// ```rust
354/// use energy_billing::GgvNutzungsplanEntry;
355/// use rust_decimal::dec;
356///
357/// let plan = vec![
358///     GgvNutzungsplanEntry { malo_id: "51238696012".into(), fraction: dec!(0.45) },
359///     GgvNutzungsplanEntry { malo_id: "51238696012".into(), fraction: dec!(0.35) },
360///     GgvNutzungsplanEntry { malo_id: "51238696799".into(), fraction: dec!(0.20) },
361/// ];
362/// let total: rust_decimal::Decimal = plan.iter().map(|e| e.fraction).sum();
363/// assert_eq!(total, dec!(1.0));
364/// ```
365#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
366pub struct GgvNutzungsplanEntry {
367    /// 11-digit Marktlokations-ID of the tenant delivery point.
368    pub malo_id: String,
369
370    /// Fraction of PV generation allocated to this tenant (0.0 < fraction ≤ 1.0).
371    ///
372    /// The sum of all fractions in the Nutzungsplan must equal exactly 1.0.
373    /// Validate with `GgvNutzungsplan::validate()` before billing.
374    pub fraction: Decimal,
375}
376
377/// §42b EnWG — GGV Nutzungsplan (complete tenant allocation table).
378///
379/// Wraps the list of entries and provides validation and allocation computation.
380#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
381pub struct GgvNutzungsplan(pub Vec<GgvNutzungsplanEntry>);
382
383impl GgvNutzungsplan {
384    /// Validate that all fractions are positive and sum to 1.0 (within 0.001 tolerance).
385    ///
386    /// Returns `Err` with a diagnostic message if validation fails.
387    pub fn validate(&self) -> Result<(), String> {
388        use rust_decimal::dec;
389        if self.0.is_empty() {
390            return Err("GGV Nutzungsplan must have at least one entry".to_owned());
391        }
392        for e in &self.0 {
393            if e.fraction <= Decimal::ZERO {
394                return Err(format!(
395                    "GGV Nutzungsplan: fraction for {} must be > 0, got {}",
396                    e.malo_id, e.fraction
397                ));
398            }
399        }
400        let total: Decimal = self.0.iter().map(|e| e.fraction).sum();
401        let diff = (total - Decimal::ONE).abs();
402        if diff > dec!(0.001) {
403            return Err(format!(
404                "GGV Nutzungsplan: fractions sum to {total}, must be 1.0 (±0.001)"
405            ));
406        }
407        Ok(())
408    }
409
410    /// Validate that the plan allocates PV to exactly `tenants`, no more and no
411    /// less.
412    ///
413    /// Fractions summing to 1.0 says nothing about *who* they cover: a plan that
414    /// omits a tenant is internally consistent, and the omitted tenant silently
415    /// falls out of the allocation — billed as if their whole consumption were
416    /// self-consumed solar, with no grid residual and no Stromsteuer. §42b Abs. 1
417    /// EEG 2023 requires the Nutzungsplan to cover the community for the duration
418    /// of the contract, so a mismatch is a configuration error, not a default.
419    ///
420    /// A MaLo appearing twice is also rejected: the allocation is keyed on the
421    /// MaLo, so a duplicate entry loses one of the two shares.
422    pub fn validate_covers<'a>(
423        &self,
424        tenants: impl IntoIterator<Item = &'a str>,
425    ) -> Result<(), String> {
426        use std::collections::BTreeSet;
427        let mut planned: BTreeSet<&str> = BTreeSet::new();
428        for e in &self.0 {
429            if !planned.insert(e.malo_id.as_str()) {
430                return Err(format!(
431                    "GGV Nutzungsplan: MaLo {} appears more than once",
432                    e.malo_id
433                ));
434            }
435        }
436        let tenants: BTreeSet<&str> = tenants.into_iter().collect();
437        let missing: Vec<&str> = tenants.difference(&planned).copied().collect();
438        if !missing.is_empty() {
439            return Err(format!(
440                "GGV Nutzungsplan: no entry for {} — every tenant must be allocated \
441                 (§42b Abs. 1 EEG 2023)",
442                missing.join(", ")
443            ));
444        }
445        let extra: Vec<&str> = planned.difference(&tenants).copied().collect();
446        if !extra.is_empty() {
447            return Err(format!(
448                "GGV Nutzungsplan: {} is allocated PV but is not a tenant of this run",
449                extra.join(", ")
450            ));
451        }
452        Ok(())
453    }
454
455    /// Allocate a generation quantity proportionally among tenants.
456    ///
457    /// Returns `(malo_id, allocated_kwh)` pairs.
458    ///
459    /// Uses `billing::proportional_split` (Largest-Remainder / Hamilton method) —
460    /// guarantees `Σ(allocated_kwh) == total_kwh` with each tenant within
461    /// ±0.001 kWh of their exact share. No single entry absorbs all rounding error.
462    ///
463    /// # Errors
464    ///
465    /// [`crate::error::EngineError::NutzungsplanSharesInvalid`] when the shares do not sum to
466    /// one closely enough for the split to distribute the whole generation. The
467    /// shares are caller-supplied — a plan entered as percentages sums to 100 —
468    /// so this is a configuration error the caller must see, not an arithmetic
469    /// failure to absorb.
470    pub fn allocate(
471        &self,
472        total_kwh: Decimal,
473    ) -> Result<Vec<(String, Decimal)>, crate::EngineError> {
474        if self.0.is_empty() || total_kwh <= Decimal::ZERO {
475            return Ok(vec![]);
476        }
477        let fractions: Vec<Decimal> = self.0.iter().map(|e| e.fraction).collect();
478        // billing::proportional_split uses Largest-Remainder (Hamilton) method:
479        // scale=3 → 0.001 kWh resolution.
480        let parts = billing::proportional_split(total_kwh, &fractions, 3).map_err(|_| {
481            crate::EngineError::NutzungsplanSharesInvalid {
482                sum: fractions.iter().copied().sum(),
483            }
484        })?;
485        Ok(self
486            .0
487            .iter()
488            .zip(parts)
489            .map(|(e, kwh)| (e.malo_id.clone(), kwh))
490            .collect())
491    }
492}
493
494/// EEG feed-in settlement meter data (simplified LF view).
495#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
496pub struct EegMeterInput {
497    /// Total kWh fed into the grid during the billing period.
498    pub einspeisung_kwh: Decimal,
499    /// kWh during negative-EPEX hours (§51 EEG suspension).
500    #[serde(default)]
501    pub kwh_during_negative_epex: Option<Decimal>,
502}
503
504/// HEMS (Home Energy Management System) subscription usage.
505#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
506pub struct HemsMeterInput {
507    /// Billing months (for monthly subscription fee).
508    #[serde(default)]
509    pub months: Option<Decimal>,
510    /// Number of optimisation events.
511    #[serde(default)]
512    pub optimization_events: Option<u32>,
513    /// Number of smart-meter readout events.
514    #[serde(default)]
515    pub readout_events: Option<u32>,
516}
517
518/// E-Mobility CPO/EMSP usage data.
519#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
520pub struct EmobilityMeterInput {
521    #[serde(default)]
522    pub months: Option<Decimal>,
523    #[serde(default)]
524    pub kwh_charged: Option<Decimal>,
525    #[serde(default)]
526    pub sessions: Option<u32>,
527    #[serde(default)]
528    pub roaming_sessions: Option<u32>,
529}
530
531/// Energiedienstleistung service usage.
532#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
533pub struct ServiceMeterInput {
534    #[serde(default)]
535    pub months: Option<Decimal>,
536    #[serde(default)]
537    pub event_count: Option<u32>,
538    #[serde(default)]
539    pub event_price_eur: Option<Decimal>,
540}
541
542/// One interval for §41a dynamic tariff billing.
543#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
544pub struct DynamicInterval {
545    /// Interval start (UTC).
546    #[serde(with = "time::serde::rfc3339")]
547    pub timestamp_utc: OffsetDateTime,
548    /// Energy in kWh for this interval.
549    pub kwh: Decimal,
550}
551
552// ── §41a Abs. 6 — Annual savings comparison ───────────────────────────────────
553
554/// §41a Abs. 6 EnWG — Annual savings comparison for dynamic tariff customers.
555///
556/// Lieferanten must provide dynamic tariff customers with an annual statement
557/// of how much they saved (or paid more) compared to a reference fixed tariff.
558///
559/// ## Legal basis
560///
561/// §41a Abs. 6 EnWG: „Der Lieferant hat dem Letztverbraucher jährlich mitzuteilen,
562/// wie viel er durch die dynamische Preiskomponente im Vergleich zu einem
563/// Standardtarif eingespart oder mehr ausgegeben hat."
564///
565/// ## Usage
566///
567/// Compute via [`Sect41aAnnualComparison::compute`] and set in [`Quantities`].
568/// `DynamicElectricityProvider` renders it as an informational position on the
569/// annual invoice.
570#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
571pub struct Sect41aAnnualComparison {
572    /// kWh consumed under the dynamic tariff in the comparison period.
573    pub actual_kwh: Decimal,
574    /// Total amount paid under the dynamic tariff (EUR brutto, inclusive of MwSt).
575    pub actual_eur_brutto: Decimal,
576    /// Reference fixed-price (ct/kWh, brutto) for the annual comparison.
577    ///
578    /// Typically the customer's previous fixed tariff or the operator's standard
579    /// product price at time of dynamic tariff contract start.
580    pub reference_price_ct_per_kwh: Decimal,
581    /// What the customer would have paid at the reference price (EUR brutto).
582    pub reference_eur_brutto: Decimal,
583    /// EUR difference: positive = saved money, negative = paid more.
584    pub savings_eur: Decimal,
585}
586
587impl Sect41aAnnualComparison {
588    /// Compute the annual comparison from actual totals and a reference price.
589    #[must_use]
590    pub fn compute(
591        actual_kwh: Decimal,
592        actual_eur_brutto: Decimal,
593        reference_price_ct_per_kwh: Decimal,
594    ) -> Self {
595        use rust_decimal::dec;
596        let reference_eur_brutto =
597            (actual_kwh * reference_price_ct_per_kwh / dec!(100)).round_kfm(2);
598        let savings_eur = (reference_eur_brutto - actual_eur_brutto).round_kfm(2);
599        Self {
600            actual_kwh,
601            actual_eur_brutto,
602            reference_price_ct_per_kwh,
603            reference_eur_brutto,
604            savings_eur,
605        }
606    }
607}
608
609// ── Grid pass-through costs ───────────────────────────────────────────────────
610
611/// Grid infrastructure charges sourced from `marktd` or supplied directly.
612#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
613pub struct GridInput {
614    // ── Strom ─────────────────────────────────────────────────────────────────
615    #[serde(default)]
616    pub nne_grundpreis_eur_per_year: Option<Decimal>,
617    #[serde(default)]
618    pub nne_arbeitspreis_ct_per_kwh: Option<Decimal>,
619    #[serde(default)]
620    pub nne_leistungspreis_eur_per_kw_year: Option<Decimal>,
621    #[serde(default)]
622    pub ka_ct_per_kwh: Option<Decimal>,
623    // ── Gas ───────────────────────────────────────────────────────────────────
624    #[serde(default)]
625    pub gas_nne_grundpreis_eur_per_year: Option<Decimal>,
626    #[serde(default)]
627    pub gas_nne_arbeitspreis_ct_per_kwh: Option<Decimal>,
628    #[serde(default)]
629    pub gas_ka_ct_per_kwh: Option<Decimal>,
630    #[serde(default)]
631    pub gas_bilanzierungsumlage_ct_per_kwh: Option<Decimal>,
632}
633
634// ── EnergyShareMeterInput ─────────────────────────────────────────────────────
635
636/// §42c EnWG Energy Sharing — metered allocation for one community participant.
637///
638/// Populated by `billingd` from the participant's virtual meter (Summenzeitreihe)
639/// computed by `edmd` using the community's `AggregationRule::GgvConstantAllocation`
640/// or `GgvProportionalAllocation` (same infrastructure as §42b EnWG GGV).
641///
642/// ## §42c EnWG vs §42b EnWG GGV
643///
644/// | | §42b EnWG GGV (Solarpaket I) | §42c Energiegemeinschaft |
645/// |---|---|---|
646/// | Scope | Building community | Grid area (0.4 kV) |
647/// | Participants | Tenants in same building | Up to 100 members |
648/// | Plant size | No limit | ≤ 500 kW total |
649/// | Metering | Building meter | Smart meter (iMSys) mandatory |
650/// | LF billing | via SolarProvider | via EnergyShareProvider |
651///
652/// ## Billing model
653///
654/// The LF bills the full grid consumption (via `ElectricityProvider`) and then
655/// credits the sharing allocation (via `EnergyShareProvider`) at the contracted
656/// rate — typically below the retail tariff and above the wholesale price.
657#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
658pub struct EnergyShareMeterInput {
659    /// kWh allocated from the community energy pool to this participant.
660    ///
661    /// Computed from the community's total generation and this participant's
662    /// allocation fraction (`GgvConstantAllocation.fraction` or proportional ratio).
663    /// Limited to the participant's actual consumption (§42c cap clause).
664    pub allocated_kwh: Decimal,
665
666    /// Total generation from the community's shared plant (kWh).
667    ///
668    /// Rendered as an informational position: a participant cannot check their
669    /// own allocation without the total it was taken from.
670    #[serde(default)]
671    pub total_plant_generation_kwh: Option<Decimal>,
672
673    /// Participant's allocation fraction (0.0–1.0).
674    ///
675    /// Rendered as an informational position — the other half of the same
676    /// check: fraction × total should be the allocated kWh, and a participant
677    /// who can see both can verify it.
678    #[serde(default)]
679    pub allocation_fraction: Option<Decimal>,
680
681    /// The community's own identifier, as the operator runs it.
682    ///
683    /// Rendered on the invoice so a participant in more than one arrangement
684    /// can tell which credit belongs to which. **Not** a statutory registration
685    /// number: § 42c EnWG contains no BNetzA/Marktstammdatenregister
686    /// registration duty for the community.
687    #[serde(default)]
688    pub gemeinschaft_id: Option<String>,
689}
690
691// ── Apportioning a period total across the legs of a split period ─────────────
692
693/// How one leg of a split billing period takes its share of a period total.
694///
695/// A period is billed in legs wherever a Tarifwechsel or a statutory rate
696/// boundary falls inside it, and each leg is priced at its own tariff and its
697/// own rates. A quantity the caller states **once for the whole period** then
698/// has to reach them: charged in full on every leg it is billed once per leg,
699/// and charged in full on one it prices the whole period at that leg's tariff.
700///
701/// mako apportions it by **calendar days**. The legs of a period are
702/// consecutive and non-overlapping, so their day counts are the only ratio
703/// available without a second reading, and a standing-charge-plus-consumption
704/// supply is what the day ratio describes. It is an apportionment and not a
705/// measurement: a caller holding real per-leg readings supplies those instead,
706/// and a caller that cannot is told so rather than having a reading invented
707/// for it — [`Quantities`] carries no per-leg register values.
708///
709/// Additive quantities — kWh, m³, months, event counts — are apportioned. A
710/// figure that is not a sum over the period's days is carried whole: a peak
711/// demand is the highest interval of the period and is the highest interval of
712/// whichever leg contains it, a sealed surface is a property of the premises,
713/// and a unit price is a price.
714///
715/// The shares are split with [`billing::proportional_split`]
716/// (Largest-Remainder), so the legs of a period sum back to the caller's total
717/// exactly rather than to it plus a rounding residue.
718#[derive(Debug, Clone)]
719pub struct DayApportionment {
720    /// Days per leg, in order.
721    days: Vec<Decimal>,
722    /// Which leg this apportionment speaks for.
723    index: usize,
724}
725
726/// Decimals kept when apportioning a quantity: 0.001 kWh / m³ / month.
727const QUANTITY_SCALE: u32 = 3;
728
729impl DayApportionment {
730    /// The apportionment for leg `index` of a period whose legs run `days` days.
731    ///
732    /// Falls back to [`Self::whole`] when the shape cannot be apportioned —
733    /// no legs, an out-of-range index, or a period of no days at all — so a
734    /// caller never has to choose between a panic and a silently dropped
735    /// quantity.
736    #[must_use]
737    pub fn new(days: &[u32], index: usize) -> Self {
738        let total: u64 = days.iter().map(|d| u64::from(*d)).sum();
739        if index >= days.len() || total == 0 {
740            return Self::whole();
741        }
742        Self {
743            days: days.iter().map(|d| Decimal::from(*d)).collect(),
744            index,
745        }
746    }
747
748    /// The period is one leg: every total belongs to it unchanged.
749    #[must_use]
750    pub fn whole() -> Self {
751        Self {
752            days: vec![Decimal::ONE],
753            index: 0,
754        }
755    }
756
757    /// Whether this apportionment leaves every total untouched.
758    #[must_use]
759    pub fn is_whole(&self) -> bool {
760        self.days.len() <= 1
761    }
762
763    /// This leg's share of `total`, rounded to `scale` decimals.
764    #[must_use]
765    pub fn share(&self, total: Decimal, scale: u32) -> Decimal {
766        if self.is_whole() || total.is_zero() {
767            return total;
768        }
769        let sum: Decimal = self.days.iter().copied().sum();
770        let last = self.days.len() - 1;
771        // `proportional_split` requires shares that sum to exactly one, and a
772        // day count rarely divides evenly; the last leg absorbs the residue.
773        let mut fractions: Vec<Decimal> = self.days.iter().map(|d| *d / sum).collect();
774        let head: Decimal = fractions[..last].iter().copied().sum();
775        fractions[last] = Decimal::ONE - head;
776        // A quantity is non-negative, but a correction run can carry a negative
777        // one; the sign is lifted out and re-applied, which is exact.
778        let negative = total < Decimal::ZERO;
779        let magnitude = if negative { -total } else { total };
780        let part = billing::proportional_split(magnitude, &fractions, scale)
781            .ok()
782            .and_then(|parts| parts.get(self.index).copied())
783            .unwrap_or_else(|| crate::rates::round_money(magnitude * fractions[self.index], scale));
784        if negative { -part } else { part }
785    }
786
787    /// This leg's share of an additive quantity — kWh, m³ or months.
788    #[must_use]
789    pub fn quantity(&self, total: Decimal) -> Decimal {
790        self.share(total, QUANTITY_SCALE)
791    }
792
793    /// This leg's share of an optional additive quantity.
794    #[must_use]
795    pub fn opt_quantity(&self, total: Option<Decimal>) -> Option<Decimal> {
796        total.map(|t| self.quantity(t))
797    }
798
799    /// This leg's share of a countable number of events.
800    ///
801    /// Split at scale 0: half an optimisation event does not exist, and the
802    /// legs still sum back to the events the caller reported.
803    #[must_use]
804    pub fn count(&self, total: u32) -> u32 {
805        use rust_decimal::prelude::ToPrimitive as _;
806        self.share(Decimal::from(total), 0)
807            .to_u32()
808            .unwrap_or(total)
809    }
810
811    /// This leg's share of an optional countable number of events.
812    #[must_use]
813    pub fn opt_count(&self, total: Option<u32>) -> Option<u32> {
814        total.map(|t| self.count(t))
815    }
816}
817
818impl WaermeMeterInput {
819    /// This leg's share of a Fernwärme period total.
820    ///
821    /// The delivered heat and the month count are sums over the period's days.
822    /// The Spitzenleistung is not: it is the highest interval of the period,
823    /// and the leg that contains it has the same peak the whole period has —
824    /// while the Leistungspreis it feeds is already pro-rated by `months`.
825    #[must_use]
826    pub fn apportioned(&self, a: &DayApportionment) -> Self {
827        Self {
828            kwh_waerme: a.quantity(self.kwh_waerme),
829            spitzenleistung_kw: self.spitzenleistung_kw,
830            months: a.opt_quantity(self.months),
831        }
832    }
833}
834
835impl WasserMeterInput {
836    /// This leg's share of a water period total.
837    ///
838    /// Frischwasser, every Absetzung and the month count are apportioned; the
839    /// same ratio on both keeps an Absetzung from overtaking the Frischwasser
840    /// it is deducted from. The versiegelte Fläche is a property of the
841    /// premises rather than a quantity delivered over the period.
842    #[must_use]
843    pub fn apportioned(&self, a: &DayApportionment) -> Self {
844        Self {
845            frischwasser_m3: a.quantity(self.frischwasser_m3),
846            absetzungen: self
847                .absetzungen
848                .iter()
849                .map(|x| Absetzung {
850                    m3: a.quantity(x.m3),
851                    grund: x.grund,
852                })
853                .collect(),
854            versiegelte_flaeche_m2: self.versiegelte_flaeche_m2,
855            months: a.opt_quantity(self.months),
856        }
857    }
858}
859
860impl SolarMeterInput {
861    /// This leg's share of a self-consumption period total.
862    #[must_use]
863    pub fn apportioned(&self, a: &DayApportionment) -> Self {
864        Self {
865            eigenverbrauch_kwh: a.quantity(self.eigenverbrauch_kwh),
866        }
867    }
868}
869
870impl EegMeterInput {
871    /// This leg's share of a feed-in period total.
872    ///
873    /// The § 51 EEG negative-price hours are apportioned with the feed-in they
874    /// are subtracted from, so the billable kWh of the legs still sum to the
875    /// billable kWh of the period.
876    #[must_use]
877    pub fn apportioned(&self, a: &DayApportionment) -> Self {
878        Self {
879            einspeisung_kwh: a.quantity(self.einspeisung_kwh),
880            kwh_during_negative_epex: a.opt_quantity(self.kwh_during_negative_epex),
881        }
882    }
883}
884
885impl HemsMeterInput {
886    /// This leg's share of a HEMS period total.
887    #[must_use]
888    pub fn apportioned(&self, a: &DayApportionment) -> Self {
889        Self {
890            months: a.opt_quantity(self.months),
891            optimization_events: a.opt_count(self.optimization_events),
892            readout_events: a.opt_count(self.readout_events),
893        }
894    }
895}
896
897impl EmobilityMeterInput {
898    /// This leg's share of an e-mobility period total.
899    #[must_use]
900    pub fn apportioned(&self, a: &DayApportionment) -> Self {
901        Self {
902            months: a.opt_quantity(self.months),
903            kwh_charged: a.opt_quantity(self.kwh_charged),
904            sessions: a.opt_count(self.sessions),
905            roaming_sessions: a.opt_count(self.roaming_sessions),
906        }
907    }
908}
909
910impl ServiceMeterInput {
911    /// This leg's share of an Energiedienstleistung period total.
912    ///
913    /// `event_price_eur` is the agreed price of one event, not a total, so it
914    /// is carried whole.
915    #[must_use]
916    pub fn apportioned(&self, a: &DayApportionment) -> Self {
917        Self {
918            months: a.opt_quantity(self.months),
919            event_count: a.opt_count(self.event_count),
920            event_price_eur: self.event_price_eur,
921        }
922    }
923}
924
925impl EnergyShareMeterInput {
926    /// This leg's share of a § 42c EnWG community allocation.
927    ///
928    /// The allocated energy and the plant generation it was taken from are
929    /// apportioned together, so the informational check the invoice offers the
930    /// participant — fraction × generation ≈ allocation — still holds on each
931    /// leg. The fraction itself is a ratio and the community identifier a name.
932    #[must_use]
933    pub fn apportioned(&self, a: &DayApportionment) -> Self {
934        Self {
935            allocated_kwh: a.quantity(self.allocated_kwh),
936            total_plant_generation_kwh: a.opt_quantity(self.total_plant_generation_kwh),
937            allocation_fraction: self.allocation_fraction,
938            gemeinschaft_id: self.gemeinschaft_id.clone(),
939        }
940    }
941}
942
943// ── Quantities ────────────────────────────────────────────────────────────────
944
945/// All metered quantities for one billing period.
946///
947/// Replaces the scattered positional parameters of the old `calculate_*` functions.
948/// Set only the fields relevant for the current billing run — defaults are `None`/
949/// empty for unused products.
950///
951/// ## Multi-product billing
952///
953/// To bill a customer with electricity + solar + HEMS on one invoice:
954///
955/// ```rust,ignore
956/// let quantities = Quantities {
957///     electricity: Some(MeterInput { arbeitsmenge_kwh: dec!(500), ..Default::default() }),
958///     solar: Some(SolarMeterInput { eigenverbrauch_kwh: dec!(120) }),
959///     hems: Some(HemsMeterInput { months: Some(dec!(1)), ..Default::default() }),
960///     ..Default::default()
961/// };
962/// ```
963#[derive(Debug, Clone, Default)]
964pub struct Quantities {
965    /// Electricity consumption (STROM, WAERMEPUMPE, WALLBOX).
966    pub electricity: Option<MeterInput>,
967
968    /// §14a Modul 3 — the controllable device's energy per Tarifstufe.
969    ///
970    /// The Netzbetreiber's time windows, not the supplier's HT/NT: the two
971    /// gratings are set by different parties and rarely coincide, which is why
972    /// this is not derived from `MeterInput`'s Zweitarif split.
973    pub sect14a_modul3: Option<Sect14aModul3Verbrauch>,
974    /// Natural gas consumption (GAS).
975    pub gas: Option<GasMeterInput>,
976    /// District heat / Fernwärme (WAERME).
977    pub heat: Option<WaermeMeterInput>,
978    /// Drinking water / wastewater (WASSER).
979    pub wasser: Option<WasserMeterInput>,
980    /// Solar self-consumption / Mieterstrom / GGV (SOLAR) — simple single-rate path.
981    pub solar: Option<SolarMeterInput>,
982    /// §42b EnWG (Solarpaket I) — GGV community solar hybrid billing.
983    ///
984    /// Use instead of (or in addition to) `solar` when the plant’s generation must be
985    /// proportionally allocated among tenants. The `SolarProvider` will then generate
986    /// **two** positions per tenant:
987    /// - **PV portion**: `min(consumption, allocated_pv)` at the community solar rate
988    /// - **Grid portion**: `max(0, consumption − allocated_pv)` at the regular electricity rate
989    ///
990    /// Computed via `GgvNutzungsplan::allocate(plant_generation_kwh)` in `billingd`.
991    pub ggv_solar: Option<GgvSolarInput>,
992    /// EEG feed-in meter data (simplified path — rates from TariffInput).
993    pub eeg: Option<EegMeterInput>,
994    /// Full EEG settlement via `eeg-billing` — set this for NB-side precision.
995    ///
996    /// When set, `EegProvider` calls `eeg_billing::calculate_settlement(eeg_full)`
997    /// for version-aware §51/§52 rules. Supersedes `eeg` when both are present.
998    ///
999    /// Requires the `eeg` feature of this crate.
1000    #[cfg(feature = "eeg")]
1001    pub eeg_full: Option<eeg_billing::SettleInput>,
1002    /// Non-EEG Direktvermarktung feed-in (EINSPEISUNG).
1003    pub einspeisung: Option<EegMeterInput>,
1004    /// HEMS subscription and event data.
1005    pub hems: Option<HemsMeterInput>,
1006    /// E-mobility CPO/EMSP data.
1007    pub emobility: Option<EmobilityMeterInput>,
1008    /// Energiedienstleistung service data.
1009    pub service: Option<ServiceMeterInput>,
1010    /// §41a dynamic tariff intervals (15-min Lastgang from edmd).
1011    pub dynamic_intervals: Vec<DynamicInterval>,
1012    /// EPEX Spot price map for §41a billing: quarter-hour MTU start (UTC) → ct/kWh.
1013    ///
1014    /// Keyed on the 15-minute market time unit start instant
1015    /// ([`crate::provider::mtu_start`]) — DST-safe and aligned with the EPEX
1016    /// SPOT 15-min day-ahead products (live since 2025-10-01).
1017    ///
1018    /// Set by the service layer (billingd) after fetching from `productd`.
1019    /// `DynamicElectricityProvider` reads this map as a fallback when its internal
1020    /// `SpotPriceSource` has no data for an interval. This is the standard production path:
1021    /// `build_engine()` creates the provider with an empty source, and prices flow in here
1022    /// at `bill()` time.
1023    pub dynamic_epex_prices: HashMap<OffsetDateTime, Decimal>,
1024    /// EEG Gutschrift credit passed through to electricity billing (e.g. from einsd).
1025    pub eeg_gutschrift_eur: Option<Decimal>,
1026    /// Prosumer meter data (PV self-consumption + grid draw).
1027    ///
1028    /// When set, `ElectricityProvider` uses the prosumer billing path:
1029    /// - Grid consumption is billed at full tariff (commodity + NNE + Stromsteuer)
1030    /// - Self-consumption is Stromsteuer-exempt (§ 9 Abs. 1 Nr. 3 StromStG)
1031    /// - NNE does NOT apply to self-consumed energy
1032    pub prosumer: Option<ProsumerMeterInput>,
1033
1034    /// §41a Abs. 6 EnWG — annual savings comparison for dynamic tariff customers.
1035    ///
1036    /// When set, `DynamicElectricityProvider` renders a mandatory informational
1037    /// position on the annual invoice comparing actual dynamic costs against a
1038    /// reference fixed tariff (§41a Abs. 6 EnWG).
1039    pub sect41a_annual_comparison: Option<Sect41aAnnualComparison>,
1040
1041    /// §42c EnWG Energy Sharing — allocated community energy for this customer.
1042    ///
1043    /// When set, `EnergyShareProvider` generates a credit position for the
1044    /// customer's share of locally produced community electricity.
1045    ///
1046    /// ## Data source
1047    ///
1048    /// Populated by `billingd` after querying the sharing community's allocation
1049    /// data from `edmd` (virtual meter with `GgvConstantAllocation` or
1050    /// `GgvProportionalAllocation` rule — same infrastructure as §42b EnWG GGV).
1051    pub energy_share: Option<EnergyShareMeterInput>,
1052}
1053
1054impl Quantities {
1055    /// The metered sources that were supplied and carry no quantity at all.
1056    ///
1057    /// A supply invoice for a period longer than a day whose every energy
1058    /// source reads zero charges the standing charges and nothing for the
1059    /// commodity, and reads exactly like an ordinary invoice — the quantity
1060    /// twin of the `KEIN_ARBEITSPREIS` family, which refuses a product that
1061    /// prices nothing.
1062    ///
1063    /// Only sources with an energy or volume dimension are considered. HEMS and
1064    /// Energiedienstleistung are billed per month and per event, so "zero kWh"
1065    /// says nothing about them.
1066    ///
1067    /// Names the sources rather than answering yes/no, so the finding can say
1068    /// which reading is missing. Empty when at least one source carries a
1069    /// quantity, or when none of these sources was supplied at all.
1070    #[must_use]
1071    pub fn empty_energy_sources(&self) -> Vec<&'static str> {
1072        let mut supplied: Vec<(&'static str, bool)> = Vec::new();
1073        if let Some(m) = &self.electricity {
1074            supplied.push(("electricity", m.billable_kwh() > Decimal::ZERO));
1075        }
1076        if let Some(m) = &self.gas {
1077            supplied.push((
1078                "gas",
1079                m.messung_qm3 > Decimal::ZERO || m.kwh_hs.unwrap_or_default() > Decimal::ZERO,
1080            ));
1081        }
1082        if let Some(m) = &self.heat {
1083            supplied.push(("heat", m.kwh_waerme > Decimal::ZERO));
1084        }
1085        if let Some(m) = &self.wasser {
1086            supplied.push(("wasser", m.frischwasser_m3 > Decimal::ZERO));
1087        }
1088        if let Some(m) = &self.solar {
1089            supplied.push(("solar", m.eigenverbrauch_kwh > Decimal::ZERO));
1090        }
1091        if let Some(m) = &self.eeg {
1092            supplied.push(("eeg", m.einspeisung_kwh > Decimal::ZERO));
1093        }
1094        if let Some(m) = &self.einspeisung {
1095            supplied.push(("einspeisung", m.einspeisung_kwh > Decimal::ZERO));
1096        }
1097        if let Some(m) = &self.emobility {
1098            supplied.push((
1099                "emobility",
1100                m.kwh_charged.unwrap_or_default() > Decimal::ZERO
1101                    || m.sessions.unwrap_or_default() > 0,
1102            ));
1103        }
1104        if let Some(g) = &self.ggv_solar {
1105            supplied.push((
1106                "ggv_solar",
1107                g.pv_allocated_kwh > Decimal::ZERO || g.actual_consumption_kwh > Decimal::ZERO,
1108            ));
1109        }
1110        if supplied.is_empty() || supplied.iter().any(|(_, has)| *has) {
1111            return Vec::new();
1112        }
1113        supplied.into_iter().map(|(name, _)| name).collect()
1114    }
1115}
1116
1117// ── ProsumerMeterInput ────────────────────────────────────────────────────────
1118
1119/// Prosumer meter data — combines grid consumption with PV self-consumption.
1120///
1121/// A prosumer simultaneously consumes electricity (partly from the grid,
1122/// partly from their own PV plant) and may export surplus generation to the grid.
1123///
1124/// ## LF billing scope (energy-billing)
1125///
1126/// The Lieferant bills **grid consumption** only. Self-consumption billing and
1127/// EEG feed-in remuneration (Einspeisevergütung) are handled by `eeg-billing`.
1128///
1129/// ## Stromsteuer exemption
1130///
1131/// § 9 Abs. 1 Nr. 3 StromStG exempts self-consumed electricity from plants up to 2 MW
1132/// from Stromsteuer. This is applied automatically when `self_consumption_kwh > 0`.
1133///
1134/// ## Network charge exemption
1135///
1136/// Netzentgelte are charged for *Netznutzung* — the § 17 StromNEV Arbeits- and
1137/// Leistungspreis are levied on what is taken from the grid at the
1138/// Entnahmestelle. Self-consumed electricity never enters it, so it is outside
1139/// that base and no NNE applies to `self_consumption_kwh`. This is not the
1140/// § 14a EnWG reduction, which is a *reduced* Netzentgelt for a controllable
1141/// load that does draw from the grid.
1142///
1143/// ## Example
1144///
1145/// ```rust
1146/// use energy_billing::ProsumerMeterInput;
1147/// use rust_decimal::dec;
1148///
1149/// let m = ProsumerMeterInput {
1150///     grid_consumption_kwh: dec!(250),   // drawn from grid → full tariff
1151///     self_consumption_kwh: dec!(150),   // from own PV → Stromsteuer-exempt, no Netznutzung
1152///     export_kwh: Some(dec!(100)),       // fed back to grid (via eeg-billing)
1153/// };
1154/// assert_eq!(m.total_consumption_kwh(), dec!(400));
1155/// ```
1156#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1157pub struct ProsumerMeterInput {
1158    /// Electricity drawn from the public grid (kWh).
1159    ///
1160    /// Full tariff applies: Arbeitspreis + NNE + Stromsteuer.
1161    pub grid_consumption_kwh: Decimal,
1162
1163    /// Electricity generated by the customer's PV plant and consumed on-site (kWh).
1164    ///
1165    /// - No NNE (does not transit the grid)
1166    /// - No Stromsteuer on the self-consumed part (§ 9 Abs. 1 Nr. 3 StromStG)
1167    /// - Appears as an informational invoice line showing the self-supply ratio
1168    pub self_consumption_kwh: Decimal,
1169
1170    /// Electricity exported to the grid (kWh). Informational only.
1171    ///
1172    /// This quantity is handled by `eeg-billing` (EEG Einspeisevergütung),
1173    /// not by `energy-billing`. Included here so the retail invoice can show
1174    /// the complete energy balance to the customer (§41 EnWG transparency).
1175    #[serde(default)]
1176    pub export_kwh: Option<Decimal>,
1177}
1178
1179impl ProsumerMeterInput {
1180    /// Total electricity consumption (grid + self, kWh).
1181    #[must_use]
1182    pub fn total_consumption_kwh(&self) -> Decimal {
1183        self.grid_consumption_kwh + self.self_consumption_kwh
1184    }
1185
1186    /// Self-supply ratio (0.0–1.0): share of total consumption from own PV.
1187    #[must_use]
1188    pub fn self_supply_ratio(&self) -> Decimal {
1189        let total = self.total_consumption_kwh();
1190        if total.is_zero() {
1191            Decimal::ZERO
1192        } else {
1193            (self.self_consumption_kwh / total).min(Decimal::ONE)
1194        }
1195    }
1196}
1197
1198/// §42b EnWG (Solarpaket I, BGBl I 2024 Nr. 107) — GGV allocation for one tenant.
1199///
1200/// Use this for **Gemeinschaftliche Gebäudeversorgung** billing where the plant’s
1201/// generation is proportionally distributed among building participants.
1202/// The `SolarProvider` splits the tenant’s invoice into:
1203///
1204/// - **PV portion** (community solar at discounted GGV rate)
1205/// - **Grid portion** (residual demand from the public grid at standard electricity rate)
1206///
1207/// ## Computing allocations
1208///
1209/// ```rust
1210/// use energy_billing::{GgvNutzungsplan, GgvNutzungsplanEntry, GgvSolarInput};
1211/// use rust_decimal::dec;
1212///
1213/// let plan = GgvNutzungsplan(vec![
1214///     GgvNutzungsplanEntry { malo_id: "A".into(), fraction: dec!(0.60) },
1215///     GgvNutzungsplanEntry { malo_id: "B".into(), fraction: dec!(0.40) },
1216/// ]);
1217/// let plant_kwh = dec!(100);
1218/// let allocs = plan.allocate(plant_kwh).unwrap(); // [("A", 60), ("B", 40)]
1219///
1220/// let tenant_a = GgvSolarInput {
1221///     pv_allocated_kwh: dec!(60),
1222///     actual_consumption_kwh: dec!(80),   // needs 80, gets 60 PV + 20 grid
1223/// };
1224/// assert_eq!(tenant_a.pv_delivered_kwh(), dec!(60));
1225/// assert_eq!(tenant_a.grid_kwh(), dec!(20));
1226/// ```
1227#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1228pub struct GgvSolarInput {
1229    /// PV energy allocated to this tenant via the GGV Nutzungsplan.
1230    ///
1231    /// = `plant_generation_kwh × tenant_fraction` (from `GgvNutzungsplan::allocate`).
1232    pub pv_allocated_kwh: Decimal,
1233    /// Actual metered energy consumption at this tenant’s delivery point.
1234    ///
1235    /// Sourced from `edmd` for the billing period.
1236    pub actual_consumption_kwh: Decimal,
1237}
1238
1239impl GgvSolarInput {
1240    /// PV energy actually delivered to this tenant.
1241    ///
1242    /// Capped at the tenant’s consumption: a tenant cannot receive more PV than they use.
1243    #[must_use]
1244    pub fn pv_delivered_kwh(&self) -> Decimal {
1245        self.actual_consumption_kwh.min(self.pv_allocated_kwh)
1246    }
1247
1248    /// Residual grid electricity needed beyond the PV allocation.
1249    ///
1250    /// This quantity is billed at the standard electricity (STROM) rate.
1251    #[must_use]
1252    pub fn grid_kwh(&self) -> Decimal {
1253        (self.actual_consumption_kwh - self.pv_allocated_kwh).max(Decimal::ZERO)
1254    }
1255
1256    /// Fraction of this tenant’s consumption covered by community PV (0.0–1.0).
1257    ///
1258    /// Useful for §40 kilowattstundenpreis reporting and sustainability KPIs.
1259    #[must_use]
1260    pub fn pv_coverage_ratio(&self) -> Decimal {
1261        if self.actual_consumption_kwh <= Decimal::ZERO {
1262            return Decimal::ZERO;
1263        }
1264        (self.pv_delivered_kwh() / self.actual_consumption_kwh)
1265            .min(Decimal::ONE)
1266            .round_kfm(4)
1267    }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273    use rust_decimal::dec;
1274
1275    fn plan(fractions: &[(&str, &str)]) -> GgvNutzungsplan {
1276        GgvNutzungsplan(
1277            fractions
1278                .iter()
1279                .map(|(id, f)| GgvNutzungsplanEntry {
1280                    malo_id: (*id).to_owned(),
1281                    fraction: f.parse().unwrap(),
1282                })
1283                .collect(),
1284        )
1285    }
1286
1287    /// Fractions summing to 1.0 do not prove the plan covers the community: a
1288    /// 3-entry plan for 4 tenants is internally consistent, and the omitted
1289    /// tenant would silently be billed as pure Solar-Eigenverbrauch.
1290    #[test]
1291    fn validate_covers_rejects_a_tenant_missing_from_the_plan() {
1292        let p = plan(&[("A", "0.5"), ("B", "0.3"), ("C", "0.2")]);
1293        p.validate().expect("fractions sum to 1.0");
1294        p.validate_covers(["A", "B", "C"]).expect("exact coverage");
1295
1296        let err = p
1297            .validate_covers(["A", "B", "C", "D"])
1298            .expect_err("D is not allocated");
1299        assert!(err.contains('D'), "{err}");
1300
1301        let err = p
1302            .validate_covers(["A", "B"])
1303            .expect_err("C is not a tenant of this run");
1304        assert!(err.contains('C'), "{err}");
1305    }
1306
1307    /// The allocation is keyed on the MaLo, so a duplicated entry loses a share.
1308    #[test]
1309    fn validate_covers_rejects_a_duplicated_malo() {
1310        let p = plan(&[("A", "0.5"), ("A", "0.3"), ("B", "0.2")]);
1311        let err = p.validate_covers(["A", "B"]).expect_err("A appears twice");
1312        assert!(err.contains("more than once"), "{err}");
1313    }
1314
1315    /// Σ(allocated) must always equal total_kwh exactly.
1316    #[test]
1317    fn allocate_sum_equals_total() {
1318        let p = plan(&[("A", "0.333"), ("B", "0.333"), ("C", "0.334")]);
1319        let total = dec!(100.000);
1320        let allocs = p
1321            .allocate(total)
1322            .expect("the shares partition the generation");
1323        let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
1324        assert_eq!(sum, total, "sum must equal total exactly");
1325    }
1326
1327    /// With 3 equal tenants the old "dump remainder on last" method would give
1328    /// last tenant 0.001 kWh extra. LRM distributes evenly.
1329    #[test]
1330    fn allocate_lrm_distributes_evenly_not_just_last_entry() {
1331        // 3 equal tenants, 100.001 kWh → exact share = 33.333666…
1332        // floor 3dp = 33.333 each → 1 leftover unit (0.001 kWh)
1333        // LRM: give it to whichever has highest fractional part (they're equal, so first)
1334        // Old naive: last tenant gets all of it
1335        let p = plan(&[("A", "0.3333"), ("B", "0.3333"), ("C", "0.3334")]);
1336        let total = dec!(100.000);
1337        let allocs = p
1338            .allocate(total)
1339            .expect("the shares partition the generation");
1340
1341        // All within ±0.001 of their exact share
1342        for (id, kwh) in &allocs {
1343            let fraction: Decimal = p.0.iter().find(|e| &e.malo_id == id).unwrap().fraction;
1344            let exact = total * fraction;
1345            let diff = (kwh - exact).abs();
1346            assert!(
1347                diff <= dec!(0.001),
1348                "{id}: allocated {kwh}, exact {exact}, diff {diff} > 0.001"
1349            );
1350        }
1351
1352        let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
1353        assert_eq!(sum, total);
1354    }
1355
1356    /// Many tenants: no single tenant should absorb disproportionate error.
1357    #[test]
1358    fn allocate_lrm_no_disproportionate_last_entry() {
1359        // 10 equal tenants, 1000.001 kWh → each gets 100.0001 → floor = 100.000
1360        // 1 leftover 0.001 unit
1361        let tenants: Vec<(String, String)> = (0..10)
1362            .map(|i| (format!("T{i}"), "0.1".to_owned()))
1363            .collect();
1364        let p = GgvNutzungsplan(
1365            tenants
1366                .iter()
1367                .map(|(id, f)| GgvNutzungsplanEntry {
1368                    malo_id: id.clone(),
1369                    fraction: f.parse().unwrap(),
1370                })
1371                .collect(),
1372        );
1373        let total = dec!(1000.001);
1374        let allocs = p
1375            .allocate(total)
1376            .expect("the shares partition the generation");
1377
1378        // With old naive: T9 (last) gets 100.001, others get 100.000
1379        // With LRM: one tenant gets 100.001, the rest get 100.000 — but it's
1380        // the one with the highest fractional part, not necessarily the last.
1381        let over_base: Vec<_> = allocs.iter().filter(|(_, k)| *k > dec!(100.000)).collect();
1382        assert_eq!(
1383            over_base.len(),
1384            1,
1385            "exactly 1 tenant should get the extra 0.001"
1386        );
1387
1388        let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
1389        assert_eq!(sum, total);
1390    }
1391}
1392
1393// ── Sect14aModul3Verbrauch ────────────────────────────────────────────────────
1394
1395/// Energy per §14a Modul 3 Tarifstufe (zeitvariable Netzentgelte, BK8-22/010-A).
1396///
1397/// All three bands are present by construction — a Modul 3 metering
1398/// configuration reports every window, and a zero band is a real zero, not an
1399/// absent one.
1400#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
1401pub struct Sect14aModul3Verbrauch {
1402    /// Hochtarif energy in kWh.
1403    pub ht_kwh: Decimal,
1404    /// Standardtarif energy in kWh.
1405    pub st_kwh: Decimal,
1406    /// Niedertarif energy in kWh.
1407    pub nt_kwh: Decimal,
1408}
1409
1410// ── Abschlagsplan ─────────────────────────────────────────────────────────────
1411
1412/// One scheduled advance payment entry (Abschlag) in an Abschlagsplan.
1413///
1414/// Advance payments must be based on the estimated annual consumption.
1415/// When the operator changes the Abschlag amount, customers must be notified
1416/// with adequate lead time per §41 Abs. 1 Nr. 6 EnWG.
1417#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1418pub struct AbschlagsplanEntry {
1419    /// Payment due date.
1420    pub faellig_am: time::Date,
1421    /// Amount to collect in EUR (brutto, i.e. inclusive of MwSt).
1422    pub betrag_eur: Decimal,
1423    /// Optional display label (e.g. `"Abschlag Januar 2026"`).
1424    #[serde(default)]
1425    pub beschreibung: Option<String>,
1426}
1427
1428/// Complete advance payment schedule for a customer contract.
1429///
1430/// Provides the statutory context (estimated annual cost and consumption) to
1431/// satisfy §41 Abs. 1 Nr. 6 EnWG requirements.
1432///
1433/// ## Legal basis
1434///
1435/// §41 Abs. 1 Nr. 6 EnWG: the invoice must show the current and planned
1436/// advance payment amounts and collection dates.
1437///
1438/// ## Example — generate a 12-month uniform schedule
1439///
1440/// ```rust
1441/// use energy_billing::Abschlagsplan;
1442/// use rust_decimal::dec;
1443/// use time::macros::date;
1444///
1445/// let plan = Abschlagsplan::monthly_uniform(
1446///     "51238696012",
1447///     date!(2026-01-01),
1448///     12,
1449///     dec!(1440.00), // annual brutto estimate
1450///     dec!(3600),    // annual kWh estimate
1451/// );
1452/// assert_eq!(plan.entries.len(), 12);
1453/// assert_eq!(plan.entries[0].betrag_eur, dec!(120.00));
1454/// ```
1455#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1456pub struct Abschlagsplan {
1457    /// Market location this plan belongs to.
1458    pub malo_id: String,
1459    /// Contract reference for ERP routing.
1460    #[serde(default)]
1461    pub contract_id: Option<String>,
1462    /// Scheduled advance payment entries in chronological order.
1463    pub entries: Vec<AbschlagsplanEntry>,
1464    /// Annual consumption estimate used to derive the plan (kWh).
1465    pub jahresverbrauch_schaetzung_kwh: Decimal,
1466    /// Annual cost estimate used to derive the plan (EUR brutto).
1467    pub jahreskosten_schaetzung_eur: Decimal,
1468}
1469
1470impl Abschlagsplan {
1471    /// Build a uniform monthly advance payment plan for `months` months.
1472    ///
1473    /// The annual amount is **distributed exactly** over a 12-month cycle via
1474    /// [`billing::Amount::distribute`] (largest-remainder): any 12 consecutive
1475    /// instalments sum to precisely `annual_brutto_eur` at cent precision.
1476    /// Naïve `round(annual / 12)` per month drifts up to 6 ct per year — a
1477    /// reconciliation gap §13 Abs. 3 StromGVV's refund duty would surface on
1478    /// every Jahresrechnung.
1479    #[must_use]
1480    pub fn monthly_uniform(
1481        malo_id: impl Into<String>,
1482        start_date: time::Date,
1483        months: u32,
1484        annual_brutto_eur: Decimal,
1485        jahresverbrauch_kwh: Decimal,
1486    ) -> Self {
1487        use rust_decimal::dec;
1488        // Exact-sum 12-month cycle; conversion failure (absurd magnitude)
1489        // degrades to the plain division, never to a panic.
1490        let cycle: Vec<Decimal> = billing::Amount::<2>::checked_from_decimal(annual_brutto_eur)
1491            .and_then(|a| a.distribute(12))
1492            .map(|parts| {
1493                parts
1494                    .into_iter()
1495                    .map(billing::Amount::into_decimal)
1496                    .collect()
1497            })
1498            .unwrap_or_else(|_| vec![(annual_brutto_eur / dec!(12)).round_kfm(2); 12]);
1499        let entries = (0..months)
1500            .filter_map(|i| {
1501                let total_months = start_date.month() as u32 - 1 + i;
1502                let year = start_date.year() + (total_months / 12) as i32;
1503                let month_idx = (total_months % 12 + 1) as u8;
1504                let month = time::Month::try_from(month_idx).ok()?;
1505                let max_day = month.length(time::util::is_leap_year(year) as i32) as u8;
1506                let day = start_date.day().min(max_day);
1507                let date = time::Date::from_calendar_date(year, month, day).ok()?;
1508                Some(AbschlagsplanEntry {
1509                    faellig_am: date,
1510                    betrag_eur: cycle[(i % 12) as usize],
1511                    beschreibung: Some(format!("Abschlag {:02}/{}", month as u8, year)),
1512                })
1513            })
1514            .collect();
1515        Self {
1516            malo_id: malo_id.into(),
1517            contract_id: None,
1518            entries,
1519            jahresverbrauch_schaetzung_kwh: jahresverbrauch_kwh,
1520            jahreskosten_schaetzung_eur: annual_brutto_eur,
1521        }
1522    }
1523
1524    /// Sum of all scheduled advance payment amounts.
1525    #[must_use]
1526    pub fn total_eur(&self) -> Decimal {
1527        self.entries.iter().map(|e| e.betrag_eur).sum()
1528    }
1529}
1530
1531#[cfg(test)]
1532mod abschlagsplan_tests {
1533    use super::*;
1534    use rust_decimal::dec;
1535    use time::macros::date;
1536
1537    #[test]
1538    fn monthly_uniform_12_months() {
1539        let plan = Abschlagsplan::monthly_uniform(
1540            "51238696781",
1541            date!(2026 - 01 - 01),
1542            12,
1543            dec!(1440.00),
1544            dec!(3600),
1545        );
1546        assert_eq!(plan.entries.len(), 12);
1547        assert_eq!(plan.entries[0].betrag_eur, dec!(120.00));
1548        assert_eq!(plan.entries[11].faellig_am.year(), 2026);
1549        assert_eq!(plan.total_eur(), dec!(1440.00));
1550    }
1551
1552    #[test]
1553    fn monthly_uniform_distributes_indivisible_annual_exactly() {
1554        // 1000.00 / 12 = 83.333… — naïve per-month rounding gives
1555        // 12 × 83.33 = 999.96, a 4 ct gap the Jahresrechnung would have to
1556        // reconcile. Largest-remainder distribution closes it.
1557        let plan = Abschlagsplan::monthly_uniform(
1558            "51238696781",
1559            date!(2026 - 01 - 01),
1560            12,
1561            dec!(1000.00),
1562            dec!(2500),
1563        );
1564        assert_eq!(plan.total_eur(), dec!(1000.00), "instalments sum exactly");
1565        // Every instalment is within one cent of the uniform value.
1566        for e in &plan.entries {
1567            assert!(
1568                e.betrag_eur == dec!(83.33) || e.betrag_eur == dec!(83.34),
1569                "uniform ± 1 ct, got {}",
1570                e.betrag_eur
1571            );
1572        }
1573        // A 24-month plan sums to exactly two annual amounts.
1574        let two_years = Abschlagsplan::monthly_uniform(
1575            "51238696781",
1576            date!(2026 - 01 - 01),
1577            24,
1578            dec!(1000.00),
1579            dec!(2500),
1580        );
1581        assert_eq!(two_years.total_eur(), dec!(2000.00));
1582    }
1583
1584    #[test]
1585    fn monthly_uniform_crosses_year_boundary() {
1586        let plan = Abschlagsplan::monthly_uniform(
1587            "51238696129",
1588            date!(2025 - 07 - 01),
1589            12,
1590            dec!(1200.00),
1591            dec!(3000),
1592        );
1593        assert_eq!(plan.entries.len(), 12);
1594        // July 2025 → June 2026
1595        assert_eq!(plan.entries[0].faellig_am.month(), time::Month::July);
1596        assert_eq!(plan.entries[0].faellig_am.year(), 2025);
1597        assert_eq!(plan.entries[5].faellig_am.month(), time::Month::December);
1598        assert_eq!(plan.entries[6].faellig_am.month(), time::Month::January);
1599        assert_eq!(plan.entries[6].faellig_am.year(), 2026);
1600    }
1601}