1#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
7pub struct Micros(pub i64);
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DecimalError {
12 NotANumber,
14 TooManyDecimals,
16}
17
18impl Micros {
19 pub const ZERO: Self = Self(0);
20 pub const SCALE: i64 = 1_000_000;
22
23 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 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 pub fn to_decimal_string(self) -> String {
58 format_fixed(i128::from(self.0), 6)
59 }
60
61 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 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 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 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
128fn 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 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
184fn 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
214pub(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
224pub(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
239pub 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 assert_eq!(rounded_product(usd("0.5"), usd("0.000336")), 168);
385 assert_eq!(rounded_product(usd("0.5"), usd("0.000001")), 1);
387 assert_eq!(rounded_product(usd("-0.5"), usd("0.000001")), -1);
388 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}