libeufin

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

commit 25e30f2266920b7a7f2f9605e2fa25c550d6cfe9
parent 19672ffdc52e77942e85a5d68b6795a55ea172e9
Author: MS <ms@taler.net>
Date:   Fri, 22 Sep 2023 13:36:52 +0200

Implementing registration bonus

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 26++++++++++++++++++++++----
Mbank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt | 42+++++++++++++++++++++++++++++++++++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 55+++++++++++++++++++++++++++++--------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 23+++++++++++------------
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 15+++++++--------
Mbank/src/test/kotlin/TalerApiTest.kt | 27+++++++++++++--------------
6 files changed, 121 insertions(+), 67 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -291,8 +291,13 @@ class Database(private val dbConfig: String) { } // BANK ACCOUNTS - // Returns false on conflicts. - fun bankAccountCreate(bankAccount: BankAccount): Boolean { + + /** + * Inserts a new bank account in the database, returning its + * row ID in the successful case. If of unique constrain violation, + * it returns null and any other error will be thrown as 500. + */ + fun bankAccountCreate(bankAccount: BankAccount): Long? { reconnect() if (bankAccount.balance != null) throw internalServerError( @@ -307,7 +312,9 @@ class Database(private val dbConfig: String) { ,is_taler_exchange ,max_debt ) - VALUES (?, ?, ?, ?, (?, ?)::taler_amount) + VALUES + (?, ?, ?, ?, (?, ?)::taler_amount) + RETURNING bank_account_id; """) stmt.setString(1, bankAccount.internalPaytoUri) stmt.setLong(2, bankAccount.owningCustomerId) @@ -316,7 +323,18 @@ class Database(private val dbConfig: String) { stmt.setLong(5, bankAccount.maxDebt.value) stmt.setInt(6, bankAccount.maxDebt.frac) // using the default zero value for the balance. - return myExecute(stmt) + val res = try { + stmt.executeQuery() + } catch (e: SQLException) { + logger.error(e.message) + if (e.errorCode == 0) return null // unique constraint violation. + throw e // rethrow on other errors. + } + res.use { + if (!it.next()) + throw internalServerError("SQL RETURNING gave no bank_account_id.") + return it.getLong("bank_account_id") + } } fun bankAccountSetMaxDebt( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt @@ -9,6 +9,7 @@ import net.taler.common.errorcodes.TalerErrorCode import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.CryptoUtil +import tech.libeufin.util.getNowUs import tech.libeufin.util.maybeUriComponent private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") @@ -95,7 +96,6 @@ fun Routing.accountsMgmtHandlers(db: Database) { if (this == null) throw internalServerError("Max debt not configured") parseTalerAmount(this) } - val bonus = db.configGet("registration_bonus") val newBankAccount = BankAccount( hasDebt = false, internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), @@ -104,8 +104,44 @@ fun Routing.accountsMgmtHandlers(db: Database) { isTalerExchange = req.is_taler_exchange, maxDebt = maxDebt ) - if (!db.bankAccountCreate(newBankAccount)) - throw internalServerError("Could not INSERT bank account despite all the checks.") + val newBankAccountId = db.bankAccountCreate(newBankAccount) + ?: throw internalServerError("Could not INSERT bank account despite all the checks.") + + /** + * The new account got created, now optionally award the registration + * bonus to it. The configuration gets either a Taler amount (of the + * bonus), or null if no bonus is meant to be awarded. + */ + val bonusAmount = db.configGet("registration_bonus") + if (bonusAmount != null) { + // Double-checking that the currency is correct. + val internalCurrency = db.configGet("internal_currency") + ?: throw internalServerError("Bank own currency missing in the config") + val bonusAmountObj = parseTalerAmount2(bonusAmount, FracDigits.EIGHT) + ?: throw internalServerError("Bonus amount found invalid in the config.") + if (bonusAmountObj.currency != internalCurrency) + throw internalServerError("Bonus amount has the wrong currency: ${bonusAmountObj.currency}") + val adminCustomer = db.customerGetFromLogin("admin") + ?: throw internalServerError("Admin customer not found") + val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) + ?: throw internalServerError("Admin bank account not found") + val adminPaysBonus = BankInternalTransaction( + creditorAccountId = newBankAccountId, + debtorAccountId = adminBankAccount.expectRowId(), + amount = bonusAmountObj, + subject = "Registration bonus.", + transactionDate = getNowUs() + ) + when(db.bankTransactionCreate(adminPaysBonus)) { + Database.BankTransactionResult.NO_CREDITOR -> + throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") + Database.BankTransactionResult.NO_DEBTOR -> + throw internalServerError("Bonus impossible: admin not found.") + Database.BankTransactionResult.CONFLICT -> + throw internalServerError("Bonus impossible: admin has insufficient balance.") + Database.BankTransactionResult.SUCCESS -> {/* continue the execution */} + } + } call.respond(HttpStatusCode.Created) return@post } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -219,51 +219,33 @@ fun badRequest( // Generates a new Payto-URI with IBAN scheme. fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}" -/** - * This helper takes the serialized version of a Taler Amount - * type and parses it into Libeufin's internal representation. - * It returns a TalerAmount type, or throws a LibeufinBankException - * it the input is invalid. Such exception will be then caught by - * Ktor, transformed into the appropriate HTTP error type, and finally - * responded to the client. - */ -fun parseTalerAmount( +// Parses Taler amount, returning null if the input is invalid. +fun parseTalerAmount2( amount: String, - fracDigits: FracDigits = FracDigits.EIGHT -): TalerAmount { + fracDigits: FracDigits +): TalerAmount? { val format = when (fracDigits) { FracDigits.TWO -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?)?$" FracDigits.EIGHT -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?)?$" } - val match = Regex(format).find(amount) ?: throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, - hint = "Invalid amount: $amount" - )) + val match = Regex(format).find(amount) ?: return null val _value = match.destructured.component2() // Fraction is at most 8 digits, so it's always < than MAX_INT. val fraction: Int = match.destructured.component3().run { var frac = 0 var power = FRACTION_BASE if (this.isNotEmpty()) - // Skips the dot and processes the fractional chars. + // Skips the dot and processes the fractional chars. this.substring(1).forEach { chr -> power /= 10 frac += power * chr.digitToInt() - } + } return@run frac } val value: Long = try { _value.toLong() } catch (e: NumberFormatException) { - throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, - hint = "Invalid amount: ${amount}, could not extract the value part." - ) - ) + return null } return TalerAmount( value = value, @@ -271,6 +253,27 @@ fun parseTalerAmount( currency = match.destructured.component1() ) } +/** + * This helper takes the serialized version of a Taler Amount + * type and parses it into Libeufin's internal representation. + * It returns a TalerAmount type, or throws a LibeufinBankException + * it the input is invalid. Such exception will be then caught by + * Ktor, transformed into the appropriate HTTP error type, and finally + * responded to the client. + */ +fun parseTalerAmount( + amount: String, + fracDigits: FracDigits = FracDigits.EIGHT +): TalerAmount { + val maybeAmount = parseTalerAmount2(amount, fracDigits) + ?: throw LibeufinBankException( + httpStatus = HttpStatusCode.BadRequest, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, + hint = "Invalid amount: $amount" + )) + return maybeAmount +} private fun normalizeAmount(amt: TalerAmount): TalerAmount { if (amt.frac > FRACTION_BASE) { diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -93,8 +93,8 @@ class DatabaseTest { assert(fooId != null) val barId = db.customerCreate(customerBar) assert(barId != null) - assert(db.bankAccountCreate(bankAccountFoo)) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountFoo) != null) + assert(db.bankAccountCreate(bankAccountBar) != null) val res = db.talerTransferCreate( req = exchangeReq, exchangeBankAccountId = 1L, @@ -127,8 +127,8 @@ class DatabaseTest { assert(fooId != null) val barId = db.customerCreate(customerBar) assert(barId != null) - assert(db.bankAccountCreate(bankAccountFoo)) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountFoo) != null) + assert(db.bankAccountCreate(bankAccountBar) != null) var fooAccount = db.bankAccountGetFromOwnerId(fooId!!) assert(fooAccount?.hasDebt == false) // Foo has NO debit. val currency = "KUDOS" @@ -224,8 +224,8 @@ class DatabaseTest { val currency = "KUDOS" assert(db.bankAccountGetFromOwnerId(1L) == null) assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) - assert(!db.bankAccountCreate(bankAccountFoo)) // Triggers conflict. + assert(db.bankAccountCreate(bankAccountFoo) != null) + assert(db.bankAccountCreate(bankAccountFoo) == null) // Triggers conflict. assert(db.bankAccountGetFromOwnerId(1L)?.balance?.equals(TalerAmount(0, 0, currency)) == true) } @@ -235,9 +235,9 @@ class DatabaseTest { val uuid = UUID.randomUUID() val currency = "KUDOS" assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) // plays the exchange. - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountBar) != null) // insert new. assert(db.talerWithdrawalCreate( uuid, @@ -305,9 +305,9 @@ class DatabaseTest { ) val fooId = db.customerCreate(customerFoo) assert(fooId != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountBar) != null) assert(db.cashoutCreate(op)) val fromDb = db.cashoutGetFromUuid(op.cashoutUuid) assert(fromDb?.subject == op.subject && fromDb.tanConfirmationTime == null) @@ -337,4 +337,4 @@ class DatabaseTest { assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED) assert(db.cashoutGetFromUuid(op.cashoutUuid) != null) // previous didn't delete. } -} -\ No newline at end of file +} diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -60,9 +60,9 @@ class LibeuFinApiTest { fun testHistory() { val db = initDb() val fooId = db.customerCreate(customerFoo); assert(fooId != null) - assert(db.bankAccountCreate(genBankAccount(fooId!!))) + assert(db.bankAccountCreate(genBankAccount(fooId!!)) != null) val barId = db.customerCreate(customerBar); assert(barId != null) - assert(db.bankAccountCreate(genBankAccount(barId!!))) + assert(db.bankAccountCreate(genBankAccount(barId!!)) != null) for (i in 1..10) { db.bankTransactionCreate(genTx("test-$i")) } testApplication { application { @@ -91,10 +91,10 @@ class LibeuFinApiTest { val db = initDb() // foo account val fooId = db.customerCreate(customerFoo); assert(fooId != null) - assert(db.bankAccountCreate(genBankAccount(fooId!!))) + assert(db.bankAccountCreate(genBankAccount(fooId!!)) != null) // bar account val barId = db.customerCreate(customerBar); assert(barId != null) - assert(db.bankAccountCreate(genBankAccount(barId!!))) + assert(db.bankAccountCreate(genBankAccount(barId!!)) != null) // accounts exist, now create one transaction. testApplication { application { @@ -185,7 +185,7 @@ class LibeuFinApiTest { maxDebt = TalerAmount(100, 0, "KUDOS"), owningCustomerId = customerRowId!! ) - )) + ) != null) testApplication { application { corebankWebApp(db) @@ -208,7 +208,7 @@ class LibeuFinApiTest { internalPaytoUri = "payto://iban/SANDBOXX/ADMIN-IBAN", maxDebt = TalerAmount(100, 0, "KUDOS"), owningCustomerId = adminRowId!! - ))) + )) != null) client.get("/accounts/foo") { expectSuccess = true basicAuth("admin", "admin") @@ -290,4 +290,4 @@ class LibeuFinApiTest { assert(resp.status == HttpStatusCode.Created) } } -} -\ No newline at end of file +} diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -49,9 +49,9 @@ class TalerApiTest { val db = initDb() // Creating the exchange and merchant accounts first. assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountBar) != null) // Give the exchange reasonable debt allowance: assert(db.bankAccountSetMaxDebt( 1L, @@ -126,9 +126,9 @@ class TalerApiTest { fun historyIncoming() { val db = initDb() assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountBar) != null) // Give Foo reasonable debt allowance: assert(db.bankAccountSetMaxDebt( 1L, @@ -161,9 +161,9 @@ class TalerApiTest { fun addIncoming() { val db = initDb() assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountBar) != null) // Give Bar reasonable debt allowance: assert(db.bankAccountSetMaxDebt( 2L, @@ -192,7 +192,7 @@ class TalerApiTest { val db = initDb() val uuid = UUID.randomUUID() assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) db.configSet( "suggested_exchange", "payto://suggested-exchange" @@ -224,7 +224,7 @@ class TalerApiTest { val db = initDb() val uuid = UUID.randomUUID() assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) db.configSet( "suggested_exchange", "payto://suggested-exchange" @@ -251,7 +251,7 @@ class TalerApiTest { val db = initDb() val uuid = UUID.randomUUID() assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) // insert new. assert(db.talerWithdrawalCreate( opUUID = uuid, @@ -277,7 +277,7 @@ class TalerApiTest { fun withdrawalCreation() { val db = initDb() assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) testApplication { application { corebankWebApp(db) @@ -305,9 +305,9 @@ class TalerApiTest { val db = initDb() // Creating Foo as the wallet owner and Bar as the exchange. assert(db.customerCreate(customerFoo) != null) - assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) - assert(db.bankAccountCreate(bankAccountBar)) + assert(db.bankAccountCreate(bankAccountBar) != null) // Artificially making a withdrawal operation for Foo. val uuid = UUID.randomUUID() @@ -363,4 +363,4 @@ class TalerApiTest { ) assert(withPort == "taler://withdraw/www.example.com:9876/taler-integration/my-id") } -} -\ No newline at end of file +}