summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-03-14 02:01:33 +0100
committerAntoine A <>2024-03-14 02:01:33 +0100
commitbef953d9dd4a02910454d5f882077248fc6967e9 (patch)
tree12a6fe6e26dd2c22cf60cd5c33603e335d45d0c5
parentcb67be6fc78bc573b91978c909322ad666e3d117 (diff)
downloadlibeufin-bef953d9dd4a02910454d5f882077248fc6967e9.tar.gz
libeufin-bef953d9dd4a02910454d5f882077248fc6967e9.tar.bz2
libeufin-bef953d9dd4a02910454d5f882077248fc6967e9.zip
Bank transaction idempotency
-rw-r--r--API_CHANGES.md28
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Constants.kt2
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt3
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt5
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt2
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt11
-rw-r--r--bank/src/test/kotlin/AmountTest.kt3
-rw-r--r--bank/src/test/kotlin/CoreBankApiTest.kt66
-rw-r--r--database-versioning/libeufin-bank-0001.sql24
-rw-r--r--database-versioning/libeufin-bank-0003.sql28
-rw-r--r--database-versioning/libeufin-bank-drop.sql1
-rw-r--r--database-versioning/libeufin-bank-procedures.sql39
12 files changed, 171 insertions, 41 deletions
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<RegisterAccountResponse> {
+ 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<RegisterAccountResponse> {
+ 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<TransactionCreateResponse>().row_id
+ client.postA("/accounts/merchant/transactions") {
+ json(valid_req) {
+ "request_uid" to requestUid
+ }
+ }.assertOkJson<TransactionCreateResponse> {
+ 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 <TransactionCreateResponse> {
+ assertBalance("merchant", "+KUDOS:1")
+ assertBalance("customer", "+KUDOS:2")
+ }.row_id
+ client.postA("/accounts/merchant/transactions") {
+ json(req)
+ }.assertOkJson<TransactionCreateResponse> {
+ 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<CashoutResponse>().cashout_id
+
+ // Check idempotent
client.postA("/accounts/customer/cashouts") {
json(req)
- }.assertOkJson<CashoutResponse>()
+ }.assertOkJson<CashoutResponse> {
+ 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 <http://www.gnu.org/licenses/>
+
+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';