|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0144 D5, D7 parameter 3, Open items 2 and 3] The home-amount |
| 5 | + * conversion arithmetic: exact decimal reading, the one division by the rate |
| 6 | + * rounded half away from zero, the canonical spelling, and the document date |
| 7 | + * a rate is picked for. |
| 8 | + * |
| 9 | + * Published here, not kept inside `@objectstack/objectql`, because two readers |
| 10 | + * must give the same answer: the engine's write-time home projection |
| 11 | + * (`@objectstack/objectql`'s `home-amount-projection.ts`, which imports these |
| 12 | + * back) and the form's live "≈ home amount" preview under `ui/`. A second |
| 13 | + * copy in the browser is exactly the drift a JPY (0 digits) or KWD (3 digits) |
| 14 | + * amount would hide. The fraction digits themselves stay in |
| 15 | + * `@objectstack/spec` (`CURRENCY_FRACTION_DIGITS`); this module is the |
| 16 | + * arithmetic only, and has no dependency of its own. |
| 17 | + */ |
| 18 | + |
| 19 | +// ── Exact decimal arithmetic ────────────────────────────────────────────── |
| 20 | + |
| 21 | +/** A decimal as a scaled integer: the value is `digits / 10^scale`. */ |
| 22 | +export interface ScaledDecimal { |
| 23 | + readonly digits: bigint; |
| 24 | + readonly scale: number; |
| 25 | +} |
| 26 | + |
| 27 | +const DECIMAL_SPELLING = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/; |
| 28 | + |
| 29 | +/** |
| 30 | + * The exact decimal a stored or written number spells, or `undefined` when it |
| 31 | + * spells none. A JavaScript number is read through its shortest round-trip |
| 32 | + * spelling (`String(0.1389)` is `'0.1389'`), which is the decimal the writer |
| 33 | + * wrote; a string (a driver answering an exact-decimal column as text) is read |
| 34 | + * as written. Exponent spellings are accepted. |
| 35 | + */ |
| 36 | +export function toScaledDecimal(value: unknown): ScaledDecimal | undefined { |
| 37 | + let text: string; |
| 38 | + if (typeof value === 'number') { |
| 39 | + if (!Number.isFinite(value)) return undefined; |
| 40 | + text = String(value); |
| 41 | + } else if (typeof value === 'bigint') { |
| 42 | + text = value.toString(); |
| 43 | + } else if (typeof value === 'string') { |
| 44 | + text = value.trim(); |
| 45 | + } else { |
| 46 | + return undefined; |
| 47 | + } |
| 48 | + const m = DECIMAL_SPELLING.exec(text); |
| 49 | + if (!m) return undefined; |
| 50 | + const [, sign, int = '', frac = '', exp] = m; |
| 51 | + if (int === '' && frac === '') return undefined; |
| 52 | + let digits = BigInt(`${int}${frac}` || '0'); |
| 53 | + let scale = frac.length - (exp === undefined ? 0 : Number(exp)); |
| 54 | + if (scale < 0) { |
| 55 | + digits *= 10n ** BigInt(-scale); |
| 56 | + scale = 0; |
| 57 | + } |
| 58 | + return { digits: sign === '-' ? -digits : digits, scale }; |
| 59 | +} |
| 60 | + |
| 61 | +const abs = (n: bigint): bigint => (n < 0n ? -n : n); |
| 62 | + |
| 63 | +/** |
| 64 | + * `numerator / denominator`, rounded to an integer half away from zero — |
| 65 | + * the one rounding step of the projection. |
| 66 | + */ |
| 67 | +function divideRoundHalfAwayFromZero(numerator: bigint, denominator: bigint): bigint { |
| 68 | + const negative = (numerator < 0n) !== (denominator < 0n); |
| 69 | + const n = abs(numerator); |
| 70 | + const d = abs(denominator); |
| 71 | + let q = n / d; |
| 72 | + if ((n % d) * 2n >= d) q += 1n; |
| 73 | + return negative ? -q : q; |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * `amount ÷ rate`, rounded once, half away from zero, to `places` decimals — |
| 78 | + * parameter 3 as arithmetic. Both operands are exact; so is every |
| 79 | + * intermediate. |
| 80 | + */ |
| 81 | +export function divideToPlaces(amount: ScaledDecimal, rate: ScaledDecimal, places: number): ScaledDecimal { |
| 82 | + // (a / 10^as) / (r / 10^rs) × 10^places = a × 10^(rs + places) / (r × 10^as) |
| 83 | + const numerator = amount.digits * 10n ** BigInt(rate.scale + places); |
| 84 | + const denominator = rate.digits * 10n ** BigInt(amount.scale); |
| 85 | + return { digits: divideRoundHalfAwayFromZero(numerator, denominator), scale: places }; |
| 86 | +} |
| 87 | + |
| 88 | +/** The canonical decimal spelling of a scaled decimal (`-1234.50` for `{ -123450n, 2 }`). */ |
| 89 | +export function formatScaledDecimal(value: ScaledDecimal): string { |
| 90 | + const text = abs(value.digits).toString().padStart(value.scale + 1, '0'); |
| 91 | + const body = value.scale === 0 ? text : `${text.slice(0, -value.scale)}.${text.slice(-value.scale)}`; |
| 92 | + return value.digits < 0n ? `-${body}` : body; |
| 93 | +} |
| 94 | + |
| 95 | +// ── The document date ───────────────────────────────────────────────────── |
| 96 | + |
| 97 | +const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; |
| 98 | + |
| 99 | +/** |
| 100 | + * The calendar date a document-date value names, as `YYYY-MM-DD`, or |
| 101 | + * `undefined` when it names none. A `date` value is its own calendar date, and |
| 102 | + * so is a bare `YYYY-MM-DD` spelling of any value. An instant (a `datetime` |
| 103 | + * value) is given `timezone`, the organization's `localization.timezone` |
| 104 | + * (IANA), and its calendar date is taken there (ruling 6539345871, letter B): |
| 105 | + * an invoice written at 01:00 in Shanghai is dated that Shanghai day. It is |
| 106 | + * the organization's setting and never the writer's request zone, so every |
| 107 | + * write path (a REST write, an import, a flow) picks the same rate for the |
| 108 | + * same stored instant. No timezone, or one `Intl` does not know, reads UTC, |
| 109 | + * the `localization.timezone` default — the `autonumber-format` reading. |
| 110 | + */ |
| 111 | +export function documentDateOf(value: unknown, timezone?: string): string | undefined { |
| 112 | + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : calendarDayIn(value, timezone); |
| 113 | + if (typeof value !== 'string') return undefined; |
| 114 | + const text = value.trim(); |
| 115 | + if (CALENDAR_DATE.test(text)) return text; |
| 116 | + const ms = Date.parse(text); |
| 117 | + return Number.isNaN(ms) ? undefined : calendarDayIn(new Date(ms), timezone); |
| 118 | +} |
| 119 | + |
| 120 | +/** The `YYYY-MM-DD` calendar day of `instant` in `timezone` (IANA), UTC when absent or unknown. */ |
| 121 | +function calendarDayIn(instant: Date, timezone: string | undefined): string { |
| 122 | + if (timezone && timezone !== 'UTC') { |
| 123 | + try { |
| 124 | + const parts = new Intl.DateTimeFormat('en-CA', { |
| 125 | + timeZone: timezone, |
| 126 | + year: 'numeric', |
| 127 | + month: '2-digit', |
| 128 | + day: '2-digit', |
| 129 | + }).formatToParts(instant); |
| 130 | + const y = parts.find((p) => p.type === 'year')?.value; |
| 131 | + const m = parts.find((p) => p.type === 'month')?.value; |
| 132 | + const d = parts.find((p) => p.type === 'day')?.value; |
| 133 | + if (y && m && d) return `${y}-${m}-${d}`; |
| 134 | + } catch { |
| 135 | + // An unknown zone reads UTC, below. |
| 136 | + } |
| 137 | + } |
| 138 | + return instant.toISOString().slice(0, 10); |
| 139 | +} |
0 commit comments