libeufin

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

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:
Mdatabase-versioning/libeufin-nexus-procedures.sql | 33+++++++++++++++++++++++----------
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 26+++++++++++++++++++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 41+++++++++++++++++++++++------------------
Mnexus/src/test/kotlin/DatabaseTest.kt | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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) } }