commit 359fb85ddf61553fe854158a80db587ebfee08a5
parent 0b1b35f95221908e3b8d49917a184fc30cecc55d
Author: Antoine A <>
Date: Mon, 26 May 2025 18:37:22 +0200
nexus: make internal talerable and bounced logic more robust
Diffstat:
4 files changed, 133 insertions(+), 35 deletions(-)
diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql
@@ -225,7 +225,9 @@ CREATE FUNCTION register_incoming(
-- Success return
,OUT out_found BOOLEAN
,OUT out_completed BOOLEAN
+ ,OUT out_talerable BOOLEAN
,OUT out_tx_id INT8
+ ,OUT out_bounce_id TEXT
)
LANGUAGE plpgsql AS $$
DECLARE
@@ -239,9 +241,12 @@ IF in_credit_fee = (0, 0)::taler_amount THEN
END IF;
-- Check if already registered
-SELECT incoming_transaction_id, subject, debit_payto, (amount).val, (amount).frac
- INTO out_tx_id, local_subject, local_debit_payto, local_amount.val, local_amount.frac
- FROM incoming_transactions
+SELECT incoming_transaction_id, tx.subject, debit_payto, (tx.amount).val, (tx.amount).frac, metadata IS NOT NULL, end_to_end_id
+ INTO out_tx_id, local_subject, local_debit_payto, local_amount.val, local_amount.frac, out_talerable, out_bounce_id
+ FROM incoming_transactions AS tx
+ LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id)
+ LEFT JOIN bounced_transactions USING (incoming_transaction_id)
+ LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id)
WHERE uetr = in_uetr OR tx_id = in_tx_id OR acct_svcr_ref = in_acct_svcr_ref;
out_found=FOUND;
IF out_found THEN
@@ -263,7 +268,7 @@ IF out_found THEN
tx_id=COALESCE(tx_id, in_tx_id),
acct_svcr_ref=COALESCE(acct_svcr_ref, in_acct_svcr_ref)
WHERE incoming_transaction_id = out_tx_id;
- out_completed=COALESCE(in_subject, local_subject) IS NOT NULL AND COALESCE(in_debit_payto, local_debit_payto) IS NOT NULL;
+ out_completed=local_debit_payto IS NULL AND in_debit_payto IS NOT NULL;
IF out_completed THEN
PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text);
END IF;
@@ -295,10 +300,11 @@ ELSE
IF in_subject IS NOT NULL AND in_debit_payto IS NOT NULL THEN
PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text);
END IF;
+ out_talerable=FALSE;
END IF;
--- Register as talerable
-IF in_type IS NOT NULL AND NOT EXISTS(SELECT FROM talerable_incoming_transactions WHERE incoming_transaction_id = out_tx_id) THEN
+-- Register as talerable if not already registered as such and not already bounced
+IF in_type IS NOT NULL AND NOT out_talerable AND out_bounce_id IS NULL THEN
-- We cannot use ON CONFLICT here because conversion use a trigger before insertion that isn't idempotent
INSERT INTO talerable_incoming_transactions (
incoming_transaction_id
@@ -310,6 +316,7 @@ IF in_type IS NOT NULL AND NOT EXISTS(SELECT FROM talerable_incoming_transaction
,in_metadata
);
PERFORM pg_notify('nexus_incoming_tx', out_tx_id::text);
+ out_talerable=TRUE;
END IF;
END $$;
@@ -325,6 +332,9 @@ CREATE FUNCTION register_and_bounce_incoming(
,IN in_bounce_amount taler_amount
,IN in_now_date INT8
,IN in_bounce_id TEXT
+ -- Error status
+ ,OUT out_talerable BOOLEAN
+ -- Success return
,OUT out_found BOOLEAN
,OUT out_completed BOOLEAN
,OUT out_tx_id INT8
@@ -336,10 +346,13 @@ init_id INT8;
bounce_amount taler_amount;
BEGIN
-- Register incoming transaction
-SELECT reg.out_found, reg.out_completed, reg.out_tx_id
+SELECT reg.out_found, reg.out_completed, reg.out_tx_id, reg.out_talerable
FROM register_incoming(in_amount, in_credit_fee, in_subject, in_execution_time, in_debit_payto, in_uetr, in_tx_id, in_acct_svcr_ref, NULL, NULL) as reg
- INTO out_found, out_completed, out_tx_id;
-
+ INTO out_found, out_completed, out_tx_id, out_talerable;
+-- Cannot bounce a transaction registered as talerable
+IF out_talerable THEN
+ RETURN;
+END IF;
-- Bounce incoming transaction
SELECT bounce.out_bounce_id INTO out_bounce_id FROM bounce_incoming(out_tx_id, in_bounce_amount, in_bounce_id, in_now_date) AS bounce;
END $$;
@@ -357,7 +370,7 @@ local_bank_id TEXT;
payto_uri TEXT;
init_id INT8;
BEGIN
--- Check if already bounce
+-- 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)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt
@@ -88,10 +88,21 @@ suspend fun registerIncomingPayment(
append(" ")
append(kind)
}
- if (res.completed) {
- append(" completed")
- } else if (!res.new) {
- append(" already seen")
+ if (res.new) {
+ if (res.bounceId != null) {
+ append(" bounced in ${res.bounceId}")
+ }
+ } else {
+ if (res.completed) {
+ append(" completed")
+ if (res.bounceId != null) {
+ append(" bounced in ${res.bounceId}")
+ }
+ } else {
+ if (res.bounceId != null) {
+ append(" already bounced in ${res.bounceId}")
+ }
+ }
}
if (suffix != "") {
append(" ")
@@ -130,7 +141,12 @@ suspend fun registerIncomingPayment(
randEbicsId(),
Instant.now()
)
- logRes(res, suffix="bounced in ${res.bounceId}: $msg")
+ when (res) {
+ IncomingBounceRegistrationResult.Talerable ->
+ logger.warn("{} tried to bounce a talerable transaction", payment)
+ is IncomingBounceRegistrationResult.Success ->
+ logRes(res, suffix=": $msg")
+ }
}
}
AccountType.normal -> {
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
@@ -70,15 +70,14 @@ class PaymentDAO(private val db: Database) {
interface InResult {
val new: Boolean
val completed: Boolean
+ val bounceId: String?
}
/** Incoming payments bounce registration result */
- data class IncomingBounceRegistrationResult(
- val id: Long,
- val bounceId: String,
- override val new: Boolean,
- override val completed: Boolean
- ): InResult
+ sealed interface IncomingBounceRegistrationResult {
+ data class Success(val id: Long, override val bounceId: String, override val new: Boolean, override val completed: Boolean): IncomingBounceRegistrationResult, InResult
+ data object Talerable: IncomingBounceRegistrationResult
+ }
/** Register an incoming payment and bounce it */
suspend fun registerMalformedIncoming(
@@ -88,7 +87,7 @@ class PaymentDAO(private val db: Database) {
timestamp: Instant
): IncomingBounceRegistrationResult = db.serializable(
"""
- SELECT out_found, out_tx_id, out_completed, out_bounce_id
+ SELECT out_found, out_tx_id, out_completed, out_bounce_id, out_talerable
FROM register_and_bounce_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,(?,?)::taler_amount,?,?)
"""
) {
@@ -104,18 +103,22 @@ class PaymentDAO(private val db: Database) {
bind(timestamp)
bind(bounceEndToEndId)
one {
- IncomingBounceRegistrationResult(
- it.getLong("out_tx_id"),
- it.getString("out_bounce_id"),
- !it.getBoolean("out_found"),
- it.getBoolean("out_completed")
- )
+ if (it.getBoolean("out_talerable")) {
+ IncomingBounceRegistrationResult.Talerable
+ } else {
+ IncomingBounceRegistrationResult.Success(
+ it.getLong("out_tx_id"),
+ it.getString("out_bounce_id"),
+ !it.getBoolean("out_found"),
+ it.getBoolean("out_completed")
+ )
+ }
}
}
/** Incoming payments registration result */
sealed interface IncomingRegistrationResult {
- data class Success(val id: Long, override val new: Boolean, override val completed: Boolean): IncomingRegistrationResult, InResult
+ data class Success(val id: Long, override val new: Boolean, override val completed: Boolean, override val bounceId: String?): IncomingRegistrationResult, InResult
data object ReservePubReuse: IncomingRegistrationResult
}
@@ -125,7 +128,7 @@ class PaymentDAO(private val db: Database) {
metadata: IncomingSubject
): IncomingRegistrationResult = db.serializable(
"""
- SELECT out_reserve_pub_reuse, out_found, out_completed, out_tx_id
+ SELECT out_reserve_pub_reuse, out_found, out_completed, out_tx_id, out_bounce_id
FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?::taler_incoming_type,?)
"""
) {
@@ -145,7 +148,8 @@ class PaymentDAO(private val db: Database) {
else -> IncomingRegistrationResult.Success(
it.getLong("out_tx_id"),
!it.getBoolean("out_found"),
- it.getBoolean("out_completed")
+ it.getBoolean("out_completed"),
+ it.getString("out_bounce_id"),
)
}
}
@@ -156,7 +160,7 @@ class PaymentDAO(private val db: Database) {
payment: IncomingPayment
): IncomingRegistrationResult.Success = db.serializable(
"""
- SELECT out_found, out_completed, out_tx_id
+ SELECT out_found, out_completed, out_tx_id, out_bounce_id
FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL)
"""
) {
@@ -172,7 +176,8 @@ class PaymentDAO(private val db: Database) {
IncomingRegistrationResult.Success(
it.getLong("out_tx_id"),
!it.getBoolean("out_found"),
- it.getBoolean("out_completed")
+ it.getBoolean("out_completed"),
+ it.getString("out_bounce_id"),
)
}
}
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -26,8 +26,8 @@ import tech.libeufin.nexus.iso20022.*
import tech.libeufin.nexus.ebics.*
import tech.libeufin.nexus.cli.*
import tech.libeufin.nexus.db.*
-import tech.libeufin.nexus.db.PaymentDAO.OutgoingRegistrationResult
-import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult
+import tech.libeufin.nexus.db.PaymentDAO.*
+import tech.libeufin.nexus.db.InitiatedDAO.*
import java.time.Instant
import java.util.UUID;
import kotlin.test.*
@@ -157,6 +157,7 @@ class IncomingPaymentsTest {
id,
Instant.now()
).run {
+ assertIs<IncomingBounceRegistrationResult.Success>(this)
assertTrue(new)
assertEquals(id, bounceId)
}
@@ -166,6 +167,7 @@ class IncomingPaymentsTest {
randEbicsId(),
Instant.now()
).run {
+ assertIs<IncomingBounceRegistrationResult.Success>(this)
assertFalse(new)
assertEquals(id, bounceId)
}
@@ -307,6 +309,7 @@ class IncomingPaymentsTest {
}
}
+ // Non talerable
for ((index, partialId) in sequenceOf(
IncomingId(UUID.randomUUID(), null, null),
IncomingId(null, randEbicsId(), null),
@@ -337,6 +340,67 @@ class IncomingPaymentsTest {
db.checkContent(fullPayment)
db.checkInCount(index + 1, index + 1, 0)
}
+
+ // Talerable
+ for ((index, partialId) in sequenceOf(
+ IncomingId(UUID.randomUUID(), null, null),
+ IncomingId(null, randEbicsId(), null),
+ IncomingId(null, null, randEbicsId()),
+ ).withIndex()) {
+ val payment = genInPay("test with ${EddsaPublicKey.randEdsaKey()} reserve pub")
+
+ // Register minimal
+ val partialPayment = payment.copy(id = partialId, subject = null, debtor = null)
+ registerIncomingPayment(db, cfg, partialPayment)
+ db.checkContent(partialPayment)
+ db.checkInCount(index + 4, 3, index)
+
+ // Recover ID
+ val fullId = IncomingId(
+ partialId.uetr ?: UUID.randomUUID(),
+ partialId.txId ?: randEbicsId(),
+ partialId.acctSvcrRef ?: randEbicsId()
+ )
+ val idPayment = partialPayment.copy(id = fullId)
+ registerIncomingPayment(db, cfg, idPayment)
+ db.checkContent(idPayment)
+ db.checkInCount(index + 4, 3, index)
+
+ // Recover subject & debtor
+ val fullPayment = payment.copy(id = fullId)
+ registerIncomingPayment(db, cfg, fullPayment)
+ db.checkContent(fullPayment)
+ db.checkInCount(index + 4, 3, index + 1)
+ }
+ }
+
+ @Test
+ fun horror() = setup { db, _ ->
+ val cfg = NexusIngestConfig.default(AccountType.exchange)
+
+ // Check we do not bounce already registered talerable transaction
+ val talerablePayment = genInPay("test with ${EddsaPublicKey.randEdsaKey()} reserve pub")
+ registerIncomingPayment(db, cfg, talerablePayment)
+ db.payment.registerMalformedIncoming(
+ talerablePayment,
+ TalerAmount("KUDOS:2.53"),
+ randEbicsId(),
+ Instant.now()
+ ).run {
+ assertEquals(IncomingBounceRegistrationResult.Talerable, this)
+ }
+ registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null))
+ registerIncomingPayment(db, cfg, talerablePayment)
+ registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null))
+ db.checkInCount(1, 0, 1)
+
+ // Check we do not register as talerable bounced transaction
+ val bouncedPayment = genInPay("bounced ${EddsaPublicKey.randEdsaKey()}")
+ registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null))
+ registerIncomingPayment(db, cfg, bouncedPayment)
+ registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null))
+ registerIncomingPayment(db, cfg, bouncedPayment)
+ db.checkInCount(2, 1, 1)
}
}