1use 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 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 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.is_multiple_of(3) {
59 out.push(',');
60 }
61 }
62 out
63}
64
65pub fn currency_symbol(code: &str) -> &str {
69 match code.to_uppercase().as_str() {
70 "SGD" => "S$",
71 "USD" => "$",
72 "GBP" => "£",
73 "EUR" => "€",
74 "JPY" => "¥",
75 "CNY" | "RMB" => "¥",
76 "HKD" => "HK$",
77 "AUD" => "A$",
78 "NZD" => "NZ$",
79 "CAD" => "C$",
80 "CHF" => "CHF",
81 "INR" => "₹",
82 "KRW" => "₩",
83 "THB" => "฿",
84 "MYR" => "RM",
85 "IDR" => "Rp",
86 "PHP" => "₱",
87 "VND" => "₫",
88 "AED" => "AED",
89 _ => "",
90 }
91}
92
93pub fn line_total(qty: Decimal, unit_price: MinorUnits) -> MinorUnits {
95 let up = unit_price.as_decimal();
96 MinorUnits::from_decimal(qty * up)
97}
98
99pub fn apply_rate(base: MinorUnits, rate: Decimal) -> MinorUnits {
101 let amt = base.as_decimal() * rate / Decimal::from(100);
102 MinorUnits::from_decimal(amt)
103}
104
105pub fn tax_amount(base: MinorUnits, rate: Decimal) -> MinorUnits {
107 apply_rate(base, rate)
108}
109
110pub fn line_total_discounted(
115 qty: Decimal,
116 unit_price: MinorUnits,
117 discount_rate: Option<Decimal>,
118 discount_fixed: Option<MinorUnits>,
119) -> MinorUnits {
120 let base = line_total(qty, unit_price);
121 if let Some(rate) = discount_rate {
122 let cut = apply_rate(base, rate);
123 let res = base.0 - cut.0;
124 return MinorUnits(res.max(0));
125 }
126 if let Some(fx) = discount_fixed {
127 let res = base.0 - fx.0;
128 return MinorUnits(res.max(0));
129 }
130 base
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use rust_decimal_macros::dec;
137
138 #[test]
139 fn formats_thousands() {
140 assert_eq!(MinorUnits(123456).format_number(), "1,234.56");
141 assert_eq!(MinorUnits(100).format_number(), "1.00");
142 assert_eq!(MinorUnits(99999999).format_number(), "999,999.99");
143 }
144
145 #[test]
146 fn negative_formatted() {
147 assert_eq!(MinorUnits(-12345).format_number(), "-123.45");
148 }
149
150 #[test]
151 fn line_total_exact() {
152 let total = line_total(dec!(18.5), MinorUnits::from_major(220.0));
154 assert_eq!(total, MinorUnits::from_major(4070.0));
155 }
156
157 #[test]
158 fn tax_exact() {
159 let tax = tax_amount(MinorUnits::from_major(24600.0), dec!(9.0));
161 assert_eq!(tax, MinorUnits::from_major(2214.0));
162 }
163
164 #[test]
165 fn line_discount_rate() {
166 let r = line_total_discounted(
168 dec!(10),
169 MinorUnits::from_major(100.0),
170 Some(dec!(10)),
171 None,
172 );
173 assert_eq!(r, MinorUnits::from_major(900.0));
174 }
175
176 #[test]
177 fn line_discount_fixed() {
178 let r = line_total_discounted(
180 dec!(1),
181 MinorUnits::from_major(500.0),
182 None,
183 Some(MinorUnits::from_major(50.0)),
184 );
185 assert_eq!(r, MinorUnits::from_major(450.0));
186 }
187
188 #[test]
189 fn line_discount_clamps_at_zero() {
190 let r = line_total_discounted(
192 dec!(1),
193 MinorUnits::from_major(10.0),
194 None,
195 Some(MinorUnits::from_major(999.0)),
196 );
197 assert_eq!(r, MinorUnits(0));
198 }
199
200 #[test]
201 fn currency_symbols_common() {
202 assert_eq!(currency_symbol("SGD"), "S$");
203 assert_eq!(currency_symbol("GBP"), "£");
204 assert_eq!(currency_symbol("eur"), "€");
205 assert_eq!(currency_symbol("USD"), "$");
206 assert_eq!(currency_symbol("JPY"), "¥");
207 assert_eq!(currency_symbol("XYZ"), "");
209 }
210}