diff --git a/.changeset/formatamount-integer-guard.md b/.changeset/formatamount-integer-guard.md new file mode 100644 index 0000000..19e3357 --- /dev/null +++ b/.changeset/formatamount-integer-guard.md @@ -0,0 +1,7 @@ +--- +"@epilot/pricing": patch +--- + +fix: `formatAmount` no longer throws `"You must provide an integer."` on malformed amounts + +`formatAmount` passed its amount straight to dinero, which rejects non-integer/non-finite values. `parseUnknownAmount` now rounds finite non-integers to the nearest minor unit (normalising `-0` to `0`) and falls back to `0` for `NaN`/`±Infinity`, so a bad stored amount degrades to a displayed value instead of crashing the caller. Integer and `NaN` inputs are unchanged. diff --git a/src/money/formatters.test.ts b/src/money/formatters.test.ts index 8603838..4c0b66a 100644 --- a/src/money/formatters.test.ts +++ b/src/money/formatters.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Currency } from '../shared/types'; import { GENERIC_UNIT_DISPLAY_LABEL } from './constants'; import { @@ -13,6 +13,10 @@ import { import { toDinero } from './to-dinero'; describe('formatAmount', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should be resilient by formatting invalid amounts. defaulting to zero and providing a good log msg', () => { const consoleErrorSpy = vi.spyOn(console, 'error'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -113,6 +117,61 @@ describe('formatAmount', () => { formatAmount({ amount: 'invalid_amount' }); }).not.toThrow(); }); + + it.each` + amount | expected + ${100.5} | ${'1,01\xa0€'} + ${100.4} | ${'1,00\xa0€'} + ${-100.5} | ${'-1,00\xa0€'} + ${-0.4} | ${'0,00\xa0€'} + ${Infinity} | ${'0,00\xa0€'} + ${-Infinity} | ${'0,00\xa0€'} + ${NaN} | ${'0,00\xa0€'} + `( + 'should never throw: rounds finite floats and falls back to zero for non-finite amounts ($amount)', + ({ amount, expected }: { amount: number; expected: string }) => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + let formattedAmount = ''; + expect(() => { + formattedAmount = formatAmount({ amount }); + }).not.toThrow(); + + expect(formattedAmount).toEqual(expected); + }, + ); + + it('should round a finite float to the nearest minor unit without logging an error', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(formatAmount({ amount: 100.5 })).toEqual('1,01\xa0€'); + expect(formatAmount({ amount: 100.4 })).toEqual('1,00\xa0€'); + expect(formatAmount({ amount: -100.5 })).toEqual('-1,00\xa0€'); + // Math.round(-0.4) === -0, normalised to +0 so no spurious minus sign is shown + expect(formatAmount({ amount: -0.4 })).toEqual('0,00\xa0€'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should fall back to zero and log an error for a non-finite amount', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(formatAmount({ amount: Infinity })).toEqual('0,00\xa0€'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'formatAmount: expects an integer amount, received this instead "Infinity", fallbacks to zero.', + new Error('NaN error, unable to cast Infinity to number.'), + ); + }); + + it('should round a finite float before the subunit (cents) display path', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + // Math.round(22.6) === 23 -> 0,23 € -> displayed as cents + expect(formatAmount({ amount: 22.6, enableSubunitDisplay: true })).toEqual('23 Cent'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); describe('formatAmountFromString', () => { diff --git a/src/money/formatters.ts b/src/money/formatters.ts index 771404a..54974d1 100644 --- a/src/money/formatters.ts +++ b/src/money/formatters.ts @@ -61,17 +61,27 @@ const parseUnknownAmount = ( ): number => { const integerAmount = Number(amount); - if (isNaN(integerAmount)) { - /* Kept here for backwards compatibility */ - console.error( - `formatAmount: expects an integer amount, received this instead "${amount}", fallbacks to zero.`, - new Error(`NaN error, unable to cast ${amount} to number.`), - ); - - return 0; - } else { + /* Integer amounts pass through unchanged (backwards compatible). */ + if (Number.isInteger(integerAmount)) { return integerAmount; } + + /* + * Round finite non-integers to the nearest minor unit so dinero never throws + * "You must provide an integer.". `|| 0` normalises Math.round's negative zero + * (e.g. -0.4 -> -0) to +0 so a zero amount never renders with a spurious minus sign. + */ + if (Number.isFinite(integerAmount)) { + return Math.round(integerAmount) || 0; + } + + /* NaN / ±Infinity — kept here for backwards compatibility. */ + console.error( + `formatAmount: expects an integer amount, received this instead "${amount}", fallbacks to zero.`, + new Error(`NaN error, unable to cast ${amount} to number.`), + ); + + return 0; }; // /**