commit d20e333bb6c423a268f89f91633bfc5187a3f463
parent 92b185967e96c3d432a6ebfbb7eb324800af0283
Author: Antoine A <>
Date: Sat, 28 Feb 2026 14:53:17 +0100
bank: improve withdrawal logic
Diffstat:
6 files changed, 108 insertions(+), 88 deletions(-)
diff --git a/database-versioning/libeufin-bank-0015.sql b/database-versioning/libeufin-bank-0015.sql
@@ -0,0 +1,29 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 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-0015', NULL, NULL);
+
+SET search_path TO libeufin_bank;
+
+-- Allow withdrawal not linked to a bank account
+ALTER TABLE taler_withdrawal_operations ALTER COLUMN wallet_bank_account DROP NOT NULL;
+
+-- Store the exchange account ID instead of the payto
+ALTER TABLE taler_withdrawal_operations ADD COLUMN exchange_bank_account INT8 REFERENCES bank_accounts(bank_account_id) ON DELETE SET NULL;
+UPDATE taler_withdrawal_operations SET exchange_bank_account=(SELECT bank_account_id FROM bank_accounts WHERE internal_payto=selected_exchange_payto);
+ALTER TABLE taler_withdrawal_operations DROP COLUMN selected_exchange_payto;
+COMMIT;
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -1,6 +1,6 @@
--
-- This file is part of TALER
--- Copyright (C) 2023-2025 Taler Systems SA
+-- Copyright (C) 2023, 2024, 2025, 2026 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
@@ -1173,74 +1173,73 @@ CREATE FUNCTION select_taler_withdrawal(
)
LANGUAGE plpgsql AS $$
DECLARE
-not_selected BOOLEAN;
-account_id int8;
+selected BOOLEAN;
+account_id INT8;
+exchange_account_id INT8;
amount_with_fee taler_amount;
BEGIN
+-- Check exchange account
+SELECT bank_account_id, NOT is_taler_exchange
+ INTO exchange_account_id, out_account_is_not_exchange
+ FROM bank_accounts
+ WHERE internal_payto=in_selected_exchange_payto;
+out_account_not_found=NOT FOUND;
+IF out_account_not_found OR out_account_is_not_exchange THEN
+ RETURN;
+END IF;
+
-- Check for conflict and idempotence
SELECT
- NOT selection_done,
+ selection_done,
aborted,
CASE
WHEN confirmation_done THEN 'confirmed'
ELSE 'selected'
END,
selection_done
- AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub != in_reserve_pub OR amount != in_amount),
+ AND (exchange_bank_account != exchange_account_id OR reserve_pub != in_reserve_pub OR amount != in_amount),
amount != in_amount,
wallet_bank_account
- INTO not_selected, out_aborted, out_status, out_already_selected, out_amount_differs, account_id
+ INTO selected, out_aborted, out_status, out_already_selected, out_amount_differs, account_id
FROM taler_withdrawal_operations
WHERE withdrawal_uuid=in_withdrawal_uuid;
out_no_op = NOT FOUND;
-IF out_no_op OR out_aborted OR out_already_selected OR out_amount_differs THEN
+IF out_no_op OR out_aborted OR out_already_selected OR out_amount_differs OR selected THEN
RETURN;
END IF;
-IF not_selected THEN
- -- Check reserve_pub reuse
- SELECT EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_reserve_pub AND type = 'reserve') OR
- EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub)
- INTO out_reserve_pub_reuse;
- IF out_reserve_pub_reuse THEN
- RETURN;
- END IF;
+-- Check reserve_pub reuse
+SELECT EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_reserve_pub AND type = 'reserve') OR
+ EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub)
+ INTO out_reserve_pub_reuse;
+IF out_reserve_pub_reuse THEN
+ RETURN;
+END IF;
- -- Check exchange account
- SELECT NOT is_taler_exchange
- INTO out_account_is_not_exchange
- FROM bank_accounts
- WHERE internal_payto=in_selected_exchange_payto;
- IF NOT FOUND OR out_account_is_not_exchange THEN
- out_account_not_found=NOT FOUND;
+IF in_amount IS NOT NULL THEN
+ SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient(
+ account_id,
+ in_amount,
+ in_wire_transfer_fees,
+ in_min_amount,
+ in_max_amount
+ ) AS test INTO out_balance_insufficient, out_bad_amount;
+ IF out_balance_insufficient OR out_bad_amount THEN
RETURN;
END IF;
+END IF;
- IF in_amount IS NOT NULL THEN
- SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient(
- account_id,
- in_amount,
- in_wire_transfer_fees,
- in_min_amount,
- in_max_amount
- ) AS test INTO out_balance_insufficient, out_bad_amount;
- IF out_balance_insufficient OR out_bad_amount THEN
- RETURN;
- END IF;
- END IF;
+-- Update withdrawal operation
+UPDATE taler_withdrawal_operations
+ SET exchange_bank_account=exchange_account_id,
+ reserve_pub=in_reserve_pub,
+ subject=in_subject,
+ selection_done=true,
+ amount=COALESCE(amount, in_amount)
+ WHERE withdrawal_uuid=in_withdrawal_uuid;
- -- Update withdrawal operation
- UPDATE taler_withdrawal_operations
- SET selected_exchange_payto=in_selected_exchange_payto,
- reserve_pub=in_reserve_pub,
- subject=in_subject,
- selection_done=true,
- amount=COALESCE(amount, in_amount)
- WHERE withdrawal_uuid=in_withdrawal_uuid;
-
- -- Notify status change
- PERFORM pg_notify('bank_withdrawal_status', in_withdrawal_uuid::text || ' selected');
-END IF;
+-- Notify status change
+PERFORM pg_notify('bank_withdrawal_status', in_withdrawal_uuid::text || ' selected');
END $$;
COMMENT ON FUNCTION select_taler_withdrawal IS 'Set details of a withdrawal operation';
@@ -1284,7 +1283,6 @@ CREATE FUNCTION confirm_taler_withdrawal(
OUT out_balance_insufficient BOOLEAN,
OUT out_bad_amount BOOLEAN,
OUT out_creditor_not_found BOOLEAN,
- OUT out_exchange_not_found BOOLEAN,
OUT out_not_selected BOOLEAN,
OUT out_missing_amount BOOLEAN,
OUT out_amount_differs BOOLEAN,
@@ -1296,7 +1294,6 @@ DECLARE
already_confirmed BOOLEAN;
subject_local TEXT;
reserve_pub_local BYTEA;
- selected_exchange_payto_local TEXT;
wallet_bank_account_local INT8;
amount_local taler_amount;
exchange_bank_account_id INT8;
@@ -1307,7 +1304,7 @@ SELECT
confirmation_done,
aborted, NOT selection_done,
reserve_pub, subject,
- selected_exchange_payto,
+ exchange_bank_account,
wallet_bank_account,
(amount).val, (amount).frac,
NOT in_is_tan AND cardinality(tan_channels) > 0,
@@ -1317,7 +1314,7 @@ SELECT
already_confirmed,
out_aborted, out_not_selected,
reserve_pub_local, subject_local,
- selected_exchange_payto_local,
+ exchange_bank_account_id,
wallet_bank_account_local,
amount_local.val, amount_local.frac,
out_tan_required,
@@ -1328,23 +1325,12 @@ SELECT
JOIN customers ON owning_customer_id=customer_id
WHERE withdrawal_uuid=in_withdrawal_uuid AND username=in_username AND deleted_at IS NULL;
out_no_op=NOT FOUND;
-IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs THEN
+IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs OR out_tan_required THEN
RETURN;
ELSIF in_amount IS NOT NULL THEN
amount_local = in_amount;
END IF;
--- Check exchange account then 2fa
-SELECT
- bank_account_id
- INTO exchange_bank_account_id
- FROM bank_accounts
- WHERE internal_payto = selected_exchange_payto_local;
-IF NOT FOUND OR out_tan_required THEN
- out_exchange_not_found=NOT FOUND;
- RETURN;
-END IF;
-
SELECT -- not checking for accounts existence, as it was done above.
transfer.out_balance_insufficient,
transfer.out_bad_amount,
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2023-2025 Taler Systems S.A.
+ * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -628,10 +628,6 @@ private fun Routing.coreBankWithdrawalApi(db: Database, cfg: BankConfig) {
"Amount either to high or too low",
TalerErrorCode.BANK_UNALLOWED_DEBIT
)
- WithdrawalConfirmationResult.UnknownExchange -> throw conflict(
- "Exchange to withdraw from not found",
- TalerErrorCode.BANK_UNKNOWN_CREDITOR
- )
WithdrawalConfirmationResult.TanRequired -> {
call.respondMfa(db, Operation.withdrawal)
}
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2023-2025 Taler Systems S.A.
+ * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -184,7 +184,6 @@ class WithdrawalDAO(private val db: Database) {
enum class WithdrawalConfirmationResult {
Success,
UnknownOperation,
- UnknownExchange,
BalanceInsufficient,
BadAmount,
NotSelected,
@@ -208,7 +207,6 @@ class WithdrawalDAO(private val db: Database) {
"""
SELECT
out_no_op,
- out_exchange_not_found,
out_balance_insufficient,
out_bad_amount,
out_not_selected,
@@ -232,7 +230,6 @@ class WithdrawalDAO(private val db: Database) {
one {
when {
it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation
- it.getBoolean("out_exchange_not_found") -> WithdrawalConfirmationResult.UnknownExchange
it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient
it.getBoolean("out_bad_amount") -> WithdrawalConfirmationResult.BadAmount
it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected
@@ -311,12 +308,15 @@ class WithdrawalDAO(private val db: Database) {
,aborted
,confirmation_done
,reserve_pub
- ,selected_exchange_payto
- ,username
+ ,wallet_user.username
,no_amount_to_wallet
+ ,exchange_account.internal_payto as exchange_payto
+ ,exchange_user.name as exchange_name
FROM taler_withdrawal_operations
- JOIN bank_accounts ON wallet_bank_account=bank_account_id
- JOIN customers ON customer_id=owning_customer_id
+ JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id
+ JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id
+ LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id
+ LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id
WHERE withdrawal_uuid=?
"""
) {
@@ -327,7 +327,7 @@ class WithdrawalDAO(private val db: Database) {
amount = it.getOptAmount("amount", db.bankCurrency),
suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency),
username = it.getString("username"),
- selected_exchange_account = it.getString("selected_exchange_payto"),
+ selected_exchange_account = it.getOptBankPayto("exchange_payto", "exchange_name", db.ctx),
selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey),
no_amount_to_wallet = it.getBoolean("no_amount_to_wallet")
)
@@ -359,17 +359,20 @@ class WithdrawalDAO(private val db: Database) {
,selection_done
,aborted
,confirmation_done
- ,internal_payto
- ,name
+ ,wallet_account.internal_payto
+ ,wallet_user.name
,reserve_pub
- ,selected_exchange_payto
+ ,exchange_account.internal_payto as exchange_payto
+ ,exchange_user.name as exchange_name
,(max_amount).val as max_amount_val
,(max_amount).frac as max_amount_frac
,no_amount_to_wallet
FROM taler_withdrawal_operations
- JOIN bank_accounts ON (wallet_bank_account=bank_account_id)
- JOIN customers ON (owning_customer_id=customer_id)
- ,account_max_amount(bank_account_id, (?, ?)::taler_amount) AS max_amount
+ JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id
+ JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id
+ LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id
+ LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id
+ ,account_max_amount(wallet_account.bank_account_id, (?, ?)::taler_amount) AS max_amount
WHERE withdrawal_uuid=?
"""
) {
@@ -387,7 +390,7 @@ class WithdrawalDAO(private val db: Database) {
sender_wire = it.getBankPayto("internal_payto", "name", db.ctx),
confirm_transfer_url = null,
suggested_exchange = null,
- selected_exchange_account = it.getString("selected_exchange_payto"),
+ selected_exchange_account = it.getOptBankPayto("exchange_payto", "exchange_name", db.ctx),
no_amount_to_wallet = it.getBoolean("no_amount_to_wallet"),
selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey),
wire_types = listOf(
diff --git a/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt b/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2023-2025 Taler Systems S.A.
+ * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -1870,12 +1870,11 @@ class CoreBankWithdrawalApiTest {
val uuid = UUID.randomUUID()
// Create a selected withdrawal directly in the database to bypass checks
db.serializable("""
- INSERT INTO taler_withdrawal_operations(withdrawal_uuid,amount,selected_exchange_payto,selection_done,wallet_bank_account,creation_date)
- VALUES (?, (?, ?)::taler_amount, ?, true, 3, 0)
+ INSERT INTO taler_withdrawal_operations(withdrawal_uuid,amount,exchange_bank_account,selection_done,wallet_bank_account,creation_date)
+ VALUES (?, (?, ?)::taler_amount, 2, true, 3, 0)
""") {
bind(uuid)
bind(amount)
- bind(exchangePayto.canonical)
executeUpdate()
}
diff --git a/libeufin-common/src/main/kotlin/db/types.kt b/libeufin-common/src/main/kotlin/db/types.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2024-2025 Taler Systems S.A.
+ * Copyright (C) 2024, 2025, 2026 Taler Systems S.A.
*
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -98,6 +98,13 @@ fun ResultSet.getBankPayto(payto: String, name: String?, ctx: BankPaytoCtx): Str
name?.let { getString(it) }
, ctx)
}
+fun ResultSet.getOptBankPayto(payto: String, name: String?, ctx: BankPaytoCtx): String? {
+ val payto = getString(payto)
+ if (payto == null) return null
+ return Payto.parse(payto).bank(
+ name?.let { getString(it) }
+ , ctx)
+}
fun ResultSet.getOptIbanPayto(payto: String): IbanPayto? {
val raw = getString(payto)