Skip to content

Repository files navigation

multiplatform-money

CI License Kotlin Platforms Maven Central

A currency-aware Money type for Kotlin Multiplatform, with exact minor-unit arithmetic, explicit rounding, and sum-exact allocation.

⚠️ Pre-1.0: the API may change between minor versions.

Features

  • Money value type backed by overflow-checked Long minor units – no floating point anywhere
  • Amounts always carry their currency's exact scale: 2 digits for USD, 0 for JPY, 3 for BHD
  • Operations that can lose precision (times, div, percent) take an explicit Rounding mode
  • Sum-exact allocation: split an amount into parts or by ratios without ever losing a minor unit
  • Currency conversion through an explicit ExchangeRate
  • Bundled ISO 4217 currency table, generated from the published ISO list; interned instances, with non-ISO currencies via Currency.custom("BTC", minorUnitDigits = 8)
  • kotlinx-serialization support with a compact string format and a self-describing structured format
  • Locale-aware formatting through the platform's own formatter (java.text.NumberFormat, NSNumberFormatter)
  • Optional SQLDelight column adapters

Installation

commonMain.dependencies {
    implementation("com.adrianczuczka:multiplatform-money:0.1.0")
}

Optional SQLDelight adapters:

commonMain.dependencies {
    implementation("com.adrianczuczka:multiplatform-money-sqldelight:0.1.0")
}

Usage

Basic arithmetic

val price = Money.parse("USD", "19.99")
val subtotal = price * 3                      // exact: USD 59.97
val total = subtotal + Money.parse("USD", "5.00")

total + Money.parse("EUR", "1.00")            // throws CurrencyMismatchException

Same-currency addition, subtraction, negation, and multiplication by an integer are exact. Mixing currencies throws rather than converting implicitly.

Rounding

Operations that can produce sub-minor-unit results take a Rounding mode, so the choice is visible at the call site:

val tax = subtotal.percent("8.25", Rounding.HALF_EVEN)
val third = subtotal.div("3", Rounding.HALF_EVEN)
val discounted = subtotal.times("0.85", Rounding.HALF_UP)

All of these also accept BigDecimal. Rounding follows the java.math.RoundingMode naming: HALF_EVEN, HALF_UP, HALF_DOWN, UP, DOWN, CEILING, FLOOR.

Splitting amounts

allocate splits an amount so the parts always sum back exactly:

Money.parse("USD", "10.00").allocate(3)       // [3.34, 3.33, 3.33]
Money.parse("USD", "0.05").allocate(3, 7)     // [0.02, 0.03]

Ratio splits use largest-remainder apportionment, so leftover units go to the parts with the largest fractional shares. Splits mirror under negation: allocating a refund produces exactly the negated parts of the original charge.

Currency conversion

Conversion goes through an explicit ExchangeRate, keeping the rate's source and rounding at the call site:

val rate = ExchangeRate(Currency.of("USD"), Currency.of("EUR"), "0.9234")
val inEuros = rate.convert(total, Rounding.HALF_EVEN)

Formatting

Money.toString() produces a stable wire format ("USD 12.34"). For display, MoneyFormatter delegates to the platform formatter:

MoneyFormatter("fr-FR").format(Money.parse("EUR", "1234.56"))   // "1 234,56 €"
MoneyFormatter("en-US").format(Money.parse("USD", "1234.56"))   // "$1,234.56"

Serialization

The default format is self-describing, so persisting with it is safe by construction:

@Serializable
data class LedgerEntry(val amount: Money)
// {"amount":{"currency":"USD","numericCode":840,"minorUnits":1234,"scale":2,"v":1}}

A compact string format is available per property for transport and logs:

@Serializable
data class Receipt(
    @Serializable(with = MoneySerializer::class)
    val total: Money                          // "USD 12.34"
)

The structured default round-trips retired ISO codes and custom currencies, and rejects payloads whose scale contradicts the ISO table. The compact format resolves scale through the table on read, so it round-trips ISO codes only. StrictMoneyStructuredSerializer additionally requires the code to exist in the ISO table.

