From ecff3ce588234e0b56bdc854074b7f772801ccbe Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Sun, 17 Feb 2019 23:58:22 -0500 Subject: [PATCH] Add conversion logic and extensions for consistency and correctness Working with bigdecimals anytime we need to multiply or divide values because it was causing issues when repeatedly toggling currency on the send screen. Coupled this with lots of improvements on the app side to do less processing while changing currencies --- .../z/wallet/sdk/ext/CurrencyFormatter.kt | 212 ++++++++++++++++-- .../cash/z/wallet/sdk/ext/ConversionsTest.kt | 93 ++++++++ 2 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 src/test/java/cash/z/wallet/sdk/ext/ConversionsTest.kt 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