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 @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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 }
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -331,28 +330,21 @@ class CurrencyFormatter(private val locale: KurrencyLocale = KurrencyLocale.syst
currencyCode: String,
options: CurrencyFormatOptions,
): Result<String> {
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 = Decimals.expandScientific(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)
Expand All @@ -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 -> ""
Expand Down Expand Up @@ -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(".")
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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.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"
}

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
while (i >= 0) {
if (chars[i] == '9') {
chars[i] = '0'
i--
} else {
chars[i] = chars[i] + 1
return chars.concatToString()
}
}
return "1" + chars.concatToString()
}
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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())
}

@Test
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())
}
}
Loading
Loading