Skip to main content

grabcli_core/
money.rs

1//! Money as integer minor units (centavos). Display adds the ₱ symbol and
2//! thousands separators.
3
4use serde::{Deserialize, Serialize};
5
6/// An amount in minor units (1/100 of a peso).
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
8pub struct Money(pub i64);
9
10impl Money {
11    pub const ZERO: Money = Money(0);
12
13    pub fn minor(self) -> i64 {
14        self.0
15    }
16
17    /// Major units as an f64 (for JSON `total_php`-style output). Lossy for
18    /// huge values but fine for prices.
19    pub fn as_major_f64(self) -> f64 {
20        self.0 as f64 / 100.0
21    }
22
23    /// `₱1,234.50`. Negative amounts use a minus sign before the symbol.
24    pub fn peso(self) -> String {
25        format!(
26            "{}₱{}",
27            if self.0 < 0 { "-" } else { "" },
28            group(self.0.abs())
29        )
30    }
31
32    /// `1,234.50` with no currency symbol.
33    pub fn plain(self) -> String {
34        format!(
35            "{}{}",
36            if self.0 < 0 { "-" } else { "" },
37            group(self.0.abs())
38        )
39    }
40
41    /// Major-unit number with up to 2 decimals and no trailing zeros
42    /// (`440`, `909.5`, `1003.25`) for vault notes.
43    pub fn trimmed_major(self) -> String {
44        let neg = self.0 < 0;
45        let abs = self.0.abs();
46        let whole = abs / 100;
47        let cents = abs % 100;
48        let body = if cents == 0 {
49            whole.to_string()
50        } else if cents % 10 == 0 {
51            format!("{whole}.{}", cents / 10)
52        } else {
53            format!("{whole}.{cents:02}")
54        };
55        if neg { format!("-{body}") } else { body }
56    }
57}
58
59impl core::ops::Add for Money {
60    type Output = Money;
61    fn add(self, rhs: Money) -> Money {
62        Money(self.0 + rhs.0)
63    }
64}
65
66impl core::ops::Sub for Money {
67    type Output = Money;
68    fn sub(self, rhs: Money) -> Money {
69        Money(self.0 - rhs.0)
70    }
71}
72
73/// Format minor units with a thousands separator and two decimals.
74fn group(minor_abs: i64) -> String {
75    let whole = minor_abs / 100;
76    let cents = minor_abs % 100;
77    let digits = whole.to_string();
78    let mut out = String::new();
79    let bytes = digits.as_bytes();
80    for (i, b) in bytes.iter().enumerate() {
81        if i > 0 && (bytes.len() - i).is_multiple_of(3) {
82            out.push(',');
83        }
84        out.push(*b as char);
85    }
86    format!("{out}.{cents:02}")
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn peso_formatting() {
95        assert_eq!(Money(57100).peso(), "₱571.00");
96        assert_eq!(Money(100000).peso(), "₱1,000.00");
97        assert_eq!(Money(123456789).peso(), "₱1,234,567.89");
98        assert_eq!(Money(-4900).peso(), "-₱49.00");
99        assert_eq!(Money(0).peso(), "₱0.00");
100        assert_eq!(Money(5).peso(), "₱0.05");
101    }
102
103    #[test]
104    fn trimmed_major_strips_zeros() {
105        assert_eq!(Money(44000).trimmed_major(), "440");
106        assert_eq!(Money(90950).trimmed_major(), "909.5");
107        assert_eq!(Money(100325).trimmed_major(), "1003.25");
108        assert_eq!(Money(81250).trimmed_major(), "812.5");
109        assert_eq!(Money(9991).trimmed_major(), "99.91");
110    }
111
112    #[test]
113    fn arithmetic() {
114        assert_eq!(
115            (Money(44000) + Money(18000) - Money(4900)).peso(),
116            "₱571.00"
117        );
118    }
119}