Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { currencyDigits } from "./money_currency_digits.ts"; ← from money.currency-digits ^1.0.0 · built alongside by fune
import { CURRENCY_DIGITS } from "./money_currency_digits_data.ts"; ← money.currency-digits’s rule data (^1.0.0) · built alongside by fune
import { CURRENCY_SYMBOLS } from "./money_parse_data.ts"; ← this capability’s own data, compiled from data/currency-symbols.json into the same file by fune build
import { type NumberStyle } from "./money_parse_types.ts";
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
// Space, no-break space, narrow no-break space: what real documents use.
const SPACES = [" ", " ", " "];
function styleOf(style: NumberStyle): { groups: string[]; decimal: string } {
switch (style) {
case "comma-dot":
return { groups: [","], decimal: "." };
case "dot-comma":
return { groups: ["."], decimal: "," };
case "space-comma":
return { groups: SPACES, decimal: "," };
case "apostrophe-dot":
return { groups: ["'", "’"], decimal: "." };
default:
throw new RangeError(`unknown number style "${style}"`);
}
}
function isDigits(s: string): boolean {
return /^[0-9]+$/.test(s);
}
/** The longest known symbol or ISO code that `matches`, or null. */
function longestSymbol(matches: (symbol: string) => boolean): string | null {
let best: string | null = null;
const candidates = [...CURRENCY_SYMBOLS.map((r) => r.symbol), ...CURRENCY_DIGITS.map((r) => r.code)];
for (const symbol of candidates) {
if (matches(symbol) && (best === null || symbol.length > best.length)) best = symbol;
}
return best;
}
/** Whole digits and fraction digits, or null when the text is not a number in this style. */
function splitNumber(s: string, groups: string[], decimal: string): [string, string] | null {
const parts = s.split(decimal);
if (parts.length > 2) return null;
const fraction = parts.length === 2 ? parts[1] : "";
if (parts.length === 2 && !isDigits(fraction)) return null;
const chunks: string[] = [""];
for (const ch of parts[0]) {
if (groups.includes(ch)) chunks.push("");
else chunks[chunks.length - 1] += ch;
}
if (!chunks.every(isDigits)) return null;
if (chunks.length > 1) {
// Real thousands grouping only: 1-3 digits without a leading zero, then threes.
const [first, ...rest] = chunks;
if (first.length > 3 || first.startsWith("0")) return null;
if (!rest.every((c) => c.length === 3)) return null;
}
return [chunks.join(""), fraction];
}
/**
* Parse an amount written as text into integer minor units.
*
* Strict by design: the caller names the currency and the number style, and
* anything ambiguous (too many decimals, odd grouping, a symbol for another
* currency) is an error rather than a guess.
*/
export function parseMoney(text: string, currency: string, style: NumberStyle): Money {
const { groups, decimal } = styleOf(style);
const digits = currencyDigits(currency);
const invalid = () => new RangeError(`"${text}" is not a valid amount`);
let s = text.replace(/^ +| +$/g, "");
let negative = false;
if (s.startsWith("-")) {
negative = true;
s = s.slice(1);
}
let symbol = longestSymbol((k) => s.startsWith(k));
if (symbol !== null) {
s = s.slice(symbol.length);
if (SPACES.includes(s.charAt(0))) s = s.slice(1);
if (!negative && s.startsWith("-")) {
negative = true;
s = s.slice(1);
}
} else {
symbol = longestSymbol((k) => s.endsWith(k));
if (symbol !== null) {
s = s.slice(0, s.length - symbol.length);
if (SPACES.includes(s.charAt(s.length - 1))) s = s.slice(0, -1);
}
}
if (symbol !== null && symbol !== currency && !CURRENCY_SYMBOLS.some((r) => r.code === currency && r.symbol === symbol)) {
throw new RangeError(`currency symbol "${symbol}" does not match ${currency}`);
}
const number = splitNumber(s, groups, decimal);
if (number === null) throw invalid();
const [whole, fraction] = number;
if (fraction.length > digits) {
throw new RangeError(`too many decimal places for ${currency} (at most ${digits})`);
}
const all = (whole + fraction.padEnd(digits, "0")).replace(/^0+/, "");
const minor = BigInt(all === "" ? "0" : all);
if (minor > MAX_SAFE) {
throw new RangeError(`amount is too large: "${text}" exceeds 2^53 - 1 minor units`);
}
const value = Number(minor);
return money(negative && value !== 0 ? -value : value, currency);
}