Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,52 @@ All notable changes to the Kurrency library will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-08-20

A correctness release for the options API. Formatted output changes for three cases
described below — no source-level breaking change, but golden-file tests will move.

### Added
- **`CurrencyFormat.getCurrencySymbolOrDefault(currencyCode, default)`** — resolves the
locale-aware currency symbol from the platform, falling back to `default`. Ships with a
default implementation returning `default`, so existing implementors of the interface
keep compiling. (#18)

### Fixed
- **A leading ISO code or currency name ran into the amount.** `SymbolDisplay.ISO_CODE`
rendered `AUD1,234.56` and `SymbolDisplay.NAME` rendered `Australian Dollars1,234.56`.
A symbol abuts the amount; a code or a name is a word and is now separated from it.
Trailing indicators already had their space and are unchanged.
- **The options API lost currency disambiguation.** `formatWithOptions` and
`formatMinorUnitsWithOptions` read their symbol from `CurrencyMetadata`, which holds one
generic symbol per currency, so every dollar currency rendered as a bare `$` and AUD was
indistinguishable from USD. The symbol now comes from the platform's locale data —
`Currency.getSymbol` (JVM), ICU `SYMBOL_NAME` (Android), `NSNumberFormatter.currencySymbol`
(iOS), `Intl.NumberFormat.formatToParts` (JS/Wasm) — and falls back to the metadata symbol
where a platform has none. To a US reader AUD is now `A$100` while USD stays `$100`. (#18)
- **`Kurrency.fromCode` stored the code as given.** `fromCode("aud").code` returned `"aud"`,
so a caller comparing it against a constant silently failed. ISO 4217 codes are upper
case and the code is now normalised, matching the validation that already accepted either case.
- **Platform formatting failures were indistinguishable from success.** Every platform
implementation ended its formatting path with `getOrElse { amount }`, returning the
unformatted input — a plausible-looking number — and because the exception was already
caught and discarded, `formatCurrencyStyleResult` could never report a failure. The
lenient methods are unchanged and still hand back the original amount; the `Result` API
now reports `KurrencyError.FormattingFailure`. (#20)
- **`LOCALE_DEFAULT` could silently mean English placement.** `detectSymbolPosition` derived
the placement from a swallowing platform call, so a platform that could not format returned
the sample unchanged, no symbol was found, and every locale fell back to `LEADING` — a German
locale rendering `$1.234,56` with nothing reported. Failures are now surfaced and each
fallback logs why it was taken. (#21)

### Internal
- The seven duplicated lenient fallbacks across the platform implementations collapse into a
single `formatLeniently` helper, and `CurrencyFormatterImpl` gained an internal
`formatOrThrow` used by the `Result` paths.
- The locale-aware symbol and its placement are both fixed for a formatter's lifetime, so
each is resolved once per currency and cached in a `@Volatile` copy-on-write map instead of
building a platform formatter on every call. (#21)

## [0.4.0] - 2026-06-05

A feature and correctness release. **Contains one breaking change** — the Compose
Expand Down Expand Up @@ -204,12 +250,14 @@ No breaking changes. This release is fully backward compatible.

| Version | Release Date | Support Status |
|---------|--------------|----------------|
| 0.2.3 | 2025-01-06 | ✅ Current |
| 0.2.2 | 2024 | ⚠️ Deprecated |
| 0.2.1 | 2024 | ⚠️ Deprecated |
| 0.5.0 | 2026-08-20 | ✅ Current |
| 0.4.0 | 2026-06-05 | ⚠️ Superseded |
| 0.3.1 | 2026-04-07 | ⚠️ Deprecated |
| 0.2.x | 2024–2025 | ⚠️ Deprecated |

---

[0.5.0]: https://github.com/Kimplify/Kurrency/compare/v0.4.0...v0.5.0
[0.3.1]: https://github.com/Kimplify/Kurrency/compare/v0.3.0...v0.3.1
[0.2.3]: https://github.com/Kimplify/Kurrency/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/Kimplify/Kurrency/compare/v0.2.1...v0.2.2
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ Type-safe currency formatting for Kotlin Multiplatform, with locale-aware output

```kotlin
dependencies {
implementation("org.kimplify:kurrency-core:0.4.0")
implementation("org.kimplify:kurrency-core:0.5.0")
}
```

### Compose integration (optional)

```kotlin
dependencies {
implementation("org.kimplify:kurrency-core:0.4.0")
implementation("org.kimplify:kurrency-compose:0.4.0")
implementation("org.kimplify:kurrency-core:0.5.0")
implementation("org.kimplify:kurrency-compose:0.5.0")
}
```

Expand Down Expand Up @@ -79,7 +79,18 @@ A currency always uses the same number of fraction digits, regardless of where i

- USD → 2 digits, JPY → 0 digits, BHD → 3 digits

The **locale** only controls presentation: decimal separator, grouping separator, symbol placement, and spacing.
The **locale** controls presentation — decimal separator, grouping separator, symbol placement, and
spacing — and it also picks the symbol itself, which is what keeps currencies sharing the `$` glyph
apart:

```kotlin
val us = CurrencyFormatter(KurrencyLocale.US)

us.formatMinorUnitsWithOptions(10_000, "AUD", CurrencyFormatOptions.STANDARD) // "A$100.00"
us.formatMinorUnitsWithOptions(10_000, "USD", CurrencyFormatOptions.STANDARD) // "$100.00"
```

An Australian reader sees plain `$100.00` for AUD, because that is the symbol in their locale.

```kotlin
val us = CurrencyFormatter(KurrencyLocale.US)
Expand Down Expand Up @@ -258,6 +269,7 @@ The shared surface implemented per platform. These methods return a plain `Strin
```kotlin
interface CurrencyFormat {
fun getFractionDigitsOrDefault(currencyCode: String, default: Int = 2): Int
fun getCurrencySymbolOrDefault(currencyCode: String, default: String): String
fun formatCurrencyStyle(amount: String, currencyCode: String): String
fun formatIsoCurrencyStyle(amount: String, currencyCode: String): String
fun formatCompactStyle(amount: String, currencyCode: String): String
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
appVersionName = "0.4.0"
appVersionName = "0.5.0"

# SDK Versions
material3 = "1.9.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,45 @@ actual class CurrencyFormatterImpl actual constructor(kurrencyLocale: KurrencyLo
}
}

actual override fun formatCurrencyStyle(
amount: String,
currencyCode: String
): String {
return formatCurrencyOrOriginal(amount, currencyCode, useIsoCode = false)
override fun getCurrencySymbolOrDefault(currencyCode: String, default: String): String {
return runCatching {
Currency.getInstance(currencyCode.uppercase()).getName(
platformLocale,
Currency.SYMBOL_NAME,
booleanArrayOf(false),
)
}.getOrElse { throwable ->
KurrencyLog.w { "Failed to get symbol for $currencyCode: ${throwable.message}" }
default
}
}

actual override fun formatIsoCurrencyStyle(
actual override fun formatCurrencyStyle(amount: String, currencyCode: String): String =
formatLeniently(amount, currencyCode) {
formatOrThrow(amount, currencyCode, PlatformFormatStyle.SYMBOL)
}

actual override fun formatIsoCurrencyStyle(amount: String, currencyCode: String): String =
formatLeniently(amount, currencyCode) {
formatOrThrow(amount, currencyCode, PlatformFormatStyle.ISO_CODE)
}

actual override fun formatCompactStyle(amount: String, currencyCode: String): String =
formatLeniently(amount, currencyCode) {
formatOrThrow(amount, currencyCode, PlatformFormatStyle.COMPACT)
}

internal actual fun formatOrThrow(
amount: String,
currencyCode: String
): String {
return formatCurrencyOrOriginal(amount, currencyCode, useIsoCode = true)
currencyCode: String,
style: PlatformFormatStyle,
): String = when (style) {
PlatformFormatStyle.SYMBOL -> format(amount, currencyCode, useIsoCode = false)
PlatformFormatStyle.ISO_CODE -> format(amount, currencyCode, useIsoCode = true)
PlatformFormatStyle.COMPACT -> formatCompact(amount, currencyCode)
}

actual override fun formatCompactStyle(amount: String, currencyCode: String): String {
private fun formatCompact(amount: String, currencyCode: String): String {
return runCatching {
val currency = Currency.getInstance(currencyCode.uppercase())
val normalized = amount.normalizeAmount().trim()
Expand All @@ -53,12 +77,15 @@ actual class CurrencyFormatterImpl actual constructor(kurrencyLocale: KurrencyLo
compactFormat.currency = currency
compactFormat.format(value.toDouble())
}.getOrElse { throwable ->
KurrencyLog.w { "Compact formatting failed for $currencyCode with amount $amount: ${throwable.message}" }
formatCurrencyStyle(amount, currencyCode)
KurrencyLog.w {
"Compact formatting failed for $currencyCode with amount $amount, " +
"falling back to standard: ${throwable.message}"
}
format(amount, currencyCode, useIsoCode = false)
}
}

private fun formatCurrencyOrOriginal(
private fun format(
amount: String,
currencyCode: String,
useIsoCode: Boolean
Expand All @@ -80,10 +107,9 @@ actual class CurrencyFormatterImpl actual constructor(kurrencyLocale: KurrencyLo
numberFormat.decimalFormatSymbols = symbols
}
numberFormat.format(value)
}.getOrElse { throwable ->
}.onFailure { throwable ->
KurrencyLog.w { "Formatting failed for $currencyCode with amount $amount: ${throwable.message}" }
amount
}
}.getOrThrow()
}

actual override fun parseCurrencyAmount(formattedText: String, currencyCode: String): Double? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ interface CurrencyFormat {
*/
fun getFractionDigitsOrDefault(currencyCode: String, default: Int = 2): Int

/**
* Gets the locale-aware currency symbol for a currency code, returning [default] on error or
* where the platform has no locale-specific data.
*
* The symbol depends on the reader's locale, not only on the currency: `AUD` is `"A$"` to a
* US reader and `"$"` to an Australian one, which is what keeps currencies sharing the `$`
* glyph apart. A static table cannot express that, so this delegates to the platform.
*
* @param currencyCode The ISO 4217 currency code (e.g., "USD", "AUD")
* @param default The symbol to fall back to when the platform cannot supply one
* @return The locale-aware symbol, or [default]
*/
fun getCurrencySymbolOrDefault(currencyCode: String, default: String): String = default

/**
* Formats an amount in currency style, returning original value on error.
* This is the recommended method for UI display.
Expand Down
Loading
Loading