libeufin

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

commit 15e29754ebfed4c65fdde94f05a4a898595d9775
parent 1606b9952d7eeb67e70656e0c19c9ae3206c5024
Author: MS <ms@taler.net>
Date:   Thu, 21 Sep 2023 18:22:18 +0200

TWG POST /transfer.

Completing the response by including the bank transaction ID.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mbank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt | 2++
Mbank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt | 20+++++++++-----------
Mbank/src/main/kotlin/tech/libeufin/bank/types.kt | 16+++++++++++-----
Mbank/src/test/kotlin/DatabaseTest.kt | 2+-
Mbank/src/test/kotlin/TalerApiTest.kt | 6++++--
Mdatabase-versioning/libeufin-bank-0001.sql | 2+-
Mdatabase-versioning/procedures.sql | 67++++++++++++++++++++++++++++++++++++++++++++-----------------------
8 files changed, 127 insertions(+), 62 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -686,7 +686,13 @@ class Database(private val dbConfig: String) { return true } - // Values coming from the wallet. + /** + * Associates a reserve public key and an exchange to + * a Taler withdrawal. Returns true on success, false + * otherwise. + * + * Checking for idempotency is entirely on the Kotlin side. + */ fun talerWithdrawalSetDetails( opUuid: UUID, exchangePayto: String, @@ -705,6 +711,9 @@ class Database(private val dbConfig: String) { return myExecute(stmt) } + /** + * + */ fun talerWithdrawalConfirm( opUuid: UUID, timestamp: Long, @@ -717,7 +726,8 @@ class Database(private val dbConfig: String) { SELECT out_nx_op, out_nx_exchange, - out_insufficient_funds + out_insufficient_funds, + out_already_confirmed_conflict FROM confirm_taler_withdrawal(?, ?, ?, ?, ?); """ ) @@ -733,6 +743,7 @@ class Database(private val dbConfig: String) { if (it.getBoolean("out_nx_op")) return WithdrawalConfirmationResult.OP_NOT_FOUND if (it.getBoolean("out_nx_exchange")) return WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND if (it.getBoolean("out_insufficient_funds")) return WithdrawalConfirmationResult.BALANCE_INSUFFICIENT + if (it.getBoolean("out_already_confirmed_conflict")) return WithdrawalConfirmationResult.CONFLICT } return WithdrawalConfirmationResult.SUCCESS } @@ -899,40 +910,58 @@ class Database(private val dbConfig: String) { ) } } - + data class TalerTransferFromDb( + // Only used when this type if defined from a DB record + val timestamp: Long, + val debitTxRowId: Long, + val requestUid: String, + val amount: TalerAmount, + val exchangeBaseUrl: String, + val wtid: String, + val creditAccount: String + ) // Gets a Taler transfer request, given its UID. - fun talerTransferGetFromUid(requestUid: String): TransferRequest? { + fun talerTransferGetFromUid(requestUid: String): TalerTransferFromDb? { reconnect() val stmt = prepare(""" SELECT wtid - ,(amount).val AS amount_value - ,(amount).frac AS amount_frac ,exchange_base_url - ,credit_account_payto - FROM taler_exchange_transfers + ,(tfr.amount).val AS amount_value + ,(tfr.amount).frac AS amount_frac + ,tfr.credit_account_payto + ,tfr.bank_transaction + ,txs.transaction_date AS timestamp + FROM taler_exchange_transfers AS tfr + JOIN bank_account_transactions AS txs + ON bank_transaction=txs.bank_transaction_id WHERE request_uid = ?; """) stmt.setString(1, requestUid) val res = stmt.executeQuery() res.use { if (!it.next()) return null - return TransferRequest( + return TalerTransferFromDb( wtid = it.getString("wtid"), amount = TalerAmount( value = it.getLong("amount_value"), frac = it.getInt("amount_frac"), ), - credit_account = it.getString("credit_account_payto"), - exchange_base_url = it.getString("exchange_base_url"), - request_uid = requestUid, - // FIXME: fix the following two after setting the bank_transaction_id on this row. - row_id = 0L, - timestamp = 0L + creditAccount = it.getString("credit_account_payto"), + exchangeBaseUrl = it.getString("exchange_base_url"), + requestUid = requestUid, + debitTxRowId = it.getLong("bank_transaction"), + timestamp = it.getLong("timestamp") ) } } + data class TalerTransferDbResult( + val txResult: BankTransactionResult, + // Row ID of the debit bank transaction + // of a successful case. Null upon errors + val txRowId: Long? = null + ) /** * This function calls the SQL function that (1) inserts the TWG * requests details into the database and (2) performs the actual @@ -951,13 +980,14 @@ class Database(private val dbConfig: String) { acctSvcrRef: String = "not used", pmtInfId: String = "not used", endToEndId: String = "not used", - ): BankTransactionResult { + ): TalerTransferDbResult { reconnect() // FIXME: future versions should return the exchange's latest bank transaction ID val stmt = prepare(""" SELECT out_exchange_balance_insufficient ,out_nx_creditor + ,out_tx_row_id FROM taler_transfer ( ?, @@ -988,9 +1018,15 @@ class Database(private val dbConfig: String) { res.use { if (!it.next()) throw internalServerError("SQL function taler_transfer did not return anything.") - if (it.getBoolean("out_exchange_balance_insufficient")) return BankTransactionResult.CONFLICT - if (it.getBoolean("out_nx_creditor")) return BankTransactionResult.NO_CREDITOR - return BankTransactionResult.SUCCESS + if (it.getBoolean("out_nx_creditor")) + return TalerTransferDbResult(BankTransactionResult.NO_CREDITOR) + if (it.getBoolean("out_exchange_balance_insufficient")) + return TalerTransferDbResult(BankTransactionResult.CONFLICT) + val txRowId = it.getLong("out_tx_row_id") + return TalerTransferDbResult( + txResult = BankTransactionResult.SUCCESS, + txRowId = txRowId + ) } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt @@ -163,6 +163,8 @@ fun Routing.talerWebHandlers() { hint = "Exchange to withdraw from not found", talerEc = TalerErrorCode.TALER_EC_END // FIXME ) + WithdrawalConfirmationResult.CONFLICT -> + throw internalServerError("Bank didn't check for idempotency") WithdrawalConfirmationResult.SUCCESS -> call.respondText( "{}", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt @@ -77,17 +77,13 @@ fun Routing.talerWireGatewayHandlers() { if (maybeDoneAlready != null) { val isIdempotent = maybeDoneAlready.amount == req.amount - && maybeDoneAlready.credit_account == req.credit_account - && maybeDoneAlready.exchange_base_url == req.exchange_base_url + && maybeDoneAlready.creditAccount == req.credit_account + && maybeDoneAlready.exchangeBaseUrl == req.exchange_base_url && maybeDoneAlready.wtid == req.wtid if (isIdempotent) { - val timestamp = maybeDoneAlready.timestamp - ?: throw internalServerError("Timestamp not found on idempotent request") - val rowId = maybeDoneAlready.row_id - ?: throw internalServerError("Row ID not found on idempotent request") call.respond(TransferResponse( - timestamp = timestamp, - row_id = rowId + timestamp = maybeDoneAlready.timestamp, + row_id = maybeDoneAlready.debitTxRowId )) return@post } @@ -105,19 +101,21 @@ fun Routing.talerWireGatewayHandlers() { exchangeBankAccountId = exchangeBankAccount.expectRowId(), timestamp = transferTimestamp ) - if (dbRes == Database.BankTransactionResult.CONFLICT) + if (dbRes.txResult == Database.BankTransactionResult.CONFLICT) throw conflict( "Insufficient balance for exchange", TalerErrorCode.TALER_EC_END // FIXME ) - if (dbRes == Database.BankTransactionResult.NO_CREDITOR) + if (dbRes.txResult == Database.BankTransactionResult.NO_CREDITOR) throw notFound( "Creditor account was not found", TalerErrorCode.TALER_EC_END // FIXME ) + val debitRowId = dbRes.txRowId + ?: throw internalServerError("Database did not return the debit tx row ID") call.respond(TransferResponse( timestamp = transferTimestamp, - row_id = 0 // FIXME! + row_id = debitRowId )) return@post } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -442,7 +442,16 @@ enum class WithdrawalConfirmationResult { SUCCESS, OP_NOT_FOUND, EXCHANGE_NOT_FOUND, - BALANCE_INSUFFICIENT + 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 } // GET /config response from the Taler Integration API. @@ -556,10 +565,7 @@ data class TransferRequest( val amount: TalerAmount, val exchange_base_url: String, val wtid: String, - val credit_account: String, - // Only used when this type if defined from a DB record - val timestamp: Long? = null, // when this request got finalized with a wire transfer - val row_id: Long? = null // DB row ID of this record + val credit_account: String ) // TWG's response to merchant payouts diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -100,7 +100,7 @@ class DatabaseTest { exchangeBankAccountId = 1L, timestamp = getNowUs() ) - assert(res == Database.BankTransactionResult.SUCCESS) + assert(res.txResult == Database.BankTransactionResult.SUCCESS) } @Test diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -70,19 +70,21 @@ class TalerApiTest { "credit_account": "BAR-IBAN-ABC" } """.trimIndent() - client.post("/accounts/foo/taler-wire-gateway/transfer") { + val resp = client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") contentType(ContentType.Application.Json) expectSuccess = true setBody(req) } + // println(resp.bodyAsText()) // check idempotency - client.post("/accounts/foo/taler-wire-gateway/transfer") { + val idemResp = client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") contentType(ContentType.Application.Json) expectSuccess = true setBody(req) } + // println(idemResp.bodyAsText()) // Trigger conflict due to reused request_uid val r = client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -370,7 +370,7 @@ CREATE TABLE IF NOT EXISTS taler_exchange_transfers ,exchange_base_url TEXT NOT NULL ,credit_account_payto TEXT NOT NULL ,amount taler_amount NOT NULL - ,bank_transaction BIGINT UNIQUE -- NOT NULL FIXME: make this not null. + ,bank_transaction BIGINT UNIQUE NOT NULL REFERENCES bank_account_transactions(bank_transaction_id) ON DELETE RESTRICT ON UPDATE RESTRICT diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql @@ -98,7 +98,8 @@ CREATE OR REPLACE FUNCTION taler_transfer( IN in_payment_information_id TEXT, IN in_end_to_end_id TEXT, OUT out_exchange_balance_insufficient BOOLEAN, - OUT out_nx_creditor BOOLEAN + OUT out_nx_creditor BOOLEAN, + OUT out_tx_row_id BIGINT ) LANGUAGE plpgsql AS $$ @@ -106,23 +107,13 @@ DECLARE maybe_balance_insufficient BOOLEAN; receiver_bank_account_id BIGINT; payment_subject TEXT; +exchange_debit_tx_id BIGINT; BEGIN -INSERT - INTO taler_exchange_transfers ( - request_uid, - wtid, - exchange_base_url, - credit_account_payto, - amount - -- FIXME: this needs the bank transaction row ID here. -) VALUES ( - in_request_uid, - in_wtid, - in_exchange_base_url, - in_credit_account_payto, - in_amount -); +-- First creating the bank transaction, then updating +-- the transfer request table, because that needs to point +-- at the bank transaction. + SELECT bank_account_id INTO receiver_bank_account_id @@ -137,8 +128,11 @@ out_nx_creditor=FALSE; SELECT CONCAT(in_wtid, ' ', in_exchange_base_url) INTO payment_subject; SELECT - out_balance_insufficient - INTO maybe_balance_insufficient + out_balance_insufficient, + out_debit_row_id + INTO + maybe_balance_insufficient, + exchange_debit_tx_id FROM bank_wire_transfer( receiver_bank_account_id, in_exchange_bank_account_id, @@ -154,6 +148,23 @@ THEN out_exchange_balance_insufficient=TRUE; END IF; out_exchange_balance_insufficient=FALSE; +INSERT + INTO taler_exchange_transfers ( + request_uid, + wtid, + exchange_base_url, + credit_account_payto, + amount, + bank_transaction +) VALUES ( + in_request_uid, + in_wtid, + in_exchange_base_url, + in_credit_account_payto, + in_amount, + exchange_debit_tx_id +); +out_tx_row_id = exchange_debit_tx_id; END $$; COMMENT ON FUNCTION taler_transfer( text, @@ -182,7 +193,8 @@ CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( -- 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_nx_exchange BOOLEAN, + OUT out_already_confirmed_conflict BOOLEAN ) LANGUAGE plpgsql AS $$ @@ -217,8 +229,10 @@ END IF; out_nx_op=FALSE; IF (confirmation_done_local) THEN - RETURN; -- nothing to do, idempotentially returning. + out_already_confirmed_conflict=TRUE + RETURN; -- Kotlin should have checked for idempotency before reaching here! END IF; +out_already_confirmed_conflict=FALSE; -- exists and wasn't confirmed, do it. UPDATE taler_withdrawal_operations SET confirmation_done = true @@ -269,7 +283,9 @@ CREATE OR REPLACE FUNCTION bank_wire_transfer( IN in_end_to_end_id TEXT, OUT out_nx_creditor BOOLEAN, OUT out_nx_debtor BOOLEAN, - OUT out_balance_insufficient BOOLEAN + OUT out_balance_insufficient BOOLEAN, + OUT out_credit_row_id BIGINT, + OUT out_debit_row_id BIGINT ) LANGUAGE plpgsql AS $$ @@ -292,6 +308,8 @@ will_debtor_have_debt BOOLEAN; will_creditor_have_debt BOOLEAN; amount_at_least_debit BOOLEAN; potential_balance_ok BOOLEAN; +new_debit_row_id BIGINT; +new_credit_row_id BIGINT; BEGIN -- check debtor exists. SELECT @@ -447,7 +465,8 @@ VALUES ( in_end_to_end_id, 'debit', in_debtor_account_id -); +) RETURNING bank_transaction_id INTO new_debit_row_id; +out_debit_row_id=new_debit_row_id; -- debtor side: INSERT INTO bank_account_transactions ( @@ -477,7 +496,9 @@ VALUES ( in_end_to_end_id, -- does this interest the receiving party? 'credit', in_creditor_account_id -); +) RETURNING bank_transaction_id INTO new_credit_row_id; +out_credit_row_id=new_credit_row_id; + -- checks and balances set up, now update bank accounts. UPDATE bank_accounts SET