Skip to main content

finance_solution/util/
error.rs

1//! Error types and validation for finance calculations.
2//!
3//! # API contract (v0.1+)
4//!
5//! All **public** financial computations return [`FinanceResult`] — that is,
6//! `Result<T, FinanceError>`. Invalid rates, non-finite amounts, empty series, and
7//! similar domain failures are **values**, not panics.
8//!
9//! Compose with `?` or `match` on variants for field-level recovery (e.g. highlight
10//! only the rate widget in a UI).
11//!
12//! ```
13//! use finance_solution::{future_value, FinanceError, FinanceResult};
14//!
15//! fn project(pv: f64, years: u32) -> FinanceResult<f64> {
16//!     future_value(0.07, years, pv, false)
17//! }
18//!
19//! assert!(project(-5_000.0, 5).is_ok());
20//!
21//! match future_value(-1.5, 10, 1_000.0, false) {
22//!     Err(FinanceError::InvalidRate { rate }) => assert!(rate < -1.0),
23//!     other => panic!("expected InvalidRate, got {other:?}"),
24//! }
25//! ```
26//!
27//! # Why structured errors
28//!
29//! 1. **Safe composition** — handlers and batch jobs can skip one bad input without aborting.
30//! 2. **Matchable variants** — `InvalidRate` vs `NonFinite` vs `EmptyInput` for metrics and UX.
31//! 3. **`Display` + `Error` + [`code`](FinanceError::code)** — logs, `?` into app error types, telemetry keys.
32//! 4. **Same formulas** — success paths match the historical math; only failure mode changed.
33//!
34//! # Stable codes
35//!
36//! [`FinanceError::code`] returns a snake_case token suitable for metrics (e.g. `"invalid_rate"`).
37use std::fmt;
38
39/// Result alias for fallible finance functions.
40///
41/// Equivalent to `Result<T, FinanceError>`. Prefer this in public signatures.
42///
43/// # Examples
44/// ```
45/// use finance_solution::{future_value, FinanceResult};
46///
47/// fn grow(pv: f64) -> FinanceResult<f64> {
48///     future_value(0.05, 10, pv, false)
49/// }
50///
51/// assert!(grow(-1_000.0).is_ok());
52/// assert!(grow(f64::NAN).is_err());
53/// ```
54pub type FinanceResult<T> = Result<T, FinanceError>;
55
56/// Domain and input errors from finance calculations.
57///
58/// Marked `non_exhaustive` so new variants can appear in minor releases without
59/// breaking downstream `match` expressions that include a wildcard arm.
60///
61/// # Examples
62///
63/// Pattern-match for recovery or user-facing messages:
64///
65/// ```
66/// use finance_solution::{payment, FinanceError};
67///
68/// match payment(-1.5, 36, 10_000.0, 0.0, false) {
69///     Ok(fv) => println!("fv = {fv}"),
70///     Err(FinanceError::InvalidRate { rate }) => {
71///         assert!(rate < -1.0);
72///         println!("bad rate: {rate} (code={})", FinanceError::InvalidRate { rate }.code());
73///     }
74///     Err(FinanceError::NonFinite { field, value }) => {
75///         println!("{field} was non-finite ({value})");
76///     }
77///     Err(e) => println!("other finance error: {e}"),
78/// }
79/// ```
80///
81/// Propagate with `?`:
82///
83/// ```
84/// use finance_solution::{present_value, future_value, FinanceResult};
85///
86/// fn round_trip(rate: f64, n: u32, fv: f64) -> FinanceResult<f64> {
87///     let pv = present_value(rate, n, fv, false)?;
88///     future_value(rate, n, pv, false)
89/// }
90///
91/// let back = round_trip(0.04, 5, 10_000.0).unwrap();
92/// assert!((back.abs() - 10_000.0).abs() < 1e-6);
93/// assert!(round_trip(-2.0, 5, 10_000.0).is_err());
94/// ```
95///
96/// Zero-value failure (present value of a zero future value is undefined):
97///
98/// ```
99/// use finance_solution::{present_value, FinanceError};
100///
101/// match present_value(0.05, 10, 0.0, false) {
102///     Err(FinanceError::ZeroValue { field }) => assert_eq!(field, "future_value"),
103///     other => panic!("expected ZeroValue, got {other:?}"),
104/// }
105/// ```
106#[non_exhaustive]
107#[derive(Clone, Debug, PartialEq)]
108pub enum FinanceError {
109    /// A numeric field was NaN or infinite.
110    NonFinite { field: &'static str, value: f64 },
111    /// Periodic rate outside the allowed domain for the formula (typically `< -1.0` or `<= -1.0`).
112    InvalidRate { rate: f64 },
113    /// Period index or count is out of range for the calculation.
114    InvalidPeriod {
115        period: u32,
116        periods: u32,
117        message: &'static str,
118    },
119    /// A required money amount was zero (or subnormal) when a nonzero value is required.
120    ZeroValue { field: &'static str },
121    /// Present and future value have the same sign when opposite signs are required.
122    SameSignValues {
123        present_value: f64,
124        future_value: f64,
125    },
126    /// Inputs make the equation unsolvable (e.g. zero periods with nonzero cash difference).
127    Unsolvable { message: &'static str },
128    /// Cashflow / payment constraint violated (sign, missing values, etc.).
129    InvalidCashflow { message: &'static str },
130    /// A required collection or series was empty.
131    EmptyInput { what: &'static str },
132    /// Two series or slices that must align have different lengths.
133    LengthMismatch {
134        left: usize,
135        right: usize,
136        context: &'static str,
137    },
138}
139
140impl FinanceError {
141    /// Stable snake_case code for logs and metrics (not localized).
142    pub fn code(&self) -> &'static str {
143        match self {
144            FinanceError::NonFinite { .. } => "non_finite",
145            FinanceError::InvalidRate { .. } => "invalid_rate",
146            FinanceError::InvalidPeriod { .. } => "invalid_period",
147            FinanceError::ZeroValue { .. } => "zero_value",
148            FinanceError::SameSignValues { .. } => "same_sign_values",
149            FinanceError::Unsolvable { .. } => "unsolvable",
150            FinanceError::InvalidCashflow { .. } => "invalid_cashflow",
151            FinanceError::EmptyInput { .. } => "empty_input",
152            FinanceError::LengthMismatch { .. } => "length_mismatch",
153        }
154    }
155}
156
157impl fmt::Display for FinanceError {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            FinanceError::NonFinite { field, value } => {
161                write!(
162                    f,
163                    "{field} must be finite (not NaN or infinity); got {value}"
164                )
165            }
166            FinanceError::InvalidRate { rate } => {
167                write!(
168                    f,
169                    "rate is outside the allowed domain for this formula; got {rate}"
170                )
171            }
172            FinanceError::InvalidPeriod {
173                period,
174                periods,
175                message,
176            } => {
177                write!(f, "{message} (period={period}, periods={periods})")
178            }
179            FinanceError::ZeroValue { field } => {
180                write!(f, "{field} must be nonzero for this calculation")
181            }
182            FinanceError::SameSignValues {
183                present_value,
184                future_value,
185            } => {
186                write!(
187                    f,
188                    "present_value ({present_value}) and future_value ({future_value}) must have opposite signs"
189                )
190            }
191            FinanceError::Unsolvable { message } => write!(f, "{message}"),
192            FinanceError::InvalidCashflow { message } => write!(f, "{message}"),
193            FinanceError::EmptyInput { what } => write!(f, "{what} must not be empty"),
194            FinanceError::LengthMismatch {
195                left,
196                right,
197                context,
198            } => {
199                write!(f, "{context}: length mismatch ({left} vs {right})")
200            }
201        }
202    }
203}
204
205impl std::error::Error for FinanceError {}
206
207// ---------------------------------------------------------------------------
208// Validators (compose with ?)
209// ---------------------------------------------------------------------------
210
211/// Ensure a value is finite.
212pub(crate) fn require_finite(field: &'static str, value: f64) -> FinanceResult<()> {
213    if value.is_finite() {
214        Ok(())
215    } else {
216        Err(FinanceError::NonFinite { field, value })
217    }
218}
219
220/// Ensure rate is finite and `>= -1.0` (TVM simple/continuous formulas).
221pub(crate) fn require_rate(rate: f64) -> FinanceResult<()> {
222    require_finite("rate", rate)?;
223    if rate < -1.0 {
224        Err(FinanceError::InvalidRate { rate })
225    } else {
226        Ok(())
227    }
228}
229
230/// Ensure rate is finite and strictly greater than -1.0 (payment / annuity formulas).
231pub(crate) fn require_rate_gt_minus_one(rate: f64) -> FinanceResult<()> {
232    require_finite("rate", rate)?;
233    if rate <= -1.0 {
234        Err(FinanceError::InvalidRate { rate })
235    } else {
236        Ok(())
237    }
238}
239
240/// Ensure a money-like amount is finite (present value, future value, payment, etc.).
241pub(crate) fn require_money(field: &'static str, value: f64) -> FinanceResult<()> {
242    require_finite(field, value)
243}
244
245/// Ensure value is finite and strictly positive (prices for log returns, etc.).
246pub(crate) fn require_positive(field: &'static str, value: f64) -> FinanceResult<()> {
247    require_finite(field, value)?;
248    if value <= 0.0 {
249        Err(FinanceError::InvalidCashflow {
250            message: "value must be strictly positive",
251        })
252    } else {
253        Ok(())
254    }
255}
256
257/// Ensure a slice is non-empty.
258pub(crate) fn require_nonempty<T>(what: &'static str, items: &[T]) -> FinanceResult<()> {
259    if items.is_empty() {
260        Err(FinanceError::EmptyInput { what })
261    } else {
262        Ok(())
263    }
264}
265
266/// Ensure every rate in a schedule is valid for TVM (`>= -1.0`).
267///
268/// Empty schedules are allowed (zero periods → no compounding). Call
269/// [`require_nonempty`] first when empty input is an error (e.g. return series).
270pub(crate) fn require_rates(rates: &[f64]) -> FinanceResult<()> {
271    for &rate in rates {
272        require_rate(rate)?;
273    }
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::future_value;
281
282    #[test]
283    fn display_and_code_invalid_rate() {
284        let err = FinanceError::InvalidRate { rate: -1.5 };
285        assert!(err.to_string().contains("-1.5"));
286        assert_eq!(err.code(), "invalid_rate");
287    }
288
289    #[test]
290    fn future_value_err_invalid_rate() {
291        match future_value(-1.5, 12, 1000.0, false) {
292            Err(FinanceError::InvalidRate { rate }) => assert_eq!(rate, -1.5),
293            other => panic!("unexpected {other:?}"),
294        }
295    }
296
297    #[test]
298    fn require_rates_empty_ok() {
299        assert!(require_rates(&[]).is_ok());
300    }
301
302    #[test]
303    fn require_rates_invalid() {
304        assert!(matches!(
305            require_rates(&[-1.5]),
306            Err(FinanceError::InvalidRate { .. })
307        ));
308    }
309}