Skip to main content

energy_billing/
engine.rs

1//! `BillingEngine` — the composition root for multi-product invoice generation.
2//!
3//! Register one `BillingProvider` per product/service. Call `bill()` to run all
4//! providers in order and assemble the `Invoice`.
5//!
6//! ## Primary API — `Product::build_engine()`
7//!
8//! The recommended way to build an engine is via [`Product::build_engine()`](crate::Product::build_engine):
9//!
10//! ```rust
11//! use energy_billing::{BillingContext, BillingPeriod, GridInput, InvoiceType, MeterInput, Product, Quantities, RegulatoryRates};
12//! use rust_decimal::dec;
13//! use time::macros::date;
14//!
15//! let product: Product = serde_json::from_str(r#"{"category":"STROM","arbeitspreis_ct_per_kwh":"30.0"}"#).unwrap();
16//! let ctx = BillingContext {
17//!     malo_id:         "51238696012".to_owned(),
18//!     lf_mp_id:        "9900000000001".to_owned(),
19//!     rechnungsnummer: "R2026-001".to_owned(),
20//!     period: BillingPeriod::new(date!(2026-01-01), date!(2026-01-31)).unwrap(),
21//!     invoice_type:     InvoiceType::Initial,
22//!     regulatory_rates: RegulatoryRates::default(),
23//!     ..Default::default()
24//! };
25//! let quantities = Quantities {
26//!     electricity: Some(MeterInput { arbeitsmenge_kwh: dec!(500), ..Default::default() }),
27//!     ..Default::default()
28//! };
29//! let invoice = product.build_engine(&GridInput::default(), &RegulatoryRates::default())
30//!     .bill(ctx, &quantities).unwrap();
31//! assert!(invoice.brutto_eur > invoice.netto_eur);
32//! ```
33//!
34//! ## Manual engine construction
35//!
36//! For advanced use cases (e.g. combining multiple providers in one engine),
37//! you can build the engine manually:
38//!
39//! ```rust,ignore
40//! let invoice = BillingEngine::new()
41//!     .add(ElectricityProvider::new(product, GridInput::default()))
42//!     .add(MwStProvider::new(dec!(0.19)))
43//!     .bill(ctx, &quantities).unwrap();
44//! ```
45
46use crate::context::BillingContext;
47use crate::error::EngineError;
48use crate::invoice::Invoice;
49use crate::position::{BillingPosition, BillingWarning, WarningSeverity};
50use crate::provider::BillingProvider;
51use crate::quantities::Quantities;
52use crate::rates::RoundMoney;
53
54/// The composition root for multi-product invoice generation.
55#[derive(Default)]
56pub struct BillingEngine {
57    providers: Vec<Box<dyn BillingProvider>>,
58}
59
60impl BillingEngine {
61    /// Create an empty engine. Register providers with [`add`](Self::add).
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Register a `BillingProvider`. Returns `self` for method chaining.
68    ///
69    /// Providers run in registration order. Register tax providers (e.g.
70    /// `MwStProvider`) **last** — they will automatically run in a second pass.
71    #[must_use]
72    #[allow(clippy::should_implement_trait)] // `add` is idiomatic for builder APIs in Rust
73    pub fn add<P: BillingProvider + 'static>(mut self, provider: P) -> Self {
74        self.providers.push(Box::new(provider));
75        self
76    }
77
78    /// Run all registered providers and collect regulatory compliance warnings.
79    ///
80    /// Does NOT generate positions or produce an invoice. Call this before `bill()`
81    /// to check regulatory preconditions (e.g. §41a iMSys guard, missing tariff
82    /// fields) without committing to billing.
83    ///
84    /// An `Error`-severity warning indicates a definite regulatory violation.
85    /// The operator should resolve the issue before calling `bill()`.
86    #[must_use]
87    pub fn validate(&self, ctx: &BillingContext, quantities: &Quantities) -> Vec<BillingWarning> {
88        self.providers
89            .iter()
90            .flat_map(|p| p.validate_warnings(ctx, quantities))
91            .collect()
92    }
93
94    /// Bill multiple (context, quantities) pairs using this engine configuration.
95    ///
96    /// Reuses the same provider set for every item in the batch. Fails fast per item
97    /// (each error is independent). For large portfolios, collect all results and
98    /// handle errors individually.
99    ///
100    /// ## Example
101    ///
102    /// ```rust,ignore
103    /// let results = engine.bill_batch(batch);
104    /// let errors: Vec<_> = results.iter().filter_map(|r| r.as_ref().err()).collect();
105    /// ```
106    pub fn bill_batch(
107        &self,
108        batch: Vec<(BillingContext, Quantities)>,
109    ) -> Vec<Result<Invoice, EngineError>> {
110        batch
111            .into_iter()
112            .map(|(ctx, quantities)| self.bill(ctx, &quantities))
113            .collect()
114    }
115
116    /// Run all providers and assemble an `Invoice`.
117    ///
118    /// Three-pass execution:
119    /// 1. Commodity + levy providers (all `is_tax_pass() == false`)
120    /// 2. Tax providers (all `is_tax_pass() == true`)
121    /// 3. Abschlag deductions from `ctx.abschlage` (on every settling document —
122    ///    every [`InvoiceType`](crate::InvoiceType) except
123    ///    [`AdvancePayment`](crate::InvoiceType::AdvancePayment))
124    pub fn bill(
125        &self,
126        ctx: BillingContext,
127        quantities: &Quantities,
128    ) -> Result<Invoice, EngineError> {
129        // ── Pass 0: collect regulatory warnings ───────────────────────────────
130        // Run context-level checks and validate_warnings() on all providers.
131        // If any Error-severity warning is found, fail before generating any
132        // positions — the billing run is invalid and must not be dispatched.
133        let mut warnings: Vec<BillingWarning> = context_warnings(&ctx);
134        warnings.extend(zero_quantity_warning(&ctx, quantities));
135        warnings.extend(coverage_warning(quantities));
136        for provider in self.providers.iter() {
137            warnings.extend(provider.validate_warnings(&ctx, quantities));
138        }
139        if warnings
140            .iter()
141            .any(|x| x.severity == WarningSeverity::Error)
142        {
143            // The error carries ALL warnings so the caller sees every violation.
144            return Err(EngineError::ValidationBlocked { warnings });
145        }
146
147        let mut positions: Vec<BillingPosition> = Vec::new();
148
149        // ── Pass 1: commodity, grid, levy ─────────────────────────────────────
150        for provider in self.providers.iter().filter(|p| !p.is_tax_pass()) {
151            let new = provider.bill(&ctx, quantities, &positions)?;
152            positions.extend(new);
153        }
154
155        // ── §13b reverse charge (before the MwSt pass) ────────────────────────
156        // Steuerschuldnerschaft des Leistungsempfängers (§13b Abs. 2 Nr. 5 lit. b
157        // UStG): when the customer is a Stromwiederverkäufer the whole supply is
158        // reverse-charged — the supplier invoices net, the recipient owes the VAT.
159        // Mark every supply position (not Tax/Abschlag/Info) reverse-charge so the
160        // MwStProvider computes 0 and `tax_subtotals_of` emits an `AE` subtotal.
161        if ctx.reverse_charge {
162            use crate::position::PositionCategory;
163            positions = positions
164                .into_iter()
165                .map(|p| {
166                    if matches!(
167                        p.category,
168                        PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
169                    ) {
170                        p
171                    } else {
172                        p.with_reverse_charge()
173                    }
174                })
175                .collect();
176        }
177
178        // ── The rate every supply position is charged at ──────────────────────
179        // Stamped before the tax pass, so the rate that produced `mwst_eur` is
180        // the same rate the BG-23/`steuerbetraege` breakdown reads back off the
181        // positions. The two must never be able to disagree: a § 19 UStG
182        // Kleinunternehmer document that charges 0 and states 19 % is an
183        // unrechtmäßiger Steuerausweis under § 14c Abs. 2 UStG.
184        let charged_rate = self
185            .providers
186            .iter()
187            .filter(|p| p.is_tax_pass())
188            .find_map(|p| p.charged_tax_rate());
189        if let Some(charged) = charged_rate {
190            use crate::position::PositionCategory;
191            for p in &mut positions {
192                if p.applicable_tax_rate.is_none()
193                    && !matches!(
194                        p.category,
195                        PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
196                    )
197                {
198                    p.applicable_tax_rate = Some(charged);
199                }
200            }
201        }
202
203        // ── Pass 2: taxes (MwSt sees the full commodity/levy base) ─────────────
204        let pre_tax_snap: Vec<BillingPosition> = positions.clone();
205        for provider in self.providers.iter().filter(|p| p.is_tax_pass()) {
206            let new = provider.bill(&ctx, quantities, &pre_tax_snap)?;
207            positions.extend(new);
208        }
209
210        // ── Pass 3: Abschlag deductions ────────────────────────────────────────
211        // §40 Abs. 1 EnWG: the settling invoice must itemise each advance
212        // payment it discharges. These positions do NOT affect netto_eur /
213        // mwst_eur — they reduce zahlbetrag_eur only (already paid by the
214        // customer, now being reconciled).
215        //
216        // An Abschlagsrechnung is the document that *collects* an advance, so
217        // it discharges none: deducting the advances already paid there would
218        // net them off the very request that asks for the next one.
219        if ctx.invoice_type.settles_advances() {
220            for abschlag in &ctx.abschlage {
221                let label = abschlag
222                    .beschreibung
223                    .clone()
224                    .unwrap_or_else(|| format!("Abschlag {}", abschlag.datum));
225                positions.push(
226                    crate::position::BillingPosition::debit(
227                        label,
228                        rust_decimal::Decimal::ONE,
229                        "EUR",
230                        -abschlag.betrag_eur, // negative unit_price → deduction
231                        crate::position::PositionCategory::Abschlag,
232                    )
233                    .with_legal_basis("§40 EnWG"),
234                );
235            }
236        }
237
238        // ── Pass 4: Minimum invoice top-up ──────────────────────────────────────
239        // When ctx.minimum_invoice_eur_brutto is set and the computed brutto_eur
240        // is below the minimum, add a Mindestbetrag position and re-run the tax pass.
241        if let Some(min_brutto) = ctx.minimum_invoice_eur_brutto {
242            let current_invoice = Invoice::from_positions(ctx.clone(), positions.clone(), vec![]);
243            let current_brutto = current_invoice.brutto_eur;
244            if current_brutto < min_brutto {
245                let gap_brutto = min_brutto - current_brutto;
246                // The Mindestbetrag is a **contractual** charge, so the rate it
247                // is agreed at is the contract's to state:
248                // `ctx.minimum_invoice_mwst_rate`, falling back to the rate the
249                // document charges. Deriving it from the position mix is
250                // unreliable exactly where it matters — when the net is zero, or
251                // every position is a credit — and using the standard rate
252                // unconditionally left a mixed-rate invoice (7 % Trinkwasser
253                // beside 19 % energy) short of the configured minimum by the
254                // rate difference on the top-up. Under §13b reverse charge the
255                // invoice carries no VAT, so the gap is net as-is.
256                let mwst_rate = ctx
257                    .minimum_invoice_mwst_rate
258                    .or(charged_rate)
259                    .unwrap_or(ctx.regulatory_rates.mwst_rate);
260                let divisor = if ctx.reverse_charge {
261                    rust_decimal::Decimal::ONE
262                } else {
263                    rust_decimal::Decimal::ONE + mwst_rate
264                };
265                let gap_netto = if divisor.is_zero() {
266                    gap_brutto
267                } else {
268                    (gap_brutto / divisor).round_kfm(5)
269                };
270
271                // Only the Tax positions are recomputed: the top-up widens the
272                // tax base, and nothing else about the invoice changes. Every
273                // other position — the Abschlag deductions of Pass 3 included —
274                // carries over untouched, so each advance stays deducted
275                // exactly once whether or not the top-up fires.
276                let mut positions2: Vec<BillingPosition> = positions
277                    .iter()
278                    .filter(|p| p.category != crate::position::PositionCategory::Tax)
279                    .cloned()
280                    .collect();
281                let mut topup = crate::position::BillingPosition::debit(
282                    format!("Mindestbetrag (Minimum {min_brutto:.2}\u{202f}EUR brutto)"),
283                    rust_decimal::Decimal::ONE,
284                    "EUR",
285                    gap_netto,
286                    crate::position::PositionCategory::Commodity,
287                )
288                .with_legal_basis("Vertraglich")
289                .with_tag("mindestbetrag");
290                // Stamp the rate the top-up is agreed at, so the MwSt pass and
291                // the BG-23 breakdown put it in the right bucket instead of the
292                // engine default.
293                if let Some(rate) = ctx.minimum_invoice_mwst_rate.or(charged_rate) {
294                    topup = topup.with_tax_rate(rate);
295                }
296                // The top-up is a supply position like any other: §13b covers it too.
297                if ctx.reverse_charge {
298                    topup = topup.with_reverse_charge();
299                }
300                positions2.push(topup);
301                let pre_tax2: Vec<BillingPosition> = positions2.clone();
302                for provider in self.providers.iter().filter(|p| p.is_tax_pass()) {
303                    let new = provider.bill(&ctx, quantities, &pre_tax2)?;
304                    positions2.extend(new);
305                }
306                // Fall through — a Stornorechnung of a topped-up invoice must
307                // still be negated by Pass 5.
308                positions = positions2;
309            }
310        }
311
312        // ── Pass 5: Cancellation (Storno) — negate all signs ──────────────────
313        // §41 EnWG: A Stornorechnung reverses the original invoice to EUR 0.
314        // All position signs are inverted so brutto_eur = -(original brutto_eur).
315        if ctx.invoice_type.is_reversal() {
316            negate_positions(&mut positions);
317        }
318
319        Ok(Invoice::from_positions(ctx, positions, warnings))
320    }
321}
322
323// ── Context-level regulatory checks ───────────────────────────────────────────
324
325/// Warnings derived from the context alone, independent of any provider.
326///
327/// One check: § 38 Abs. 4 EnWG ends the Ersatzversorgung „spätestens aber drei
328/// Monate nach Beginn der Ersatzenergieversorgung", so a longer period
329/// describes a supply that cannot legally exist and blocks the run (`Error`
330/// severity). Bill the first three months as Ersatzversorgung and the remainder
331/// under the regime the supply actually continued in.
332///
333/// The three months run from the day the **supply** began
334/// ([`vertragsbeginn`](BillingContext::vertragsbeginn)), which is the day
335/// § 38 Abs. 1 EnWG attaches the Ersatzversorgung to. Measuring from the
336/// invoice period start makes every period its own beginning, so a
337/// monthly-billed Ersatzversorgung would never reach the limit however long it
338/// runs.
339///
340/// An Ersatzversorgung is by definition a supply with no assignable contract,
341/// so the supply start is exactly the fact most often missing — and the period
342/// being billed is itself the supply. Without a stated start the period's own
343/// first day anchors the limit: it is never later than the true one, so the
344/// three months can only be reported early, never missed. That the anchor was
345/// assumed is reported alongside.
346fn context_warnings(ctx: &BillingContext) -> Vec<BillingWarning> {
347    let mut warnings = Vec::new();
348    if ctx.vertragsart == crate::context::Vertragsart::Ersatzversorgung {
349        let beginn = ctx.vertragsbeginn.unwrap_or_else(|| ctx.period_from());
350        if ctx.vertragsbeginn.is_none() {
351            warnings.push(BillingWarning {
352                code: "ERSATZVERSORGUNG_BEGINN_FEHLT",
353                severity: WarningSeverity::Warning,
354                message: format!(
355                    "Ersatzversorgung ohne Belieferungsbeginn im Kontext: die \
356                     Drei-Monats-Grenze des § 38 Abs. 4 EnWG wird ab dem \
357                     Zeitraumbeginn {beginn} gemessen — vertragsbeginn setzen, \
358                     wenn die Belieferung früher begann"
359                ),
360            });
361        }
362        // Three months after the first day of supply; the Ersatzversorgung may
363        // run through the day before.
364        let limit = add_months(beginn, 3);
365        if ctx.period_to() >= limit {
366            warnings.push(BillingWarning {
367                code: "ERSATZVERSORGUNG_UEBER_3_MONATE",
368                severity: WarningSeverity::Error,
369                message: format!(
370                    "Ersatzversorgung endet spätestens drei Monate nach Beginn \
371                     der Belieferung am {beginn} (§ 38 Abs. 4 EnWG): Zeitraum \
372                     {}..{} überschreitet die Grenze {limit}",
373                    ctx.period_from(),
374                    ctx.period_to(),
375                ),
376            });
377        }
378    }
379    warnings
380}
381
382/// `KEINE_MENGE` — every metered source of a multi-day period reads zero.
383///
384/// The quantity twin of the `KEIN_ARBEITSPREIS` family: a period longer than a
385/// day that bills no commodity at all charges the standing charges and nothing
386/// else, and the resulting invoice reads as ordinary. A single day can
387/// legitimately be empty, and so can a vacant delivery point over a longer one —
388/// which is why this is a finding and not a refusal. Whether a reading was
389/// *missing* rather than genuinely zero is a question only the caller that
390/// resolved it can answer, and `billingd` refuses there with `NO_METER_DATA`.
391fn zero_quantity_warning(ctx: &BillingContext, quantities: &Quantities) -> Option<BillingWarning> {
392    if ctx.days() <= 1 {
393        return None;
394    }
395    let empty = quantities.empty_energy_sources();
396    if empty.is_empty() {
397        return None;
398    }
399    Some(BillingWarning {
400        code: "KEINE_MENGE",
401        severity: WarningSeverity::Warning,
402        message: format!(
403            "kein Verbrauch im Zeitraum {}..{}: {} liefer(n) 0 — die Rechnung stellt \
404             nur Grund- und Leistungspreise. Fehlt die Ablesung, ist die Menge \
405             nachzuliefern, bevor abgerechnet wird",
406            ctx.period_from(),
407            ctx.period_to(),
408            empty.join(", "),
409        ),
410    })
411}
412
413/// `MENGE_UNVOLLSTAENDIG` — the period was not fully delivered.
414///
415/// A sum over the readings that arrived says nothing about the ones that did
416/// not, and it says it invisibly: the Arbeitsmenge of a month delivered up to
417/// the 3rd is a perfectly ordinary number.
418///
419/// § 40a Abs. 2 EnWG is what makes such a period billable at all — where the
420/// supplier cannot determine the actual consumption for reasons it does not
421/// answer for, the invoice „darf … auf einer Verbrauchsschätzung beruhen, die
422/// unter angemessener Berücksichtigung der tatsächlichen Verhältnisse zu
423/// erfolgen hat", and Satz 3 requires the estimate, the ground for it and the
424/// factors behind it to be stated on the document „unter ausdrücklichem und
425/// optisch besonders hervorgehobenem Hinweis". A finding rather than a refusal
426/// for that reason: the gap is to be estimated and labelled, not left
427/// uninvoiced while the § 40c Abs. 2 EnWG clock runs.
428fn coverage_warning(quantities: &Quantities) -> Vec<BillingWarning> {
429    let full = rust_decimal::Decimal::ONE_HUNDRED;
430    [
431        (
432            "Strom",
433            quantities.electricity.as_ref().and_then(|m| m.coverage_pct),
434        ),
435        ("Gas", quantities.gas.as_ref().and_then(|m| m.coverage_pct)),
436    ]
437    .into_iter()
438    .filter_map(|(label, pct)| {
439        let pct = pct?;
440        (pct < full).then(|| BillingWarning {
441            code: "MENGE_UNVOLLSTAENDIG",
442            severity: WarningSeverity::Warning,
443            message: format!(
444                "{label}: nur {pct} % des Abrechnungszeitraums sind durch abrechenbare \
445                 Messwerte gedeckt — die Lücke ist nach § 40a Abs. 2 EnWG zu schätzen \
446                 und die Schätzung auf der Rechnung hervorgehoben auszuweisen"
447            ),
448        })
449    })
450    .collect()
451}
452
453/// `date` plus `months` calendar months, clamped to the last valid day.
454fn add_months(date: time::Date, months: i32) -> time::Date {
455    let total = date.month() as i32 - 1 + months;
456    let year = date.year() + total.div_euclid(12);
457    let month = time::Month::try_from((total.rem_euclid(12) + 1) as u8).expect("1..=12");
458    let day = date.day().min(time::util::days_in_month(month, year));
459    time::Date::from_calendar_date(year, month, day).expect("valid clamped date")
460}
461
462// ── Cancellation helpers ──────────────────────────────────────────────────────
463
464/// Negate all position amounts for a Stornorechnung (Cancellation invoice).
465///
466/// Called internally by `BillingEngine::bill()` when `ctx.invoice_type.is_reversal()`.
467/// All `net_eur` and `unit_price_eur` are sign-inverted so the Invoice's
468/// `netto_eur`, `mwst_eur`, and `brutto_eur` equal `-(original)`.
469fn negate_positions(positions: &mut [crate::position::BillingPosition]) {
470    for p in positions.iter_mut() {
471        p.net_eur = -p.net_eur;
472        p.unit_price_eur = -p.unit_price_eur;
473    }
474}