libeufin

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

commit d20e333bb6c423a268f89f91633bfc5187a3f463
parent 92b185967e96c3d432a6ebfbb7eb324800af0283
Author: Antoine A <>
Date:   Sat, 28 Feb 2026 14:53:17 +0100

bank: improve withdrawal logic

Diffstat:
Adatabase-versioning/libeufin-bank-0015.sql | 29+++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 110+++++++++++++++++++++++++++++++++++--------------------------------------------
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 6+-----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 35+++++++++++++++++++----------------
Mlibeufin-bank/src/test/kotlin/CoreBankApiTest.kt | 7+++----
Mlibeufin-common/src/main/kotlin/db/types.kt | 9++++++++-
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)