> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Money is Decimal, always

> Float is rejected outright in the posting path. One conversion boundary, one rounding rule, and splits that sum exactly.

# Money is Decimal, always

## What it guarantees

No money value in the posting path is ever a `float`.
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 Python `float()` 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

| Rule              | Value                                     | Why                                                                                                                                                                 |
| ----------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scale             | `Decimal("0.0001")`, four decimal places  | matches `NUMERIC(20,4)`, the column type every money field uses                                                                                                     |
| Rounding          | `ROUND_HALF_UP`                           | the accounting convention, and deterministic. Python's default is banker's rounding, which is not what a Vietnamese VAT return expects                              |
| Coercion          | `Decimal(str(x))`, never `Decimal(float)` | `Decimal(0.1)` is `0.1000000000000000055511151231257827`; `Decimal(str(0.1))` is `0.1`                                                                              |
| Float             | rejected by `to_money`                    | a float here means money leaked through a cast upstream, which is a bug to surface loudly                                                                           |
| Balance tolerance | `Decimal("0.005")`                        | both legs are quantized to 4dp from Decimal, so a balanced entry differs by exactly 0. The tolerance absorbs legitimate multi-line rounding, never a real imbalance |

## The one conversion boundary

Values genuinely do arrive as floats.
A JSON number from a bank connector is a float by the time `json.loads` is finished with it, and no amount of discipline changes that.

So there is exactly **one** function allowed to turn one into money:

```python theme={null}
from_external(0.1)   # -> Decimal("0.1000")     the value's decimal representation
to_money(0.1)        # -> TypeError             the float never gets that far
```

`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`.

<Note>
  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.
</Note>

## 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.

```
allocate_exact(1_000_000, [1, 1, 1])  ->  [333_333.3333, 333_333.3333, 333_333.3334]
                                          Σ = 1_000_000.0000, exactly
```

## 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

| Concern                                                 | Code                                                                                   |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| the whole money contract, in 118 lines                  | `backend/app/logics/finance/money.py`                                                  |
| the balance tolerance every comparison shares           | `money.BALANCE_TOLERANCE`, re-exported by the ledger writer                            |
| line preparation, where `to_money` runs on every amount | `backend/app/services/ledger_service.py`, `_prepare_line`                              |
| rate coercion, which rejects float too                  | `money.to_money_rate`                                                                  |
| document currency contract                              | `langgraph_chat/data_entry/kb/data/worthstate_models.source.json`, `currency_contract` |

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](/greatbook/capabilities/fx) - the other half of how an amount becomes a book value
* [The 18 GL invariants](/greatbook/capabilities/gl-invariants) - invariants 3, 4 and 7 rest on this module
* [VN VAT and statutory tax](/greatbook/capabilities/subledgers/vat-statutory) - where the rounding rule is load-bearing
* [Why a ledger, not a spreadsheet](/greatbook/why-a-ledger) - the failure class this belongs to
