libeufin

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

commit ffd714d263da95e59088906e1d7fe5165e39985c
parent b0d98e47e6e92e6ec179148f886422c241ecac53
Author: MS <ms@taler.net>
Date:   Tue, 20 Dec 2022 17:25:48 +0100

balance (1) and debit limit (2)

1.  Get balances using the last statement as the
base, then optionally adding pending transactions
to it.

2.  Enforce debit limit for wire transfers and
  Taler withdrawals.

Diffstat:
Msandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt | 58++++++++++++++++++++++++++++++----------------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt | 109+------------------------------------------------------------------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 49+++++++++++++++++++++++++++----------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msandbox/src/test/kotlin/BalanceTest.kt | 5+++--
5 files changed, 217 insertions(+), 198 deletions(-)

diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt @@ -501,22 +501,6 @@ fun buildCamtString( } /** - * The last balance is the one accounted in the bank account's - * last statement. - */ -fun getLastBalance( - bankAccount: BankAccountEntity, -): BigDecimal { - val lastStatement = BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull() - val lastBalance = if (lastStatement == null) { - BigDecimal.ZERO - } else { BigDecimal(lastStatement.balanceClbd) } - return lastBalance -} - -/** * Builds CAMT response. * * @param type 52 or 53. @@ -533,28 +517,46 @@ private fun constructCamtResponse( if (dateRange != null) throw EbicsOrderParamsIgnored("C52 does not support date ranges.") val history = mutableListOf<RawPayment>() - val lastBalance = transaction { + transaction { BankAccountFreshTransactionEntity.all().forEach { if (it.transactionRef.account.label == bankAccount.label) { history.add(getHistoryElementFromTransactionRow(it)) } } - getLastBalance(bankAccount) } - if (history.size == 0) - throw EbicsNoDownloadDataAvailable() - - val freshBalance = balanceForAccount( - history = history, - baseBalance = lastBalance - ) - + if (history.size == 0) throw EbicsNoDownloadDataAvailable() + + /** + * PRCD balance: balance mentioned in the last statement. This + * will be normally zero, because statements need to be explicitly created. + * + * CLBD balance: PRCD + transactions accounted in the current C52. + * Alternatively, that could be changed into: PRCD + all the pending + * transactions. This way, the CLBD balance would closer reflect the + * latest (pending) activities. + */ + val prcdBalance = getBalance(bankAccount, withPending = false) + val clbdBalance = run { + var base = prcdBalance + history.forEach { tx -> + when (tx.direction) { + "DBIT" -> base -= parseDecimal(tx.amount) + "CRDT" -> base += parseDecimal(tx.amount) + else -> { + logger.error("Transaction with subject '${tx.subject}' is " + + "inconsistent: neither DBIT nor CRDT") + throw internalServerError("Transactions internal error.") + } + } + } + base + } val camtData = buildCamtString( type, bankAccount.iban, history, - balancePrcd = lastBalance, - balanceClbd = freshBalance + balancePrcd = prcdBalance, + balanceClbd = clbdBalance ) val paymentsList: String = if (logger.isDebugEnabled) { var ret = " It includes the payments:" diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt @@ -203,117 +203,10 @@ fun getDefaultDemobank(): DemobankConfigEntity { ) } -fun wireTransfer( - debitAccount: String, - creditAccount: String, - demobank: String, - subject: String, - amount: String, // $currency:x.y - pmtInfId: String? = null -): String { - val args: Triple<BankAccountEntity, BankAccountEntity, DemobankConfigEntity> = transaction { - val debitAccountDb = BankAccountEntity.find { - BankAccountsTable.label eq debitAccount - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Debit account '$debitAccount' not found" - ) - val creditAccountDb = BankAccountEntity.find { - BankAccountsTable.label eq creditAccount - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Credit account '$creditAccount' not found" - ) - val demoBank = DemobankConfigEntity.find { - DemobankConfigsTable.name eq demobank - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Demobank '$demobank' not found" - ) - - Triple(debitAccountDb, creditAccountDb, demoBank) - } - - /** - * Only validating the amount. Actual check on the - * currency will be done by the callee below. - */ - val amountObj = parseAmount(amount) - return wireTransfer( - debitAccount = args.first, - creditAccount = args.second, - demobank = args.third, - subject = subject, - amount = amountObj.amount.toPlainString(), - pmtInfId - ) -} -/** - * Book a CRDT and a DBIT transaction and return the unique reference thereof. - * - * At the moment there is redundancy because all the creditor / debtor details - * are contained (directly or indirectly) already in the BankAccount parameters. - * - * This is kept both not to break the existing tests and to allow future versions - * where one party of the transaction is not a customer of the running Sandbox. - */ - -fun wireTransfer( - debitAccount: BankAccountEntity, - creditAccount: BankAccountEntity, - demobank: DemobankConfigEntity, - subject: String, - amount: String, - pmtInfId: String? = null -): String { - // sanity check on the amount, no currency allowed here. - val checkAmount = parseDecimal(amount) - if (checkAmount == BigDecimal.ZERO) throw badRequest("Wire transfers of zero not possible.") - val timeStamp = getUTCnow().toInstant().toEpochMilli() - val transactionRef = getRandomString(8) - transaction { - BankAccountTransactionEntity.new { - creditorIban = creditAccount.iban - creditorBic = creditAccount.bic - this.creditorName = getPersonNameFromCustomer(creditAccount.owner) - debtorIban = debitAccount.iban - debtorBic = debitAccount.bic - debtorName = getPersonNameFromCustomer(debitAccount.owner) - this.subject = subject - this.amount = amount - this.currency = demobank.currency - date = timeStamp - accountServicerReference = transactionRef - account = creditAccount - direction = "CRDT" - this.demobank = demobank - this.pmtInfId = pmtInfId - } - BankAccountTransactionEntity.new { - creditorIban = creditAccount.iban - creditorBic = creditAccount.bic - this.creditorName = getPersonNameFromCustomer(creditAccount.owner) - debtorIban = debitAccount.iban - debtorBic = debitAccount.bic - debtorName = getPersonNameFromCustomer(debitAccount.owner) - this.subject = subject - this.amount = amount - this.currency = demobank.currency - date = timeStamp - accountServicerReference = transactionRef - account = debitAccount - direction = "DBIT" - this.demobank = demobank - this.pmtInfId = pmtInfId - } - } - return transactionRef -} - fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { return transaction { TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(opId) + TalerWithdrawalsTable.wopid eq UUID.fromString(opId) }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Withdrawal operation $opId not found." ) diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -251,11 +251,8 @@ class Camt053Tick : CliktCommand( * Resorting the closing (CLBD) balance of the last statement; will * become the PRCD balance of the _new_ one. */ - val lastBalance = getLastBalance(accountIter) - val balanceClbd = balanceForAccount( - history = newStatements[accountIter.label] ?: mutableListOf(), - baseBalance = lastBalance - ) + val lastBalance = getBalance(accountIter, withPending = false) + val balanceClbd = getBalance(accountIter, withPending = true) val camtData = buildCamtString( 53, accountIter.iban, @@ -671,7 +668,7 @@ val sandboxApp: Application.() -> Unit = { val bankAccount = getBankAccountFromLabel(label, demobank) if (!allowOwnerOrAdmin(username, label)) throw unauthorized("'${username}' has no rights over '$label'") - val balance = balanceForAccount(bankAccount) + val balance = getBalance(bankAccount, withPending = true) object { val balance = "${bankAccount.demoBank.currency}:${balance}" val iban = bankAccount.iban @@ -1192,7 +1189,7 @@ val sandboxApp: Application.() -> Unit = { val ret = TalerWithdrawalStatus( selection_done = wo.selectionDone, transfer_done = wo.confirmationDone, - amount = "${demobank.currency}:${wo.amount}", + amount = wo.amount, suggested_exchange = demobank.suggestedExchangeBaseUrl, aborted = wo.aborted, confirm_transfer_url = captcha_page @@ -1209,14 +1206,10 @@ val sandboxApp: Application.() -> Unit = { val payto = parsePayto(req.paytoUri) val amount: String? = payto.amount ?: req.amount if (amount == null) throw badRequest("Amount is missing") - val amountParsed = parseAmountAsString(amount) /** * The transaction block below lets the 'demoBank' field * of 'bankAccount' be correctly accessed. */ transaction { - if ((amountParsed.second != null) - && (bankAccount.demoBank.currency != amountParsed.second)) - throw badRequest("Currency '${amountParsed.second}' is wrong") wireTransfer( debitAccount = bankAccount, creditAccount = getBankAccountFromIban(payto.iban), @@ -1224,7 +1217,7 @@ val sandboxApp: Application.() -> Unit = { subject = payto.message ?: throw badRequest( "'message' query parameter missing in Payto address" ), - amount = amountParsed.first + amount = amount ) } call.respond(object {}) @@ -1233,13 +1226,13 @@ val sandboxApp: Application.() -> Unit = { // Information about one withdrawal. get("/accounts/{account_name}/withdrawals/{withdrawal_id}") { val op = getWithdrawalOperation(call.getUriComponent("withdrawal_id")) - val demobank = ensureDemobank(call) + ensureDemobank(call) if (!op.selectionDone && op.reservePub != null) throw internalServerError( "Unselected withdrawal has a reserve public key", LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE ) call.respond(object { - val amount = "${demobank.currency}:${op.amount}" + val amount = op.amount val aborted = op.aborted val confirmation_done = op.confirmationDone val selection_done = op.selectionDone @@ -1270,12 +1263,25 @@ val sandboxApp: Application.() -> Unit = { val req = call.receiveJson<WithdrawalRequest>() // Check for currency consistency val amount = parseAmount(req.amount) - if (amount.currency != demobank.currency) throw badRequest( - "Currency ${amount.currency} differs from Demobank's: ${demobank.currency}" - ) + if (amount.currency != demobank.currency) + throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.currency}") + /** + * Check for debit threshold. That's however also later checked + * after the /confirm call. Username == null case is handled above. + */ + val pendingBalance = getBalance(username!!, withPending = true) + if ((pendingBalance - amount.amount).abs() > BigDecimal.valueOf(demobank.usersDebtLimit.toLong())) { + logger.info("User $username would surpass user debit " + + "threshold of ${demobank.usersDebtLimit}. Rollback Taler withdrawal" + ) + throw SandboxError( + HttpStatusCode.Forbidden, + "Insufficient funds." + ) + } val wo: TalerWithdrawalEntity = transaction { TalerWithdrawalEntity.new { - this.amount = amount.amount.toPlainString() + this.amount = req.amount walletBankAccount = maybeOwnedAccount } } @@ -1336,7 +1342,6 @@ val sandboxApp: Application.() -> Unit = { ) ) if (!wo.confirmationDone) { - // Need the exchange bank account! wireTransfer( debitAccount = wo.walletBankAccount, creditAccount = exchangeBankAccount, @@ -1382,7 +1387,7 @@ val sandboxApp: Application.() -> Unit = { ) throw forbidden( "Customer '$username' cannot access bank account '$accountAccessed'" ) - val balance = balanceForAccount(bankAccount) + val balance = getBalance(bankAccount, withPending = true) call.respond(object { val balance = object { val amount = "${demobank.currency}:${balance.abs(). toPlainString()}" @@ -1478,7 +1483,7 @@ val sandboxApp: Application.() -> Unit = { BankAccountsTable.demoBank eq demobank.id ) }.forEach { - val balanceIter = balanceForAccount(it) + val balanceIter = getBalance(it, withPending = true) ret.publicAccounts.add( PublicAccountInfo( balance = "${demobank.currency}:$balanceIter", @@ -1564,7 +1569,7 @@ val sandboxApp: Application.() -> Unit = { bankAccount.bonus("${demobank.currency}:100") bankAccount } - val balance = balanceForAccount(bankAccount) + val balance = getBalance(bankAccount, withPending = true) call.respond(object { val balance = object { val amount = "${demobank.currency}:$balance" diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt @@ -2,57 +2,175 @@ package tech.libeufin.sandbox import io.ktor.http.* import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction -import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync import org.jetbrains.exposed.sql.transactions.transaction -import org.slf4j.Logger -import org.slf4j.LoggerFactory import tech.libeufin.util.* import java.math.BigDecimal -// Mainly useful inside the Camt generator. -fun balanceForAccount( - history: MutableList<RawPayment>, - baseBalance: BigDecimal +/** + * The last balance is the one mentioned in the bank account's + * last statement. If the bank account does not have any statement + * yet, then zero is returned. When 'withPending' is true, it adds + * the pending transactions to it. + */ +fun getBalance( + bankAccount: BankAccountEntity, + withPending: Boolean = true ): BigDecimal { - var ret = baseBalance - history.forEach direction@ { - if (it.direction == "CRDT") { - val amount = parseDecimal(it.amount) - ret += amount - return@direction + val lastStatement = transaction { + BankAccountStatementEntity.find { + BankAccountStatementsTable.bankAccount eq bankAccount.id + }.lastOrNull() + } + var lastBalance = if (lastStatement == null) { + BigDecimal.ZERO + } else { BigDecimal(lastStatement.balanceClbd) } + if (!withPending) return lastBalance + /** + * Caller asks to include the pending transactions in the + * balance. The block below gets the transactions happened + * later than the last statement and adds them to the balance + * that was calculated so far. + */ + transaction { + val pendingTransactions = BankAccountTransactionEntity.find { + BankAccountTransactionsTable.account eq bankAccount.id and ( + BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L)) } - if (it.direction == "DBIT") { - val amount = parseDecimal(it.amount) - ret -= amount - return@direction + pendingTransactions.forEach { tx -> + when (tx.direction) { + "DBIT" -> lastBalance -= parseDecimal(tx.amount) + "CRDT" -> lastBalance += parseDecimal(tx.amount) + else -> { + logger.error("Transaction ${tx.id} is neither debit nor credit.") + throw SandboxError( + HttpStatusCode.InternalServerError, + "Error in transactions state." + ) + } + } } - throw SandboxError( - HttpStatusCode.InternalServerError, - "A payment direction was found neither CRDT nor DBIT", - LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE + } + return lastBalance +} + +// Wrapper offering to get bank accounts from a string. +fun getBalance(accountLabel: String, withPending: Boolean = false): BigDecimal { + val account = transaction { + BankAccountEntity.find { BankAccountsTable.label.eq(accountLabel) }.firstOrNull() + } + if (account == null) throw notFound("Bank account $accountLabel not found") + return getBalance(account, withPending) +} + +fun wireTransfer( + debitAccount: String, + creditAccount: String, + demobank: String, + subject: String, + amount: String, // $currency:x.y + pmtInfId: String? = null +): String { + val args: Triple<BankAccountEntity, BankAccountEntity, DemobankConfigEntity> = transaction { + val debitAccountDb = BankAccountEntity.find { + BankAccountsTable.label eq debitAccount + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, + "Debit account '$debitAccount' not found" + ) + val creditAccountDb = BankAccountEntity.find { + BankAccountsTable.label eq creditAccount + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, + "Credit account '$creditAccount' not found" + ) + val demoBank = DemobankConfigEntity.find { + DemobankConfigsTable.name eq demobank + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, + "Demobank '$demobank' not found" ) + + Triple(debitAccountDb, creditAccountDb, demoBank) } - return ret + + return wireTransfer( + debitAccount = args.first, + creditAccount = args.second, + demobank = args.third, + subject = subject, + amount = amount, + pmtInfId + ) } +/** + * Book a CRDT and a DBIT transaction and return the unique reference thereof. + * + * At the moment there is redundancy because all the creditor / debtor details + * are contained (directly or indirectly) already in the BankAccount parameters. + * + * This is kept both not to break the existing tests and to allow future versions + * where one party of the transaction is not a customer of the running Sandbox. + */ -fun balanceForAccount(bankAccount: BankAccountEntity): BigDecimal { - var balance = BigDecimal.ZERO +fun wireTransfer( + debitAccount: BankAccountEntity, + creditAccount: BankAccountEntity, + demobank: DemobankConfigEntity, + subject: String, + amount: String, // $currency:$value + pmtInfId: String? = null +): String { + val checkAmount = parseAmount(amount) + if (checkAmount.amount == 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}") + // Check funds are sufficient. + val pendingBalance = getBalance(debitAccount, withPending = true) + val maxDebt = if (debitAccount.label == "bank") { + demobank.bankDebtLimit + } else demobank.usersDebtLimit + if ((pendingBalance - checkAmount.amount).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") + } + val timeStamp = getUTCnow().toInstant().toEpochMilli() + val transactionRef = getRandomString(8) transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.direction eq "CRDT" and ( - BankAccountTransactionsTable.account eq bankAccount.id) - }.forEach { - val amount = parseDecimal(it.amount) - balance += amount + BankAccountTransactionEntity.new { + creditorIban = creditAccount.iban + creditorBic = creditAccount.bic + this.creditorName = getPersonNameFromCustomer(creditAccount.owner) + debtorIban = debitAccount.iban + debtorBic = debitAccount.bic + debtorName = getPersonNameFromCustomer(debitAccount.owner) + this.subject = subject + this.amount = checkAmount.amount.toPlainString() + this.currency = demobank.currency + date = timeStamp + accountServicerReference = transactionRef + account = creditAccount + direction = "CRDT" + this.demobank = demobank + this.pmtInfId = pmtInfId } - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.direction eq "DBIT" and ( - BankAccountTransactionsTable.account eq bankAccount.id) - }.forEach { - val amount = parseDecimal(it.amount) - balance -= amount + BankAccountTransactionEntity.new { + creditorIban = creditAccount.iban + creditorBic = creditAccount.bic + this.creditorName = getPersonNameFromCustomer(creditAccount.owner) + debtorIban = debitAccount.iban + debtorBic = debitAccount.bic + debtorName = getPersonNameFromCustomer(debitAccount.owner) + this.subject = subject + this.amount = checkAmount.amount.toPlainString() + this.currency = demobank.currency + date = timeStamp + accountServicerReference = transactionRef + account = debitAccount + direction = "DBIT" + this.demobank = demobank + this.pmtInfId = pmtInfId } } - return balance + return transactionRef } \ No newline at end of file diff --git a/sandbox/src/test/kotlin/BalanceTest.kt b/sandbox/src/test/kotlin/BalanceTest.kt @@ -16,7 +16,8 @@ class BalanceTest { SchemaUtils.create( BankAccountsTable, BankAccountTransactionsTable, - BankAccountFreshTransactionsTable + BankAccountFreshTransactionsTable, + BankAccountStatementsTable ) val demobank = DemobankConfigEntity.new { currency = "EUR" @@ -84,7 +85,7 @@ class BalanceTest { accountServicerReference = "test-account-servicer-reference" this.demobank = demobank } - assert(BigDecimal.ONE == balanceForAccount(one)) + assert(BigDecimal.ONE == getBalance(one, withPending = true)) } } }