feat: add exact signed money parsing and formatting

This commit is contained in:
jb
2026-09-18 23:55:32 -04:00
parent 49495af196
commit aea41d8cea
6 changed files with 202 additions and 24 deletions
+3 -5
View File
@@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { AssetOperation } from '$lib/domain/model'; import type { AssetOperation } from '$lib/domain/model';
import { formatIskAmount, formatQuantity } from '$lib/domain/formatting';
let { result, onSync }: { result: AssetOperation; onSync?: () => void } = $props(); let { result, onSync }: { result: AssetOperation; onSync?: () => void } = $props();
const isk = (cents: string) => {
const n = BigInt(cents); return `${(n / 100n).toLocaleString('en-US')}.${(n % 100n).toString().padStart(2, '0')}`;
};
const time = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; const time = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
</script> </script>
{#if result.status === 'ready'} {#if result.status === 'ready'}
<section class="panel" aria-label="Reference valuation"> <section class="panel" aria-label="Reference valuation">
<div class="row spread"><div><small>{result.report.unpricedItems > 0 ? 'PARTIAL REFERENCE ESTIMATE' : 'REFERENCE ESTIMATE'} · ISK</small><div class="stat">{isk(result.report.total)}</div></div><div class="muted">{result.report.items.length} asset stacks · {result.report.characters.length} characters<br />{result.report.unpricedItems} unpriced stacks excluded</div></div> <div class="row spread"><div><small>{result.report.unpricedItems > 0 ? 'PARTIAL REFERENCE ESTIMATE' : 'REFERENCE ESTIMATE'} · ISK</small><div class="stat">{formatIskAmount(result.report.total)}</div></div><div class="muted">{result.report.items.length} asset stacks · {result.report.characters.length} characters<br />{result.report.unpricedItems} unpriced stacks excluded</div></div>
<p class="muted">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.</p> <p class="muted">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.</p>
<small>Prices: {result.report.prices.source}<br />Fetched/revalidated: {time(result.report.prices.asOf)} (not a source price timestamp)<br />Reference artifact: {result.report.referenceVersion}</small> <small>Prices: {result.report.prices.source}<br />Fetched/revalidated: {time(result.report.prices.asOf)} (not a source price timestamp)<br />Reference artifact: {result.report.referenceVersion}</small>
{#each result.report.warnings as warning}<p class="warning">{warning}</p>{/each} {#each result.report.warnings as warning}<p class="warning">{warning}</p>{/each}
</section> </section>
<div class="table-wrap"><table><thead><tr><th>Item</th><th>Character</th><th>Location ID (unresolved)</th><th class="number">Quantity</th><th class="number">Estimated ISK</th></tr></thead><tbody> <div class="table-wrap"><table><thead><tr><th>Item</th><th>Character</th><th>Location ID (unresolved)</th><th class="number">Quantity</th><th class="number">Estimated ISK</th></tr></thead><tbody>
{#each result.report.items as item (`${item.characterId}:${item.itemId}`)}<tr><td>{item.name}{#if item.exclusion}<br /><small>{item.exclusion.replaceAll('_', ' ')}</small>{/if}</td><td>{result.report.characters.find(c => c.id === item.characterId)?.name ?? item.characterId}</td><td>{item.locationId}</td><td class="number">{item.quantity.toLocaleString('en-US')}</td><td class="number">{item.estimatedValue === null ? 'Unpriced' : isk(item.estimatedValue)}</td></tr>{/each} {#each result.report.items as item (`${item.characterId}:${item.itemId}`)}<tr><td>{item.name}{#if item.exclusion}<br /><small>{item.exclusion.replaceAll('_', ' ')}</small>{/if}</td><td>{result.report.characters.find(c => c.id === item.characterId)?.name ?? item.characterId}</td><td>{item.locationId}</td><td class="number">{formatQuantity(item.quantity)}</td><td class="number">{item.estimatedValue === null ? 'Unpriced' : formatIskAmount(item.estimatedValue)}</td></tr>{/each}
</tbody></table></div> </tbody></table></div>
{#if result.report.items.length === 0}<p>No assets in the synchronized snapshots.</p>{/if} {#if result.report.items.length === 0}<p>No assets in the synchronized snapshots.</p>{/if}
{:else if result.status === 'unavailable'} {:else if result.status === 'unavailable'}
+32
View File
@@ -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));
}
+81
View File
@@ -7,6 +7,87 @@
export type IskLike = string | bigint | number; export type IskLike = string | bigint | number;
const INTEGER_TEXT = /^-?\d+$/; 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. */ /** Parse a serialized integer-cent amount. */
export function parseIsk(value: unknown): bigint { export function parseIsk(value: unknown): bigint {
+9 -17
View File
@@ -4,6 +4,7 @@ import type {
MarketEstimates, MarketEstimates,
PriceSnapshot, PriceSnapshot,
} from "$lib/domain/model"; } from "$lib/domain/model";
import { decimalIskToCents } from "../../domain/money";
/** The reviewed compatibility date captured in docs/research/eve.md. */ /** The reviewed compatibility date captured in docs/research/eve.md. */
export const ESI_COMPATIBILITY_DATE = "2026-08-18" as const; export const ESI_COMPATIBILITY_DATE = "2026-08-18" as const;
@@ -834,28 +835,19 @@ export class EsiClient {
} }
function centsFromDecimal(value: string): string { 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"); throw new EsiError("invalid_price", "ESI average_price is not a finite non-negative decimal");
} }
const [coefficient, exponentText] = value.toLowerCase().split("e"); try {
const exponent = exponentText === undefined ? 0 : Number(exponentText); return decimalIskToCents(value);
if (!Number.isSafeInteger(exponent) || exponent < -1_000 || exponent > 1_000) { } 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 exponent is outside bounds");
} }
const [whole, fraction = ""] = coefficient.split("."); throw new EsiError("invalid_price", "ESI average_price is not a finite non-negative decimal");
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();
} }
/** /**
+8
View File
@@ -123,6 +123,14 @@ describe("central ESI client", () => {
}); });
describe("ESI market estimates", () => { 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 () => { test("uses average prices only and returns cents with retrieval provenance", async () => {
const queued = queueFetch([ const queued = queueFetch([
response([ response([
+67
View File
@@ -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();
});
});