feat: add exact signed money parsing and formatting
This commit is contained in:
@@ -1,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { AssetOperation } from '$lib/domain/model';
|
||||
import { formatIskAmount, formatQuantity } from '$lib/domain/formatting';
|
||||
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';
|
||||
</script>
|
||||
{#if result.status === 'ready'}
|
||||
<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>
|
||||
<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}
|
||||
</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>
|
||||
{#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>
|
||||
{#if result.report.items.length === 0}<p>No assets in the synchronized snapshots.</p>{/if}
|
||||
{:else if result.status === 'unavailable'}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+11
-19
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user