taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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 }