money.parse
Parse "£1,234.50" or "1.234,50 €" into integer minor units, strictly: no guessing, no rounding.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 37 tests, run in TypeScript, Python and Rust.
What it does
Turns text such as `"£1,234.50"`, `"1.234,50 €"`, `"CHF 1'234.50"` or `"1 234,56 kr"` into a `Money` in integer minor units, and refuses anything it would have to guess at.
**The caller says the currency and the number style.** `"1.234"` is one thousand two hundred and thirty-four in Germany and one point two three four in the UK, and no amount of cleverness can tell which from the text alone. So:
For example
parse_money(£1,234.50, GBP, comma-dot)→ £1,234.50 pounds with a thousands commaparse_money(1.234,50 €, EUR, dot-comma)→ €1,234.50 euros written the German way, symbol afterparse_money(€1,234.50, EUR, comma-dot)→ €1,234.50 euros written the Irish way
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn parse_money(text: &str, currency: &str, style: &str) -> Money
| text | string | what the person typed or the document says, e.g. "£1,234.50" |
| currency | string | the currency the amount must be in; a symbol in the text has to agree |
| style | NumberStyle | which characters group thousands and mark the decimal |
| returns | Money |
The type it declares, generated into your project
// NumberStyle is a string in Rust, one of: "comma-dot", "dot-comma", "space-comma", "apostrophe-dot".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(money.parse@^1); // then call parse_money(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_amount::{money, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_currency_digits::currency_digits; ← from money.currency-digits ^1.0.0 · built alongside by fune
use super::money_currency_digits_data::CURRENCY_DIGITS; ← money.currency-digits’s rule data (^1.0.0) · built alongside by fune
use super::money_parse_data::CURRENCY_SYMBOLS; ← this capability’s own data, compiled from data/currency-symbols.json into the same file by fune build
const MAX_SAFE: u128 = 9_007_199_254_740_991;
// Space, no-break space, narrow no-break space: what real documents use.
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];
fn style_of(style: &str) -> (&'static [char], char) {
match style {
"comma-dot" => (&[','], '.'),
"dot-comma" => (&['.'], ','),
"space-comma" => (SPACES, ','),
"apostrophe-dot" => (&['\'', '\u{2019}'], '.'),
other => panic!("unknown number style \"{}\"", other),
}
}
fn is_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// The longest known symbol or ISO code that `matches`, or None.
fn longest_symbol(matches: impl Fn(&str) -> bool) -> Option<&'static str> {
let mut best: Option<&'static str> = None;
let candidates = CURRENCY_SYMBOLS
.iter()
.map(|r| r.symbol)
.chain(CURRENCY_DIGITS.iter().map(|r| r.code));
for symbol in candidates {
// Compare in characters, as TypeScript and Python do.
let longer = match best {
None => true,
Some(b) => symbol.chars().count() > b.chars().count(),
};
if matches(symbol) && longer {
best = Some(symbol);
}
}
best
}
/// Whole digits and fraction digits, or None when the text is not a number in this style.
fn split_number(s: &str, groups: &[char], decimal: char) -> Option<(String, String)> {
let parts: Vec<&str> = s.split(decimal).collect();
if parts.len() > 2 {
return None;
}
let fraction = if parts.len() == 2 { parts[1] } else { "" };
if parts.len() == 2 && !is_digits(fraction) {
return None;
}
let chunks: Vec<&str> = parts[0].split(|c: char| groups.contains(&c)).collect();
if !chunks.iter().all(|c| is_digits(c)) {
return None;
}
if chunks.len() > 1 {
// Real thousands grouping only: 1-3 digits without a leading zero, then threes.
let first = chunks[0];
if first.len() > 3 || first.starts_with('0') {
return None;
}
if !chunks[1..].iter().all(|c| c.len() == 3) {
return None;
}
}
Some((chunks.concat(), fraction.to_string()))
}
/// 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.
///
/// # Panics
/// Panics on an unknown style or currency, a symbol for another currency, text
/// that is not an amount in the style, too many decimal places, or an amount
/// beyond 2^53 - 1 minor units.
pub fn parse_money(text: &str, currency: &str, style: &str) -> Money {
let (groups, decimal) = style_of(style);
let digits = currency_digits(currency) as usize;
let mut s: &str = text.trim_matches(' ');
let mut negative = false;
if let Some(rest) = s.strip_prefix('-') {
negative = true;
s = rest;
}
let mut symbol = longest_symbol(|k| s.starts_with(k));
if let Some(found) = symbol {
s = &s[found.len()..];
if let Some(rest) = s.strip_prefix(SPACES) {
s = rest;
}
if !negative {
if let Some(rest) = s.strip_prefix('-') {
negative = true;
s = rest;
}
}
} else {
symbol = longest_symbol(|k| s.ends_with(k));
if let Some(found) = symbol {
s = &s[..s.len() - found.len()];
if let Some(rest) = s.strip_suffix(SPACES) {
s = rest;
}
}
}
if let Some(found) = symbol {
if found != currency
&& !CURRENCY_SYMBOLS
.iter()
.any(|r| r.code == currency && r.symbol == found)
{
panic!("currency symbol \"{}\" does not match {}", found, currency);
}
}
let (whole, fraction) = match split_number(s, groups, decimal) {
Some(parts) => parts,
None => panic!("\"{}\" is not a valid amount", text),
};
if fraction.len() > digits {
panic!("too many decimal places for {} (at most {})", currency, digits);
}
let all = format!("{}{}{}", whole, fraction, "0".repeat(digits - fraction.len()));
let significant = all.trim_start_matches('0');
let minor: u128 = if significant.len() > 16 {
MAX_SAFE + 1
} else if significant.is_empty() {
0
} else {
significant.parse().unwrap()
};
if minor > MAX_SAFE {
panic!("amount is too large: \"{}\" exceeds 2^53 - 1 minor units", text);
}
let value = minor as i64;
money(if negative { -value } else { value }, currency)
}
pub fn fune_vector(args: &[Value]) -> Value {
money_to_value(&parse_money(
args[0].as_str(),
args[1].as_str(),
args[2].as_str(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add money.parse
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./money.parse-1.0.0-rust.fune, or fetch it from a terminal with fune pull money.parse@1.0.0:rust.
The whole function, every language, is one file too: money.parse-1.0.0.fune, 28,750 bytes, sha256 ecbdeba4a39292c61457306e88c137e00908b4f639810f6736ead0376b3c3162. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before money.parse
after — your function gets the result and the arguments, and returns the final result.
// fune: after money.parse
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace money.amount in money.parse
// fune: replace money.currency-digits in money.parse
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show money.parse --steps.
// fune: step money.parse after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| pounds with a thousands comma | £1,234.50, GBP, comma-dot | → | £1,234.50 |
| euros written the German way, symbol after | 1.234,50 €, EUR, dot-comma | → | €1,234.50 |
| euros written the Irish way | €1,234.50, EUR, comma-dot | → | €1,234.50 |
| millions in the German style | 1.234.567,89 €, EUR, dot-comma | → | €1,234,567.89 |
| no grouping and one decimal digit | 1234.5, GBP, comma-dot | → | £1,234.50 |
| whole pounds | £5, GBP, comma-dot | → | £5.00 |
| pence only | £0.05, GBP, comma-dot | → | £0.05 |
| minus before the symbol | -£12.34, GBP, comma-dot | → | -£12.34 |
| minus after the symbol | £-12.34, GBP, comma-dot | → | -£12.34 |
| negative zero is zero | -0.00, GBP, comma-dot | → | £0.00 |
Show the other 27 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| yen have no decimal places | ¥1,500, JPY, comma-dot | → | ¥1,500 |
| dinar have three decimal places | KD 1.500, KWD, comma-dot | → | 1.500 KWD |
| Swiss apostrophe grouping with the ISO code | CHF 1'234'567.89, CHF, apostrophe-dot | → | 1,234,567.89 CHF |
| Swedish space grouping, trailing symbol | 1 234,56 kr, SEK, space-comma | → | 1,234.56 SEK |
| no-break spaces, as word processors write them | 1 234,56 kr, SEK, space-comma | → | 1,234.56 SEK |
| French narrow no-break space grouping | 1 234,56 €, EUR, space-comma | → | €1,234.56 |
| an ISO code after the number | 10.00 GBP, GBP, comma-dot | → | £10.00 |
| a dollar sign is fine once the caller says Canadian dollars | $5.00, CAD, comma-dot | → | 5.00 CAD |
| US$ is recognised as one symbol | US$9.99, USD, comma-dot | → | $9.99 |
| surrounding spaces are ignored | £5.00 , GBP, comma-dot | → | £5.00 |
| one space between symbol and number | £ 5.00, GBP, comma-dot | → | £5.00 |
| the largest amount that is still exact | 90071992547409.91, GBP, comma-dot | → | £90,071,992,547,409.91 |
| three decimals for pounds is refused, not rounded | £1,234.567, GBP, comma-dot | → | error: too many decimal places |
| 1.500 yen is not fifteen hundred yen in comma-dot style | 1.500, JPY, comma-dot | → | error: too many decimal places |
| a German thousand read in the UK style is refused, not read as 1.23 | £1.234, GBP, comma-dot | → | error: too many decimal places |
| lakh grouping is not thousands grouping | £1,23,456.00, GBP, comma-dot | → | error: is not a valid amount |
| the other style's separators are refused | 1,234.50, EUR, dot-comma | → | error: is not a valid amount |
| a symbol for another currency is refused | €5.00, GBP, comma-dot | → | error: does not match GBP |
| R$ is Brazilian real, not rand | R$10,00, ZAR, space-comma | → | error: does not match ZAR |
| letters are refused | 12abc, GBP, comma-dot | → | error: is not a valid amount |
| a symbol alone is refused | £, GBP, comma-dot | → | error: is not a valid amount |
| an empty string is refused | , GBP, comma-dot | → | error: is not a valid amount |
| a trailing decimal point is refused | £5., GBP, comma-dot | → | error: is not a valid amount |
| accounting brackets are refused | (£5.00), GBP, comma-dot | → | error: is not a valid amount |
| beyond 2^53 - 1 minor units is refused | 90071992547409.92, GBP, comma-dot | → | error: amount is too large |
| an unknown currency is an error | 5.00, XYZ, comma-dot | → | error: no ISO 4217 minor unit |
| an unknown style is an error | 5.00, GBP, indian | → | error: unknown number style |
More from the author
| style | groups thousands with | decimal mark | |------------------|---------------------------------------|--------------| | `comma-dot` | `,` | `.` | | `dot-comma` | `.` | `,` | | `space-comma` | space, no-break space or narrow no-break space | `,` | | `apostrophe-dot` | `'` or `’` | `.` |
Grouping is optional, but when it is used it must be real thousands grouping: one to three digits, then groups of exactly three (`1,234,567`). `1,23,456` (Indian lakh grouping) and `12,34` are refused rather than read as something.
**The decimal places come from ISO 4217** via `money.currency-digits`. More decimals than the currency has is an error, not a rounding: `"£1.234"` in the `comma-dot` style is refused, because either it is a typo or it is a German thousand, and neither should become £1.23. Fewer are fine: `"£5"` and `"£5.5"` are 500 and 550 pence. Yen take no decimal part at all.
**A symbol must agree with the currency.** One currency symbol or ISO code may appear, before the number or after it, with at most one space (ordinary, no-break or narrow no-break) between. It must be the currency's own ISO code or one of its symbols in `data/currency-symbols.json`; `"€5"` parsed as GBP is an error. `"$"` is accepted for every dollar and peso in the table because the caller has already said which one it is; `"US$"` is only accepted for USD.
**Signs.** A leading `-`, before or after a leading symbol (`-£5`, `£-5`). Accounting brackets, trailing minus signs and `+` are refused.
**Whitespace.** Ordinary spaces around the whole text are ignored; nothing else is trimmed.
The result must fit in ±(2^53 - 1) minor units. Anything else - letters, two decimal marks, a group separator from another style, an empty string - is `"… is not a valid amount"`.
The symbol table is a convenience for recognising common symbols, not a standard; codes are always accepted. The decimal places are ISO 4217 List One (see `money.currency-digits` for the source).
Files
| Path | Bytes |
|---|---|
| README.md | 2,562 |
| data/currency-symbols.json | 1,486 |
| impl/python.py | 4,094 |
| impl/rust.rs | 5,175 |
| impl/typescript.ts | 4,273 |
| vectors.json | 6,929 |