Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/formatamount-integer-guard.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 60 additions & 1 deletion src/money/formatters.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 19 additions & 9 deletions src/money/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

// /**
Expand Down
Loading