From bef953d9dd4a02910454d5f882077248fc6967e9 Mon Sep 17 00:00:00 2001 From: Antoine A <> Date: Thu, 14 Mar 2024 02:01:33 +0100 Subject: Bank transaction idempotency --- API_CHANGES.md | 28 ++++++--- .../main/kotlin/tech/libeufin/bank/Constants.kt | 2 +- .../main/kotlin/tech/libeufin/bank/TalerMessage.kt | 3 +- .../kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 5 ++ .../kotlin/tech/libeufin/bank/db/AccountDAO.kt | 2 +- .../kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 11 +++- bank/src/test/kotlin/AmountTest.kt | 3 +- bank/src/test/kotlin/CoreBankApiTest.kt | 66 ++++++++++++++++++++-- database-versioning/libeufin-bank-0001.sql | 24 ++++---- database-versioning/libeufin-bank-0003.sql | 28 +++++++++ database-versioning/libeufin-bank-drop.sql | 1 + database-versioning/libeufin-bank-procedures.sql | 39 ++++++++++--- 12 files changed, 171 insertions(+), 41 deletions(-) create mode 100644 database-versioning/libeufin-bank-0003.sql diff --git a/API_CHANGES.md b/API_CHANGES.md index 3440e24f..da085bb7 100644 --- a/API_CHANGES.md +++ b/API_CHANGES.md @@ -4,16 +4,24 @@ This files contains all the API changes for the current release: ## bank serve -- POST /accounts: now returns RegisterAccountResponse with IBAN on http code 200 instead of 201 -- CREATE /accounts: new debit_threshold field similar to the one of PATH /accounts -- GET /config: new default_debit_threshold field for the default debt limit for newly created accounts -- GET /config: new supported_tan_channels field which lists all the TAN channels supported by the server -- GET /config: new allow_edit_name and allow_edit_cashout_payto_uri fields for path authorisation -- POST /accounts: rename challenge_contact_data to contact_data and internal_payto_uri to payto_uri -- PATCH /accounts/USERNAME: add is_public, remove is_taler_exchange and rename challenge_contact_data to contact_data +- POST /accounts: now returns RegisterAccountResponse with IBAN on http code 200 + instead of 201 +- CREATE /accounts: new debit_threshold field similar to the one of PATH + /accounts +- GET /config: new default_debit_threshold field for the default debt limit for + newly created accounts +- GET /config: new supported_tan_channels field which lists all the TAN channels + supported by the server +- GET /config: new allow_edit_name and allow_edit_cashout_payto_uri fields for + path authorisation +- POST /accounts: rename challenge_contact_data to contact_data and + internal_payto_uri to payto_uri +- PATCH /accounts/USERNAME: add is_public, remove is_taler_exchange and rename + challenge_contact_data to contact_data - GET /accounts: add payto_uri, is_public and is_taler_exchange - GET /accounts/USERNAME: add is_public and is_taler_exchange -- GET /public-accounts: add is_taler_exchange and rename account_name to username +- GET /public-accounts: add is_taler_exchange and rename account_name to + username - PATCH /accounts: fix PATCH semantic - PATCH /accounts: restrict PATCH contact_data to admin - POST /accounts/USERNAME/transactions: prohibit transaction to admin account @@ -25,7 +33,8 @@ This files contains all the API changes for the current release: - Add POST /accounts/USERNAME/challenge/CHALLENGE_ID - Add POST /accounts/USERNAME/challenge/CHALLENGE_ID/confirm - POST /accounts/USERNAME/cashouts: remove tan_channel field -- POST /accounts/USERNAME/cashouts/CASHOUT_ID: remove confirmation_time, tan_channel, tan_info and status fields +- POST /accounts/USERNAME/cashouts/CASHOUT_ID: remove confirmation_time, + tan_channel, tan_info and status fields - POST /accounts/$USERNAME/cashouts: remove status field - POST /cashouts: remove status field - PATCH /accounts/USERNAME: add tan_channel @@ -37,6 +46,7 @@ This files contains all the API changes for the current release: - GET /accounts: add row_id field - GET /public-accounts: add row_id field - GET /config: new bank_name field for the bank name +- POST /accounts/USERNAME/transactions: new request_uid field for idempotency and new idempotency error ## bank cli diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt index 48fc3992..7a192308 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -40,7 +40,7 @@ const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5 const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB // API version -const val COREBANK_API_VERSION: String = "4:3:0" +const val COREBANK_API_VERSION: String = "4:4:0" const val CONVERSION_API_VERSION: String = "0:0:0" const val INTEGRATION_API_VERSION: String = "2:0:2" const val WIRE_GATEWAY_API_VERSION: String = "0:2:0" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt index 0284266b..ce459baa 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -380,7 +380,8 @@ data class AccountData( @Serializable data class TransactionCreateRequest( val payto_uri: Payto, - val amount: TalerAmount? + val amount: TalerAmount?, + val request_uid: ShortHashCode? ) @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt index dd6aab4b..a3bb8265 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -459,6 +459,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { subject = subject, amount = amount, timestamp = Instant.now(), + requestUid = req.request_uid, is2fa = challenge != null ) when (res) { @@ -479,6 +480,10 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + BankTransactionResult.RequestUidReuse -> throw conflict( + "request_uid used already", + TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED + ) is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id)) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt index 13bc1e09..f78947e7 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -157,7 +157,7 @@ class AccountDAO(private val db: Database) { if (bonus.value != 0L || bonus.frac != 0) { conn.prepareStatement(""" SELECT out_balance_insufficient - FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true) + FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL) """).run { setString(1, internalPayto.canonical) setLong(2, bonus.value) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt index 9b03cd2d..81fd558f 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -38,6 +38,7 @@ class TransactionDAO(private val db: Database) { data object BothPartySame: BankTransactionResult data object BalanceInsufficient: BankTransactionResult data object TanRequired: BankTransactionResult + data object RequestUidReuse: BankTransactionResult } /** Create a new transaction */ @@ -47,7 +48,8 @@ class TransactionDAO(private val db: Database) { subject: String, amount: TalerAmount, timestamp: Instant, - is2fa: Boolean + is2fa: Boolean, + requestUid: ShortHashCode?, ): BankTransactionResult = db.serializable { conn -> val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank() conn.transaction { @@ -57,6 +59,7 @@ class TransactionDAO(private val db: Database) { ,out_debtor_not_found ,out_same_account ,out_balance_insufficient + ,out_request_uid_reuse ,out_tan_required ,out_credit_bank_account_id ,out_debit_bank_account_id @@ -65,7 +68,8 @@ class TransactionDAO(private val db: Database) { ,out_creditor_is_exchange ,out_debtor_is_exchange ,out_creditor_admin - FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?) + ,out_idempotent + FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?) """ ) stmt.setString(1, creditAccountPayto.canonical) @@ -75,6 +79,7 @@ class TransactionDAO(private val db: Database) { stmt.setInt(5, amount.frac) stmt.setLong(6, now) stmt.setBoolean(7, is2fa) + stmt.setBytes(8, requestUid?.raw) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("Bank transaction didn't properly return") @@ -83,6 +88,8 @@ class TransactionDAO(private val db: Database) { it.getBoolean("out_same_account") -> BankTransactionResult.BothPartySame it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BalanceInsufficient it.getBoolean("out_creditor_admin") -> BankTransactionResult.AdminCreditor + it.getBoolean("out_request_uid_reuse") -> BankTransactionResult.RequestUidReuse + it.getBoolean("out_idempotent") -> BankTransactionResult.Success(it.getLong("out_debit_row_id")) it.getBoolean("out_tan_required") -> BankTransactionResult.TanRequired else -> { val creditAccountId = it.getLong("out_credit_bank_account_id") diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt index 94716208..ccd64400 100644 --- a/bank/src/test/kotlin/AmountTest.kt +++ b/bank/src/test/kotlin/AmountTest.kt @@ -53,7 +53,8 @@ class AmountTest { subject = "test", amount = due, timestamp = Instant.now(), - is2fa = false + is2fa = false, + requestUid = null ) val txBool = when (txRes) { BankTransactionResult.BalanceInsufficient -> false diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt index f5f668f5..b1b89cc9 100644 --- a/bank/src/test/kotlin/CoreBankApiTest.kt +++ b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -174,7 +174,9 @@ class CoreBankAccountsApiTest { // Check idempotency client.post("/accounts") { json(req) - }.assertOk() + }.assertOkJson { + assertEquals(payto, it.internal_payto_uri) + } // Check idempotency with payto client.post("/accounts") { json(req) { @@ -208,8 +210,9 @@ class CoreBankAccountsApiTest { // Testing idempotency client.post("/accounts") { json(req) - }.assertOk() - + }.assertOkJson { + assertEquals(payto.full("Jane"), it.internal_payto_uri) + } // Check admin only debit_threshold obj { "username" to "bat" @@ -851,6 +854,28 @@ class CoreBankTransactionsApiTest { assertEquals(TalerAmount("KUDOS:0.3"), tx.amount) } } + + // Check idempotency + ShortHashCode.rand().let { requestUid -> + val id = client.postA("/accounts/merchant/transactions") { + json(valid_req) { + "request_uid" to requestUid + } + }.assertOkJson().row_id + client.postA("/accounts/merchant/transactions") { + json(valid_req) { + "request_uid" to requestUid + } + }.assertOkJson { + assertEquals(id, it.row_id) + } + client.postA("/accounts/merchant/transactions") { + json(valid_req) { + "request_uid" to requestUid + "amount" to "KUDOS:42" + } + }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) + } // Check amount in payto_uri client.postA("/accounts/merchant/transactions") { @@ -975,6 +1000,31 @@ class CoreBankTransactionsApiTest { assertBalance("merchant", "+KUDOS:2") assertBalance("customer", "+KUDOS:1") } + + // Check 2fa idempotency + val req = obj { + "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1" + "request_uid" to ShortHashCode.rand() + } + val id = client.postA("/accounts/merchant/transactions") { + json(req) + }.assertChallenge { _,_-> + assertBalance("merchant", "+KUDOS:2") + assertBalance("customer", "+KUDOS:1") + }.assertOkJson { + assertBalance("merchant", "+KUDOS:1") + assertBalance("customer", "+KUDOS:2") + }.row_id + client.postA("/accounts/merchant/transactions") { + json(req) + }.assertOkJson { + assertEquals(id, it.row_id) + } + client.postA("/accounts/merchant/transactions") { + json(req) { + "payto_uri" to "$customerPayto?message=tan+chec2k&amount=KUDOS:1" + } + }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) } } @@ -1122,7 +1172,6 @@ class CoreBankWithdrawalApiTest { } } - class CoreBankCashoutApiTest { // POST /accounts/{USERNAME}/cashouts @Test @@ -1143,9 +1192,16 @@ class CoreBankCashoutApiTest { fillCashoutInfo("customer") // Check OK + val id = client.postA("/accounts/customer/cashouts") { + json(req) + }.assertOkJson().cashout_id + + // Check idempotent client.postA("/accounts/customer/cashouts") { json(req) - }.assertOkJson() + }.assertOkJson { + assertEquals(id, it.cashout_id) + } // Trigger conflict due to reused request_uid client.postA("/accounts/customer/cashouts") { diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql index 334ddebe..a3ac4083 100644 --- a/database-versioning/libeufin-bank-0001.sql +++ b/database-versioning/libeufin-bank-0001.sql @@ -55,7 +55,7 @@ CREATE TYPE rounding_mode -- start of: bank accounts -CREATE TABLE IF NOT EXISTS customers +CREATE TABLE customers (customer_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE ,login TEXT NOT NULL UNIQUE ,password_hash TEXT NOT NULL @@ -69,7 +69,7 @@ COMMENT ON COLUMN customers.cashout_payto COMMENT ON COLUMN customers.name IS 'Full name of the customer.'; -CREATE TABLE IF NOT EXISTS bank_accounts +CREATE TABLE bank_accounts (bank_account_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE ,internal_payto_uri TEXT NOT NULL UNIQUE ,owning_customer_id INT8 NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with customers @@ -96,7 +96,7 @@ can be publicly shared'; COMMENT ON COLUMN bank_accounts.owning_customer_id IS 'Login that owns the bank account'; -CREATE TABLE IF NOT EXISTS bearer_tokens +CREATE TABLE bearer_tokens (bearer_token_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32) ,creation_time INT8 @@ -114,7 +114,7 @@ COMMENT ON COLUMN bearer_tokens.bank_customer ' created the very first token that originated all the refreshes until' ' this token was created.'; -CREATE TABLE IF NOT EXISTS iban_history +CREATE TABLE iban_history (iban TEXT PRIMARY KEY ,creation_time INT8 NOT NULL ); @@ -124,7 +124,7 @@ COMMENT ON TABLE iban_history IS 'Track all generated iban, some might be unused -- start of: money transactions -CREATE TABLE IF NOT EXISTS bank_account_transactions +CREATE TABLE bank_account_transactions (bank_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE ,creditor_payto_uri TEXT NOT NULL ,creditor_name TEXT NOT NULL @@ -152,7 +152,7 @@ COMMENT ON COLUMN bank_account_transactions.bank_account_id -- end of: money transactions -- start of: TAN challenge -CREATE TABLE IF NOT EXISTS challenges +CREATE TABLE challenges (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE, code TEXT NOT NULL, creation_date INT8 NOT NULL, @@ -179,7 +179,7 @@ COMMENT ON COLUMN challenges.confirmation_date -- start of: cashout management -CREATE TABLE IF NOT EXISTS cashout_operations +CREATE TABLE cashout_operations (cashout_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE ,request_uid BYTEA NOT NULL PRIMARY KEY CHECK (LENGTH(request_uid)=32) ,amount_debit taler_amount NOT NULL @@ -207,7 +207,7 @@ COMMENT ON COLUMN cashout_operations.tan_info IS 'Info of the last successful tr -- end of: cashout management -- start of: Taler integration -CREATE TABLE IF NOT EXISTS taler_exchange_outgoing +CREATE TABLE taler_exchange_outgoing (exchange_outgoing_id INT8 GENERATED BY DEFAULT AS IDENTITY ,request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64) ,wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32) @@ -219,7 +219,7 @@ CREATE TABLE IF NOT EXISTS taler_exchange_outgoing REFERENCES bank_accounts(bank_account_id) ); -CREATE TABLE IF NOT EXISTS taler_exchange_incoming +CREATE TABLE taler_exchange_incoming (exchange_incoming_id INT8 GENERATED BY DEFAULT AS IDENTITY ,reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32) ,bank_transaction INT8 UNIQUE NOT NULL @@ -227,7 +227,7 @@ CREATE TABLE IF NOT EXISTS taler_exchange_incoming ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS taler_withdrawal_operations +CREATE TABLE taler_withdrawal_operations (withdrawal_id INT8 GENERATED BY DEFAULT AS IDENTITY ,withdrawal_uuid uuid NOT NULL UNIQUE ,amount taler_amount NOT NULL @@ -249,7 +249,7 @@ COMMENT ON COLUMN taler_withdrawal_operations.confirmation_done -- end of: Taler integration -- start of: Statistics -CREATE TABLE IF NOT EXISTS bank_stats ( +CREATE TABLE bank_stats ( timeframe stat_timeframe_enum NOT NULL ,start_time timestamp NOT NULL ,taler_in_count INT8 NOT NULL DEFAULT 0 @@ -295,7 +295,7 @@ COMMENT ON COLUMN bank_stats.cashout_fiat_volume -- start of: Conversion -CREATE TABLE IF NOT EXISTS config ( +CREATE TABLE config ( key TEXT NOT NULL PRIMARY KEY, value JSONB NOT NULL ); diff --git a/database-versioning/libeufin-bank-0003.sql b/database-versioning/libeufin-bank-0003.sql new file mode 100644 index 00000000..14f1075f --- /dev/null +++ b/database-versioning/libeufin-bank-0003.sql @@ -0,0 +1,28 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2024 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see + +BEGIN; + +SELECT _v.register_patch('libeufin-bank-0003', NULL, NULL); +SET search_path TO libeufin_bank; + +CREATE TABLE bank_transaction_operations + (request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=32) + ,bank_transaction INT8 UNIQUE NOT NULL + REFERENCES bank_account_transactions(bank_transaction_id) + ON DELETE CASCADE + ); + +COMMIT; diff --git a/database-versioning/libeufin-bank-drop.sql b/database-versioning/libeufin-bank-drop.sql index 3746c28d..7fbcc342 100644 --- a/database-versioning/libeufin-bank-drop.sql +++ b/database-versioning/libeufin-bank-drop.sql @@ -5,6 +5,7 @@ BEGIN; -- legacy schema is being removed. SELECT _v.unregister_patch('libeufin-bank-0001'); SELECT _v.unregister_patch('libeufin-bank-0002'); +SELECT _v.unregister_patch('libeufin-bank-0003'); DROP SCHEMA libeufin_bank CASCADE; COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql index 09680f23..8f82fc65 100644 --- a/database-versioning/libeufin-bank-procedures.sql +++ b/database-versioning/libeufin-bank-procedures.sql @@ -443,6 +443,7 @@ CREATE FUNCTION bank_transaction( IN in_amount taler_amount, IN in_timestamp INT8, IN in_is_tan BOOLEAN, + IN in_request_uid BYTEA, -- Error status OUT out_creditor_not_found BOOLEAN, OUT out_debtor_not_found BOOLEAN, @@ -450,13 +451,15 @@ CREATE FUNCTION bank_transaction( OUT out_balance_insufficient BOOLEAN, OUT out_creditor_admin BOOLEAN, OUT out_tan_required BOOLEAN, + OUT out_request_uid_reuse BOOLEAN, -- Success return OUT out_credit_bank_account_id INT8, OUT out_debit_bank_account_id INT8, OUT out_credit_row_id INT8, OUT out_debit_row_id INT8, OUT out_creditor_is_exchange BOOLEAN, - OUT out_debtor_is_exchange BOOLEAN + OUT out_debtor_is_exchange BOOLEAN, + OUT out_idempotent BOOLEAN ) LANGUAGE plpgsql AS $$ BEGIN @@ -466,24 +469,37 @@ SELECT bank_account_id, is_taler_exchange, login='admin' FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE internal_payto_uri = in_credit_account_payto; -IF NOT FOUND THEN - out_creditor_not_found=TRUE; - RETURN; -ELSIF out_creditor_admin THEN +IF NOT FOUND OR out_creditor_admin THEN + out_creditor_not_found=NOT FOUND; RETURN; END IF; --- Find debit bank account id and check it's a different account +-- Find debit bank account ID and check it's a different account and if 2FA is required SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id, NOT in_is_tan AND tan_channel IS NOT NULL INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account, out_tan_required FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE login = in_debit_account_username; -IF NOT FOUND THEN - out_debtor_not_found=TRUE; +IF NOT FOUND OR out_same_account THEN + out_debtor_not_found=NOT FOUND; RETURN; -ELSIF out_same_account OR out_tan_required THEN +END IF; +-- Check for idempotence and conflict +IF in_request_uid IS NOT NULL THEN + SELECT (amount != in_amount + OR subject != in_subject + OR bank_account_id != out_debit_bank_account_id), bank_transaction + INTO out_request_uid_reuse, out_debit_row_id + FROM bank_transaction_operations + JOIN bank_account_transactions ON bank_transaction = bank_transaction_id + WHERE request_uid = in_request_uid; + IF found OR out_tan_required THEN + out_idempotent = found AND NOT out_request_uid_reuse; + RETURN; + END IF; +ELSIF out_tan_required THEN RETURN; END IF; + -- Perform bank transfer SELECT transfer.out_balance_insufficient, @@ -503,6 +519,11 @@ SELECT NULL, NULL ) as transfer; +-- Store operation +IF in_request_uid IS NOT NULL THEN + INSERT INTO bank_transaction_operations (request_uid, bank_transaction) + VALUES (in_request_uid, out_debit_row_id); +END IF; END $$; COMMENT ON FUNCTION bank_transaction IS 'Create a bank transaction'; -- cgit v1.2.3