libeufin

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

commit a752f81acf05d1610452913e84f685eafbc43571
parent 7769c74a2f4e756ebf8f1ca79a885810ebc75765
Author: MS <ms@taler.net>
Date:   Wed, 25 Oct 2023 14:05:05 +0200

Initiated payments UID.

Every initiated payment gets one UID in the 'request_uid'
column.  This value could come either from a nexus-httpd client
(the Taler exchange, for example) or from Nexus itself, in
case the initiated payment bounces an incoming one.

Diffstat:
Mdatabase-versioning/libeufin-nexus-0001.sql | 13+++++++++----
Mdatabase-versioning/libeufin-nexus-procedures.sql | 10++++++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 29+++++++++++++++++++----------
Mnexus/src/test/kotlin/Common.kt | 6+++---
Mnexus/src/test/kotlin/DatabaseTest.kt | 23+++++++++++++----------
5 files changed, 52 insertions(+), 29 deletions(-)

diff --git a/database-versioning/libeufin-nexus-0001.sql b/database-versioning/libeufin-nexus-0001.sql @@ -56,11 +56,15 @@ CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions ,outgoing_transaction_id INT8 REFERENCES outgoing_transactions (outgoing_transaction_id) ,submitted BOOL DEFAULT FALSE ,hidden BOOL DEFAULT FALSE -- FIXME: explain this. - ,client_request_uuid TEXT UNIQUE -- only there for HTTP requests idempotence. + ,request_uid TEXT NOT NULL UNIQUE ,failure_message TEXT -- NOTE: that may mix soon failures (those found at initiation time), or late failures (those found out along a fetch operation) ); COMMENT ON COLUMN initiated_outgoing_transactions.outgoing_transaction_id - IS 'Points to the bank transaction that was found via nexus-fetch. If "submitted" is false or nexus-fetch could not download this initiation, this column is expected to be NULL.'; - -COMMIT; + IS 'Points to the bank transaction that was found via nexus-fetch. If "submitted" is false or nexus-fetch could not download this initiation, this column is expected to be NULL.'; +COMMENT ON COLUMN initiated_outgoing_transactions.request_uid + IS 'Unique identifier of this outgoing transaction initiation. +This value could come both from a nexus-httpd client or directly +generated when nexus-fetch bounces one payment. In both cases, this +value will be used as a unique identifier for its related pain.001 document.'; +COMMIT; +\ No newline at end of file diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -8,6 +8,7 @@ CREATE OR REPLACE FUNCTION create_incoming_and_bounce( ,IN in_debit_payto_uri TEXT ,IN in_bank_transfer_id TEXT ,IN in_timestamp BIGINT + ,IN in_request_uid TEXT ) RETURNS void LANGUAGE plpgsql AS $$ BEGIN @@ -33,15 +34,17 @@ INSERT INTO initiated_outgoing_transactions ( ,wire_transfer_subject ,credit_payto_uri ,initiation_time + ,request_uid ) VALUES ( in_amount ,'refund: ' || in_wire_transfer_subject ,in_debit_payto_uri ,in_timestamp + ,in_request_uid ); END $$; -COMMENT ON FUNCTION create_incoming_and_bounce(taler_amount, TEXT, BIGINT, TEXT, TEXT, BIGINT) +COMMENT ON FUNCTION create_incoming_and_bounce(taler_amount, TEXT, BIGINT, TEXT, TEXT, BIGINT, TEXT) IS 'creates one incoming transaction with a bounced state and initiates its related refund.'; CREATE OR REPLACE FUNCTION create_outgoing_payment( @@ -100,6 +103,7 @@ COMMENT ON FUNCTION create_outgoing_payment(taler_amount, TEXT, BIGINT, TEXT, TE CREATE OR REPLACE FUNCTION bounce_payment( IN in_incoming_transaction_id BIGINT ,IN in_initiation_time BIGINT + ,IN in_request_uid TEXT ,OUT out_nx_incoming_payment BOOLEAN ) LANGUAGE plpgsql AS $$ @@ -110,12 +114,14 @@ INSERT INTO initiated_outgoing_transactions ( ,wire_transfer_subject ,credit_payto_uri ,initiation_time + ,request_uid ) SELECT amount ,'refund: ' || wire_transfer_subject ,debit_payto_uri ,in_initiation_time + ,in_request_uid FROM incoming_transactions WHERE incoming_transaction_id = in_incoming_transaction_id; @@ -134,4 +140,4 @@ UPDATE incoming_transactions WHERE incoming_transaction_id = in_incoming_transaction_id; END $$; -COMMENT ON FUNCTION bounce_payment(BIGINT, BIGINT) IS 'Marks an incoming payment as bounced and initiates its refunding payment'; +COMMENT ON FUNCTION bounce_payment(BIGINT, BIGINT, TEXT) IS 'Marks an incoming payment as bounced and initiates its refunding payment'; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -42,7 +42,7 @@ data class InitiatedPayment( val wireTransferSubject: String?, val creditPaytoUri: String, val initiationTime: Instant, - val clientRequestUuid: String? = null + val requestUid: String ) /** @@ -207,18 +207,21 @@ class Database(dbConfig: String): java.io.Closeable { * means that the actual value was returned to the initial debtor. * * @param rowId row ID of the payment to flag as bounced. + * @param initiatedRequestUid unique identifier for the outgoing payment to + * initiate for this bouncing. * @return true if the payment could be set as bounced, false otherwise. */ - suspend fun incomingPaymentSetAsBounced(rowId: Long): Boolean = runConn { conn -> + suspend fun incomingPaymentSetAsBounced(rowId: Long, initiatedRequestUid: String): Boolean = runConn { conn -> val timestamp = Instant.now().toDbMicros() ?: throw Exception("Could not convert Instant.now() to microseconds, won't bounce this payment.") val stmt = conn.prepareStatement(""" SELECT out_nx_incoming_payment - FROM bounce_payment(?,?) + FROM bounce_payment(?,?,?) """ ) stmt.setLong(1, rowId) stmt.setLong(2, timestamp) + stmt.setString(3, initiatedRequestUid) stmt.executeQuery().use { maybeResult -> if (!maybeResult.next()) throw Exception("Expected outcome from the SQL bounce_payment function") return@runConn !maybeResult.getBoolean("out_nx_incoming_payment") @@ -227,12 +230,16 @@ class Database(dbConfig: String): java.io.Closeable { /** * Creates an incoming payment as bounced _and_ initiates its - * reimbursement. Throws exception on unique constraint violation, - * or other errors. + * reimbursement. * * @param paymentData information related to the incoming payment. + * @param requestUid unique identifier of the outgoing payment to + * initiate, in order to reimburse the bounced tx. */ - suspend fun incomingPaymentCreateBounced(paymentData: IncomingPayment) = runConn { conn -> + suspend fun incomingPaymentCreateBounced( + paymentData: IncomingPayment, + requestUid: String + ) = runConn { conn -> val refundTimestamp = Instant.now().toDbMicros() ?: throw Exception("Could not convert refund execution time from Instant.now() to microsends.") val executionTime = paymentData.executionTime.toDbMicros() @@ -245,6 +252,7 @@ class Database(dbConfig: String): java.io.Closeable { ,? ,? ,? + ,? )""") stmt.setLong(1, paymentData.amount.value) stmt.setInt(2, paymentData.amount.fraction) @@ -253,6 +261,7 @@ class Database(dbConfig: String): java.io.Closeable { stmt.setString(5, paymentData.debitPaytoUri) stmt.setString(6, paymentData.bankTransferId) stmt.setLong(7, refundTimestamp) + stmt.setString(8, requestUid) stmt.executeQuery() } @@ -325,7 +334,7 @@ class Database(dbConfig: String): java.io.Closeable { ,wire_transfer_subject ,credit_payto_uri ,initiation_time - ,client_request_uuid + ,request_uid FROM initiated_outgoing_transactions WHERE submitted=false; """) @@ -347,7 +356,7 @@ class Database(dbConfig: String): java.io.Closeable { creditPaytoUri = it.getString("credit_payto_uri"), wireTransferSubject = it.getString("wire_transfer_subject"), initiationTime = initiationTime, - clientRequestUuid = it.getString("client_request_uuid") + requestUid = it.getString("request_uid") ) } while (it.next()) } @@ -368,7 +377,7 @@ class Database(dbConfig: String): java.io.Closeable { ,wire_transfer_subject ,credit_payto_uri ,initiation_time - ,client_request_uuid + ,request_uid ) VALUES ( (?,?)::taler_amount ,? @@ -389,7 +398,7 @@ class Database(dbConfig: String): java.io.Closeable { throw Exception("Initiation time could not be converted to microseconds for the database.") } stmt.setLong(5, initiationTime) - stmt.setString(6, paymentData.clientRequestUuid) // can be null. + stmt.setString(6, paymentData.requestUid) // can be null. if (stmt.maybeUpdate()) return@runConn PaymentInitiationOutcome.SUCCESS /** diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt @@ -72,17 +72,17 @@ fun getPofiConfig(userId: String, partnerId: String) = """ """.trimIndent() // Generates a payment initiation, given its subject. -fun genInitPay(subject: String? = null, rowUuid: String? = null) = +fun genInitPay(subject: String? = null, rowUid: String = "unique") = InitiatedPayment( amount = TalerAmount(44, 0, "KUDOS"), creditPaytoUri = "payto://iban/not-used", wireTransferSubject = subject, initiationTime = Instant.now(), - clientRequestUuid = rowUuid + requestUid = rowUid ) // Generates an incoming payment, given its subject. -fun genIncPay(subject: String? = null, rowUuid: String? = null) = +fun genIncPay(subject: String? = null) = IncomingPayment( amount = TalerAmount(44, 0, "KUDOS"), debitPaytoUri = "payto://iban/not-used", diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -49,7 +49,10 @@ class IncomingPaymentsTest { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) runBlocking { // creating and bouncing one incoming transaction. - db.incomingPaymentCreateBounced(genIncPay("incoming and bounced")) + db.incomingPaymentCreateBounced( + genIncPay("incoming and bounced"), + "UID" + ) db.runConn { // check the bounced flaag is true val checkBounced = it.prepareStatement(""" @@ -86,13 +89,13 @@ class IncomingPaymentsTest { assertTrue(expectNotBounced.next()) assertFalse(expectNotBounced.getBoolean("bounced")) // now bouncing it. - assertTrue(db.incomingPaymentSetAsBounced(1)) + assertTrue(db.incomingPaymentSetAsBounced(1, "unique 0")) // asserting it got flagged as bounced. val expectBounced = it.execSQLQuery(bouncedSql) assertTrue(expectBounced.next()) assertTrue(expectBounced.getBoolean("bounced")) // Trying to bounce a non-existing payment. - assertFalse(db.incomingPaymentSetAsBounced(5)) + assertFalse(db.incomingPaymentSetAsBounced(5, "unique 1")) } } } @@ -168,7 +171,7 @@ class PaymentInitiationsTest { amount = TalerAmount(44, 0, "KUDOS"), creditPaytoUri = "payto://iban/not-used", wireTransferSubject = "test", - clientRequestUuid = "unique", + requestUid = "unique", initiationTime = Instant.now() ) runBlocking { @@ -178,7 +181,7 @@ class PaymentInitiationsTest { assertTrue { haveOne.size == 1 && haveOne.containsKey(1) - && haveOne[1]?.clientRequestUuid == "unique" + && haveOne[1]?.requestUid == "unique" } } } @@ -189,11 +192,11 @@ class PaymentInitiationsTest { fun paymentInitiationsMultiple() { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) runBlocking { - assertEquals(db.initiatedPaymentCreate(genInitPay("#1")), PaymentInitiationOutcome.SUCCESS) - assertEquals(db.initiatedPaymentCreate(genInitPay("#2")), PaymentInitiationOutcome.SUCCESS) - assertEquals(db.initiatedPaymentCreate(genInitPay("#3")), PaymentInitiationOutcome.SUCCESS) - assertEquals(db.initiatedPaymentCreate(genInitPay("#4")), PaymentInitiationOutcome.SUCCESS) - assertEquals(db.initiatedPaymentCreate(genInitPay()), PaymentInitiationOutcome.SUCCESS) // checking the nullable subject + assertEquals(db.initiatedPaymentCreate(genInitPay("#1", "unique1")), PaymentInitiationOutcome.SUCCESS) + assertEquals(db.initiatedPaymentCreate(genInitPay("#2", "unique2")), PaymentInitiationOutcome.SUCCESS) + assertEquals(db.initiatedPaymentCreate(genInitPay("#3", "unique3")), PaymentInitiationOutcome.SUCCESS) + assertEquals(db.initiatedPaymentCreate(genInitPay("#4", "unique4")), PaymentInitiationOutcome.SUCCESS) + assertEquals(db.initiatedPaymentCreate(genInitPay(rowUid = "unique5")), PaymentInitiationOutcome.SUCCESS) // checking the nullable subject // Marking one as submitted, hence not expecting it in the results. db.runConn { conn ->