Standalone Currency fields follow the same pattern: structured by default ({"code":"USD","numericCode":840,"scale":2,"v":1}), with CurrencySerializer as the compact code-string opt-in. Structured payloads always carry a v version field for future format evolution. The compact formats and the SQLDelight adapters decode through the ISO table, so they refuse to write non-ISO currencies rather than produce data they could never read back.

Persistence

For database storage, two plain columns keep the schema self-describing with no adapter needed:

CREATE TABLE payment(
    currency TEXT NOT NULL,        -- "USD"
    amount_minor INTEGER NOT NULL  -- 1234 == USD 12.34
);
val money = Money.ofMinor(Currency.of(row.currency), row.amount_minor)

The multiplatform-money-sqldelight artifact provides single-column TEXT adapters (MoneyColumnAdapter, CurrencyColumnAdapter) for simpler tables. A Room TypeConverter is small enough to define in your own project:

class MoneyConverters {
    @TypeConverter fun fromMoney(value: Money): String = value.toString()
    @TypeConverter fun toMoney(value: String): Money = Money.parse(value)
}

Testing

Money, Currency, and ExchangeRate are plain immutable values with no platform dependencies, so tests that use them need no mocks or fakes.

Supported platforms

Platform Targets Formatter backend
Android AGP KMP library (minSdk 26) java.text.NumberFormat
iOS iosArm64, iosSimulatorArm64, iosX64 NSNumberFormatter
macOS macosArm64, macosX64 NSNumberFormatter
JVM jvm (17+) java.text.NumberFormat

Design notes

  • Amounts are Long minor units. Addition, subtraction, and integer multiplication are exact, and overflow throws instead of wrapping. An amount can never hold more decimal places than its currency allows.
  • Rounding is a parameter, not a default. Which mode to use is a business decision, so it appears in code where it can be reviewed.
  • Conversion is not a method on Money. An exchange rate has a source and a timestamp; modeling it as its own object keeps those decisions with the caller.
  • Currency identity is controlled. ISO currencies are interned from the bundled table, and Currency.custom refuses codes that contradict it – an instance carrying an ISO code always agrees with the table on scale.
  • The multiplatform BigDecimal is part of the API. High-precision operations deliberately expose ionspin bignum types; String overloads cover common call sites without it. This dependency is a long-term commitment.
  • Formatting delegates to the platform. Locale data ages quickly; the OS's own formatter keeps symbols and grouping current.
  • High-precision intermediates. For multi-step calculations (interest, FX chains), do the intermediate math in BigDecimal via money.amount and convert back once with Money.of(currency, value, rounding).

Limitations

  • Amounts are Long minor units, and currencies may declare at most 9 decimal digits. This covers fiat and BTC-style satoshis; 18-decimal tokens would need the planned BigMoney type.
  • The bundled ISO table is a snapshot, regenerated by tools/generate_iso4217.py; it does not update at runtime. Codes ISO lists without a defined exponent (metals like XAU, fund codes like XDR, and XXX/XTS) are not bundled – define them with Currency.custom. ISO specifies no scale for these, so pick one per system and keep it consistent: amounts at different scales deliberately refuse to mix. A release that regenerates the table always bumps the minor version, since structured payloads are validated against it.
  • The opt-in compact serializer resolves scale through the ISO table, so it only round-trips ISO codes; the structured default has no such restriction.
  • div, times, and percent discard the rounded-off fraction. allocate is the residual-free way to split an amount; remainder-returning arithmetic is planned.

Roadmap

  • BigMoney – currency-tagged arbitrary-precision intermediates
  • Remainder-returning arithmetic (divideWithRemainder)
  • wasmJs / js targets with Intl.NumberFormat-backed formatting

Contributing

Issues and pull requests are welcome.

License

Apache 2.0 – see LICENSE.

About

A currency-aware Money type for Kotlin Multiplatform – exact minor-unit arithmetic, mandatory explicit rounding, sum-exact allocation

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages