Skip to content
93 linesCodeBlameRaw
1/**
2 * The one way money is written on g1t: a sum in millionths of a dollar
3 * (micros, as every service counts it) as `$1,234.56`. Every page, the
4 * top bar's pill, Spend, Home, Usage, the statement and the price book
5 * read the same arithmetic, so the same sum reads the same everywhere.
6 *
7 * - Rounding is on whole micros, half up at the last place shown; never on
8 * a floating-point `toFixed`, which rounds `.5` either way.
9 * - Negatives lead with a minus sign (U+2212); thousands are separated.
10 * - By default, to the cent, and a fraction of a cent reads `<$0.01`
11 * rather than `$0.00` or `$0.004`: a total is a total. Exactly nothing
12 * is `$0.00`.
13 * - `precise` shows up to four places (`$0.0063`), trailing zeros trimmed
14 * down to two, for where the fraction of a cent is the point: a
15 * statement's lines, a receipt, the price book, per-token rates.
16 * - `compact` is an axis label: `$0`, `$0.50`, `$2`, `$1.2K`.
17 *
18 * Pure, so it is tested on its own; it imports nothing.
19 */
20
21/** Millionths of a dollar in one dollar, as `@g1t/contracts` `MICROS_PER_DOLLAR`; here so this file imports nothing. */
22export const MICROS_PER_DOLLAR = 1_000_000;
23
24export type MoneyOptions = {
25 /** Up to four places, trailing zeros trimmed to two: `$0.0063`, `$0.25`. */
26 precise?: boolean;
27 /** An axis label: whole dollars without places, thousands as `$1.2K`. */
28 compact?: boolean;
29};
30
31const MINUS = "−";
32
33/** `micros` as whole micros, rounded half up at `unit` (10_000 for a cent, 100 for a hundredth of one), as a count of units. Non-negative input. */
34function unitsOf(micros: number, unit: number): number {
35 return Math.floor((micros + unit / 2) / unit);
36}
37
38/** Thousands separated: `1234567` as `1,234,567`. */
39function grouped(whole: number): string {
40 return String(whole).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
41}
42
43/** `units` of `scale` places as `whole.fraction`, the fraction trimmed down to `min` places. */
44function withPlaces(units: number, places: number, min: number): string {
45 const per = 10 ** places;
46 const whole = Math.floor(units / per);
47 let fraction = String(units % per).padStart(places, "0");
48 while (fraction.length > min && fraction.endsWith("0")) fraction = fraction.slice(0, -1);
49 return fraction ? `${grouped(whole)}.${fraction}` : grouped(whole);
50}
51
52/** Dollars, to the cent. See the file's notes for `precise` and `compact`. */
53export function money(micros: number, options: MoneyOptions = {}): string {
54 const sign = micros < 0 ? MINUS : "";
55 // Whole micros only: a float that reached here is rounded before anything else.
56 const abs = Math.round(Math.abs(micros));
57 if (abs === 0) return options.compact ? "$0" : "$0.00";
58 if (options.compact) {
59 if (abs >= 1_000_000 * MICROS_PER_DOLLAR) return `${sign}$${withPlaces(unitsOf(abs, 100_000 * MICROS_PER_DOLLAR), 1, 0)}M`;
60 if (abs >= 1_000 * MICROS_PER_DOLLAR) return `${sign}$${withPlaces(unitsOf(abs, 100 * MICROS_PER_DOLLAR), 1, 0)}K`;
61 if (abs % MICROS_PER_DOLLAR === 0) return `${sign}$${grouped(abs / MICROS_PER_DOLLAR)}`;
62 return `${sign}$${withPlaces(unitsOf(abs, 100), 4, 2)}`;
63 }
64 if (options.precise) {
65 const units = unitsOf(abs, 100);
66 return units === 0 ? `<${sign}$0.0001` : `${sign}$${withPlaces(units, 4, 2)}`;
67 }
68 if (abs < 10_000) return `<${sign}$0.01`;
69 return `${sign}$${withPlaces(unitsOf(abs, 10_000), 2, 2)}`;
70}
71
72/** Whole dollars when they are whole, else to the cent: `$1,000`, `$0.10`. The price book's and the prepay presets' style. */
73export function wholeDollars(micros: number): string {
74 const abs = Math.round(Math.abs(micros));
75 if (abs % MICROS_PER_DOLLAR !== 0) return money(micros);
76 return `${micros < 0 ? MINUS : ""}$${grouped(abs / MICROS_PER_DOLLAR)}`;
77}
78
79/**
80 * Dollars for a form field, with no sign or symbol, to the cent: `12.50`;
81 * with `whole`, `20` for a whole sum. Rounded the same way as `money`.
82 */
83export function plainDollars(micros: number, options: { whole?: boolean } = {}): string {
84 const abs = Math.round(Math.abs(micros));
85 const cents = unitsOf(abs, 10_000);
86 const text = options.whole && cents % 100 === 0 ? String(cents / 100) : `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
87 return micros < 0 ? `-${text}` : text;
88}
89
90/** A sum in dollars (a float from a service that counts in dollars) as whole micros. */
91export function microsOf(dollars: number): number {
92 return Math.round(dollars * MICROS_PER_DOLLAR);
93}