faktorei

Engineering

A currency is not a language

Ask a renderer how many decimal places to print and it will usually consult the locale. That is the wrong question asked of the wrong object, and on a Japanese invoice it produces a number that does not exist.

The Japanese yen has no minor unit. There is no such thing as a hundredth of a yen; ISO 4217 assigns JPY an exponent of zero. A total of eighteen thousand yen is 18,000. Printing 18,000.00 is not a formatting preference — it invents a precision the currency does not have, on a document somebody files for tax.

JPY is the famous one. It is not alone: ISK, KRW, CLP, VND, XAF, XOF, XPF, BIF, DJF, GNF, KMF, PYG, RWF, UGX, UYI and VUV are all zero-decimal. In the other direction, several Gulf currencies use three — BHD, IQD, JOD, KWD, LYD, OMR, TND are quoted in thousandths.

How renderers get here

Almost every invoice renderer starts European, and in Europe the question never comes up. Every currency you meet has two decimal places, so "two" gets written down as a constant, usually inside whatever formats numbers for the current language. It looks like this:

<!-- one branch per language, decimals baked into each --> <xsl:when test="$lang = 'de'"> <xsl:value-of select="format-number($v, '#.##0,00')"/> </xsl:when> <xsl:otherwise> <xsl:value-of select="format-number($v, '#,##0.00')"/> </xsl:otherwise>

Read it and the mistake is visible: the branch selects on language, and the number of decimals is part of the picture string in each branch. German gets 1.234,56 and English 1,234.56, which is correct — those are language properties. But the 00 at the end came along for the ride, and it is not a language property at all.

Two independent things had been fused into one decision. Separators belong to the reader; decimal places belong to the money.

The fix, and the part that is easy to miss

Splitting them is small. The grouping and decimal marks stay with the language; the number of places comes from BT-5, the document currency:

<xsl:variable name="minor-units" as="xs:integer" select="if ($currency-code = $k:zero-decimal) then 0 else if ($currency-code = $k:three-decimal) then 3 else 2"/>

The trap is one layer down. Having discovered that decimals come from the currency, the obvious next move is to route every number through that rule — and quantities are not money.

An invoice line for two and a half hours of consultancy carries 2.5. Format that with the currency's minor units on a JPY invoice and it rounds to 3. You have silently altered the quantity on a tax document, in the course of fixing a formatting bug, and every total on the page still reconciles because the arithmetic was done upstream. Nothing validates against it. It is a data corruption wearing a formatting bug's clothes.

So quantity gets its own rule, which has nothing to do with currency: print whole numbers whole, and fractional quantities with the decimals they actually have.

<xsl:function name="k:qty" as="xs:string"> <xsl:param name="v"/> <xsl:sequence select="if (xs:decimal($v) = floor(xs:decimal($v))) then format-number(xs:decimal($v), '0') else k:format($v, 2)"/> </xsl:function>

The fixture that guards it

The corpus carries a JPY invoice whose lines are deliberately mixed: whole-unit quantities, and one line billed in hours at 2.5. It is the second line that matters. A regression that reintroduces the original bug makes the totals wrong and is caught immediately; a regression that over-corrects — routing quantities through the currency rule — leaves every total correct and quietly turns 2.5 hours into 3.

Only a fixture containing a fractional quantity in a zero-decimal currency distinguishes those two failures. That combination looks contrived until you realise it is the only thing standing between the two ways of getting this wrong.

Measured, before and after

Rendering that fixture through two published container versions, and counting how many amounts print with two decimals:

ImageTwo-decimal amountsReading
2025.11.19every money amount wrongly carries decimals
2025.11.21the one remaining is 2.50 — the quantity, correct

That surviving 2.50 is the point of the whole exercise. A zero-decimal currency must still be able to print two and a half hours.

Where this generalises

The specific bug is narrow. The habit behind it is not: a formatting decision usually belongs to one of several different owners, and the language is only ever one of them. Separators belong to the reader. Decimal places belong to the money. Quantity precision belongs to the transaction. Whenever those get resolved in the same branch, the least-common case is being formatted by the most-common case's rules — and it works perfectly right up to the first invoice from outside the region you built it in.

The stylesheets and the JPY fixture are Apache-2.0: github.com/faktorei/stylesheets — see corpus/fixtures/ubl/018-jpy-zero-decimal.xml and tools/test_money.py.