Skip to main content

proto_types/common/
money.rs

1//! Implementations for the google.type.Money message.
2//!
3//!
4//! DISCLAIMER: all of the methods implemented for Money are just implemented for convenience, and they are provided as is, without warranties of any kind. By using this module, the user is relieving the authors of this library from any responsibility for any damage that may be caused by its usage.
5
6use core::cmp::Ordering;
7use core::fmt::Write;
8
9use thiserror::Error;
10
11use crate::{String, ToString, common::Money};
12
13const NANO_FACTOR: i32 = 1_000_000_000;
14
15/// Errors that can occur during the creation, conversion or validation of [`Money`].
16#[derive(Debug, Error, PartialEq, Eq, Clone)]
17#[non_exhaustive]
18pub enum MoneyError {
19	#[error("Currency mismatch: Expected '{expected}', found '{found}'")]
20	CurrencyMismatch { expected: String, found: String },
21	#[error("Money arithmetic operation failed (overflow, underflow, or invalid operand)")]
22	OutOfRange,
23}
24
25fn normalize_money_fields_checked(
26	mut units: i64,
27	mut nanos: i32,
28) -> Result<(i64, i32), MoneyError> {
29	if nanos.abs() >= NANO_FACTOR {
30		let units_carry = i64::from(nanos / (NANO_FACTOR));
31		units = units
32			.checked_add(units_carry)
33			.ok_or(MoneyError::OutOfRange)?;
34		nanos %= NANO_FACTOR;
35	}
36
37	if units > 0 && nanos < 0 {
38		units = units
39			.checked_sub(1)
40			.ok_or(MoneyError::OutOfRange)?;
41		nanos = nanos
42			.checked_add(NANO_FACTOR)
43			.ok_or(MoneyError::OutOfRange)?;
44	} else if units < 0 && nanos > 0 {
45		units = units
46			.checked_add(1)
47			.ok_or(MoneyError::OutOfRange)?;
48		nanos = nanos
49			.checked_sub(NANO_FACTOR)
50			.ok_or(MoneyError::OutOfRange)?;
51	}
52
53	Ok((units, nanos))
54}
55
56impl PartialOrd for Money {
57	#[inline]
58	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
59		if self.currency_code != other.currency_code {
60			return None;
61		}
62
63		self.total_nanos()
64			.partial_cmp(&other.total_nanos())
65	}
66}
67
68fn fields_from_total_nanos(total: i128) -> Result<(i64, i32), MoneyError> {
69	let factor = i128::from(NANO_FACTOR);
70
71	let units_val = total / factor;
72	let units = i64::try_from(units_val).map_err(|_| MoneyError::OutOfRange)?;
73
74	let remainder_val = total % factor;
75	let nanos = i32::try_from(remainder_val).map_err(|_| MoneyError::OutOfRange)?;
76
77	Ok((units, nanos))
78}
79
80impl Money {
81	/// Returns the total amount in `nano` units.
82	#[inline]
83	#[must_use]
84	pub const fn total_nanos(&self) -> i128 {
85		(self.units as i128) * (NANO_FACTOR as i128) + (self.nanos as i128)
86	}
87
88	/// Creates a new instance from a total amount of nanos and a currency code.
89	pub fn from_total_nanos(currency: impl Into<String>, total: i128) -> Result<Self, MoneyError> {
90		let (units, nanos) = fields_from_total_nanos(total)?;
91
92		Ok(Self {
93			currency_code: currency.into(),
94			units,
95			nanos,
96		})
97	}
98
99	/// Normalizes the [`Money`] amount and returns a string containing the currency symbol and the monetary amount with the specified amount of decimal places, while truncating the rest.
100	#[must_use]
101	pub fn to_formatted_string(&self, symbol: &str, decimal_places: u32) -> String {
102		let decimal_places = u32::min(9, decimal_places);
103
104		let mut current_units: i128 = i128::from(self.units);
105		let mut current_nanos: i128 = i128::from(self.nanos);
106
107		let ten_pow_9 = i128::from(NANO_FACTOR);
108		if current_nanos >= ten_pow_9 || current_nanos <= -ten_pow_9 {
109			current_units += current_nanos / ten_pow_9;
110			current_nanos %= ten_pow_9;
111		}
112
113		if current_units > 0 && current_nanos < 0 {
114			current_units -= 1;
115			current_nanos += ten_pow_9;
116		} else if current_units < 0 && current_nanos > 0 {
117			current_units += 1;
118			current_nanos -= ten_pow_9;
119		}
120
121		let mut rounded_nanos = 0;
122		let mut units_carry = 0;
123
124		if decimal_places > 0 {
125			let power_of_10_for_display = 10_i128.pow(decimal_places);
126			let rounding_power = 10_i128.pow(9 - decimal_places);
127
128			let abs_nanos = current_nanos.abs();
129
130			let remainder_for_rounding = abs_nanos % rounding_power;
131			rounded_nanos = abs_nanos / rounding_power;
132
133			// Only round if we are actually truncating precision (rounding_power > 1).
134			// Otherwise, when decimal_places=9 (rounding_power=1),
135			// remainder (0) >= 1/2 (0) triggers an unwanted increment.
136			if rounding_power > 1 && remainder_for_rounding >= rounding_power / 2 {
137				rounded_nanos += 1;
138			}
139
140			// Handle carry-over from nanos rounding to units
141			if rounded_nanos >= power_of_10_for_display {
142				units_carry = 1;
143				rounded_nanos = 0;
144			}
145		}
146
147		let is_negative = current_units < 0 || (current_units == 0 && current_nanos < 0);
148
149		let final_units_abs = current_units.abs() + units_carry;
150
151		let mut formatted_string = String::new();
152
153		if is_negative {
154			formatted_string.push('-');
155		}
156		formatted_string.push_str(symbol);
157		formatted_string.push_str(&final_units_abs.to_string());
158
159		if decimal_places > 0 {
160			formatted_string.push('.');
161			// Format rounded_nanos to the specified number of decimal places, zero-padded
162			let _ = write!(
163				formatted_string,
164				"{:0width$}",
165				rounded_nanos,
166				width = decimal_places as usize
167			);
168		}
169
170		formatted_string
171	}
172
173	/// Normalizes units and nanos. Fails in case of overflow.
174	pub fn normalize(mut self) -> Result<Self, MoneyError> {
175		let (normalized_units, normalized_nanos) =
176			normalize_money_fields_checked(self.units, self.nanos)?;
177		self.units = normalized_units;
178		self.nanos = normalized_nanos;
179
180		Ok(self)
181	}
182
183	/// Creates a new instance, if the normalization does not return errors like Overflow or Underflow.
184	pub fn new(
185		currency_code: impl Into<String>,
186		units: i64,
187		nanos: i32,
188	) -> Result<Self, MoneyError> {
189		let (normalized_units, normalized_nanos) = normalize_money_fields_checked(units, nanos)?;
190		Ok(Self {
191			currency_code: currency_code.into(),
192			units: normalized_units,
193			nanos: normalized_nanos,
194		})
195	}
196
197	/// Converts the [`Money`] amount into a decimal (f64) representation,
198	/// rounded to the specified number of decimal places.
199	///
200	/// `decimal_places` determines the precision of the rounding. For example:
201	/// - `0` rounds to the nearest whole unit.
202	/// - `2` rounds to two decimal places (e.g., for cents).
203	///
204	/// WARNING: The usage of `f64` introduces floating-point precision issues. Do not use it for critical financial calculations.
205	pub fn to_rounded_imprecise_f64(&self, decimal_places: u32) -> Result<f64, MoneyError> {
206		if decimal_places > i32::MAX as u32 {
207			return Err(MoneyError::OutOfRange);
208		}
209
210		let full_amount = self.as_imprecise_f64();
211
212		let factor_exponent: i32 = decimal_places
213			.try_into()
214			.map_err(|_| MoneyError::OutOfRange)?;
215		let factor = 10.0f64.powi(factor_exponent);
216
217		if !factor.is_finite() {
218			return Err(MoneyError::OutOfRange);
219		}
220
221		let result = (full_amount * factor).round() / factor;
222
223		if !result.is_finite() {
224			return Err(MoneyError::OutOfRange);
225		}
226
227		Ok(result)
228	}
229
230	/// Converts the `Money` amount into a decimal (f64) representation.
231	///
232	/// WARNING: The usage of `f64` introduces floating-point precision issues. Do not use it for critical financial calculations.
233	#[must_use]
234	pub fn as_imprecise_f64(&self) -> f64 {
235		self.units as f64 + (f64::from(self.nanos) / 1_000_000_000.0)
236	}
237
238	/// Creates a new `Money` instance with the given currency code and decimal amount.
239	///
240	/// This is a convenience constructor that handles splitting a decimal value
241	/// into units and nanos.
242	///
243	/// WARNING: The usage of `f64` introduces floating-point precision issues. Do not use it for critical financial calculations.
244	pub fn from_imprecise_f64(
245		currency_code: impl Into<String>,
246		amount: f64,
247	) -> Result<Self, MoneyError> {
248		if !amount.is_finite() {
249			return Err(MoneyError::OutOfRange);
250		}
251
252		let truncated_amount = amount.trunc();
253
254		if truncated_amount > i64::MAX as f64 || truncated_amount < i64::MIN as f64 {
255			return Err(MoneyError::OutOfRange);
256		}
257
258		// SAFETY: We already truncateda, and this cast is safe because we checked the range
259		#[allow(clippy::cast_possible_truncation)]
260		let units = truncated_amount as i64;
261
262		let raw_nanos_f64 = amount.fract().abs() * f64::from(NANO_FACTOR);
263		// SAFETY: The range is guaranteed to be 0..1,000,000,000 by logic.
264		#[allow(clippy::cast_possible_truncation)]
265		let nanos: i32 = raw_nanos_f64.round() as i32;
266
267		let final_nanos = if units < 0 && nanos > 0 {
268			-nanos
269		} else if units == 0 && amount < 0.0 && nanos > 0 {
270			// For -0.5, ensure nanos is -500M
271			-nanos
272		} else {
273			nanos
274		};
275
276		Self::new(currency_code, units, final_nanos)
277	}
278
279	/// Attempts to add another [`Money`] amount to this one, returning a new [`Money`] instance.
280	/// Returns an error if currencies mismatch or if addition causes an overflow/underflow.
281	pub fn try_add(&self, other: &Self) -> Result<Self, MoneyError> {
282		if self.currency_code != other.currency_code {
283			return Err(MoneyError::CurrencyMismatch {
284				expected: self.currency_code.clone(),
285				found: other.currency_code.clone(),
286			});
287		}
288
289		let total = self
290			.total_nanos()
291			.checked_add(other.total_nanos())
292			.ok_or(MoneyError::OutOfRange)?;
293		Self::from_total_nanos(self.currency_code.clone(), total)
294	}
295
296	/// Attempts to add another [`Money`] amount to this one in place.
297	/// Returns an error if currencies mismatch or if addition causes an overflow/underflow.
298	pub fn try_add_assign(&mut self, other: &Self) -> Result<(), MoneyError> {
299		if self.currency_code != other.currency_code {
300			return Err(MoneyError::CurrencyMismatch {
301				expected: self.currency_code.clone(),
302				found: other.currency_code.clone(),
303			});
304		}
305
306		let total = self
307			.total_nanos()
308			.checked_add(other.total_nanos())
309			.ok_or(MoneyError::OutOfRange)?;
310		let (new_units, new_nanos) = fields_from_total_nanos(total)?;
311
312		self.units = new_units;
313		self.nanos = new_nanos;
314		Ok(())
315	}
316
317	/// Attempts to subtract another [`Money`] amount from this one, returning a new [`Money`] instance.
318	/// Returns an error if currencies mismatch or if subtraction causes an overflow/underflow.
319	pub fn try_sub(&self, other: &Self) -> Result<Self, MoneyError> {
320		if self.currency_code != other.currency_code {
321			return Err(MoneyError::CurrencyMismatch {
322				expected: self.currency_code.clone(),
323				found: other.currency_code.clone(),
324			});
325		}
326
327		let total = self
328			.total_nanos()
329			.checked_sub(other.total_nanos())
330			.ok_or(MoneyError::OutOfRange)?;
331		Self::from_total_nanos(self.currency_code.clone(), total)
332	}
333
334	/// Attempts to subtract another [`Money`] amount from this one in place.
335	/// Returns an error if currencies mismatch or if subtraction causes an overflow/underflow.
336	pub fn try_sub_assign(&mut self, other: &Self) -> Result<(), MoneyError> {
337		if self.currency_code != other.currency_code {
338			return Err(MoneyError::CurrencyMismatch {
339				expected: self.currency_code.clone(),
340				found: other.currency_code.clone(),
341			});
342		}
343
344		let total = self
345			.total_nanos()
346			.checked_sub(other.total_nanos())
347			.ok_or(MoneyError::OutOfRange)?;
348		let (new_units, new_nanos) = fields_from_total_nanos(total)?;
349
350		self.units = new_units;
351		self.nanos = new_nanos;
352		Ok(())
353	}
354
355	/// Attempts to multiply this [`Money`] amount by an integer scalar, returning a new [`Money`] instance.
356	/// Returns an error if multiplication causes an overflow/underflow.
357	pub fn try_mul_i64(&self, rhs: i64) -> Result<Self, MoneyError> {
358		let total = self
359			.total_nanos()
360			.checked_mul(i128::from(rhs))
361			.ok_or(MoneyError::OutOfRange)?;
362		Self::from_total_nanos(self.currency_code.clone(), total)
363	}
364
365	/// Attempts to multiply this [`Money`] amount by a float scalar, returning a new [`Money`] instance.
366	/// Returns an error if the result is non-finite or causes an internal conversion error.
367	/// WARNING: The usage of `f64` introduces floating-point precision issues. Do not use it for critical financial calculations.
368	pub fn try_mul_f64(&self, rhs: f64) -> Result<Self, MoneyError> {
369		if !rhs.is_finite() {
370			return Err(MoneyError::OutOfRange);
371		}
372
373		let decimal_amount = self.as_imprecise_f64();
374		let result_decimal = decimal_amount * rhs;
375
376		if !result_decimal.is_finite() {
377			return Err(MoneyError::OutOfRange);
378		}
379
380		// Pass the result to from_decimal_f64, which will normalize and validate.
381		Self::from_imprecise_f64(self.currency_code.clone(), result_decimal)
382	}
383
384	/// Attempts to divide this [`Money`] amount by an integer scalar, returning a new [`Money`] instance.
385	/// Returns an error if the divisor is zero, or if division causes an overflow/underflow.
386	pub fn try_div_i64(&self, rhs: i64) -> Result<Self, MoneyError> {
387		if rhs == 0 {
388			return Err(MoneyError::OutOfRange);
389		}
390
391		let total = self
392			.total_nanos()
393			.checked_div(i128::from(rhs))
394			.ok_or(MoneyError::OutOfRange)?;
395		Self::from_total_nanos(self.currency_code.clone(), total)
396	}
397
398	/// Attempts to divide this [`Money`] amount by a float scalar, returning a new [`Money`] instance.
399	/// Returns an error if the divisor is zero, non-finite, or if division causes an internal conversion error.
400	/// WARNING: The usage of `f64` introduces floating-point precision issues. Do not use it for critical financial calculations.
401	pub fn try_div_f64(&self, rhs: f64) -> Result<Self, MoneyError> {
402		if rhs == 0.0 {
403			return Err(MoneyError::OutOfRange);
404		}
405		if !rhs.is_finite() {
406			return Err(MoneyError::OutOfRange);
407		}
408
409		let decimal_amount = self.as_imprecise_f64();
410		let result_decimal = decimal_amount / rhs;
411
412		if !result_decimal.is_finite() {
413			return Err(MoneyError::OutOfRange);
414		}
415
416		Self::from_imprecise_f64(self.currency_code.clone(), result_decimal)
417	}
418
419	/// Attempts to negate this [`Money`] amount, returning a new [`Money`] instance.
420	/// Returns an error if negation causes an overflow/underflow.
421	pub fn try_neg(&self) -> Result<Self, MoneyError> {
422		let neg_units = self
423			.units
424			.checked_neg()
425			.ok_or(MoneyError::OutOfRange)?;
426		let neg_nanos = self
427			.nanos
428			.checked_neg()
429			.ok_or(MoneyError::OutOfRange)?;
430
431		Self::new(self.currency_code.clone(), neg_units, neg_nanos)
432	}
433
434	/// Checks if the money's currency code matches the given `code`.
435	/// The `code` should be a three-letter ISO 4217 currency code (e.g., "USD", "EUR").
436	#[must_use]
437	#[inline]
438	pub fn is_currency(&self, code: &str) -> bool {
439		self.currency_code == code
440	}
441
442	/// Checks if the money's currency is United States Dollar (USD).
443	#[must_use]
444	#[inline]
445	pub fn is_usd(&self) -> bool {
446		self.is_currency("USD")
447	}
448
449	/// Checks if the money's currency is Euro (EUR).
450	#[must_use]
451	#[inline]
452	pub fn is_eur(&self) -> bool {
453		self.is_currency("EUR")
454	}
455
456	/// Checks if the money's currency is British Pound Sterling (GBP).
457	#[must_use]
458	#[inline]
459	pub fn is_gbp(&self) -> bool {
460		self.is_currency("GBP")
461	}
462
463	/// Checks if the money's currency is Japanese Yen (JPY).
464	#[must_use]
465	#[inline]
466	pub fn is_jpy(&self) -> bool {
467		self.is_currency("JPY")
468	}
469
470	/// Checks if the money's currency is Canadian Dollar (CAD).
471	#[must_use]
472	#[inline]
473	pub fn is_cad(&self) -> bool {
474		self.is_currency("CAD")
475	}
476
477	/// Checks if the money's currency is Australian Dollar (AUD).
478	#[must_use]
479	#[inline]
480	pub fn is_aud(&self) -> bool {
481		self.is_currency("AUD")
482	}
483
484	/// Checks if the money amount is strictly positive (greater than zero).
485	#[must_use]
486	#[inline]
487	pub const fn is_positive(&self) -> bool {
488		self.units > 0 || (self.units == 0 && self.nanos > 0)
489	}
490
491	/// Checks if the money amount is strictly negative (less than zero).
492	#[must_use]
493	#[inline]
494	pub const fn is_negative(&self) -> bool {
495		self.units < 0 || (self.units == 0 && self.nanos < 0)
496	}
497
498	/// Checks if the money amount is exactly zero.
499	#[must_use]
500	#[inline]
501	pub const fn is_zero(&self) -> bool {
502		self.units == 0 && self.nanos == 0
503	}
504}
505
506#[cfg(test)]
507mod tests {
508	use super::*;
509
510	fn usd(u: i64, n: i32) -> Money {
511		Money::new("USD", u, n).unwrap()
512	}
513
514	fn eur(u: i64, n: i32) -> Money {
515		Money::new("EUR", u, n).unwrap()
516	}
517
518	#[test]
519	fn test_normalization_carry() {
520		// 1. Simple positive carry
521		// 1 unit + 1.5B nanos -> 2 units + 500M nanos
522		let m = usd(1, 1_500_000_000);
523		assert_eq!(m.units, 2);
524		assert_eq!(m.nanos, 500_000_000);
525
526		// 2. Simple negative carry
527		// -1 unit - 1.5B nanos -> -2 units - 500M nanos
528		let m = usd(-1, -1_500_000_000);
529		assert_eq!(m.units, -2);
530		assert_eq!(m.nanos, -500_000_000);
531
532		// 1 unit + 1000M nanos -> 2 units
533		let m = usd(1, 1_000_000_000);
534		assert_eq!(m.units, 2);
535		assert_eq!(m.nanos, 0);
536	}
537
538	#[test]
539	fn test_normalization_sign_correction() {
540		// 1. Positive Units, Negative Nanos -> Reduce Unit
541		// 1 unit - 100 nanos -> 0 units, 999,999,900 nanos
542		let m = usd(1, -100);
543		assert_eq!(m.units, 0);
544		assert_eq!(m.nanos, 999_999_900);
545
546		// 2. Negative Units, Positive Nanos -> Increase Unit (towards zero)
547		// -1 unit + 100 nanos -> 0 units, -999,999,900 nanos
548		let m = usd(-1, 100);
549		assert_eq!(m.units, 0);
550		assert_eq!(m.nanos, -999_999_900);
551
552		// 3. Zero units, mixed nanos (allowed, sign is strictly determined by nanos)
553		let m = usd(0, -500);
554		assert!(m.is_negative());
555	}
556
557	// --- 2. Arithmetic Stress Tests ---
558
559	#[test]
560	fn test_add_sub() {
561		// 1. Standard Addition
562		let m1 = usd(10, 500_000_000);
563		let m2 = usd(20, 500_000_000);
564		let sum = m1.try_add(&m2).unwrap();
565		assert_eq!(sum.units, 31);
566		assert_eq!(sum.nanos, 0);
567
568		// 2. Addition causing Overflow of Units
569		let max = usd(i64::MAX, 0);
570		let one = usd(1, 0);
571		assert_eq!(max.try_add(&one), Err(MoneyError::OutOfRange));
572
573		// 3. Subtraction crossing zero
574		let m1 = usd(1, 0);
575		let m2 = usd(2, 0);
576		let diff = m1.try_sub(&m2).unwrap();
577		assert_eq!(diff.units, -1);
578		assert_eq!(diff.nanos, 0);
579
580		// 4. Subtraction causing Underflow
581		let min = usd(i64::MIN, 0);
582		let one = usd(1, 0);
583		assert_eq!(min.try_sub(&one), Err(MoneyError::OutOfRange));
584
585		// 5. Currency Mismatch
586		let u = usd(10, 0);
587		let e = eur(10, 0);
588		assert!(matches!(
589			u.try_add(&e),
590			Err(MoneyError::CurrencyMismatch { .. })
591		));
592	}
593
594	#[test]
595	fn test_assign_ops() {
596		// Reuse logic from try_add but specifically testing the mutable reference implementations
597		let mut m = usd(1, 500_000_000);
598		m.try_add_assign(&usd(0, 600_000_000)).unwrap();
599		// 1.5 + 0.6 = 2.1
600		assert_eq!(m.units, 2);
601		assert_eq!(m.nanos, 100_000_000);
602
603		m.try_sub_assign(&usd(3, 0)).unwrap();
604		// 2.1 - 3.0 = -0.9
605		assert_eq!(m.units, 0);
606		assert_eq!(m.nanos, -900_000_000);
607	}
608
609	#[test]
610	fn test_mul() {
611		// 1. Multiply positive
612		let m = usd(10, 500_000_000); // 10.5
613		let res = m.try_mul_i64(2).unwrap();
614		assert_eq!(res.units, 21);
615
616		// 2. Multiply negative
617		let res = m.try_mul_i64(-2).unwrap();
618		assert_eq!(res.units, -21);
619
620		// 3. Multiply Overflow
621		// (i64::MAX / 2) + 1  -> Multiplying by 2 should overflow
622		let huge = usd((i64::MAX / 2) + 2, 0);
623		assert_eq!(huge.try_mul_i64(2), Err(MoneyError::OutOfRange));
624
625		// 4. Nanos Overflow triggering Unit Overflow
626		// Units = i64::MAX - 1. Nanos = big enough that doubling adds 2 units.
627		let edge = usd(i64::MAX - 1, 600_000_000);
628		// Double nanos = 1.2B -> +1 unit carry, 200M rem.
629		// Units = (MAX-1)*2 + 1 = Overflow.
630		assert_eq!(edge.try_mul_i64(2), Err(MoneyError::OutOfRange));
631	}
632
633	#[test]
634	fn test_div() {
635		// 1. Clean Division
636		let m = usd(10, 0);
637		let res = m.try_div_i64(2).unwrap();
638		assert_eq!(res.units, 5);
639
640		// 2. Fractional Division (Penny splitting)
641		// 1 unit / 3 = 0.333333333
642		let m = usd(1, 0);
643		let res = m.try_div_i64(3).unwrap();
644		assert_eq!(res.units, 0);
645		assert_eq!(res.nanos, 333_333_333);
646
647		// 3. Division by Zero
648		assert_eq!(m.try_div_i64(0), Err(MoneyError::OutOfRange));
649	}
650
651	// --- 3. Float Conversions & Math ---
652
653	#[test]
654	fn test_f64_math_robustness() {
655		let m = usd(10, 0);
656
657		// 1. Normal Mul
658		let res = m.try_mul_f64(1.5).unwrap();
659		assert_eq!(res.units, 15);
660
661		// 2. Normal Div
662		let res = m.try_div_f64(2.0).unwrap();
663		assert_eq!(res.units, 5);
664
665		// 3. Infinite Result
666		assert_eq!(m.try_mul_f64(f64::INFINITY), Err(MoneyError::OutOfRange));
667		assert_eq!(m.try_div_f64(0.0), Err(MoneyError::OutOfRange)); // Checked specifically
668
669		// 4. Round trip precision check
670		// Create 10.55
671		let m = Money::from_imprecise_f64("USD", 10.55).unwrap();
672		assert_eq!(m.nanos, 550_000_000);
673		let f = m.as_imprecise_f64();
674		assert!((f - 10.55).abs() < 1e-9);
675	}
676
677	#[test]
678	fn test_f64_construction_edge_cases() {
679		// 1. NaN
680		assert_eq!(
681			Money::from_imprecise_f64("USD", f64::NAN),
682			Err(MoneyError::OutOfRange)
683		);
684
685		// 2. Overflow (Amount larger than i64::MAX)
686		assert_eq!(
687			Money::from_imprecise_f64("USD", 1e20),
688			Err(MoneyError::OutOfRange)
689		);
690
691		// 3. Negative handling (-0.005) -> -5M nanos
692		let m = Money::from_imprecise_f64("USD", -0.005).unwrap();
693		assert_eq!(m.units, 0);
694		assert_eq!(m.nanos, -5_000_000);
695	}
696
697	// --- 4. Comparison & Helpers ---
698
699	#[test]
700	fn test_comparison() {
701		let m1 = usd(10, 0);
702		let m2 = usd(10, 1);
703		let m3 = usd(10, 0);
704
705		assert!(m1 < m2);
706		assert!(m1 == m3);
707
708		let e = eur(10, 0);
709		// Currency mismatch -> None
710		assert_eq!(m1.partial_cmp(&e), None);
711	}
712
713	#[test]
714	fn test_flags() {
715		let zero = usd(0, 0);
716		assert!(zero.is_zero());
717		assert!(!zero.is_positive());
718		assert!(!zero.is_negative());
719
720		let pos = usd(0, 1);
721		assert!(pos.is_positive());
722
723		let neg = usd(0, -1);
724		assert!(neg.is_negative());
725	}
726
727	// --- 5. Formatting ---
728
729	#[test]
730	fn test_formatting_precision() {
731		// 1. Basic
732		assert_eq!(usd(1, 0).to_formatted_string("$", 2), "$1.00");
733
734		// 2. Rounding up
735		// 1.005 -> 1.01
736		assert_eq!(usd(1, 5_000_000).to_formatted_string("$", 2), "$1.01");
737
738		// 3. Rounding down
739		// 1.004 -> 1.00
740		assert_eq!(usd(1, 4_000_000).to_formatted_string("$", 2), "$1.00");
741
742		// 4. Large precision
743		assert_eq!(
744			usd(1, 123_456_789).to_formatted_string("$", 9),
745			"$1.123456789"
746		);
747
748		// 5. Zero precision
749		assert_eq!(usd(1, 900_000_000).to_formatted_string("$", 0), "$1"); // Truncates to $1
750
751		// 6. Negative
752		assert_eq!(usd(-5, -500_000_000).to_formatted_string("€", 2), "-€5.50");
753	}
754
755	#[test]
756	fn test_arithmetic_rollover_bugs() {
757		// This previously failed in `try_mul_i64` because 500M * 3 > i32::MAX
758		let m = usd(0, 500_000_000);
759		let res = m.try_mul_i64(10).unwrap(); // 5B nanos -> 5 units
760		assert_eq!(res.units, 5);
761		assert_eq!(res.nanos, 0);
762
763		// Addition Rollover
764		let m1 = usd(0, 600_000_000);
765		let m2 = usd(0, 600_000_000);
766		let res = m1.try_add(&m2).unwrap(); // 1.2B nanos -> 1 unit, 200M nanos
767		assert_eq!(res.units, 1);
768		assert_eq!(res.nanos, 200_000_000);
769	}
770
771	#[test]
772	fn test_formatting() {
773		let m = usd(10, 500_000_000); // 10.50
774		assert_eq!(m.to_formatted_string("$", 2), "$10.50");
775
776		// Rounding check
777		let m = usd(10, 555_000_000); // 10.555
778		assert_eq!(m.to_formatted_string("$", 2), "$10.56");
779
780		// Negative formatting
781		let m = usd(-5, -500_000_000); // -5.50
782		assert_eq!(m.to_formatted_string("$", 2), "-$5.50");
783	}
784
785	#[test]
786	fn test_f64_conversions() {
787		// From f64
788		let m = Money::from_imprecise_f64("USD", 10.50).unwrap();
789		assert_eq!(m.units, 10);
790		assert_eq!(m.nanos, 500_000_000);
791
792		// To f64 (Rounded)
793		// 10.555 -> round(2) -> 10.56
794		let m = usd(10, 555_000_000);
795		let f = m.to_rounded_imprecise_f64(2).unwrap();
796		assert!((f - 10.56).abs() < f64::EPSILON);
797	}
798}