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:
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)
}
}
}