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
This commit is contained in:
Kevin Gorham 2019-02-17 23:58:22 -05:00
parent 2132bc14fd
commit ecff3ce588
2 changed files with 292 additions and 13 deletions

View File

@ -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
}
}

View File

@ -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))
}
}