2026-06-10 20:09:25 -05:00
|
|
|
/**
|
|
|
|
|
* Money utilities — integer-cents core.
|
|
|
|
|
*
|
|
|
|
|
* All arithmetic runs in integer cents so sums and rounding are exact;
|
|
|
|
|
* floats only appear at the edges (DB columns that still store dollar
|
|
|
|
|
* REALs, API payloads, display formatting).
|
|
|
|
|
*
|
|
|
|
|
* Storage units today:
|
|
|
|
|
* - bills / payments / monthly_bill_state / budgets / income: dollars (REAL)
|
|
|
|
|
* - SimpleFIN transactions / financial_accounts: cents (INTEGER)
|
|
|
|
|
*
|
|
|
|
|
* The planned v1.03 migration (see docs/cents-migration-plan.md) converts the
|
|
|
|
|
* dollar columns to integer cents. Until then, services work in dollars at
|
|
|
|
|
* their boundaries and these helpers guarantee cent-exact math internally.
|
2026-07-05 13:44:04 -05:00
|
|
|
*
|
|
|
|
|
* Branded types (mirroring client/lib/money.ts) make the cents↔dollars boundary
|
|
|
|
|
* a compile error for any typed (.ts) caller: a `Cents` value cannot be passed
|
|
|
|
|
* where `Dollars` is expected, and vice-versa. (.js callers are unaffected —
|
|
|
|
|
* the brands are erased at runtime; toCents/fromCents are the only crossings.)
|
2026-06-10 20:09:25 -05:00
|
|
|
*/
|
|
|
|
|
|
2026-07-05 13:44:04 -05:00
|
|
|
/** Integer cents — a raw amount as stored for SimpleFIN transactions/accounts. */
|
|
|
|
|
export type Cents = number & { readonly __brand: 'Cents' };
|
|
|
|
|
/** Dollars — the unit bills/payments/budgets store and the API serialises. */
|
|
|
|
|
export type Dollars = number & { readonly __brand: 'Dollars' };
|
|
|
|
|
|
|
|
|
|
/** Accepted at the dollars→cents boundary: a Dollars/number, a "$1,234.56" string, or empty. */
|
|
|
|
|
export type DollarsInput = Dollars | number | string | null | undefined;
|
|
|
|
|
/** Accepted at the cents→dollars boundary. */
|
|
|
|
|
export type CentsInput = Cents | number | null | undefined;
|
|
|
|
|
|
2026-06-10 20:09:25 -05:00
|
|
|
/**
|
|
|
|
|
* Dollars (number or string like "$1,234.56") → integer cents.
|
|
|
|
|
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
2026-07-02 21:11:12 -05:00
|
|
|
*
|
|
|
|
|
* Rounds off the decimal string (via the shortest round-trip representation for
|
|
|
|
|
* numbers) rather than `Math.round(n * 100)`, whose binary-float error rounds
|
|
|
|
|
* e.g. 1.005 down to 100 instead of 101 (QA-B7-01). Output is identical to the
|
|
|
|
|
* old helper for all integer / ≤2-decimal / "$1,234.56" inputs; only 3+-decimal
|
|
|
|
|
* half-cent values change, and they now round half away from zero.
|
2026-06-10 20:09:25 -05:00
|
|
|
*/
|
2026-07-05 13:44:04 -05:00
|
|
|
export function toCents(dollars: DollarsInput): Cents | null {
|
2026-06-10 20:09:25 -05:00
|
|
|
if (dollars === null || dollars === undefined || dollars === '') return null;
|
2026-07-02 21:11:12 -05:00
|
|
|
const cleaned = typeof dollars === 'string' ? dollars.replace(/[$,\s]/g, '') : dollars;
|
|
|
|
|
const n = Number(cleaned);
|
2026-07-05 13:44:04 -05:00
|
|
|
if (!Number.isFinite(n)) return NaN as Cents;
|
2026-07-02 21:11:12 -05:00
|
|
|
|
|
|
|
|
// The shortest decimal string that round-trips to this float (e.g. 1.005 for
|
|
|
|
|
// the number 1.005, not 1.00499999…). Scientific notation → float fallback.
|
|
|
|
|
const decimal = typeof cleaned === 'string' ? cleaned : n.toString();
|
2026-07-05 13:44:04 -05:00
|
|
|
if (/[eE]/.test(decimal)) return Math.round(n * 100) as Cents;
|
2026-07-02 21:11:12 -05:00
|
|
|
|
|
|
|
|
const negative = decimal.trim().startsWith('-');
|
|
|
|
|
const [intPart, fracPart = ''] = decimal.replace('-', '').split('.');
|
|
|
|
|
const frac3 = (fracPart + '000').slice(0, 3);
|
|
|
|
|
const cents = Number(intPart || '0') * 100 + Number(frac3.slice(0, 2)) + (Number(frac3[2]) >= 5 ? 1 : 0);
|
2026-07-05 13:44:04 -05:00
|
|
|
return (negative ? -cents : cents) as Cents;
|
2026-06-10 20:09:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Integer cents → dollar number (for API payloads). null/undefined → null. */
|
2026-07-05 13:44:04 -05:00
|
|
|
export function fromCents(cents: CentsInput): Dollars | null {
|
2026-06-10 20:09:25 -05:00
|
|
|
if (cents === null || cents === undefined) return null;
|
|
|
|
|
const n = Number(cents);
|
|
|
|
|
if (!Number.isFinite(n)) return null;
|
2026-07-05 13:44:04 -05:00
|
|
|
return (n / 100) as Dollars;
|
2026-06-10 20:09:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Round a dollar amount to the nearest cent, exactly.
|
|
|
|
|
* Drop-in replacement for the old `Math.round(x * 100) / 100` helpers.
|
|
|
|
|
*/
|
2026-07-05 13:44:04 -05:00
|
|
|
export function roundMoney(value: DollarsInput): Dollars {
|
2026-06-10 20:09:25 -05:00
|
|
|
const cents = toCents(value);
|
2026-07-05 13:44:04 -05:00
|
|
|
return (cents === null || Number.isNaN(cents) ? 0 : cents / 100) as Dollars;
|
2026-06-10 20:09:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cent-exact sum of dollar amounts. Replaces float `reduce((s, x) => s + x)`
|
|
|
|
|
* chains, which accumulate binary representation error.
|
|
|
|
|
*
|
|
|
|
|
* sumMoney([0.1, 0.2]) → 0.3 (not 0.30000000000000004)
|
|
|
|
|
* sumMoney(rows, r => r.amount) → picks a field
|
|
|
|
|
*/
|
2026-07-05 13:44:04 -05:00
|
|
|
export function sumMoney<T>(values: Iterable<T>, pick?: (value: T) => DollarsInput): Dollars {
|
2026-06-10 20:09:25 -05:00
|
|
|
let total = 0;
|
|
|
|
|
for (const v of values) {
|
2026-07-05 13:44:04 -05:00
|
|
|
const raw = pick ? pick(v) : (v as unknown as DollarsInput);
|
2026-06-10 20:09:25 -05:00
|
|
|
const cents = toCents(raw);
|
|
|
|
|
if (cents !== null && !Number.isNaN(cents)) total += cents;
|
|
|
|
|
}
|
2026-07-05 13:44:04 -05:00
|
|
|
return (total / 100) as Dollars;
|
2026-06-10 20:09:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Multiply a dollar amount by a factor (interest rate, proration, etc.),
|
|
|
|
|
* rounding the result to the nearest cent.
|
|
|
|
|
*/
|
2026-07-05 13:44:04 -05:00
|
|
|
export function mulMoney(dollars: DollarsInput, factor: number): Dollars {
|
2026-06-10 20:09:25 -05:00
|
|
|
const cents = toCents(dollars);
|
2026-07-05 13:44:04 -05:00
|
|
|
if (cents === null || Number.isNaN(cents) || !Number.isFinite(factor)) return 0 as Dollars;
|
|
|
|
|
return (Math.round(cents * factor) / 100) as Dollars;
|
2026-06-10 20:09:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const _usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
|
|
2026-07-02 20:36:09 -05:00
|
|
|
/** Dollar number → "$1,234.56" (negatives as "-$1,234.56"). null/undefined → "$0.00". */
|
2026-07-05 13:44:04 -05:00
|
|
|
export function formatUSD(dollars: DollarsInput): string {
|
2026-07-02 20:36:09 -05:00
|
|
|
const n = Number(dollars) || 0;
|
|
|
|
|
return (n < 0 ? '-' : '') + '$' + _usd.format(Math.abs(n));
|
2026-06-10 20:09:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Integer cents → "$1,234.56". */
|
2026-07-05 13:44:04 -05:00
|
|
|
export function formatCentsUSD(cents: CentsInput): string {
|
2026-06-10 20:09:25 -05:00
|
|
|
return formatUSD(fromCents(cents) ?? 0);
|
|
|
|
|
}
|