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.
Moneyvalue type backed by overflow-checkedLongminor 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 explicitRoundingmode - 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
commonMain.dependencies {
implementation("com.adrianczuczka:multiplatform-money:0.1.0")
}Optional SQLDelight adapters:
commonMain.dependencies {
implementation("com.adrianczuczka:multiplatform-money-sqldelight:0.1.0")
}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 CurrencyMismatchExceptionSame-currency addition, subtraction, negation, and multiplication by an integer are exact. Mixing currencies throws rather than converting implicitly.
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.
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.
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)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"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.
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)
}Money, Currency, and ExchangeRate are plain immutable values with no platform dependencies, so tests that use them need no mocks or fakes.
| 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 |
- Amounts are
Longminor 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.customrefuses codes that contradict it – an instance carrying an ISO code always agrees with the table on scale. - The multiplatform
BigDecimalis 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
BigDecimalviamoney.amountand convert back once withMoney.of(currency, value, rounding).
- Amounts are
Longminor units, and currencies may declare at most 9 decimal digits. This covers fiat and BTC-style satoshis; 18-decimal tokens would need the plannedBigMoneytype. - 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 withCurrency.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, andpercentdiscard the rounded-off fraction.allocateis the residual-free way to split an amount; remainder-returning arithmetic is planned.
-
BigMoney– currency-tagged arbitrary-precision intermediates - Remainder-returning arithmetic (
divideWithRemainder) -
wasmJs/jstargets withIntl.NumberFormat-backed formatting
Issues and pull requests are welcome.
Apache 2.0 – see LICENSE.