From 2dd9a3245ce72bf7983ce1f7a842a85e83cb48f6 Mon Sep 17 00:00:00 2001 From: Merkost Date: Fri, 5 Jun 2026 14:09:52 +1000 Subject: [PATCH 1/6] feat(core): add RoundingMode and exact decimal-string Decimals utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure foundation for decimal-exact formatting: a public RoundingMode enum (HALF_EVEN, HALF_UP, DOWN, UP) and an internal Decimals object providing roundToScale, isZero, isNegative, abs, and isOne — all operating on decimal strings with no Double round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/org/kimplify/kurrency/Decimals.kt | 66 +++++++++++++ .../org/kimplify/kurrency/RoundingMode.kt | 22 +++++ .../org/kimplify/kurrency/DecimalsTest.kt | 98 +++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt create mode 100644 kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/RoundingMode.kt create mode 100644 kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt new file mode 100644 index 0000000..e1693a9 --- /dev/null +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt @@ -0,0 +1,66 @@ +package org.kimplify.kurrency + +internal object Decimals { + + fun isZero(s: String): Boolean = s.none { it in '1'..'9' } + + fun isNegative(s: String): Boolean = s.startsWith("-") && !isZero(s) + + fun abs(s: String): String = if (s.startsWith("-")) s.substring(1) else s + + fun isOne(s: String): Boolean { + val a = abs(s) + val dot = a.indexOf('.') + val intPart = (if (dot >= 0) a.substring(0, dot) else a).trimStart('0').ifEmpty { "0" } + val fracPart = if (dot >= 0) a.substring(dot + 1) else "" + return intPart == "1" && fracPart.all { it == '0' } + } + + fun roundToScale(s: String, scale: Int, mode: RoundingMode): String { + val dot = s.indexOf('.') + val intDigits = (if (dot >= 0) s.substring(0, dot) else s).ifEmpty { "0" } + val fracPart = if (dot >= 0) s.substring(dot + 1) else "" + + if (fracPart.length <= scale) { + val padded = fracPart.padEnd(scale, '0') + return if (scale == 0) intDigits else "$intDigits.$padded" + } + + val kept = fracPart.substring(0, scale) + val dropped = fracPart.substring(scale) + val combined = intDigits + kept + + val roundUp = when (mode) { + RoundingMode.DOWN -> false + RoundingMode.UP -> dropped.any { it != '0' } + RoundingMode.HALF_UP -> dropped[0] >= '5' + RoundingMode.HALF_EVEN -> when { + dropped[0] > '5' -> true + dropped[0] < '5' -> false + dropped.drop(1).any { it != '0' } -> true + else -> (combined.last() - '0') % 2 == 1 + } + } + + val resultDigits = if (roundUp) incrementDigits(combined) else combined + val intResult = (if (scale == 0) resultDigits else resultDigits.dropLast(scale)) + .trimStart('0').ifEmpty { "0" } + val fracResult = if (scale == 0) "" else resultDigits.takeLast(scale) + return if (scale == 0) intResult else "$intResult.$fracResult" + } + + private fun incrementDigits(digits: String): String { + val chars = digits.toCharArray() + var i = chars.size - 1 + while (i >= 0) { + if (chars[i] == '9') { + chars[i] = '0' + i-- + } else { + chars[i] = chars[i] + 1 + return chars.concatToString() + } + } + return "1" + chars.concatToString() + } +} diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/RoundingMode.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/RoundingMode.kt new file mode 100644 index 0000000..a080940 --- /dev/null +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/RoundingMode.kt @@ -0,0 +1,22 @@ +package org.kimplify.kurrency + +import kotlinx.serialization.Serializable + +/** + * Rounding strategy applied when an amount has more fraction digits than the + * target scale. Used by [CurrencyFormatOptions.roundingMode]. + */ +@Serializable +enum class RoundingMode { + /** Round to the nearest neighbor; ties go to the even digit (banker's rounding). */ + HALF_EVEN, + + /** Round to the nearest neighbor; ties round away from zero. */ + HALF_UP, + + /** Round toward zero (truncate). */ + DOWN, + + /** Round away from zero. */ + UP, +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt new file mode 100644 index 0000000..c6cbf71 --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt @@ -0,0 +1,98 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DecimalsTest { + + @Test fun halfEven_tieToEven_roundsDown() { + assertEquals("2.66", Decimals.roundToScale("2.665", 2, RoundingMode.HALF_EVEN)) + } + + @Test fun halfEven_tieToEven_roundsUp() { + assertEquals("2.68", Decimals.roundToScale("2.675", 2, RoundingMode.HALF_EVEN)) + } + + @Test fun halfEven_aboveHalf_roundsUp() { + assertEquals("2.68", Decimals.roundToScale("2.6751", 2, RoundingMode.HALF_EVEN)) + } + + @Test fun halfEven_belowHalf_roundsDown() { + assertEquals("2.67", Decimals.roundToScale("2.6749", 2, RoundingMode.HALF_EVEN)) + } + + @Test fun halfEven_scaleZero() { + assertEquals("2", Decimals.roundToScale("2.5", 0, RoundingMode.HALF_EVEN)) + assertEquals("4", Decimals.roundToScale("3.5", 0, RoundingMode.HALF_EVEN)) + } + + @Test fun halfUp_tie_roundsAwayFromZero() { + assertEquals("2.67", Decimals.roundToScale("2.665", 2, RoundingMode.HALF_UP)) + assertEquals("3", Decimals.roundToScale("2.5", 0, RoundingMode.HALF_UP)) + } + + @Test fun down_truncates() { + assertEquals("2.67", Decimals.roundToScale("2.679", 2, RoundingMode.DOWN)) + assertEquals("2", Decimals.roundToScale("2.999", 0, RoundingMode.DOWN)) + } + + @Test fun up_anyRemainderRoundsUp() { + assertEquals("2.68", Decimals.roundToScale("2.671", 2, RoundingMode.UP)) + assertEquals("2.67", Decimals.roundToScale("2.670", 2, RoundingMode.UP)) + assertEquals("3", Decimals.roundToScale("2.001", 0, RoundingMode.UP)) + } + + @Test fun carryPropagates() { + assertEquals("10.00", Decimals.roundToScale("9.999", 2, RoundingMode.HALF_UP)) + assertEquals("1", Decimals.roundToScale("0.999", 0, RoundingMode.HALF_UP)) + assertEquals("100.0", Decimals.roundToScale("99.95", 1, RoundingMode.HALF_UP)) + } + + @Test fun padsWhenFewerDigitsThanScale() { + assertEquals("2.50", Decimals.roundToScale("2.5", 2, RoundingMode.HALF_EVEN)) + assertEquals("2.00", Decimals.roundToScale("2", 2, RoundingMode.HALF_EVEN)) + } + + @Test fun beyondDoublePrecision_isExact() { + assertEquals( + "9007199254740993.01", + Decimals.roundToScale("9007199254740993.005", 2, RoundingMode.HALF_UP), + ) + } + + @Test fun isZero_variants() { + assertTrue(Decimals.isZero("0")) + assertTrue(Decimals.isZero("0.00")) + assertTrue(Decimals.isZero("-0.0")) + assertFalse(Decimals.isZero("0.01")) + } + + @Test fun isNegative_excludesNegativeZero() { + assertTrue(Decimals.isNegative("-3.50")) + assertFalse(Decimals.isNegative("-0.00")) + assertFalse(Decimals.isNegative("3.50")) + } + + @Test fun abs_stripsSign() { + assertEquals("3.50", Decimals.abs("-3.50")) + assertEquals("3.50", Decimals.abs("3.50")) + } + + @Test fun isOne_variants() { + assertTrue(Decimals.isOne("1")) + assertTrue(Decimals.isOne("1.00")) + assertTrue(Decimals.isOne("-1.0")) + assertFalse(Decimals.isOne("1.5")) + assertFalse(Decimals.isOne("10")) + } + + @Test fun carryGrowsIntegerWidthAtNonZeroScale() { + assertEquals("100.00", Decimals.roundToScale("99.999", 2, RoundingMode.HALF_UP)) + } + + @Test fun halfEven_tieWithTrailingNonZero_roundsUp() { + assertEquals("2.67", Decimals.roundToScale("2.6650001", 2, RoundingMode.HALF_EVEN)) + } +} From f0e69114f875365541bcc4059ea5021a3a07fe04 Mon Sep 17 00:00:00 2001 From: Merkost Date: Fri, 5 Jun 2026 14:16:24 +1000 Subject: [PATCH 2/6] feat(core): add roundingMode option to CurrencyFormatOptions Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kurrency/CurrencyFormatOptions.kt | 7 ++++++- .../CurrencyFormatOptionsSerializerTest.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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 bf5e995..5f5712e 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatOptions.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatOptions.kt @@ -41,6 +41,8 @@ import kotlinx.serialization.Serializable * @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]. + * @property roundingMode Strategy used when the amount has more fraction digits than the + * target scale. Defaults to [RoundingMode.HALF_EVEN]. */ @Serializable data class CurrencyFormatOptions( @@ -52,6 +54,7 @@ data class CurrencyFormatOptions( val symbolDisplay: SymbolDisplay = SymbolDisplay.SYMBOL, val zeroDisplay: ZeroDisplay = ZeroDisplay.SHOW, val hideZeroFractionDigits: Boolean = false, + val roundingMode: RoundingMode = RoundingMode.HALF_EVEN, ) { init { if (minFractionDigits != null && maxFractionDigits != null) { @@ -112,6 +115,7 @@ data class CurrencyFormatOptions( var symbolDisplay: SymbolDisplay = SymbolDisplay.SYMBOL var zeroDisplay: ZeroDisplay = ZeroDisplay.SHOW var hideZeroFractionDigits: Boolean = false + var roundingMode: RoundingMode = RoundingMode.HALF_EVEN fun symbolPosition(value: SymbolPosition) = apply { symbolPosition = value } fun grouping(value: Boolean) = apply { grouping = value } @@ -121,11 +125,12 @@ data class CurrencyFormatOptions( fun symbolDisplay(value: SymbolDisplay) = apply { symbolDisplay = value } fun zeroDisplay(value: ZeroDisplay) = apply { zeroDisplay = value } fun hideZeroFractionDigits(value: Boolean) = apply { hideZeroFractionDigits = value } + fun roundingMode(value: RoundingMode) = apply { roundingMode = value } /** Builds an immutable [CurrencyFormatOptions] from the current builder state. */ fun build() = CurrencyFormatOptions( symbolPosition, grouping, minFractionDigits, maxFractionDigits, - negativeStyle, symbolDisplay, zeroDisplay, hideZeroFractionDigits, + negativeStyle, symbolDisplay, zeroDisplay, hideZeroFractionDigits, roundingMode, ) } } 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 7527f0e..94f78df 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 @@ -5,6 +5,7 @@ import org.kimplify.kurrency.CurrencyFormatOptions import org.kimplify.kurrency.NegativeStyle import org.kimplify.kurrency.SymbolDisplay import org.kimplify.kurrency.SymbolPosition +import org.kimplify.kurrency.RoundingMode import org.kimplify.kurrency.ZeroDisplay import kotlin.test.Test import kotlin.test.assertEquals @@ -79,4 +80,22 @@ class CurrencyFormatOptionsSerializerTest { assertTrue(!serialized.contains("grouping")) assertTrue(!serialized.contains("zeroDisplay")) } + + @Test + fun roundingModeRoundTrip() { + val options = CurrencyFormatOptions(roundingMode = RoundingMode.HALF_UP) + val serialized = json.encodeToString(CurrencyFormatOptions.serializer(), options) + assertTrue(serialized.contains("HALF_UP")) + val deserialized = json.decodeFromString(CurrencyFormatOptions.serializer(), serialized) + assertEquals(RoundingMode.HALF_UP, deserialized.roundingMode) + } + + @Test + fun roundingModeDefaultsToHalfEvenWhenAbsent() { + val deserialized = json.decodeFromString( + CurrencyFormatOptions.serializer(), + """{"symbolDisplay":"NONE"}""", + ) + assertEquals(RoundingMode.HALF_EVEN, deserialized.roundingMode) + } } From 9f03e1c601b243d3b42c494ad9cb2d455c4b9024 Mon Sep 17 00:00:00 2001 From: Merkost Date: Fri, 5 Jun 2026 14:22:06 +1000 Subject: [PATCH 3/6] refactor(core): format options path with exact decimal arithmetic, honoring roundingMode Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kimplify/kurrency/CurrencyFormatter.kt | 55 ++++++------------- .../kotlin/org/kimplify/kurrency/Decimals.kt | 2 +- .../kurrency/DecimalExactFormattingTest.kt | 43 +++++++++++++++ .../org/kimplify/kurrency/DecimalsTest.kt | 4 ++ 4 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt 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 ee41eb0..d496fd6 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt @@ -1,7 +1,6 @@ package org.kimplify.kurrency import org.kimplify.kurrency.extensions.normalizeAmount -import kotlin.math.abs expect class CurrencyFormatterImpl(kurrencyLocale: KurrencyLocale = KurrencyLocale.systemLocale()) : CurrencyFormat { override fun getFractionDigitsOrDefault(currencyCode: String, default: Int): Int @@ -331,28 +330,21 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst currencyCode: String, options: CurrencyFormatOptions, ): Result { - return formatWithValidation(amount, currencyCode) { normalizedAmount -> + return formatWithValidation(amount, currencyCode) { rawAmount -> runCatching { - val numericValue = normalizedAmount.toDoubleOrNull() - ?: throw KurrencyError.InvalidAmount(amount) - - // Zero display handling - if (numericValue == 0.0) { + val normalizedAmount = rawAmount.normalizeAmount() + if (Decimals.isZero(normalizedAmount)) { when (options.zeroDisplay) { - ZeroDisplay.DASH -> return@runCatching "\u2014" // em-dash + ZeroDisplay.DASH -> return@runCatching "\u2014" ZeroDisplay.EMPTY -> return@runCatching "" - ZeroDisplay.SHOW -> { /* fall through to normal formatting */ } + ZeroDisplay.SHOW -> {} } } val metadata = CurrencyMetadata.parse(currencyCode).getOrNull() val symbol = metadata?.symbol ?: "" - val isNegative = numericValue < 0 - val absAmount = if (isNegative) { - if (normalizedAmount.startsWith("-")) normalizedAmount.drop(1) else normalizedAmount - } else { - normalizedAmount - } + val isNegative = Decimals.isNegative(normalizedAmount) + val absAmount = Decimals.abs(normalizedAmount) // Format the absolute amount with custom fraction digits if needed val formattedAbsAmount = formatNumberPortion(absAmount, currencyCode, options) @@ -362,7 +354,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst SymbolDisplay.SYMBOL -> symbol SymbolDisplay.ISO_CODE -> currencyCode SymbolDisplay.NAME -> { - if (abs(numericValue) == 1.0) metadata?.displayName ?: currencyCode + if (Decimals.isOne(normalizedAmount)) metadata?.displayName ?: currencyCode else metadata?.displayNamePlural ?: currencyCode } SymbolDisplay.NONE -> "" @@ -415,10 +407,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst val maxFrac = rawMaxFrac val minFrac = minOf(rawMinFrac, maxFrac) - val doubleVal = absAmount.toDoubleOrNull() ?: 0.0 - - // Format the number to a plain decimal string with the right precision - val plainFormatted = formatDecimal(doubleVal, minFrac, maxFrac) + val plainFormatted = formatDecimal(absAmount, minFrac, maxFrac, options.roundingMode) // Split into integer and fractional parts val parts = plainFormatted.split(".") @@ -592,21 +581,17 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst return result } - /** - * Formats a Double to a plain decimal string with the specified fraction digit range. - * Uses rounding-half-even (banker's rounding) via standard Kotlin rounding. - */ - internal fun formatDecimal(value: Double, minFractionDigits: Int, maxFractionDigits: Int): String { - // Round to maxFractionDigits - val factor = tenPow(maxFractionDigits) - val rounded = kotlin.math.round(value * factor) / factor - - val plain = doubleToPlainString(rounded) - val parts = plain.split(".") + internal fun formatDecimal( + amount: String, + minFractionDigits: Int, + maxFractionDigits: Int, + mode: RoundingMode, + ): String { + val rounded = Decimals.roundToScale(amount, maxFractionDigits, mode) + val parts = rounded.split(".") val intPart = parts[0] val rawFrac = if (parts.size > 1) parts[1] else "" - // Trim trailing zeros down to minFractionDigits, but keep at least minFractionDigits val paddedFrac = rawFrac.padEnd(maxFractionDigits, '0').take(maxFractionDigits) val trimmedFrac = if (paddedFrac.length > minFractionDigits) { val trimmed = paddedFrac.trimEnd('0') @@ -636,12 +621,6 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst return sb.reverse().toString() } - private fun tenPow(n: Int): Double { - var result = 1.0 - repeat(n) { result *= 10.0 } - return result - } - /** * Converts a Double to a plain decimal string without scientific notation. * This is needed because Kotlin's Double.toString() may use scientific notation diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt index e1693a9..f72a60c 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt @@ -6,7 +6,7 @@ internal object Decimals { fun isNegative(s: String): Boolean = s.startsWith("-") && !isZero(s) - fun abs(s: String): String = if (s.startsWith("-")) s.substring(1) else s + fun abs(s: String): String = if (s.startsWith("-") || s.startsWith("+")) s.substring(1) else s fun isOne(s: String): Boolean { val a = abs(s) diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt new file mode 100644 index 0000000..c55b21a --- /dev/null +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt @@ -0,0 +1,43 @@ +package org.kimplify.kurrency + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DecimalExactFormattingTest { + + private val us = CurrencyFormatter(KurrencyLocale.US) + + @Test + fun exactBeyondDoublePrecision() { + val r = us.formatWithOptions("9007199254740993.01", "USD", CurrencyFormatOptions()).getOrThrow() + assertEquals("$9,007,199,254,740,993.01", r) + } + + @Test + fun halfEvenDefault_correctsDoubleArtifact() { + assertEquals("$2.68", us.formatWithOptions("2.675", "USD", CurrencyFormatOptions()).getOrThrow()) + } + + @Test + fun roundingMode_halfUp() { + val opts = CurrencyFormatOptions { roundingMode = RoundingMode.HALF_UP } + assertEquals("$2.67", us.formatWithOptions("2.665", "USD", opts).getOrThrow()) + } + + @Test + fun roundingMode_down_truncates() { + val opts = CurrencyFormatOptions { roundingMode = RoundingMode.DOWN } + assertEquals("$2.67", us.formatWithOptions("2.679", "USD", opts).getOrThrow()) + } + + @Test + fun roundingMode_up_awayFromZero() { + val opts = CurrencyFormatOptions { roundingMode = RoundingMode.UP } + assertEquals("$2.68", us.formatWithOptions("2.671", "USD", opts).getOrThrow()) + } + + @Test + fun leadingPlus_isHandled() { + assertEquals("$5.00", us.formatWithOptions("+5", "USD", CurrencyFormatOptions()).getOrThrow()) + } +} diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt index c6cbf71..bf513f5 100644 --- a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt @@ -80,6 +80,10 @@ class DecimalsTest { assertEquals("3.50", Decimals.abs("3.50")) } + @Test fun abs_stripsLeadingPlus() { + assertEquals("3.50", Decimals.abs("+3.50")) + } + @Test fun isOne_variants() { assertTrue(Decimals.isOne("1")) assertTrue(Decimals.isOne("1.00")) From ad1c22cdab16e1ff97b8580eb2fd6e4c63bab5e9 Mon Sep 17 00:00:00 2001 From: Merkost Date: Fri, 5 Jun 2026 14:32:43 +1000 Subject: [PATCH 4/6] docs(deci): drop precision caveat now that formatWithOptions is decimal-exact Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kimplify/kurrency/deci/DeciExtensions.kt | 6 ------ .../kurrency/deci/DeciExactFormattingTest.kt | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciExactFormattingTest.kt 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 139c214..c683c02 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 @@ -49,9 +49,6 @@ fun CurrencyFormat.formatIsoCurrencyStyle(amount: Deci, currency: Kurrency): Str /** * 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 @@ -66,9 +63,6 @@ fun CurrencyFormatter.formatWithOptions( /** * 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 diff --git a/kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciExactFormattingTest.kt b/kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciExactFormattingTest.kt new file mode 100644 index 0000000..99150df --- /dev/null +++ b/kurrency-deci/src/commonTest/kotlin/org/kimplify/kurrency/deci/DeciExactFormattingTest.kt @@ -0,0 +1,18 @@ +package org.kimplify.kurrency.deci + +import org.kimplify.deci.Deci +import org.kimplify.kurrency.CurrencyFormatOptions +import org.kimplify.kurrency.CurrencyFormatter +import org.kimplify.kurrency.KurrencyLocale +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeciExactFormattingTest { + + @Test + fun deci_beyondDoublePrecision_isExact() { + val formatter = CurrencyFormatter(KurrencyLocale.US) + val r = formatter.formatWithOptions(Deci("9007199254740993.01"), "USD", CurrencyFormatOptions()).getOrThrow() + assertEquals("$9,007,199,254,740,993.01", r) + } +} From a03158104d727f186ea6cdf6396de7c5403d73ca Mon Sep 17 00:00:00 2001 From: Merkost Date: Fri, 5 Jun 2026 14:55:30 +1000 Subject: [PATCH 5/6] fix(core): expand scientific notation before the exact decimal path isValidAmount accepts scientific notation (e.g. "1e3") via toDoubleOrNull, but the string-based formatWithOptions path would emit garbage like "1e3.00". Expand exponent notation to a plain decimal string first, matching the platform path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kimplify/kurrency/CurrencyFormatter.kt | 2 +- .../kotlin/org/kimplify/kurrency/Decimals.kt | 20 +++++++++++++++++++ .../kurrency/DecimalExactFormattingTest.kt | 5 +++++ .../org/kimplify/kurrency/DecimalsTest.kt | 18 +++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) 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 d496fd6..c35645b 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/CurrencyFormatter.kt @@ -332,7 +332,7 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst ): Result { return formatWithValidation(amount, currencyCode) { rawAmount -> runCatching { - val normalizedAmount = rawAmount.normalizeAmount() + val normalizedAmount = Decimals.expandScientific(rawAmount.normalizeAmount()) if (Decimals.isZero(normalizedAmount)) { when (options.zeroDisplay) { ZeroDisplay.DASH -> return@runCatching "\u2014" diff --git a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt index f72a60c..309b9f3 100644 --- a/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt +++ b/kurrency-core/src/commonMain/kotlin/org/kimplify/kurrency/Decimals.kt @@ -49,6 +49,26 @@ internal object Decimals { return if (scale == 0) intResult else "$intResult.$fracResult" } + fun expandScientific(s: String): String { + if (s.none { it == 'e' || it == 'E' }) return s + val sign = if (s.startsWith("-")) "-" else "" + val unsigned = if (s.startsWith("-") || s.startsWith("+")) s.substring(1) else s + val ei = unsigned.indexOfFirst { it == 'e' || it == 'E' } + val mantissa = unsigned.substring(0, ei) + val exp = unsigned.substring(ei + 1).toIntOrNull() ?: return s + val dot = mantissa.indexOf('.') + val intDigits = if (dot >= 0) mantissa.substring(0, dot) else mantissa + val fracDigits = if (dot >= 0) mantissa.substring(dot + 1) else "" + val digits = intDigits + fracDigits + val pointPos = intDigits.length + exp + val body = when { + pointPos <= 0 -> "0." + "0".repeat(-pointPos) + digits + pointPos >= digits.length -> digits + "0".repeat(pointPos - digits.length) + else -> digits.substring(0, pointPos) + "." + digits.substring(pointPos) + } + return sign + body + } + private fun incrementDigits(digits: String): String { val chars = digits.toCharArray() var i = chars.size - 1 diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt index c55b21a..0380bfd 100644 --- a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt @@ -40,4 +40,9 @@ class DecimalExactFormattingTest { fun leadingPlus_isHandled() { assertEquals("$5.00", us.formatWithOptions("+5", "USD", CurrencyFormatOptions()).getOrThrow()) } + + @Test + fun scientificNotation_isFormatted() { + assertEquals("$1,000.00", us.formatWithOptions("1e3", "USD", CurrencyFormatOptions()).getOrThrow()) + } } diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt index bf513f5..2f13e92 100644 --- a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt @@ -99,4 +99,22 @@ class DecimalsTest { @Test fun halfEven_tieWithTrailingNonZero_roundsUp() { assertEquals("2.67", Decimals.roundToScale("2.6650001", 2, RoundingMode.HALF_EVEN)) } + + @Test fun expandScientific_positiveExponent() { + assertEquals("1000", Decimals.expandScientific("1e3")) + assertEquals("1500", Decimals.expandScientific("1.5e3")) + } + + @Test fun expandScientific_negativeExponent() { + assertEquals("0.0123", Decimals.expandScientific("1.23e-2")) + } + + @Test fun expandScientific_uppercaseAndSign() { + assertEquals("-2000", Decimals.expandScientific("-2E3")) + assertEquals("0", Decimals.expandScientific("0E0")) + } + + @Test fun expandScientific_plainPassthrough() { + assertEquals("1234.56", Decimals.expandScientific("1234.56")) + } } From a16caf904ff7b135a640c9bdaa69d51bc3566f20 Mon Sep 17 00:00:00 2001 From: Merkost Date: Fri, 5 Jun 2026 15:00:31 +1000 Subject: [PATCH 6/6] test(core): cover negative-with-rounding, default half-even, and +exponent cases Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kurrency/DecimalExactFormattingTest.kt | 16 ++++++++++++++++ .../kotlin/org/kimplify/kurrency/DecimalsTest.kt | 1 + 2 files changed, 17 insertions(+) diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt index 0380bfd..9126416 100644 --- a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalExactFormattingTest.kt @@ -45,4 +45,20 @@ class DecimalExactFormattingTest { fun scientificNotation_isFormatted() { assertEquals("$1,000.00", us.formatWithOptions("1e3", "USD", CurrencyFormatOptions()).getOrThrow()) } + + @Test + fun defaultHalfEven_tieRoundsToEven() { + assertEquals("$2.66", us.formatWithOptions("2.665", "USD", CurrencyFormatOptions()).getOrThrow()) + } + + @Test + fun negativeAmount_isRounded_minusSign() { + assertEquals("-$2.68", us.formatWithOptions("-2.675", "USD", CurrencyFormatOptions()).getOrThrow()) + } + + @Test + fun negativeAmount_isRounded_parentheses() { + val opts = CurrencyFormatOptions { negativeStyle = NegativeStyle.PARENTHESES } + assertEquals("($2.68)", us.formatWithOptions("-2.675", "USD", opts).getOrThrow()) + } } diff --git a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt index 2f13e92..5d0b424 100644 --- a/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt +++ b/kurrency-core/src/commonTest/kotlin/org/kimplify/kurrency/DecimalsTest.kt @@ -103,6 +103,7 @@ class DecimalsTest { @Test fun expandScientific_positiveExponent() { assertEquals("1000", Decimals.expandScientific("1e3")) assertEquals("1500", Decimals.expandScientific("1.5e3")) + assertEquals("1000", Decimals.expandScientific("1e+3")) } @Test fun expandScientific_negativeExponent() {