libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 8aeffb3f9d4fa5323d896a46902ed2384a953cbd
parent 73a98866810b47e341b00c5e2fcc0fc4f9a22cbe
Author: Antoine A <>
Date:   Tue, 23 Jan 2024 17:16:55 +0100

Share TalerAmount logic

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 1+
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 12+++++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 93+------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 1+
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 8--------
Mbank/src/test/kotlin/AmountTest.kt | 36++++++++++++++++--------------------
Mbank/src/test/kotlin/ConversionApiTest.kt | 1+
Mbank/src/test/kotlin/JsonTest.kt | 1+
Mintegration/src/main/kotlin/Main.kt | 7+++----
Mintegration/src/test/kotlin/IntegrationTest.kt | 12+++++-------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 34++++++----------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 50--------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 8+++-----
Mnexus/src/test/kotlin/DatabaseTest.kt | 7++++---
Mnexus/src/test/kotlin/Parsing.kt | 57+--------------------------------------------------------
Mutil/build.gradle | 1+
Mutil/src/main/kotlin/DB.kt | 8++++++++
Autil/src/main/kotlin/TalerCommon.kt | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autil/src/test/kotlin/AmountTest.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 223 insertions(+), 278 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -18,6 +18,7 @@ */ package tech.libeufin.bank +import tech.libeufin.util.* import ConfigSource import TalerConfig import TalerConfigError diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -172,13 +172,15 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) { while (rootCause?.cause != null) rootCause = rootCause.cause // Telling apart invalid JSON vs missing parameter vs invalid parameter. - val talerErrorCode = when (cause) { - is MissingRequestParameterException -> + val talerErrorCode = when { + cause is MissingRequestParameterException -> TalerErrorCode.GENERIC_PARAMETER_MISSING - - is ParameterConversionException -> + cause is ParameterConversionException -> TalerErrorCode.GENERIC_PARAMETER_MALFORMED - + rootCause is CommonError -> when (rootCause) { + is CommonError.AmountFormat -> TalerErrorCode.BANK_BAD_FORMAT_AMOUNT + is CommonError.AmountNumberTooBig -> TalerErrorCode.BANK_NUMBER_TOO_BIG + } else -> TalerErrorCode.GENERIC_JSON_INVALID } call.err( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -19,6 +19,7 @@ package tech.libeufin.bank +import tech.libeufin.util.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* @@ -197,98 +198,6 @@ data class TalerProtocolTimestamp( } } -/** - * Represents a Taler amount. This type can be used both - * to hold database records and amounts coming from the parser. - * If maybeCurrency is null, then the constructor defaults it - * to be the "internal currency". Internal currency is the one - * with which Libeufin-Bank moves funds within itself, therefore - * not to be mistaken with the cashout currency, which is the one - * that gets credited to Libeufin-Bank users to their cashout_payto_uri. - * - * maybeCurrency is typically null when the TalerAmount object gets - * defined by the Database class. - */ -@Serializable(with = TalerAmount.Serializer::class) -class TalerAmount { - val value: Long - val frac: Int - val currency: String - - constructor(value: Long, frac: Int, currency: String) { - this.value = value - this.frac = frac - this.currency = currency - } - constructor(encoded: String) { - val match = PATTERN.matchEntire(encoded) ?: throw badRequest( - "Invalid amount format", - TalerErrorCode.BANK_BAD_FORMAT_AMOUNT - ); - val (currency, value, frac) = match.destructured - this.currency = currency - this.value = value.toLongOrNull() ?: throw badRequest( - "Invalid value", - TalerErrorCode.BANK_BAD_FORMAT_AMOUNT - ) - if (this.value > MAX_VALUE) throw badRequest( - "Value specified in amount is too large", - TalerErrorCode.BANK_NUMBER_TOO_BIG - ) - this.frac = if (frac.isEmpty()) { - 0 - } else { - var tmp = frac.toIntOrNull() ?: throw badRequest( - "Invalid fractional value", - TalerErrorCode.BANK_BAD_FORMAT_AMOUNT - ) - if (tmp > FRACTION_BASE) throw badRequest( - "Fractional calue specified in amount is too large", - TalerErrorCode.BANK_NUMBER_TOO_BIG - ) - repeat(8 - frac.length) { - tmp *= 10 - } - tmp - } - } - - override fun equals(other: Any?): Boolean { - return other is TalerAmount && - other.value == this.value && - other.frac == this.frac && - other.currency == this.currency - } - - override fun toString(): String { - if (frac == 0) { - return "$currency:$value" - } else { - return "$currency:$value.${frac.toString().padStart(8, '0')}" - .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 - } - } - - internal object Serializer : KSerializer<TalerAmount> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: TalerAmount) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): TalerAmount { - return TalerAmount(decoder.decodeString()) - } - } - - companion object { - const val FRACTION_BASE = 100000000 - const val MAX_VALUE = 4503599627370496L; // 2^52 - private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?"); - } -} - @Serializable(with = DecimalNumber.Serializer::class) class DecimalNumber { val value: Long diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -19,6 +19,7 @@ package tech.libeufin.bank +import tech.libeufin.util.* import io.ktor.http.* import io.ktor.server.application.* import kotlinx.serialization.* diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -210,12 +210,4 @@ fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{ return TalerProtocolTimestamp( getLong(name).microsToJavaInstant() ?: throw faultyTimestampByBank() ) -} - -fun ResultSet.getAmount(name: String, currency: String): TalerAmount{ - return TalerAmount( - getLong("${name}_val"), - getInt("${name}_frac"), - currency - ) } \ No newline at end of file diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -27,6 +27,7 @@ import tech.libeufin.bank.db.* import tech.libeufin.bank.db.TransactionDAO.* import tech.libeufin.bank.db.WithdrawalDAO.* import tech.libeufin.util.* +import net.taler.common.errorcodes.TalerErrorCode class AmountTest { // Test amount computation in database @@ -127,26 +128,6 @@ class AmountTest { }} @Test - fun parse() { - assertEquals(TalerAmount("EUR:4"), TalerAmount(4L, 0, "EUR")) - assertEquals(TalerAmount("EUR:0.02"), TalerAmount(0L, 2000000, "EUR")) - assertEquals(TalerAmount("EUR:4.12"), TalerAmount(4L, 12000000, "EUR")) - assertEquals(TalerAmount("LOCAL:4444.1000"), TalerAmount(4444L, 10000000, "LOCAL")) - assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), TalerAmount(TalerAmount.MAX_VALUE, 99999999, "EUR")) - - assertException("Invalid amount format") {TalerAmount("")} - assertException("Invalid amount format") {TalerAmount("EUR")} - assertException("Invalid amount format") {TalerAmount("eur:12")} - assertException("Invalid amount format") {TalerAmount(" EUR:12")} - assertException("Invalid amount format") {TalerAmount("EUR:1.")} - assertException("Invalid amount format") {TalerAmount("EUR:.1")} - assertException("Invalid amount format") {TalerAmount("AZERTYUIOPQSD:12")} - assertException("Value specified in amount is too large") {TalerAmount("EUR:${Long.MAX_VALUE}")} - assertException("Invalid amount format") {TalerAmount("EUR:4.000000000")} - assertException("Invalid amount format") {TalerAmount("EUR:4.4a")} - } - - @Test fun parseRoundTrip() { for (amount in listOf("EUR:4", "EUR:0.02", "EUR:4.12")) { assertEquals(amount, TalerAmount(amount).toString()) @@ -333,4 +314,19 @@ class AmountTest { } } } + + @Test + fun apiError() = bankSetup { _ -> + val base = obj { + "payto_uri" to "$exchangePayto?message=payout" + } + + // Check OK + client.postA("/accounts/merchant/transactions") { + json(base) { "amount" to "KUDOS:0.3ABC" } + }.assertBadRequest(TalerErrorCode.BANK_BAD_FORMAT_AMOUNT) + client.postA("/accounts/merchant/transactions") { + json(base) { "amount" to "KUDOS:999999999999999999" } + }.assertBadRequest(TalerErrorCode.BANK_NUMBER_TOO_BIG) + } } \ No newline at end of file diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode import org.junit.Test import tech.libeufin.bank.* +import tech.libeufin.util.* class ConversionApiTest { // GET /conversion-info/config diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.* +import tech.libeufin.util.* @Serializable data class MyJsonType( diff --git a/integration/src/main/kotlin/Main.kt b/integration/src/main/kotlin/Main.kt @@ -20,7 +20,6 @@ package tech.libeufin.integration import tech.libeufin.nexus.Database as NexusDb -import tech.libeufin.nexus.TalerAmount as NexusAmount import tech.libeufin.nexus.* import tech.libeufin.bank.* import tech.libeufin.util.* @@ -136,7 +135,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { "tx" -> { step("Test submit one transaction") nexusDb.initiatedPaymentCreate(InitiatedPayment( - amount = NexusAmount(42L, 0, "CFH"), + amount = TalerAmount("CFH:42"), creditPaytoUri = payto, wireTransferSubject = "single transaction test", initiationTime = Instant.now(), @@ -148,7 +147,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { step("Test submit many transaction") repeat(4) { nexusDb.initiatedPaymentCreate(InitiatedPayment( - amount = NexusAmount(100L + it, 0, "CFH"), + amount = TalerAmount("CFH:${100L+it}"), creditPaytoUri = payto, wireTransferSubject = "multi transaction test $it", initiationTime = Instant.now(), @@ -199,7 +198,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { step("Submit new transaction") // TODO interactive payment editor nexusDb.initiatedPaymentCreate(InitiatedPayment( - amount = getTalerAmount("1.1", "CFH"), + amount = TalerAmount("CFH:1.1"), creditPaytoUri = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans", wireTransferSubject = "single transaction test", initiationTime = Instant.now(), diff --git a/integration/src/test/kotlin/IntegrationTest.kt b/integration/src/test/kotlin/IntegrationTest.kt @@ -20,10 +20,8 @@ import org.junit.Test import net.taler.wallet.crypto.Base32Crockford import tech.libeufin.bank.* -import tech.libeufin.bank.TalerAmount as BankAmount import tech.libeufin.nexus.* import tech.libeufin.nexus.Database as NexusDb -import tech.libeufin.nexus.TalerAmount as NexusAmount import tech.libeufin.bank.db.AccountDAO.* import tech.libeufin.util.* import java.io.File @@ -151,7 +149,7 @@ class IntegrationTest { val reservePub = randBytes(32) val payment = IncomingPayment( - amount = NexusAmount(10, 0, "EUR"), + amount = TalerAmount("EUR:10"), debitPaytoUri = userPayTo.canonical, wireTransferSubject = "Error test ${Base32Crockford.encode(reservePub)}", executionTime = Instant.now(), @@ -201,7 +199,7 @@ class IntegrationTest { // Too small amount checkCount(db, 0, 0, 0) ingestIncomingPayment(db, payment.copy( - amount = NexusAmount(0, 10, "EUR"), + amount = TalerAmount("EUR:0.01"), )) checkCount(db, 1, 1, 0) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { @@ -210,7 +208,7 @@ class IntegrationTest { // Check success ingestIncomingPayment(db, IncomingPayment( - amount = NexusAmount(10, 0, "EUR"), + amount = TalerAmount("EUR:10"), debitPaytoUri = userPayTo.canonical, wireTransferSubject = "Success ${Base32Crockford.encode(randBytes(32))}", executionTime = Instant.now(), @@ -279,7 +277,7 @@ class IntegrationTest { // Cashin repeat(3) { i -> val reservePub = randBytes(32); - val amount = NexusAmount(20L + i, 0, "EUR") + val amount = TalerAmount("EUR:${20+i}") val subject = "cashin test $i: ${Base32Crockford.encode(reservePub)}" ingestIncomingPayment(db, IncomingPayment( @@ -311,7 +309,7 @@ class IntegrationTest { // Cashout repeat(3) { i -> val requestUid = randBytes(32); - val amount = BankAmount("KUDOS:${10+i}") + val amount = TalerAmount("KUDOS:${10+i}") val convert = client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount") .assertOkJson<ConversionResponse>().amount_credit; client.post("http://0.0.0.0:8080/accounts/customer/cashouts") { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -39,24 +39,6 @@ fun Instant.fmtDateTime(): String { return formatter.format(Date.from(this)) } - -// Remove this once TalerAmount from the bank -// module gets moved to the 'util' module (#7987). -data class TalerAmount( - val value: Long, - val fraction: Int, // has at most 8 digits. - val currency: String -) { - override fun toString(): String { - if (fraction == 0) { - return "$currency:$value" - } else { - return "$currency:$value.${fraction.toString().padStart(8, '0')}" - .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 - } - } -} - // INCOMING PAYMENTS STRUCTS /** @@ -222,7 +204,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { val executionTime = paymentData.executionTime.toDbMicros() ?: throw Exception("Could not convert outgoing payment execution_time to microseconds") stmt.setLong(1, paymentData.amount.value) - stmt.setInt(2, paymentData.amount.fraction) + stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) stmt.setLong(4, executionTime) stmt.setString(5, paymentData.creditPaytoUri) @@ -274,13 +256,13 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { val executionTime = paymentData.executionTime.toDbMicros() ?: throw Exception("Could not convert payment execution time from Instant to microseconds.") stmt.setLong(1, paymentData.amount.value) - stmt.setInt(2, paymentData.amount.fraction) + stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) stmt.setLong(4, executionTime) stmt.setString(5, paymentData.debitPaytoUri) stmt.setString(6, paymentData.bankId) stmt.setLong(7, bounceAmount.value) - stmt.setInt(8, bounceAmount.fraction) + stmt.setInt(8, bounceAmount.frac) stmt.setLong(9, refundTimestamp) stmt.executeQuery().use { when { @@ -319,7 +301,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { val executionTime = paymentData.executionTime.toDbMicros() ?: throw Exception("Could not convert payment execution time from Instant to microseconds.") stmt.setLong(1, paymentData.amount.value) - stmt.setInt(2, paymentData.amount.fraction) + stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) stmt.setLong(4, executionTime) stmt.setString(5, paymentData.debitPaytoUri) @@ -478,11 +460,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { throw Exception("Found invalid timestamp at initiated payment with ID: $rowId") } maybeMap[rowId] = InitiatedPayment( - amount = TalerAmount( - value = it.getLong("amount_val"), - fraction = it.getInt("amount_frac"), - currency = currency - ), + amount = it.getAmount("amount", currency), creditPaytoUri = it.getString("credit_payto_uri"), wireTransferSubject = it.getString("wire_transfer_subject"), initiationTime = initiationTime, @@ -517,7 +495,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { ) """) stmt.setLong(1, paymentData.amount.value) - stmt.setInt(2, paymentData.amount.fraction) + stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) parsePayto(paymentData.creditPaytoUri).apply { if (this == null) return@conn PaymentInitiationOutcome.BAD_CREDIT_PAYTO diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -146,37 +146,6 @@ private fun makeTalerFrac(bankFrac: String): Int { } /** - * Gets Taler amount from a currency-agnostic value. - * - * @param noCurrencyAmount currency-agnostic value coming from the bank. - * @param currency currency to set to the result. - * @return [TalerAmount] - */ -fun getTalerAmount( - noCurrencyAmount: String, - currency: String, - errorMessagePrefix: String = "" -): TalerAmount { - if (currency.isEmpty()) throw Exception("Wrong helper invocation: currency is empty") - val split = noCurrencyAmount.split(".") - // only 1 (no fraction) or 2 (with fraction) sizes allowed. - if (split.size != 1 && split.size != 2) - throw Exception("${errorMessagePrefix}invalid amount: $noCurrencyAmount") - val value = split[0].toLongOrNull() - ?: throw Exception("${errorMessagePrefix}value part '${split[0]}' not a long") - if (split.size == 1) return TalerAmount( - value = value, - fraction = 0, - currency = currency - ) - return TalerAmount( - value = value, - fraction = makeTalerFrac(split[1]), - currency = currency - ) -} - -/** * Converts valid reserve pubs to its binary representation. * * @param maybeReservePub input. @@ -293,25 +262,6 @@ suspend fun ingestIncomingPayment( } } -/** - * Compares amounts. - * - * @param a first argument - * @param b second argument - * @return true if the first argument - * is less than the second - */ -fun firstLessThanSecond( - a: TalerAmount, - b: TalerAmount -): Boolean { - if (a.currency != b.currency) - throw Exception("different currencies: ${a.currency} vs. ${b.currency}") - if (a.value == b.value) - return a.fraction < b.fraction - return a.value < b.value -} - private fun ingestDocument( db: Database, currency: String, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -44,12 +44,10 @@ data class Pain001Namespaces( * @return [String] of the amount number without the currency. */ fun getAmountNoCurrency(amount: TalerAmount): String { - if (amount.fraction.toString().length > 8) - throw Exception("Taler amount must have at most 8 fractional digits") - if (amount.fraction == 0) { + if (amount.frac == 0) { return amount.value.toString() } else { - val fractionFormat = amount.fraction.toString().padStart(8, '0').dropLastWhile { it == '0' } + val fractionFormat = amount.frac.toString().padStart(8, '0').dropLastWhile { it == '0' } if (fractionFormat.length > 2) throw Exception("Sub-cent amounts not supported") return "${amount.value}.${fractionFormat}" } @@ -332,7 +330,7 @@ fun parseTxNotif( * FIXME: test by sending non-CHF to PoFi and see which currency gets here. */ if (currency != acceptedCurrency) throw Exception("Currency $currency not supported") - getTalerAmount(focusElement.textContent, currency) + TalerAmount("$currency:${focusElement.textContent}") } when (kind) { "CRDT" -> { diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.runBlocking import org.junit.Test import tech.libeufin.nexus.* +import tech.libeufin.util.* import java.time.Instant import kotlin.random.Random import kotlin.test.* @@ -70,14 +71,14 @@ class IncomingPaymentsTest { val payment = genInPay("incoming and bounced") db.registerMalformedIncoming( payment, - TalerAmount(2, 53000000, "KUDOS"), + TalerAmount("KUDOS:2.53"), Instant.now() ).run { assertTrue(new) } db.registerMalformedIncoming( payment, - TalerAmount(2, 53000000, "KUDOS"), + TalerAmount("KUDOS:2.53"), Instant.now() ).run { assertFalse(new) @@ -90,7 +91,7 @@ class IncomingPaymentsTest { """).executeQuery() assertTrue(checkIncoming.next()) assertEquals(payment.amount.value, checkIncoming.getLong("amount_value")) - assertEquals(payment.amount.fraction, checkIncoming.getInt("amount_frac")) + assertEquals(payment.amount.frac, checkIncoming.getInt("amount_frac")) // Checking the bounced table got its row. val checkBounced = it.prepareStatement(""" SELECT 1 FROM bounced_transactions diff --git a/nexus/src/test/kotlin/Parsing.kt b/nexus/src/test/kotlin/Parsing.kt @@ -20,6 +20,7 @@ import org.junit.Test import org.junit.jupiter.api.assertThrows import tech.libeufin.nexus.* +import tech.libeufin.util.* import tech.libeufin.util.parseBookDate import tech.libeufin.util.parseCamtTime import java.lang.StringBuilder @@ -30,18 +31,6 @@ import kotlin.test.assertTrue class Parsing { - @Test // move eventually to util (#7987) - fun amountComparison() { - val one = TalerAmount(1, 0, "KUDOS") - val two = TalerAmount(2, 0, "KUDOS") - val moreFrac = TalerAmount(2, 4, "KUDOS") - val lessFrac = TalerAmount(2, 3, "KUDOS") - val zeroMoreFrac = TalerAmount(0, 4, "KUDOS") - val zeroLessFrac = TalerAmount(0, 3, "KUDOS") - assertTrue(firstLessThanSecond(one, two)) - assertTrue(firstLessThanSecond(lessFrac, moreFrac)) - assertTrue(firstLessThanSecond(zeroLessFrac, zeroMoreFrac)) - } @Test fun gregorianTime() { parseCamtTime("2023-11-06T20:00:00") @@ -119,50 +108,6 @@ class Parsing { getAmountNoCurrency(TalerAmount(0, 10000000, "KUDOS")) ) } - @Test // parses amounts as found in the camt.05x documents. - fun parseCurrencyAgnosticAmount() { - assertTrue { - getTalerAmount("1.00", "KUDOS").run { - this.value == 1L && this.fraction == 0 && this.currency == "KUDOS" - } - } - assertTrue { - getTalerAmount("1", "KUDOS").run { - this.value == 1L && this.fraction == 0 && this.currency == "KUDOS" - } - } - assertTrue { - getTalerAmount("0.99", "KUDOS").run { - this.value == 0L && this.fraction == 99000000 && this.currency == "KUDOS" - } - } - assertTrue { - getTalerAmount("0.01", "KUDOS").run { - this.value == 0L && this.fraction == 1000000 && this.currency == "KUDOS" - } - } - assertThrows<Exception> { - getTalerAmount("", "") - } - assertThrows<Exception> { - getTalerAmount(".1", "KUDOS") - } - assertThrows<Exception> { - getTalerAmount("1.", "KUDOS") - } - assertThrows<Exception> { - getTalerAmount("0.123", "KUDOS") - } - assertThrows<Exception> { - getTalerAmount("noise", "KUDOS") - } - assertThrows<Exception> { - getTalerAmount("1.noise", "KUDOS") - } - assertThrows<Exception> { - getTalerAmount("5", "") - } - } // Checks that the input decodes to a 32-bytes value. @Test diff --git a/util/build.gradle b/util/build.gradle @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("java") id("kotlin") + id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" } version = rootProject.version diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt @@ -328,4 +328,12 @@ abstract class DbPool(cfg: String, schema: String): java.io.Closeable { override fun close() { pool.close() } +} + +fun ResultSet.getAmount(name: String, currency: String): TalerAmount{ + return TalerAmount( + getLong("${name}_val"), + getInt("${name}_frac"), + currency + ) } \ No newline at end of file diff --git a/util/src/main/kotlin/TalerCommon.kt b/util/src/main/kotlin/TalerCommon.kt @@ -0,0 +1,100 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + * + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + * + * LibEuFin 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 Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.util + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.* + +sealed class CommonError(msg: String): Exception(msg) { + class AmountFormat(msg: String): CommonError(msg) + class AmountNumberTooBig(msg: String): CommonError(msg) +} + +@Serializable(with = TalerAmount.Serializer::class) +class TalerAmount { + val value: Long + val frac: Int + val currency: String + + constructor(value: Long, frac: Int, currency: String) { + this.value = value + this.frac = frac + this.currency = currency + } + constructor(encoded: String) { + val match = PATTERN.matchEntire(encoded) ?: + throw CommonError.AmountFormat("Invalid amount format"); + val (currency, value, frac) = match.destructured + this.currency = currency + this.value = value.toLongOrNull() ?: + throw CommonError.AmountFormat("Invalid value") + if (this.value > MAX_VALUE) + throw CommonError.AmountNumberTooBig("Value specified in amount is too large") + this.frac = if (frac.isEmpty()) { + 0 + } else { + var tmp = frac.toIntOrNull() ?: + throw CommonError.AmountFormat("Invalid fractional value") + if (tmp > FRACTION_BASE) + throw CommonError.AmountFormat("Fractional calue specified in amount is too large") + repeat(8 - frac.length) { + tmp *= 10 + } + tmp + } + } + + override fun equals(other: Any?): Boolean { + return other is TalerAmount && + other.value == this.value && + other.frac == this.frac && + other.currency == this.currency + } + + override fun toString(): String { + if (frac == 0) { + return "$currency:$value" + } else { + return "$currency:$value.${frac.toString().padStart(8, '0')}" + .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 + } + } + + internal object Serializer : KSerializer<TalerAmount> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: TalerAmount) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): TalerAmount { + return TalerAmount(decoder.decodeString()) + } + } + + companion object { + const val FRACTION_BASE = 100000000 + const val MAX_VALUE = 4503599627370496L; // 2^52 + private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?"); + } +} +\ No newline at end of file diff --git a/util/src/test/kotlin/AmountTest.kt b/util/src/test/kotlin/AmountTest.kt @@ -0,0 +1,62 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import java.time.Instant +import java.util.* +import kotlin.test.* +import org.junit.Test +import tech.libeufin.util.* + +class AmountTest { + @Test + fun parse() { + assertEquals(TalerAmount("EUR:4"), TalerAmount(4L, 0, "EUR")) + assertEquals(TalerAmount("EUR:0.02"), TalerAmount(0L, 2000000, "EUR")) + assertEquals(TalerAmount("EUR:4.12"), TalerAmount(4L, 12000000, "EUR")) + assertEquals(TalerAmount("LOCAL:4444.1000"), TalerAmount(4444L, 10000000, "LOCAL")) + assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), TalerAmount(TalerAmount.MAX_VALUE, 99999999, "EUR")) + + assertException("Invalid amount format") {TalerAmount("")} + assertException("Invalid amount format") {TalerAmount("EUR")} + assertException("Invalid amount format") {TalerAmount("eur:12")} + assertException("Invalid amount format") {TalerAmount(" EUR:12")} + assertException("Invalid amount format") {TalerAmount("EUR:1.")} + assertException("Invalid amount format") {TalerAmount("EUR:.1")} + assertException("Invalid amount format") {TalerAmount("AZERTYUIOPQSD:12")} + assertException("Value specified in amount is too large") {TalerAmount("EUR:${Long.MAX_VALUE}")} + assertException("Invalid amount format") {TalerAmount("EUR:4.000000000")} + assertException("Invalid amount format") {TalerAmount("EUR:4.4a")} + } + + @Test + fun parseRoundTrip() { + for (amount in listOf("EUR:4", "EUR:0.02", "EUR:4.12")) { + assertEquals(amount, TalerAmount(amount).toString()) + } + } + + fun assertException(msg: String, lambda: () -> Unit) { + try { + lambda() + throw Exception("Expected failure") + } catch (e: Exception) { + assert(e.message!!.startsWith(msg)) { "${e.message}" } + } + } +} +\ No newline at end of file