Sign in

    moonmoney

    Exact money and currency arithmetic for MoonBit: ISO 4217 minor units, allocation without losing a cent, tax, cash rounding, parsing and formatting

    money
    currency
    decimal
    finance
    iso4217
    rounding
    allocation
    billing
    Download zip
    Author
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    6

    #MoonMoney

    Exact money and currency arithmetic for MoonBit. Amounts are integers in the smallest unit a currency defines, so a cent is never lost to a float; splitting an amount always adds back up; tax, cash rounding and currency conversion each round exactly once. The ISO 4217 registry, the parser and twenty locale formats come with it.

    $ moon run cmd/main -- tax 10000 USD 19% net: $100.00 rate: 19% tax: $19.00 gross: $119.00 $ moon run cmd/main -- split 10000 USD 3 1: $33.34 2: $33.33 3: $33.33 total: $100.00 $ moon run cmd/main -- parse "CA$12.34" CAD 12.34 minor units: 1234 currency: CAD (Canadian Dollar)

    The point is the third line of the first block and the last line of the second. A naive implementation gives 100.00 / 3 as 33.3333... three times and loses the last cent, or stores 119.00 as 118.99999999999999. MoonMoney stores 100.00 as the integer 10000 and 19% as the integer 1900 basis points, so both answers are exact by construction rather than by luck.

    #Why

    The MoonBit ecosystem already has arbitrary-precision arithmetic (moonbit-community/decimal, Luna-Flow/floating) and several date and schedule libraries. What it did not have, as of 2026-10-16, was a money domain: a registry of ISO 4217 currencies with their minor units, an amount type that cannot be added to an amount in another currency, allocation that preserves the total, and the small set of operations that billing, expense splitting and reporting actually need.

    MoonMoney is not a decimal arithmetic library. It does not do arbitrary-precision general arithmetic and it does not wrap another one; it is the layer above, and that is the layer where a cent goes missing.

    #Requirements

    • The MoonBit toolchain, moonc 0.10.14 or newer — the floor the OSC acceptance rules set. This repository is developed, formatted and verified with moon 0.1.20261012 / moonc v0.10.15, which is the release continuous integration installs as well, so moon fmt and moon info reproduce the committed files byte for byte.
    • Nothing outside moonbitlang/core is needed to build, test or run the library, on wasm, wasm-gc and js.
    • A C toolchain for --target native: the toolchain's Windows native backend wants MSVC Build Tools (cl.exe or clang-cl.exe), so a Windows machine with only mingw cannot build that one target. CI covers all four targets on Linux.
    • Node.js only if you want to regenerate the currency table.

    The package is published as haol-05/moonmoney on Mooncakes.

    #Quick start

    # use it from another project moon add haol-05/moonmoney # work on this repository moon check --deny-warn --target all moon build --target all moon test --deny-warn --target all moon run examples/demo # a self-contained tour, no arguments moon run cmd/main -- parse "USD 1,234.56"

    examples/demo prints each section of the library in turn and ends with the same amount written five ways. Its output is the fastest way to see what the library does and what it refuses.

    #What it does

    AreaDetails
    Registry165 ISO 4217 currencies with a defined minor unit: alphabetic and numeric code, minor units, CLDR display digits, cash digits and cash step, English name, symbol. Currency::custom covers everything else.
    AmountsMoney is { minor: Int64, currency: Currency }. Build from minor units, whole units, or units plus a fraction; read back whole units and the fraction; compare, negate, take the absolute value.
    ArithmeticAdd, subtract, multiply by a whole number, divide with a rounding mode. Combining two currencies is an error, not a conversion. Overflow is reported, never wrapped.
    RoundingHalfUp, HalfDown, HalfEven, Up, Down, Ceiling, Floor, plus rounding to an arbitrary step and to a currency's own cash step (CHF to 0.05, DKK to 0.50).
    PercentagesPercent is an exact number of basis points. Apply a percentage, add it (net to gross) or subtract it (a discount).
    AllocationSplit an amount into equal, weighted or percentage shares with the largest-remainder method: the shares always sum to the original and no two differ by more than one minor unit.
    TaxTax on a net amount, the net recovered from a gross amount, and one line per rate.
    ExchangeA rate is an exact scaled integer; conversion multiplies in arbitrary precision and rounds once.
    ParsingCodes and disambiguated symbols, both separator conventions, signs, trailing signs, accounting parentheses.
    FormattingSymbol, code or bare; grouping by threes or the Indian 3-2 pattern; twenty locale presets; minus, parentheses or trailing minus.

    #Using it

    // A net invoice line and its tax. Every call that can fail says so in its type.
    fn invoice_line(net_minor : Int64, rate_text : String) -> String raise @moonmoney.MoneyError {
    let usd = @moonmoney.Currency::find("USD").unwrap()
    let net = @moonmoney.Money::of_minor(net_minor, usd)
    let rate = @moonmoney.Percent::parse(rate_text)
    let gross = net.with_tax(rate)
    "\{@format.format(net)} + \{rate.text()} = \{@format.format(gross)}"
    }

    // Splitting a bill without losing a cent.
    let total = @moonmoney.Money::of_minor(10000L, usd) // USD 100.00
    let shares = total.allocate(3) // [33.34, 33.33, 33.33]
    let weighted = total.allocate_ratios([3L, 2L, 1L]) // [50.00, 33.33, 16.67]

    // Reading amounts that were written the way people write them.
    let a = @parse.amount("USD 1,234.56") // USD 1234.56
    let b = @parse.amount("1.234,56 €") // EUR 1234.56, the continental reading
    let c = @parse.amount("CA$12.34") // CAD 12.34
    let d = @parse.amount_in("(5.00)", usd) // -USD 5.00, accounting negative

    #Reading an amount

    The parser is strict where guessing would cost money.

    • The currency has to be known. Either the text names it (USD 12.34, CA$12.34, €12.34) or the caller supplies it with amount_in. A symbol is only matched as a whole token and the symbols are the disambiguated CLDR ones, so A$ is the Australian dollar, CA$ the Canadian one and a bare $ is the US dollar — the only currency CLDR gives that symbol. Total: 12.34 is reported as unknown rather than parsed as if Total: were a currency.
    • The decimal mark is found by rule. One to exponent digits after the last mark make it the decimal mark, so 1,234.56 and 1.234,56 both read as one thousand two hundred thirty four point five six. More than that is still a decimal mark unless exactly three digits follow, because three is a grouping group: 1,235 is 1235, while 1.2345 is a fraction of four places. Grouping that is not in threes is refused.
    • Precision that does not fit is refused. amount_in("USD 1.2345") reports an error. amount_in_rounded rounds once under a mode you name instead.

    #Writing an amount

    @format.format(money) // $1,234.56
    @format.format(money, style=@format.MoneyStyle::code_first()) // USD 1,234.56
    @format.format(money, style=@format.MoneyStyle::plain()) // 1234.56
    @format.format(money, style=@format.MoneyStyle::accounting()) // ($1,234.56) when negative
    @format.MoneyStyle::for_locale("de-DE") // 1.234,56 $
    @format.MoneyStyle::for_locale("en-IN") // ₹1,23,456.78

    The number of decimal places follows the currency unless a style overrides it. Formatting to fewer places than the currency holds rounds half to even, once; asking for more pads with zeros and never invents precision.

    #Honest limits

    • No exchange rates are fetched. Conversion needs a rate you supply, which keeps the result deterministic and the library offline. There is no rate feed, no historical rate and no triangulation.
    • No decimal arithmetic. The library is fixed point. Amounts are bounded by Int64 minor units; a result that does not fit is reported as an overflow rather than promoted to a bigger type or a float.
    • No locale database. Twenty layouts ship in the source. An unknown tag returns None rather than a guess.
    • Nothing is validated against a payment network. The ISO 4217 table is reference data, not a statement about what a bank will accept.
    • One provenance point needs a reviewer's eye. The ISO 4217 compilation MoonMoney reads is under ODC-PDDL-1.0, but the SIX Group lists underneath it state no licence. SOURCES.md records this and the one-constant change that swaps in the official list instead.

    #Reproducing the currency table

    node tools/generate_currency_table.mjs

    The script reads the two data sets over jsDelivr, reconciles them and rewrites currency_table.mbt in the canonical moon fmt form, so the generated file is byte-identical to what the formatter would produce and moon fmt is a no-op on it. Running the script twice produces a byte-identical file. The provenance of every field is recorded in SOURCES.md and repeated in the header of the generated file.

    #Layout

    moonmoney/ the core: Currency and its table, Money, rounding, percentages, allocation, tax, exchange parse/ text to Money format/ Money to text, with the locale presets cmd/main/ the command line examples/demo/ a runnable tour tools/ the table generator

    #Licence

    Apache-2.0; see LICENSE. The data sets and their licences are recorded in SOURCES.md.

    MoneyError

    pub(all) suberror MoneyError {
    DivisionByZero
    Overflow(String)
    CurrencyMismatch(String, String)
    UnknownCurrency(String)
    AmbiguousCurrency(String, String)
    InvalidAmount(String, String)
    EmptyAllocation(String)
    EmptyRatio
    } derive(
    Debug
    )

    MoneyError::message

    fn MoneyError::message(self : MoneyError) -> String

    A printable explanation, used by the command line and by tests.

    Currency

    pub(all) struct Currency {
    code : String
    numeric : Int
    exponent : Int
    display_digits : Int
    cash_exponent : Int
    cash_step : Int
    name : String
    symbol : String
    } derive(Eq,
    Debug
    )

    Currency::all

    fn Currency::all() -> Array[Currency]

    Every currency in the registry, in code order.

    Currency::by_token

    fn Currency::by_token(token : StringView) -> Array[Currency]

    Every currency whose code or symbol matches token.

    The parser uses this to tell an unambiguous symbol such as € from an ambiguous one such as $, which several dollar currencies share.

    Currency::cash_increment

    fn Currency::cash_increment(self : Currency) -> Int64

    The step a cash payment is rounded to, in minor units.

    CHF has an exponent of 2 and a cash step of 5, so this is 5 (0.05 franc). DKK settles to 0.50, so this is 50. A currency without a separate cash rule reports 1.

    Currency::count

    fn Currency::count() -> Int

    How many currencies the registry holds.

    Currency::custom

    fn Currency::custom(code : String, exponent : Int, symbol? : String) -> Currency?

    Builds a currency that is not in the ISO 4217 table, for private or historical codes and for the ISO codes that carry no minor unit.

    None when exponent is outside 0..=18, the range an Int64 can scale exactly. The code is upper-cased so lookups and comparisons stay stable.

    Currency::display_symbol

    fn Currency::display_symbol(self : Currency) -> String

    The symbol, falling back to the code when the currency has none.

    Currency::equal

    fn Currency::equal(Currency, Currency) -> Bool

    Currency::find

    fn Currency::find(code : StringView) -> Currency?

    Looks an ISO 4217 currency up by its alphabetic code, ignoring case.

    Currency::has_cash_rounding

    fn Currency::has_cash_rounding(self : Currency) -> Bool

    True when a cash payment is settled in larger steps than the minor unit.

    Currency::known_codes

    fn Currency::known_codes() -> Array[String]

    The alphabetic codes of the registry, in order.

    Currency::label

    fn Currency::label(self : Currency) -> String

    USD (US Dollar).

    Currency::not_equal

    fn Currency::not_equal(x : Currency, y : Currency) -> Bool

    Currency::same_as

    fn Currency::same_as(self : Currency, other : Currency) -> Bool

    Identity of a currency for arithmetic: the alphabetic code alone.

    Currency::scale

    fn Currency::scale(self : Currency) -> Int64

    How many minor units make one major unit: 100 for USD, 1 for JPY.

    Currency::to_repr

    Money

    pub(all) struct Money {
    minor : Int64
    currency : Currency
    } derive(
    Debug
    )

    An amount of money: an integer number of minor units plus its currency.
    impl Eq for Money

    Money::abs

    fn Money::abs(self : Money) -> Money raise MoneyError

    Money::add

    fn Money::add(self : Money, other : Money) -> Money raise MoneyError

    Sum of two amounts in the same currency.

    Money::add_percent

    fn Money::add_percent(self : Money, percent : Percent, mode? : RoundingMode) -> Money raise MoneyError

    The amount plus the percentage, e.g. net plus tax.

    Money::allocate

    fn Money::allocate(self : Money, parts : Int) -> Array[Money] raise MoneyError

    Splits the amount into parts equal shares, handing the minor units that do not divide evenly to the earliest parts.

    10.00 into 3 gives 3.34, 3.33, 3.33.

    Money::allocate_percents

    fn Money::allocate_percents(self : Money, percents : Array[Percent]) -> Array[Money] raise MoneyError

    Splits the amount in proportion to percentages.

    The percentages are weights, not a required sum: [25, 25, 25] leaves the final quarter to be assigned to the largest remainders rather than dropped. This is what an expense splitter and a revenue share calculation need.

    Money::allocate_ratios

    fn Money::allocate_ratios(self : Money, weights : Array[Int64]) -> Array[Money] raise MoneyError

    Splits the amount by weights, keeping the total exact.

    The weights are relative, so [1, 1, 1] splits three ways and [3, 1] gives three quarters to the first part. A negative weight is refused rather than interpreted.

    Money::compare

    fn Money::compare(self : Money, other : Money) -> Int raise MoneyError

    Total order over amounts of the same currency, used by sorting and by min and max.

    Money::convert

    fn Money::convert(self : Money, target : Currency, rate : Rate, mode? : RoundingMode) -> Money raise MoneyError

    Converts an amount into another currency with an explicit rate.

    The amount is multiplied by the rate and by the ratio of the two minor units in arbitrary precision, then rounded exactly once, so the result carries no double-rounding error. No rate is fetched from anywhere: the caller supplies it, which keeps conversion deterministic and offline.

    Money::debug_string

    fn Money::debug_string(self : Money) -> String

    -12.34 USD, a debugging form. Use the format package for anything a user is meant to read.

    Money::div

    fn Money::div(self : Money, divisor : Int, mode? : RoundingMode) -> Money raise MoneyError

    Money::div_int64

    fn Money::div_int64(self : Money, divisor : Int64, mode? : RoundingMode) -> Money raise MoneyError

    Division by a whole number, rounded once under mode.

    Money::equal

    fn Money::equal(self : Money, other : Money) -> Bool

    Money::fraction

    fn Money::fraction(self : Money) -> Int64

    The fraction in minor units, always non-negative.

    Money::is_negative

    fn Money::is_negative(self : Money) -> Bool

    Money::is_positive

    fn Money::is_positive(self : Money) -> Bool

    Money::is_zero

    fn Money::is_zero(self : Money) -> Bool

    Money::max

    fn Money::max(self : Money, other : Money) -> Money raise MoneyError

    Money::min

    fn Money::min(self : Money, other : Money) -> Money raise MoneyError

    Money::mul

    fn Money::mul(self : Money, factor : Int) -> Money raise MoneyError

    Multiplication by a whole number.

    Money::mul_int64

    fn Money::mul_int64(self : Money, factor : Int64) -> Money raise MoneyError

    Money::neg

    fn Money::neg(self : Money) -> Money raise MoneyError

    Money::net_from_gross

    fn Money::net_from_gross(gross : Money, rate : Percent, mode? : RoundingMode) -> (Money, Money) raise MoneyError

    Recovers the net and the tax from a tax-inclusive amount.

    The tax is the difference between the gross and the recovered net, so the two parts always add back up to the gross. A rate of -100% or less cannot be reversed and is refused.

    Money::not_equal

    fn Money::not_equal(self : Money, other : Money) -> Bool

    Money::of_minor

    fn Money::of_minor(minor : Int64, currency : Currency) -> Money

    An amount from an exact number of minor units.

    Money::of_parts

    fn Money::of_parts(units : Int64, fraction : Int64, currency : Currency) -> Money raise MoneyError

    An amount from whole units plus a non-negative fraction in minor units.

    The fraction must be smaller than one major unit, and the sign of units decides the sign of the result: of_parts(-2L, 50L, usd) is -USD 2.50.

    Money::of_units

    fn Money::of_units(units : Int64, currency : Currency) -> Money raise MoneyError

    An amount from whole major units, e.g. of_units(12L, usd) is USD 12.00.

    Money::percent

    fn Money::percent(self : Money, percent : Percent, mode? : RoundingMode) -> Money raise MoneyError

    The part of an amount that the percentage represents.

    This is the rounding boundary of a tax or discount calculation: the product is exact and exactly one rounding step is applied.

    Money::round_cash

    fn Money::round_cash(self : Money, mode? : RoundingMode) -> Money raise MoneyError

    Rounds to the step a cash payment of this currency is settled in.

    CHF settles cash to 0.05, so 1.02 becomes 1.00 and 1.03 becomes 1.05. A currency without a separate cash rule is returned unchanged.

    Money::round_to

    fn Money::round_to(self : Money, increment : Int64, mode? : RoundingMode) -> Money raise MoneyError

    Rounds the amount to the nearest multiple of increment minor units.

    Money::same_currency

    fn Money::same_currency(self : Money, other : Money) -> Bool

    True when both amounts are in the same currency.

    Money::scale

    fn Money::scale(self : Money) -> Int64

    The number of minor units in one major unit of this amount's currency.

    Money::sign

    fn Money::sign(self : Money) -> Int

    -1, 0 or 1.

    Money::sub

    fn Money::sub(self : Money, other : Money) -> Money raise MoneyError

    Difference of two amounts in the same currency.

    Money::sub_percent

    fn Money::sub_percent(self : Money, percent : Percent, mode? : RoundingMode) -> Money raise MoneyError

    The amount with the percentage taken off, e.g. a discount.

    Money::sum

    fn Money::sum(amounts : Array[Money], fallback : Currency) -> Money raise MoneyError

    Sum of a list of amounts. An empty list is only meaningful together with the currency it would be zero in, so the caller supplies that as fallback.

    Money::tax_lines

    fn Money::tax_lines(self : Money, rates : Array[Percent], mode? : RoundingMode) -> Array[TaxLine] raise MoneyError

    One line per rate, each rate applied to the same net amount.

    This models the taxes that stack on a single net -- a sales tax plus a local levy, say -- rather than a basket of differently taxed items. The gross of each line is the net plus that line's tax, so the lines can be printed directly.

    Money::tax_on

    fn Money::tax_on(self : Money, rate : Percent, mode? : RoundingMode) -> Money raise MoneyError

    The tax an amount attracts at a rate.

    Money::to_double

    fn Money::to_double(self : Money) -> Double

    The amount as a floating point number, for reporting and for plotting.

    This is a lossy view. Never build an amount back from it: parse the decimal text instead, or the rounding error will be reintroduced.

    Money::to_repr

    Money::units

    fn Money::units(self : Money) -> Int64

    The whole units, truncated towards zero.

    Money::with_tax

    fn Money::with_tax(self : Money, rate : Percent, mode? : RoundingMode) -> Money raise MoneyError

    The net amount plus its tax.

    Money::zero

    fn Money::zero(currency : Currency) -> Money

    Zero in a given currency.

    Percent

    pub(all) struct Percent {
    bp : Int64
    } derive(Eq,
    Debug
    )

    A percentage as an exact number of basis points: 1% is 100 bp.

    Percent::as_double

    fn Percent::as_double(self : Percent) -> Double

    Percent::bp

    fn Percent::bp(self : Percent) -> Int64

    Percent::equal

    fn Percent::equal(Percent, Percent) -> Bool

    Percent::is_zero

    fn Percent::is_zero(self : Percent) -> Bool

    Percent::not_equal

    fn Percent::not_equal(x : Percent, y : Percent) -> Bool

    Percent::of_bp

    fn Percent::of_bp(bp : Int64) -> Percent

    Percent::of_ratio

    fn Percent::of_ratio(numerator : Int64, denominator : Int64, mode? : RoundingMode) -> Percent raise MoneyError

    A ratio as a percentage, rounded to the nearest basis point.

    Percent::of_whole

    fn Percent::of_whole(whole : Int64) -> Percent

    A whole number of percent, e.g. of_whole(8L) is 8%.

    Percent::parse

    fn Percent::parse(text : StringView) -> Percent raise MoneyError

    Reads a percentage such as 8.25%, 8.25 or -8.

    A leading or trailing percent sign is accepted and ignored. At most two decimal places are accepted, because a basis point is the smallest unit the type holds; anything finer is refused rather than silently rounded.

    Percent::text

    fn Percent::text(self : Percent) -> String

    825 basis points becomes 8.25%, and trailing zeros are dropped.

    Percent::to_repr

    Percent::zero

    fn Percent::zero() -> Percent

    Rate

    pub(all) struct Rate {
    scaled : Int64
    scale : Int
    } derive(Eq,
    Debug
    )

    An exchange rate, exactly scaled / 10^scale.

    Rate::as_double

    fn Rate::as_double(self : Rate) -> Double

    Rate::equal

    fn Rate::equal(Rate, Rate) -> Bool

    Rate::inverse

    fn Rate::inverse(self : Rate, mode? : RoundingMode) -> Rate raise MoneyError

    The reciprocal, with twelve decimal places and one rounding step.

    Rate::not_equal

    fn Rate::not_equal(x : Rate, y : Rate) -> Bool

    Rate::of_scaled

    fn Rate::of_scaled(scaled : Int64, scale : Int) -> Rate?

    Builds a rate from an integer and a number of decimal places.

    None when the number of places is outside 0..=18.

    Rate::parse

    fn Rate::parse(text : StringView) -> Rate raise MoneyError

    Reads a decimal rate such as 7.2451, up to nine decimal places.

    Rate::to_repr

    RoundingMode

    pub(all) enum RoundingMode {
    HalfUp
    HalfDown
    HalfEven
    Up
    Down
    Ceiling
    Floor
    } derive(Eq,
    Debug
    )

    How a value that falls between two minor units is resolved.

    The names are the ones accounting and payment code uses. HalfUp is the common commercial default, HalfEven is banker's rounding, Ceiling and Floor are towards positive and negative infinity.

    RoundingMode::equal

    RoundingMode::label

    fn RoundingMode::label(self : RoundingMode) -> String

    RoundingMode::not_equal

    fn RoundingMode::not_equal(x : RoundingMode, y : RoundingMode) -> Bool

    TaxLine

    pub(all) struct TaxLine {
    rate : Percent
    net : Money
    tax : Money
    gross : Money
    } derive(
    Debug
    )

    One rate's worth of an invoice: the net it applied to, the tax it produced and the gross that results.

    TaxLine::to_repr

    add_checked

    fn add_checked(left : Int64, right : Int64, operation : String) -> Int64 raise MoneyError

    Checked addition; operation names the caller in the error.

    div_round

    fn div_round(numerator : Int64, denominator : Int64, mode : RoundingMode) -> Int64 raise MoneyError

    Rounds numerator / denominator to an integer.

    max_amount

    fn max_amount() -> Int64

    The largest amount an `Int64` can hold in minor units.

    min_amount

    fn min_amount() -> Int64

    The smallest amount an `Int64` can hold in minor units.

    mul_checked

    fn mul_checked(left : Int64, right : Int64, operation : String) -> Int64 raise MoneyError

    Checked multiplication.

    mul_div_round

    fn mul_div_round(left : Int64, right : Int64, divisor : Int64, mode : RoundingMode) -> Int64 raise MoneyError

    Computes left * right / divisor exactly and rounds once, at the end.

    This is the shape of every rate application in the library: apply a percentage, add tax, convert a currency. Forming the product in arbitrary precision means an intermediate that does not fit 64 bits cannot silently wrap, and rounding once at the end avoids the double-rounding error of rounding the product before dividing.

    pow10

    fn pow10(exponent : Int) -> Int64?

    Ten to the power of exponent, or None when the result would not fit an Int64. This bounds every exponent in the library at 18.

    sub_checked

    fn sub_checked(left : Int64, right : Int64, operation : String) -> Int64 raise MoneyError

    Checked subtraction.