/*
* This file is part of GNU Taler
* (C) 2020 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see
*/
package net.taler.common
import android.os.Build
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.roundToInt
public class AmountParserException(msg: String? = null, cause: Throwable? = null) :
Exception(msg, cause)
public class AmountOverflowException(msg: String? = null, cause: Throwable? = null) :
Exception(msg, cause)
@Serializable(with = KotlinXAmountSerializer::class)
public data class Amount(
/**
* name of the currency using either a three-character ISO 4217 currency code,
* or a regional currency identifier starting with a "*" followed by at most 10 characters.
* ISO 4217 exponents in the name are not supported,
* although the "fraction" is corresponds to an ISO 4217 exponent of 6.
*/
val currency: String,
/**
* The integer part may be at most 2^52.
* Note that "1" here would correspond to 1 EUR or 1 USD, depending on currency, not 1 cent.
*/
val value: Long,
/**
* Unsigned 32 bit fractional value to be added to value representing
* an additional currency fraction, in units of one hundred millionth (1e-8)
* of the base currency value. For example, a fraction
* of 50_000_000 would correspond to 50 cents.
*/
val fraction: Int,
/**
* Currency specification for amount
*/
val spec: CurrencySpecification? = null,
) : Comparable {
public companion object {
private const val FRACTIONAL_BASE: Int = 100000000 // 1e8
private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""")
public val MAX_VALUE: Long = 2.0.pow(52).toLong()
private const val MAX_FRACTION_LENGTH = 8
public const val MAX_FRACTION: Int = 99_999_999
public fun zero(currency: String): Amount {
return Amount(checkCurrency(currency), 0, 0)
}
public fun fromJSONString(str: String): Amount {
val split = str.split(":")
if (split.size != 2) throw AmountParserException("Invalid Amount Format")
return fromString(split[0], split[1])
}
public fun fromString(currency: String, str: String): Amount {
// value
val valueSplit = str.split(".")
val value = checkValue(valueSplit[0].toLongOrNull())
// fraction
val fraction: Int = if (valueSplit.size > 1) {
val fractionStr = valueSplit[1]
if (fractionStr.length > MAX_FRACTION_LENGTH)
throw AmountParserException("Fraction $fractionStr too long")
checkFraction(fractionStr.getFraction())
} else 0
return Amount(checkCurrency(currency), value, fraction)
}
public fun isValidAmountStr(str: String): Boolean {
if (str.count { it == '.' } > 1) return false
val split = str.split(".")
try {
checkValue(split[0].toLongOrNull())
} catch (e: AmountParserException) {
return false
}
// also check fraction, if it exists
if (split.size > 1) {
val fractionStr = split[1]
if (fractionStr.length > MAX_FRACTION_LENGTH) return false
val fraction = fractionStr.getFraction() ?: return false
return fraction <= MAX_FRACTION
}
return true
}
private fun String.getFraction(): Int? {
return "0.$this".toDoubleOrNull()
?.times(FRACTIONAL_BASE)
?.roundToInt()
}
public fun min(currency: String): Amount = Amount(currency, 0, 1)
public fun max(currency: String): Amount = Amount(currency, MAX_VALUE, MAX_FRACTION)
internal fun checkCurrency(currency: String): String {
if (!REGEX_CURRENCY.matches(currency))
throw AmountParserException("Invalid currency: $currency")
return currency
}
internal fun checkValue(value: Long?): Long {
if (value == null || value > MAX_VALUE)
throw AmountParserException("Value $value greater than $MAX_VALUE")
return value
}
internal fun checkFraction(fraction: Int?): Int {
if (fraction == null || fraction > MAX_FRACTION)
throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION")
return fraction
}
}
public val amountStr: String
get() = if (fraction == 0) "$value" else {
var f = fraction
var fractionStr = ""
while (f > 0) {
fractionStr += f / (FRACTIONAL_BASE / 10)
f = (f * 10) % FRACTIONAL_BASE
}
"$value.$fractionStr"
}
public operator fun plus(other: Amount): Amount {
check(currency == other.currency) { "Can only subtract from same currency" }
val resultValue =
value + other.value + floor((fraction + other.fraction).toDouble() / FRACTIONAL_BASE).toLong()
if (resultValue > MAX_VALUE)
throw AmountOverflowException()
val resultFraction = (fraction + other.fraction) % FRACTIONAL_BASE
return Amount(currency, resultValue, resultFraction)
}
public operator fun times(factor: Int): Amount {
// TODO consider replacing with a faster implementation
if (factor == 0) return zero(currency)
var result = this
for (i in 1 until factor) result += this
return result
}
public fun withCurrency(currency: String): Amount {
return Amount(checkCurrency(currency), this.value, this.fraction)
}
fun withSpec(spec: CurrencySpecification?) = copy(spec = spec)
public operator fun minus(other: Amount): Amount {
check(currency == other.currency) { "Can only subtract from same currency" }
var resultValue = value
var resultFraction = fraction
if (resultFraction < other.fraction) {
if (resultValue < 1L)
throw AmountOverflowException()
resultValue--
resultFraction += FRACTIONAL_BASE
}
check(resultFraction >= other.fraction)
resultFraction -= other.fraction
if (resultValue < other.value)
throw AmountOverflowException()
resultValue -= other.value
return Amount(currency, resultValue, resultFraction)
}
public fun isZero(): Boolean {
return value == 0L && fraction == 0
}
public fun toJSONString(): String {
return "$currency:$amountStr"
}
override fun toString() = toString(
showSymbol = true,
negative = false,
)
fun toString(
showSymbol: Boolean = true,
negative: Boolean = false,
symbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols,
): String {
// We clone the object to safely/cleanly modify it
val s = symbols.clone() as DecimalFormatSymbols
val amount = (if (negative) "-$amountStr" else amountStr).toBigDecimal()
// No currency spec, so we render normally
if (spec == null) {
val format = NumberFormat.getInstance()
format.maximumFractionDigits = MAX_FRACTION_LENGTH
format.minimumFractionDigits = 0
if (Build.VERSION.SDK_INT >= 34) {
s.groupingSeparator = s.monetaryGroupingSeparator
}
s.decimalSeparator = s.monetaryDecimalSeparator
(format as DecimalFormat).decimalFormatSymbols = s
val fmt = format.format(amount)
return if (showSymbol) "$fmt $currency" else fmt
}
// There is currency spec, so we can do things right
val format = NumberFormat.getCurrencyInstance()
format.maximumFractionDigits = spec.numFractionalNormalDigits
format.minimumFractionDigits = spec.numFractionalTrailingZeroDigits
s.currencySymbol = spec.symbol ?: ""
(format as DecimalFormat).decimalFormatSymbols = s
val fmt = format.format(amount)
return if (showSymbol) {
// If no symbol, then we use the currency string
if (spec.symbol != null) fmt else "$fmt $currency"
} else {
// We should do better than manually removing the symbol here
fmt.replace(s.currencySymbol, "").trim()
}
}
override fun compareTo(other: Amount): Int {
check(currency == other.currency) { "Can only compare amounts with the same currency" }
when {
value == other.value -> {
if (fraction < other.fraction) return -1
if (fraction > other.fraction) return 1
return 0
}
value < other.value -> return -1
else -> return 1
}
}
}
@Suppress("EXPERIMENTAL_API_USAGE")
@Serializer(forClass = Amount::class)
internal object KotlinXAmountSerializer : KSerializer {
override fun serialize(encoder: Encoder, value: Amount) {
encoder.encodeString(value.toJSONString())
}
override fun deserialize(decoder: Decoder): Amount {
return Amount.fromJSONString(decoder.decodeString())
}
}