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}