energy_billing/provider.rs
1//! The `BillingProvider` trait — one implementation per product type.
2//!
3//! Every product type (electricity, gas, EEG feed-in, HEMS…) implements
4//! `BillingProvider`. The `BillingEngine` orchestrates them in order.
5//!
6//! ## Execution order and tax computation
7//!
8//! Providers run in registration order. Each receives `prior_positions` — all
9//! positions produced by earlier providers. Tax providers (MwSt) are typically
10//! registered last and compute their amount from `prior_positions`.
11//!
12//! ```text
13//! ElectricityProvider → commodity + grid + Stromsteuer positions
14//! GridChargeProvider → NNE/KA positions (if not already in Electricity)
15//! StromsteuerProvider → levy position (if separate from ElectricityProvider)
16//! MwStProvider → tax position on sum(prior_positions)
17//! ```
18//!
19//! The `is_tax_pass()` method marks tax providers so the engine knows to run them
20//! in a second pass (after all commodity/levy providers have executed).
21
22use crate::error::EngineError;
23
24use crate::context::BillingContext;
25use crate::position::{BillingPosition, BillingWarning};
26
27// ── Quantities ────────────────────────────────────────────────────────────────
28
29pub use crate::quantities::Quantities;
30
31// ── Market time unit ──────────────────────────────────────────────────────────
32
33/// Market Time Unit (MTU) length for the EPEX day-ahead auction, in minutes.
34///
35/// Since 2025-10-01 the SDAC day-ahead auction settles on **15-minute**
36/// products (96 quarter-hours per delivery day; 92/100 on the DST days) —
37/// EPEX SPOT go-live 01.10.2025. All spot pricing is keyed on this MTU.
38pub const MTU_MINUTES: i64 = 15;
39
40/// Floor a UTC instant to the start of its EPEX market time unit (quarter-hour).
41///
42/// CET/CEST are whole-hour offsets, so a local quarter-hour boundary is always
43/// a UTC quarter-hour boundary — flooring in UTC is therefore DST-safe and
44/// needs no timezone conversion. This is the canonical spot-price map key:
45/// [`Quantities::dynamic_epex_prices`](crate::Quantities::dynamic_epex_prices)
46/// is keyed on it, and a consumption interval is floored to it before lookup.
47///
48/// There is deliberately no `SpotPriceSource` trait behind this. One existed,
49/// with one implementation over a `HashMap` and a documented invitation to add
50/// NordPool and Tibber adapters; every construction path in the workspace
51/// passed it an **empty** map and priced from `dynamic_epex_prices` anyway. A
52/// seam nothing enters is not an extension point, it is a second code path to
53/// keep correct — and this one hid the price lookup behind a `dyn` call that
54/// could return `None` for reasons the caller could not see.
55#[must_use]
56pub fn mtu_start(timestamp_utc: time::OffsetDateTime) -> time::OffsetDateTime {
57 let step = MTU_MINUTES * 60;
58 let secs = timestamp_utc.unix_timestamp();
59 let floored = secs - secs.rem_euclid(step);
60 time::OffsetDateTime::from_unix_timestamp(floored).unwrap_or(timestamp_utc)
61}
62
63// ── BillingProvider trait ─────────────────────────────────────────────────────
64
65/// A product or service component that generates billing positions.
66///
67/// Implement this trait for each billable product type. The engine calls
68/// `bill()` for each registered provider in order, passing the accumulated
69/// positions from all earlier providers.
70///
71/// ## Tax providers
72///
73/// Override `is_tax_pass()` to return `true` when this provider computes taxes
74/// on the accumulated positions (e.g. MwSt). The engine ensures all commodity/
75/// levy providers run before any tax provider.
76///
77/// ## Example
78///
79/// ```rust,ignore
80/// struct MyFlatFeeProvider { eur: Decimal }
81///
82/// impl BillingProvider for MyFlatFeeProvider {
83/// fn bill(
84/// &self,
85/// _ctx: &BillingContext,
86/// _quantities: &Quantities,
87/// _prior: &[BillingPosition],
88/// ) -> Result<Vec<BillingPosition>, EngineError> {
89/// Ok(vec![
90/// BillingPosition::debit("Service Fee", Decimal::ONE, "Pauschal", self.eur, PositionCategory::Fee)
91/// .with_tag("service_fee"),
92/// ])
93/// }
94/// }
95/// ```
96pub trait BillingProvider: Send + Sync {
97 /// Generate billing positions for this provider.
98 ///
99 /// `prior` contains all positions from providers that ran before this one.
100 /// Most providers ignore `prior`; tax providers use it to compute their base.
101 fn bill(
102 &self,
103 ctx: &BillingContext,
104 quantities: &Quantities,
105 prior: &[BillingPosition],
106 ) -> Result<Vec<BillingPosition>, EngineError>;
107
108 /// `true` when this provider computes taxes on accumulated prior positions.
109 ///
110 /// Tax providers run in a second pass, after all commodity/levy providers
111 /// have completed. The default is `false`.
112 fn is_tax_pass(&self) -> bool {
113 false
114 }
115
116 /// The VAT rate this provider charges a position that states none of its own.
117 ///
118 /// Only a tax provider answers. The engine stamps it onto every supply
119 /// position before the tax pass, so the amount charged and the BG-23
120 /// breakdown that states it are read off the same number: a § 19 UStG
121 /// Kleinunternehmer document charges nothing and must therefore state
122 /// nothing, and an invoice that prints a 19 % Steuerbetrag beside
123 /// `mwst_eur = 0.00` is an unrechtmäßiger Steuerausweis (§ 14c Abs. 2 UStG).
124 fn charged_tax_rate(&self) -> Option<rust_decimal::Decimal> {
125 None
126 }
127
128 /// Produce regulatory compliance warnings without generating billing positions.
129 ///
130 /// Called by [`BillingEngine::validate()`](crate::BillingEngine::validate) and
131 /// [`BillingEngine::bill()`](crate::BillingEngine::bill) to collect warnings
132 /// before and during billing.
133 ///
134 /// Default implementation returns no warnings. Override in providers that must
135 /// enforce regulatory preconditions (e.g. `DynamicElectricityProvider` enforces
136 /// §41a iMSys requirement).
137 ///
138 /// Warnings with `WarningSeverity::Error` cause `BillingEngine::bill()` to return
139 /// [`EngineError::ValidationBlocked`] before any positions are generated.
140 fn validate_warnings(
141 &self,
142 _ctx: &BillingContext,
143 _quantities: &Quantities,
144 ) -> Vec<BillingWarning> {
145 vec![]
146 }
147}