Money is Decimal, always
What it guarantees
No money value in the posting path is ever afloat.
Not converted, not rounded, not tolerated: a float reaching the money layer raises a TypeError with the reason attached.
Every amount is a Decimal quantized to four decimal places with ROUND_HALF_UP, matching the NUMERIC(20,4) columns it lands in.
Why this is a control and not a style preference
This is audit finding #5, and it is worth stating in full because it is the clearest example of why a book of record needs different engineering from a report. The running floor cast base, native and rate amounts through Pythonfloat() at the posting boundary.
Float cannot represent decimal cents exactly.
So a ledger doing arithmetic in float will, given enough entries, fail its own balance check by a sub-cent residue.
And a book of record cannot be “balanced except for rounding”.
Either debits equal credits or the books do not balance, and there is no third state that an auditor accepts.
The fix was not “round more carefully”.
It was to make the wrong type impossible to pass.
The rules
The one conversion boundary
Values genuinely do arrive as floats. A JSON number from a bank connector is a float by the timejson.loads is finished with it, and no amount of discipline changes that.
So there is exactly one function allowed to turn one into money:
from_external converts via str(), so the result is the value’s decimal representation rather than the float’s binary artifact.
Everything downstream of that call is Decimal.
The asymmetry is the design.
from_external is called at ingestion, once, explicitly, in the connector.
to_money is called everywhere else and raises.
So “where did this float become money” always has exactly one answer, and it is grep-able.Splits that sum exactly
allocate_exact(amount, weights) splits an amount across weights so the shares sum to exactly the source.
Each share is rounded to money scale, and the last non-zero-weight share absorbs the rounding residual.
No dong is lost or created.
This is the workbook’s landed-cost and 627-overhead-absorption discipline, and it is the difference between an allocation that ties and one that leaves a few dong in a suspense account every month until somebody writes it off.
Supported currency codes
Document-side currency is also a typed contract rather than free text.greatbook.currency-code/v1 contains 307 exact uppercase ISO 4217 alphabetic assignments: the current List One currencies and funds plus historical List Three assignments needed to round-trip dated evidence.
The schema accepts codes such as VND, USD, EUR, JPY and historical BGN.
It rejects lowercase codes, localized spellings, symbols, malformed values and invented ISO-shaped strings before agent, Typewriter, workflow or posting logic runs.
Every one of the 27 currency-bearing document schemas references the same generated CurrencyCode definition.
Typewriter create/revise and staged review render that enum as a native select, preserve a visible legacy invalid value for correction, block Save or resubmit before sending a request, and use each document schema’s own authored default rather than forcing VND.
The canonical schema and the UI’s build-time copy are checked byte-for-byte so a browser cannot offer a currency the server refuses.
Where it is enforced
Every finance module imports
money rather than doing its own arithmetic.
That is why there is one balance tolerance in the system instead of one per module that agree until they do not.
The test that would fail if it broke
backend/tests/test_money.py covers the float rejection, the rounding mode, from_external’s decimal-representation behaviour, and allocate_exact’s exact-sum property.
What goes wrong without it
The failure is slow and it is quiet. A book posts cleanly for months, and then a trial balance is off by 0.0003, and the residue is spread across ten thousand entries with no single wrong one to find. The second failure is louder.ROUND_HALF_EVEN on a VAT calculation produces a tax amount that differs from the supplier’s invoice by one dong, which fails the per-line VAT integrity checksum (tax == ROUND(base x rate)), which blocks a close, for a reason nobody can see by reading the numbers.
Related
- FX as of the transaction date - the other half of how an amount becomes a book value
- The 18 GL invariants - invariants 3, 4 and 7 rest on this module
- VN VAT and statutory tax - where the rounding rule is load-bearing
- Why a ledger, not a spreadsheet - the failure class this belongs to