Carry exact money over JSON
Keep money exact inside aontu and cross JSON as a fixed-scale decimal string with a conversion mark.
Rendered from
docs/how-to/carry-exact-money-over-json.md
in the engine repository, where a correction belongs, and where the
test suite executes every example on this page.
Inside an aontu document, money is a
bigdecimal:
0d literals are exact base-10 values and + on them is exact
arithmetic: no binary rounding, ever. Write this as money.aon:
subtotal: 0d19.99
shipping: 0d4.01
total: $.subtotal + $.shipping
$ aontu money.aon
{
"shipping": 4.01,
"subtotal": 19.99,
"total": 24.0
}
The problem is the wire. A bigdecimal schema cannot be satisfied by
a plain JSON number, by design: JSON.parse has already turned 0.1
into a binary64 float before aontu ever sees it, and the numeric
leaves are
disjoint, so the
exactness the field demands is gone at the door. Put the schema in
invoice.aon:
invoice: total: bigdecimal
and a parsed-and-reserialised number in invoice.json:
{"invoice": {"total": 0.1}}
$ aontu vet invoice.aon invoice.json
verdict: invalid
$.invoice.total: no_scalar_unify [conflict]
[aontu/no_scalar_unify]: Cannot unify values at path $.invoice.total
data: invoice.json:1:23 (0.1)
schema: invoice.aon:1:17 (bigdecimal)
$ echo $?
1
This refusal is the feature: a schema that admitted 0.1 here would
be certifying a value the wire already corrupted. The convention that
works is string decimals at the boundary: the JSON field carries the
exact digits as text, and the schema pins its shape with
re.
Replace invoice.aon with the string form:
invoice: total: string & re("^-?[0-9]+\\.[0-9][0-9]$")
A conforming wire value in wire.json:
{"invoice": {"total": "19.99"}}
and a wrong-scale one in bad.json:
{"invoice": {"total": "19.9"}}
$ aontu vet invoice.aon wire.json
verdict: valid
$ aontu vet invoice.aon bad.json
verdict: invalid
$.invoice.total: constraint [conflict]
[aontu/constraint]: Cannot unify values at path $.invoice.total
expected: re("^-?[0-9]+\\.[0-9][0-9]$")
actual: "19.9"
data: bad.json:1:23 ("19.9")
schema: invoice.aon:1:26 (re("^-?[0-9]+\\.[0-9][0-9]$"))
$ echo $?
1
The convention, in full
A pattern alone leaves the reader guessing that the string is a
number at all. The convention has two parts, a canonical wire form
and a conversion mark that says what the text means. Write it as
money-wire.aon:
# A decimal carried as text, at a fixed scale. Canonical only: no
# leading zeros, no separators, no exponent, no bare -0.
Dec2: type(string & re("^-?(0|[1-9][0-9]*)[.][0-9]{2}$") & neq("-0.00"))
Money: type(close({
amount: $.Dec2
currency: string & re("^[A-Z]{3}$") & neq("XXX", "XTS")
# The conversion mark: which leaf, at what scale. OPTIONAL, so a
# producer is never asked to send it; CONSTANT, so a producer that
# does send it cannot claim something else.
dec?: "bigdecimal:2"
}))
Write the mark as a constant, not as a preference. dec?: *"bigdecimal:2" looks equivalent and is not: a preference is a
default, so it yields to whatever the data says, and {"dec": "float"} would vet clean.
The currency travels with the amount. A decimal string with no currency beside it is a number rather than money, and every rounding rule that matters belongs to the currency.
Crossing the boundary
The conversion is textual: the wire string is the 0d literal’s
digits, so nothing is parsed as a float on the way in and nothing is
rounded. Put the corner cases in convert.aon:
amount: 0d3998.19
refund: -0d12.05
sameNumber: 0d10.50 & 0d10.5
scaleZeroRight: 0d10.0
$ aontu --canon convert.aon
{"amount":0d3998.19,"refund":-0d12.05,"sameNumber":0d10.5,"scaleZeroRight":0d10.0}
Three details decide whether an implementation of this is correct:
- The sign goes outside the prefix.
"-12.05"becomes-0d12.05.0d-12.05is not a literal, so a converter that pastes the sign after the prefix fails to parse rather than computing the wrong number: the safe direction, but still the detail everyone gets wrong once. - Scale is not part of the value.
0d10.50and0d10.5are the same number and unify; canon prints the shorter one. A serialiser therefore cannot recover"10.50"from the value: it formats to the scale the schema declared, which is why the mark names a scale as well as a leaf. - At scale 0 the point still has to be written.
0d10is abigintegerand the leaves are disjoint, so a scale-0 wire decimal converts to0d<digits>.0: never0d<digits>, which would refuse the very schema it was converted for.
The producer formats its exact value into the string; the consumer,
after vet passes, parses the string with a decimal parser (never
parseFloat): in TypeScript the Decimal class the engine itself
uses, in Go math/big. Inside the trust boundary, keep the value a
0d exact literal and let aontu’s arithmetic and the lossy-literal
refusal
protect it. Note the asymmetry: aontu’s own generated JSON writes
an exact value’s digits faithfully (the 24.0 above), but a standard
JSON reader hands them back as a float. Exactness survives writing,
not the round trip, which is exactly why the wire field is a string.
The convention survives export
A bare bigdecimal field is a loss on a JSON Schema export: the
exactness has no receiver. The wire form is the way around it:
export JSON Schema covers the verb, and the
Money type crosses whole, mark included:
$ aontu jsonschema --at '$.Money' money-wire.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"amount": {
...
"pattern": "^-?(0|[1-9][0-9]*)[.][0-9]{2}$",
"type": "string"
},
...
"dec": {
"const": "bigdecimal:2",
"type": "string"
}
},
"required": [
"amount",
"currency"
],
"type": "object"
}
type and pattern do different jobs here and both are needed:
type: "string" is what refuses a bare JSON number (whose text the
pattern would happily accept), pattern is what refuses the wrong
scale. A consumer that never runs aontu still enforces the wire form,
and still learns the leaf and the scale from the exported const.
A worked end-to-end version (the schema, strictly JSON records that
pass, the three that must not, the exported schema checked against the
same records, and the conversion written as theorems) is
use-cases/10-data-model
(money-wire.aon and money-convert.aon), with check.sh asserting
every claim on this page.