1use std::cmp::Ordering;
4use std::error::Error;
5use std::fmt;
6
7pub const MIN_DECIMAL_PRECISION: u8 = 1;
9
10pub const MAX_DECIMAL_PRECISION: u8 = 38;
12
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct DecimalSpec {
16 precision: u8,
17 scale: u8,
18}
19
20impl DecimalSpec {
21 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 #[must_use]
34 pub const fn precision(self) -> u8 {
35 self.precision
36 }
37
38 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum DecimalSpecError {
52 PrecisionOutOfRange {
54 precision: u8,
56 },
57 ScaleExceedsPrecision {
59 precision: u8,
61 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
85pub struct Decimal {
86 spec: DecimalSpec,
87 coefficient: i128,
88}
89
90impl Decimal {
91 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 #[must_use]
101 pub const fn spec(self) -> DecimalSpec {
102 self.spec
103 }
104
105 #[must_use]
107 pub const fn coefficient(self) -> i128 {
108 self.coefficient
109 }
110
111 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum DecimalError {
186 CoefficientOutOfRange {
188 spec: DecimalSpec,
190 },
191 ArithmeticOverflow,
193 SpecMismatch {
195 left: DecimalSpec,
197 right: DecimalSpec,
199 },
200 LossyRescale {
202 from_scale: u8,
204 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
241pub enum CurrencyCodeError {
242 InvalidLength,
244 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
265pub struct CurrencyCode([u8; 3]);
266
267impl CurrencyCode {
268 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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
298pub struct Money {
299 currency: CurrencyCode,
300 amount: Decimal,
301}
302
303impl Money {
304 #[must_use]
306 pub const fn new(currency: CurrencyCode, amount: Decimal) -> Self {
307 Self { currency, amount }
308 }
309
310 #[must_use]
312 pub const fn currency(self) -> CurrencyCode {
313 self.currency
314 }
315
316 #[must_use]
318 pub const fn amount(self) -> Decimal {
319 self.amount
320 }
321
322 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
362pub enum MoneyError {
363 CurrencyMismatch,
365 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
395crate::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}