Amount.kt (11559B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2020 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.common 18 19 import android.os.Build 20 import kotlinx.serialization.ExperimentalSerializationApi 21 import kotlinx.serialization.KSerializer 22 import kotlinx.serialization.Serializable 23 import kotlinx.serialization.Serializer 24 import kotlinx.serialization.encoding.Decoder 25 import kotlinx.serialization.encoding.Encoder 26 import java.math.RoundingMode 27 import java.text.DecimalFormat 28 import java.text.DecimalFormatSymbols 29 import java.text.NumberFormat 30 import kotlin.math.floor 31 import kotlin.math.pow 32 import kotlin.math.roundToInt 33 34 class AmountParserException(msg: String? = null, cause: Throwable? = null) : 35 Exception(msg, cause) 36 37 class AmountOverflowException(msg: String? = null, cause: Throwable? = null) : 38 Exception(msg, cause) 39 40 @Serializable(with = KotlinXAmountSerializer::class) 41 data class Amount( 42 /** 43 * name of the currency using either a three-character ISO 4217 currency code, 44 * or a regional currency identifier starting with a "*" followed by at most 10 characters. 45 * ISO 4217 exponents in the name are not supported, 46 * although the "fraction" is corresponds to an ISO 4217 exponent of 6. 47 */ 48 val currency: String, 49 50 /** 51 * The integer part may be at most 2^52. 52 * Note that "1" here would correspond to 1 EUR or 1 USD, depending on currency, not 1 cent. 53 */ 54 val value: Long, 55 56 /** 57 * Unsigned 32 bit fractional value to be added to value representing 58 * an additional currency fraction, in units of one hundred millionth (1e-8) 59 * of the base currency value. For example, a fraction 60 * of 50_000_000 would correspond to 50 cents. 61 */ 62 val fraction: Int, 63 64 /** 65 * Currency specification for amount 66 */ 67 val spec: CurrencySpecification? = null, 68 ) : Comparable<Amount> { 69 70 companion object { 71 72 const val DEFAULT_INPUT_DECIMALS = 2 73 private const val FRACTIONAL_BASE: Int = 100000000 // 1e8 74 75 private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""") 76 val MAX_VALUE: Long = 2.0.pow(52).toLong() 77 private const val MAX_FRACTION_LENGTH = 8 78 const val MAX_FRACTION: Int = 99_999_999 79 80 fun zero(currency: String): Amount { 81 return Amount(checkCurrency(currency), 0, 0) 82 } 83 84 fun fromJSONString(str: String): Amount { 85 val split = str.split(":") 86 if (split.size != 2) throw AmountParserException("Invalid Amount Format") 87 return fromString(split[0], split[1]) 88 } 89 90 fun fromString(currency: String, str: String): Amount { 91 // value 92 val valueSplit = str.split(".") 93 val value = checkValue(valueSplit[0].toLongOrNull()) 94 // fraction 95 val fraction: Int = if (valueSplit.size > 1) { 96 val fractionStr = valueSplit[1] 97 if (fractionStr.length > MAX_FRACTION_LENGTH) 98 throw AmountParserException("Fraction $fractionStr too long") 99 checkFraction(fractionStr.getFraction()) 100 } else 0 101 return Amount(checkCurrency(currency), value, fraction) 102 } 103 104 fun isValidAmountStr(str: String): Boolean { 105 if (str.count { it == '.' } > 1) return false 106 val split = str.split(".") 107 try { 108 checkValue(split[0].toLongOrNull()) 109 } catch (e: AmountParserException) { 110 return false 111 } 112 // also check fraction, if it exists 113 if (split.size > 1) { 114 val fractionStr = split[1] 115 if (fractionStr.length > MAX_FRACTION_LENGTH) return false 116 val fraction = fractionStr.getFraction() ?: return false 117 return fraction <= MAX_FRACTION 118 } 119 return true 120 } 121 122 private fun String.getFraction(): Int? { 123 return "0.$this".toDoubleOrNull() 124 ?.times(FRACTIONAL_BASE) 125 ?.roundToInt() 126 } 127 128 fun min(currency: String): Amount = Amount(currency, 0, 1) 129 fun max(currency: String): Amount = Amount(currency, MAX_VALUE, MAX_FRACTION) 130 131 132 internal fun checkCurrency(currency: String): String { 133 if (!REGEX_CURRENCY.matches(currency)) 134 throw AmountParserException("Invalid currency: $currency") 135 return currency 136 } 137 138 internal fun checkValue(value: Long?): Long { 139 if (value == null || value > MAX_VALUE) 140 throw AmountParserException("Value $value greater than $MAX_VALUE") 141 return value 142 } 143 144 internal fun checkFraction(fraction: Int?): Int { 145 if (fraction == null || fraction > MAX_FRACTION) 146 throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION") 147 return fraction 148 } 149 150 } 151 152 val amountStr: String 153 get() = if (fraction == 0) "$value" else { 154 var f = fraction 155 var fractionStr = "" 156 while (f > 0) { 157 fractionStr += f / (FRACTIONAL_BASE / 10) 158 f = (f * 10) % FRACTIONAL_BASE 159 } 160 "$value.$fractionStr" 161 } 162 163 operator fun plus(other: Amount): Amount { 164 check(currency == other.currency) { "Can only subtract from same currency" } 165 val resultValue = 166 value + other.value + floor((fraction + other.fraction).toDouble() / FRACTIONAL_BASE).toLong() 167 if (resultValue > MAX_VALUE) 168 throw AmountOverflowException() 169 val resultFraction = (fraction + other.fraction) % FRACTIONAL_BASE 170 return Amount(currency, resultValue, resultFraction) 171 } 172 173 operator fun times(factor: Int): Amount { 174 // TODO consider replacing with a faster implementation 175 if (factor == 0) return zero(currency) 176 var result = this 177 for (i in 1 until factor) result += this 178 return result 179 } 180 181 fun withCurrency(currency: String): Amount { 182 return Amount(checkCurrency(currency), this.value, this.fraction) 183 } 184 185 fun withSpec(spec: CurrencySpecification?) = copy(spec = spec) 186 187 operator fun minus(other: Amount): Amount { 188 check(currency == other.currency) { "Can only subtract from same currency" } 189 var resultValue = value 190 var resultFraction = fraction 191 if (resultFraction < other.fraction) { 192 if (resultValue < 1L) 193 throw AmountOverflowException() 194 resultValue-- 195 resultFraction += FRACTIONAL_BASE 196 } 197 check(resultFraction >= other.fraction) 198 resultFraction -= other.fraction 199 if (resultValue < other.value) 200 throw AmountOverflowException() 201 resultValue -= other.value 202 return Amount(currency, resultValue, resultFraction) 203 } 204 205 fun isZero(): Boolean { 206 return value == 0L && fraction == 0 207 } 208 209 fun toJSONString(): String { 210 return "$currency:$amountStr" 211 } 212 213 override fun toString() = toString( 214 showSymbol = true, 215 negative = false, 216 ) 217 218 fun addInputDigit(c: Char): Amount? = c.digitToIntOrNull()?.let { digit -> 219 try { 220 val value = amountStr.toBigDecimal() 221 val decimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS 222 fromString( 223 currency, 224 // some real math! 225 ((value * 10.0.toBigDecimal().setScale(decimals)) 226 + (digit.toBigDecimal().setScale(decimals) 227 / 10.0.toBigDecimal().pow(decimals))).toString() 228 ) 229 } catch (e: AmountParserException) { null } 230 } 231 232 fun removeInputDigit(): Amount? = try { 233 val decimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS 234 val value = amountStr.toBigDecimal().setScale(decimals + 1, RoundingMode.FLOOR) 235 fromString( 236 currency, 237 // more math! 238 (value / "10.0".toBigDecimal()).setScale(decimals, RoundingMode.FLOOR).toString() 239 ) 240 } catch (e: AmountParserException) { null } 241 242 fun toString( 243 showSymbol: Boolean = true, 244 negative: Boolean = false, 245 symbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols, 246 ): String { 247 // We clone the object to safely/cleanly modify it 248 val s = symbols.clone() as DecimalFormatSymbols 249 val amount = (if (negative) "-$amountStr" else amountStr).toBigDecimal() 250 251 // No currency spec, so we render normally 252 if (spec == null) { 253 val format = NumberFormat.getInstance() 254 format.maximumFractionDigits = MAX_FRACTION_LENGTH 255 format.minimumFractionDigits = 2 256 if (Build.VERSION.SDK_INT >= 34) { 257 s.groupingSeparator = s.monetaryGroupingSeparator 258 } 259 s.decimalSeparator = s.monetaryDecimalSeparator 260 (format as DecimalFormat).decimalFormatSymbols = s 261 262 val fmt = format.format(amount) 263 return if (showSymbol) "$fmt $currency" else fmt 264 } 265 266 // There is currency spec, so we can do things right 267 val format = NumberFormat.getCurrencyInstance() 268 format.minimumFractionDigits = spec.numFractionalTrailingZeroDigits 269 format.maximumFractionDigits = MAX_FRACTION_LENGTH 270 s.currencySymbol = spec.symbol ?: "" 271 (format as DecimalFormat).decimalFormatSymbols = s 272 273 val fmt = format.format(amount) 274 return if (showSymbol) { 275 // If no symbol, then we use the currency string 276 (if (spec.symbol != null) fmt else "$fmt $currency").trim() 277 } else { 278 // We should do better than manually removing the symbol here 279 fmt.replace(s.currencySymbol, "").trim() 280 } 281 } 282 283 override fun equals(other: Any?): Boolean { 284 return other is Amount && (value == other.value && fraction == other.fraction && currency == other.currency) 285 } 286 287 override fun compareTo(other: Amount): Int { 288 check(currency == other.currency) { "Can only compare amounts with the same currency" } 289 when { 290 value == other.value -> { 291 if (fraction < other.fraction) return -1 292 if (fraction > other.fraction) return 1 293 return 0 294 } 295 value < other.value -> return -1 296 else -> return 1 297 } 298 } 299 } 300 301 @OptIn(ExperimentalSerializationApi::class) 302 @Suppress("EXPERIMENTAL_API_USAGE") 303 @Serializer(forClass = Amount::class) 304 internal object KotlinXAmountSerializer : KSerializer<Amount> { 305 override fun serialize(encoder: Encoder, value: Amount) { 306 encoder.encodeString(value.toJSONString()) 307 } 308 309 override fun deserialize(decoder: Decoder): Amount { 310 return Amount.fromJSONString(decoder.decodeString()) 311 } 312 }