| 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. */ |
| 22 | export const MICROS_PER_DOLLAR = 1_000_000; |
| 23 | |
| 24 | export 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 | |
| 31 | const 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. */ |
| 34 | function unitsOf(micros: number, unit: number): number { |
| 35 | return Math.floor((micros + unit / 2) / unit); |
| 36 | } |
| 37 | |
| 38 | /** Thousands separated: `1234567` as `1,234,567`. */ |
| 39 | function 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. */ |
| 44 | function 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`. */ |
| 53 | export 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. */ |
| 73 | export 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 | */ |
| 83 | export 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. */ |
| 91 | export function microsOf(dollars: number): number { |
| 92 | return Math.round(dollars * MICROS_PER_DOLLAR); |
| 93 | } |