Skip to main content

invoice_cli/
money.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Money — precise numeric handling.
3//
4// Amounts are stored as i64 minor units (cents). Tax math uses rust_decimal
5// to avoid float rounding artefacts seen in some upstream Typst templates.
6// ═══════════════════════════════════════════════════════════════════════════
7
8use rust_decimal::prelude::*;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub struct MinorUnits(pub i64);
13
14impl MinorUnits {
15    pub fn from_major(major: f64) -> Self {
16        Self((major * 100.0).round() as i64)
17    }
18
19    pub fn from_decimal(d: Decimal) -> Self {
20        let scaled = (d * Decimal::from(100)).round();
21        Self(scaled.to_i64().unwrap_or(0))
22    }
23
24    pub fn as_major(&self) -> f64 {
25        self.0 as f64 / 100.0
26    }
27
28    pub fn as_decimal(&self) -> Decimal {
29        Decimal::from(self.0) / Decimal::from(100)
30    }
31
32    /// Format like `1,234.56` (no currency symbol).
33    pub fn format_number(&self) -> String {
34        let sign = if self.0 < 0 { "-" } else { "" };
35        let abs = self.0.abs();
36        let whole = abs / 100;
37        let frac = abs % 100;
38        let whole_str = format_thousands(whole);
39        format!("{}{}.{:02}", sign, whole_str, frac)
40    }
41
42    /// Format with currency symbol: `S$1,234.56`.
43    pub fn format_with_symbol(&self, symbol: &str) -> String {
44        let sign = if self.0 < 0 { "-" } else { "" };
45        let abs = Self(self.0.abs());
46        format!("{}{}{}", sign, symbol, abs.format_number())
47    }
48}
49
50fn format_thousands(n: i64) -> String {
51    let s = n.to_string();
52    let mut out = String::with_capacity(s.len() + s.len() / 3);
53    let chars: Vec<char> = s.chars().collect();
54    let len = chars.len();
55    for (i, c) in chars.iter().enumerate() {
56        out.push(*c);
57        let remaining = len - i - 1;
58        if remaining > 0 && remaining % 3 == 0 {
59            out.push(',');
60        }
61    }
62    out
63}
64
65/// Compute line total in minor units: qty * unit_price.
66pub fn line_total(qty: Decimal, unit_price: MinorUnits) -> MinorUnits {
67    let up = unit_price.as_decimal();
68    MinorUnits::from_decimal(qty * up)
69}
70
71/// Apply a percent rate to a base. Shared by tax_amount and discount math.
72pub fn apply_rate(base: MinorUnits, rate: Decimal) -> MinorUnits {
73    let amt = base.as_decimal() * rate / Decimal::from(100);
74    MinorUnits::from_decimal(amt)
75}
76
77/// Compute tax amount in minor units: base * rate / 100.
78pub fn tax_amount(base: MinorUnits, rate: Decimal) -> MinorUnits {
79    apply_rate(base, rate)
80}
81
82/// Line total after applying at most one of (rate discount, fixed discount).
83/// If both are set, `rate` wins — caller should enforce mutual exclusion at
84/// the CLI layer. Result is clamped at zero so a mis-sized fixed discount
85/// can't flip the line negative.
86pub fn line_total_discounted(
87    qty: Decimal,
88    unit_price: MinorUnits,
89    discount_rate: Option<Decimal>,
90    discount_fixed: Option<MinorUnits>,
91) -> MinorUnits {
92    let base = line_total(qty, unit_price);
93    if let Some(rate) = discount_rate {
94        let cut = apply_rate(base, rate);
95        let res = base.0 - cut.0;
96        return MinorUnits(res.max(0));
97    }
98    if let Some(fx) = discount_fixed {
99        let res = base.0 - fx.0;
100        return MinorUnits(res.max(0));
101    }
102    base
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use rust_decimal_macros::dec;
109
110    #[test]
111    fn formats_thousands() {
112        assert_eq!(MinorUnits(123456).format_number(), "1,234.56");
113        assert_eq!(MinorUnits(100).format_number(), "1.00");
114        assert_eq!(MinorUnits(99999999).format_number(), "999,999.99");
115    }
116
117    #[test]
118    fn negative_formatted() {
119        assert_eq!(MinorUnits(-12345).format_number(), "-123.45");
120    }
121
122    #[test]
123    fn line_total_exact() {
124        // 18.5 × 220.00 = 4070.00 exactly
125        let total = line_total(dec!(18.5), MinorUnits::from_major(220.0));
126        assert_eq!(total, MinorUnits::from_major(4070.0));
127    }
128
129    #[test]
130    fn tax_exact() {
131        // 24,600.00 × 9% = 2214.00
132        let tax = tax_amount(MinorUnits::from_major(24600.0), dec!(9.0));
133        assert_eq!(tax, MinorUnits::from_major(2214.0));
134    }
135
136    #[test]
137    fn line_discount_rate() {
138        // 10 × 100 = 1000, 10% off → 900
139        let r = line_total_discounted(dec!(10), MinorUnits::from_major(100.0), Some(dec!(10)), None);
140        assert_eq!(r, MinorUnits::from_major(900.0));
141    }
142
143    #[test]
144    fn line_discount_fixed() {
145        // 1 × 500 = 500, fixed 50 off → 450
146        let r = line_total_discounted(
147            dec!(1),
148            MinorUnits::from_major(500.0),
149            None,
150            Some(MinorUnits::from_major(50.0)),
151        );
152        assert_eq!(r, MinorUnits::from_major(450.0));
153    }
154
155    #[test]
156    fn line_discount_clamps_at_zero() {
157        // Over-discount — shouldn't go negative
158        let r = line_total_discounted(
159            dec!(1),
160            MinorUnits::from_major(10.0),
161            None,
162            Some(MinorUnits::from_major(999.0)),
163        );
164        assert_eq!(r, MinorUnits(0));
165    }
166}