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}