feat(ts): branded Cents/Dollars money types — first TS conversion (T2)
Converted client/lib/money.js -> money.ts with branded types:
type Cents = number & { __unit: 'cents' }
type Dollars = number & { __unit: 'dollars' }
plus asCents/asDollars/centsToDollars/dollarsToCents. The formatters now require
the correct branded unit (a bare number won't do), so a typed caller physically
cannot format cents as dollars (the 100×-too-big bug) or vice-versa. Existing
.jsx callers are unaffected (checkJs off) — gradual adoption.
money.type-test.ts is a compile-time guard (never imported/bundled): its
@ts-expect-error lines assert each unit mixup is a real error, so typecheck
fails loudly if the branding ever regresses. Verified: removing a guard makes
tsc error 'Argument of type 1234 is not assignable to DollarsInput'.
typecheck + build (with React Compiler) + 48 client tests all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
907a407399
commit
255036afc2
|
|
@ -1,77 +0,0 @@
|
||||||
// Currency formatting for the client, mirroring the server's utils/money.js.
|
|
||||||
//
|
|
||||||
// The API sends money in two units: bill / summary values are serialized as
|
|
||||||
// DOLLARS (the server calls fromCents before responding), while raw bank
|
|
||||||
// transaction amounts arrive as integer CENTS. So there are two entry points —
|
|
||||||
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
|
|
||||||
// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only,
|
|
||||||
// and this matches the server). Inputs are coerced defensively so null, '',
|
|
||||||
// undefined, or NaN never render as "$NaN".
|
|
||||||
|
|
||||||
const DASH = '—';
|
|
||||||
|
|
||||||
function toNumber(value) {
|
|
||||||
const n = Number(value);
|
|
||||||
if (!Number.isFinite(n)) return 0;
|
|
||||||
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlank(value) {
|
|
||||||
return value === null || value === undefined || value === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a DOLLAR amount → "$1,234.56".
|
|
||||||
* @param {number|string|null} dollars
|
|
||||||
* @param {{ whole?: boolean, dash?: boolean }} [opts]
|
|
||||||
* whole — drop the cents ("$1,235"); dash — render blank input as "—" not "$0.00".
|
|
||||||
*/
|
|
||||||
export function formatUSD(dollars, { whole = false, dash = false } = {}) {
|
|
||||||
if (dash && isBlank(dollars)) return DASH;
|
|
||||||
return toNumber(dollars).toLocaleString('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
minimumFractionDigits: whole ? 0 : 2,
|
|
||||||
maximumFractionDigits: whole ? 0 : 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whole-dollar convenience → "$1,235". */
|
|
||||||
export function formatUSDWhole(dollars, opts = {}) {
|
|
||||||
return formatUSD(dollars, { ...opts, whole: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34".
|
|
||||||
* @param {number|string|null} cents
|
|
||||||
* @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts]
|
|
||||||
* signed — prefix "+"/"-" (income vs expense); dash — blank input → "—";
|
|
||||||
* currency — ISO code (defaults USD).
|
|
||||||
*/
|
|
||||||
export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) {
|
|
||||||
if (dash && isBlank(cents)) return DASH;
|
|
||||||
const c = toNumber(cents);
|
|
||||||
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency || 'USD',
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
if (signed) return (c < 0 ? '-' : '+') + body;
|
|
||||||
return (c < 0 ? '-' : '') + body;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared form validator for a non-negative money field (dollars). Blank is
|
|
||||||
* allowed (returns ''); otherwise the value must parse to a number ≥ 0. Returns
|
|
||||||
* '' when valid, or an error string labelled for the field. Zero is allowed —
|
|
||||||
* these are non-negative, not strictly-positive, amounts.
|
|
||||||
* @param {number|string|null} val
|
|
||||||
* @param {string} [label] — field name for the message (e.g. "Amount", "Balance")
|
|
||||||
*/
|
|
||||||
export function validateNonNegativeMoney(val, label = 'Amount') {
|
|
||||||
if (val === '' || val === null || val === undefined) return '';
|
|
||||||
const num = parseFloat(val);
|
|
||||||
if (isNaN(num) || num < 0) return `${label} must be a non-negative number`;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Currency formatting for the client, mirroring the server's utils/money.js.
|
||||||
|
//
|
||||||
|
// The API sends money in two units: bill / summary values are serialized as
|
||||||
|
// DOLLARS (the server calls fromCents before responding), while raw bank
|
||||||
|
// transaction amounts arrive as integer CENTS. So there are two entry points —
|
||||||
|
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
|
||||||
|
// formatUSD / formatCentsUSD split. USD / en-US throughout. Inputs are coerced
|
||||||
|
// defensively so null, '', undefined, or NaN never render as "$NaN".
|
||||||
|
//
|
||||||
|
// The two units are *branded* below: a Dollars value can't be passed where Cents
|
||||||
|
// is expected (or vice-versa) without an explicit conversion, so the cents↔dollars
|
||||||
|
// mixups that have caused real money bugs (e.g. displaying cents as dollars →
|
||||||
|
// 100× wrong) become compile errors in typed code.
|
||||||
|
|
||||||
|
/** Integer cents, e.g. a raw bank transaction amount (1234 = $12.34). */
|
||||||
|
export type Cents = number & { readonly __unit: 'cents' };
|
||||||
|
/** Dollars, e.g. an API-serialized bill amount (12.34 = $12.34). */
|
||||||
|
export type Dollars = number & { readonly __unit: 'dollars' };
|
||||||
|
|
||||||
|
/** Brand a raw number as cents (the sanctioned way to enter the typed world). */
|
||||||
|
export const asCents = (n: number): Cents => n as Cents;
|
||||||
|
/** Brand a raw number as dollars. */
|
||||||
|
export const asDollars = (n: number): Dollars => n as Dollars;
|
||||||
|
/** Convert cents → dollars (the only way to cross the unit boundary). */
|
||||||
|
export const centsToDollars = (c: Cents): Dollars => (c / 100) as Dollars;
|
||||||
|
/** Convert dollars → cents, rounding to the nearest cent. */
|
||||||
|
export const dollarsToCents = (d: Dollars): Cents => Math.round(d * 100) as Cents;
|
||||||
|
|
||||||
|
// A money value as it may still arrive untyped from a form field or legacy code:
|
||||||
|
// the branded unit, or a numeric string, or blank. Note bare `number` is NOT
|
||||||
|
// included — that's deliberate, so typed callers must brand (asDollars/asCents)
|
||||||
|
// and can't accidentally hand cents to a dollars formatter.
|
||||||
|
type DollarsInput = Dollars | string | null | undefined;
|
||||||
|
type CentsInput = Cents | string | null | undefined;
|
||||||
|
|
||||||
|
const DASH = '—';
|
||||||
|
|
||||||
|
function toNumber(value: unknown): number {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlank(value: unknown): value is null | undefined | '' {
|
||||||
|
return value === null || value === undefined || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a DOLLAR amount → "$1,234.56".
|
||||||
|
* `whole` drops the cents ("$1,235"); `dash` renders blank input as "—".
|
||||||
|
*/
|
||||||
|
export function formatUSD(
|
||||||
|
dollars: DollarsInput,
|
||||||
|
{ whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
if (dash && isBlank(dollars)) return DASH;
|
||||||
|
return toNumber(dollars).toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: whole ? 0 : 2,
|
||||||
|
maximumFractionDigits: whole ? 0 : 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-dollar convenience → "$1,235". */
|
||||||
|
export function formatUSDWhole(
|
||||||
|
dollars: DollarsInput,
|
||||||
|
opts: { dash?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
return formatUSD(dollars, { ...opts, whole: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34".
|
||||||
|
* `signed` prefixes "+"/"-" (income vs expense); `dash` renders blank → "—";
|
||||||
|
* `currency` is an ISO code (defaults USD).
|
||||||
|
*/
|
||||||
|
export function formatCentsUSD(
|
||||||
|
cents: CentsInput,
|
||||||
|
{ signed = false, dash = false, currency = 'USD' }: { signed?: boolean; dash?: boolean; currency?: string } = {},
|
||||||
|
): string {
|
||||||
|
if (dash && isBlank(cents)) return DASH;
|
||||||
|
const c = toNumber(cents);
|
||||||
|
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
if (signed) return (c < 0 ? '-' : '+') + body;
|
||||||
|
return (c < 0 ? '-' : '') + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared form validator for a non-negative money field (dollars). Blank is
|
||||||
|
* allowed (returns ''); otherwise the value must parse to a number ≥ 0. Returns
|
||||||
|
* '' when valid, or an error string labelled for the field. Zero is allowed —
|
||||||
|
* these are non-negative, not strictly-positive, amounts.
|
||||||
|
*/
|
||||||
|
export function validateNonNegativeMoney(
|
||||||
|
val: string | number | null | undefined,
|
||||||
|
label = 'Amount',
|
||||||
|
): string {
|
||||||
|
if (val === '' || val === null || val === undefined) return '';
|
||||||
|
const num = parseFloat(String(val));
|
||||||
|
if (isNaN(num) || num < 0) return `${label} must be a non-negative number`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Compile-time guard for the branded money types. Not imported anywhere (so it
|
||||||
|
// never ships in a bundle); `npm run typecheck` type-checks it via the tsconfig
|
||||||
|
// include, and each `@ts-expect-error` asserts the following line IS a genuine
|
||||||
|
// type error. If the cents/dollars branding ever regresses, one of these lines
|
||||||
|
// stops erroring and typecheck fails loudly ("unused @ts-expect-error").
|
||||||
|
import { formatUSD, formatCentsUSD, asCents, asDollars, centsToDollars } from './money';
|
||||||
|
|
||||||
|
const cents = asCents(1234); // $12.34 as integer cents
|
||||||
|
const dollars = asDollars(12.34); // $12.34 as dollars
|
||||||
|
|
||||||
|
// ✅ Correct unit → compiles fine.
|
||||||
|
formatUSD(dollars);
|
||||||
|
formatCentsUSD(cents);
|
||||||
|
formatUSD(centsToDollars(cents)); // cross the boundary explicitly
|
||||||
|
|
||||||
|
// ❌ Unit mixups → compile errors (the class of bug we keep fixing).
|
||||||
|
// @ts-expect-error cents can't be formatted as dollars (would render 100× too big)
|
||||||
|
formatUSD(cents);
|
||||||
|
// @ts-expect-error dollars can't be formatted as cents
|
||||||
|
formatCentsUSD(dollars);
|
||||||
|
// @ts-expect-error a raw number must be branded (asDollars/asCents) first
|
||||||
|
formatUSD(1234);
|
||||||
Loading…
Reference in New Issue