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:
| Image | Two-decimal amounts | Reading |
|---|---|---|
2025.11.1 | 9 | every money amount wrongly carries decimals |
2025.11.2 | 1 | the 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.