Skip to main content

riffdb_types/
decimal.rs

1//! Checked fixed-scale decimals and money values.
2
3use std::cmp::Ordering;
4use std::error::Error;
5use std::fmt;
6
7/// The smallest supported decimal precision.
8pub const MIN_DECIMAL_PRECISION: u8 = 1;
9
10/// The largest supported decimal precision.
11pub const MAX_DECIMAL_PRECISION: u8 = 38;
12
13/// A checked fixed-scale decimal type declaration.
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct DecimalSpec {
16    precision: u8,
17    scale: u8,
18}
19
20impl DecimalSpec {
21    /// Validates and creates a decimal specification.
22    pub const fn new(precision: u8, scale: u8) -> Result<Self, DecimalSpecError> {
23        if precision < MIN_DECIMAL_PRECISION || precision > MAX_DECIMAL_PRECISION {
24            return Err(DecimalSpecError::PrecisionOutOfRange { precision });
25        }
26        if scale > precision {
27            return Err(DecimalSpecError::ScaleExceedsPrecision { precision, scale });
28        }
29        Ok(Self { precision, scale })
30    }
31
32    /// Returns the total decimal precision.
33    #[must_use]
34    pub const fn precision(self) -> u8 {
35        self.precision
36    }
37
38    /// Returns the number of fractional decimal digits.
39    #[must_use]
40    pub const fn scale(self) -> u8 {
41        self.scale
42    }
43
44    const fn coefficient_limit(self) -> i128 {
45        10_i128.pow(self.precision as u32)
46    }
47}
48
49/// A safe validation failure for a decimal specification.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum DecimalSpecError {
52    /// Precision is not in `1..=38`.
53    PrecisionOutOfRange {
54        /// The invalid precision.
55        precision: u8,
56    },
57    /// Scale is greater than precision.
58    ScaleExceedsPrecision {
59        /// The declared precision.
60        precision: u8,
61        /// The invalid scale.
62        scale: u8,
63    },
64}
65
66impl fmt::Display for DecimalSpecError {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::PrecisionOutOfRange { precision } => write!(
70                formatter,
71                "decimal precision {precision} is outside the supported range 1..=38"
72            ),
73            Self::ScaleExceedsPrecision { precision, scale } => write!(
74                formatter,
75                "decimal scale {scale} exceeds precision {precision}"
76            ),
77        }
78    }
79}
80
81impl Error for DecimalSpecError {}
82
83/// A checked fixed-scale decimal value.
84#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
85pub struct Decimal {
86    spec: DecimalSpec,
87    coefficient: i128,
88}
89
90impl Decimal {
91    /// Validates and creates a decimal from its type and coefficient.
92    pub const fn new(spec: DecimalSpec, coefficient: i128) -> Result<Self, DecimalError> {
93        if coefficient <= -spec.coefficient_limit() || coefficient >= spec.coefficient_limit() {
94            return Err(DecimalError::CoefficientOutOfRange { spec });
95        }
96        Ok(Self { spec, coefficient })
97    }
98
99    /// Returns the declared decimal type.
100    #[must_use]
101    pub const fn spec(self) -> DecimalSpec {
102        self.spec
103    }
104
105    /// Returns the signed fixed-scale coefficient.
106    #[must_use]
107    pub const fn coefficient(self) -> i128 {
108        self.coefficient
109    }
110
111    /// Adds two values of exactly the same decimal type.
112    pub fn checked_add(self, other: Self) -> Result<Self, DecimalError> {
113        self.require_same_spec(other)?;
114        let coefficient = self
115            .coefficient
116            .checked_add(other.coefficient)
117            .ok_or(DecimalError::ArithmeticOverflow)?;
118        Self::new(self.spec, coefficient)
119    }
120
121    /// Subtracts two values of exactly the same decimal type.
122    pub fn checked_sub(self, other: Self) -> Result<Self, DecimalError> {
123        self.require_same_spec(other)?;
124        let coefficient = self
125            .coefficient
126            .checked_sub(other.coefficient)
127            .ok_or(DecimalError::ArithmeticOverflow)?;
128        Self::new(self.spec, coefficient)
129    }
130
131    /// Compares two values of exactly the same decimal type.
132    pub fn checked_cmp(self, other: Self) -> Result<Ordering, DecimalError> {
133        self.require_same_spec(other)?;
134        Ok(self.coefficient.cmp(&other.coefficient))
135    }
136
137    /// Changes the declared scale without rounding or changing the numeric value.
138    pub fn rescale(self, target: DecimalSpec) -> Result<Self, DecimalError> {
139        if target.scale == self.spec.scale {
140            return Self::new(target, self.coefficient);
141        }
142
143        if target.scale > self.spec.scale {
144            let factor = decimal_power(target.scale - self.spec.scale);
145            let coefficient = self
146                .coefficient
147                .checked_mul(factor)
148                .ok_or(DecimalError::ArithmeticOverflow)?;
149            return Self::new(target, coefficient);
150        }
151
152        let factor = decimal_power(self.spec.scale - target.scale);
153        if self.coefficient % factor != 0 {
154            return Err(DecimalError::LossyRescale {
155                from_scale: self.spec.scale,
156                to_scale: target.scale,
157            });
158        }
159        Self::new(target, self.coefficient / factor)
160    }
161
162    fn require_same_spec(self, other: Self) -> Result<(), DecimalError> {
163        if self.spec != other.spec {
164            return Err(DecimalError::SpecMismatch {
165                left: self.spec,
166                right: other.spec,
167            });
168        }
169        Ok(())
170    }
171}
172
173impl PartialOrd for Decimal {
174    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
175        self.checked_cmp(*other).ok()
176    }
177}
178
179fn decimal_power(exponent: u8) -> i128 {
180    10_i128.pow(exponent as u32)
181}
182
183/// A safe decimal construction or arithmetic failure.
184#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum DecimalError {
186    /// The coefficient does not fit the declared precision.
187    CoefficientOutOfRange {
188        /// The decimal type whose precision was exceeded.
189        spec: DecimalSpec,
190    },
191    /// An `i128` arithmetic operation overflowed.
192    ArithmeticOverflow,
193    /// An operation combined distinct decimal types.
194    SpecMismatch {
195        /// The left operand type.
196        left: DecimalSpec,
197        /// The right operand type.
198        right: DecimalSpec,
199    },
200    /// Reducing scale would discard nonzero decimal digits.
201    LossyRescale {
202        /// The source scale.
203        from_scale: u8,
204        /// The requested scale.
205        to_scale: u8,
206    },
207}
208
209impl fmt::Display for DecimalError {
210    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match self {
212            Self::CoefficientOutOfRange { spec } => write!(
213                formatter,
214                "decimal coefficient exceeds precision {}",
215                spec.precision()
216            ),
217            Self::ArithmeticOverflow => formatter.write_str("decimal arithmetic overflow"),
218            Self::SpecMismatch { left, right } => write!(
219                formatter,
220                "decimal type mismatch: <{},{}> and <{},{}>",
221                left.precision(),
222                left.scale(),
223                right.precision(),
224                right.scale()
225            ),
226            Self::LossyRescale {
227                from_scale,
228                to_scale,
229            } => write!(
230                formatter,
231                "rescaling from scale {from_scale} to {to_scale} would lose precision"
232            ),
233        }
234    }
235}
236
237impl Error for DecimalError {}
238
239/// A safe validation failure for a three-letter currency code.
240#[derive(Clone, Copy, Debug, Eq, PartialEq)]
241pub enum CurrencyCodeError {
242    /// The code is not exactly three bytes.
243    InvalidLength,
244    /// A byte is not an uppercase ASCII letter.
245    InvalidCharacter,
246}
247
248impl fmt::Display for CurrencyCodeError {
249    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250        match self {
251            Self::InvalidLength => {
252                formatter.write_str("currency code must contain exactly three ASCII letters")
253            }
254            Self::InvalidCharacter => {
255                formatter.write_str("currency code must contain only uppercase ASCII letters")
256            }
257        }
258    }
259}
260
261impl Error for CurrencyCodeError {}
262
263/// An exact three-letter uppercase ASCII currency identifier.
264#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
265pub struct CurrencyCode([u8; 3]);
266
267impl CurrencyCode {
268    /// Validates and creates a currency code.
269    pub fn new(value: impl AsRef<[u8]>) -> Result<Self, CurrencyCodeError> {
270        let bytes: [u8; 3] = value
271            .as_ref()
272            .try_into()
273            .map_err(|_| CurrencyCodeError::InvalidLength)?;
274        if !bytes.iter().all(u8::is_ascii_uppercase) {
275            return Err(CurrencyCodeError::InvalidCharacter);
276        }
277        Ok(Self(bytes))
278    }
279
280    /// Returns the three canonical ASCII bytes.
281    #[must_use]
282    pub const fn as_bytes(&self) -> &[u8; 3] {
283        &self.0
284    }
285}
286
287impl fmt::Display for CurrencyCode {
288    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
289        for byte in self.0 {
290            formatter.write_fmt(format_args!("{}", char::from(byte)))?;
291        }
292        Ok(())
293    }
294}
295
296/// A money value with an explicit currency and checked decimal amount.
297#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
298pub struct Money {
299    currency: CurrencyCode,
300    amount: Decimal,
301}
302
303impl Money {
304    /// Creates a money value from an already validated currency and decimal amount.
305    #[must_use]
306    pub const fn new(currency: CurrencyCode, amount: Decimal) -> Self {
307        Self { currency, amount }
308    }
309
310    /// Returns the currency identifier.
311    #[must_use]
312    pub const fn currency(self) -> CurrencyCode {
313        self.currency
314    }
315
316    /// Returns the fixed-scale amount.
317    #[must_use]
318    pub const fn amount(self) -> Decimal {
319        self.amount
320    }
321
322    /// Adds two money values with identical currency and decimal type.
323    pub fn checked_add(self, other: Self) -> Result<Self, MoneyError> {
324        self.require_same_currency(other)?;
325        Ok(Self::new(
326            self.currency,
327            self.amount.checked_add(other.amount)?,
328        ))
329    }
330
331    /// Subtracts two money values with identical currency and decimal type.
332    pub fn checked_sub(self, other: Self) -> Result<Self, MoneyError> {
333        self.require_same_currency(other)?;
334        Ok(Self::new(
335            self.currency,
336            self.amount.checked_sub(other.amount)?,
337        ))
338    }
339
340    /// Compares two values with identical currency and decimal type.
341    pub fn checked_cmp(self, other: Self) -> Result<Ordering, MoneyError> {
342        self.require_same_currency(other)?;
343        Ok(self.amount.checked_cmp(other.amount)?)
344    }
345
346    fn require_same_currency(self, other: Self) -> Result<(), MoneyError> {
347        if self.currency != other.currency {
348            return Err(MoneyError::CurrencyMismatch);
349        }
350        Ok(())
351    }
352}
353
354impl PartialOrd for Money {
355    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
356        self.checked_cmp(*other).ok()
357    }
358}
359
360/// A safe checked-money arithmetic failure.
361#[derive(Clone, Copy, Debug, Eq, PartialEq)]
362pub enum MoneyError {
363    /// The operands use distinct currency identifiers.
364    CurrencyMismatch,
365    /// The decimal operation failed.
366    Decimal(DecimalError),
367}
368
369impl fmt::Display for MoneyError {
370    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
371        match self {
372            Self::CurrencyMismatch => {
373                formatter.write_str("money operands use different currencies")
374            }
375            Self::Decimal(error) => error.fmt(formatter),
376        }
377    }
378}
379
380impl Error for MoneyError {
381    fn source(&self) -> Option<&(dyn Error + 'static)> {
382        match self {
383            Self::CurrencyMismatch => None,
384            Self::Decimal(error) => Some(error),
385        }
386    }
387}
388
389impl From<DecimalError> for MoneyError {
390    fn from(error: DecimalError) -> Self {
391        Self::Decimal(error)
392    }
393}
394
395// Exhaustive non-durable ownership inventories for exact-provider admission.
396crate::inspect_owned_capacity_copy_v1!(DecimalSpec);
397
398crate::inspect_owned_capacity_copy_v1!(CurrencyCode);
399
400crate::inspect_owned_capacity_copy_v1!(Decimal);
401
402crate::inspect_owned_capacity_copy_v1!(Money);
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn decimal_spec_enforces_precision_and_scale_bounds() {
410        assert_eq!(
411            DecimalSpec::new(0, 0),
412            Err(DecimalSpecError::PrecisionOutOfRange { precision: 0 })
413        );
414        assert_eq!(
415            DecimalSpec::new(39, 0),
416            Err(DecimalSpecError::PrecisionOutOfRange { precision: 39 })
417        );
418        assert_eq!(
419            DecimalSpec::new(2, 3),
420            Err(DecimalSpecError::ScaleExceedsPrecision {
421                precision: 2,
422                scale: 3
423            })
424        );
425    }
426
427    #[test]
428    fn decimal_coefficient_must_fit_declared_precision() {
429        let spec = DecimalSpec::new(3, 2).expect("valid spec");
430        assert!(Decimal::new(spec, 999).is_ok());
431        assert!(Decimal::new(spec, -999).is_ok());
432        assert_eq!(
433            Decimal::new(spec, 1_000),
434            Err(DecimalError::CoefficientOutOfRange { spec })
435        );
436        assert_eq!(
437            Decimal::new(spec, -1_000),
438            Err(DecimalError::CoefficientOutOfRange { spec })
439        );
440    }
441
442    #[test]
443    fn decimal_arithmetic_checks_type_and_precision() {
444        let spec = DecimalSpec::new(3, 2).expect("valid spec");
445        let left = Decimal::new(spec, 600).expect("valid decimal");
446        let right = Decimal::new(spec, 399).expect("valid decimal");
447        assert_eq!(left.checked_add(right).expect("sum").coefficient(), 999);
448        assert!(matches!(
449            left.checked_add(Decimal::new(spec, 400).expect("valid decimal")),
450            Err(DecimalError::CoefficientOutOfRange { .. })
451        ));
452
453        let other_spec = DecimalSpec::new(4, 2).expect("valid spec");
454        assert!(matches!(
455            left.checked_sub(Decimal::new(other_spec, 1).expect("valid decimal")),
456            Err(DecimalError::SpecMismatch { .. })
457        ));
458        assert_eq!(left.checked_cmp(right), Ok(Ordering::Greater));
459        assert_eq!(
460            left.partial_cmp(&Decimal::new(other_spec, 1).expect("valid decimal")),
461            None
462        );
463    }
464
465    #[test]
466    fn rescale_is_exact_or_fails() {
467        let source = Decimal::new(DecimalSpec::new(5, 2).expect("valid spec"), 1_200)
468            .expect("valid decimal");
469        let lower = DecimalSpec::new(4, 1).expect("valid spec");
470        assert_eq!(
471            source.rescale(lower).expect("exact rescale").coefficient(),
472            120
473        );
474
475        let lossy = Decimal::new(DecimalSpec::new(5, 2).expect("valid spec"), 1_201)
476            .expect("valid decimal");
477        assert!(matches!(
478            lossy.rescale(lower),
479            Err(DecimalError::LossyRescale { .. })
480        ));
481
482        let higher = DecimalSpec::new(6, 3).expect("valid spec");
483        assert_eq!(
484            source.rescale(higher).expect("exact rescale").coefficient(),
485            12_000
486        );
487    }
488
489    #[test]
490    fn money_requires_exact_currency_codes_and_matching_operands() {
491        assert!(CurrencyCode::new("USD").is_ok());
492        assert_eq!(
493            CurrencyCode::new("usd"),
494            Err(CurrencyCodeError::InvalidCharacter)
495        );
496        assert_eq!(
497            CurrencyCode::new("EURO"),
498            Err(CurrencyCodeError::InvalidLength)
499        );
500
501        let spec = DecimalSpec::new(6, 2).expect("valid spec");
502        let amount = Decimal::new(spec, 100).expect("valid decimal");
503        let usd = Money::new(CurrencyCode::new("USD").expect("valid code"), amount);
504        let eur = Money::new(CurrencyCode::new("EUR").expect("valid code"), amount);
505        assert_eq!(usd.checked_add(eur), Err(MoneyError::CurrencyMismatch));
506        assert_eq!(usd.partial_cmp(&eur), None);
507        assert_eq!(usd.checked_cmp(usd), Ok(Ordering::Equal));
508    }
509}