
Currency-aware Money value type with exact minor-unit arithmetic, explicit rounding, sum-exact allocation, exchange-rate conversion, bundled ISO currency data, compact/structured serialization and locale-aware formatting.
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.
Money value type backed by overflow-checked Long minor units – no floating point anywheretimes, div, percent) take an explicit Rounding modeExchangeRate
Currency.custom("BTC", minorUnitDigits = 8)
java.text.NumberFormat, NSNumberFormatter)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 |
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.Money. An exchange rate has a source and a timestamp; modeling it as its own object keeps those decisions with the caller.Currency.custom refuses codes that contradict it – an instance carrying an ISO code always agrees with the table on scale.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.BigDecimal via money.amount and convert back once with Money.of(currency, value, rounding).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.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.div, times, and percent discard the rounded-off fraction. allocate is the residual-free way to split an amount; remainder-returning arithmetic is planned.BigMoney – currency-tagged arbitrary-precision intermediatesdivideWithRemainder)wasmJs / js targets with Intl.NumberFormat-backed formattingIssues and pull requests are welcome.
Apache 2.0 – see LICENSE.
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.
Money value type backed by overflow-checked Long minor units – no floating point anywheretimes, div, percent) take an explicit Rounding modeExchangeRate
Currency.custom("BTC", minorUnitDigits = 8)
java.text.NumberFormat, NSNumberFormatter)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 |
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.Money. An exchange rate has a source and a timestamp; modeling it as its own object keeps those decisions with the caller.Currency.custom refuses codes that contradict it – an instance carrying an ISO code always agrees with the table on scale.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.BigDecimal via money.amount and convert back once with Money.of(currency, value, rounding).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.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.div, times, and percent discard the rounded-off fraction. allocate is the residual-free way to split an amount; remainder-returning arithmetic is planned.BigMoney – currency-tagged arbitrary-precision intermediatesdivideWithRemainder)wasmJs / js targets with Intl.NumberFormat-backed formattingIssues and pull requests are welcome.
Apache 2.0 – see LICENSE.