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 <noreply@anthropic.com>
This commit is contained in:
Marcel Enguehard 2026-08-23 15:17:47 +02:00
parent 0cf311bdf4
commit 3ab4a4064a

View file

@ -1,6 +1,15 @@
use std::fmt; 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)] #[derive(PartialOrd, PartialEq, Ord, Eq, Clone, Copy, Debug)]
pub struct Euros { pub struct Euros {
@ -12,6 +21,18 @@ impl Euros {
Euros { value_as_cents } Euros { value_as_cents }
} }
pub fn from_euros_as_decimal(value_as_euros: Decimal) -> Result<Self, EurosError> {
// 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 { pub const fn as_cents(self) -> i64 {
self.value_as_cents self.value_as_cents
} }
@ -106,4 +127,37 @@ mod tests {
let euros = Euros::from_cents_as_i64(-123_00); let euros = Euros::from_cents_as_i64(-123_00);
assert_eq!(format!("{}", euros), "-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));
}
} }