libeufin

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

commit 4c7d9b70d583faf4cbc14d755f22fc44e0ac2dfe
parent e3d5f7288ae495f445e0fb4b5ca944d301b78c37
Author: Antoine A <>
Date:   Tue, 30 Sep 2025 13:47:18 +0200

common: trigger cashout when exchange bouce a withdrawal

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 7++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt | 10+++++++---
Mbank/src/test/kotlin/GcTest.kt | 2+-
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 43+++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/helpers.kt | 4+++-
Adatabase-versioning/libeufin-bank-0014.sql | 26++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mdatabase-versioning/libeufin-conversion-setup.sql | 41++---------------------------------------
Mdatabase-versioning/libeufin-nexus-procedures.sql | 10+++++-----
Mtestbench/src/test/kotlin/IntegrationTest.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
10 files changed, 277 insertions(+), 59 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -53,7 +53,8 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { val res = db.exchange.transfer( req = req, username = call.pathUsername, - timestamp = Instant.now() + timestamp = Instant.now(), + conversion = cfg.allowConversion ) when (res) { TransferResult.UnknownExchange -> throw unknownAccount(call.pathUsername) @@ -74,6 +75,10 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { "Insufficient balance for exchange", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + TransferResult.AdminCreditor -> throw conflict( + "Cannot transfer money to admin account when conversion is disabled", + TalerErrorCode.BANK_ADMIN_CREDITOR + ) is TransferResult.Success -> call.respond( TransferResponse( timestamp = res.timestamp, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -106,13 +106,15 @@ class ExchangeDAO(private val db: Database) { data object BalanceInsufficient: TransferResult data object ReserveUidReuse: TransferResult data object WtidReuse: TransferResult + data object AdminCreditor: TransferResult } /** Perform a Taler transfer */ suspend fun transfer( req: TransferRequest, username: String, - timestamp: Instant + timestamp: Instant, + conversion: Boolean ): TransferResult = db.serializable( """ SELECT @@ -122,13 +124,14 @@ class ExchangeDAO(private val db: Database) { ,out_request_uid_reuse ,out_wtid_reuse ,out_exchange_balance_insufficient + ,out_creditor_admin ,out_tx_row_id ,out_timestamp FROM taler_transfer ( ?, ?, ?, (?,?)::taler_amount, - ?, ?, ?, ? + ?, ?, ?, ?, ? ); """ ) { @@ -142,7 +145,7 @@ class ExchangeDAO(private val db: Database) { bind(req.credit_account.canonical) bind(username) bind(timestamp) - + bind(conversion) one { when { it.getBoolean("out_debtor_not_found") -> TransferResult.UnknownExchange @@ -151,6 +154,7 @@ class ExchangeDAO(private val db: Database) { it.getBoolean("out_exchange_balance_insufficient") -> TransferResult.BalanceInsufficient it.getBoolean("out_request_uid_reuse") -> TransferResult.ReserveUidReuse it.getBoolean("out_wtid_reuse") -> TransferResult.WtidReuse + it.getBoolean("out_creditor_admin") -> TransferResult.AdminCreditor else -> TransferResult.Success( id = it.getLong("out_tx_row_id"), timestamp = it.getTalerTimestamp("out_timestamp") diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -132,7 +132,7 @@ class GcTest { assertIs<TransferResult.Success>( db.exchange.transfer( TransferRequest(HashCode.rand(), from, BaseURL.parse("http://localhost/"), ShortHashCode.rand(), customerPayto), - "exchange", time + "exchange", time, false ) ) } diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -61,6 +61,23 @@ class WireGatewayApiTest { json(valid_req) }.assertOk() + val new_req = obj(valid_req) { + "request_uid" to HashCode.rand() + "wtid" to ShortHashCode.rand() + "credit_account" to adminPayto + } + + // Check conversion bounce + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(new_req) + }.assertOk() + + // check idempotency + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(new_req) + }.assertOk() + + // Trigger conflict due to reused request_uid client.postA("/accounts/exchange/taler-wire-gateway/transfer") { json(valid_req) { @@ -130,6 +147,32 @@ class WireGatewayApiTest { } } + @Test + fun transferNoConversion() = bankSetup("test_no_conversion.conf") { + val valid_req = obj { + "request_uid" to HashCode.rand() + "amount" to "KUDOS:55" + "exchange_base_url" to "http://exchange.example.com/" + "wtid" to ShortHashCode.rand() + "credit_account" to merchantPayto.canonical + } + setMaxDebt("exchange", "KUDOS:1000") + + // Transfer works for common accounts + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) + }.assertOk() + + // But fails to admin accounts + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) { + "credit_account" to adminPayto + "request_uid" to HashCode.rand() + "wtid" to ShortHashCode.rand() + } + }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR) + } + // GET /accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID} @Test fun transferById() = bankSetup { diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -46,6 +46,7 @@ val exchangePayto = IbanPayto.rand() val customerPayto = IbanPayto.rand() val unknownPayto = IbanPayto.rand() var tmpPayTo = IbanPayto.rand() +lateinit var adminPayto: String val paytos = mapOf( "merchant" to merchantPayto, "exchange" to exchangePayto, @@ -134,7 +135,8 @@ fun bankSetup( pwCrypto = cfg.pwCrypto )) // Create admin account - assertIs<AccountCreationResult.Success>(createAdminAccount(db, cfg, "admin-password")) + val result = assertIs<AccountCreationResult.Success>(createAdminAccount(db, cfg, "admin-password")) + adminPayto = result.payto testApplication { application { corebankWebApp(db, cfg) diff --git a/database-versioning/libeufin-bank-0014.sql b/database-versioning/libeufin-bank-0014.sql @@ -0,0 +1,26 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 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-0014', NULL, NULL); + +SET search_path TO libeufin_bank; + +ALTER TABLE cashout_operations DROP CONSTRAINT cashout_operations_pkey; +ALTER TABLE cashout_operations ADD CONSTRAINT request_uid_unique UNIQUE (request_uid); +ALTER TABLE cashout_operations ALTER COLUMN request_uid DROP NOT NULL; + +COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -644,10 +644,12 @@ CREATE FUNCTION taler_transfer( IN in_credit_account_payto TEXT, IN in_username TEXT, IN in_timestamp INT8, + IN in_conversion BOOLEAN, -- Error status OUT out_debtor_not_found BOOLEAN, OUT out_debtor_not_exchange BOOLEAN, OUT out_both_exchanges BOOLEAN, + OUT out_creditor_admin BOOLEAN, OUT out_request_uid_reuse BOOLEAN, OUT out_wtid_reuse BOOLEAN, OUT out_exchange_balance_insufficient BOOLEAN, @@ -659,10 +661,14 @@ LANGUAGE plpgsql AS $$ DECLARE exchange_account_id INT8; creditor_account_id INT8; +account_conversion_rate_class_id INT8; creditor_name TEXT; +creditor_admin BOOLEAN; credit_row_id INT8; debit_row_id INT8; outgoing_id INT8; +bounce_tx INT8; +bounce_amount taler_amount; BEGIN -- Check for idempotence and conflict SELECT (amount != in_amount @@ -683,8 +689,8 @@ END IF; out_timestamp=in_timestamp; -- Find exchange bank account id SELECT - bank_account_id, NOT is_taler_exchange - INTO exchange_account_id, out_debtor_not_exchange + bank_account_id, NOT is_taler_exchange, conversion_rate_class_id + INTO exchange_account_id, out_debtor_not_exchange, account_conversion_rate_class_id FROM bank_accounts JOIN customers ON customer_id=owning_customer_id @@ -695,9 +701,10 @@ IF out_debtor_not_found OR out_debtor_not_exchange THEN END IF; -- Find creditor bank account id SELECT - bank_account_id, is_taler_exchange - INTO creditor_account_id, out_both_exchanges + bank_account_id, is_taler_exchange, username = 'admin' + INTO creditor_account_id, out_both_exchanges, creditor_admin FROM bank_accounts + JOIN customers ON owning_customer_id=customer_id WHERE internal_payto = in_credit_account_payto; IF NOT FOUND THEN -- Register failure @@ -728,6 +735,68 @@ IF NOT FOUND THEN ELSIF out_both_exchanges THEN RETURN; END IF; + +IF creditor_admin THEN + -- Check conversion is enabled + IF NOT in_conversion THEN + out_creditor_admin=TRUE; + RETURN; + END IF; + + -- Find the bounced transaction + SELECT (amount).val, (amount).frac, incoming_transaction_id + INTO bounce_amount.val, bounce_amount.frac, bounce_tx + FROM libeufin_nexus.incoming_transactions + JOIN libeufin_nexus.talerable_incoming_transactions USING (incoming_transaction_id) + WHERE metadata=in_wtid AND type='reserve'; + IF NOT FOUND THEN + -- Register failure + INSERT INTO transfer_operations ( + request_uid, + wtid, + amount, + exchange_base_url, + transfer_date, + exchange_outgoing_id, + creditor_payto, + status, + status_msg, + exchange_id + ) VALUES ( + in_request_uid, + in_wtid, + in_amount, + in_exchange_base_url, + in_timestamp, + NULL, + in_credit_account_payto, + 'permanent_failure', + 'Unknown bounced transaction', + exchange_account_id + ) RETURNING transfer_operation_id INTO out_tx_row_id; + RETURN; + END IF; + + -- Bounce the transaction + PERFORM libeufin_nexus.bounce_incoming( + bounce_tx + ,((bounce_amount).val, (bounce_amount).frac)::libeufin_nexus.taler_amount + -- use gen_random_uuid to get some randomness + -- remove all - characters as they are not random + -- capitalise the UUID as some bank may still be case sensitive + -- end with 34 random chars which is valid for EBICS (max 35 chars) + ,upper(replace(gen_random_uuid()::text, '-', '')) + ,in_timestamp + ,'exchange bounced' + ); +END IF; + +-- Check if this is a conversion bounce +out_creditor_admin=creditor_admin AND NOT in_conversion; +IF out_creditor_admin THEN + RETURN; +END IF; + -- Perform bank transfer SELECT out_balance_insufficient, @@ -783,6 +852,30 @@ INSERT INTO transfer_operations ( -- Notify new transaction PERFORM pg_notify('bank_outgoing_tx', exchange_account_id || ' ' || creditor_account_id || ' ' || debit_row_id || ' ' || credit_row_id); + +IF creditor_admin THEN + -- Create cashout operation + INSERT INTO cashout_operations ( + request_uid + ,amount_debit + ,amount_credit + ,creation_time + ,bank_account + ,subject + ,local_transaction + ) VALUES ( + NULL + ,in_amount + ,bounce_amount + ,in_timestamp + ,exchange_account_id + ,in_subject + ,debit_row_id + ); + + -- update stats + CALL stats_register_payment('cashout', NULL, in_amount, bounce_amount); +END IF; END $$; COMMENT ON FUNCTION taler_transfer IS 'Create an outgoing taler transaction and register it'; @@ -1363,6 +1456,7 @@ LANGUAGE plpgsql AS $$ DECLARE account_id INT8; account_conversion_rate_class_id INT8; +account_cashout_payto TEXT; admin_account_id INT8; tx_id INT8; BEGIN @@ -1370,17 +1464,20 @@ BEGIN -- Check account exists, has all info and if 2FA is required SELECT bank_account_id, is_taler_exchange, conversion_rate_class_id, - cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL) + cashout_payto, (NOT in_is_tan AND tan_channel IS NOT NULL) INTO account_id, out_account_is_exchange, account_conversion_rate_class_id, - out_no_cashout_payto, out_tan_required + account_cashout_payto, out_tan_required FROM bank_accounts JOIN customers ON owning_customer_id=customer_id WHERE username=in_username; IF NOT FOUND THEN out_account_not_found=TRUE; RETURN; -ELSIF out_account_is_exchange OR out_no_cashout_payto THEN +ELSIF account_cashout_payto IS NULL THEN + out_no_cashout_payto=TRUE; + RETURN; +ELSIF out_account_is_exchange THEN RETURN; END IF; @@ -1448,6 +1545,25 @@ INSERT INTO cashout_operations ( ,tx_id ) RETURNING cashout_id INTO out_cashout_id; +-- Initiate libeufin-nexus transaction +INSERT INTO libeufin_nexus.initiated_outgoing_transactions ( + amount + ,subject + ,credit_payto + ,initiation_time + ,end_to_end_id +) VALUES ( + ((in_amount_credit).val, (in_amount_credit).frac)::libeufin_nexus.taler_amount + ,in_subject + ,account_cashout_payto + ,in_timestamp + -- use gen_random_uuid to get some randomness + -- remove all - characters as they are not random + -- capitalise the UUID as some bank may still be case sensitive + -- end with 34 random chars which is valid for EBICS (max 35 chars) + ,upper(replace(gen_random_uuid()::text, '-', '')) +); + -- update stats CALL stats_register_payment('cashout', NULL, in_amount_debit, in_amount_credit); END $$; diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql @@ -16,45 +16,8 @@ BEGIN; SET search_path TO libeufin_bank; -CREATE OR REPLACE FUNCTION cashout_link() -RETURNS trigger -LANGUAGE plpgsql AS $$ - DECLARE - now_date INT8; - payto_uri TEXT; - BEGIN - IF NEW.local_transaction IS NOT NULL THEN - SELECT transaction_date INTO now_date - FROM libeufin_bank.bank_account_transactions - WHERE bank_transaction_id = NEW.local_transaction; - SELECT cashout_payto INTO payto_uri - FROM libeufin_bank.bank_accounts - JOIN libeufin_bank.customers ON customer_id=owning_customer_id - WHERE bank_account_id=NEW.bank_account; - INSERT INTO libeufin_nexus.initiated_outgoing_transactions ( - amount - ,subject - ,credit_payto - ,initiation_time - ,end_to_end_id - ) VALUES ( - ((NEW.amount_credit).val, (NEW.amount_credit).frac)::libeufin_nexus.taler_amount - ,NEW.subject - ,payto_uri - ,now_date - -- use gen_random_uuid to get some randomness - -- remove all - characters as they are not random - -- capitalise the UUID as some bank may still be case sensitive - -- end with 34 random chars which is valid for EBICS (max 35 chars) - ,upper(replace(gen_random_uuid()::text, '-', '')) - ); - END IF; - RETURN NEW; - END; -$$; - -CREATE OR REPLACE TRIGGER cashout_link BEFORE INSERT OR UPDATE ON libeufin_bank.cashout_operations - FOR EACH ROW EXECUTE FUNCTION cashout_link(); +DROP TRIGGER IF EXISTS cashin_link ON libeufin_nexus.talerable_incoming_transactions; +DROP FUNCTION IF EXISTS cashin_link; CREATE OR REPLACE FUNCTION cashin_link() RETURNS trigger diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -374,8 +374,8 @@ init_id INT8; BEGIN -- Check if already bounced SELECT end_to_end_id INTO out_bounce_id - FROM initiated_outgoing_transactions - JOIN bounced_transactions USING (initiated_outgoing_transaction_id) + FROM libeufin_nexus.initiated_outgoing_transactions + JOIN libeufin_nexus.bounced_transactions USING (initiated_outgoing_transaction_id) WHERE incoming_transaction_id = in_tx_id; -- Else initiate the bounce transaction @@ -384,10 +384,10 @@ IF NOT FOUND THEN -- Get incoming transaction bank ID and creditor SELECT COALESCE(uetr::text, tx_id, acct_svcr_ref), debit_payto INTO local_bank_id, payto_uri - FROM incoming_transactions + FROM libeufin_nexus.incoming_transactions WHERE incoming_transaction_id = in_tx_id; -- Initiate the bounce transaction - INSERT INTO initiated_outgoing_transactions ( + INSERT INTO libeufin_nexus.initiated_outgoing_transactions ( amount ,subject ,credit_payto @@ -402,7 +402,7 @@ IF NOT FOUND THEN ) RETURNING initiated_outgoing_transaction_id INTO init_id; -- Register the bounce - INSERT INTO bounced_transactions (incoming_transaction_id, initiated_outgoing_transaction_id) + INSERT INTO libeufin_nexus.bounced_transactions (incoming_transaction_id, initiated_outgoing_transaction_id) VALUES (in_tx_id, init_id); END IF; END$$; diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -33,6 +33,7 @@ import tech.libeufin.bank.ConversionResponse import tech.libeufin.bank.RegisterAccountResponse import tech.libeufin.bank.cli.LibeufinBank import tech.libeufin.common.* +import tech.libeufin.common.db.* import tech.libeufin.common.test.* import tech.libeufin.common.api.engine import tech.libeufin.nexus.* @@ -263,6 +264,28 @@ class IntegrationTest { @Test fun conversion() { + + suspend fun NexusDb.checkInitiated(amount: TalerAmount) { + serializable( + """ + SELECT + (amount).val AS amount_val, + (amount).frac AS amount_frac, + credit_payto, + subject + FROM initiated_outgoing_transactions + ORDER BY initiation_time DESC + """ + ) { + one { + val am = it.getAmount("amount", amount.currency) + val payto = it.getIbanPayto("credit_payto") + val subject = it.getString("subject") + println("$am $payto '$subject'") + assertEquals(amount, am) + } + } + } val flags = "-c conf/integration.conf -L DEBUG" nexusCmd.run("dbinit $flags -r") bankCmd.run("dbinit $flags -r") @@ -336,15 +359,51 @@ class IntegrationTest { repeat(3) { i -> val requestUid = ShortHashCode.rand() val amount = TalerAmount("KUDOS:${10+i}") - val convert = client.get("/conversion-info/cashout-rate?amount_debit=$amount") + val converted = client.get("/conversion-info/cashout-rate?amount_debit=$amount") .assertOkJson<ConversionResponse>().amount_credit client.postA("/accounts/customer/cashouts") { json { "request_uid" to requestUid "amount_debit" to amount - "amount_credit" to convert + "amount_credit" to converted } }.assertOkJson<CashoutResponse>() + db.checkInitiated(converted) + } + + // Exchange bounce + repeat(3) { i -> + val reservePub = EddsaPublicKey.randEdsaKey() + val amount = TalerAmount("EUR:${30+i}") + val subject = "exchange bounce test $i: $reservePub" + + // Cashin + nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo") + val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${30 + i}") + .assertOkJson<ConversionResponse>().amount_credit + client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> { + val tx = it.transactions.first() + assertEquals(subject, tx.subject) + assertEquals(converted, tx.amount) + } + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> { + val tx = it.incoming_transactions.first() + assertEquals(converted, tx.amount) + assertIs<IncomingReserveTransaction>(tx) + assertEquals(reservePub, tx.reserve_pub) + } + + // Bounce + val transferId = client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json { + "request_uid" to HashCode.rand() + "amount" to converted + "exchange_base_url" to "http://exchange.example.com/" + "wtid" to reservePub + "credit_account" to "payto://x-taler-bank/localhost/admin" + } + }.assertOkJson<TransferResponse>().row_id + db.checkInitiated(amount) } } }