Skip to main content

energy_billing/
error.rs

1//! `EngineError` — the typed error surface of the billing engine.
2//!
3//! Every failure mode a caller can act on differently is its own variant:
4//! a blocked regulatory validation carries the warnings that blocked it, a
5//! price out of monetary range names the tariff field, an invalid period
6//! carries both dates. The arithmetic core (`billing` crate) keeps its own
7//! error type; it passes through as [`EngineError::Arithmetic`].
8//!
9//! Each variant maps to a stable machine-readable [`code`](EngineError::code)
10//! so services can build structured error responses without parsing display
11//! strings.
12
13use rust_decimal::Decimal;
14
15use crate::position::BillingWarning;
16
17/// Errors returned by [`BillingEngine::bill`](crate::BillingEngine::bill) and
18/// the invoice assembly functions.
19#[derive(Debug, Clone, thiserror::Error)]
20pub enum EngineError {
21    /// One or more providers raised `Error`-severity regulatory warnings
22    /// during validation — the run must not produce an invoice.
23    ///
24    /// Carries **all** warnings collected up to and including the blocking
25    /// provider, so the caller sees every violation at once. The blocking
26    /// ones are those with [`WarningSeverity::Error`](crate::WarningSeverity::Error).
27    #[error(
28        "billing blocked by regulatory validation: {}",
29        blocking_summary(warnings)
30    )]
31    ValidationBlocked {
32        /// All warnings collected before the run was aborted.
33        warnings: Vec<BillingWarning>,
34    },
35
36    /// A tariff price could not be represented in the monetary type.
37    ///
38    /// Raised when a configured ct/kWh price exceeds the `Amount` range —
39    /// in practice always a corrupt tariff, never a real price.
40    #[error("tariff field {field} out of monetary range: {value}")]
41    PriceOutOfRange {
42        /// The tariff field holding the offending value (e.g. `"arbeitspreis_ht_ct_per_kwh"`).
43        field: String,
44        /// The value that could not be represented.
45        value: Decimal,
46    },
47
48    /// A billing period whose end precedes its start.
49    ///
50    /// Unreachable through [`BillingPeriod::new`](crate::BillingPeriod::new) —
51    /// this is what the constructor returns, making the invalid pair
52    /// unrepresentable everywhere downstream.
53    #[error("invalid billing period: {from} is after {to}")]
54    InvalidPeriod {
55        /// The requested first day.
56        from: time::Date,
57        /// The requested last day, before `from`.
58        to: time::Date,
59    },
60
61    /// `Invoice::allocate_proportionally` was called with mismatched shapes.
62    #[error("allocation mismatch: {fractions} fractions vs {contexts} contexts")]
63    AllocationMismatch {
64        /// Number of allocation fractions supplied.
65        fractions: usize,
66        /// Number of recipient contexts supplied.
67        contexts: usize,
68    },
69
70    /// `Invoice::allocate_proportionally` was given weights it cannot normalise.
71    ///
72    /// A negative weight is not an allocation share, and weights summing to
73    /// zero name no recipient at all. Both are caller mistakes rather than
74    /// arithmetic failures, so they are refused before the split runs.
75    #[error("allocation weights must be non-negative and sum above zero, got sum {sum}")]
76    AllocationWeightsInvalid {
77        /// The sum of the supplied weights.
78        sum: Decimal,
79    },
80
81    /// A §42b EEG Nutzungsplan whose shares do not describe an allocation.
82    ///
83    /// The plan's fractions are caller-supplied and must partition the plant's
84    /// generation exactly once — a plan entered as percentages allocates a
85    /// hundred times the generation, and one summing short leaves kWh
86    /// unallocated. Refused before the split rather than distributed.
87    #[error("GGV Nutzungsplan shares must sum to 1, got {sum}")]
88    NutzungsplanSharesInvalid {
89        /// The sum of the supplied shares.
90        sum: Decimal,
91    },
92
93    /// A value the EN 16931 semantic model cannot represent.
94    ///
95    /// A rendered e-invoice is a legally binding document: a line amount
96    /// saturated to `0.00`, or a BT-2 Ausstellungsdatum taken from a fallback
97    /// constant, states as fact something § 14 Abs. 4 UStG requires the document
98    /// to get right. Both are refused rather than emitted.
99    #[error("{field} cannot be represented in the EN 16931 model: {value}")]
100    Unrepresentable {
101        /// The business term that could not be represented (e.g. `"BT-131"`).
102        field: String,
103        /// The value that could not be represented.
104        value: String,
105    },
106
107    /// EN 16931 reconciliation (BG-22/BG-23, BR-CO-10..16) failed.
108    ///
109    /// The breakdown and the totals of an e-invoice are *derived*, and a
110    /// derivation that fails leaves the document carrying whichever totals the
111    /// builder happened to hold — a document that states amounts nothing
112    /// computed. Refused rather than emitted.
113    #[error("EN 16931 reconciliation failed: {reason}")]
114    ReconciliationFailed {
115        /// What the reconciler reported.
116        reason: String,
117    },
118
119    /// An arithmetic or document error from the `billing` core —
120    /// monetary overflow, invalid schedule, tax-layer failure.
121    #[error(transparent)]
122    Arithmetic(#[from] billing::BillingError),
123}
124
125impl EngineError {
126    /// Stable machine-readable code for structured error responses.
127    #[must_use]
128    pub const fn code(&self) -> &'static str {
129        match self {
130            Self::ValidationBlocked { .. } => "VALIDATION_BLOCKED",
131            Self::PriceOutOfRange { .. } => "PRICE_OUT_OF_RANGE",
132            Self::InvalidPeriod { .. } => "INVALID_PERIOD",
133            Self::AllocationMismatch { .. } => "ALLOCATION_MISMATCH",
134            Self::AllocationWeightsInvalid { .. } => "ALLOCATION_WEIGHTS_INVALID",
135            Self::NutzungsplanSharesInvalid { .. } => "NUTZUNGSPLAN_SHARES_INVALID",
136            Self::Unrepresentable { .. } => "UNREPRESENTABLE",
137            Self::ReconciliationFailed { .. } => "RECONCILIATION_FAILED",
138            Self::Arithmetic(_) => "ARITHMETIC",
139        }
140    }
141
142    /// The `Error`-severity warnings that blocked the run, when this is a
143    /// [`ValidationBlocked`](Self::ValidationBlocked).
144    #[must_use]
145    pub fn blocking_warnings(&self) -> &[BillingWarning] {
146        match self {
147            Self::ValidationBlocked { warnings } => warnings,
148            _ => &[],
149        }
150    }
151}
152
153/// Display helper: the blocking warnings as `CODE: message; CODE: message`.
154fn blocking_summary(warnings: &[BillingWarning]) -> String {
155    warnings
156        .iter()
157        .filter(|w| w.severity == crate::position::WarningSeverity::Error)
158        .map(|w| format!("{}: {}", w.code, w.message))
159        .collect::<Vec<_>>()
160        .join("; ")
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::position::WarningSeverity;
167
168    /// The display shows only the blocking warnings, prefixed with their codes.
169    #[test]
170    fn validation_blocked_displays_codes() {
171        let err = EngineError::ValidationBlocked {
172            warnings: vec![
173                BillingWarning {
174                    code: "ESTIMATED_READING",
175                    severity: WarningSeverity::Warning,
176                    message: "reading estimated".into(),
177                },
178                BillingWarning {
179                    code: "MODUL3_AND_FLAT_NNE",
180                    severity: WarningSeverity::Error,
181                    message: "both configured".into(),
182                },
183            ],
184        };
185        let s = err.to_string();
186        assert!(s.contains("MODUL3_AND_FLAT_NNE: both configured"), "{s}");
187        assert!(!s.contains("ESTIMATED_READING"), "{s}");
188        assert_eq!(err.code(), "VALIDATION_BLOCKED");
189        assert_eq!(err.blocking_warnings().len(), 2);
190    }
191
192    /// Arithmetic errors pass through transparently, keeping their message.
193    #[test]
194    fn arithmetic_passthrough() {
195        let inner = billing::BillingError::InvalidInput {
196            reason: "negative quantity".into(),
197        };
198        let err: EngineError = inner.into();
199        assert_eq!(err.code(), "ARITHMETIC");
200        assert!(err.to_string().contains("negative quantity"));
201    }
202}