From 3ab4a4064a1e55c5dc4e619a9cd5e5d033156bb7 Mon Sep 17 00:00:00 2001 From: Marcel Enguehard Date: Sun, 23 Aug 2026 15:17:47 +0200 Subject: [PATCH] Add Euros::from_euros_as_decimal with a typed error Converting a Decimal amount into integer cents can fail two ways: the value carries sub-cent precision, or it does not fit in an i64 once scaled. Both are reported through EurosError rather than silently truncating, since a money type that rounds without being asked is the wrong kind of convenient. The scale check demands exactly 2 rather than at most 2, so callers have to be explicit about the precision they are handing over. Co-Authored-By: Claude Opus 5 --- crates/immo-core/src/euros.rs | 56 ++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/immo-core/src/euros.rs b/crates/immo-core/src/euros.rs index 0839ba0..1f38da0 100644 --- a/crates/immo-core/src/euros.rs +++ b/crates/immo-core/src/euros.rs @@ -1,6 +1,15 @@ use std::fmt; -use rust_decimal::Decimal; +use rust_decimal::{Decimal, prelude::ToPrimitive}; +use thiserror::Error; + +#[derive(Debug, PartialEq, Eq, Error, Clone)] +pub enum EurosError { + #[error("Can only create from decimal with scale = 2: {0}")] + WrongDecimalScale(Decimal), + #[error("Decimal cannot be represented as i64: {0}")] + DecimalIsTooBig(Decimal), +} #[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)] pub struct Euros { @@ -12,6 +21,18 @@ impl Euros { Euros { value_as_cents } } + pub fn from_euros_as_decimal(value_as_euros: Decimal) -> Result { + // Euros should explicitely have scale 2 to validate cents precision + if value_as_euros.scale() != 2 { + Err(EurosError::WrongDecimalScale(value_as_euros)) + } else { + (value_as_euros * Decimal::ONE_HUNDRED) + .to_i64() + .map(Euros::from_cents_as_i64) + .ok_or(EurosError::DecimalIsTooBig(value_as_euros)) + } + } + pub const fn as_cents(self) -> i64 { self.value_as_cents } @@ -106,4 +127,37 @@ mod tests { let euros = Euros::from_cents_as_i64(-123_00); assert_eq!(format!("{}", euros), "-123.00 €"); } + + #[test] + fn from_euros_as_decimal_fails_if_scale_is_larger_than_2() { + let dec = Decimal::new(3141, 3); + let ret = Euros::from_euros_as_decimal(dec); + assert!(ret.is_err()); + assert_eq!(ret.unwrap_err(), EurosError::WrongDecimalScale(dec)); + } + + #[test] + fn from_euros_as_decimal_fails_if_scale_is_lower_than_2() { + let dec = Decimal::new(3141, 1); + let ret = Euros::from_euros_as_decimal(dec); + assert!(ret.is_err()); + assert_eq!(ret.unwrap_err(), EurosError::WrongDecimalScale(dec)); + } + + #[test] + fn from_euros_as_decimal_yields_correct_result_if_scale_is_2() { + let dec = Decimal::new(3141, 2); + let ret = Euros::from_euros_as_decimal(dec); + assert!(ret.is_ok()); + assert_eq!(ret.unwrap().as_cents(), 3141); + } + + #[test] + fn from_euros_as_decimal_yields_error_with_decimal_too_big() { + let mut dec = Decimal::from(u64::MAX); + dec.rescale(2); + let ret = Euros::from_euros_as_decimal(dec); + assert!(ret.is_err()); + assert_eq!(ret.unwrap_err(), EurosError::DecimalIsTooBig(dec)); + } }