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:
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