diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatOptions.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatOptions.kt index b5ac5c6..bf5e995 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatOptions.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatOptions.kt @@ -38,6 +38,9 @@ import kotlinx.serialization.Serializable * @property negativeStyle How negative amounts should be rendered. * @property symbolDisplay What form the currency indicator takes (symbol, ISO code, name, or none). * @property zeroDisplay How zero amounts should be rendered. + * @property hideZeroFractionDigits When `true`, omit fraction digits entirely if they are all zero + * (e.g. `"$34.00"` → `"$34"`), while non-zero fractions are displayed unchanged + * (e.g. `"$34.20"` remains `"$34.20"`). Takes priority over [minFractionDigits]. */ @Serializable data class CurrencyFormatOptions( @@ -48,6 +51,7 @@ data class CurrencyFormatOptions( val negativeStyle: NegativeStyle = NegativeStyle.MINUS_SIGN, val symbolDisplay: SymbolDisplay = SymbolDisplay.SYMBOL, val zeroDisplay: ZeroDisplay = ZeroDisplay.SHOW, + val hideZeroFractionDigits: Boolean = false, ) { init { if (minFractionDigits != null && maxFractionDigits != null) { @@ -107,6 +111,7 @@ data class CurrencyFormatOptions( var negativeStyle: NegativeStyle = NegativeStyle.MINUS_SIGN var symbolDisplay: SymbolDisplay = SymbolDisplay.SYMBOL var zeroDisplay: ZeroDisplay = ZeroDisplay.SHOW + var hideZeroFractionDigits: Boolean = false fun symbolPosition(value: SymbolPosition) = apply { symbolPosition = value } fun grouping(value: Boolean) = apply { grouping = value } @@ -115,11 +120,12 @@ data class CurrencyFormatOptions( fun negativeStyle(value: NegativeStyle) = apply { negativeStyle = value } fun symbolDisplay(value: SymbolDisplay) = apply { symbolDisplay = value } fun zeroDisplay(value: ZeroDisplay) = apply { zeroDisplay = value } + fun hideZeroFractionDigits(value: Boolean) = apply { hideZeroFractionDigits = value } /** Builds an immutable [CurrencyFormatOptions] from the current builder state. */ fun build() = CurrencyFormatOptions( symbolPosition, grouping, minFractionDigits, maxFractionDigits, - negativeStyle, symbolDisplay, zeroDisplay, + negativeStyle, symbolDisplay, zeroDisplay, hideZeroFractionDigits, ) } } 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 655bef9..ee41eb0 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt @@ -432,9 +432,12 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst integerPart } - // Reassemble with locale decimal separator - return if (fractionalPart.isNotEmpty()) { - "$groupedInteger${locale.decimalSeparator}$fractionalPart" + val effectiveFraction = + if (options.hideZeroFractionDigits && fractionalPart.isNotEmpty() && fractionalPart.all { it == '0' }) "" + else fractionalPart + + return if (effectiveFraction.isNotEmpty()) { + "$groupedInteger${locale.decimalSeparator}$effectiveFraction" } else { groupedInteger } diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/HideZeroFractionDigitsTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/HideZeroFractionDigitsTest.kt new file mode 100644 index 0000000..a3ed1d0 --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/HideZeroFractionDigitsTest.kt @@ -0,0 +1,128 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HideZeroFractionDigitsTest { + + private val us = CurrencyFormatter(KurrencyLocale.US) + private val de = CurrencyFormatter(KurrencyLocale.GERMANY) + private val hide = CurrencyFormatOptions { hideZeroFractionDigits = true } + private val show = CurrencyFormatOptions() // default: hideZeroFractionDigits = false + + @Test + fun us_allZeroFraction_isDropped() { + assertEquals("$34", us.formatWithOptions("34.00", "USD", hide).getOrThrow()) + } + + @Test + fun us_nonZeroFraction_isKept() { + assertEquals("$34.20", us.formatWithOptions("34.20", "USD", hide).getOrThrow()) + assertEquals("$34.88", us.formatWithOptions("34.88", "USD", hide).getOrThrow()) + } + + @Test + fun us_partialTrailingZero_isKept() { + assertEquals("$34.50", us.formatWithOptions("34.50", "USD", hide).getOrThrow()) + } + + @Test + fun us_grouping_isPreservedWhenFractionDropped() { + assertEquals("$1,000", us.formatWithOptions("1000.00", "USD", hide).getOrThrow()) + } + + @Test + fun us_roundsDownToZero_isDropped() { + assertEquals("$34", us.formatWithOptions("34.004", "USD", hide).getOrThrow()) + } + + @Test + fun us_roundsToNonZeroFraction_isKept() { + assertEquals("$34.02", us.formatWithOptions("34.019", "USD", hide).getOrThrow()) + } + + @Test + fun us_nonZeroAmountRoundingToZero_isDropped() { + assertEquals("$0", us.formatWithOptions("0.004", "USD", hide).getOrThrow()) + } + + @Test + fun us_zeroAmount_isDropped() { + assertEquals("$0", us.formatWithOptions("0.00", "USD", hide).getOrThrow()) + } + + @Test + fun hideWinsOverMinFractionDigits() { + val opts = CurrencyFormatOptions { + hideZeroFractionDigits = true + minFractionDigits = 2 + } + assertEquals("$34", us.formatWithOptions("34.00", "USD", opts).getOrThrow()) + } + + @Test + fun disabledByDefault_keepsDecimals() { + assertEquals("$34.00", us.formatWithOptions("34.00", "USD", show).getOrThrow()) + } + + @Test + fun negative_allZeroFraction_isDropped() { + assertEquals("-$34", us.formatWithOptions("-34.00", "USD", hide).getOrThrow()) + } + + @Test + fun negative_nonZeroFraction_hideHasNoEffect() { + assertEquals( + us.formatWithOptions("-34.20", "USD", show).getOrThrow(), + us.formatWithOptions("-34.20", "USD", hide).getOrThrow(), + ) + } + + @Test + fun bhd_allZeroFraction_isDropped() { + val r = us.formatWithOptions("10.000", "BHD", hide).getOrThrow() + assertFalse(r.contains("."), "expected no decimals in: $r") + assertTrue(r.contains("10"), "expected amount in: $r") + } + + @Test + fun bhd_nonZeroFraction_isKept() { + assertEquals( + us.formatWithOptions("10.500", "BHD", show).getOrThrow(), + us.formatWithOptions("10.500", "BHD", hide).getOrThrow(), + ) + } + + @Test + fun germanLocale_allZeroFraction_dropsCommaSeparator() { + val r = de.formatWithOptions("34.00", "EUR", hide).getOrThrow() + assertFalse(r.contains(","), "German decimal separator should be gone in: $r") + assertTrue(r.contains("34"), "expected amount in: $r") + } + + @Test + fun germanLocale_nonZeroFraction_isKept() { + assertEquals( + de.formatWithOptions("34.20", "EUR", show).getOrThrow(), + de.formatWithOptions("34.20", "EUR", hide).getOrThrow(), + ) + } + + @Test + fun jpy_zeroDigitsCurrency_isNoOp() { + assertEquals( + us.formatWithOptions("34", "JPY", show).getOrThrow(), + us.formatWithOptions("34", "JPY", hide).getOrThrow(), + ) + } + + @Test + fun kurrencyFormatAmountWithOptions_propagatesOption() { + assertEquals( + "$34", + Kurrency.USD.formatAmountWithOptions("34.00", hide, KurrencyLocale.US).getOrThrow(), + ) + } +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/serialization/CurrencyFormatOptionsSerializerTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/serialization/CurrencyFormatOptionsSerializerTest.kt index 255276f..7527f0e 100644 --- a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/serialization/CurrencyFormatOptionsSerializerTest.kt +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/serialization/CurrencyFormatOptionsSerializerTest.kt @@ -38,6 +38,15 @@ class CurrencyFormatOptionsSerializerTest { assertEquals(original, deserialized) } + @Test + fun hideZeroFractionDigitsRoundTrip() { + val original = CurrencyFormatOptions(hideZeroFractionDigits = true) + val serialized = json.encodeToString(CurrencyFormatOptions.serializer(), original) + val deserialized = json.decodeFromString(CurrencyFormatOptions.serializer(), serialized) + assertEquals(original, deserialized) + assertTrue(deserialized.hideZeroFractionDigits) + } + @Test fun partialJsonUsesDefaults() { val partial = """{"symbolDisplay":"NONE"}""" @@ -50,6 +59,7 @@ class CurrencyFormatOptionsSerializerTest { assertEquals(null, deserialized.maxFractionDigits) assertEquals(NegativeStyle.MINUS_SIGN, deserialized.negativeStyle) assertEquals(ZeroDisplay.SHOW, deserialized.zeroDisplay) + assertEquals(false, deserialized.hideZeroFractionDigits) } @Test diff --git a/kurrency-deci/src/commonMain/kotlin/org/kimplify/kurrency/deci/DeciExtensions.kt b/kurrency-deci/src/commonMain/kotlin/org/kimplify/kurrency/deci/DeciExtensions.kt index 4b87123..139c214 100644 --- a/kurrency-deci/src/commonMain/kotlin/org/kimplify/kurrency/deci/DeciExtensions.kt +++ b/kurrency-deci/src/commonMain/kotlin/org/kimplify/kurrency/deci/DeciExtensions.kt @@ -2,6 +2,8 @@ package org.kimplify.kurrency.deci import org.kimplify.deci.Deci import org.kimplify.kurrency.CurrencyFormat +import org.kimplify.kurrency.CurrencyFormatOptions +import org.kimplify.kurrency.CurrencyFormatter import org.kimplify.kurrency.Kurrency /** @@ -43,3 +45,37 @@ fun CurrencyFormat.formatIsoCurrencyStyle(amount: Deci, currencyCode: String): S */ fun CurrencyFormat.formatIsoCurrencyStyle(amount: Deci, currency: Kurrency): String = formatIsoCurrencyStyle(amount.toString(), currency.code) + +/** + * Formats a [Deci] amount with fine-grained [CurrencyFormatOptions] using this formatter's locale. + * + * Formatting is delegated to the `Double`-based engine, so values beyond `Double` precision + * may be rounded; this does not guarantee exact decimal formatting of the [Deci]. + * + * @param amount The [Deci] amount to format + * @param currencyCode The ISO 4217 currency code (e.g., "USD", "EUR") + * @param options The formatting options to apply + * @return Result containing the formatted string, or failure if validation fails + */ +fun CurrencyFormatter.formatWithOptions( + amount: Deci, + currencyCode: String, + options: CurrencyFormatOptions, +): Result = formatWithOptions(amount.toString(), currencyCode, options) + +/** + * Formats a [Deci] amount with fine-grained [CurrencyFormatOptions] using this formatter's locale. + * + * Formatting is delegated to the `Double`-based engine, so values beyond `Double` precision + * may be rounded; this does not guarantee exact decimal formatting of the [Deci]. + * + * @param amount The [Deci] amount to format + * @param currency The [Kurrency] whose ISO code should be used + * @param options The formatting options to apply + * @return Result containing the formatted string, or failure if validation fails + */ +fun CurrencyFormatter.formatWithOptions( + amount: Deci, + currency: Kurrency, + options: CurrencyFormatOptions, +): Result = formatWithOptions(amount.toString(), currency.code, options) diff --git a/kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciHideZeroFractionDigitsTest.kt b/kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciHideZeroFractionDigitsTest.kt new file mode 100644 index 0000000..c2407ce --- /dev/null +++ b/kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciHideZeroFractionDigitsTest.kt @@ -0,0 +1,42 @@ +package org.kimplify.kurrency.deci + +import org.kimplify.deci.Deci +import org.kimplify.kurrency.CurrencyFormatOptions +import org.kimplify.kurrency.CurrencyFormatter +import org.kimplify.kurrency.Kurrency +import org.kimplify.kurrency.KurrencyLocale +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeciHideZeroFractionDigitsTest { + + private val formatter = CurrencyFormatter(KurrencyLocale.US) + private val hide = CurrencyFormatOptions { hideZeroFractionDigits = true } + + @Test + fun deci_allZeroFraction_isDropped_withCurrencyCode() { + assertEquals("$34", formatter.formatWithOptions(Deci("34.00"), "USD", hide).getOrThrow()) + } + + @Test + fun deci_nonZeroFraction_isKept_withCurrencyCode() { + assertEquals("$34.20", formatter.formatWithOptions(Deci("34.20"), "USD", hide).getOrThrow()) + } + + @Test + fun deci_allZeroFraction_isDropped_withKurrency() { + val usd = Kurrency.fromCode("USD").getOrThrow() + assertEquals("$34", formatter.formatWithOptions(Deci("34.00"), usd, hide).getOrThrow()) + } + + @Test + fun deci_nonZeroFraction_isKept_withKurrency() { + val usd = Kurrency.fromCode("USD").getOrThrow() + assertEquals("$34.20", formatter.formatWithOptions(Deci("34.20"), usd, hide).getOrThrow()) + } + + @Test + fun deci_highPrecisionFractionRoundsToZero_isDropped() { + assertEquals("$34", formatter.formatWithOptions(Deci("34.0000001"), "USD", hide).getOrThrow()) + } +}