Functional Weave
Code in Python

money.parse@1.0.0

impl/typescript.ts

4,273 bytes · the TypeScript implementation · view raw

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);
}