libeufin

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

commit 546f35fd8476f3c6adbb2b12702de32ec2de3476
parent 83fe3f1e623b0857f04e8da755f760694f5a900d
Author: MS <ms@taler.net>
Date:   Sat, 30 Sep 2023 22:15:28 +0200

GET /accounts DB logic, fix POST /transfer idempotence.

Along this change, the helper that strips IBAN payto URI
got changed to return null on invalid input.  The reason
is (1) a more convenient error handling by the caller: Elvis
operator instead of a try-catch block and (2) the impossibility
of 'util' to throw LibeufinBankException to drive Ktor to any
wanted error response.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 11+++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 16++++++++--------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt | 2+-
Mbank/src/test/kotlin/DatabaseTest.kt | 22++++++++++++++++++----
Mbank/src/test/kotlin/TalerApiTest.kt | 12+++++++-----
Mutil/src/main/kotlin/IbanPayto.kt | 15+++++++--------
7 files changed, 107 insertions(+), 27 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -369,6 +369,17 @@ data class Balance( ) /** + * GET /accounts response. + */ +@Serializable +data class AccountMinimalData( + val username: String, + val name: String, + val balance: Balance, + val debit_threshold: TalerAmount +) + +/** * GET /accounts/$USERNAME response. */ @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -118,9 +118,9 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { db.bankAccountGetFromOwnerId(this.expectRowId()) } val internalPayto: String = if (req.internal_payto_uri != null) { - stripIbanPayto(req.internal_payto_uri) + stripIbanPayto(req.internal_payto_uri) ?: throw badRequest("internal_payto_uri is invalid") } else { - stripIbanPayto(genIbanPaytoUri()) + stripIbanPayto(genIbanPaytoUri()) ?: throw internalServerError("Bank generated an invalid internal payto URI") } if (maybeCustomerExists != null && maybeHasBankAccount != null) { logger.debug("Registering username was found: ${maybeCustomerExists.login}") // Checking _all_ the details are the same. @@ -382,7 +382,6 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden() val txData = call.receive<BankAccountTransactionCreate>() val payto = parsePayto(txData.payto_uri) ?: throw badRequest("Invalid creditor Payto") - val paytoWithoutParams = stripIbanPayto(txData.payto_uri) val subject = payto.message ?: throw badRequest("Wire transfer lacks subject") val debtorBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("Debtor bank account not found") @@ -397,11 +396,12 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { maxDebt = debtorBankAccount.maxDebt )) throw conflict(hint = "Insufficient balance.", talerEc = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT) - logger.info("creditor payto: $paytoWithoutParams") - val creditorBankAccount = db.bankAccountGetFromInternalPayto(paytoWithoutParams) ?: throw notFound( - "Creditor account not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) + logger.info("creditor payto: ${txData.payto_uri}") + val creditorBankAccount = db.bankAccountGetFromInternalPayto("payto://iban/${payto.iban.lowercase()}") + ?: throw notFound( + "Creditor account not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) val dbInstructions = BankInternalTransaction( debtorAccountId = debtorBankAccount.expectRowId(), creditorAccountId = creditorBankAccount.expectRowId(), diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -26,6 +26,7 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.getJdbcConnectionFromPg import tech.libeufin.util.microsToJavaInstant +import tech.libeufin.util.stripIbanPayto import tech.libeufin.util.toDbMicros import java.io.File import java.sql.DriverManager @@ -357,6 +358,58 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { } } + // MIXED CUSTOMER AND BANK ACCOUNT DATA + + /** + * Gets a minimal set of account data, as outlined in the GET /accounts + * endpoint. + */ + fun accountsGetForAdmin(nameFilter: String = "%"): List<AccountMinimalData> { + reconnect() + val stmt = prepare(""" + SELECT + login, + name, + (b.balance).val AS balance_val, + (b.balance).frac AS balance_frac, + (b).has_debt AS balance_has_debt, + (max_debt).val as max_debt_val, + (max_debt).frac as max_debt_frac + FROM customers JOIN bank_accounts AS b + ON customer_id = b.owning_customer_id + WHERE name LIKE ?; + """) + stmt.setString(1, nameFilter) + val res = stmt.executeQuery() + val ret = mutableListOf<AccountMinimalData>() + res.use { + if (!it.next()) return ret + do { + ret.add(AccountMinimalData( + username = it.getString("login"), + name = it.getString("name"), + balance = Balance( + amount = TalerAmount( + value = it.getLong("balance_val"), + frac = it.getInt("balance_frac"), + currency = getCurrency() + ), + credit_debit_indicator = it.getBoolean("balance_has_debt").run { + if (this) return@run CorebankCreditDebitInfo.debit + return@run CorebankCreditDebitInfo.credit + } + ), + debit_threshold = TalerAmount( + value = it.getLong("max_debt_val"), + frac = it.getInt("max_debt_frac"), + currency = getCurrency() + ) + )) + } while (it.next()) + } + return ret + } + // BANK ACCOUNTS /** @@ -1158,12 +1211,13 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { ? ); """) + stmt.setString(1, req.request_uid) stmt.setString(2, req.wtid) stmt.setLong(3, req.amount.value) stmt.setInt(4, req.amount.frac) stmt.setString(5, req.exchange_base_url) - stmt.setString(6, req.credit_account) + stmt.setString(6, stripIbanPayto(req.credit_account) ?: throw badRequest("credit_account payto URI is invalid")) stmt.setLong(7, exchangeBankAccountId) stmt.setLong(8, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setString(9, acctSvcrRef) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt @@ -74,7 +74,7 @@ fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) if (db.bankTransactionCheckExists(req.reserve_pub) != null) throw conflict( "Reserve pub. already used", TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - val exchangePayto = stripIbanPayto(req.selected_exchange) + val exchangePayto = stripIbanPayto(req.selected_exchange) ?: throw badRequest("selected_exchange payto is invalid") db.talerWithdrawalSetDetails( op.withdrawalUuid, exchangePayto, req.reserve_pub ) diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -61,14 +61,14 @@ class DatabaseTest { cashoutCurrency = "KUDOS" ) private val bankAccountFoo = BankAccount( - internalPaytoUri = "FOO-IBAN-XYZ", + internalPaytoUri = "payto://iban/FOO-IBAN-XYZ".lowercase(), lastNexusFetchRowId = 1L, owningCustomerId = 1L, hasDebt = false, maxDebt = TalerAmount(10, 1, "KUDOS") ) private val bankAccountBar = BankAccount( - internalPaytoUri = "BAR-IBAN-ABC", + internalPaytoUri = "payto://iban/BAR-IBAN-ABC".lowercase(), lastNexusFetchRowId = 1L, owningCustomerId = 2L, hasDebt = false, @@ -108,7 +108,7 @@ class DatabaseTest { fun talerTransferTest() { val exchangeReq = TransferRequest( amount = TalerAmount(9, 0, "KUDOS"), - credit_account = "BAR-IBAN-ABC", // foo pays bar + credit_account = "payto://iban/BAR-IBAN-ABC".lowercase(), // foo pays bar exchange_base_url = "example.com/exchange", request_uid = "entropic 0", wtid = "entropic 1" @@ -268,7 +268,7 @@ class DatabaseTest { // Setting the details. assert(db.talerWithdrawalSetDetails( opUuid = uuid, - exchangePayto = "BAR-IBAN-ABC", + exchangePayto = "payto://iban/BAR-IBAN-ABC".lowercase(), reservePub = "UNCHECKED-RESERVE-PUB" )) val opSelected = db.talerWithdrawalGet(uuid) @@ -355,4 +355,18 @@ class DatabaseTest { assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED) assert(db.cashoutGetFromUuid(op.cashoutUuid) != null) // previous didn't delete. } + + // Tests the retrieval of many accounts, used along GET /accounts + @Test + fun accountsForAdmin() { + val db = initDb() + assert(db.accountsGetForAdmin().isEmpty()) // No data exists yet. + assert(db.customerCreate(customerFoo) != null) + assert(db.bankAccountCreate(bankAccountFoo) != null) + assert(db.customerCreate(customerBar) != null) + assert(db.bankAccountCreate(bankAccountBar) != null) + assert(db.accountsGetForAdmin().size == 2) + assert(db.accountsGetForAdmin("F%").size == 1) // gets Foo only + assert(db.accountsGetForAdmin("%ar").size == 1) // gets Bar only + } } diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil +import tech.libeufin.util.stripIbanPayto import java.util.* class TalerApiTest { @@ -20,14 +21,14 @@ class TalerApiTest { cashoutCurrency = "KUDOS" ) private val bankAccountFoo = BankAccount( - internalPaytoUri = "FOO-IBAN-XYZ", + internalPaytoUri = "payto://iban/FOO-IBAN-XYZ".lowercase(), lastNexusFetchRowId = 1L, owningCustomerId = 1L, hasDebt = false, maxDebt = TalerAmount(10, 1, "KUDOS") ) val bankAccountBar = BankAccount( - internalPaytoUri = "BAR-IBAN-ABC", + internalPaytoUri = stripIbanPayto("payto://iban/BAR-IBAN-ABC")!!, lastNexusFetchRowId = 1L, owningCustomerId = 2L, hasDebt = false, @@ -64,7 +65,7 @@ class TalerApiTest { "wtid": "entropic 1", "exchange_base_url": "http://exchange.example.com/", "amount": "KUDOS:55", - "credit_account": "BAR-IBAN-ABC" + "credit_account": "${stripIbanPayto(bankAccountBar.internalPaytoUri)}" } """.trimIndent() // Checking exchange debt constraint. @@ -74,6 +75,7 @@ class TalerApiTest { expectSuccess = false setBody(req) } + println(resp.bodyAsText()) assert(resp.status == HttpStatusCode.Conflict) // Giving debt allowance and checking the OK case. assert(db.bankAccountSetMaxDebt( @@ -189,7 +191,7 @@ class TalerApiTest { setBody(deflater(""" {"amount": "KUDOS:44", "reserve_pub": "RESERVE-PUB-TEST", - "debit_account": "BAR-IBAN-ABC" + "debit_account": "${"payto://iban/BAR-IBAN-ABC".lowercase()}" } """.trimIndent())) } @@ -326,7 +328,7 @@ class TalerApiTest { // Specifying Bar as the exchange, via its Payto URI. assert(db.talerWithdrawalSetDetails( opUuid = uuid, - exchangePayto = "BAR-IBAN-ABC", + exchangePayto = "payto://iban/BAR-IBAN-ABC".lowercase(), reservePub = "UNCHECKED-RESERVE-PUB" )) diff --git a/util/src/main/kotlin/IbanPayto.kt b/util/src/main/kotlin/IbanPayto.kt @@ -98,14 +98,12 @@ fun buildIbanPaytoUri( } /** - * Strip a payto://iban URI of everything - * except the IBAN. + * Strip a payto://iban URI of everything except the IBAN. + * Return null on an invalid URI, letting the caller decide + * how to handle the problem. */ -fun stripIbanPayto(paytoUri: String): String { - val parsedPayto = parsePayto(paytoUri) - if (parsedPayto == null) { - throw Error("invalid payto://iban URI") - } +fun stripIbanPayto(paytoUri: String): String? { + val parsedPayto = parsePayto(paytoUri) ?: return null val canonIban = parsedPayto.iban.lowercase() return "payto://iban/${canonIban}" -} +} +\ No newline at end of file