Skip to main content

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}