diff --git a/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt b/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt index ee4e027d..9e37dbbc 100644 --- a/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt +++ b/src/main/java/cash/z/wallet/sdk/ext/CurrencyFormatter.kt @@ -1,29 +1,215 @@ package cash.z.wallet.sdk.ext +import cash.z.wallet.sdk.ext.Conversions.USD_FORMATTER +import cash.z.wallet.sdk.ext.Conversions.ZEC_FORMATTER +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import java.text.NumberFormat import java.util.* +//TODO: provide a dynamic way to configure this globally for the SDK +// For now, just make these vars so at least they could be modified in one place +object Conversions { + var ONE_ZEC_IN_ZATOSHI = BigDecimal(100000000.0, MathContext.DECIMAL128) + var ZEC_FORMATTER = NumberFormat.getInstance(Locale.getDefault()).apply { + roundingMode = RoundingMode.HALF_EVEN + maximumFractionDigits = 6 + minimumFractionDigits = 0 + minimumIntegerDigits = 1 + } + var USD_FORMATTER = NumberFormat.getInstance(Locale.getDefault()).apply { + roundingMode = RoundingMode.HALF_EVEN + maximumFractionDigits = 2 + minimumFractionDigits = 2 + minimumIntegerDigits = 1 + } +} + + /** - * Format a Zatoshi value into Zec with the given number of decimal places. + * Format a Zatoshi value into Zec with the given number of digits, represented as a string. + * Start with Zatoshi -> End with Zec. * - * @param decimalPlaces the number of decimal places to use in the format. Default is 3 because Zec is better than Usd. + * @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than Usd. + * @param minDecimals the minimum number of digits to allow to the right of the decimal. */ -inline fun Long?.toZec(decimalPlaces: Int = 3): String { - val amount = (this ?: 0L)/100000000.0 - return String.format(Locale.getDefault(), "%,.${decimalPlaces}f", amount) +inline fun Long?.convertZatoshiToZecString( + maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, + minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits +): String { + return currencyFormatter(maxDecimals, minDecimals).format(this.convertZatoshiToZec(maxDecimals)) } /** - * Format a double as a dollar amount. + * Format a Zec value into Zec with the given number of digits, represented as a string. + * Start with ZeC -> End with Zec. * - * @param includeSymbol whether or not to include the $ symbol + * @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better when right. + * @param minDecimals the minimum number of digits to allow to the right of the decimal. */ -inline fun Double?.toUsd(includeSymbol: Boolean = true): String { - val amount = this ?: 0.0 - val symbol = if (includeSymbol) "$" else "" - return if (amount < 0) { - String.format(Locale.getDefault(), "-$symbol%,.2f", Math.abs(amount)) +inline fun Double?.toZecString( + maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, + minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits +): String { + return currencyFormatter(maxDecimals, minDecimals).format(this.toZec(maxDecimals)) +} + +/** + * Format a Zatoshi value into Zec with the given number of decimal places, represented as a string. + * Start with ZeC -> End with Zec. + * + * @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than bread. + * @param minDecimals the minimum number of digits to allow to the right of the decimal. + */ +inline fun BigDecimal?.toZecString( + maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, + minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits +): String { + return currencyFormatter(maxDecimals, minDecimals).format(this.toZec(maxDecimals)) +} + +/** + * Format a Usd value into Usd with the given number of digits, represented as a string. + * + * @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is better than pennies + * @param minDecimals the minimum number of digits to allow to the right of the decimal. + */ +inline fun Double?.toUsdString( + maxDecimals: Int = USD_FORMATTER.maximumFractionDigits, + minDecimals: Int = USD_FORMATTER.minimumFractionDigits +): String { + return if(this == 0.0) { + "0" } else { - String.format(Locale.getDefault(), "$symbol%,.2f", amount) + currencyFormatter(maxDecimals, minDecimals).format(this.toUsd(maxDecimals)) + } +} + +/** + * Format a Zatoshi value into Usd with the given number of decimal places, represented as a string. + * @param maxDecimals the number of decimal places to use in the format. Default is 6 because Zec is glorious. + * @param minDecimals the minimum number of digits to allow to the right of the decimal. + */ +inline fun BigDecimal?.toUsdString( + maxDecimals: Int = USD_FORMATTER.maximumFractionDigits, + minDecimals: Int = USD_FORMATTER.minimumFractionDigits +): String { + return currencyFormatter(maxDecimals, minDecimals).format(this.toUsd(maxDecimals)) +} + +/** + * Create a number formatter for use with converting currency to strings. This probably isn't needed externally since + * the other formatting functions leverage this, instead. Leverages the default rounding mode for zec found in + * ZEC_FORMATTER. + */ +inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat { + return NumberFormat.getInstance(Locale.getDefault()).apply { + roundingMode = ZEC_FORMATTER.roundingMode + maximumFractionDigits = maxDecimals + minimumFractionDigits = minDecimals + minimumIntegerDigits = 1 + } +} + +/** + * Convert a Zatoshi value into Zec, right-padded to the given number of fraction digits, represented as a BigDecimal in + * order to preserve rounding that minimizes cumulative error when applied repeatedly over a sequence of calculations. + * Start with Zatoshi -> End with Zec. + * + * @param scale the number of digits to the right of the decimal place. Right-padding will be added, if necessary. + */ +inline fun Long?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal { + return BigDecimal(this ?: 0L, MathContext.DECIMAL128).divide(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).setScale(scale, ZEC_FORMATTER.roundingMode) +} + +/** + * Convert a Zec value into Zatoshi. + */ +inline fun BigDecimal?.convertZecToZatoshi(): Long { + if (this == null) return 0L + if (this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid ZEC value: $this. ZEC is represented by notes and cannot be negative") + return this.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).toLong() +} + +/** + * Format a Double Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits. + * Start with Zec -> End with Zec. + */ +inline fun Double?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal { + return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, ZEC_FORMATTER.roundingMode) +} + +/** + * Format a BigDecimal Zec value as a BigDecimal Zec value, right-padded to the given number of fraction digits. + * Start with Zec -> End with Zec. + */ +inline fun BigDecimal?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal { + return (this ?: BigDecimal.ZERO).setScale(decimals, ZEC_FORMATTER.roundingMode) +} + +/** + * Format a Double Usd value as a BigDecimal Usd value, right-padded to the given number of fraction digits. + */ +inline fun Double?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal { + return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale(decimals, USD_FORMATTER.roundingMode) +} + +/** + * Format a BigDecimal Usd value as a BigDecimal Usd value, right-padded to the given number of fraction digits. + */ +inline fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal { + return (this ?: BigDecimal.ZERO).setScale(decimals, USD_FORMATTER.roundingMode) +} + +/** + * Convert this ZEC value to USD, using the given price per ZEC. + * + * @param zecPrice the current price of ZEC represented as USD per ZEC + */ +inline fun BigDecimal?.convertZecToUsd(zecPrice: BigDecimal): BigDecimal { + if(this == null) return BigDecimal.ZERO + if(this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid ZEC value: $zecPrice. ZEC is represented by notes and cannot be negative") + return this.multiply(zecPrice, MathContext.DECIMAL128) +} + +/** + * Convert this USD value to ZEC, using the given price per ZEC. + * + * @param zecPrice the current price of ZEC represented as USD per ZEC + */ +inline fun BigDecimal?.convertUsdToZec(zecPrice: BigDecimal): BigDecimal { + if(this == null) return BigDecimal.ZERO + if(this < BigDecimal.ZERO) throw IllegalArgumentException("Invalid USD value: $zecPrice. Converting this would result in negative ZEC and ZEC is represented by notes and cannot be negative") + return this.divide(zecPrice, MathContext.DECIMAL128) +} + +/** + * Convert this value from one currency to the other, based on given price and whether this value is USD. + * + * @param isUsd whether this value represents USD or not (ZEC) + */ +inline fun BigDecimal.convertCurrency(zecPrice: BigDecimal, isUsd: Boolean): BigDecimal { + return if (isUsd) { + this.convertUsdToZec(zecPrice) + } else { + this.convertZecToUsd(zecPrice) + } +} + +/** + * Parse this string into a BigDecimal, ignoring all non numeric characters. + * + * @return null when parsing fails + */ +inline fun String?.safelyConvertToBigDecimal(): BigDecimal? { + if (this.isNullOrEmpty()) return BigDecimal.ZERO + return try { + // ignore commas and whitespace + var sanitizedInput = this.filter { it.isDigit() or (it == '.') } + BigDecimal.ZERO.max(BigDecimal(sanitizedInput, MathContext.DECIMAL128)) + } catch (t: Throwable) { + return null } } diff --git a/src/test/java/cash/z/wallet/sdk/ext/ConversionsTest.kt b/src/test/java/cash/z/wallet/sdk/ext/ConversionsTest.kt new file mode 100644 index 00000000..4e1d47c9 --- /dev/null +++ b/src/test/java/cash/z/wallet/sdk/ext/ConversionsTest.kt @@ -0,0 +1,93 @@ +package cash.z.wallet.sdk.ext + +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Assertions.* +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +internal class ConversionsTest { + + @Test + fun `default right padding is 6`() { + assertEquals(1.13.toZec(6), 113000000L.convertZatoshiToZec()) + assertEquals(1.13.toZec(6), 1.13.toZec()) + } + @Test + fun `toZec uses banker's rounding`() { + assertEquals("1.004", 1.0035.toZecString(3)) + assertEquals("1.004", 1.0045.toZecString(3)) + } + @Test + fun `toZecString defaults to 6 digits`() { + assertEquals("1.123457", 112345678L.convertZatoshiToZecString()) + } + @Test + fun `toZecString uses banker's rounding`() { + assertEquals("1.123456", 112345650L.convertZatoshiToZecString()) + } +// @Test +// fun `toZec preserves precision when scale is changed`() { +// val desiredFunds = 1.1234567890123456789.toZec(3) +// assertEquals(1.123, desiredFunds.toDouble()) +// assertEquals("1.1234567", desiredFunds.setScale(7)) +// } + @Test + fun `toZecString honors minimum digits`() { + assertEquals("1.1000", 1.1.toZecString(6, 4)) + } + @Test + fun `toZecString drops trailing zeros`() { + assertEquals("1.1", 1.10000000.toZecString(6, 0)) + } + @Test + fun `toZecString limits trailing zeros`() { + assertEquals("1.10", 1.10000000.toZecString(6, 2)) + } + @Test + fun `toZecString hides decimal when min is zero`() { + assertEquals("1", 1.0.toZecString(6, 0)) + } + @Test + fun `toZecString defaults are resonable`() { + // basically check for no extra zeros and banker's rounding + assertEquals("1", 1.0000000.toZecString()) + assertEquals("0", 0.0000000.toZecString()) + assertEquals("1.01", 1.0100000.toZecString()) + assertEquals("1.000004", 1.0000035.toZecString()) + assertEquals("1.000004", 1.0000045.toZecString()) + assertEquals("1.000006", 1.0000055.toZecString()) + } + @Test + fun `toUsdString defaults are resonable`() { + // basically check for no extra zeros and banker's rounding + assertEquals("1.00", 1.0000000.toUsdString()) + assertEquals("0", 0.0000000.toUsdString()) + assertEquals("1.01", 1.0100000.toUsdString()) + assertEquals("0.01", .0100000.toUsdString()) + assertEquals("1.02", 1.025.toUsdString()) + } + @Test + fun `toZecString zatoshi converts`() { + assertEquals("1.123456", 112345650L.convertZatoshiToZecString(6, 0)) + } + @Test + fun `toZecString big decimal formats`() { + assertEquals("1.123", BigDecimal(1.123456789).toZecString(3, 0)) + } + @Test + fun `toZec reduces precision`() { + val amount = "20.37905033625433054819645404524149".safelyConvertToBigDecimal() + val expected = "20.379050".safelyConvertToBigDecimal() + assertEquals(expected, amount.toZec(6)) + assertEquals("20.37905", amount.toZecString(6)) + } + @Test + fun `convert usd to zec`() { + val price = BigDecimal("49.07", MathContext.DECIMAL128) + val usdValue = "1000".safelyConvertToBigDecimal() + val zecValue = usdValue.convertUsdToZec(price) + assertEquals("20.379050".safelyConvertToBigDecimal(), zecValue.toZec(6)) + } +} \ No newline at end of file