libeufin

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

commit f5995b846492c7b091c09e204f7c6450b5369034
parent 3365e7dabcb2a6aa37017c71811c37b0073caa3a
Author: MS <ms@taler.net>
Date:   Fri,  6 Jan 2023 18:50:07 +0100

Amount representation.

Defer the conversion of amount strings into
BigDecimal until the point where they act as
numeric operands.

This saves resources because in several cases
the amount strings do not partecipate in any
calculation.  For example, an error might occur
before the calculation, or the calculation is
not carried at all by the function that gets
the amount string from the network.

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/DB.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt | 6+++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt | 11++++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt | 2+-
Mnexus/src/test/kotlin/DownloadAndSubmit.kt | 6+++---
Mnexus/src/test/kotlin/Iso20022Test.kt | 2+-
Msandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt | 16+++++++++-------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 14++++----------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt | 18+++++++++++-------
Mutil/src/main/kotlin/amounts.kt | 32+++++++++-----------------------
Mutil/src/main/kotlin/strings.kt | 2+-
Mutil/src/test/kotlin/AmountTest.kt | 32++++++++++++++++++++++++--------
13 files changed, 74 insertions(+), 71 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt @@ -201,7 +201,7 @@ object PaymentInitiationsTable : LongIdTable() { val bankAccount = reference("bankAccount", NexusBankAccountsTable) val preparationDate = long("preparationDate") val submissionDate = long("submissionDate").nullable() - val sum = amount("sum") + val sum = text("sum") // the amount to transfer. val currency = text("currency") val endToEndId = text("endToEndId") val paymentInformationId = text("paymentInformationId") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt @@ -249,7 +249,7 @@ fun processCamtMessage( val rawEntity = NexusBankTransactionEntity.new { bankAccount = acct accountTransactionId = "AcctSvcrRef:$acctSvcrRef" - amount = singletonBatchedTransaction.amount.value.toPlainString() + amount = singletonBatchedTransaction.amount.value currency = singletonBatchedTransaction.amount.currency transactionJson = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(entry) creditDebitIndicator = singletonBatchedTransaction.creditDebitIndicator.name diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt @@ -632,7 +632,7 @@ private fun XmlElementDestructor.extractParty(): PartyIdentification { private fun XmlElementDestructor.extractCurrencyAmount(): CurrencyAmount { return CurrencyAmount( - value = BigDecimal(requireUniqueChildNamed("Amt") { focusElement.textContent }), + value = requireUniqueChildNamed("Amt") { focusElement.textContent }, currency = requireUniqueChildNamed("Amt") { focusElement.getAttribute("Ccy") } ) } @@ -641,7 +641,7 @@ private fun XmlElementDestructor.maybeExtractCurrencyAmount(): CurrencyAmount? { return maybeUniqueChildNamed("Amt") { CurrencyAmount( focusElement.getAttribute("Ccy"), - BigDecimal(focusElement.textContent) + focusElement.textContent ) } } @@ -667,7 +667,7 @@ private fun XmlElementDestructor.extractBatches( if (mapEachChildNamed("NtryDtls") {}.size != 1) throw CamtParsingError( "This money movement (AcctSvcrRef: $acctSvcrRef) is not a singleton #0" ) - var txs = requireUniqueChildNamed("NtryDtls") { + val txs = requireUniqueChildNamed("NtryDtls") { if (mapEachChildNamed("TxDtls") {}.size != 1) { throw CamtParsingError("This money movement (AcctSvcrRef: $acctSvcrRef) is not a singleton #1") } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt @@ -381,7 +381,7 @@ data class Pain001Data( val creditorIban: String, val creditorBic: String?, val creditorName: String, - val sum: Amount, + val sum: String, val currency: String, val subject: String ) @@ -418,7 +418,7 @@ class CurrencyAmountDeserializer(jc: Class<*> = CurrencyAmount::class.java) : St val s = p.valueAsString val components = s.split(":") // FIXME: error handling! - return CurrencyAmount(components[0], BigDecimal(components[1])) + return CurrencyAmount(components[0], components[1]) } } @@ -430,19 +430,20 @@ class CurrencyAmountSerializer(jc: Class<CurrencyAmount> = CurrencyAmount::class if (value == null) { gen.writeNull() } else { - gen.writeString("${value.currency}:${value.value.toPlainString()}") + gen.writeString("${value.currency}:${value.value}") } } } +// FIXME: this type duplicates AmountWithCurrency. @JsonDeserialize(using = CurrencyAmountDeserializer::class) @JsonSerialize(using = CurrencyAmountSerializer::class) data class CurrencyAmount( val currency: String, - val value: BigDecimal // allows calculations + val value: String // allows calculations ) fun CurrencyAmount.toPlainString(): String { - return "${this.currency}:${this.value.toPlainString()}" + return "${this.currency}:${this.value}" } data class InitiatedPayments( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt @@ -208,7 +208,7 @@ val nexusApp: Application.() -> Unit = { cause.httpStatusCode, message = ErrorResponse( code = TalerErrorCode.TALER_EC_LIBEUFIN_NEXUS_GENERIC_ERROR.code, - hint = "EBICS protocol error", + hint = "The EBICS communication with the bank failed: ${cause.ebicsTechnicalCode}", detail = cause.reason, ) ) diff --git a/nexus/src/test/kotlin/DownloadAndSubmit.kt b/nexus/src/test/kotlin/DownloadAndSubmit.kt @@ -151,7 +151,7 @@ class DownloadAndSubmit { creditorBic = "SANDBOXX", creditorName = "Tester", subject = "test payment", - sum = Amount(1), + sum = "1", currency = "TESTKUDOS" ), transaction { @@ -246,7 +246,7 @@ class DownloadAndSubmit { creditorBic = "not-a-BIC", creditorName = "Tester", subject = "test payment", - sum = Amount(1), + sum = "1", currency = "TESTKUDOS" ), transaction { @@ -282,7 +282,7 @@ class DownloadAndSubmit { creditorBic = "SANDBOXX", creditorName = "Tester", subject = "test payment", - sum = Amount(1), + sum = "1", currency = "EUR" ), transaction { diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -54,7 +54,7 @@ class Iso20022Test { assertEquals(1, r.reports.size) // First Entry - assertTrue(BigDecimal(100).compareTo(r.reports[0].entries[0].amount.value) == 0) + assertTrue("100" == r.reports[0].entries[0].amount.value) assertEquals("EUR", r.reports[0].entries[0].amount.currency) assertEquals(CreditDebitIndicator.CRDT, r.reports[0].entries[0].creditDebitIndicator) assertEquals(EntryStatus.BOOK, r.reports[0].entries[0].status) diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt @@ -113,8 +113,8 @@ fun generateCashoutSubject( amountCredit: AmountWithCurrency, amountDebit: AmountWithCurrency ): String { - return "Cash-out of ${amountDebit.currency}:${amountDebit.amount.toPlainString()}" + - " to ${amountCredit.currency}:${amountCredit.amount.toPlainString()}" + return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" + + " to ${amountCredit.currency}:${amountCredit.amount}" } /** @@ -295,18 +295,20 @@ fun circuitApi(circuitRoute: Route) { // check rates correctness val sellRatio = BigDecimal(ratiosAndFees.sell_at_ratio.toString()) val sellFee = BigDecimal(ratiosAndFees.sell_out_fee.toString()) - val amountCreditCheck = (amountDebit.amount * sellRatio) - sellFee + val amountDebitAsNumber = BigDecimal(amountDebit.amount) + val expectedAmountCredit = (amountDebitAsNumber * sellRatio) - sellFee val commonRounding = MathContext(2) // ensures both amounts end with ".XY" - if (amountCreditCheck.round(commonRounding) != amountCredit.amount.round(commonRounding)) { + val amountCreditAsNumber = BigDecimal(amountCredit.amount) + if (expectedAmountCredit.round(commonRounding) != amountCreditAsNumber.round(commonRounding)) { val msg = "Rates application are incorrect." + - " The expected amount to credit is: ${amountCreditCheck}," + - " but ${amountCredit.amount.toPlainString()} was specified." + " The expected amount to credit is: ${expectedAmountCredit}," + + " but ${amountCredit.amount} was specified." logger.info(msg) throw badRequest(msg) } // check that the balance is sufficient val balance = getBalance(user, withPending = true) - val balanceCheck = balance - amountDebit.amount + val balanceCheck = balance - amountDebitAsNumber if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal(demobank.usersDebtLimit)) { val msg = "Cash-out not possible due to insufficient funds. Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}" logger.info(msg) diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -720,16 +720,9 @@ val sandboxApp: Application.() -> Unit = { "invalid BIC" ) } - val (amount, currency) = parseAmountAsString(body.amount) + val amount = parseAmount(body.amount) transaction { val demobank = getDefaultDemobank() - /** - * This API needs compatibility with the currency-less format. - */ - if (currency != null) { - if (currency != demobank.currency) - throw SandboxError(HttpStatusCode.BadRequest, "Currency ${currency} not supported.") - } val account = getBankAccountFromLabel( accountLabel, demobank ) @@ -743,7 +736,7 @@ val sandboxApp: Application.() -> Unit = { debtorBic = reqDebtorBic debtorName = body.debtorName subject = body.subject - this.amount = amount + this.amount = amount.amount date = getUTCnow().toInstant().toEpochMilli() accountServicerReference = "sandbox-$randId" this.account = account @@ -1316,7 +1309,8 @@ val sandboxApp: Application.() -> Unit = { val maxDebt = if (username == "admin") { demobank.bankDebtLimit } else demobank.usersDebtLimit - if ((pendingBalance - amount.amount).abs() > BigDecimal.valueOf(maxDebt.toLong())) { + val amountAsNumber = BigDecimal(amount.amount) + if ((pendingBalance - amountAsNumber).abs() > BigDecimal.valueOf(maxDebt.toLong())) { logger.info("User $username would surpass user debit " + "threshold of ${demobank.usersDebtLimit}. Rollback Taler withdrawal" ) diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt @@ -108,11 +108,15 @@ fun wireTransfer( amount: String, // $currency:$value pmtInfId: String? = null ): String { - val checkAmount = parseAmount(amount) - if (checkAmount.amount == BigDecimal.ZERO) + val parsedAmount = parseAmount(amount) + val amountAsNumber = BigDecimal(parsedAmount.amount) + if (amountAsNumber == BigDecimal.ZERO) throw badRequest("Wire transfers of zero not possible.") - if (checkAmount.currency != demobank.currency) - throw badRequest("Won't wire transfer with currency: ${checkAmount.currency}") + if (parsedAmount.currency != demobank.currency) + throw badRequest( + "Won't wire transfer with currency: ${parsedAmount.currency}." + + " Only ${demobank.currency} allowed." + ) // Check funds are sufficient. /** * Using 'pending' balance because Libeufin never books. The @@ -122,7 +126,7 @@ fun wireTransfer( val maxDebt = if (debitAccount.label == "admin") { demobank.bankDebtLimit } else demobank.usersDebtLimit - val balanceCheck = pendingBalance - checkAmount.amount + val balanceCheck = pendingBalance - amountAsNumber if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal.valueOf(maxDebt.toLong())) { logger.info("Account ${debitAccount.label} would surpass debit threshold of $maxDebt. Rollback wire transfer") throw SandboxError(HttpStatusCode.PreconditionFailed, "Insufficient funds") @@ -138,7 +142,7 @@ fun wireTransfer( debtorBic = debitAccount.bic debtorName = getPersonNameFromCustomer(debitAccount.owner) this.subject = subject - this.amount = checkAmount.amount.toPlainString() + this.amount = parsedAmount.amount this.currency = demobank.currency date = timeStamp accountServicerReference = transactionRef @@ -155,7 +159,7 @@ fun wireTransfer( debtorBic = debitAccount.bic debtorName = getPersonNameFromCustomer(debitAccount.owner) this.subject = subject - this.amount = checkAmount.amount.toPlainString() + this.amount = parsedAmount.amount this.currency = demobank.currency date = timeStamp accountServicerReference = transactionRef diff --git a/util/src/main/kotlin/amounts.kt b/util/src/main/kotlin/amounts.kt @@ -22,33 +22,18 @@ import io.ktor.http.* * <http://www.gnu.org/licenses/> */ -val re = Regex("^([0-9]+(\\.[0-9]+)?)$") -val reWithSign = Regex("^-?([0-9]+(\\.[0-9]+)?)$") - +const val plainAmountRe = "^([0-9]+(\\.[0-9][0-9]?)?)$" +const val plainAmountReWithSign = "^-?([0-9]+(\\.[0-9][0-9]?)?)$" +const val amountWithCurrencyRe = "^([A-Z]+):([0-9]+(\\.[0-9][0-9]?)?)$" fun validatePlainAmount(plainAmount: String, withSign: Boolean = false): Boolean { - if (withSign) return reWithSign.matches(plainAmount) - return re.matches(plainAmount) -} - -/** - * Parse an "amount" where the currency is optional. It returns - * a pair where the first item is always the amount, and the second - * is the currency or null (when this one wasn't given in the input) - */ -fun parseAmountAsString(amount: String): Pair<String, String?> { - val match = Regex("^([A-Z]+:)?([0-9]+(\\.[0-9]+)?)$").find(amount) ?: throw - UtilError(HttpStatusCode.BadRequest, "invalid amount: $amount") - var (currency, number) = match.destructured - // Currency given, need to strip the ":". - if (currency.isNotEmpty()) - currency = currency.dropLast(1) - return Pair(number, if (currency.isEmpty()) null else currency) + if (withSign) return Regex(plainAmountReWithSign).matches(plainAmount) + return Regex(plainAmountRe).matches(plainAmount) } fun parseAmount(amount: String): AmountWithCurrency { - val match = Regex("([A-Z]+):([0-9]+(\\.[0-9]+)?)").find(amount) ?: + val match = Regex(amountWithCurrencyRe).find(amount) ?: throw UtilError(HttpStatusCode.BadRequest, "invalid amount: $amount") val (currency, number) = match.destructured - return AmountWithCurrency(currency, Amount(number)) -} + return AmountWithCurrency(currency = currency, amount = number) +} +\ No newline at end of file diff --git a/util/src/main/kotlin/strings.kt b/util/src/main/kotlin/strings.kt @@ -100,7 +100,7 @@ fun chunkString(input: String): String { data class AmountWithCurrency( val currency: String, - val amount: Amount + val amount: String ) fun parseDecimal(decimalStr: String): BigDecimal { diff --git a/util/src/test/kotlin/AmountTest.kt b/util/src/test/kotlin/AmountTest.kt @@ -1,15 +1,31 @@ import org.junit.Test -import tech.libeufin.util.parseAmountAsString -import kotlin.reflect.typeOf +import tech.libeufin.util.parseAmount +import tech.libeufin.util.validatePlainAmount +inline fun <reified ExceptionType> assertException(block: () -> Unit) { + try { + block() + } catch (e: Throwable) { + assert(e.javaClass == ExceptionType::class.java) + return + } + return assert(false) +} class AmountTest { @Test fun parse() { - val resWithCurrency = parseAmountAsString("CURRENCY:5.5") - assert(resWithCurrency.first == "5.5") - assert(resWithCurrency.second == "CURRENCY") - val resWithoutCurrency = parseAmountAsString("5.5") - assert(resWithoutCurrency.first == "5.5") - assert(resWithoutCurrency.second == null) + var res = parseAmount("KUDOS:5.5") + assert(res.amount == "5.5") + assert(res.currency == "KUDOS") + assert(validatePlainAmount("1.0")) + assert(validatePlainAmount("1.00")) + assert(!validatePlainAmount("1.000")) + res = parseAmount("TESTKUDOS:1.11") + assert(res.amount == "1.11") + assert(res.currency == "TESTKUDOS") + assertException<UtilError> { parseAmount("TESTKUDOS:1.") } + assertException<UtilError> { parseAmount("TESTKUDOS:.1") } + assertException<UtilError> { parseAmount("TESTKUDOS:1.000") } + assertException<UtilError> { parseAmount("TESTKUDOS:1..") } } } \ No newline at end of file