Parsing money the way Vietnamese people type it: 2,5tr, 500k and 1tr+500k

Units like tr and tỷ, a dot that means two things, sums typed as in Excel, a caret that stays put, and the missing + key on phone keypads.

· Published · Updated · 8 min read · About the project →

In Vietnam nobody types "2500000" for an electricity bill. People write "2,5tr", "2.5 triệu", "500k", "0,1 tỷ" or "2.500.000", and in a spreadsheet they happily type "=250k*3". Financial Plan, my budgeting app, is almost entirely amount fields, so it had to read all of these. This post covers the small parser behind them, the dot that means two different things, sums typed like in Excel, keeping the caret in place while formatting, and the missing + key on phone keyboards.

Why a number input can't hold Vietnamese amounts

<input type="number"> rejects letters, so "2,5tr" is out, and it reads separators by the browser's rules, not the user's. Vietnamese also uses the opposite marks to English: vi-VN groups thousands with a dot and uses a comma for decimals, so "2.500.000" is two million five hundred thousand. Yet the same people will write "2.5tr" with a dot as the decimal point, because that is how phone keyboards and English-language apps taught them.

So every amount in the app is a text field with inputmode="decimal", which asks phones for a numeric keypad, and a parser that turns the text into a number when the field is saved.

One figure: a number plus an optional unit

A single figure is digits and separators, optionally followed by a unit. The units are the ones people actually type: k, n, nghìn, ngàn for thousands; tr, triệu, trieu, m for millions; tỷ, tỉ, ty, b for billions.

const m = s.match(/^([\d.,]+)(k|n|nghìn|ngàn|tr|triệu|trieu|m|tỷ|tỉ|ty|b)?$/);

Before matching, the input is lowercased, spaces are removed, and so are the currency's code, symbol and common spellings (VND, ₫, vnđ, đ), plus a leading =. "100.000đ", "100.000 VND" and "100000" all reach the parser as the same string.

The unit decides what the separators mean:

  • With a unit, the separator is a decimal point. "2,5tr" and "2.5tr" are both 2,500,000; the comma is replaced by a dot and the figure is multiplied by the unit.
  • Without a unit, in a currency with no decimals, every dot and comma is grouping. "2.500.000", "2,500,000" and "2500000" are the same number. The Vietnamese đồng has no subunit in use, so a bare figure is always whole.
  • In a currency with decimals, the currency's own locale decides: its grouping mark is dropped and its decimal mark becomes the point. "$2,500.50" and "2.500,50 €" each read correctly.

The result is rounded to the currency's decimal places, so a sum like "100k/3" saves as 33,333 đ, not 33,333.333.

Decision flow for one typed figure: clean up the input, then if there is a unit (k, tr, triệu, tỷ, m, b) the comma or dot is a decimal point and the figure is multiplied by the unit; otherwise, if the currency has decimals, its own locale decides both marks; otherwise every dot and comma is grouping.Clean up the inputlowercase; drop spaces, đ, ₫, VND and a leading =Is there a unit?k, tr, triệu, tỷ, m, b…the comma or dot is adecimal point, × the unit2,5tr → 2,500,000yesnoHas decimals?$ and € yes, đồng nothe currency’s own localedecides both marks2.500,50 € → 2,500.50yesnoevery dot and comma groups2.500.000 → 2,500,000
The unit decides what a separator means: with a unit it is a decimal point, without one, in đồng, it only groups digits.

The dot that means two things

Formatting while typing is where the ambiguity bites. Showing "2.500.000" as the user types "2500000" is helpful; turning "2.5" into "25" because the dot looked like a grouping mark is not.

The rule the app settled on: only reformat text that is unambiguously a plain whole figure, meaning digits only, or digits in groups of three separated by dots.

const PLAIN = /^-?(\d+|\d{1,3}(\.\d{3})+)$/;

"2500000" and "2.500.000" match and get grouped. "2.5tr", "0,1 tỷ" and "14.31" don't, so they are left exactly as typed and only converted to the full figure when the field loses focus. One exception keeps editing pleasant: if the previous value was a figure the formatter produced, deleting a digit from "2.500.000" leaves "2.50.000", which no longer matches the pattern but should still be regrouped.

