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}