libeufin

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

commit 640cc009d787ee69aaef8e32b35dcbd74d932c73
parent 15269b462cc172258702377babe03529de29234c
Author: Antoine A <>
Date:   Fri, 13 Oct 2023 13:25:20 +0000

Improve wire gateway API

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 9+++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 40----------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 117+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mbank/src/test/kotlin/TalerApiTest.kt | 95++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mdatabase-versioning/procedures.sql | 232++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
6 files changed, 490 insertions(+), 238 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -246,7 +246,8 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { 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.CONFLICT -> throw internalServerError("Bonus impossible: admin has insufficient balance.") + 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 */ } } @@ -546,10 +547,14 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { ) val res = db.bankTransactionCreate(dbInstructions) when (res) { - BankTransactionResult.CONFLICT -> throw conflict( + BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict( "Insufficient funds", TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT ) + BankTransactionResult.SAME_ACCOUNT -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) BankTransactionResult.NO_CREDITOR -> throw internalServerError("Creditor not found despite previous checks.") BankTransactionResult.NO_DEBTOR -> throw internalServerError("Debtor not found despite the request was authenticated.") BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -733,12 +733,13 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos conn.transaction { val stmt = conn.prepareStatement(""" SELECT - out_nx_creditor - ,out_nx_debtor - ,out_balance_insufficient + out_same_account + ,out_debtor_not_found + ,out_creditor_not_found + ,out_balance_insufficient ,out_credit_row_id ,out_debit_row_id - ,out_creditor_is_exchange + ,out_creditor_is_exchange ,out_debtor_is_exchange FROM bank_wire_transfer(?,?,TEXT(?),(?,?)::taler_amount,?,TEXT(?),TEXT(?),TEXT(?)) """ @@ -755,24 +756,24 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos stmt.executeQuery().use { when { !it.next() -> throw internalServerError("Bank transaction didn't properly return") - it.getBoolean("out_nx_debtor") -> { + it.getBoolean("out_debtor_not_found") -> { logger.error("No debtor account found") BankTransactionResult.NO_DEBTOR } - it.getBoolean("out_nx_creditor") -> { + it.getBoolean("out_creditor_not_found") -> { logger.error("No creditor account found") BankTransactionResult.NO_CREDITOR } + it.getBoolean("out_same_account") -> BankTransactionResult.SAME_ACCOUNT it.getBoolean("out_balance_insufficient") -> { logger.error("Balance insufficient") - BankTransactionResult.CONFLICT + BankTransactionResult.BALANCE_INSUFFICIENT } else -> { val metadata = TxMetadata.parse(tx.subject) if (it.getBoolean("out_creditor_is_exchange")) { val rowId = it.getLong("out_credit_row_id") if (metadata is IncomingTxMetadata) { - val stmt = conn.prepareStatement(""" INSERT INTO taler_exchange_incoming (reserve_pub, bank_transaction) @@ -884,7 +885,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos private suspend fun <T> poolHistory( params: HistoryParams, bankAccountId: Long, - listen: suspend NotificationWatcher.(Long, suspend (Flow<Notification>) -> Unit) -> Unit, + listen: suspend NotificationWatcher.(Long, suspend (Flow<Long>) -> Unit) -> Unit, query: String, map: (ResultSet) -> T ): List<T> { @@ -940,7 +941,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos if (missing > 0) { withTimeoutOrNull(params.poll_ms) { buffered - .filter { it.rowId > min } // Skip transactions already checked + .filter { it > min } // Skip transactions already checked .take(missing).count() // Wait for missing transactions } @@ -1235,9 +1236,9 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos ): WithdrawalConfirmationResult = conn { conn -> val stmt = conn.prepareStatement(""" SELECT - out_nx_op, - out_nx_exchange, - out_insufficient_funds, + out_no_op, + out_exchange_not_found, + out_balance_insufficient, out_already_confirmed_conflict FROM confirm_taler_withdrawal(?, ?, ?, ?, ?); """ @@ -1251,9 +1252,9 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos when { !it.next() -> throw internalServerError("No result from DB procedure confirm_taler_withdrawal") - it.getBoolean("out_nx_op") -> WithdrawalConfirmationResult.OP_NOT_FOUND - it.getBoolean("out_nx_exchange") -> WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND - it.getBoolean("out_insufficient_funds") -> WithdrawalConfirmationResult.BALANCE_INSUFFICIENT + it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.OP_NOT_FOUND + it.getBoolean("out_exchange_not_found") -> WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND + it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BALANCE_INSUFFICIENT it.getBoolean("out_already_confirmed_conflict") -> WithdrawalConfirmationResult.CONFLICT else -> WithdrawalConfirmationResult.SUCCESS } @@ -1470,7 +1471,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos val txRowId: Long? = null, val timestamp: TalerProtocolTimestamp? = null ) - /** + /** TODO better doc * This function calls the SQL function that (1) inserts the TWG * requests details into the database and (2) performs the actual * bank transaction to pay the merchant according to the 'req' parameter. @@ -1492,26 +1493,20 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos val subject = OutgoingTxMetadata(req.wtid, req.exchange_base_url).toString() val stmt = conn.prepareStatement(""" SELECT - out_exchange_balance_insufficient - ,out_nx_debitor - ,out_nx_exchange - ,out_nx_creditor - ,out_tx_row_id - ,out_request_uid_reuse - ,out_timestamp + out_debtor_not_found + ,out_debtor_not_exchange + ,out_creditor_not_found + ,out_same_account + ,out_both_exchanges + ,out_request_uid_reuse + ,out_exchange_balance_insufficient + ,out_tx_row_id + ,out_timestamp FROM taler_transfer ( - ?, - ?, - ?, + ?, ?, ?, (?,?)::taler_amount, - ?, - ?, - ?, - ?, - ?, - ?, - ? + ?, ?, ?, ?, ?, ?, ? ); """) @@ -1532,16 +1527,20 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos when { !it.next() -> throw internalServerError("SQL function taler_transfer did not return anything.") - it.getBoolean("out_nx_debitor") -> + it.getBoolean("out_debtor_not_found") -> TalerTransferCreationResult(TalerTransferResult.NO_DEBITOR) - it.getBoolean("out_nx_exchange") -> + it.getBoolean("out_debtor_not_exchange") -> TalerTransferCreationResult(TalerTransferResult.NOT_EXCHANGE) - it.getBoolean("out_request_uid_reuse") -> - TalerTransferCreationResult(TalerTransferResult.REQUEST_UID_REUSE) + it.getBoolean("out_creditor_not_found") -> + TalerTransferCreationResult(TalerTransferResult.NO_CREDITOR) + it.getBoolean("out_same_account") -> + TalerTransferCreationResult(TalerTransferResult.SAME_ACCOUNT) + it.getBoolean("out_both_exchanges") -> + TalerTransferCreationResult(TalerTransferResult.BOTH_EXCHANGE) it.getBoolean("out_exchange_balance_insufficient") -> TalerTransferCreationResult(TalerTransferResult.BALANCE_INSUFFICIENT) - it.getBoolean("out_nx_creditor") -> - TalerTransferCreationResult(TalerTransferResult.NO_CREDITOR) + it.getBoolean("out_request_uid_reuse") -> + TalerTransferCreationResult(TalerTransferResult.REQUEST_UID_REUSE) else -> { TalerTransferCreationResult( txResult = TalerTransferResult.SUCCESS, @@ -1554,21 +1553,153 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos } } } + + data class TalerAddIncomingCreationResult( + val txResult: TalerAddIncomingResult, + val txRowId: Long? = null + ) + + /** TODO better doc + * This function calls the SQL function that (1) inserts the TWG + * requests details into the database and (2) performs the actual + * bank transaction to pay the merchant according to the 'req' parameter. + * + * 'req' contains the same data that was POSTed by the exchange + * to the TWG /transfer endpoint. The exchangeBankAccountId parameter + * is the row ID of the exchange's bank account. The return type + * is the same returned by "bank_wire_transfer()" where however + * the NO_DEBTOR error will hardly take place. + */ + suspend fun talerAddIncomingCreate( + req: AddIncomingRequest, + username: String, + timestamp: Instant, + acctSvcrRef: String = "not used", + pmtInfId: String = "not used", + endToEndId: String = "not used", + ): TalerAddIncomingCreationResult = conn { conn -> + val subject = IncomingTxMetadata(req.reserve_pub).toString() + val stmt = conn.prepareStatement(""" + SELECT + out_creditor_not_found + ,out_creditor_not_exchange + ,out_debtor_not_found + ,out_same_account + ,out_both_exchanges + ,out_reserve_pub_reuse + ,out_debitor_balance_insufficient + ,out_tx_row_id + FROM + taler_add_incoming ( + ?, ?, + (?,?)::taler_amount, + ?, ?, ?, ?, ?, ? + ); + """) + + stmt.setBytes(1, req.reserve_pub.raw) + stmt.setString(2, subject) + stmt.setLong(3, req.amount.value) + stmt.setInt(4, req.amount.frac) + stmt.setString(5, stripIbanPayto(req.debit_account) ?: throw badRequest("debit_account payto URI is invalid")) + stmt.setString(6, username) + stmt.setLong(7, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setString(8, acctSvcrRef) + stmt.setString(9, pmtInfId) + stmt.setString(10, endToEndId) + + stmt.executeQuery().use { + when { + !it.next() -> + throw internalServerError("SQL function taler_add_incoming did not return anything.") + it.getBoolean("out_creditor_not_found") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.NO_CREDITOR) + it.getBoolean("out_creditor_not_exchange") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.NOT_EXCHANGE) + it.getBoolean("out_debtor_not_found") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.NO_DEBITOR) + it.getBoolean("out_same_account") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.SAME_ACCOUNT) + it.getBoolean("out_both_exchanges") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.BOTH_EXCHANGE) + it.getBoolean("out_debitor_balance_insufficient") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.BALANCE_INSUFFICIENT) + it.getBoolean("out_reserve_pub_reuse") -> + TalerAddIncomingCreationResult(TalerAddIncomingResult.RESERVE_PUB_REUSE) + else -> { + TalerAddIncomingCreationResult( + txResult = TalerAddIncomingResult.SUCCESS, + txRowId = it.getLong("out_tx_row_id") + ) + } + } + } + } +} + +/** Result status of customer account deletion */ +enum class CustomerDeletionResult { + SUCCESS, + CUSTOMER_NOT_FOUND, + BALANCE_NOT_ZERO } +/** Result status of bank transaction creation .*/ +enum class BankTransactionResult { + NO_CREDITOR, + NO_DEBTOR, + SAME_ACCOUNT, + BALANCE_INSUFFICIENT, + SUCCESS, +} + +/** Result status of taler transfer transaction */ enum class TalerTransferResult { NO_DEBITOR, NOT_EXCHANGE, NO_CREDITOR, + SAME_ACCOUNT, + BOTH_EXCHANGE, REQUEST_UID_REUSE, BALANCE_INSUFFICIENT, SUCCESS } -private data class Notification(val rowId: Long) +/** Result status of taler add incoming transaction */ +enum class TalerAddIncomingResult { + NO_DEBITOR, + NOT_EXCHANGE, + NO_CREDITOR, + SAME_ACCOUNT, + BOTH_EXCHANGE, + RESERVE_PUB_REUSE, + BALANCE_INSUFFICIENT, + SUCCESS +} + +/** + * This type communicates the result of a database operation + * to confirm one withdrawal operation. + */ +enum class WithdrawalConfirmationResult { + SUCCESS, + OP_NOT_FOUND, + EXCHANGE_NOT_FOUND, + BALANCE_INSUFFICIENT, + + /** + * This state indicates that the withdrawal was already + * confirmed BUT Kotlin did not detect it and still invoked + * the SQL procedure to confirm the withdrawal. This is + * conflictual because only Kotlin is responsible to check + * for idempotency, and this state witnesses a failure in + * this regard. + */ + CONFLICT +} private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { - private class CountedSharedFlow(val flow: MutableSharedFlow<Notification>, var count: Int) + private class CountedSharedFlow(val flow: MutableSharedFlow<Long>, var count: Int) private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() private val outgoingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() @@ -1595,12 +1726,12 @@ private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { val creditRow = info[3]; bankTxFlows[debtorAccount]?.run { - flow.emit(Notification(debitRow)) - flow.emit(Notification(creditRow)) + flow.emit(debitRow) + flow.emit(creditRow) } bankTxFlows[creditorAccount]?.run { - flow.emit(Notification(debitRow)) - flow.emit(Notification(creditRow)) + flow.emit(debitRow) + flow.emit(creditRow) } } else { val info = it.parameter.split(' ', limit = 2).map { it.toLong() } @@ -1608,11 +1739,11 @@ private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { val row = info[1]; if (it.name == "outgoing_tx") { outgoingTxFlows[account]?.run { - flow.emit(Notification(row)) + flow.emit(row) } } else { incomingTxFlows[account]?.run { - flow.emit(Notification(row)) + flow.emit(row) } } } @@ -1626,7 +1757,7 @@ private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { } } - private suspend fun listen(map: ConcurrentHashMap<Long, CountedSharedFlow>, account: Long, lambda: suspend (Flow<Notification>) -> Unit) { + private suspend fun listen(map: ConcurrentHashMap<Long, CountedSharedFlow>, account: Long, lambda: suspend (Flow<Long>) -> Unit) { // Register listener val flow = map.compute(account) { _, v -> val tmp = v ?: CountedSharedFlow(MutableSharedFlow(), 0); @@ -1646,15 +1777,15 @@ private class NotificationWatcher(private val pgSource: PGSimpleDataSource) { } } - suspend fun listenBank(account: Long, lambda: suspend (Flow<Notification>) -> Unit) { + suspend fun listenBank(account: Long, lambda: suspend (Flow<Long>) -> Unit) { listen(bankTxFlows, account, lambda) } - suspend fun listenOutgoing(account: Long, lambda: suspend (Flow<Notification>) -> Unit) { + suspend fun listenOutgoing(account: Long, lambda: suspend (Flow<Long>) -> Unit) { listen(outgoingTxFlows, account, lambda) } - suspend fun listenIncoming(account: Long, lambda: suspend (Flow<Notification>) -> Unit) { + suspend fun listenIncoming(account: Long, lambda: suspend (Flow<Long>) -> Unit) { listen(incomingTxFlows, account, lambda) } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -426,46 +426,6 @@ fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { fun ApplicationCall.getResourceName(param: String): ResourceName = this.expectUriComponent(param) -/** - * This type communicates the result of deleting an account - * from the database. - */ -enum class CustomerDeletionResult { - SUCCESS, - CUSTOMER_NOT_FOUND, - BALANCE_NOT_ZERO -} - -/** - * This type communicates the result of a database operation - * to confirm one withdrawal operation. - */ -enum class WithdrawalConfirmationResult { - SUCCESS, - OP_NOT_FOUND, - EXCHANGE_NOT_FOUND, - BALANCE_INSUFFICIENT, - - /** - * This state indicates that the withdrawal was already - * confirmed BUT Kotlin did not detect it and still invoked - * the SQL procedure to confirm the withdrawal. This is - * conflictual because only Kotlin is responsible to check - * for idempotency, and this state witnesses a failure in - * this regard. - */ - CONFLICT -} - -/** - * Communicates the result of creating a bank transaction in the database. - */ -enum class BankTransactionResult { - NO_CREDITOR, - NO_DEBTOR, - SUCCESS, - CONFLICT // balance insufficient -} // GET /config response from the Taler Integration API. @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -39,9 +39,9 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) { /** Authenticate and check access rights */ suspend fun ApplicationCall.authCheck(scope: TokenScope, withAdmin: Boolean): String { - val authCustomer = authenticateBankRequest(db, scope) ?: throw unauthorized() + val authCustomer = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login") val username = getResourceName("USERNAME") - if (!username.canI(authCustomer, withAdmin)) throw forbidden() + if (!username.canI(authCustomer, withAdmin)) throw unauthorized("No right on $username account") return username } @@ -73,20 +73,29 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) timestamp = Instant.now() ) when (dbRes.txResult) { - TalerTransferResult.NO_DEBITOR -> - throw notFound( - hint = "Customer $username not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. - ) - TalerTransferResult.NOT_EXCHANGE -> - throw forbidden("$username is not an exchange account.") + TalerTransferResult.NO_DEBITOR -> throw notFound( + "Customer $username not found", + TalerErrorCode.TALER_EC_END // FIXME: need EC. + ) + TalerTransferResult.NOT_EXCHANGE -> throw conflict( + "$username is not an exchange account.", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) TalerTransferResult.NO_CREDITOR -> throw notFound( "Creditor account was not found", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) + TalerTransferResult.SAME_ACCOUNT -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerTransferResult.BOTH_EXCHANGE -> throw conflict( + "Wire transfer attempted with credit and debit party being both exchange account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) TalerTransferResult.REQUEST_UID_REUSE -> throw conflict( - hint = "request_uid used already", - talerEc = TalerErrorCode.TALER_EC_BANK_TRANSFER_REQUEST_UID_REUSED + "request_uid used already", + TalerErrorCode.TALER_EC_BANK_TRANSFER_REQUEST_UID_REUSED ) TalerTransferResult.BALANCE_INSUFFICIENT -> throw conflict( "Insufficient balance for exchange", @@ -106,10 +115,15 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) reduce: (List<T>, String) -> Any, dbLambda: suspend Database.(HistoryParams, Long) -> List<T> ) { - call.authCheck(TokenScope.readonly, true) + val username = call.authCheck(TokenScope.readonly, true) val params = getHistoryParams(call.request.queryParameters) val bankAccount = call.bankAccount() - if (!bankAccount.isTalerExchange) throw forbidden("History is not related to a Taler exchange.") + + if (!bankAccount.isTalerExchange) + throw conflict( + "$username is not an exchange account.", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) val items = db.dbLambda(params, bankAccount.id); @@ -129,57 +143,54 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) } post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - call.authCheck(TokenScope.readwrite, false); + val username = call.authCheck(TokenScope.readwrite, false) val req = call.receive<AddIncomingRequest>() if (req.amount.currency != ctx.currency) throw badRequest( "Currency mismatch", TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH ) - - val subject = IncomingTxMetadata(req.reserve_pub).toString() - - // TODO check conflict in transaction - if (db.bankTransactionCheckExists(subject) != null) - throw conflict( - "Reserve pub. already used", - TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT + val timestamp = Instant.now() + val dbRes = db.talerAddIncomingCreate( + req = req, + username = username, + timestamp = timestamp + ) + when (dbRes.txResult) { + TalerAddIncomingResult.NO_CREDITOR -> throw notFound( + "Customer $username not found", + TalerErrorCode.TALER_EC_END // FIXME: need EC. ) - val strippedIbanPayto: String = stripIbanPayto(req.debit_account) ?: throw badRequest("Invalid debit_account payto URI") - val walletAccount = db.bankAccountGetFromInternalPayto(strippedIbanPayto) - ?: throw notFound( - "debit_account not found", + TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict( + "$username is not an exchange account.", TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT ) - val exchangeAccount = call.bankAccount() - if (!exchangeAccount.isTalerExchange) throw forbidden("Expected taler exchange bank account.") - - val txTimestamp = Instant.now() - val op = BankInternalTransaction( - debtorAccountId = walletAccount.expectRowId(), - amount = req.amount, - creditorAccountId = exchangeAccount.id, - transactionDate = txTimestamp, - subject = subject - ) - val res = db.bankTransactionCreate(op) - /** - * Other possible errors are highly unlikely, because of the - * previous checks on the existence of the involved bank accounts. - */ - if (res == BankTransactionResult.CONFLICT) - throw conflict( - "Insufficient balance", + TalerAddIncomingResult.NO_DEBITOR -> throw notFound( + "Debitor account was not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + TalerAddIncomingResult.SAME_ACCOUNT -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerAddIncomingResult.BOTH_EXCHANGE -> throw conflict( + "Wire transfer attempted with credit and debit party being both exchange account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerAddIncomingResult.RESERVE_PUB_REUSE -> throw conflict( + "reserve_pub used already", + TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + TalerAddIncomingResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient balance for debitor", TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT ) - val rowId = db.bankTransactionCheckExists(req.reserve_pub.encoded()) - ?: throw internalServerError("Could not find the just inserted bank transaction") - call.respond( - AddIncomingResponse( - row_id = rowId, - timestamp = TalerProtocolTimestamp(txTimestamp) + TalerAddIncomingResult.SUCCESS -> call.respond( + AddIncomingResponse( + timestamp = TalerProtocolTimestamp(timestamp), + row_id = dbRes.txRowId!! + ) ) - ) - return@post + } } } \ No newline at end of file diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -4,8 +4,7 @@ import io.ktor.client.statement.* import io.ktor.client.HttpClient import io.ktor.http.* import io.ktor.server.testing.* -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.* import kotlinx.coroutines.* import net.taler.wallet.crypto.Base32Crockford import org.junit.Test @@ -96,8 +95,8 @@ class TalerApiTest { } // Test endpoint is correctly authenticated - suspend fun authRoutine(client: HttpClient, path: String, method: HttpMethod = HttpMethod.Post) { - // No body because authentication must happen before parsing the body + suspend fun authRoutine(client: HttpClient, path: String, body: JsonObject? = null, method: HttpMethod = HttpMethod.Post) { + // No body when authentication must happen before parsing the body // Unknown account client.request(path) { @@ -115,7 +114,14 @@ class TalerApiTest { client.request(path) { this.method = method basicAuth("bar", "secret") - }.assertStatus(HttpStatusCode.Forbidden) + }.assertStatus(HttpStatusCode.Unauthorized) + + // Not exchange account + client.request(path) { + this.method = method + if (body != null) jsonBody(body) + basicAuth("foo", "pw") + }.assertStatus(HttpStatusCode.Conflict) } // Testing the POST /transfer call from the TWG API. @@ -127,23 +133,15 @@ class TalerApiTest { corebankWebApp(db, ctx) } - // TODO what to do when creditor and debtor are both exchanges - - authRoutine(client, "/accounts/foo/taler-wire-gateway/transfer") - val valid_req = json { "request_uid" to randHashCode() "amount" to "KUDOS:55" "exchange_base_url" to "http://exchange.example.com/" "wtid" to randShortHashCode() - "credit_account" to "${stripIbanPayto(bankAccountFoo.internalPaytoUri)}" + "credit_account" to stripIbanPayto(bankAccountFoo.internalPaytoUri) }; - // Checking exchange account constraint. - client.post("/accounts/foo/taler-wire-gateway/transfer") { - basicAuth("foo", "pw") - jsonBody(valid_req) - }.assertStatus(HttpStatusCode.Forbidden) + authRoutine(client, "/accounts/foo/taler-wire-gateway/transfer", valid_req) // Checking exchange debt constraint. client.post("/accounts/bar/taler-wire-gateway/transfer") { @@ -178,19 +176,17 @@ class TalerApiTest { ) }.assertStatus(HttpStatusCode.Conflict) - // Triggering currency mismatch + // Currency mismatch client.post("/accounts/bar/taler-wire-gateway/transfer") { basicAuth("bar", "secret") jsonBody( - json(valid_req) { - "request_uid" to randHashCode() - "wtid" to randShortHashCode() + json(valid_req) { "amount" to "EUR:33" } ) }.assertBadRequest() - // Unknown account currency mismatch + // Unknown account client.post("/accounts/bar/taler-wire-gateway/transfer") { basicAuth("bar", "secret") jsonBody( @@ -286,7 +282,7 @@ class TalerApiTest { corebankWebApp(db, ctx) } - authRoutine(client, "/accounts/foo/taler-wire-gateway/history/incoming", HttpMethod.Get) + authRoutine(client, "/accounts/foo/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get) // Check error when no transactions client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=7") { @@ -425,7 +421,7 @@ class TalerApiTest { corebankWebApp(db, ctx) } - authRoutine(client, "/accounts/foo/taler-wire-gateway/history/outgoing", HttpMethod.Get) + authRoutine(client, "/accounts/foo/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get) // Check error when no transactions client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") { @@ -524,34 +520,63 @@ class TalerApiTest { // Testing the /admin/add-incoming call from the TWG API. @Test fun addIncoming() = commonSetup { db, ctx -> - // Give Bar reasonable debt allowance: - assert(db.bankAccountSetMaxDebt( - 2L, - TalerAmount(1000, 0, "KUDOS") - )) testApplication { application { corebankWebApp(db, ctx) } - authRoutine(client, "/accounts/foo/taler-wire-gateway/admin/add-incoming") - val valid_req = json { "amount" to "KUDOS:44" "reserve_pub" to randEddsaPublicKey() - "debit_account" to "${"payto://iban/BAR-IBAN-ABC"}" + "debit_account" to bankAccountFoo.internalPaytoUri }; - - client.post("/accounts/foo/taler-wire-gateway/admin/add-incoming") { - basicAuth("foo", "pw") - jsonBody(valid_req, deflate = true) - }.assertStatus(HttpStatusCode.Forbidden) + + authRoutine(client, "/accounts/foo/taler-wire-gateway/admin/add-incoming", valid_req) + + // Checking exchange debt constraint. + client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") { + basicAuth("bar", "secret") + jsonBody(valid_req) + }.assertStatus(HttpStatusCode.Conflict) + + // Giving debt allowance and checking the OK case. + assert(db.bankAccountSetMaxDebt( + 1L, + TalerAmount(1000, 0, "KUDOS") + )) client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") { basicAuth("bar", "secret") jsonBody(valid_req, deflate = true) }.assertOk() + // Trigger conflict due to reused reserve_pub + client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") { + basicAuth("bar", "secret") + jsonBody(valid_req) + }.assertStatus(HttpStatusCode.Conflict) + + // Currency mismatch + client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") { + basicAuth("bar", "secret") + jsonBody( + json(valid_req) { + "amount" to "EUR:33" + } + ) + }.assertBadRequest() + + // Unknown account + client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") { + basicAuth("bar", "secret") + jsonBody( + json(valid_req) { + "reserve_pub" to randEddsaPublicKey() + "debit_account" to "payto://iban/UNKNOWN-IBAN-XYZ" + } + ) + }.assertStatus(HttpStatusCode.NotFound) + // Bad BASE32 reserve_pub client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") { basicAuth("bar", "secret") diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql @@ -206,11 +206,15 @@ CREATE OR REPLACE FUNCTION taler_transfer( IN in_account_servicer_reference TEXT, IN in_payment_information_id TEXT, IN in_end_to_end_id TEXT, + -- Error status + OUT out_debtor_not_found BOOLEAN, + OUT out_debtor_not_exchange BOOLEAN, + OUT out_creditor_not_found BOOLEAN, + OUT out_same_account BOOLEAN, + OUT out_both_exchanges BOOLEAN, OUT out_request_uid_reuse BOOLEAN, OUT out_exchange_balance_insufficient BOOLEAN, - OUT out_nx_debitor BOOLEAN, - OUT out_nx_exchange BOOLEAN, - OUT out_nx_creditor BOOLEAN, + -- Success return OUT out_tx_row_id BIGINT, OUT out_timestamp BIGINT ) @@ -237,37 +241,38 @@ END IF; -- Find exchange bank account id SELECT bank_account_id, NOT is_taler_exchange - INTO exchange_bank_account_id, out_nx_exchange + INTO exchange_bank_account_id, out_debtor_not_exchange FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE login = in_username; IF NOT FOUND THEN - out_nx_debitor=TRUE; + out_debtor_not_found=TRUE; RETURN; -ELSIF out_nx_exchange THEN +ELSIF out_debtor_not_exchange THEN RETURN; END IF; --- Find receiver bank account id --- TODO handle bounce when receiver is exchange ? --- TODO handle transfer to self ? +-- Find receiver bank account id SELECT - bank_account_id - INTO receiver_bank_account_id + bank_account_id, is_taler_exchange + INTO receiver_bank_account_id, out_both_exchanges FROM bank_accounts WHERE internal_payto_uri = in_credit_account_payto; -IF NOT FOUND -THEN - out_nx_creditor=TRUE; +IF NOT FOUND THEN + out_creditor_not_found=TRUE; + RETURN; +ELSIF out_both_exchanges THEN RETURN; END IF; -- Perform bank transfer SELECT out_balance_insufficient, - out_debit_row_id + out_debit_row_id, + transfer.out_same_account INTO out_exchange_balance_insufficient, - out_tx_row_id + out_tx_row_id, + out_same_account FROM bank_wire_transfer( receiver_bank_account_id, exchange_bank_account_id, @@ -277,7 +282,7 @@ SELECT in_account_servicer_reference, in_payment_information_id, in_end_to_end_id - ); + ) as transfer; IF out_exchange_balance_insufficient THEN RETURN; END IF; @@ -310,10 +315,124 @@ COMMENT ON FUNCTION taler_transfer( text, text, text - ) + )-- TODO new comment + IS 'function that (1) inserts the TWG requests' + 'details into the database and (2) performs ' + 'the actual bank transaction to pay the merchant'; + + +CREATE OR REPLACE FUNCTION taler_add_incoming( + IN in_reserve_pub BYTEA, + IN in_subject TEXT, + IN in_amount taler_amount, + IN in_debit_account_payto TEXT, + IN in_username TEXT, + IN in_timestamp BIGINT, + IN in_account_servicer_reference TEXT, + IN in_payment_information_id TEXT, + IN in_end_to_end_id TEXT, + -- Error status + OUT out_creditor_not_found BOOLEAN, + OUT out_creditor_not_exchange BOOLEAN, + OUT out_debtor_not_found BOOLEAN, + OUT out_same_account BOOLEAN, + OUT out_both_exchanges BOOLEAN, + OUT out_reserve_pub_reuse BOOLEAN, + OUT out_debitor_balance_insufficient BOOLEAN, + -- Success return + OUT out_tx_row_id BIGINT +) +LANGUAGE plpgsql +AS $$ +DECLARE +exchange_bank_account_id BIGINT; +sender_bank_account_id BIGINT; +BEGIN +-- Check for idempotence and conflict +SELECT true + FROM taler_exchange_incoming + JOIN bank_account_transactions AS txs + ON bank_transaction=txs.bank_transaction_id + WHERE reserve_pub = in_reserve_pub + INTO out_reserve_pub_reuse; +IF out_reserve_pub_reuse THEN + RETURN; +END IF; +-- Find exchange bank account id +SELECT + bank_account_id, NOT is_taler_exchange + INTO exchange_bank_account_id, out_creditor_not_exchange + FROM bank_accounts + JOIN customers + ON customer_id=owning_customer_id + WHERE login = in_username; +IF NOT FOUND THEN + out_creditor_not_found=TRUE; + RETURN; +ELSIF out_creditor_not_exchange THEN + RETURN; +END IF; +-- Find sender bank account id +SELECT + bank_account_id, is_taler_exchange + INTO sender_bank_account_id, out_both_exchanges + FROM bank_accounts + WHERE internal_payto_uri = in_debit_account_payto; +IF NOT FOUND THEN + out_debtor_not_found=TRUE; + RETURN; +ELSIF out_both_exchanges THEN + RETURN; +END IF; +-- Perform bank transfer +SELECT + out_balance_insufficient, + out_debit_row_id, + transfer.out_same_account + INTO + out_debitor_balance_insufficient, + out_tx_row_id, + out_same_account + FROM bank_wire_transfer( + exchange_bank_account_id, + sender_bank_account_id, + in_subject, + in_amount, + in_timestamp, + in_account_servicer_reference, + in_payment_information_id, + in_end_to_end_id + ) as transfer; +IF out_debitor_balance_insufficient THEN + RETURN; +END IF; +-- Register incoming transaction +INSERT + INTO taler_exchange_incoming ( + reserve_pub, + bank_transaction +) VALUES ( + in_reserve_pub, + out_tx_row_id +); +-- notify new transaction +PERFORM pg_notify('incoming_tx', exchange_bank_account_id || ' ' || out_tx_row_id); +END $$; +COMMENT ON FUNCTION taler_add_incoming( + bytea, + text, + taler_amount, + text, + text, + bigint, + text, + text, + text + ) -- TODO new comment IS 'function that (1) inserts the TWG requests' 'details into the database and (2) performs ' 'the actual bank transaction to pay the merchant'; + CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( IN in_withdrawal_uuid uuid, @@ -321,12 +440,9 @@ CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( IN in_acct_svcr_ref TEXT, IN in_pmt_inf_id TEXT, IN in_end_to_end_id TEXT, - OUT out_nx_op BOOLEAN, - -- can't use out_balance_insufficient, because - -- it conflicts with the return column of the called - -- function that moves the funds. FIXME? - OUT out_insufficient_funds BOOLEAN, - OUT out_nx_exchange BOOLEAN, + OUT out_no_op BOOLEAN, + OUT out_balance_insufficient BOOLEAN, + OUT out_exchange_not_found BOOLEAN, OUT out_already_confirmed_conflict BOOLEAN ) LANGUAGE plpgsql @@ -356,10 +472,10 @@ SELECT -- Really no-star policy and instead DECLARE almost one var per column? WHERE withdrawal_uuid=in_withdrawal_uuid; IF NOT FOUND THEN - out_nx_op=TRUE; + out_no_op=TRUE; RETURN; END IF; -out_nx_op=FALSE; +out_no_op=FALSE; IF (confirmation_done_local) THEN out_already_confirmed_conflict=TRUE @@ -378,14 +494,14 @@ SELECT WHERE internal_payto_uri = selected_exchange_payto_local; IF NOT FOUND THEN - out_nx_exchange=TRUE; + out_exchange_not_found=TRUE; RETURN; END IF; -out_nx_exchange=FALSE; +out_exchange_not_found=FALSE; SELECT -- not checking for accounts existence, as it was done above. - out_balance_insufficient + transfer.out_balance_insufficient INTO - maybe_balance_insufficient + out_balance_insufficient FROM bank_wire_transfer( exchange_bank_account_id, wallet_bank_account_local, @@ -395,12 +511,12 @@ FROM bank_wire_transfer( in_acct_svcr_ref, in_pmt_inf_id, in_end_to_end_id -); +) as transfer; IF (maybe_balance_insufficient) THEN - out_insufficient_funds=TRUE; + out_balance_insufficient=TRUE; END IF; -out_insufficient_funds=FALSE; +out_balance_insufficient=FALSE; END $$; COMMENT ON FUNCTION confirm_taler_withdrawal(uuid, bigint, text, text, text) IS 'Set a withdrawal operation as confirmed and wire the funds to the exchange.'; @@ -414,9 +530,12 @@ CREATE OR REPLACE FUNCTION bank_wire_transfer( IN in_account_servicer_reference TEXT, IN in_payment_information_id TEXT, IN in_end_to_end_id TEXT, - OUT out_nx_creditor BOOLEAN, - OUT out_nx_debtor BOOLEAN, + -- Error status + OUT out_same_account BOOLEAN, + OUT out_debtor_not_found BOOLEAN, + OUT out_creditor_not_found BOOLEAN, OUT out_balance_insufficient BOOLEAN, + -- Success return OUT out_credit_row_id BIGINT, OUT out_debit_row_id BIGINT, OUT out_creditor_is_exchange BOOLEAN, @@ -446,6 +565,13 @@ potential_balance_ok BOOLEAN; new_debit_row_id BIGINT; new_credit_row_id BIGINT; BEGIN + +IF in_creditor_account_id=in_debtor_account_id THEN + out_same_account=TRUE; + RETURN; +END IF; +out_same_account=FALSE; + -- check debtor exists. SELECT has_debt, @@ -462,12 +588,11 @@ SELECT FROM bank_accounts JOIN customers ON (bank_accounts.owning_customer_id = customers.customer_id) WHERE bank_account_id=in_debtor_account_id; -IF NOT FOUND -THEN - out_nx_debtor=TRUE; +IF NOT FOUND THEN + out_debtor_not_found=TRUE; RETURN; END IF; -out_nx_debtor=FALSE; +out_debtor_not_found=FALSE; -- check creditor exists. Future versions may skip this -- due to creditors being hosted at other banks. SELECT @@ -483,16 +608,16 @@ SELECT FROM bank_accounts JOIN customers ON (bank_accounts.owning_customer_id = customers.customer_id) WHERE bank_account_id=in_creditor_account_id; -IF NOT FOUND -THEN - out_nx_creditor=TRUE; +IF NOT FOUND THEN + out_creditor_not_found=TRUE; RETURN; END IF; -out_nx_creditor=FALSE; +out_creditor_not_found=FALSE; + -- DEBTOR SIDE -- check debtor has enough funds. -IF (debtor_has_debt) -THEN -- debt case: simply checking against the max debt allowed. +IF debtor_has_debt THEN + -- debt case: simply checking against the max debt allowed. CALL amount_add(debtor_balance, in_amount, potential_balance); @@ -500,8 +625,7 @@ THEN -- debt case: simply checking against the max debt allowed. INTO potential_balance_check FROM amount_left_minus_right(debtor_max_debt, potential_balance); - IF (NOT potential_balance_check) - THEN + IF NOT potential_balance_check THEN out_balance_insufficient=TRUE; RETURN; END IF; @@ -517,8 +641,7 @@ ELSE -- not a debt account potential_balance.frac FROM amount_left_minus_right(debtor_balance, in_amount); - IF (potential_balance_ok) -- debtor has enough funds in the (positive) balance. - THEN + IF potential_balance_ok THEN -- debtor has enough funds in the (positive) balance. new_debtor_balance=potential_balance; will_debtor_have_debt=FALSE; ELSE -- debtor will switch to debt: determine their new negative balance. @@ -533,8 +656,7 @@ ELSE -- not a debt account INTO potential_balance_check FROM amount_left_minus_right(debtor_max_debt, new_debtor_balance); - IF (NOT potential_balance_check) - THEN + IF NOT potential_balance_check THEN out_balance_insufficient=TRUE; RETURN; END IF; @@ -545,8 +667,7 @@ END IF; -- Here we figure out whether the creditor would switch -- from debit to a credit situation, and adjust the balance -- accordingly. -IF (NOT creditor_has_debt) -- easy case. -THEN +IF NOT creditor_has_debt THEN -- easy case. CALL amount_add(creditor_balance, in_amount, new_creditor_balance); will_creditor_have_debt=FALSE; ELSE -- creditor had debit but MIGHT switch to credit. @@ -558,9 +679,8 @@ ELSE -- creditor had debit but MIGHT switch to credit. amount_at_least_debit FROM amount_left_minus_right(in_amount, creditor_balance); - IF (amount_at_least_debit) - -- the amount is at least as big as the debit, can switch to credit then. - THEN + IF amount_at_least_debit THEN + -- the amount is at least as big as the debit, can switch to credit then. will_creditor_have_debt=FALSE; -- compute new balance. ELSE