Skip to main content

ci_bill/
money.rs

1/// Exact decimal stored as an integer count of millionths (10^-6).
2///
3/// CSV amounts and quantities are parsed from decimal text. Nothing in this
4/// type uses floating point. Half-up rounding happens only in the display
5/// helpers.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
7pub struct Micros(pub i64);
8
9/// Why a decimal string was rejected.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DecimalError {
12    /// Empty, text, NaN, infinity, exponent junk, or a magnitude that does not fit.
13    NotANumber,
14    /// The value is not an integer number of millionths, so it would need a 7th decimal.
15    TooManyDecimals,
16}
17
18impl Micros {
19    pub const ZERO: Self = Self(0);
20    /// 1.0 = 1_000_000.
21    pub const SCALE: i64 = 1_000_000;
22
23    /// Parse `[-+]digits[.digits][e[-+]digits]`.
24    ///
25    /// A value that is not an exact multiple of 10^-6 is `TooManyDecimals`.
26    /// Trailing zeros are kept only when the value still fits in 6 decimal places
27    /// (`1.2300000` is 1.23; `1.2345678` is rejected).
28    pub fn parse(raw: &str) -> Result<Self, DecimalError> {
29        let text = raw.trim();
30        if text.is_empty() {
31            return Err(DecimalError::NotANumber);
32        }
33        let (negative, unsigned) = match text.as_bytes()[0] {
34            b'+' => (false, &text[1..]),
35            b'-' => (true, &text[1..]),
36            _ => (false, text),
37        };
38        if unsigned.is_empty() {
39            return Err(DecimalError::NotANumber);
40        }
41        let (coefficient, exponent) = split_exponent(unsigned)?;
42        let (significand, frac_len) = parse_coefficient(coefficient)?;
43        // value = significand * 10^(exponent - frac_len)
44        // micros = value * 10^6 = significand * 10^(exponent - frac_len + 6)
45        let power = exponent
46            .checked_sub(frac_len)
47            .and_then(|power| power.checked_add(6))
48            .ok_or(DecimalError::NotANumber)?;
49        let scaled = scale_pow10(significand, power)?;
50        let signed = if negative { -scaled } else { scaled };
51        i64::try_from(signed)
52            .map(Self)
53            .map_err(|_| DecimalError::NotANumber)
54    }
55
56    /// `44.232000`. Always 6 digits after the decimal point.
57    pub fn to_decimal_string(self) -> String {
58        format_fixed(i128::from(self.0), 6)
59    }
60
61    /// Half-up, away from zero, to cents. Display only.
62    pub fn format_cents(self) -> String {
63        let neg = self.0 < 0;
64        let abs = self.0.unsigned_abs();
65        let mut cents = abs / 10_000;
66        let rem = abs % 10_000;
67        if rem >= 5_000 {
68            cents += 1;
69        }
70        let sign = if neg && cents != 0 { "-" } else { "" };
71        format!("{sign}{}.{:02}", cents / 100, cents % 100)
72    }
73
74    /// Up to 3 decimal places, half-up, trailing zeros removed. Used for minutes.
75    pub fn format_quantity(self) -> String {
76        let neg = self.0 < 0;
77        let abs = self.0.unsigned_abs();
78        let mut thousandths = abs / 1_000;
79        let rem = abs % 1_000;
80        if rem >= 500 {
81            thousandths += 1;
82        }
83        let sign = if neg && thousandths != 0 { "-" } else { "" };
84        let whole = thousandths / 1_000;
85        let frac = (thousandths % 1_000) as u32;
86        if frac == 0 {
87            return format!("{sign}{whole}");
88        }
89        let digits = format!("{frac:03}");
90        let trimmed = digits.trim_end_matches('0');
91        format!("{sign}{whole}.{trimmed}")
92    }
93
94    /// Whole units when this value has no fractional millionths.
95    pub fn whole_units(self) -> Option<i64> {
96        if self.0 % Self::SCALE == 0 {
97            Some(self.0 / Self::SCALE)
98        } else {
99            None
100        }
101    }
102
103    /// `None` when the sum does not fit in `i64` micros, instead of panicking.
104    /// Use this to add amounts from untrusted input, such as CSV rows.
105    ///
106    /// `Micros` deliberately has no `Add`, `AddAssign` or `Sum` impl: every sum
107    /// must choose between this checked form and an explicit saturating one,
108    /// so no `+` on money can panic on overflow.
109    pub fn checked_add(self, rhs: Self) -> Option<Self> {
110        self.0.checked_add(rhs.0).map(Self)
111    }
112}
113
114impl std::str::FromStr for Micros {
115    type Err = DecimalError;
116
117    fn from_str(raw: &str) -> Result<Self, Self::Err> {
118        Self::parse(raw)
119    }
120}
121
122impl std::fmt::Display for Micros {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str(&self.to_decimal_string())
125    }
126}
127
128/// `power` is the base-10 exponent applied to `significand` to produce micros.
129fn scale_pow10(mut significand: i128, power: i32) -> Result<i128, DecimalError> {
130    if significand == 0 {
131        return Ok(0);
132    }
133    if power >= 0 {
134        for _ in 0..power {
135            significand = significand
136                .checked_mul(10)
137                .ok_or(DecimalError::NotANumber)?;
138        }
139        return Ok(significand);
140    }
141    // More than ~38 trailing zeros cannot divide a non-zero i128 down to an integer micro.
142    if power < -40 {
143        return Err(DecimalError::TooManyDecimals);
144    }
145    for _ in 0..(-power) {
146        if significand % 10 != 0 {
147            return Err(DecimalError::TooManyDecimals);
148        }
149        significand /= 10;
150    }
151    Ok(significand)
152}
153
154fn split_exponent(unsigned: &str) -> Result<(&str, i32), DecimalError> {
155    let Some(index) = unsigned
156        .bytes()
157        .position(|byte| byte == b'e' || byte == b'E')
158    else {
159        return Ok((unsigned, 0));
160    };
161    let (coefficient, rest) = unsigned.split_at(index);
162    let exp_text = &rest[1..];
163    if coefficient.is_empty() || exp_text.is_empty() {
164        return Err(DecimalError::NotANumber);
165    }
166    let (exp_neg, digits) = match exp_text.as_bytes()[0] {
167        b'+' => (false, &exp_text[1..]),
168        b'-' => (true, &exp_text[1..]),
169        _ => (false, exp_text),
170    };
171    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
172        return Err(DecimalError::NotANumber);
173    }
174    let mut exp: i32 = 0;
175    for byte in digits.bytes() {
176        exp = exp
177            .checked_mul(10)
178            .and_then(|value| value.checked_add(i32::from(byte - b'0')))
179            .ok_or(DecimalError::NotANumber)?;
180    }
181    Ok((coefficient, if exp_neg { -exp } else { exp }))
182}
183
184/// Integer significand and how many of its digits sit after the decimal point.
185fn parse_coefficient(coefficient: &str) -> Result<(i128, i32), DecimalError> {
186    let (whole, frac) = match coefficient.split_once('.') {
187        Some((whole, frac)) => {
188            if frac.contains('.') {
189                return Err(DecimalError::NotANumber);
190            }
191            (whole, frac)
192        }
193        None => (coefficient, ""),
194    };
195    if whole.is_empty() && frac.is_empty() {
196        return Err(DecimalError::NotANumber);
197    }
198    if !whole.bytes().all(|byte| byte.is_ascii_digit())
199        || !frac.bytes().all(|byte| byte.is_ascii_digit())
200    {
201        return Err(DecimalError::NotANumber);
202    }
203    let mut significand: i128 = 0;
204    for byte in whole.bytes().chain(frac.bytes()) {
205        significand = significand
206            .checked_mul(10)
207            .and_then(|value| value.checked_add(i128::from(byte - b'0')))
208            .ok_or(DecimalError::NotANumber)?;
209    }
210    let frac_len = i32::try_from(frac.len()).map_err(|_| DecimalError::NotANumber)?;
211    Ok((significand, frac_len))
212}
213
214/// `quantity * unit_price` in micros, rounded half away from zero.
215///
216/// Both factors are already in micros, so the raw product is in 10^-12 dollars.
217/// Dividing by [`Micros::SCALE`] returns micros. A remainder of half a micro
218/// rounds away from zero, the same rule as cent display.
219pub(crate) fn rounded_product(quantity: Micros, unit_price: Micros) -> i128 {
220    let product = i128::from(quantity.0) * i128::from(unit_price.0);
221    div_half_away(product, i128::from(Micros::SCALE))
222}
223
224/// Six digits after the decimal point, same shape as [`Micros::to_decimal_string`].
225pub(crate) fn format_micros(value: i128) -> String {
226    format_fixed(value, 6)
227}
228
229fn format_fixed(value: i128, digits: usize) -> String {
230    let neg = value < 0;
231    let abs = value.unsigned_abs();
232    let scale = 10u128.pow(u32::try_from(digits).expect("decimal width fits u32"));
233    let whole = abs / scale;
234    let frac = abs % scale;
235    let sign = if neg && abs != 0 { "-" } else { "" };
236    format!("{sign}{whole}.{frac:0digits$}")
237}
238
239/// Percentage `net / total * 100`, half-up, with exactly `digits` decimal places.
240/// A zero total is `0` with that many decimals.
241pub fn percent_fixed(net: Micros, total: Micros, digits: u32) -> String {
242    if total.0 == 0 {
243        return format_fixed(0, digits as usize);
244    }
245    let scale = 10i128.pow(digits);
246    let num = i128::from(net.0) * 100 * scale;
247    let den = i128::from(total.0);
248    format_fixed(div_half_away(num, den), digits as usize)
249}
250
251pub(crate) fn div_half_away(num: i128, den: i128) -> i128 {
252    let quot = num / den;
253    let rem = num % den;
254    if rem != 0 && rem.abs() * 2 >= den.abs() {
255        quot + num.signum() * den.signum()
256    } else {
257        quot
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn usd(text: &str) -> Micros {
266        Micros::parse(text).unwrap()
267    }
268
269    #[test]
270    fn parses_plain_decimals_without_rounding() {
271        assert_eq!(usd("0").0, 0);
272        assert_eq!(usd("0.00").0, 0);
273        assert_eq!(usd("1").0, 1_000_000);
274        assert_eq!(usd("1.").0, 1_000_000);
275        assert_eq!(usd(".5").0, 500_000);
276        assert_eq!(usd("+1.5").0, 1_500_000);
277        assert_eq!(usd("-1.5").0, -1_500_000);
278        assert_eq!(usd("-.5").0, -500_000);
279        assert_eq!(usd("00.50").0, 500_000);
280        assert_eq!(usd("7.316").0, 7_316_000);
281        assert_eq!(usd("0.000336").0, 336);
282        assert_eq!(usd("0.000001").0, 1);
283        assert_eq!(usd("1.234567").0, 1_234_567);
284        assert_eq!(usd("44.232").to_decimal_string(), "44.232000");
285        assert_eq!(usd("-0.25").to_decimal_string(), "-0.250000");
286    }
287
288    #[test]
289    fn exponent_form_stays_exact() {
290        assert_eq!(usd("1e1").0, 10_000_000);
291        assert_eq!(usd("10e0").0, 10_000_000);
292        assert_eq!(usd("0e0").0, 0);
293        assert_eq!(usd("1E-1").0, 100_000);
294        assert_eq!(usd("1.25e-1").0, 125_000);
295        assert_eq!(usd("10e-7").0, 1);
296        assert_eq!(usd("1.2300000").0, 1_230_000);
297        assert_eq!(usd("1.2345670").0, 1_234_567);
298        assert_eq!(usd("10000000e-7").0, 1_000_000);
299    }
300
301    #[test]
302    fn rejects_non_numbers_and_a_seventh_decimal() {
303        for raw in [
304            "",
305            " ",
306            "+",
307            "-",
308            ".",
309            "e10",
310            "1e",
311            "1e+",
312            "1e-",
313            "1e2e3",
314            "1.2.3",
315            "NaN",
316            "nan",
317            "+NaN",
318            "inf",
319            "INF",
320            "+inf",
321            "-inf",
322            "Infinity",
323            "-Infinity",
324            "abc",
325            "1,000",
326            "10usd",
327            "1e309",
328            "--",
329        ] {
330            assert_eq!(Micros::parse(raw), Err(DecimalError::NotANumber), "{raw}");
331        }
332        for raw in [
333            "1.2345678",
334            "0.0000001",
335            "1e-7",
336            "12345678e-7",
337            "-0.0000001",
338        ] {
339            assert_eq!(
340                Micros::parse(raw),
341                Err(DecimalError::TooManyDecimals),
342                "{raw}"
343            );
344        }
345    }
346
347    #[test]
348    fn cents_round_half_up_away_from_zero() {
349        assert_eq!(usd("0").format_cents(), "0.00");
350        assert_eq!(usd("0.005").format_cents(), "0.01");
351        assert_eq!(usd("2.112").format_cents(), "2.11");
352        assert_eq!(usd("2.115").format_cents(), "2.12");
353        assert_eq!(usd("1.004").format_cents(), "1.00");
354        assert_eq!(usd("1.005").format_cents(), "1.01");
355        assert_eq!(usd("-0.005").format_cents(), "-0.01");
356        assert_eq!(usd("-0.004").format_cents(), "0.00");
357        assert_eq!(usd("284.208").format_cents(), "284.21");
358        assert_eq!(usd("44.232").format_cents(), "44.23");
359        assert_eq!(usd("230.144").format_cents(), "230.14");
360    }
361
362    #[test]
363    fn quantity_trims_to_three_decimals() {
364        assert_eq!(usd("3712").format_quantity(), "3712");
365        assert_eq!(usd("0").format_quantity(), "0");
366        assert_eq!(usd("0.5").format_quantity(), "0.5");
367        assert_eq!(usd("10.125").format_quantity(), "10.125");
368        assert_eq!(usd("1.2345").format_quantity(), "1.235");
369        assert_eq!(usd("-1.2").format_quantity(), "-1.2");
370    }
371
372    #[test]
373    fn product_118_minutes_at_0_062_is_7_316() {
374        assert_eq!(rounded_product(usd("118"), usd("0.062")), 7_316_000);
375        assert_eq!(
376            format_micros(rounded_product(usd("118"), usd("0.062"))),
377            "7.316000"
378        );
379    }
380
381    #[test]
382    fn product_rounds_half_away_from_zero_before_the_micro() {
383        // 0.5 * 0.000336 = 0.000168 exactly (storage sample row).
384        assert_eq!(rounded_product(usd("0.5"), usd("0.000336")), 168);
385        // 0.5 * 0.000001 = 0.5 micros -> 1 micro.
386        assert_eq!(rounded_product(usd("0.5"), usd("0.000001")), 1);
387        assert_eq!(rounded_product(usd("-0.5"), usd("0.000001")), -1);
388        // 0.1 micro rounds down to 0.
389        assert_eq!(rounded_product(usd("0.1"), usd("0.000001")), 0);
390        assert_eq!(rounded_product(usd("0"), usd("9.99")), 0);
391        assert_eq!(rounded_product(usd("118"), usd("0.080")), 9_440_000);
392        assert_eq!(format_micros(-1), "-0.000001");
393    }
394
395    #[test]
396    fn percent_half_up_matches_known_ratios() {
397        assert_eq!(percent_fixed(usd("3"), usd("4"), 2), "75.00");
398        assert_eq!(percent_fixed(usd("1"), usd("4"), 2), "25.00");
399        assert_eq!(percent_fixed(usd("1"), usd("3"), 2), "33.33");
400        assert_eq!(percent_fixed(usd("1"), usd("3"), 6), "33.333333");
401        assert_eq!(percent_fixed(usd("2"), usd("3"), 2), "66.67");
402        assert_eq!(percent_fixed(usd("2"), usd("3"), 6), "66.666667");
403        assert_eq!(percent_fixed(usd("1"), Micros::ZERO, 2), "0.00");
404        assert_eq!(percent_fixed(usd("230.144"), usd("284.208"), 2), "80.98");
405    }
406    #[test]
407    fn checked_add_reports_overflow_instead_of_panicking() {
408        assert_eq!(usd("1.25").checked_add(usd("2.5")), Some(usd("3.75")));
409        assert_eq!(usd("-1").checked_add(usd("1")), Some(Micros::ZERO));
410        assert_eq!(Micros(i64::MAX).checked_add(Micros(1)), None);
411        assert_eq!(Micros(i64::MIN).checked_add(Micros(-1)), None);
412        assert_eq!(
413            Micros(i64::MAX).checked_add(Micros(i64::MIN)),
414            Some(Micros(-1))
415        );
416    }
417}