libeufin

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

commit ce31d171834bcf493ab39f41db64ad1961b610c5
parent 5de415d9534784711a33b710f25dfb802399188c
Author: Antoine A <>
Date:   Tue, 28 May 2024 19:09:03 +0900

nexus: support for incoming transactions without bank ID and fix instant transactions support from GLS

Diffstat:
Adatabase-versioning/libeufin-nexus-0004.sql | 24++++++++++++++++++++++++
Mdatabase-versioning/libeufin-nexus-procedures.sql | 29+++++++++++++++++++++--------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 4++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 203+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 2+-
Mnexus/src/test/kotlin/CliTest.kt | 3+++
Mnexus/src/test/kotlin/DatabaseTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/test/kotlin/Iso20022Test.kt | 2+-
Mnexus/src/test/kotlin/helpers.kt | 10++++++++--
10 files changed, 242 insertions(+), 96 deletions(-)

diff --git a/database-versioning/libeufin-nexus-0004.sql b/database-versioning/libeufin-nexus-0004.sql @@ -0,0 +1,24 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2024 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-nexus-0004', NULL, NULL); + +SET search_path TO libeufin_nexus; + +-- TODO fix this hack in a future update +ALTER TABLE incoming_transactions ALTER COLUMN bank_id DROP NOT NULL; +COMMIT; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -228,15 +228,28 @@ CREATE FUNCTION register_incoming_and_talerable( ,OUT out_tx_id INT8 ) LANGUAGE plpgsql AS $$ +DECLARE +need_reconcile BOOLEAN; BEGIN --- Check conflict -IF EXISTS ( - SELECT FROM talerable_incoming_transactions - JOIN incoming_transactions USING(incoming_transaction_id) - WHERE reserve_public_key = in_reserve_public_key - AND bank_id != in_bank_id -) THEN - out_reserve_pub_reuse = TRUE; +-- Check if exists +SELECT incoming_transaction_id, + bank_id IS DISTINCT FROM in_bank_id, + bank_id IS NULL AND amount = in_amount + AND debit_payto_uri = in_debit_payto_uri + AND wire_transfer_subject = in_wire_transfer_subject + INTO out_tx_id, out_reserve_pub_reuse, need_reconcile + FROM talerable_incoming_transactions + JOIN incoming_transactions USING(incoming_transaction_id) + WHERE reserve_public_key = in_reserve_public_key; + +IF FOUND THEN + IF need_reconcile THEN + IF in_bank_id IS NOT NULL THEN + -- Update the bank_id now that we have it + UPDATE incoming_transactions SET bank_id = in_bank_id WHERE incoming_transaction_id = out_tx_id; + END IF; + out_reserve_pub_reuse=false; + END IF; RETURN; END IF; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -122,6 +122,10 @@ suspend fun ingestIncomingPayment( accountType: AccountType ) { suspend fun bounce(msg: String) { + if (payment.bankId == null) { + logger.debug("$payment ignored: missing bank ID") + return; + } when (accountType) { AccountType.exchange -> { val result = db.payment.registerMalformedIncoming( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -271,8 +271,8 @@ sealed interface TxNotification { /** ISO20022 incoming payment */ data class IncomingPayment( - /** ISO20022 AccountServicerReference */ - val bankId: String, + /** ISO20022 UETR & TxID */ + val bankId: String? = null, // Null when TxID is wrong with Atruvia's implementation of instant transactions val amount: TalerAmount, val wireTransferSubject: String, override val executionTime: Instant, @@ -288,9 +288,9 @@ data class OutgoingPayment( /** ISO20022 MessageIdentification & EndToEndId */ val messageId: String, val amount: TalerAmount, - val wireTransferSubject: String? = null, // not showing in camt.054 + val wireTransferSubject: String? = null, // Some implementation does not provide this for recovery override val executionTime: Instant, - val creditPaytoUri: String? = null, // not showing in camt.054 + val creditPaytoUri: String? = null // Some implementation does not provide this for recovery ): TxNotification { override fun toString(): String { return "OUT ${executionTime.fmtDate()} $amount '$messageId' creditor=$creditPaytoUri subject=\"$wireTransferSubject\"" @@ -324,29 +324,53 @@ fun parseTx( /* In ISO 20022 specifications, most fields are optional and the same information can be written several times in different places. For libeufin, we're only - interested in a subset of the available values that can be found in both camt.053 - and camt.054. This function should not fail on legitimate files and should simply - warn when available information are insufficient. + interested in a subset of the available values that can be found in both camt.052, + camt.053 and camt.054. This function should not fail on legitimate files and should + simply warn when available information are insufficient. + + EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the + UETR (unique end-to-end transaction reference), which is a universally unique + identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification) + is a unique identification as assigned by the first instructing agent. As its format + is ambiguous, its uniqueness is not guaranteed by the standard, and it is only + supposed to be unique for a “pre-agreed period”, whatever that means. These two + identifiers are optional in the standard, but have the advantage of being unique + and can be used to track a transaction between banks so we use them when available. + + It is also possible to use AccountServicerReference, which is a unique reference + assigned by the account servicing institution. They can be present at several levels + (batch level, transaction level, etc.) and are often optional. They also have the + disadvantage of being known only by the account servicing institution. They should + therefore only be used as a last resort. */ - /** Assert that transaction status is BOOK */ - fun XmlDestructor.assertBooked(ref: String?) { - one("Sts") { + /** Check if a transaction status is BOOK */ + fun XmlDestructor.isBooked(): Boolean { + // We check at the Sts or Sts/Cd level for retrocompatibility + return one("Sts") { val status = opt("Cd")?.text() ?: text() - require(status == "BOOK") { - "Found non booked entry $ref, stop parsing: expected BOOK got $status" - } + status == "BOOK" } } + /** Parse the instruction execution date */ fun XmlDestructor.executionDate(): Instant { // Value date if present else booking date - return (opt("ValDt") ?: one("BookgDt")).one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC) + val date = opt("ValDt") ?: one("BookgDt") + val parsed = date.opt("Dt") { + date().atStartOfDay() + } ?: date.one("DtTm") { + dateTime() + } + return parsed.toInstant(ZoneOffset.UTC) } + /** Parse original transaction ID generated by libeufin-nexus */ fun XmlDestructor.nexusId(): String? = + // We check at the EndToEndId or MsgId level for retrocompatibility opt("Refs") { opt("EndToEndId")?.textProvided() ?: opt("MsgId")?.text() } + /** Parse and format transaction return reasons */ fun XmlDestructor.returnReason(): String = one("RtrInf") { val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>() val info = opt("AddtlInf")?.text() @@ -383,13 +407,14 @@ fun parseTx( XmlDestructor.fromStream(notifXml, "Document") { when (dialect) { Dialect.gls -> { + /** Common parsing logic for camt.052 and camt.053 */ fun XmlDestructor.parseGlsInner() { opt("Acct") { // Sanity check on currency and IBAN ? } each("Ntry") { + if (!isBooked()) return@each val entryRef = opt("AcctSvcrRef")?.text() - assertBooked(entryRef) val bookDate = executionDate() val kind = one("CdtDbtInd").enum<Kind>() val amount = amount(acceptedCurrency) @@ -438,38 +463,41 @@ fun parseTx( } } } - opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052 + opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 + // All transactions appear here the day after they are booked parseGlsInner() } - opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 + opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052 + // Transactions might appear here first before the end of the day parseGlsInner() } opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054 + // Instant transactions appear here a few seconds after being booked opt("Acct") { // Sanity check on currency and IBAN ? } each("Ntry") { + if (!isBooked()) return@each + if (isReversalCode()) return@each val entryRef = opt("AcctSvcrRef")?.text() - assertBooked(entryRef) val bookDate = executionDate() val kind = one("CdtDbtInd").enum<Kind>() val amount = amount(acceptedCurrency) - if (!isReversalCode()) { - one("NtryDtls").one("TxDtls") { - val txRef = one("Refs").opt("AcctSvcrRef")?.text() - val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") - if (kind == Kind.CRDT) { - val bankId = one("Refs").opt("TxId")?.text() - val debtorPayto = opt("RltdPties") { payto("Dbtr") } - txsInfo.add(TxInfo.Credit( - ref = bankId ?: txRef ?: entryRef, - bookDate = bookDate, - bankId = bankId, - amount = amount, - subject = subject, - debtorPayto = debtorPayto - )) - } + one("NtryDtls").one("TxDtls") { + val txRef = one("Refs").opt("AcctSvcrRef")?.text() + val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") + if (kind == Kind.CRDT) { + val bankId = one("Refs").opt("TxId")?.text() + val debtorPayto = opt("RltdPties") { payto("Dbtr") } + txsInfo.add(TxInfo.Credit( + ref = txRef ?: entryRef, + bookDate = bookDate, + // TODO use the bank ID again when Atruvia's implementation is fixed + bankId = null, + amount = amount, + subject = subject, + debtorPayto = debtorPayto + )) } } } @@ -477,70 +505,79 @@ fun parseTx( } Dialect.postfinance -> { opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 + /* + All transactions appear here on the day following their booking. Alas, some + necessary metadata is missing, which is only present in camt.054. However, + this file contains the structured return reasons that are missing from the + camt.054 files. That's why we only use this file for this purpose. + */ opt("Acct") { // Sanity check on currency and IBAN ? } each("Ntry") { + if (!isBooked()) return@each + // Non reversal transaction are handled in camt.054 + if (!isReversalCode()) return@each + val entryRef = opt("AcctSvcrRef")?.text() - assertBooked(entryRef) val bookDate = executionDate() - if (isReversalCode()) { - one("NtryDtls").one("TxDtls") { - val kind = one("CdtDbtInd").enum<Kind>() - if (kind == Kind.CRDT) { - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - val nexusId = nexusId() - val reason = returnReason() - txsInfo.add(TxInfo.CreditReversal( - ref = nexusId ?: txRef ?: entryRef, - bookDate = bookDate, - nexusId = nexusId, - reason = reason - )) - } + one("NtryDtls").one("TxDtls") { + val kind = one("CdtDbtInd").enum<Kind>() + if (kind == Kind.CRDT) { + val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() + val nexusId = nexusId() + val reason = returnReason() + txsInfo.add(TxInfo.CreditReversal( + ref = nexusId ?: txRef ?: entryRef, + bookDate = bookDate, + nexusId = nexusId, + reason = reason + )) } } } } opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054 + // Instant transactions appear here a moment after being booked opt("Acct") { // Sanity check on currency and IBAN ? } each("Ntry") { + if (!isBooked()) return@each + // Reversal are handled from camt.053 + if (isReversalCode()) return@each + val entryRef = opt("AcctSvcrRef")?.text() - assertBooked(entryRef) val bookDate = executionDate() - if (!isReversalCode()) { - one("NtryDtls").each("TxDtls") { - val kind = one("CdtDbtInd").enum<Kind>() - val amount = amount(acceptedCurrency) - val txRef = one("Refs").opt("AcctSvcrRef")?.text() - val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") - when (kind) { - Kind.CRDT -> { - val bankId = one("Refs").opt("UETR")?.text() - val debtorPayto = opt("RltdPties") { payto("Dbtr") } - txsInfo.add(TxInfo.Credit( - ref = bankId ?: txRef ?: entryRef, - bookDate = bookDate, - bankId = bankId, - amount = amount, - subject = subject, - debtorPayto = debtorPayto - )) - } - Kind.DBIT -> { - val nexusId = nexusId() - val creditorPayto = opt("RltdPties") { payto("Cdtr") } - txsInfo.add(TxInfo.Debit( - ref = nexusId ?: txRef ?: entryRef, - bookDate = bookDate, - nexusId = nexusId, - amount = amount, - subject = subject, - creditorPayto = creditorPayto - )) - } + one("NtryDtls").each("TxDtls") { + val kind = one("CdtDbtInd").enum<Kind>() + val amount = amount(acceptedCurrency) + val txRef = one("Refs").opt("AcctSvcrRef")?.text() + val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") + when (kind) { + Kind.CRDT -> { + val bankId = one("Refs").opt("UETR")?.text() + val debtorPayto = opt("RltdPties") { payto("Dbtr") } + txsInfo.add(TxInfo.Credit( + ref = bankId ?: txRef ?: entryRef, + bookDate = bookDate, + bankId = bankId, + amount = amount, + subject = subject, + debtorPayto = debtorPayto + )) + } + Kind.DBIT -> { + val nexusId = nexusId() + val creditorPayto = opt("RltdPties") { payto("Cdtr") } + txsInfo.add(TxInfo.Debit( + ref = nexusId ?: txRef ?: entryRef, + bookDate = bookDate, + nexusId = nexusId, + amount = amount, + subject = subject, + creditorPayto = creditorPayto + )) } } } @@ -604,8 +641,8 @@ private fun parseTxLogic(info: TxInfo): TxNotification { ) } is TxInfo.Credit -> { - if (info.bankId == null) - throw TxErr("missing bank ID for Credit ${info.ref}") + /*if (info.bankId == null) TODO use the bank ID again when Atruvia's implementation is fixed + throw TxErr("missing bank ID for Credit ${info.ref}")*/ if (info.subject == null) throw TxErr("missing subject for Credit ${info.ref}") if (info.debtorPayto == null) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -346,7 +346,7 @@ class ListCmd: CliktCommand("List nexus transactions", name = "list") { txs.map { listOf( "${it.date} ${it.amount}", - it.id, + it.id.toString(), it.reservePub?.toString() ?: "", fmtPayto(it.debtor), it.subject diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -283,7 +283,7 @@ data class IncomingTxMetadata( val amount: DecimalNumber, val subject: String, val debtor: String, - val id: String, + val id: String?, val reservePub: EddsaPublicKey? ) diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -151,5 +151,8 @@ class CliTest { talerableOut(db) talerableIn(db) check() + // Check with null id + talerableIn(db, true) + check() } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -19,7 +19,9 @@ import org.junit.Test import tech.libeufin.common.* +import tech.libeufin.common.db.* import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult +import tech.libeufin.nexus.db.* import tech.libeufin.nexus.* import java.time.Instant import kotlin.test.assertEquals @@ -71,6 +73,23 @@ class OutgoingPaymentsTest { } } +suspend fun Database.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { + conn { + val cIncoming = it.prepareStatement("SELECT count(*) FROM incoming_transactions").one { it.getInt(1) } + val cBounce = it.prepareStatement("SELECT count(*) FROM bounced_transactions").one { it.getInt(1) } + val cTalerable = it.prepareStatement("SELECT count(*) FROM talerable_incoming_transactions").one { it.getInt(1) } + assertEquals(Triple(nbIncoming, nbBounce, nbTalerable), Triple(cIncoming, cBounce, cTalerable)) + } +} + +suspend fun Database.inTxExists(id: String): Boolean = conn { + it.prepareStatement("SELECT EXISTS(SELECT FROM incoming_transactions WHERE bank_id = ?)").apply { + setString(1, id) + }.one { + it.getBoolean(1) + } +} + class IncomingPaymentsTest { // Tests creating and bouncing incoming payments in one DB transaction. @Test @@ -125,6 +144,46 @@ class IncomingPaymentsTest { ) } } + + // Test creating an incoming taler transaction without and ID and reconcile it later again + @Test + fun reconcileMissingId() = setup { db, _ -> + // Register with missing ID + val reserve_pub = ShortHashCode.rand() + val incoming = genInPay("history test with $reserve_pub reserve pub") + val incomingMissingId = incoming.copy(bankId = null) + ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) + db.checkCount(1, 0, 1) + assertFalse(db.inTxExists(incoming.bankId!!)) + + // Idempotent + ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) + db.checkCount(1, 0, 1) + + // Different metadata is bounced + ingestIncomingPayment(db, genInPay("another $reserve_pub reserve pub"), AccountType.exchange) + db.checkCount(2, 1, 1) + + // Different medata with missing id is ignored + ingestIncomingPayment(db, incomingMissingId.copy(amount = TalerAmount("KUDOS:9")), AccountType.exchange) + db.checkCount(2, 1, 1) + + // Recover bank ID when metadata match + ingestIncomingPayment(db, incoming, AccountType.exchange) + assertTrue(db.inTxExists(incoming.bankId!!)) + + // Idempotent + ingestIncomingPayment(db, incoming, AccountType.exchange) + db.checkCount(2, 1, 1) + + // Missing ID is ignored + ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) + db.checkCount(2, 1, 1) + + // Other ID is bounced known that we know the id + ingestIncomingPayment(db, incomingMissingId.copy(bankId = "NEW"), AccountType.exchange) + db.checkCount(3, 2, 1) + } } class PaymentInitiationsTest { diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -171,7 +171,7 @@ class Iso20022Test { assertEquals( listOf( IncomingPayment( - bankId = "IS11PGENODEFF2DA8899900378806", + bankId = null, //"IS11PGENODEFF2DA8899900378806", amount = TalerAmount("EUR:2.5"), wireTransferSubject = "Test ICT", executionTime = instant("2024-05-05"), diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -147,9 +147,15 @@ suspend fun talerableOut(db: Database) { } /** Ingest a talerable incoming transaction */ -suspend fun talerableIn(db: Database) { +suspend fun talerableIn(db: Database, nullId: Boolean = false) { val reserve_pub = ShortHashCode.rand() - ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub"), AccountType.exchange) + ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub").run { + if (nullId) { + copy(bankId = null) + } else { + this + } + }, AccountType.exchange) } /** Ingest an incoming transaction */