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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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 }
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"""
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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
*/
Comment thread
Merkost marked this conversation as resolved.
fun CurrencyFormatter.formatWithOptions(
amount: Deci,
currencyCode: String,
options: CurrencyFormatOptions,
): Result<String> = 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
*/
Comment thread
Merkost marked this conversation as resolved.
fun CurrencyFormatter.formatWithOptions(
amount: Deci,
currency: Kurrency,
options: CurrencyFormatOptions,
): Result<String> = formatWithOptions(amount.toString(), currency.code, options)
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading