Zornux docs
Get started Spec

Data & Enterprise

Finance & Money

Money isn't a number. 10 USD and 10 EUR both hold ten, but they aren't the same money — and a plain decimal can't hold a price exactly (0.1 + 0.2 is 0.30000000000000004). Zornux ships a finance library that models money as an exact domain value, so accounting code adds up to the penny.

Making money

Import the library and create a Money value with finance.money. The amount is exact from text — no decimal ever touches it — and is held as a whole number of the currency's minor units (cents, whole yen, thousandths of a dinar):

zornux
import finance

create price = finance.money("12.50", "USD")   # exact from text
create fee   = finance.of_minor(99, "USD")      # 99 cents = $0.99

show finance.format(price)     # $12.50
No money keyword, no second number type

Money is an ordinary value from a library, not new syntax — the language gains neither a money keyword nor a second numeric kind. Because the amount is whole minor units, every add and subtract is exact where a plain number would drift.

Currency-safe arithmetic

Arithmetic is functions, not operators — and currencies never mix. Adding dollars to euros is refused, not silently summed; you convert first (below). finance.multiply rounds back to whole minor units under a policy you state.

zornux
create subtotal = finance.add(price, fee)          # same currency — ok
create doubled  = finance.multiply(price, 2)       # $25.00
create taxed    = finance.multiply(price, "1.08")  # rounds to minor units

show finance.compare(price, fee)     # 1  (price is larger)
show finance.is_zero(price)          # false
FunctionResult
finance.add(a, b) / finance.subtract(a, b)Sum or difference — same currency only.
finance.multiply(money, factor)Scale by a rate or quantity, rounded to minor units.
finance.negate(money) / finance.abs(money)Flip the sign / drop the sign.
finance.compare(a, b)-1, 0, or 1 — same currency only.
finance.is_zero / is_negative / is_positive(money)Sign tests, as a Truth.
finance.minor_units / currency_code / currency_info(money)The raw minor-unit count, the ISO code, or the full currency record.
finance.format(money)Render $1,234.50 — options turn the symbol, grouping, or code on or off.

Splitting loses nothing

Dividing money must never lose or invent a penny. finance.split_evenly splits into equal parts, and finance.allocate distributes by weighted ratios — both hand out leftover minor units by largest remainder, so the parts always sum back to the whole.

zornux
create parts = finance.split_evenly(finance.money("10.00", "USD"), 3)
# [$3.34, $3.33, $3.33]  — sums back to exactly $10.00

create shares = finance.allocate(finance.money("100.00", "USD"), [2, 1, 1])
# [$50.00, $25.00, $25.00]

Rounding is a value

Rounding happens only where it's unavoidable, and always under a policy you name — never a hidden default. finance.rounding turns a policy name into a value you can name once and reuse; a typo is caught the moment you make it, not at some later calculation.

zornux
create bankers = finance.rounding("half_even")   # a RoundingPolicy value
create total   = finance.multiply(price, "1.075", bankers)
PolicyRounds…
half_even (default)To nearest; ties to the even digit (banker's rounding).
half_upTo nearest; ties away from zero.
up / downAway from / toward zero.
ceiling / floorToward positive / negative infinity.
Or pass the name directly

Every function that rounds — multiply, convert, and the tax functions — also accepts a bare policy name like "half_up". Make a finance.rounding value when you want to state the rule once and reuse it everywhere.

Tax, both ways

Tax is easy to get subtly wrong by rounding twice. A tax is just a name and a rate (finance.tax), or a bare rate; the library rounds once and always reconciles, whether the tax is added to a net price or extracted from a gross one.

zornux
create vat = finance.tax("VAT", "0.20")          # 20%

# Sales-tax style — add tax onto a net price.
show finance.format(finance.tax_on(price, vat))  # the tax alone
show finance.format(finance.add_tax(price, vat))  # net + tax

# VAT style — pull the tax out of a tax-inclusive price.
create gross = finance.money("100.00", "GBP")
show finance.format(finance.remove_tax(gross, vat))        # net inside
show finance.format(finance.tax_included_in(gross, vat))   # tax portion
Receipts that add up

For an inclusive price, the net is rounded and the tax is taken as the remainder — net = gross − tax — so net + tax equals the gross exactly, even when £100.00 splits into £83.33 and £16.67.

Exchange & conversion

Because currencies don't mix, converting is explicit. Convert at a one-off rate, or make a directional exchange rate to hold and reuse — applying it to the wrong currency is refused rather than silently wrong.

zornux
# One-off conversion — one USD buys 'rate' units of the target.
create in_jpy = finance.convert(price, "JPY", "155.0")   # exact across scales

# A reusable, directional rate.
create usd_eur = finance.exchange_rate("USD", "EUR", "0.92")
create in_eur  = finance.convert_with(price, usd_eur)
create eur_usd = finance.invert_rate(usd_eur)            # EUR → USD

Money as data

A Money value serializes to JSON as { amount, currency, minor_units } — with the amount written as text, so JSON's own floating-point numbers can never lose a penny. It reads back the same way, ready to store or send.

zornux
show to_json(price)
# { "amount": "12.50", "currency": "USD", "minor_units": 1250 }
Global names stay yours

The library uses finance.currency_info, finance.abs, finance.split_evenly, and finance.rounding precisely so it never collides with the global currency, absolute, split, and round you already use elsewhere.

Next: settings and secrets — Configuration.