What this does not solve is a bare decimal in đồng. With no unit every dot is grouping, so "2.5" saves as 25 đ and "14.31" as 1,431 đ. The parser can't tell a slip from a real figure, so whoever means two and a half million has to type the unit.

Keeping the caret where it was

Reformatting a field moves its text around, and a naïve update sends the caret to the end, which makes fixing a digit in the middle impossible. The fix is to count digits rather than characters: before formatting, count how many digits sit before the caret; after formatting, walk the new string and place the caret after the same number of digits. Inserted dots don't count, so the caret stays after the digit the user just typed.

Sums, as in a spreadsheet

People budget with arithmetic: "1tr+500k" for two payments, "=250k*3" for three of something, "(3tr+2tr)/2" for an average. Any amount field accepts this.

The input is split on operators, including the ones phones and Excel users type: × and x for multiplication, ÷ for division, and − (the real minus sign) as well as -. Then a small recursive-descent parser evaluates it with the usual precedence:

const sum = () => {
  let v = product();
  while (tokens[i] === "+" || tokens[i] === "-") v = tokens[i++] === "+" ? v + product() : v - product();
  return v;
};

product handles * and / the same way, and factor handles unary minus, brackets and single figures, each parsed with the rules above, so "1,5tr*2" mixes units and arithmetic. Anything that doesn't fully parse returns NaN: tokens left over, an unclosed bracket, or a result that isn't finite, such as a division by zero. The field then keeps its previous value and a message shows what it couldn't read, with examples of what it can, instead of saving a wrong number.

Two details make it usable:

  • A leading minus is a figure, not a sum. "-500k" is a negative amount; "1tr-500k" is a sum. The check strips one leading minus before looking for operators.
  • The result shows under the field while typing, and what gets saved is the figure, not the expression. Reopening the field shows "1.500.000", which is what the rest of the app works with.

Phones have no + key on the number pad

On iOS, inputmode="decimal" brings up a keypad of digits and a decimal separator, with no plus or minus at all. So on touch devices, detected with matchMedia("(pointer: coarse)"), a focused amount field shows its own row of + − × ÷ keys.

Those keys have to type into a React-controlled input as if the user had pressed them. Setting input.value directly doesn't trigger React's onChange, so the code goes through the native setter and dispatches an input event:

Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
requestAnimationFrame(() => el.setSelectionRange(caret, caret));

The caret is restored on the next frame, after React has re-rendered the field.

These fields hit one more iOS quirk: the page zooms into any field with text under 16 px. That fix sits with the other iPhone details in keeping an installed PWA usable on iPhone across updates and deploys.

Displaying amounts: let Intl decide

Output uses Intl.NumberFormat rather than hand-written rules. The app asks formatToParts how the currency's home locale writes 1234.5, and reads off the symbol, which side it goes on, whether there's a space, the grouping and decimal marks and the number of decimals. That gives "2.500.000 ₫", "$2,500.50" and "2.500,50 €" from the same code; supporting another currency is one line naming its locale. Short forms follow the interface language: "2,5 tr" in Vietnamese and "2.5M" in English.

Negative amounts use the real minus sign "−" rather than a hyphen, which lines up with "+" and reads correctly in screen readers.

Tests pin down what people type

The parser is easy to break with a well-meant change, so its tests are a table of real inputs:

Typed Read as
5.500.000 5,500,000
5.5tr 5,500,000
5,5 triệu 5,500,000
500k 500,000
0,1 tỷ 100,000,000
-2tr −2,000,000
=250k*3 750,000
abc not a number
empty no value

Summary

Problem What the app does
Units and Vietnamese separators Text field with inputmode="decimal"; a unit makes the separator a decimal point, no unit makes it grouping
Formatting while typing Only plain whole figures are regrouped; anything else waits for blur
Caret jumping to the end Place it after the same number of digits
Sums like "1tr+500k" Tokenizer plus a recursive-descent parser; result shown live, figure saved
No + key on phone keypads A row of + − × ÷ keys on touch devices, typed through the native value setter
Several currencies Symbol, side, marks and decimals read from Intl.NumberFormat

None of this is large, a few hundred lines with the tests, but it is the part of a budgeting app that users touch most. An amount field that understands "1tr+500k" is one fewer reason to keep a calculator open next to it.

If you've built an amount field that reads "2,5tr" or "500k" in your own way, I'd love to compare notes. My email is on the home page.