diff --git a/.changeset/brave-decimals-clean.md b/.changeset/brave-decimals-clean.md new file mode 100644 index 0000000..b9bf21c --- /dev/null +++ b/.changeset/brave-decimals-clean.md @@ -0,0 +1,5 @@ +--- +'@inflowpayai/inflow': patch +--- + +Normalize balance decimal response strings before rendering CLI output. diff --git a/packages/core/src/resources/balance.ts b/packages/core/src/resources/balance.ts index ca7c532..f1d5eca 100644 --- a/packages/core/src/resources/balance.ts +++ b/packages/core/src/resources/balance.ts @@ -2,6 +2,7 @@ import { type InflowOptions, type ResolvedInflowSdkConfig, resolveInflowSdkConfi import { InflowApiError } from '../errors.js'; import type { Balance } from '../types/index.js'; import { InflowApiClient } from '../utils/api-client.js'; +import { normalizeDecimalString } from '../utils/decimal.js'; import { redactRawBody } from '../utils/redact.js'; import type { IBalanceResource } from './interfaces.js'; @@ -31,6 +32,9 @@ export class BalanceResource implements IBalanceResource { ); } const body = data as BalancesResponse | null; - return body?.balances ?? []; + return (body?.balances ?? []).map((balance) => ({ + ...balance, + available: normalizeDecimalString(balance.available), + })); } } diff --git a/packages/core/src/utils/decimal.ts b/packages/core/src/utils/decimal.ts new file mode 100644 index 0000000..59736b5 --- /dev/null +++ b/packages/core/src/utils/decimal.ts @@ -0,0 +1,24 @@ +const PLAIN_DECIMAL_RE = /^-?\d+(?:\.\d+)?$/u; + +/** + * Normalize server decimal response strings without converting through `Number`, so precision is preserved and + * exponential notation is never introduced. Non-plain forms are returned unchanged. + */ +export function normalizeDecimalString(value: string): string { + if (!PLAIN_DECIMAL_RE.test(value)) return value; + + const negative = value.startsWith('-'); + const unsigned = negative ? value.slice(1) : value; + const [rawInteger = '0', rawFraction] = unsigned.split('.'); + const integer = stripLeadingZeros(rawInteger); + const fraction = rawFraction?.replace(/0+$/u, '') ?? ''; + + if (integer === '0' && fraction.length === 0) return '0'; + const sign = negative ? '-' : ''; + return fraction.length === 0 ? `${sign}${integer}` : `${sign}${integer}.${fraction}`; +} + +function stripLeadingZeros(value: string): string { + const stripped = value.replace(/^0+/u, ''); + return stripped.length === 0 ? '0' : stripped; +} diff --git a/packages/core/test/unit/resources/balance.test.ts b/packages/core/test/unit/resources/balance.test.ts index b717a25..1530465 100644 --- a/packages/core/test/unit/resources/balance.test.ts +++ b/packages/core/test/unit/resources/balance.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; import { InflowApiError } from '../../../src/errors.js'; import { BalanceResource } from '../../../src/resources/balance.js'; import { BASE_URL, balancesHappy, balancesEmpty, balances500 } from '../fixtures/handlers.js'; @@ -24,6 +25,48 @@ describe('BalanceResource', () => { ]); }); + it('normalizes server decimal strings without scientific notation', async () => { + server.use( + http.get(`${BASE_URL}/v1/balances`, () => + HttpResponse.json({ + balances: [ + { available: '100.500000000000000000', currency: 'USDC' }, + { available: '0.000000000000000000', currency: 'USD' }, + { available: '0.000001000000000000', currency: 'PYUSD' }, + ], + }), + ), + ); + const r = new BalanceResource({ + apiBaseUrl: BASE_URL, + accessToken: 'tk', + }); + expect(await r.list()).toEqual([ + { available: '100.5', currency: 'USDC' }, + { available: '0', currency: 'USD' }, + { available: '0.000001', currency: 'PYUSD' }, + ]); + }); + + it('passes an AbortSignal through request options', async () => { + let signalSeen = false; + server.use( + http.get(`${BASE_URL}/v1/balances`, ({ request }) => { + signalSeen = request.signal instanceof AbortSignal; + return HttpResponse.json({ + balances: [{ available: '1.000000000000000000', currency: 'USDC' }], + }); + }), + ); + const r = new BalanceResource({ + apiBaseUrl: BASE_URL, + accessToken: 'tk', + }); + const controller = new AbortController(); + await expect(r.list({ signal: controller.signal })).resolves.toEqual([{ available: '1', currency: 'USDC' }]); + expect(signalSeen).toBe(true); + }); + it('returns [] for an empty server response', async () => { server.use(balancesEmpty); const r = new BalanceResource({ @@ -33,6 +76,15 @@ describe('BalanceResource', () => { expect(await r.list()).toEqual([]); }); + it('returns [] when the server response body is null', async () => { + server.use(http.get(`${BASE_URL}/v1/balances`, () => HttpResponse.json(null))); + const r = new BalanceResource({ + apiBaseUrl: BASE_URL, + accessToken: 'tk', + }); + expect(await r.list()).toEqual([]); + }); + it('throws InflowApiError on 5xx after retries', async () => { server.use(balances500); const r = new BalanceResource({ diff --git a/packages/core/test/unit/utils/decimal.test.ts b/packages/core/test/unit/utils/decimal.test.ts new file mode 100644 index 0000000..1d901f8 --- /dev/null +++ b/packages/core/test/unit/utils/decimal.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeDecimalString } from '../../../src/utils/decimal.js'; + +describe('normalizeDecimalString', () => { + it('strips trailing fractional zeros without precision loss', () => { + expect(normalizeDecimalString('100.500000000000000000')).toBe('100.5'); + expect(normalizeDecimalString('0.010000000000000000')).toBe('0.01'); + expect(normalizeDecimalString('0.000001000000000000')).toBe('0.000001'); + }); + + it('collapses whole-number decimals and negative zero', () => { + expect(normalizeDecimalString('00042')).toBe('42'); + expect(normalizeDecimalString('42.000')).toBe('42'); + expect(normalizeDecimalString('00042.000')).toBe('42'); + expect(normalizeDecimalString('-0.000')).toBe('0'); + }); + + it('preserves the sign on non-zero negative decimals', () => { + expect(normalizeDecimalString('-0012.3400')).toBe('-12.34'); + }); + + it('leaves non-plain decimal strings untouched', () => { + expect(normalizeDecimalString('1e-6')).toBe('1e-6'); + expect(normalizeDecimalString('1,000.00')).toBe('1,000.00'); + }); +});