From aea41d8cea8317a1710413e8d7213d8b540d7528 Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 18 Sep 2026 23:51:57 -0400 Subject: [PATCH] feat: add exact signed money parsing and formatting --- src/lib/components/AssetResult.svelte | 8 +-- src/lib/domain/formatting.ts | 32 +++++++++++ src/lib/domain/money.ts | 81 +++++++++++++++++++++++++++ src/lib/server/esi/index.ts | 30 ++++------ tests/esi.test.ts | 8 +++ tests/money-formatting.test.ts | 67 ++++++++++++++++++++++ 6 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 src/lib/domain/formatting.ts create mode 100644 tests/money-formatting.test.ts diff --git a/src/lib/components/AssetResult.svelte b/src/lib/components/AssetResult.svelte index cf642cd..5c55f17 100644 --- a/src/lib/components/AssetResult.svelte +++ b/src/lib/components/AssetResult.svelte @@ -1,20 +1,18 @@ {#if result.status === 'ready'}
-
{result.report.unpricedItems > 0 ? 'PARTIAL REFERENCE ESTIMATE' : 'REFERENCE ESTIMATE'} · ISK
{isk(result.report.total)}
{result.report.items.length} asset stacks · {result.report.characters.length} characters
{result.report.unpricedItems} unpriced stacks excluded
+
{result.report.unpricedItems > 0 ? 'PARTIAL REFERENCE ESTIMATE' : 'REFERENCE ESTIMATE'} · ISK
{formatIskAmount(result.report.total)}
{result.report.items.length} asset stacks · {result.report.characters.length} characters
{result.report.unpricedItems} unpriced stacks excluded

Not immediate liquidation, a Jita quote, or a replacement-cost estimate. No order depth, fees, taxes or hauling costs are included. Unit prices are rounded to 0.01 ISK before multiplication.

Prices: {result.report.prices.source}
Fetched/revalidated: {time(result.report.prices.asOf)} (not a source price timestamp)
Reference artifact: {result.report.referenceVersion}
{#each result.report.warnings as warning}

{warning}

{/each}
- {#each result.report.items as item (`${item.characterId}:${item.itemId}`)}{/each} + {#each result.report.items as item (`${item.characterId}:${item.itemId}`)}{/each}
ItemCharacterLocation ID (unresolved)QuantityEstimated ISK
{item.name}{#if item.exclusion}
{item.exclusion.replaceAll('_', ' ')}{/if}
{result.report.characters.find(c => c.id === item.characterId)?.name ?? item.characterId}{item.locationId}{item.quantity.toLocaleString('en-US')}{item.estimatedValue === null ? 'Unpriced' : isk(item.estimatedValue)}
{item.name}{#if item.exclusion}
{item.exclusion.replaceAll('_', ' ')}{/if}
{result.report.characters.find(c => c.id === item.characterId)?.name ?? item.characterId}{item.locationId}{formatQuantity(item.quantity)}{item.estimatedValue === null ? 'Unpriced' : formatIskAmount(item.estimatedValue)}
{#if result.report.items.length === 0}

No assets in the synchronized snapshots.

{/if} {:else if result.status === 'unavailable'} diff --git a/src/lib/domain/formatting.ts b/src/lib/domain/formatting.ts new file mode 100644 index 0000000..88fd017 --- /dev/null +++ b/src/lib/domain/formatting.ts @@ -0,0 +1,32 @@ +import { parseIsk, validateQuantity } from './money'; + +/** Group a non-negative decimal integer without converting it to Number. */ +function groupInteger(value: bigint): string { + const digits = value.toString(10); + let grouped = ''; + for (let index = 0; index < digits.length; index += 1) { + if (index > 0 && (digits.length - index) % 3 === 0) grouped += ','; + grouped += digits[index]; + } + return grouped; +} + +/** + * Format serialized integer hundredths of an ISK for user-facing output. + * + * Amounts are parsed as bigint and negative values are formatted from their + * absolute magnitude, so -1 cent is rendered as -0.01 rather than -0.-1. + */ +export function formatIskAmount(value: string | bigint): string { + const cents = typeof value === 'bigint' ? value : parseIsk(value); + const negative = cents < 0n; + const absolute = negative ? -cents : cents; + const whole = absolute / 100n; + const fraction = (absolute % 100n).toString(10).padStart(2, '0'); + return `${negative ? '-' : ''}${groupInteger(whole)}.${fraction}`; +} + +/** Format an inventory quantity after applying the domain's quantity policy. */ +export function formatQuantity(value: number | bigint): string { + return groupInteger(validateQuantity(value)); +} diff --git a/src/lib/domain/money.ts b/src/lib/domain/money.ts index 45303b8..a96087b 100644 --- a/src/lib/domain/money.ts +++ b/src/lib/domain/money.ts @@ -7,6 +7,87 @@ export type IskLike = string | bigint | number; const INTEGER_TEXT = /^-?\d+$/; +// The final lookahead makes the end assertion strict: JavaScript's `$` alone +// would also accept a match immediately before one trailing newline. +const DECIMAL_TEXT = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$(?![\s\S])/; + +/** Resource bounds for signed decimal ingestion. These reject excess input; no truncation is performed. */ +export const MAX_DECIMAL_INPUT_LENGTH = 8_192; +export const MAX_DECIMAL_COEFFICIENT_DIGITS = 4_096; +export const MAX_DECIMAL_EXPONENT = 1_000n; +export const MAX_DECIMAL_SHIFT = 1_000n; + +/** Parse a bounded exponent without constructing a large bigint from hostile text. */ +function parseDecimalExponent(value: string): bigint { + let sign = 1n; + let digits = value; + if (digits.startsWith('+') || digits.startsWith('-')) { + if (digits.startsWith('-')) sign = -1n; + digits = digits.slice(1); + } + + const normalized = digits.replace(/^0+/, '') || '0'; + if (normalized.length > 4 || (normalized.length === 4 && normalized > '1000')) { + throw new RangeError('ISK decimal exponent is outside bounds'); + } + return sign * BigInt(normalized); +} + +/** + * Convert signed decimal ISK text to integer hundredths without Number + * coercion. The non-negative subset matches the inherited ESI grammar and + * adds one leading minus sign for balances/debits. Values with more than two + * fractional digits use half-away-from-zero rounding; negative zero + * canonicalizes to `0`. + * + * Exponent, derived decimal shift, coefficient digit count, and raw input + * length are bounded explicitly. Resource-excess inputs are rejected rather + * than truncated; these bounds are the intentional compatibility difference + * for oversized source spellings. ESI market adapters separately reject + * every negative spelling. + */ +export function decimalIskToCents(value: string): string { + if (typeof value !== 'string') { + throw new TypeError('ISK decimals must be signed finite decimal text'); + } + if (value.length > MAX_DECIMAL_INPUT_LENGTH) { + throw new RangeError('ISK decimal input exceeds resource bounds'); + } + if (!DECIMAL_TEXT.test(value)) { + throw new TypeError('ISK decimals must be signed finite decimal text'); + } + + const exponentIndex = value.search(/[eE]/); + const coefficient = exponentIndex < 0 ? value : value.slice(0, exponentIndex); + const exponentText = exponentIndex < 0 ? undefined : value.slice(exponentIndex + 1); + const negative = coefficient.startsWith('-'); + const unsignedCoefficient = negative ? coefficient.slice(1) : coefficient; + const [whole, fraction = ''] = unsignedCoefficient.split('.'); + if (whole.length + fraction.length > MAX_DECIMAL_COEFFICIENT_DIGITS) { + throw new RangeError('ISK decimal coefficient exceeds resource bounds'); + } + + const exponent = exponentText === undefined ? 0n : parseDecimalExponent(exponentText); + const decimalPlaces = BigInt(fraction.length) - exponent; + if (decimalPlaces < -MAX_DECIMAL_SHIFT || decimalPlaces > MAX_DECIMAL_SHIFT) { + throw new RangeError('ISK decimal shift exceeds resource bounds'); + } + + const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, ''); + const significand = BigInt(digits); + let magnitude: bigint; + if (decimalPlaces <= 2n) { + magnitude = significand * 10n ** (2n - decimalPlaces); + } else { + const divisor = 10n ** (decimalPlaces - 2n); + const quotient = significand / divisor; + const remainder = significand % divisor; + // Half-away-from-zero is half-up for this non-negative magnitude. + magnitude = quotient + (remainder * 2n >= divisor ? 1n : 0n); + } + + return negative && magnitude !== 0n ? `-${magnitude.toString(10)}` : magnitude.toString(10); +} /** Parse a serialized integer-cent amount. */ export function parseIsk(value: unknown): bigint { diff --git a/src/lib/server/esi/index.ts b/src/lib/server/esi/index.ts index 8cdc852..d8825c9 100644 --- a/src/lib/server/esi/index.ts +++ b/src/lib/server/esi/index.ts @@ -4,6 +4,7 @@ import type { MarketEstimates, PriceSnapshot, } from "$lib/domain/model"; +import { decimalIskToCents } from "../../domain/money"; /** The reviewed compatibility date captured in docs/research/eve.md. */ export const ESI_COMPATIBILITY_DATE = "2026-08-18" as const; @@ -834,28 +835,19 @@ export class EsiClient { } function centsFromDecimal(value: string): string { - if (!/^(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) { + // Market estimates retain the inherited unsigned ESI policy even though + // the shared money parser also supports signed balances and debits. + if (value.startsWith("-")) { throw new EsiError("invalid_price", "ESI average_price is not a finite non-negative decimal"); } - const [coefficient, exponentText] = value.toLowerCase().split("e"); - const exponent = exponentText === undefined ? 0 : Number(exponentText); - if (!Number.isSafeInteger(exponent) || exponent < -1_000 || exponent > 1_000) { - throw new EsiError("invalid_price", "ESI average_price exponent is outside bounds"); + try { + return decimalIskToCents(value); + } catch (error) { + if (error instanceof RangeError && error.message === "ISK decimal exponent is outside bounds") { + throw new EsiError("invalid_price", "ESI average_price exponent is outside bounds"); + } + throw new EsiError("invalid_price", "ESI average_price is not a finite non-negative decimal"); } - const [whole, fraction = ""] = coefficient.split("."); - const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, ""); - const decimalPlaces = fraction.length - exponent; - let cents: bigint; - if (decimalPlaces <= 2) { - cents = BigInt(digits) * 10n ** BigInt(2 - decimalPlaces); - } else { - const shift = decimalPlaces - 2; - const divisor = 10n ** BigInt(shift); - const numerator = BigInt(digits); - cents = numerator / divisor; - if (numerator % divisor * 2n >= divisor) cents += 1n; - } - return cents.toString(); } /** diff --git a/tests/esi.test.ts b/tests/esi.test.ts index 9f839fb..43baf74 100644 --- a/tests/esi.test.ts +++ b/tests/esi.test.ts @@ -123,6 +123,14 @@ describe("central ESI client", () => { }); describe("ESI market estimates", () => { + test("keeps the inherited non-negative market-price policy", async () => { + const queued = queueFetch([ + response([{ type_id: 34, average_price: -1 }], { ETag: '"negative"', "Cache-Control": "max-age=60" }), + ]); + const client = new EsiClient({ userAgent: "test", fetch: queued.fetch, now: () => 1234 }); + await expect(new EsiMarketEstimates(client).estimates([34])).rejects.toMatchObject({ code: "invalid_price" }); + }); + test("uses average prices only and returns cents with retrieval provenance", async () => { const queued = queueFetch([ response([ diff --git a/tests/money-formatting.test.ts b/tests/money-formatting.test.ts new file mode 100644 index 0000000..a197785 --- /dev/null +++ b/tests/money-formatting.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { + decimalIskToCents, + validateQuantity, +} from '../src/lib/domain/money'; +import { formatIskAmount, formatQuantity } from '../src/lib/domain/formatting'; + +describe('exact decimal ISK conversion', () => { + test('preserves the existing unsigned ESI syntax and positive half-up rounding', () => { + expect(decimalIskToCents('0')).toBe('0'); + expect(decimalIskToCents('12.34')).toBe('1234'); + expect(decimalIskToCents('12.345')).toBe('1235'); + expect(decimalIskToCents('12.344')).toBe('1234'); + expect(decimalIskToCents('1e2')).toBe('10000'); + expect(decimalIskToCents('1.2e-1')).toBe('12'); + expect(decimalIskToCents('0.000e1000')).toBe('0'); + }); + + test('rounds signed values half away from zero and normalizes negative zero', () => { + expect(decimalIskToCents('1.005')).toBe('101'); + expect(decimalIskToCents('-1.005')).toBe('-101'); + expect(decimalIskToCents('1.004')).toBe('100'); + expect(decimalIskToCents('-1.004')).toBe('-100'); + expect(decimalIskToCents('-0')).toBe('0'); + expect(decimalIskToCents('-0.004')).toBe('0'); + expect(decimalIskToCents('-0.005')).toBe('-1'); + }); + + test('rejects malformed syntax, strict trailing-newline bypasses, and plus signs', () => { + for (const value of ['+1', '01', '1.', '.1', 'NaN', 'Infinity', '1_000', ' 1', '1 ', '١', '1\n', '1\r', '1e2\n']) { + expect(() => decimalIskToCents(value)).toThrow(); + } + }); + + test('enforces exponent, shift, coefficient, and raw-input limits without truncation', () => { + expect(decimalIskToCents(`1e${'0'.repeat(8190)}`)).toBe('100'); + expect(() => decimalIskToCents(`1e${'0'.repeat(8191)}`)).toThrow('input exceeds resource bounds'); + expect(() => decimalIskToCents('1e1001')).toThrow('exponent is outside bounds'); + expect(() => decimalIskToCents('1e-1001')).toThrow('exponent is outside bounds'); + expect(decimalIskToCents('1e1000')).toBe(`1${'0'.repeat(1002)}`); + expect(decimalIskToCents(`0.${'0'.repeat(999)}1`)).toBe('0'); + expect(() => decimalIskToCents(`0.${'0'.repeat(1000)}1`)).toThrow('shift exceeds resource bounds'); + expect(decimalIskToCents('1'.repeat(4096))).toBe(`${BigInt('1'.repeat(4096)) * 100n}`); + expect(() => decimalIskToCents('1'.repeat(4097))).toThrow('coefficient exceeds resource bounds'); + }); +}); + +describe('shared exact money formatting', () => { + test('formats signed cents with correct fractional sign and grouping', () => { + expect(formatIskAmount('0')).toBe('0.00'); + expect(formatIskAmount('123456789')).toBe('1,234,567.89'); + expect(formatIskAmount('-1')).toBe('-0.01'); + expect(formatIskAmount('-99')).toBe('-0.99'); + expect(formatIskAmount('-100')).toBe('-1.00'); + expect(formatIskAmount('-101')).toBe('-1.01'); + expect(formatIskAmount('-123456789')).toBe('-1,234,567.89'); + expect(formatIskAmount(-1n)).toBe('-0.01'); + }); + + test('formats quantities without coercing bigint or changing quantity policy', () => { + const large = 2n ** 100n; + expect(validateQuantity(Number.MAX_SAFE_INTEGER)).toBe(BigInt(Number.MAX_SAFE_INTEGER)); + expect(validateQuantity(large)).toBe(large); + expect(formatQuantity(large)).toBe('1,267,650,600,228,229,401,496,703,205,376'); + expect(() => formatQuantity(-1)).toThrow(); + }); +});