diff --git a/CHANGELOG.md b/CHANGELOG.md index 7466aac..054fb26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 1955851..a8dfb9e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ 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") } ``` @@ -47,8 +47,8 @@ dependencies { ```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") } ``` @@ -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) @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6341f9c..ec10867 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -appVersionName = "0.4.0" +appVersionName = "0.5.0" # SDK Versions material3 = "1.9.0" diff --git a/kurrency-core/src/androidMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt b/kurrency-core/src/androidMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt index da36c68..22bf426 100644 --- a/kurrency-core/src/androidMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt +++ b/kurrency-core/src/androidMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt @@ -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() @@ -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 @@ -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? { diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormat.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormat.kt index b181181..bf2ea3c 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormat.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormat.kt @@ -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. diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt index c35645b..7ced8a3 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt @@ -1,5 +1,6 @@ package org.kimplify.kurrency +import kotlin.concurrent.Volatile import org.kimplify.kurrency.extensions.normalizeAmount expect class CurrencyFormatterImpl(kurrencyLocale: KurrencyLocale = KurrencyLocale.systemLocale()) : CurrencyFormat { @@ -8,6 +9,14 @@ expect class CurrencyFormatterImpl(kurrencyLocale: KurrencyLocale = KurrencyLoca override fun formatIsoCurrencyStyle(amount: String, currencyCode: String): String override fun formatCompactStyle(amount: String, currencyCode: String): String override fun parseCurrencyAmount(formattedText: String, currencyCode: String): Double? + + /** + * Formats through the platform and lets the failure out, unlike the [CurrencyFormat] methods + * above, which document a lenient fallback and keep it. [CurrencyFormatter] uses this for the + * paths that return a `Result`, so a formatting failure reaches a caller who asks for one + * instead of arriving as the unformatted input. + */ + internal fun formatOrThrow(amount: String, currencyCode: String, style: PlatformFormatStyle): String } expect fun isValidCurrency(currencyCode: String): Boolean @@ -51,11 +60,17 @@ expect fun isValidCurrency(currencyCode: String): Boolean */ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.systemLocale()) : CurrencyFormat { - private val impl: CurrencyFormat by lazy { + private val impl: CurrencyFormatterImpl by lazy { KurrencyLog.d { "Initializing CurrencyFormatter with locale: ${locale.languageTag}" } CurrencyFormatterImpl(locale) } + @Volatile + private var detectedPositions: Map = emptyMap() + + @Volatile + private var resolvedSymbols: Map = emptyMap() + override fun getFractionDigitsOrDefault(currencyCode: String, default: Int): Int = impl.getFractionDigitsOrDefault(currencyCode, default) @@ -70,7 +85,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst fun formatCompactStyleResult(amount: String, currencyCode: String): Result { return formatWithValidation(amount, currencyCode) { - Result.success(impl.formatCompactStyle(it, currencyCode)) + Result.success(impl.formatOrThrow(it, currencyCode, PlatformFormatStyle.COMPACT)) } } @@ -286,7 +301,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst */ fun formatCurrencyStyleResult(amount: String, currencyCode: String): Result { return formatWithValidation(amount, currencyCode) { - Result.success(impl.formatCurrencyStyle(it, currencyCode)) + Result.success(impl.formatOrThrow(it, currencyCode, PlatformFormatStyle.SYMBOL)) } } @@ -299,7 +314,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst */ fun formatIsoCurrencyStyleResult(amount: String, currencyCode: String): Result { return formatWithValidation(amount, currencyCode) { - Result.success(impl.formatIsoCurrencyStyle(it, currencyCode)) + Result.success(impl.formatOrThrow(it, currencyCode, PlatformFormatStyle.ISO_CODE)) } } @@ -342,7 +357,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst } val metadata = CurrencyMetadata.parse(currencyCode).getOrNull() - val symbol = metadata?.symbol ?: "" + val symbol = resolveSymbol(currencyCode, metadata?.symbol ?: "") val isNegative = Decimals.isNegative(normalizedAmount) val absAmount = Decimals.abs(normalizedAmount) @@ -367,12 +382,14 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst SymbolPosition.TRAILING -> SymbolPosition.TRAILING } - // Assemble result with currency indicator + // Assemble result with currency indicator. A symbol abuts the amount; an ISO + // code or a currency name is a word and needs separating from it. + val separator = if (options.symbolDisplay == SymbolDisplay.SYMBOL) "" else " " var result = when { currencyIndicator.isEmpty() -> formattedAbsAmount - effectivePosition == SymbolPosition.LEADING || effectivePosition == SymbolPosition.LOCALE_DEFAULT -> - "$currencyIndicator$formattedAbsAmount" - else -> "$formattedAbsAmount $currencyIndicator" + effectivePosition == SymbolPosition.TRAILING -> + "$formattedAbsAmount $currencyIndicator" + else -> "$currencyIndicator$separator$formattedAbsAmount" } // Handle negative style @@ -433,25 +450,52 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst } /** - * Detects whether the locale places the currency symbol before or after the number - * by formatting a sample amount and checking the position. + * Detects whether the locale places the currency symbol before or after the number by + * formatting a sample amount once per currency and caching the answer: the locale is fixed for + * the lifetime of this formatter, so the placement cannot change under it. + */ + /** + * The platform knows the locale-aware symbol ("A$" for AUD to a US reader); [CurrencyMetadata] + * holds one generic symbol per currency and is the fallback. Resolved once per currency for the + * same reason the placement is: the locale cannot change under this formatter, and asking the + * platform means building a platform formatter on every call. */ + private fun resolveSymbol(currencyCode: String, fallback: String): String { + resolvedSymbols[currencyCode]?.let { return it } + val resolved = impl.getCurrencySymbolOrDefault(currencyCode, fallback) + resolvedSymbols = resolvedSymbols + (currencyCode to resolved) + return resolved + } + private fun detectSymbolPosition(currencyCode: String, symbol: String): SymbolPosition { if (symbol.isEmpty()) return SymbolPosition.LEADING + detectedPositions[currencyCode]?.let { return it } + val detected = resolveSymbolPosition(currencyCode, symbol) + detectedPositions = detectedPositions + (currencyCode to detected) + return detected + } - // Format a sample to detect position - val sample = impl.formatCurrencyStyle("1", currencyCode) + private fun resolveSymbolPosition(currencyCode: String, symbol: String): SymbolPosition { + val sample = runCatching { impl.formatOrThrow("1", currencyCode, PlatformFormatStyle.SYMBOL) } + .getOrElse { throwable -> + KurrencyLog.w { + "Symbol position for $currencyCode falls back to LEADING: " + + "platform formatting failed: ${throwable.message}" + } + return SymbolPosition.LEADING + } - // Check if symbol appears before or after the digit val symbolIndex = sample.indexOf(symbol) val digitIndex = sample.indexOfFirst { it.isDigit() } - - return when { - symbolIndex < 0 -> SymbolPosition.LEADING // fallback - digitIndex < 0 -> SymbolPosition.LEADING // fallback - symbolIndex < digitIndex -> SymbolPosition.LEADING - else -> SymbolPosition.TRAILING + if (symbolIndex < 0 || digitIndex < 0) { + KurrencyLog.w { + "Symbol position for $currencyCode falls back to LEADING: " + + "no $symbol and no digit in \"$sample\"" + } + return SymbolPosition.LEADING } + + return if (symbolIndex < digitIndex) SymbolPosition.LEADING else SymbolPosition.TRAILING } private fun formatWithValidation( @@ -472,10 +516,11 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst } KurrencyLog.d { "Formatting: amount=$amount, currency=$currencyCode" } - return format(amount) - .onFailure { throwable -> + return runCatching { format(amount).getOrThrow() } + .recoverCatching { throwable -> val error = KurrencyError.FormattingFailure(currencyCode, amount, throwable) KurrencyLog.e(throwable) { error.errorMessage } + throw error } } diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Kurrency.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Kurrency.kt index 3e877da..369ebc6 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Kurrency.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Kurrency.kt @@ -17,9 +17,14 @@ class Kurrency private constructor(val code: String) { get() = CurrencyFormatter.getFractionDigitsOrDefault(code) companion object Companion { + /** + * ISO 4217 codes are upper case, so the code is normalised before it is stored. Without + * this, `fromCode("aud").code` returns `"aud"` and a caller comparing it against a + * constant silently fails. + */ fun fromCode(code: String): Result { return if (isValid(code)) { - Result.success(Kurrency(code)) + Result.success(Kurrency(code.uppercase())) } else { Result.failure(KurrencyError.InvalidCurrencyCode(code)) } diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/PlatformFormatStyle.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/PlatformFormatStyle.kt new file mode 100644 index 0000000..ccf48aa --- /dev/null +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/PlatformFormatStyle.kt @@ -0,0 +1,26 @@ +package org.kimplify.kurrency + +/** + * Which platform formatting path [CurrencyFormatterImpl.formatOrThrow] should take. Internal: the + * public surface expresses the same choice through the separate [CurrencyFormat] methods. + */ +internal enum class PlatformFormatStyle { + SYMBOL, + ISO_CODE, + COMPACT, +} + +/** + * Applies the lenient contract the [CurrencyFormat] methods document — the original amount is + * returned when the platform cannot format it — in one place, so the platform implementations do not + * each decide it for themselves. The failure still reaches callers who ask for a `Result`, because + * [CurrencyFormatter] takes the throwing path instead of this one. + */ +internal inline fun formatLeniently( + amount: String, + currencyCode: String, + format: () -> String, +): String = runCatching { format() }.getOrElse { throwable -> + KurrencyLog.w { "Formatting failed for $currencyCode with amount $amount: ${throwable.message}" } + amount +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/CurrencyIndicatorSeparatorTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/CurrencyIndicatorSeparatorTest.kt new file mode 100644 index 0000000..377d8fa --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/CurrencyIndicatorSeparatorTest.kt @@ -0,0 +1,92 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A currency symbol abuts the amount; an ISO code or a currency name is a word and has to be + * separated from it. Before these cases existed the assembly used one rule for every indicator, + * so a leading code rendered as "AUD1,234.56". The locale is pinned because the symbol + * itself is locale-dependent (AUD is "A$" to a US reader) and only the separator is under test. + */ +class CurrencyIndicatorSeparatorTest { + + private val aud = Kurrency.fromCode("AUD").getOrThrow() + + @Test + fun symbolAbutsTheAmount() { + assertEquals("A$1,234.56", format(SymbolDisplay.SYMBOL)) + } + + @Test + fun isoCodeIsSeparatedFromTheAmount() { + assertEquals("AUD 1,234.56", format(SymbolDisplay.ISO_CODE)) + } + + @Test + fun currencyNameIsSeparatedFromTheAmount() { + assertEquals("Australian Dollars 1,234.56", format(SymbolDisplay.NAME)) + } + + @Test + fun theIsoPresetIsSeparatedToo() { + assertEquals( + "AUD 1,234.56", + aud.formatAmountWithOptions("1234.56", CurrencyFormatOptions.ISO, KurrencyLocale.US).getOrThrow(), + ) + } + + @Test + fun noIndicatorLeavesNoStraySeparator() { + assertEquals("1,234.56", format(SymbolDisplay.NONE)) + } + + @Test + fun aTrailingIndicatorKeepsItsSingleSpace() { + assertEquals( + "1,234.56 A$", + aud.formatAmountWithOptions( + "1234.56", + CurrencyFormatOptions( + symbolDisplay = SymbolDisplay.SYMBOL, + symbolPosition = SymbolPosition.TRAILING, + ), + locale = KurrencyLocale.US, + ).getOrThrow(), + ) + } + + @Test + fun aNegativeAmountKeepsTheSeparatorInsideTheSign() { + assertEquals( + "-AUD 1,234.56", + aud.formatAmountWithOptions( + "-1234.56", + CurrencyFormatOptions(symbolDisplay = SymbolDisplay.ISO_CODE), + locale = KurrencyLocale.US, + ).getOrThrow(), + ) + } + + @Test + fun aParenthesisedNegativeWrapsTheWholeIndicatorAndAmount() { + assertEquals( + "(AUD 1,234.56)", + aud.formatAmountWithOptions( + "-1234.56", + CurrencyFormatOptions( + symbolDisplay = SymbolDisplay.ISO_CODE, + negativeStyle = NegativeStyle.PARENTHESES, + ), + locale = KurrencyLocale.US, + ).getOrThrow(), + ) + } + + private fun format(symbolDisplay: SymbolDisplay): String = + aud.formatAmountWithOptions( + "1234.56", + CurrencyFormatOptions(symbolDisplay = symbolDisplay, symbolPosition = SymbolPosition.LEADING), + locale = KurrencyLocale.US, + ).getOrThrow() +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/KurrencyCodeNormalisationTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/KurrencyCodeNormalisationTest.kt new file mode 100644 index 0000000..7adc847 --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/KurrencyCodeNormalisationTest.kt @@ -0,0 +1,37 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * ISO 4217 codes are upper case. A payload carrying a lower-case code used to produce a Kurrency + * whose [Kurrency.code] compared unequal to the constant a caller was checking against. + */ +class KurrencyCodeNormalisationTest { + + @Test + fun aLowerCaseCodeIsStoredUpperCase() { + assertEquals("AUD", Kurrency.fromCode("aud").getOrThrow().code) + } + + @Test + fun aMixedCaseCodeIsStoredUpperCase() { + assertEquals("GBP", Kurrency.fromCode("gBp").getOrThrow().code) + } + + @Test + fun anUpperCaseCodeIsUnchanged() { + assertEquals("USD", Kurrency.fromCode("USD").getOrThrow().code) + } + + @Test + fun aNormalisedCodeEqualsItsConstant() { + assertEquals(Kurrency.AUD, Kurrency.fromCode("aud").getOrThrow()) + } + + @Test + fun anInvalidCodeStillFails() { + assertTrue(Kurrency.fromCode("zzz").isFailure) + } +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/LenientFacadeContractTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/LenientFacadeContractTest.kt new file mode 100644 index 0000000..9c2dd5e --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/LenientFacadeContractTest.kt @@ -0,0 +1,35 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Platform failures now propagate out of the implementations, so the lenient contract the + * non-`Result` API documents — "returns the original amount on error" — has exactly one home left: + * this facade. These tests pin it there so the leniency cannot quietly disappear with the swallow. + */ +class LenientFacadeContractTest { + + private val formatter = CurrencyFormatter(KurrencyLocale.US) + + @Test + fun anUnusableCurrencyCodeGivesTheAmountBack() { + assertEquals("1234.56", formatter.formatCurrencyStyle("1234.56", "XYZ")) + assertEquals("1234.56", formatter.formatIsoCurrencyStyle("1234.56", "XYZ")) + assertEquals("1234.56", formatter.formatCompactStyle("1234.56", "XYZ")) + } + + @Test + fun theResultApiReportsWhatTheLenientApiHides() { + val result = formatter.formatCurrencyStyleResult("1234.56", "XYZ") + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun aSupportedCurrencyIsUnaffected() { + assertEquals("$1,234.56", formatter.formatCurrencyStyle("1234.56", "USD")) + } +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/LocaleAwareSymbolTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/LocaleAwareSymbolTest.kt new file mode 100644 index 0000000..bda8ef5 --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/LocaleAwareSymbolTest.kt @@ -0,0 +1,70 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The options path resolved its symbol from [CurrencyMetadata], which holds one generic symbol per + * currency, so every dollar currency rendered as a bare `$`. Migrating from the locale-aware + * formatters to the options API silently lost the disambiguation that keeps AUD and USD apart. + */ +class LocaleAwareSymbolTest { + + private val us = CurrencyFormatter(KurrencyLocale.US) + + @Test + fun audToAUsReaderKeepsItsDisambiguatingSymbol() { + assertEquals( + "A$100", + us.formatMinorUnitsWithOptions( + 10_000, + "AUD", + CurrencyFormatOptions { hideZeroFractionDigits = true }, + ).getOrThrow(), + ) + } + + @Test + fun usdToAUsReaderStaysAPlainDollarSign() { + assertEquals( + "$100", + us.formatMinorUnitsWithOptions( + 10_000, + "USD", + CurrencyFormatOptions { hideZeroFractionDigits = true }, + ).getOrThrow(), + ) + } + + @Test + fun theOptionsPathAgreesWithTheLocaleAwarePathOnTheSymbol() { + val viaPlatform = us.formatMinorUnits(10_000, "AUD") + val viaOptions = us.formatMinorUnitsWithOptions( + 10_000, + "AUD", + CurrencyFormatOptions.STANDARD, + ).getOrThrow() + assertEquals(viaPlatform, viaOptions) + } + + @Test + fun theSameCurrencyCarriesADifferentSymbolForADifferentReader() { + val au = CurrencyFormatter(KurrencyLocale.fromLanguageTag("en-AU").getOrThrow()) + assertEquals( + "A$100.00", + us.formatMinorUnitsWithOptions(10_000, "AUD", CurrencyFormatOptions.STANDARD).getOrThrow(), + ) + assertEquals( + "$100.00", + au.formatMinorUnitsWithOptions(10_000, "AUD", CurrencyFormatOptions.STANDARD).getOrThrow(), + ) + } + + @Test + fun anIsoCodeIsUnaffectedByLocaleAwareSymbolResolution() { + assertEquals( + "AUD 100.00", + us.formatMinorUnitsWithOptions(10_000, "AUD", CurrencyFormatOptions.ISO).getOrThrow(), + ) + } +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/SymbolPositionDetectionTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/SymbolPositionDetectionTest.kt new file mode 100644 index 0000000..26e9a03 --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/SymbolPositionDetectionTest.kt @@ -0,0 +1,68 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [SymbolPosition.LOCALE_DEFAULT] asks the platform where the locale puts the symbol, and the symbol + * itself comes from the platform too. Both are fixed for a formatter — its locale cannot change — so + * both are resolved once per currency and cached, and a platform that cannot answer the placement + * logs its fallback instead of silently choosing English placement. + */ +class SymbolPositionDetectionTest { + + private val symbolFirst = CurrencyFormatOptions( + symbolDisplay = SymbolDisplay.SYMBOL, + symbolPosition = SymbolPosition.LOCALE_DEFAULT, + ) + + @Test + fun aUsReaderGetsTheSymbolBeforeTheAmount() { + val formatted = CurrencyFormatter(KurrencyLocale.US) + .formatWithOptions("1234.56", "USD", symbolFirst) + .getOrThrow() + assertEquals("$1,234.56", formatted) + } + + @Test + fun aGermanReaderGetsTheSymbolAfterTheAmount() { + val formatted = CurrencyFormatter(KurrencyLocale.GERMANY) + .formatWithOptions("1234.56", "EUR", symbolFirst) + .getOrThrow() + assertEquals("1.234,56 €", formatted) + } + + @Test + fun theCachedAnswerMatchesTheFirstOne() { + val formatter = CurrencyFormatter(KurrencyLocale.GERMANY) + val first = formatter.formatWithOptions("1234.56", "EUR", symbolFirst).getOrThrow() + val second = formatter.formatWithOptions("1234.56", "EUR", symbolFirst).getOrThrow() + val third = formatter.formatWithOptions("9.99", "EUR", symbolFirst).getOrThrow() + assertEquals(first, second) + assertEquals("9,99 €", third) + } + + @Test + fun interleavedCurrenciesKeepTheirOwnCachedSymbolAndPlacement() { + val formatter = CurrencyFormatter(KurrencyLocale.US) + val expected = listOf("A$1,234.56", "$1,234.56", "€1,234.56") + val codes = listOf("AUD", "USD", "EUR") + repeat(3) { + codes.forEachIndexed { index, code -> + assertEquals( + expected[index], + formatter.formatWithOptions("1234.56", code, symbolFirst).getOrThrow(), + ) + } + } + } + + @Test + fun oneCurrencysPlacementDoesNotLeakToAnother() { + val formatter = CurrencyFormatter(KurrencyLocale.US) + val euro = formatter.formatWithOptions("1234.56", "EUR", symbolFirst).getOrThrow() + val dollar = formatter.formatWithOptions("1234.56", "USD", symbolFirst).getOrThrow() + assertEquals("€1,234.56", euro) + assertEquals("$1,234.56", dollar) + } +} diff --git a/kurrency-core/src/iosMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt b/kurrency-core/src/iosMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt index 947f54d..522962d 100644 --- a/kurrency-core/src/iosMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt +++ b/kurrency-core/src/iosMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt @@ -19,6 +19,20 @@ actual class CurrencyFormatterImpl actual constructor(private val kurrencyLocale private val formattingLocale: NSLocale = kurrencyLocale.nsLocale + override fun getCurrencySymbolOrDefault(currencyCode: String, default: String): String { + return runCatching { + val formatter = NSNumberFormatter().apply { + this.locale = formattingLocale + this.currencyCode = currencyCode.uppercase() + this.numberStyle = NSNumberFormatterCurrencyStyle + } + formatter.currencySymbol ?: default + }.getOrElse { throwable -> + KurrencyLog.w { "Failed to get symbol for $currencyCode: ${throwable.message}" } + default + } + } + actual override fun getFractionDigitsOrDefault(currencyCode: String, default: Int): Int { return runCatching { val formatter = NSNumberFormatter().apply { @@ -33,14 +47,33 @@ actual class CurrencyFormatterImpl actual constructor(private val kurrencyLocale } } - actual override fun formatCurrencyStyle( + 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, NSNumberFormatterCurrencyStyle) + currencyCode: String, + style: PlatformFormatStyle, + ): String = when (style) { + PlatformFormatStyle.SYMBOL -> format(amount, currencyCode, NSNumberFormatterCurrencyStyle) + PlatformFormatStyle.ISO_CODE -> + format(amount, currencyCode, NSNumberFormatterCurrencyISOCodeStyle) + 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 normalizedAmount = amount.normalizeAmount().trim() if (normalizedAmount.isEmpty()) return amount @@ -67,20 +100,12 @@ actual class CurrencyFormatterImpl actual constructor(private val kurrencyLocale val lastDigitIndex = formatted.indexOfLast { it.isDigit() } if (lastDigitIndex < 0) return "$formatted$suffix" formatted.substring(0, lastDigitIndex + 1) + suffix + formatted.substring(lastDigitIndex + 1) - }.getOrElse { throwable -> + }.onFailure { throwable -> KurrencyLog.w { "Compact formatting failed for $currencyCode with amount $amount: ${throwable.message}" } - amount - } + }.getOrThrow() } - actual override fun formatIsoCurrencyStyle( - amount: String, - currencyCode: String - ): String { - return formatCurrencyOrOriginal(amount, currencyCode, NSNumberFormatterCurrencyISOCodeStyle) - } - - private fun formatCurrencyOrOriginal( + private fun format( amount: String, currencyCode: String, style: NSNumberFormatterStyle @@ -95,10 +120,9 @@ actual class CurrencyFormatterImpl actual constructor(private val kurrencyLocale val value = NSNumber(doubleValue) val numberFormatter = createNumberFormatter(currencyCode, style) numberFormatter.stringFromNumber(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? { diff --git a/kurrency-core/src/jsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt b/kurrency-core/src/jsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt index 110531a..dc81b3f 100644 --- a/kurrency-core/src/jsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt +++ b/kurrency-core/src/jsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt @@ -30,6 +30,19 @@ internal actual fun webFormatSymbol(amt: String, cur: String, loc: String?): Str return formatter.format(amt.toDouble()) } +internal actual fun webCurrencySymbol(cur: String, loc: String?): String { + val options = js("({ style: 'currency', currency: cur })") + val formatter = IntlCurrency.NumberFormat(loc, options) + val parts = formatter.asDynamic().formatToParts(0) + val length = parts.length as Int + for (index in 0 until length) { + if (parts[index].type == "currency") { + return parts[index].value as String + } + } + return "" +} + internal actual fun webFormatIso(amt: String, cur: String, loc: String?): String { val options = js("({ style: 'currency', currency: cur, currencyDisplay: 'code' })") val formatter = IntlCurrency.NumberFormat(loc, options) diff --git a/kurrency-core/src/jvmMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt b/kurrency-core/src/jvmMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt index 3dca15d..f60447e 100644 --- a/kurrency-core/src/jvmMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt +++ b/kurrency-core/src/jvmMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt @@ -23,25 +23,37 @@ actual class CurrencyFormatterImpl actual constructor( } } - 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()).getSymbol(locale) + }.getOrElse { throwable -> + KurrencyLog.w { "Failed to get symbol for $currencyCode: ${throwable.message}" } + default + } } - actual override fun formatCompactStyle(amount: String, currencyCode: String): String { - return formatCurrencyStyle(amount, currencyCode) - } + actual override fun formatCurrencyStyle(amount: String, currencyCode: String): String = + formatLeniently(amount, currencyCode) { + formatOrThrow(amount, currencyCode, PlatformFormatStyle.SYMBOL) + } - actual override fun formatIsoCurrencyStyle( + 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 = format(amount, currencyCode, useIsoCode = style == PlatformFormatStyle.ISO_CODE) - private fun formatCurrencyOrOriginal( + private fun format( amount: String, currencyCode: String, useIsoCode: Boolean @@ -68,10 +80,9 @@ actual class CurrencyFormatterImpl actual constructor( val numberFormat = createNumberFormat(locale, currencyCode) 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? { diff --git a/kurrency-core/src/jvmTest/kotlin/org/kimplify/kurrency/PlatformFailureSurfacesTest.kt b/kurrency-core/src/jvmTest/kotlin/org/kimplify/kurrency/PlatformFailureSurfacesTest.kt new file mode 100644 index 0000000..8ff72dd --- /dev/null +++ b/kurrency-core/src/jvmTest/kotlin/org/kimplify/kurrency/PlatformFailureSurfacesTest.kt @@ -0,0 +1,46 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * A platform that cannot format used to return the unformatted input from every path, so a failure + * was indistinguishable from success and `Result` could never fail. The lenient contract the + * [CurrencyFormat] methods document is unchanged — they still hand back the amount — but the + * failure is now reachable through [CurrencyFormatterImpl.formatOrThrow], which is what + * [CurrencyFormatter] uses for the paths that return a `Result`. + */ +class PlatformFailureSurfacesTest { + + private val impl = CurrencyFormatterImpl(KurrencyLocale.US) + + @Test + fun theDocumentedMethodsStayLenient() { + assertEquals("1234.56", impl.formatCurrencyStyle("1234.56", "XYZ")) + assertEquals("1234.56", impl.formatIsoCurrencyStyle("1234.56", "XYZ")) + assertEquals("1234.56", impl.formatCompactStyle("1234.56", "XYZ")) + } + + @Test + fun theThrowingPathReportsWhatTheLenientOneHides() { + assertFails { impl.formatOrThrow("1234.56", "XYZ", PlatformFormatStyle.SYMBOL) } + assertFails { impl.formatOrThrow("1234.56", "XYZ", PlatformFormatStyle.ISO_CODE) } + assertFails { impl.formatOrThrow("1234.56", "XYZ", PlatformFormatStyle.COMPACT) } + } + + @Test + fun bothPathsAgreeOnASupportedCurrency() { + assertEquals("$1,234.56", impl.formatCurrencyStyle("1234.56", "USD")) + assertEquals( + "$1,234.56", + impl.formatOrThrow("1234.56", "USD", PlatformFormatStyle.SYMBOL), + ) + } + + @Test + fun aDefaultingAccessorKeepsItsDefault() { + assertEquals(7, impl.getFractionDigitsOrDefault("XYZ", default = 7)) + assertEquals("fallback", impl.getCurrencySymbolOrDefault("XYZ", default = "fallback")) + } +} diff --git a/kurrency-core/src/wasmJsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt b/kurrency-core/src/wasmJsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt index 02138e8..21611dc 100644 --- a/kurrency-core/src/wasmJsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt +++ b/kurrency-core/src/wasmJsMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt @@ -23,6 +23,10 @@ private external fun wasmCanCreateCurrencyFormatter(cur: String): Boolean internal actual fun webGetMaxFractionDigits(cur: String, loc: String?): Int = wasmGetMaxFractionDigits(cur, loc) internal actual fun webGetResolvedCurrency(cur: String, loc: String?): String = wasmGetResolvedCurrency(cur, loc) internal actual fun webFormatSymbol(amt: String, cur: String, loc: String?): String = wasmFormatSymbol(amt, cur, loc) +internal actual fun webCurrencySymbol(cur: String, loc: String?): String = wasmCurrencySymbol(cur, loc) + +@JsFun("function(cur, loc) { var p = new Intl.NumberFormat(loc || undefined, {style:'currency', currency:cur}).formatToParts(0); for (var i = 0; i < p.length; i++) { if (p[i].type === 'currency') return p[i].value; } return ''; }") +private external fun wasmCurrencySymbol(cur: String, loc: String?): String internal actual fun webFormatIso(amt: String, cur: String, loc: String?): String = wasmFormatIso(amt, cur, loc) internal actual fun webIsSupportedCurrency(cur: String): Boolean? = wasmIsSupportedCurrency(cur) internal actual fun webCanCreateCurrencyFormatter(cur: String): Boolean = wasmCanCreateCurrencyFormatter(cur) diff --git a/kurrency-core/src/webMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt b/kurrency-core/src/webMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt index 319aaef..910a1a2 100644 --- a/kurrency-core/src/webMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt +++ b/kurrency-core/src/webMain/kotlin/org/kimplify/kurrency/CurrencyFormatterImpl.kt @@ -5,6 +5,7 @@ import org.kimplify.kurrency.extensions.normalizeAmount internal expect fun webGetMaxFractionDigits(cur: String, loc: String?): Int internal expect fun webGetResolvedCurrency(cur: String, loc: String?): String internal expect fun webFormatSymbol(amt: String, cur: String, loc: String?): String +internal expect fun webCurrencySymbol(cur: String, loc: String?): String internal expect fun webFormatIso(amt: String, cur: String, loc: String?): String internal expect fun webIsSupportedCurrency(cur: String): Boolean? internal expect fun webCanCreateCurrencyFormatter(cur: String): Boolean @@ -31,45 +32,45 @@ actual class CurrencyFormatterImpl actual constructor( } } - actual override fun formatCurrencyStyle(amount: String, currencyCode: String): String { + override fun getCurrencySymbolOrDefault(currencyCode: String, default: String): String { return runCatching { - val normalizedAmount = amount.normalizeAmount().trim() - if (normalizedAmount.isEmpty()) return amount - - val doubleValue = normalizedAmount.toDouble() - require(doubleValue.isFinite()) { "Amount must be a finite number" } - webFormatSymbol(normalizedAmount, currencyCode, locale) + webCurrencySymbol(currencyCode.uppercase(), locale).ifEmpty { default } }.getOrElse { throwable -> - KurrencyLog.w { "Formatting failed for $currencyCode with amount $amount: ${throwable.message}" } - amount + KurrencyLog.w { "Failed to get symbol for $currencyCode: ${throwable.message}" } + default } } - actual override fun formatIsoCurrencyStyle(amount: String, currencyCode: String): String { - return runCatching { - val normalizedAmount = amount.normalizeAmount().trim() - if (normalizedAmount.isEmpty()) return amount + actual override fun formatCurrencyStyle(amount: String, currencyCode: String): String = + formatLeniently(amount, currencyCode) { + formatOrThrow(amount, currencyCode, PlatformFormatStyle.SYMBOL) + } - val doubleValue = normalizedAmount.toDouble() - require(doubleValue.isFinite()) { "Amount must be a finite number" } - webFormatIso(normalizedAmount, currencyCode, locale) - }.getOrElse { throwable -> - KurrencyLog.w { "Formatting failed for $currencyCode with amount $amount: ${throwable.message}" } - amount + 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 { - return runCatching { - val normalizedAmount = amount.normalizeAmount().trim() - if (normalizedAmount.isEmpty()) return amount + actual override fun formatCompactStyle(amount: String, currencyCode: String): String = + formatLeniently(amount, currencyCode) { + formatOrThrow(amount, currencyCode, PlatformFormatStyle.COMPACT) + } - val doubleValue = normalizedAmount.toDouble() - require(doubleValue.isFinite()) { "Amount must be a finite number" } - webFormatCompact(normalizedAmount, currencyCode, locale) - }.getOrElse { throwable -> - KurrencyLog.w { "Compact formatting failed for $currencyCode with amount $amount: ${throwable.message}" } - amount + internal actual fun formatOrThrow( + amount: String, + currencyCode: String, + style: PlatformFormatStyle, + ): String { + val normalizedAmount = amount.normalizeAmount().trim() + if (normalizedAmount.isEmpty()) return amount + + val doubleValue = normalizedAmount.toDouble() + require(doubleValue.isFinite()) { "Amount must be a finite number" } + + return when (style) { + PlatformFormatStyle.SYMBOL -> webFormatSymbol(normalizedAmount, currencyCode, locale) + PlatformFormatStyle.ISO_CODE -> webFormatIso(normalizedAmount, currencyCode, locale) + PlatformFormatStyle.COMPACT -> webFormatCompact(normalizedAmount, currencyCode, locale) } }