libeufin

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

commit 6024130ab85171528bff4f57d8ac31a1a06994d2
parent 0920cb07545501e54659f6a7ae94afbab86633f5
Author: Antoine A <>
Date:   Thu, 26 Oct 2023 15:45:35 +0000

Remove unused columns, fix account creation and other improvements

Diffstat:
Abank/conf/test_bonus.conf | 10++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 264++++++++++++++++++++-----------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 320++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 15+++++++++++----
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 86+++++++++++--------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 48+++++++++++++++++++++++-------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mbank/src/test/kotlin/DatabaseTest.kt | 17++---------------
Mbank/src/test/kotlin/helpers.kt | 21++++++++++++---------
Mdatabase-versioning/libeufin-bank-0001.sql | 58+++++++++++++---------------------------------------------
Mdatabase-versioning/libeufin-bank-procedures.sql | 27+++++++++++++++------------
11 files changed, 433 insertions(+), 495 deletions(-)

diff --git a/bank/conf/test_bonus.conf b/bank/conf/test_bonus.conf @@ -0,0 +1,9 @@ +[libeufin-bank] +CURRENCY = KUDOS +DEFAULT_ADMIN_DEBT_LIMIT = KUDOS:10 +REGISTRATION_BONUS_ENABLED = yes +REGISTRATION_BONUS = KUDOS:10 + +[libeufin-bankdb-postgres] +SQL_DIR = $DATADIR/sql/ +CONFIG = postgresql:///libeufincheck +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -98,27 +98,21 @@ private fun Routing.coreBankTokenApi(db: Database) { throw badRequest("Bad token duration: ${e.message}") } } - val customerDbRow = - db.customerGetFromLogin(login)?.customerId - ?: throw internalServerError( - "Could not get customer '$login' database row ID" - ) - val token = - BearerToken( - bankCustomer = customerDbRow, - content = tokenBytes, - creationTime = creationTime, - expirationTime = expirationTimestamp, - scope = req.scope, - isRefreshable = req.refreshable - ) - if (!db.bearerTokenCreate(token)) - throw internalServerError("Failed at inserting new token in the database") + if (!db.bearerTokenCreate( + login = login, + content = tokenBytes, + creationTime = creationTime, + expirationTime = expirationTimestamp, + scope = req.scope, + isRefreshable = req.refreshable + )) { + throw internalServerError("Failed at inserting new token in the database") + } call.respond( - TokenSuccessResponse( - access_token = Base32Crockford.encode(tokenBytes), - expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) - ) + TokenSuccessResponse( + access_token = Base32Crockford.encode(tokenBytes), + expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) + ) ) } delete("/accounts/{USERNAME}/token") { @@ -148,101 +142,42 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankConfig) { val req = call.receive<RegisterAccountRequest>() // Prohibit reserved usernames: if (reservedAccounts.contains(req.username)) - throw forbidden( - "Username '${req.username}' is reserved.", - TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT - ) - // Checking idempotency. - val maybeCustomerExists = - db.customerGetFromLogin( - req.username - ) // Can be null if previous call crashed before completion. - val maybeHasBankAccount = - maybeCustomerExists.run { - if (this == null) return@run null - db.bankAccountGetFromOwnerId(this.customerId) - } - val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri()) - if (maybeCustomerExists != null && maybeHasBankAccount != null) { - logger.debug( - "Registering username was found: ${maybeCustomerExists.login}" - ) // Checking _all_ the details are the same. - val isIdentic = - maybeCustomerExists.name == req.name && - maybeCustomerExists.email == req.challenge_contact_data?.email && - maybeCustomerExists.phone == req.challenge_contact_data?.phone && - maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && - CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) && - maybeHasBankAccount.isPublic == req.is_public && - maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && - maybeHasBankAccount.internalPaytoUri.canonical == - internalPayto.canonical - if (isIdentic) { - call.respond(HttpStatusCode.Created) - return@post - } - throw conflict( - "Idempotency check failed.", - TalerErrorCode.TALER_EC_END // FIXME: provide appropriate EC. + throw forbidden( + "Username '${req.username}' is reserved.", + TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT ) - } - // From here: fresh user being added. - val (_, newBankAccountId) = db.accountCreate( - login = req.username, - name = req.name, - email = req.challenge_contact_data?.email, - phone = req.challenge_contact_data?.phone, - cashoutPayto = - req.cashout_payto_uri, // Following could be gone, if included in - // cashout_payto_uri - cashoutCurrency = ctx.fiatCurrency, - passwordHash = CryptoUtil.hashpw(req.password), - internalPaytoUri = internalPayto, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = ctx.defaultCustomerDebtLimit + val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri()) + val result = db.accountCreate( + login = req.username, + name = req.name, + email = req.challenge_contact_data?.email, + phone = req.challenge_contact_data?.phone, + cashoutPayto = req.cashout_payto_uri, + password = req.password, + internalPaytoUri = internalPayto, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = ctx.defaultCustomerDebtLimit, + bonus = if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus + else null ) - // The new account got created, now optionally award the registration - // bonus to it. - val bonusAmount = - if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus - else null - if (bonusAmount != null) { - val adminCustomer = - db.customerGetFromLogin("admin") - ?: throw internalServerError("Admin customer not found") - val adminBankAccount = - db.bankAccountGetFromOwnerId(adminCustomer.customerId) - ?: throw internalServerError("Admin bank account not found") - val adminPaysBonus = - BankInternalTransaction( - creditorAccountId = newBankAccountId, - debtorAccountId = adminBankAccount.bankAccountId, - amount = bonusAmount, - subject = "Registration bonus.", - transactionDate = Instant.now() - ) - when (db.bankTransactionCreate(adminPaysBonus)) { - BankTransactionResult.NO_CREDITOR -> - throw internalServerError( - "Bonus impossible: creditor not found, despite its recent creation." - ) - BankTransactionResult.NO_DEBTOR -> - throw internalServerError("Bonus impossible: admin not found.") - BankTransactionResult.BALANCE_INSUFFICIENT -> - throw internalServerError( - "Bonus impossible: admin has insufficient balance." - ) - BankTransactionResult.SAME_ACCOUNT -> - throw internalServerError("Bonus impossible: admin should not be creditor.") - BankTransactionResult.SUCCESS -> { - /* continue the execution */ - } - } + when (result) { + CustomerCreationResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + CustomerCreationResult.CONFLICT_LOGIN -> throw conflict( + "Customer username reuse '${req.username}'", + TalerErrorCode.TALER_EC_END // FIXME: provide appropriate EC. + ) + CustomerCreationResult.CONFLICT_PAY_TO -> throw conflict( + "Bank internalPayToUri reuse '${internalPayto.canonical}'", + TalerErrorCode.TALER_EC_END // FIXME: provide appropriate EC. + ) + CustomerCreationResult.SUCCESS -> call.respond(HttpStatusCode.Created) } - call.respond(HttpStatusCode.Created) } delete("/accounts/{USERNAME}") { val (login, _) = @@ -277,57 +212,25 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankConfig) { val (login, isAdmin) = call.authCheck(db, TokenScope.readwrite, withAdmin = true) // admin is not allowed itself to change its own details. if (login == "admin") throw forbidden("admin account not patchable") - // authentication OK, go on. + val req = call.receive<AccountReconfiguration>() - /** - * This object holds the details of the customer that's affected by this operation, as it - * MAY differ from the one being authenticated. This typically happens when admin did the - * request. - */ - val accountCustomer = - db.customerGetFromLogin(login) - ?: throw notFound( - "Account $login not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC. - ) - // Check if a non-admin user tried to change their legal name - if (!isAdmin && (req.name != null) && (req.name != accountCustomer.name)) + val res = db.accountReconfig( + login = login, + name = req.name, + cashoutPayto = req.cashout_address, + emailAddress = req.challenge_contact_data?.email, + isTalerExchange = req.is_exchange, + phoneNumber = req.challenge_contact_data?.phone, + isAdmin = isAdmin + ) + when (res) { + CustomerPatchResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + CustomerPatchResult.ACCOUNT_NOT_FOUND -> throw notFound( + "Customer '$login' not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + CustomerPatchResult.CONFLICT_LEGAL_NAME -> throw forbidden("non-admin user cannot change their legal name") - // Preventing identical data to be overridden. - val bankAccount = - db.bankAccountGetFromOwnerId(accountCustomer.customerId) - ?: throw internalServerError( - "Customer '${accountCustomer.login}' lacks bank account." - ) - if ((req.is_exchange == bankAccount.isTalerExchange) && - (req.cashout_address == accountCustomer.cashoutPayto) && - (req.name == accountCustomer.name) && - (req.challenge_contact_data?.phone == accountCustomer.phone) && - (req.challenge_contact_data?.email == accountCustomer.email) - ) { - call.respond(HttpStatusCode.NoContent) - return@patch - } - val dbRes = - db.accountReconfig( - login = accountCustomer.login, - name = req.name, - cashoutPayto = req.cashout_address, - emailAddress = req.challenge_contact_data?.email, - isTalerExchange = req.is_exchange, - phoneNumber = req.challenge_contact_data?.phone - ) - when (dbRes) { - AccountReconfigDBResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) - AccountReconfigDBResult.CUSTOMER_NOT_FOUND -> { - // Rare case. Only possible if a deletion happened before the flow reaches here. - logger.warn("Authenticated customer wasn't found any more in the database") - throw notFound("Customer not found", TalerErrorCode.TALER_EC_END) // FIXME: needs EC - } - AccountReconfigDBResult.BANK_ACCOUNT_NOT_FOUND -> { - // Bank's fault: no customer should lack a bank account. - throw internalServerError("Customer '${accountCustomer.login}' lacks bank account") - } } } patch("/accounts/{USERNAME}/auth") { @@ -370,44 +273,11 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankConfig) { } get("/accounts/{USERNAME}") { val (login, _) = call.authCheck(db, TokenScope.readonly, withAdmin = true) - val customerData = - db.customerGetFromLogin(login) - ?: throw notFound( - "Customer '$login' not found in the database.", - talerEc = TalerErrorCode.TALER_EC_END - ) - val bankAccountData = - db.bankAccountGetFromOwnerId(customerData.customerId) - ?: throw internalServerError( - "Customer '$login' had no bank account despite they are customer.'" - ) - val balance = - Balance( - amount = bankAccountData.balance - ?: throw internalServerError( - "Account '${customerData.login}' lacks balance!" - ), - credit_debit_indicator = - if (bankAccountData.hasDebt) { - CorebankCreditDebitInfo.debit - } else { - CorebankCreditDebitInfo.credit - } - ) - call.respond( - AccountData( - name = customerData.name, - balance = balance, - debit_threshold = bankAccountData.maxDebt, - payto_uri = bankAccountData.internalPaytoUri, - contact_data = - ChallengeContactData( - email = customerData.email, - phone = customerData.phone - ), - cashout_payto_uri = customerData.cashoutPayto, - ) + val account = db.accountDataFromLogin(login) ?: throw notFound( + "Customer '$login' not found in the database.", + talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) + call.respond(account) } } @@ -418,7 +288,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { val bankAccount = call.bankAccount(db) val history: List<BankAccountTransactionInfo> = - db.bankPoolHistory(params, bankAccount.bankAccountId!!) + db.bankPoolHistory(params, bankAccount.bankAccountId) call.respond(BankAccountTransactionsResponse(history)) } get("/accounts/{USERNAME}/transactions/{T_ID}") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -172,57 +172,40 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f } } - suspend fun customerGetFromLogin(login: String): Customer? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - customer_id, - password_hash, - name, - email, - phone, - cashout_payto, - cashout_currency - FROM customers - WHERE login=? - """) - stmt.setString(1, login) - stmt.oneOrNull { - Customer( - login = login, - passwordHash = it.getString("password_hash"), - name = it.getString("name"), - phone = it.getString("phone"), - email = it.getString("email"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutPayto = it.getString("cashout_payto"), - customerId = it.getLong("customer_id") - ) - } - } - - // Possibly more "customerGetFrom*()" to come. - // BEARER TOKEN - suspend fun bearerTokenCreate(token: BearerToken): Boolean = conn { conn -> + suspend fun bearerTokenCreate( + login: String, + content: ByteArray, + creationTime: Instant, + expirationTime: Instant, + scope: TokenScope, + isRefreshable: Boolean + ): Boolean = conn { conn -> + val bankCustomer = conn.prepareStatement(""" + SELECT customer_id FROM customers WHERE login=? + """).run { + setString(1, login) + oneOrNull { it.getLong(1) }!! + } val stmt = conn.prepareStatement(""" - INSERT INTO bearer_tokens - (content, + INSERT INTO bearer_tokens ( + content, creation_time, expiration_time, scope, bank_customer, is_refreshable - ) VALUES - (?, ?, ?, ?::token_scope_enum, ?, ?) + ) VALUES (?, ?, ?, ?::token_scope_enum, ?, ?) """) - stmt.setBytes(1, token.content) - stmt.setLong(2, token.creationTime.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.setLong(3, token.expirationTime.toDbMicros() ?: throw faultyDurationByClient()) - stmt.setString(4, token.scope.name) - stmt.setLong(5, token.bankCustomer) - stmt.setBoolean(6, token.isRefreshable) + stmt.setBytes(1, content) + stmt.setLong(2, creationTime.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setLong(3, expirationTime.toDbMicros() ?: throw faultyDurationByClient()) + stmt.setString(4, scope.name) + stmt.setLong(5, bankCustomer) + stmt.setBoolean(6, isRefreshable) stmt.executeUpdateViolation() } + suspend fun bearerTokenGet(token: ByteArray): BearerToken? = conn { conn -> val stmt = conn.prepareStatement(""" SELECT @@ -265,63 +248,171 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f suspend fun accountCreate( login: String, - passwordHash: String, + password: String, name: String, email: String? = null, phone: String? = null, - cashoutPayto: String? = null, - cashoutCurrency: String? = null, + cashoutPayto: IbanPayTo? = null, internalPaytoUri: IbanPayTo, isPublic: Boolean, isTalerExchange: Boolean, - maxDebt: TalerAmount - ): Pair<Long, Long> = conn { it -> + maxDebt: TalerAmount, + bonus: TalerAmount? + ): CustomerCreationResult = conn { it -> it.transaction { conn -> - val customerId = conn.prepareStatement(""" - INSERT INTO customers ( - login - ,password_hash - ,name - ,email - ,phone - ,cashout_payto - ,cashout_currency - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING customer_id - """ - ).run { - setString(1, login) - setString(2, passwordHash) - setString(3, name) - setString(4, email) - setString(5, phone) - setString(6, cashoutPayto) - setString(7, cashoutCurrency) - oneOrNull { it.getLong("customer_id") } - ?: throw internalServerError("SQL RETURNING gave no customer_id.") + val idempotent = conn.prepareStatement(""" + SELECT password_hash, name=? + AND email IS NOT DISTINCT FROM ? + AND phone IS NOT DISTINCT FROM ? + AND cashout_payto IS NOT DISTINCT FROM ? + AND internal_payto_uri=? + AND is_public=? + AND is_taler_exchange=? + FROM customers + JOIN bank_accounts + ON customer_id=owning_customer_id + WHERE login=? + """).run { + setString(1, name) + setString(2, email) + setString(3, phone) + setString(4, cashoutPayto?.canonical) + setString(5, internalPaytoUri.canonical) + setBoolean(6, isPublic) + setBoolean(7, isTalerExchange) + setString(8, login) + oneOrNull { + CryptoUtil.checkpw(password, it.getString(1)) && it.getBoolean(2) + } + } + if (idempotent != null) { + if (idempotent) { + CustomerCreationResult.SUCCESS + } else { + CustomerCreationResult.CONFLICT_LOGIN + } + } else { + val customerId = conn.prepareStatement(""" + INSERT INTO customers ( + login + ,password_hash + ,name + ,email + ,phone + ,cashout_payto + ) VALUES (?, ?, ?, ?, ?, ?) + RETURNING customer_id + """ + ).run { + setString(1, login) + setString(2, CryptoUtil.hashpw(password)) + setString(3, name) + setString(4, email) + setString(5, phone) + setString(6, cashoutPayto?.canonical) + oneOrNull { it.getLong("customer_id") }!! + } + + conn.prepareStatement(""" + INSERT INTO bank_accounts( + internal_payto_uri + ,owning_customer_id + ,is_public + ,is_taler_exchange + ,max_debt + ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount) + """).run { + setString(1, internalPaytoUri.canonical) + setLong(2, customerId) + setBoolean(3, isPublic) + setBoolean(4, isTalerExchange) + setLong(5, maxDebt.value) + setInt(6, maxDebt.frac) + if (!executeUpdateViolation()) { + conn.rollback() + return@transaction CustomerCreationResult.CONFLICT_PAY_TO + } + } + + if (bonus != null) { + conn.prepareStatement(""" + SELECT out_balance_insufficient + FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,?,?,?) + """).run { + setString(1, internalPaytoUri.canonical) + setLong(2, bonus.value) + setInt(3, bonus.frac) + setLong(4, Instant.now().toDbMicros() ?: throw faultyTimestampByBank()) + setString(5, "not used") // ISO20022 + setString(6, "not used") // ISO20022 + setString(7, "not used") // ISO20022 + executeQuery().use { + when { + !it.next() -> throw internalServerError("Bank transaction didn't properly return") + it.getBoolean("out_balance_insufficient") -> { + conn.rollback() + CustomerCreationResult.BALANCE_INSUFFICIENT + } + else -> CustomerCreationResult.SUCCESS + } + } + } + } else { + CustomerCreationResult.SUCCESS + } } - - val stmt = conn.prepareStatement(""" - INSERT INTO bank_accounts - (internal_payto_uri - ,owning_customer_id - ,is_public - ,is_taler_exchange - ,max_debt - ) - VALUES (?, ?, ?, ?, (?, ?)::taler_amount) - RETURNING bank_account_id; - """) - stmt.setString(1, internalPaytoUri.canonical) - stmt.setLong(2, customerId) - stmt.setBoolean(3, isPublic) - stmt.setBoolean(4, isTalerExchange) - stmt.setLong(5, maxDebt.value) - stmt.setInt(6, maxDebt.frac) - val bankId = stmt.oneOrNull { it.getLong("bank_account_id") } - ?: throw internalServerError("SQL RETURNING gave no bank_account_id.") - Pair(customerId, bankId) + } + } + + suspend fun accountDataFromLogin( + login: String + ): AccountData? = conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + name + ,email + ,phone + ,cashout_payto + ,internal_payto_uri + ,(balance).val AS balance_val + ,(balance).frac AS balance_frac + ,has_debt + ,(max_debt).val AS max_debt_val + ,(max_debt).frac AS max_debt_frac + FROM customers + JOIN bank_accounts + ON customer_id=owning_customer_id + WHERE login=? + """) + stmt.setString(1, login) + stmt.oneOrNull { + AccountData( + name = it.getString("name"), + contact_data = ChallengeContactData( + email = it.getString("email"), + phone = it.getString("phone") + ), + cashout_payto_uri = it.getString("cashout_payto")?.run(::IbanPayTo), + payto_uri = IbanPayTo(it.getString("internal_payto_uri")), + balance = Balance( + amount = TalerAmount( + it.getLong("balance_val"), + it.getInt("balance_frac"), + getCurrency() + ), + credit_debit_indicator = + if (it.getBoolean("has_debt")) { + CorebankCreditDebitInfo.debit + } else { + CorebankCreditDebitInfo.credit + } + ), + debit_threshold = TalerAmount( + value = it.getLong("max_debt_val"), + frac = it.getInt("max_debt_frac"), + getCurrency() + ) + ) } } @@ -342,33 +433,34 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f suspend fun accountReconfig( login: String, name: String?, - cashoutPayto: String?, + cashoutPayto: IbanPayTo?, phoneNumber: String?, emailAddress: String?, - isTalerExchange: Boolean? - ): AccountReconfigDBResult = conn { conn -> + isTalerExchange: Boolean?, + isAdmin: Boolean + ): CustomerPatchResult = conn { conn -> val stmt = conn.prepareStatement(""" SELECT - out_nx_customer, - out_nx_bank_account - FROM account_reconfig(?, ?, ?, ?, ?, ?) + out_not_found, + out_legal_name_change + FROM account_reconfig(?, ?, ?, ?, ?, ?, ?) """) stmt.setString(1, login) stmt.setString(2, name) stmt.setString(3, phoneNumber) stmt.setString(4, emailAddress) - stmt.setString(5, cashoutPayto) - + stmt.setString(5, cashoutPayto?.canonical) if (isTalerExchange == null) stmt.setNull(6, Types.NULL) else stmt.setBoolean(6, isTalerExchange) + stmt.setBoolean(7, isAdmin) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("accountReconfig() returned nothing") - it.getBoolean("out_nx_customer") -> AccountReconfigDBResult.CUSTOMER_NOT_FOUND - it.getBoolean("out_nx_bank_account") -> AccountReconfigDBResult.BANK_ACCOUNT_NOT_FOUND - else -> AccountReconfigDBResult.SUCCESS + it.getBoolean("out_not_found") -> CustomerPatchResult.ACCOUNT_NOT_FOUND + it.getBoolean("out_legal_name_change") -> CustomerPatchResult.CONFLICT_LEGAL_NAME + else -> CustomerPatchResult.SUCCESS } } } @@ -483,11 +575,10 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f suspend fun bankAccountGetFromOwnerId(ownerId: Long): BankAccount? = conn { conn -> val stmt = conn.prepareStatement(""" SELECT - internal_payto_uri + internal_payto_uri ,owning_customer_id ,is_public ,is_taler_exchange - ,last_nexus_fetch_row_id ,(balance).val AS balance_val ,(balance).frac AS balance_frac ,has_debt @@ -507,7 +598,6 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f it.getInt("balance_frac"), getCurrency() ), - lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), owningCustomerId = it.getLong("owning_customer_id"), hasDebt = it.getBoolean("has_debt"), isTalerExchange = it.getBoolean("is_taler_exchange"), @@ -530,7 +620,6 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f ,internal_payto_uri ,is_public ,is_taler_exchange - ,last_nexus_fetch_row_id ,(balance).val AS balance_val ,(balance).frac AS balance_frac ,has_debt @@ -551,7 +640,6 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f it.getInt("balance_frac"), getCurrency() ), - lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), owningCustomerId = it.getLong("owning_customer_id"), hasDebt = it.getBoolean("has_debt"), isTalerExchange = it.getBoolean("is_taler_exchange"), @@ -560,6 +648,7 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f frac = it.getInt("max_debt_frac"), getCurrency() ), + isPublic = it.getBoolean("is_public"), bankAccountId = it.getLong("bank_account_id") ) } @@ -741,6 +830,7 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f ,end_to_end_id ,direction ,bank_account_id + ,bank_transaction_id FROM bank_account_transactions WHERE bank_transaction_id=? """) @@ -762,7 +852,8 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f bankAccountId = it.getLong("bank_account_id"), paymentInformationId = it.getString("payment_information_id"), subject = it.getString("subject"), - transactionDate = it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank() + transactionDate = it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank(), + dbRowId = it.getLong("bank_transaction_id") ) } } @@ -1530,6 +1621,21 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f } } +/** Result status of customer account creation */ +enum class CustomerCreationResult { + SUCCESS, + CONFLICT_LOGIN, + CONFLICT_PAY_TO, + BALANCE_INSUFFICIENT, +} + +/** Result status of customer account patch */ +enum class CustomerPatchResult { + ACCOUNT_NOT_FOUND, + CONFLICT_LEGAL_NAME, + SUCCESS +} + /** Result status of customer account deletion */ enum class CustomerDeletionResult { SUCCESS, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -410,14 +410,21 @@ class ExchangeUrl { } } +sealed class PaytoUri { + abstract val amount: TalerAmount? + abstract val message: String? + abstract val receiverName: String? +} + +// TODO x-taler-bank Payto @Serializable(with = IbanPayTo.Serializer::class) -class IbanPayTo { +class IbanPayTo: PaytoUri { val parsed: URI val canonical: String - val amount: TalerAmount? - val message: String? - val receiverName: String? + override val amount: TalerAmount? + override val message: String? + override val receiverName: String? constructor(raw: String) { parsed = URI(raw) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -82,7 +82,7 @@ data class RegisterAccountRequest( val is_taler_exchange: Boolean = false, val challenge_contact_data: ChallengeContactData? = null, // External bank account where to send cashout amounts. - val cashout_payto_uri: String? = null, + val cashout_payto_uri: IbanPayTo? = null, // Bank account internal to Libeufin-Bank. val internal_payto_uri: IbanPayTo? = null ) @@ -124,30 +124,6 @@ data class MonitorWithCashout( ) : MonitorResponse() /** - * Convenience type to hold customer data, typically after such - * data gets fetched from the database. It is also used to _insert_ - * customer data to the database. - */ -data class Customer( - val login: String, - val passwordHash: String, - val name: String, - val customerId: Long, - val email: String? = null, - val phone: String? = null, - /** - * External bank account where customers send - * their cashout amounts. - */ - val cashoutPayto: String? = null, - /** - * Currency of the external bank account where - * customers send their cashout amounts. - */ - val cashoutCurrency: String? = null -) - -/** * Convenience type to get and set bank account information * from/to the database. */ @@ -156,20 +132,9 @@ data class BankAccount( // Database row ID of the customer that owns this bank account. val owningCustomerId: Long, val bankAccountId: Long, - val isPublic: Boolean = false, - val isTalerExchange: Boolean = false, - /** - * Because bank accounts MAY be funded by an external currency, - * local bank accounts need to query Nexus, in order to find this - * out. This field is a pointer to the latest incoming payment that - * was contained in a Nexus history response. - * - * Typically, the 'admin' bank account uses this field, in order - * to initiate Taler withdrawals that depend on an external currency - * being wired by wallet owners. - */ - val lastNexusFetchRowId: Long = 0L, - val balance: TalerAmount? = null, // null when a new bank account gets created. + val isPublic: Boolean, + val isTalerExchange: Boolean, + val balance: TalerAmount, val hasDebt: Boolean, val maxDebt: TalerAmount ) @@ -253,7 +218,7 @@ data class BankAccountTransaction( */ val bankAccountId: Long, // Null if this type is used to _create_ one transaction. - val dbRowId: Long? = null, + val dbRowId: Long, // Following are ISO20022 specific. val accountServicerReference: String, val paymentInformationId: String, @@ -267,9 +232,9 @@ data class BankAccountTransaction( data class TalerWithdrawalOperation( val withdrawalUuid: UUID, val amount: TalerAmount, - val selectionDone: Boolean = false, - val aborted: Boolean = false, - val confirmationDone: Boolean = false, + val selectionDone: Boolean, + val aborted: Boolean, + val confirmationDone: Boolean, val reservePub: EddsaPublicKey?, val selectedExchangePayto: IbanPayTo?, val walletBankAccount: Long @@ -349,7 +314,7 @@ data class AccountData( val payto_uri: IbanPayTo, val debit_threshold: TalerAmount, val contact_data: ChallengeContactData? = null, - val cashout_payto_uri: String? = null, + val cashout_payto_uri: IbanPayTo? = null, ) /** @@ -660,34 +625,7 @@ data class AccountPasswordChange( @Serializable data class AccountReconfiguration( val challenge_contact_data: ChallengeContactData?, - val cashout_address: String?, + val cashout_address: IbanPayTo?, val name: String?, val is_exchange: Boolean? -) - -/** - * This type expresses the outcome of updating the account - * data in the database. - */ -enum class AccountReconfigDBResult { - /** - * This indicates that despite the customer row was - * found in the database, its related bank account was not. - * This condition is a hard failure of the bank, since - * every customer must have one (and only one) bank account. - */ - BANK_ACCOUNT_NOT_FOUND, - - /** - * The customer row wasn't found in the database. This error - * should be rare, as the client got authenticated in the first - * place, before the handler could try the reconfiguration in - * the database. - */ - CUSTOMER_NOT_FOUND, - - /** - * Reconfiguration successful. - */ - SUCCESS -} -\ No newline at end of file +) +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -221,30 +221,28 @@ data class CashoutRateParams( * It returns false in case of problems, true otherwise. */ suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig, pw: String? = null): Boolean { - val maybeAdminCustomer = db.customerGetFromLogin("admin") - if (maybeAdminCustomer == null) { - logger.debug("Creating admin's account") - var pwStr = pw; - if (pwStr == null) { - val pwBuf = ByteArray(32) - Random().nextBytes(pwBuf) - pwStr = String(pwBuf, Charsets.UTF_8) - } - - - db.accountCreate( - login = "admin", - /** - * Hashing the password helps to avoid the "password not hashed" - * error, in case the admin tries to authenticate. - */ - passwordHash = CryptoUtil.hashpw(pwStr), - name = "Bank administrator", - internalPaytoUri = IbanPayTo(genIbanPaytoUri()), - isPublic = false, - isTalerExchange = false, - maxDebt = ctx.defaultAdminDebtLimit - ) + logger.debug("Creating admin's account") + var pwStr = pw; + if (pwStr == null) { + val pwBuf = ByteArray(32) + Random().nextBytes(pwBuf) + pwStr = String(pwBuf, Charsets.UTF_8) + } + + val res = db.accountCreate( + login = "admin", + password = pwStr, + name = "Bank administrator", + internalPaytoUri = IbanPayTo(genIbanPaytoUri()), + isPublic = false, + isTalerExchange = false, + maxDebt = ctx.defaultAdminDebtLimit, + bonus = null + ) + return when (res) { + CustomerCreationResult.BALANCE_INSUFFICIENT -> false + CustomerCreationResult.CONFLICT_LOGIN -> true + CustomerCreationResult.CONFLICT_PAY_TO -> false + CustomerCreationResult.SUCCESS -> true } - return true } \ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -178,7 +178,7 @@ class CoreBankAccountsMgmtApiTest { client.post("/accounts") { jsonBody(req) }.assertCreated() - // Testing idempotency. + // Testing idempotency client.post("/accounts") { jsonBody(req) }.assertCreated() @@ -202,6 +202,55 @@ class CoreBankAccountsMgmtApiTest { }) }.assertForbidden().assertErr(TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT) } + + // Testing login conflict + client.post("/accounts") { + jsonBody(json(req) { + "name" to "Foo" + }) + }.assertConflict() + // Testing payto conflict + client.post("/accounts") { + jsonBody(json(req) { + "username" to "bar" + }) + }.assertConflict() + client.get("/accounts/bar") { + basicAuth("admin", "admin-password") + }.assertNotFound().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT) + } + + // Test account created with bonus + @Test + fun createAccountBonusTest() = bankSetup(conf = "test_bonus.conf") { _ -> + val req = json { + "username" to "foo" + "password" to "xyz" + "name" to "Mallory" + } + + // Check ok + client.post("/accounts") { + basicAuth("admin", "admin-password") + jsonBody(req) + }.assertCreated() + client.get("/accounts/foo") { + basicAuth("admin", "admin-password") + }.assertOk().run { + val obj: AccountData = Json.decodeFromString(bodyAsText()) + assertEquals(TalerAmount("KUDOS:10"), obj.balance.amount) + } + + // Check unsufficient funs + client.post("/accounts") { + basicAuth("admin", "admin-password") + jsonBody(json(req) { + "username" to "bar" + }) + }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT) + client.get("/accounts/bar") { + basicAuth("admin", "admin-password") + }.assertNotFound().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT) } // Test admin-only account creation @@ -280,10 +329,10 @@ class CoreBankAccountsMgmtApiTest { // PATCH /accounts/USERNAME @Test - fun accountReconfig() = bankSetup { db -> + fun accountReconfig() = bankSetup { _ -> // Successful attempt now. val req = json { - "cashout_address" to "payto://new-cashout-address" + "cashout_address" to IbanPayTo(genIbanPaytoUri()).canonical "challenge_contact_data" to json { "email" to "new@example.com" "phone" to "+987" @@ -300,10 +349,11 @@ class CoreBankAccountsMgmtApiTest { jsonBody(req) }.assertNoContent() + val cashout = IbanPayTo(genIbanPaytoUri()) val nameReq = json { "login" to "foo" "name" to "Another Foo" - "cashout_address" to "payto://cashout" + "cashout_address" to cashout.canonical "challenge_contact_data" to json { "phone" to "+99" "email" to "foo@example.com" @@ -326,11 +376,10 @@ class CoreBankAccountsMgmtApiTest { }.assertOk().run { val obj: AccountData = Json.decodeFromString(bodyAsText()) assertEquals("Another Foo", obj.name) - assertEquals("payto://cashout", obj.cashout_payto_uri) + assertEquals(cashout.canonical, obj.cashout_payto_uri?.canonical) assertEquals("+99", obj.contact_data?.phone) assertEquals("foo@example.com", obj.contact_data?.email) } - } // PATCH /accounts/USERNAME/auth @@ -780,7 +829,6 @@ class CoreBankWithdrawalApiTest { basicAuth("merchant", "merchant-password") jsonBody(json { "amount" to "KUDOS:90" }) }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT) - } // GET /withdrawals/withdrawal_id diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -50,23 +50,10 @@ class DatabaseTest { // Testing the helper that creates the admin account. @Test fun createAdminTest() = setup { db, ctx -> - // No admin accounts is expected. - val noAdminCustomer = db.customerGetFromLogin("admin") - assert(noAdminCustomer == null) - // Now creating one. + // Create admin account assert(maybeCreateAdminAccount(db, ctx)) - // Now expecting one. - val yesAdminCustomer = db.customerGetFromLogin("admin") - assert(yesAdminCustomer != null) - // Expecting also its _bank_ account. - assert(db.bankAccountGetFromOwnerId(yesAdminCustomer!!.customerId) != null) - // Checking idempotency. + // Checking idempotency assert(maybeCreateAdminAccount(db, ctx)) - // Checking that the random password blocks a login. - assert(!CryptoUtil.checkpw( - "likely-wrong", - yesAdminCustomer.passwordHash - )) } } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -38,32 +38,35 @@ fun bankSetup( ) { setup(conf) { db, ctx -> // Creating the exchange and merchant accounts first. - assertNotNull(db.accountCreate( + assertEquals(CustomerCreationResult.SUCCESS, db.accountCreate( login = "merchant", - passwordHash = CryptoUtil.hashpw("merchant-password"), + password = "merchant-password", name = "Merchant", internalPaytoUri = IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"), maxDebt = TalerAmount(10, 1, "KUDOS"), isTalerExchange = false, - isPublic = false + isPublic = false, + bonus = null )) - assertNotNull(db.accountCreate( + assertEquals(CustomerCreationResult.SUCCESS, db.accountCreate( login = "exchange", - passwordHash = CryptoUtil.hashpw("exchange-password"), + password = "exchange-password", name = "Exchange", internalPaytoUri = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), maxDebt = TalerAmount(10, 1, "KUDOS"), isTalerExchange = true, - isPublic = false + isPublic = false, + bonus = null )) - assertNotNull(db.accountCreate( + assertEquals(CustomerCreationResult.SUCCESS, db.accountCreate( login = "customer", - passwordHash = CryptoUtil.hashpw("customer-password"), + password = "customer-password", name = "Customer", internalPaytoUri = IbanPayTo("payto://iban/CUSTOMER-IBAN-XYZ"), maxDebt = TalerAmount(10, 1, "KUDOS"), isTalerExchange = false, - isPublic = false + isPublic = false, + bonus = null )) // Create admin account assert(maybeCreateAdminAccount(db, ctx, "admin-password")) diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -61,8 +61,7 @@ CREATE TABLE IF NOT EXISTS customers ,name TEXT ,email TEXT ,phone TEXT - ,cashout_payto TEXT -- here because has no business meaning inside libeufin-bank - ,cashout_currency TEXT + ,cashout_payto TEXT ); COMMENT ON COLUMN customers.cashout_payto @@ -97,7 +96,6 @@ CREATE TABLE IF NOT EXISTS bank_accounts ON DELETE CASCADE ,is_public BOOLEAN DEFAULT FALSE NOT NULL -- privacy by default ,is_taler_exchange BOOLEAN DEFAULT FALSE NOT NULL - ,last_nexus_fetch_row_id BIGINT ,balance taler_amount DEFAULT (0, 0) ,max_debt taler_amount DEFAULT (0, 0) ,has_debt BOOLEAN NOT NULL DEFAULT FALSE @@ -112,11 +110,6 @@ one bank account for one user, and additionally the bank account label matches always the login.'; COMMENT ON COLUMN bank_accounts.has_debt IS 'When true, the balance is negative'; -COMMENT ON COLUMN bank_accounts.last_nexus_fetch_row_id - IS 'Keeps the ID of the last incoming payment that was learnt -from Nexus. For that reason, this ID is stored verbatim as -it was returned by Nexus. It helps to build queries to Nexus -that needs this value as a parameter.'; COMMENT ON COLUMN bank_accounts.is_public IS 'Indicates whether the bank account history @@ -161,29 +154,22 @@ COMMENT ON COLUMN bank_account_transactions.bank_account_id -- start of: cashout management CREATE TABLE IF NOT EXISTS cashout_operations - (cashout_operation_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE - ,cashout_uuid uuid PRIMARY KEY - ,local_transaction BIGINT UNIQUE -- FIXME: Comment that the transaction only gets created after the TAN confirmation - REFERENCES bank_account_transactions(bank_transaction_id) - ON DELETE RESTRICT - ON UPDATE RESTRICT - ,amount_debit taler_amount NOT NULL -- FIXME: comment on column how to derive the currency - ,amount_credit taler_amount NOT NULL -- FIXME: comment on column how to derive the currency - ,buy_at_ratio INT4 NOT NULL -- FIXME: document format (fractional base) - ,buy_in_fee taler_amount NOT NULL -- FIXME: comment on column how to derive the currency - ,sell_at_ratio INT4 NOT NULL -- FIXME: document format (fractional base) - ,sell_out_fee taler_amount NOT NULL -- FIXME: comment on column how to derive the currency + (cashout_uuid uuid NOT NULL PRIMARY KEY + ,amount_debit taler_amount NOT NULL + ,amount_credit taler_amount NOT NULL ,subject TEXT NOT NULL ,creation_time BIGINT NOT NULL - ,tan_confirmation_time BIGINT - ,tan_channel tan_enum NOT NULL - ,tan_code TEXT NOT NULL - ,bank_account BIGINT DEFAULT(NULL) + ,bank_account BIGINT NOT NULL REFERENCES bank_accounts(bank_account_id) ON DELETE CASCADE ON UPDATE RESTRICT - ,credit_payto_uri TEXT NOT NULL - ,cashout_currency TEXT NOT NULL -- need, or include in credit_payto_uri? + ,tan_channel tan_enum NOT NULL + ,tan_code TEXT NOT NULL + ,tan_confirmation_time BIGINT + ,local_transaction BIGINT UNIQUE -- FIXME: Comment that the transaction only gets created after the TAN confirmation + REFERENCES bank_account_transactions(bank_transaction_id) + ON DELETE RESTRICT + ON UPDATE RESTRICT ); -- FIXME: table comment missing @@ -193,23 +179,6 @@ COMMENT ON COLUMN cashout_operations.tan_confirmation_time COMMENT ON COLUMN cashout_operations.tan_code IS 'text that the customer must send to confirm the cash-out operation'; --- FIXME: check in the code if this really only has pending or failed submissions! -CREATE TABLE IF NOT EXISTS pending_cashout_submissions - (cashout_submission_id BIGINT GENERATED BY DEFAULT AS IDENTITY - ,cashout_operation_id BIGINT NOT NULL - REFERENCES cashout_operations(cashout_operation_id) - ON DELETE CASCADE - ON UPDATE RESTRICT - ,nexus_response TEXT - ,submission_time BIGINT - ); - -COMMENT ON TABLE pending_cashout_submissions - IS 'Tracks payment requests made from Sandbox to Nexus to trigger fiat transactions that finalize cash-outs.'; -COMMENT ON COLUMN pending_cashout_submissions.nexus_response - IS 'Keeps the Nexus response to the payment submission on failure'; - - -- end of: cashout management -- start of: EBICS management @@ -379,8 +348,7 @@ CREATE TABLE IF NOT EXISTS taler_exchange_incoming ); CREATE TABLE IF NOT EXISTS taler_withdrawal_operations - (taler_withdrawal_id BIGINT GENERATED BY DEFAULT AS IDENTITY - ,withdrawal_uuid uuid NOT NULL + (withdrawal_uuid uuid NOT NULL PRIMARY KEY ,amount taler_amount NOT NULL ,selection_done BOOLEAN DEFAULT FALSE NOT NULL ,aborted BOOLEAN DEFAULT FALSE NOT NULL diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -101,35 +101,38 @@ CREATE OR REPLACE FUNCTION account_reconfig( IN in_email TEXT, IN in_cashout_payto TEXT, IN in_is_taler_exchange BOOLEAN, - OUT out_nx_customer BOOLEAN, - OUT out_nx_bank_account BOOLEAN + IN in_is_admin BOOLEAN, + OUT out_not_found BOOLEAN, + OUT out_legal_name_change BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE my_customer_id INT8; BEGIN SELECT - customer_id - INTO my_customer_id + customer_id, + in_name IS NOT NULL AND name != in_name AND NOT in_is_admin + INTO my_customer_id, out_legal_name_change FROM customers WHERE login=in_login; IF NOT FOUND THEN - out_nx_customer=TRUE; + out_not_found=TRUE; + RETURN; +ELSIF out_legal_name_change THEN RETURN; END IF; -out_nx_customer=FALSE; -- optionally updating the Taler exchange flag IF in_is_taler_exchange IS NOT NULL THEN UPDATE bank_accounts SET is_taler_exchange = in_is_taler_exchange WHERE owning_customer_id = my_customer_id; + IF NOT FOUND THEN + out_not_found=TRUE; + RETURN; + END IF; END IF; -IF in_is_taler_exchange IS NOT NULL AND NOT FOUND THEN - out_nx_bank_account=TRUE; - RETURN; -END IF; -out_nx_bank_account=FALSE; + -- bank account patching worked, custom must as well -- since this runs in a DB transaction and the customer @@ -145,7 +148,7 @@ IF in_name IS NOT NULL THEN UPDATE customers SET name=in_name WHERE customer_id = my_customer_id; END IF; END $$; -COMMENT ON FUNCTION account_reconfig(TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN) +COMMENT ON FUNCTION account_reconfig(TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN) IS 'Updates values on customer and bank account rows based on the input data.'; CREATE OR REPLACE FUNCTION customer_delete(