libeufin

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

commit 9252d28b3a9c8d332e9aacc3fe3802d4ccfdefef
parent fa530bdc65f7eb86796259314b6cb9455177cf32
Author: Antoine A <>
Date:   Tue, 25 Feb 2025 17:21:05 +0100

nexus: use three ids for incoming transactions

Diffstat:
Mdatabase-versioning/libeufin-nexus-0010.sql | 2++
Adatabase-versioning/libeufin-nexus-0011.sql | 30++++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-nexus-procedures.sql | 89++++++++++++++++++++++---------------------------------------------------------
Mnexus/sample/platform/maerki_baumann_camt053.xml | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/sample/platform/postfinance_camt054.xml | 1+
Mnexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt | 4+++-
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt | 7+++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt | 10++++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 30++++++++++++++++++------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt | 534+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mnexus/src/test/kotlin/DatabaseTest.kt | 87++++++-------------------------------------------------------------------------
Mnexus/src/test/kotlin/Iso20022Test.kt | 37++++++++++++++++++++++++++++---------
Mnexus/src/test/kotlin/RegistrationTest.kt | 30+++++++++++++++++++++++-------
Mnexus/src/test/kotlin/bench.kt | 10----------
Mnexus/src/test/kotlin/helpers.kt | 8++++----
Mtestbench/src/test/kotlin/IntegrationTest.kt | 6+++---
17 files changed, 497 insertions(+), 469 deletions(-)

diff --git a/database-versioning/libeufin-nexus-0010.sql b/database-versioning/libeufin-nexus-0010.sql @@ -19,6 +19,8 @@ SELECT _v.register_patch('libeufin-nexus-0010', NULL, NULL); SET search_path TO libeufin_nexus; +-- TODO migrate existing ids + -- Better polymorphism schema ALTER TABLE talerable_incoming_transactions ADD COLUMN metadata BYTEA; UPDATE talerable_incoming_transactions diff --git a/database-versioning/libeufin-nexus-0011.sql b/database-versioning/libeufin-nexus-0011.sql @@ -0,0 +1,30 @@ +-- +-- 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-nexus-0011', NULL, NULL); + +SET search_path TO libeufin_nexus; + +-- Better polymorphism schema +ALTER TABLE incoming_transactions + DROP COLUMN bank_id, + ADD COLUMN uetr UUID UNIQUE, + ADD COLUMN tx_id TEXT UNIQUE, + ADD COLUMN acct_svcr_ref TEXT UNIQUE, + ADD CONSTRAINT bank_id CHECK(COALESCE(uetr::text, tx_id, acct_svcr_ref) IS NOT NULL); + +COMMIT; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -212,7 +212,9 @@ CREATE FUNCTION register_incoming( ,IN in_subject TEXT ,IN in_execution_time INT8 ,IN in_debit_payto TEXT - ,IN in_bank_id TEXT + ,IN in_uetr UUID + ,IN in_tx_id TEXT + ,IN in_acct_svcr_ref TEXT ,IN in_type taler_incoming_type ,IN in_metadata BYTEA -- Error status @@ -222,8 +224,8 @@ CREATE FUNCTION register_incoming( ,OUT out_tx_id INT8 ) LANGUAGE plpgsql AS $$ -DECLARE -need_reconcile BOOLEAN; +DECLARE +local_ref TEXT; local_amount taler_amount; local_subject TEXT; local_debit_payto TEXT; @@ -232,76 +234,29 @@ IF in_credit_fee = (0, 0)::taler_amount THEN in_credit_fee = NULL; END IF; -IF in_type IS NULL THEN - -- No talerable logic -ELSIF in_type = 'reserve' THEN - -- Search if already inserted based on unique reserve_pub key - -- Reconcile missing bank_id if metadata match - -- Check for reserve_pub reuse - SELECT incoming_transaction_id, bank_id IS DISTINCT FROM in_bank_id, - bank_id IS NULL AND amount = in_amount - AND debit_payto = in_debit_payto - AND subject = in_subject - INTO out_tx_id, out_reserve_pub_reuse, need_reconcile - FROM talerable_incoming_transactions - JOIN incoming_transactions USING(incoming_transaction_id) - WHERE metadata = in_metadata AND type = 'reserve'; - - IF FOUND THEN - IF need_reconcile THEN - IF in_bank_id IS NOT NULL THEN - 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; -ELSIF in_type = 'kyc' THEN - -- Search if already inserted based on metadata match and account_pub - -- Reconcile missing bank_id - SELECT incoming_transaction_id, bank_id IS NULL - INTO out_tx_id, need_reconcile - FROM talerable_incoming_transactions - JOIN incoming_transactions USING(incoming_transaction_id) - WHERE metadata = in_metadata - AND amount = in_amount - AND debit_payto = in_debit_payto - AND subject = in_subject - AND type = 'kyc'; - - IF FOUND THEN - -- If bank_id is missing we assume it's the same transaction - IF in_bank_id IS NULL THEN - RETURN; - -- Else if bank_id is present we assume it's the same transaction and reconciliate - ELSIF need_reconcile THEN - UPDATE incoming_transactions SET bank_id = in_bank_id WHERE incoming_transaction_id = out_tx_id; - RETURN; - END IF; - -- Else we consider it's a new transaction - END IF; -ELSE - RAISE EXCEPTION 'Unsupported incoming type %', in_type; -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 - WHERE bank_id = in_bank_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 + local_ref=COALESCE(in_uetr::text, in_tx_id, in_acct_svcr_ref); -- Check metadata IF local_subject != in_subject THEN - RAISE NOTICE 'incoming tx %: stored subject is ''%'' got ''%''', in_bank_id, local_subject, in_subject; + RAISE NOTICE 'incoming tx %: stored subject is ''%'' got ''%''', local_ref, local_subject, in_subject; END IF; IF local_debit_payto != in_debit_payto THEN - RAISE NOTICE 'incoming tx %: stored subject debit payto is % got %', in_bank_id, local_debit_payto, in_debit_payto; + RAISE NOTICE 'incoming tx %: stored subject debit payto is % got %', local_ref, local_debit_payto, in_debit_payto; END IF; IF local_amount != in_amount THEN - RAISE NOTICE 'incoming tx %: stored amount is % got %', in_bank_id, local_amount, in_amount; + RAISE NOTICE 'incoming tx %: stored amount is % got %', local_ref, local_amount, in_amount; END IF; ELSE + out_reserve_pub_reuse=in_type = 'reserve' AND EXISTS(SELECT FROM talerable_incoming_transactions WHERE metadata = in_metadata AND type = 'reserve'); + IF out_reserve_pub_reuse THEN + RETURN; + END IF; -- Store the transaction in the database INSERT INTO incoming_transactions ( amount @@ -309,14 +264,18 @@ ELSE ,subject ,execution_time ,debit_payto - ,bank_id + ,uetr + ,tx_id + ,acct_svcr_ref ) VALUES ( in_amount ,in_credit_fee ,in_subject ,in_execution_time ,in_debit_payto - ,in_bank_id + ,in_uetr + ,in_tx_id + ,in_acct_svcr_ref ) RETURNING incoming_transaction_id INTO out_tx_id; PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text); END IF; @@ -343,7 +302,9 @@ CREATE FUNCTION register_and_bounce_incoming( ,IN in_subject TEXT ,IN in_execution_time INT8 ,IN in_debit_payto TEXT - ,IN in_bank_id TEXT + ,IN in_uetr UUID + ,IN in_tx_id TEXT + ,IN in_acct_svcr_ref TEXT ,IN in_bounce_amount taler_amount ,IN in_now_date INT8 ,IN in_bounce_id TEXT @@ -358,7 +319,7 @@ bounce_amount taler_amount; BEGIN -- Register incoming transaction SELECT reg.out_found, reg.out_tx_id - FROM register_incoming(in_amount, in_credit_fee, in_subject, in_execution_time, in_debit_payto, in_bank_id, NULL, NULL) as reg + 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_tx_id; -- Bounce incoming transaction @@ -388,7 +349,7 @@ SELECT end_to_end_id INTO out_bounce_id IF NOT FOUND THEN out_bounce_id = in_bounce_id; -- Get incoming transaction bank ID and creditor - SELECT bank_id, debit_payto + SELECT COALESCE(uetr::text, tx_id, acct_svcr_ref), debit_payto INTO local_bank_id, payto_uri FROM incoming_transactions WHERE incoming_transaction_id = in_tx_id; diff --git a/nexus/sample/platform/maerki_baumann_camt053.xml b/nexus/sample/platform/maerki_baumann_camt053.xml @@ -542,6 +542,85 @@ </NtryDtls> <AddtlNtryInf>Bank clearing payment Grothoff Hans</AddtlNtryInf> </Ntry> + <Ntry> + <Amt Ccy="CHF">.1</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <RvslInd>true</RvslInd> + <Sts> + <Cd>BOOK</Cd> + </Sts> + <BookgDt> + <Dt>2024-11-21</Dt> + </BookgDt> + <ValDt> + <Dt>2024-11-21</Dt> + </ValDt> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>CAJT</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <Chrgs> + <TtlChrgsAndTaxAmt Ccy="CHF">.2</TtlChrgsAndTaxAmt> + <Rcrd> + <Amt Ccy="CHF">.2</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <ChrgInclInd>true</ChrgInclInd> + <Tp> + <Prtry> + <Id>PT inc.paym.exp</Id> + </Prtry> + </Tp> + <Br>DEBT</Br> + </Rcrd> + </Chrgs> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">.1</TtlAmt> + <CdtDbtInd>DBIT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <AcctSvcrRef>ZV20241121/773118/1</AcctSvcrRef> + <EndToEndId>NOTPROVIDED</EndToEndId> + <UETR>81b0d8c6-a677-4577-b75e-a639dcc03681</UETR> + <TxId>41120636093.0001</TxId> + </Refs> + <Amt Ccy="CHF">-.1</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.1</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.1</Amt> + </TxAmt> + </AmtDtls> + <RltdPties> + <Dbtr> + <Pty> + <Nm>Grothoff Hans</Nm> + </Pty> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>CH7389144832588726658</IBAN> + </Id> + </DbtrAcct> + </RltdPties> + <RmtInf> + <Ustrd>small transfer test</Ustrd> + </RmtInf> + <AddtlTxInf>Bank clearing payment Grothoff Hans</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Bank clearing payment Grothoff Hans</AddtlNtryInf> + </Ntry> </Stmt> </BkToCstmrStmt> </Document> \ No newline at end of file diff --git a/nexus/sample/platform/postfinance_camt054.xml b/nexus/sample/platform/postfinance_camt054.xml @@ -49,6 +49,7 @@ <AddtlNtryInf>LASTSCHRIFT ...</AddtlNtryInf> </Ntry> <Ntry> + <Amt Ccy="CHF">12.53</Amt> <RvslInd>false</RvslInd> <Sts> <Cd>BOOK</Cd> diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2020, 2024 Taler Systems S.A. + * Copyright (C) 2020-2025 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -23,6 +23,7 @@ import org.w3c.dom.Document import org.w3c.dom.Element import java.io.InputStream import java.io.StringWriter +import java.util.UUID import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -175,6 +176,7 @@ class XmlDestructor internal constructor(private val el: Element) { fun <T> one(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T = f(one(path, signed)) fun <T> opt(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T? = opt(path, signed)?.run(f) + fun uuid(): UUID = UUID.fromString(text()) fun text(): String = el.textContent fun bool(): Boolean = el.textContent.toBoolean() fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -126,7 +126,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir debtorPayto = debitAccount, subject = subject, executionTime = timestamp, - bankId = bankId + bankId = IncomingId(null, bankId, null) ), metadata) when (res) { IncomingRegistrationResult.ReservePubReuse -> throw conflict( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -102,7 +102,7 @@ class FakeIncoming: CliktCommand() { subject = subject, creditFee = creditFee, executionTime = Instant.now(), - bankId = randEbicsId() + bankId = IncomingId(null, randEbicsId(), null) ) ) } @@ -229,12 +229,15 @@ class ListCmd: CliktCommand("list") { ListKind.incoming -> { val txs = db.list.incoming() for (tx in txs) { - println("${tx.date} ${tx.id} ${tx.amount}") + println("${tx.date} ${tx.id} ${tx.amount}-${tx.creditFee}") println(" debtor: ${fmtPayto(tx.debtor)}") println(" subject: ${tx.subject}") if (tx.talerable != null) { println(" talerable: ${tx.talerable}") } + if (tx.bounced != 0) { + println(" bounced: ${tx.bounced}") + } println() } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt @@ -37,10 +37,10 @@ class ListDAO(private val db: Database) { ,(credit_fee).val AS credit_fee_val ,(credit_fee).frac AS credit_fee_frac ,subject - ,(bounced_transactions.initiated_outgoing_transaction_id IS NOT NULL) AS bounced + ,bounced_transactions.initiated_outgoing_transaction_id AS bounced ,execution_time ,debit_payto - ,bank_id + ,COALESCE(uetr::text, tx_id, acct_svcr_ref) as bank_id ,type ,metadata FROM incoming_transactions AS incoming @@ -58,8 +58,9 @@ class ListDAO(private val db: Database) { subject = it.getString("subject"), debtor = it.getString("debit_payto"), id = it.getString("bank_id"), + bounced = it.getInt("bounced"), talerable = when (type) { - null -> if (it.getBoolean("bounced")) "bounced" else "" + null -> null IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}" IncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}" IncomingType.wad -> throw UnsupportedOperationException() @@ -143,7 +144,8 @@ data class IncomingTxMetadata( val subject: String, val debtor: String, val id: String?, - val talerable: String? + val talerable: String?, + val bounced: Int ) /** Outgoing transaction metadata for debugging */ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -83,7 +83,7 @@ class PaymentDAO(private val db: Database) { ): IncomingBounceRegistrationResult = db.serializable( """ SELECT out_found, out_tx_id, out_bounce_id - FROM register_and_bounce_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,(?,?)::taler_amount,?,?) + FROM register_and_bounce_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,(?,?)::taler_amount,?,?) """ ) { setLong(1, payment.amount.value) @@ -93,11 +93,13 @@ class PaymentDAO(private val db: Database) { setString(5, payment.subject) setLong(6, payment.executionTime.micros()) setString(7, payment.debtorPayto.toString()) - setString(8, payment.bankId) - setLong(9, bounceAmount.value) - setInt(10, bounceAmount.frac) - setLong(11, timestamp.micros()) - setString(12, bounceEndToEndId) + setObject(8, payment.bankId.uetr) + setString(9, payment.bankId.txId) + setString(10, payment.bankId.acctSvcrRef) + setLong(11, bounceAmount.value) + setInt(12, bounceAmount.frac) + setLong(13, timestamp.micros()) + setString(14, bounceEndToEndId) one { IncomingBounceRegistrationResult( it.getLong("out_tx_id"), @@ -120,7 +122,7 @@ class PaymentDAO(private val db: Database) { ): IncomingRegistrationResult = db.serializable( """ SELECT out_reserve_pub_reuse, out_found, out_tx_id - FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?::taler_incoming_type,?) + FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?::taler_incoming_type,?) """ ) { val executionTime = payment.executionTime.micros() @@ -131,9 +133,11 @@ class PaymentDAO(private val db: Database) { setString(5, payment.subject) setLong(6, executionTime) setString(7, payment.debtorPayto.toString()) - setString(8, payment.bankId) - setString(9, metadata.type.name) - setBytes(10, metadata.key.raw) + setObject(8, payment.bankId.uetr) + setString(9, payment.bankId.txId) + setString(10, payment.bankId.acctSvcrRef) + setString(11, metadata.type.name) + setBytes(12, metadata.key.raw) one { when { it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse @@ -151,7 +155,7 @@ class PaymentDAO(private val db: Database) { ): IncomingRegistrationResult.Success = db.serializable( """ SELECT out_found, out_tx_id - FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,NULL,NULL) + FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL) """ ) { val executionTime = payment.executionTime.micros() @@ -162,7 +166,9 @@ class PaymentDAO(private val db: Database) { setString(5, payment.subject) setLong(6, executionTime) setString(7, payment.debtorPayto.toString()) - setString(8, payment.bankId) + setObject(8, payment.bankId.uetr) + setString(9, payment.bankId.txId) + setString(10, payment.bankId.acctSvcrRef) one { IncomingRegistrationResult.Success( it.getLong("out_tx_id"), diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -24,15 +24,45 @@ import tech.libeufin.nexus.ebics.Dialect import java.io.InputStream import java.time.Instant import java.time.ZoneOffset +import java.util.UUID + sealed interface TxNotification { val executionTime: Instant } +/** Unique ID provided by banks */ +data class IncomingId( + val uetr: UUID? = null, + val txId: String? = null, + val acctSvcrRef: String? = null, +) { + constructor(uetr: String, txId: String?, acctSvcrRef: String?) : this(UUID.fromString(uetr), txId, acctSvcrRef); + fun ref(): String = uetr?.toString() ?: txId ?: acctSvcrRef!! + + override fun toString(): String = buildString { + append('(') + if (uetr != null) { + append("uetr=") + append(uetr.toString()) + } + if (txId != null) { + if (length != 1) append(", ") + append("tx=") + append(txId) + } + if (acctSvcrRef != null) { + if (length != 1) append(", ") + append("ref=") + append(acctSvcrRef) + } + append(')') + } +} + /** ISO20022 incoming payment */ data class IncomingPayment( - /** ISO20022 UETR or TxID */ - val bankId: String? = null, // Null when TxID is wrong with Atruvia's implementation of instant transactions + val bankId: IncomingId, val amount: TalerAmount, val creditFee: TalerAmount? = null, val subject: String, @@ -41,7 +71,7 @@ data class IncomingPayment( ): TxNotification { override fun toString(): String { val fee = if (creditFee == null) "" else "-$creditFee" - return "IN ${executionTime.fmtDate()} $amount$fee $bankId debitor=$debtorPayto subject=\"$subject\"" + return "IN ${executionTime.fmtDate()} $amount$fee ${bankId} debitor=$debtorPayto subject=\"$subject\"" } } @@ -155,6 +185,13 @@ private fun XmlDestructor.outgoingId(): OutgoingId = one("Refs") { } } +/** Parse transaction ids as provided by bank*/ +private fun XmlDestructor.incomingId(ref: String?): IncomingId? = opt("Refs") { + val uetr = opt("UETR")?.uuid() + val txId = opt("TxId")?.text() + IncomingId(uetr, txId, ref) +} + /** Parse and format transaction return reasons */ private fun XmlDestructor.returnReason(): String = opt("RtrInf") { val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>() @@ -181,26 +218,108 @@ private fun XmlDestructor.amount() = one("Amt") { TalerAmount(concat) } -/** Parse credit fees */ -private fun XmlDestructor.creditFee(): TalerAmount? { - var charges: TalerAmount? = null +sealed interface ComplexAmount { + data class Simple( + val amount: TalerAmount + ): ComplexAmount + data class Converted( + val sent: TalerAmount, + val received: TalerAmount + ): ComplexAmount + data class Charged( + val received: TalerAmount, + val creditFee: TalerAmount + ): ComplexAmount + + /// The amount to register in database + fun amount(): TalerAmount { + return when (this) { + is Simple -> amount + is Converted -> received + is Charged -> received + } + } + + /// The credit fee to register in database + fun creditFee(): TalerAmount? { + return when (this) { + is Simple, is Converted -> null + is Charged -> creditFee + } + } + + /// Check that entry and tx amount are compatible and return the result + fun resolve(other: ComplexAmount): ComplexAmount { + when (this) { + is Simple -> { + when (other) { + is Simple -> {} + is Converted -> require(other.sent == amount || other.received == amount) { "bad currency conversion $other != $this" } + is Charged -> require(other.received == amount + other.creditFee) { "bad tx charge $other != $amount" } + } + return other + } + is Converted -> { + require(other is Simple) + require(other.amount == sent) + return this + } + is Charged -> { + require(this == other) { "$this != $other" } + return this + } + } + } +} + +private fun XmlDestructor.complexAmount(): ComplexAmount? { + var overflow = false; + val received = opt("Amt") { + val currency = attr("Ccy") + var amount = text() + overflow = amount.startsWith('-') + amount = amount.trimStart('-') + val concat = if (amount.startsWith('.')) { + "$currency:0$amount" + } else { + "$currency:$amount" + } + TalerAmount(concat) + } + if (received == null) return null + + val sent = opt("AmtDtls")?.opt("TxAmt") { + amount() + } ?: received + + var creditFee: TalerAmount? = null opt("Chrgs")?.each("Rcrd") { if (one("ChrgInclInd").bool() && one("CdtDbtInd").text() == "DBIT" ) { val amount = amount() - charges = charges?.let { it + amount } ?: amount + creditFee = creditFee?.let { it + amount } ?: amount } } - return charges -} -/** Parse amounts and compute fees */ -private fun XmlDestructor.amountAndFee(): Pair<TalerAmount, TalerAmount?> { - var amount = amount() - val charges = creditFee() - if (charges != null) { - amount += charges + if (received.currency != sent.currency) { + require(creditFee == null) { "Do not support fee on currency conversion" } + require(!overflow) + return ComplexAmount.Converted(sent, received) + } else if (creditFee == null && received == sent && !overflow) { + return ComplexAmount.Simple(received) + } else { + if (received != sent || overflow) { + val diff = if (overflow) { + sent + received + } else { + sent - received + } + require(creditFee == null || creditFee == diff) + return ComplexAmount.Charged(if (overflow) received else sent, diff) + } else { + val diff = requireNotNull(creditFee) + return ComplexAmount.Charged(received, diff) + } } - return Pair(amount, charges) } /** Parse bank transaction code */ @@ -291,281 +410,155 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> */ logger.trace("Parse transactions camt file for $dialect") val accountTxs = mutableListOf<AccountTransactions>() - XmlDestructor.fromStream(notifXml, "Document") { when (dialect) { - Dialect.gls -> { - /** Common parsing logic for camt.052 and camt.053 */ - fun XmlDestructor.parseGlsInner() { - val (iban, currency) = account() - val txsInfo = mutableListOf<TxInfo>() - each("Ntry") { - if (!isBooked()) return@each - val code = bankTransactionCode() - if (!code.isPayment()) return@each - val entryRef = opt("AcctSvcrRef")?.text() - val bookDate = executionDate() - val kind = one("CdtDbtInd").enum<Kind>() - val amount = amount() - one("NtryDtls").one("TxDtls") { // TODO handle batches - val code = optBankTransactionCode() ?: code - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - if (code.isReversal()) { - val outgoingId = outgoingId() - if (kind == Kind.CRDT) { - val reason = returnReason() - txsInfo.add(TxInfo.CreditReversal( - ref = outgoingId.ref() ?: txRef ?: entryRef, - bookDate = bookDate, - id = outgoingId, - reason = reason, - code = code - )) - } - } else { - val subject = wireTransferSubject() - when (kind) { - Kind.CRDT -> { - val bankId = one("Refs").opt("TxId")?.text() - val debtorPayto = payto("Dbtr") - txsInfo.add(TxInfo.Credit( - ref = bankId ?: txRef ?: entryRef, - bookDate = bookDate, - bankId = bankId, - amount = amount, - subject = subject, - debtorPayto = debtorPayto, - code = code, - creditFee = null - )) - } - Kind.DBIT -> { - val outgoingId = outgoingId() - val creditorPayto = payto("Cdtr") - txsInfo.add(TxInfo.Debit( - ref = outgoingId.ref() ?: txRef ?: entryRef, - bookDate = bookDate, - id = outgoingId, - amount = amount, - subject = subject, - creditorPayto = creditorPayto, - code = code - )) - } - } - } + /** Common parsing logic for camt.052, camt.053 and camt.054 */ + fun XmlDestructor.parseInner() { + val (iban, currency) = account() + val txInfos = mutableListOf<TxInfo>() + val batches = each("Ntry") { + if (!isBooked()) return@each + val entryCode = bankTransactionCode() + if (!entryCode.isPayment()) return@each + val reversal = opt("RvslInd")?.text() == "true" + val entryKind = opt("CdtDbtInd")?.enum<Kind>(); + val tmp = one("NtryDtls").map("TxDtls") { this } + val unique = tmp.size == 1 + val entryRef = opt("AcctSvcrRef")?.text() + val bookDate = executionDate() + val entryAmount = complexAmount()!! + var totalAmount: TalerAmount? = null + for (it in tmp) {it.run { + // Transaction direction + val txKind = opt("CdtDbtInd")?.enum<Kind>() + val kind: Kind = requireNotNull(entryKind ?: txKind) { "Missing entry kind" } + + // Transaction code + val code = optBankTransactionCode() ?: entryCode + + // Amount + val txAmount = complexAmount() + val amount = if (unique) { + if (entryAmount != null && txAmount != null) { + entryAmount.resolve(txAmount) + } else { + requireNotNull(entryAmount ?: txAmount) { "Missing unique tx amount" } } + } else { + requireNotNull(txAmount) { "Missing batch tx amount" } } - accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo)) - } - opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 - // All transactions appear here the day after they are booked - parseGlsInner() - } - 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 - val (iban, currency) = account() - val txsInfo = mutableListOf<TxInfo>() - each("Ntry") { - if (!isBooked()) return@each - val code = bankTransactionCode() - if (code.isReversal() || !code.isPayment()) return@each - val entryRef = opt("AcctSvcrRef")?.text() - val bookDate = executionDate() - val kind = one("CdtDbtInd").enum<Kind>() - val amount = amount() - one("NtryDtls").one("TxDtls") { - val code = optBankTransactionCode() ?: code - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - val subject = wireTransferSubject() - if (kind == Kind.CRDT) { - val bankId = one("Refs").opt("TxId")?.text() - val debtorPayto = 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, - code = code, - creditFee = null - )) - } + if (entryAmount != null) { + totalAmount = totalAmount?.let { it + amount.amount() } ?: amount.amount() + } + + // Ref + val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() + val ref = if (txRef == null) { + // We can only use the entry ref as the transaction ref if there is a single transaction in the batch + if (entryRef != null && unique) { + entryRef + } else { + null } + } else { + txRef } - accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo)) - } - } - 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. - */ - val (iban, currency) = account() - val txsInfo = mutableListOf<TxInfo>() - each("Ntry") { - if (!isBooked()) return@each - val code = bankTransactionCode() - // Non reversal transaction are handled in camt.054 - if (!(code.isReversal() && code.isPayment())) return@each - - val entryRef = opt("AcctSvcrRef")?.text() - val bookDate = executionDate() - one("NtryDtls").one("TxDtls") { - val kind = one("CdtDbtInd").enum<Kind>() - val code = optBankTransactionCode() ?: code - if (kind == Kind.CRDT) { - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - val outgoingId = outgoingId() + + if (code.isReversal() || reversal) { + val outgoingId = outgoingId() + when (kind) { + Kind.CRDT -> { val reason = returnReason() - txsInfo.add(TxInfo.CreditReversal( - ref = outgoingId.ref() ?: txRef ?: entryRef, + txInfos.add(TxInfo.CreditReversal( + ref = ref, bookDate = bookDate, id = outgoingId, reason = reason, code = code )) } - } - } - accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo)) - } - opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054 - // Instant transactions appear here a moment after being booked - val (iban, currency) = account() - val txsInfo = mutableListOf<TxInfo>() - each("Ntry") { - if (!isBooked()) return@each - val code = bankTransactionCode() - // Reversal are handled from camt.053 - if (code.isReversal() || !code.isPayment()) return@each - - val entryRef = opt("AcctSvcrRef")?.text() - val bookDate = executionDate() - one("NtryDtls").each("TxDtls") { - val kind = one("CdtDbtInd").enum<Kind>() - val code = optBankTransactionCode() ?: code - val amount = amount() - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - val subject = wireTransferSubject() - when (kind) { - Kind.CRDT -> { - val bankId = opt("Refs")?.opt("UETR")?.text() - val debtorPayto = payto("Dbtr") - txsInfo.add(TxInfo.Credit( - ref = bankId ?: txRef ?: entryRef, - bookDate = bookDate, - bankId = bankId, - amount = amount, - subject = subject, - debtorPayto = debtorPayto, - code = code, - creditFee = null - )) - } - Kind.DBIT -> { - val outgoingId = outgoingId() - val creditorPayto = payto("Cdtr") - txsInfo.add(TxInfo.Debit( - ref = outgoingId.ref() ?: txRef ?: entryRef, - bookDate = bookDate, - id = outgoingId, - amount = amount, - subject = subject, - creditorPayto = creditorPayto, - code = code - )) - } + Kind.DBIT -> { + val id = incomingId(ref) + val subject = wireTransferSubject() + val debtorPayto = payto("Dbtr") + val creditFee = amount.creditFee() + requireNotNull(creditFee) { "Do not support failed debit without credit fee" } + require(creditFee > amount.amount()) + txInfos.add(TxInfo.Credit( + ref = ref, + bookDate = bookDate, + bankId = id, + amount = amount.amount(), + subject = subject, + debtorPayto = debtorPayto, + code = code, + creditFee = creditFee + )) } } - } - accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo)) - } - } - Dialect.maerki_baumann -> { - opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 - val (iban, currency) = account() - val txsInfo = mutableListOf<TxInfo>() - each("Ntry") { - if (!isBooked()) return@each - val code = bankTransactionCode() - if (!code.isPayment()) return@each - val kind = one("CdtDbtInd").enum<Kind>() - val reversal = one("RvslInd").bool() - val entryRef = opt("AcctSvcrRef")?.text() - val bookDate = executionDate() - if (reversal) { - // Check reversal by fee over amount - require(kind == Kind.DBIT) { "reversal credit not yet supported" } - val fee = requireNotNull(creditFee()) { "Missing fee" } - val amount = amount() - one("NtryDtls").one("TxDtls") { - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - val bankId = opt("Refs")?.opt("UETR")?.text() - val subject = wireTransferSubject() + } else { + val subject = wireTransferSubject() + when (kind) { + Kind.CRDT -> { + val id = incomingId(ref) val debtorPayto = payto("Dbtr") - txsInfo.add(TxInfo.Credit( - ref = bankId ?: txRef ?: entryRef, + txInfos.add(TxInfo.Credit( + ref = ref, bookDate = bookDate, - bankId = bankId, - amount = amount, + bankId = id, + amount = amount.amount(), subject = subject, debtorPayto = debtorPayto, code = code, - creditFee = fee + creditFee = amount.creditFee() )) } - } else { - one("NtryDtls").one("TxDtls") { - val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text() - val kind = one("CdtDbtInd").enum<Kind>() - val code = optBankTransactionCode() ?: code - val (amount, fee) = amountAndFee() - val subject = wireTransferSubject() - if (!code.isReversal()) { - when (kind) { - Kind.CRDT -> { - val bankId = opt("Refs")?.opt("UETR")?.text() - val debtorPayto = payto("Dbtr") - txsInfo.add(TxInfo.Credit( - ref = bankId ?: txRef ?: entryRef, - bookDate = bookDate, - bankId = bankId, - amount = amount, - subject = subject, - debtorPayto = debtorPayto, - code = code, - creditFee = fee - )) - } - Kind.DBIT -> { - val outgoingId = outgoingId() - val creditorPayto = payto("Cdtr") - txsInfo.add(TxInfo.Debit( - ref = outgoingId.ref() ?: txRef ?: entryRef, - bookDate = bookDate, - id = outgoingId, - amount = amount, - subject = subject, - creditorPayto = creditorPayto, - code = code - )) - } - } - } + Kind.DBIT -> { + val outgoingId = outgoingId() + val creditorPayto = payto("Cdtr") + require(amount.creditFee() == null) { "Do not support debit with credit fees" } + txInfos.add(TxInfo.Debit( + ref = ref, + bookDate = bookDate, + id = outgoingId, + amount = amount.amount(), + subject = subject, + creditorPayto = creditorPayto, + code = code + )) } } } - accountTxs.add(AccountTransactions.fromParts(iban, currency, txsInfo)) + }} + if (totalAmount != null) { + //require(totalAmount == entryAmount.amount()) { "Entry amount doesn't match batch amount sum $entryAmount != $totalAmount" } } } + accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos)) + } + XmlDestructor.fromStream(notifXml, "Document") { when (dialect) { + Dialect.gls -> { + // Camt.053 All transactions appear here the day after they are booked + opt("BkToCstmrStmt")?.each("Stmt") { parseInner() } + // Camt.052 Transactions might appear here first before the end of the day + opt("BkToCstmrAcctRpt")?.each("Rpt") { parseInner() } + // Camt.054 Instant transactions appear here a few seconds after being booked + opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() } + } + Dialect.postfinance -> { + /* + 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("BkToCstmrStmt")?.each("Stmt") { parseInner() } + // Camt.054 Instant transactions appear here a moment after being booked + opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() } + } + Dialect.maerki_baumann -> { + // Camt.053 All transactions appear here the day after they are booked + opt("BkToCstmrStmt")?.each("Stmt") { parseInner() } + } }} return accountTxs } @@ -589,8 +582,7 @@ sealed interface TxInfo { override val ref: String?, override val bookDate: Instant, override val code: BankTransactionCode, - // Unique ID generated by payment provider - val bankId: String?, + val bankId: IncomingId?, val amount: TalerAmount, val creditFee: TalerAmount?, val subject: String?, @@ -620,8 +612,8 @@ sealed interface TxInfo { ) } is TxInfo.Credit -> { - /*if (bankId == null) TODO use the bank ID again when Atruvia's implementation is fixed - throw TxErr("missing bank ID for Credit $ref")*/ + if (bankId == null) + throw TxErr("missing bank ID for Credit $ref") if (subject == null) throw TxErr("missing subject for Credit $ref") if (debtorPayto == null) diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -60,15 +60,6 @@ suspend fun Database.checkOutCount(nbIncoming: Int, nbTalerable: Int) = serializ } } -suspend fun Database.inTxExists(id: String): Boolean = serializable( - "SELECT EXISTS(SELECT FROM incoming_transactions WHERE bank_id = ?)" -) { - setString(1, id) - one { - it.getBoolean(1) - } -} - class OutgoingPaymentsTest { @Test fun registerTx() = setup { db, _ -> @@ -206,22 +197,21 @@ class IncomingPaymentsTest { // Test creating an incoming reserve taler transaction without and ID and reconcile it later again @Test - fun reconcileMissingId() = setup { db, _ -> + fun registerPayment() = setup { db, _ -> val cfg = NexusIngestConfig.default(AccountType.exchange) val subject = "test with ${EddsaPublicKey.randEdsaKey()} reserve pub" - // Register with missing ID + // Register val incoming = genInPay(subject) - val incomingMissingId = incoming.copy(bankId = null) - registerIncomingPayment(db, cfg, incomingMissingId) + registerIncomingPayment(db, cfg, incoming) db.checkInCount(1, 0, 1) - assertFalse(db.inTxExists(incoming.bankId!!)) + // Idempotent - registerIncomingPayment(db, cfg, incomingMissingId) + registerIncomingPayment(db, cfg, incoming) db.checkInCount(1, 0, 1) - // Different metadata is bounced + // Key reuse is bounced registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) registerIncomingPayment(db, cfg, genInPay("another $subject")) db.checkInCount(3, 2, 1) @@ -229,71 +219,6 @@ class IncomingPaymentsTest { // Admin balance adjust is ignored registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) db.checkInCount(4, 2, 1) - - // Different medata with missing id is ignored - registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9"))) - registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "another $subject")) - db.checkInCount(4, 2, 1) - - // Recover bank ID when metadata match - registerIncomingPayment(db, cfg, incoming) - assertTrue(db.inTxExists(incoming.bankId!!)) - - // Idempotent - registerIncomingPayment(db, cfg, incoming) - db.checkInCount(4, 2, 1) - - // Missing ID is ignored - registerIncomingPayment(db, cfg, incomingMissingId) - db.checkInCount(4, 2, 1) - - // Other ID is bounced known that we know the id - registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW")) - db.checkInCount(5, 3, 1) - } - - // Test creating an incoming kyc taler transaction without and ID and reconcile it later again - @Test - fun reconcileMissingIdKyc() = setup { db, _ -> - val cfg = NexusIngestConfig.default(AccountType.exchange) - val subject = "test with KYC:${EddsaPublicKey.randEdsaKey()} account pub" - - // Register with missing ID - val incoming = genInPay(subject) - val incomingMissingId = incoming.copy(bankId = null) - registerIncomingPayment(db, cfg, incomingMissingId) - db.checkInCount(1, 0, 1) - assertFalse(db.inTxExists(incoming.bankId!!)) - - // Idempotent - registerIncomingPayment(db, cfg, incomingMissingId) - db.checkInCount(1, 0, 1) - - // Different metadata is accepted - registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) - registerIncomingPayment(db, cfg, genInPay("another $subject")) - db.checkInCount(3, 0, 3) - - // Different medata with missing id are accepted - registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9.5"))) - registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "again another $subject")) - db.checkInCount(5, 0, 5) - - // Recover bank ID when metadata match - registerIncomingPayment(db, cfg, incoming) - assertTrue(db.inTxExists(incoming.bankId!!)) - - // Idempotent - registerIncomingPayment(db, cfg, incoming) - db.checkInCount(5, 0, 5) - - // Missing ID is ignored - registerIncomingPayment(db, cfg, incomingMissingId) - db.checkInCount(5, 0, 5) - - // Other ID is accepted - registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW")) - db.checkInCount(6, 0, 6) } } diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -154,19 +154,25 @@ class Iso20022Test { creditorPayto = null ), IncomingPayment( - bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71", + bankId = IncomingId("62e2b511-7313-4ccd-8d40-c9d8e612cd71", null, "231121CH0AZWCR9T"), amount = TalerAmount("CHF:10"), subject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG", executionTime = dateToInstant("2023-12-19"), debtorPayto = ibanPayto("CH7389144832588726658", "Mr Test") ), IncomingPayment( - bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71", + bankId = IncomingId("62e2b511-7313-4ccd-8d40-c9d8e612cd71", null, "231121CH0AZWCVR1"), amount = TalerAmount("CHF:2.53"), subject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB", executionTime = dateToInstant("2023-12-19"), debtorPayto = ibanPayto("CH7389144832588726658", "Mr Test") ), + OutgoingReversal( + endToEndId = "50820f78-9024-44ff-978d-63a18c", + msgId = "50820f78-9024-44ff-978d-63a18c", + reason = "", + executionTime = dateToInstant("2024-01-15") + ), OutgoingBatch( msgId = "ZS1PGNTSV0ZNDFAJBBWWB8015G", executionTime = dateToInstant("2024-01-15") @@ -195,6 +201,10 @@ class Iso20022Test { msgId = "4cc61cc7-6230-49c2-b5e2-b40bbb", reason = "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing.' - 'more info here ...'", executionTime = dateToInstant("2023-11-22") + ), + OutgoingBatch( + msgId = "EB4D22D428214261B2B3012D2A8CEC36", + executionTime = dateToInstant("2024-08-26") ) ) )) @@ -223,7 +233,7 @@ class Iso20022Test { executionTime = dateToInstant("2024-09-05") ), IncomingPayment( - bankId = "BYLADEM1WOR-G2910276709458A2", + bankId = IncomingId(null, "BYLADEM1WOR-G2910276709458A2", "2024041210041357000"), amount = TalerAmount("EUR:3"), subject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", executionTime = dateToInstant("2024-04-12"), @@ -298,7 +308,7 @@ class Iso20022Test { executionTime = dateToInstant("2024-09-04") ), IncomingPayment( - bankId = "BYLADEM1WOR-G2910276709458A2", + bankId = IncomingId(null, "BYLADEM1WOR-G2910276709458A2", "2024041210041357000"), amount = TalerAmount("EUR:3"), subject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", executionTime = dateToInstant("2024-04-12"), @@ -339,7 +349,8 @@ class Iso20022Test { currency = "EUR", txs = listOf<TxNotification>( IncomingPayment( - bankId = null, //"IS11PGENODEFF2DA8899900378806", + bankId = IncomingId(null, "IS11PGENODEFF2DA8899900378806", null), + //bankId = null, //"IS11PGENODEFF2DA8899900378806", amount = TalerAmount("EUR:2.5"), subject = "Test ICT", executionTime = dateToInstant("2024-05-05"), @@ -359,7 +370,7 @@ class Iso20022Test { currency = "CHF", txs = listOf<TxNotification>( IncomingPayment( - bankId = "adbe4a5a-6cea-4263-b259-8ab964561a32", + bankId = IncomingId("adbe4a5a-6cea-4263-b259-8ab964561a32", "41103099704.0002", "ZV20241104/765446/1"), amount = TalerAmount("CHF:1"), creditFee = TalerAmount("CHF:0.2"), subject = "SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G", @@ -367,7 +378,7 @@ class Iso20022Test { debtorPayto = ibanPayto("CH7389144832588726658", "Mr Test") ), IncomingPayment( - bankId = "7371795e-62fa-42dd-93b7-da89cc120faa", + bankId = IncomingId("7371795e-62fa-42dd-93b7-da89cc120faa", "41103099704.0003", "ZV20241104/765447/1"), amount = TalerAmount("CHF:1"), creditFee = TalerAmount("CHF:0.2"), subject = "Random subject", @@ -407,12 +418,20 @@ class Iso20022Test { creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), IncomingPayment( - bankId = "f203fbb4-6e13-4c78-9b2a-d852fea6374a", + bankId = IncomingId("f203fbb4-6e13-4c78-9b2a-d852fea6374a", "41202060702.0001", "ZV20241202/778108/1"), amount = TalerAmount("CHF:0.15"), creditFee = TalerAmount("CHF:0.2"), subject = "mini", executionTime = dateToInstant("2024-12-02"), debtorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") + ), + IncomingPayment( + bankId = IncomingId("81b0d8c6-a677-4577-b75e-a639dcc03681", "41120636093.0001", "ZV20241121/773118/1"), + amount = TalerAmount("CHF:0.1"), + creditFee = TalerAmount("CHF:0.2"), + subject = "small transfer test", + executionTime = dateToInstant("2024-11-21"), + debtorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ) ) )) diff --git a/nexus/src/test/kotlin/RegistrationTest.kt b/nexus/src/test/kotlin/RegistrationTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -27,6 +27,7 @@ import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* import java.nio.file.Files import java.time.Instant +import java.util.UUID import kotlin.io.path.* import kotlin.test.* @@ -103,7 +104,10 @@ class RegistrationTest { // Check incoming transactions val incoming_tx = this.serializable( """ - SELECT bank_id + SELECT + uetr + ,tx_id + ,acct_svcr_ref ,(amount).val as amount_val ,(amount).frac AS amount_frac ,(credit_fee).val AS credit_fee_val @@ -117,7 +121,11 @@ class RegistrationTest { ) { all { IncomingPayment( - bankId = it.getString("bank_id"), + bankId = IncomingId( + it.getObject("uetr") as UUID?, + it.getString("tx_id"), + it.getString("acct_svcr_ref"), + ), amount = it.getAmount("amount", this@check.bankCurrency), creditFee = it.getAmount("credit_fee", this@check.bankCurrency).notZeroOrNull(), subject = it.getString("subject"), @@ -327,7 +335,7 @@ class RegistrationTest { ), incoming = listOf( IncomingPayment( - bankId = "BYLADEM1WOR-G2910276709458A2", + bankId = IncomingId(null, "BYLADEM1WOR-G2910276709458A2", "2024041210041357000"), amount = TalerAmount("EUR:3"), subject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", executionTime = dateToInstant("2024-04-12"), @@ -429,7 +437,7 @@ class RegistrationTest { ), incoming = listOf( IncomingPayment( - bankId = "adbe4a5a-6cea-4263-b259-8ab964561a32", + bankId = IncomingId("adbe4a5a-6cea-4263-b259-8ab964561a32", "41103099704.0002", "ZV20241104/765446/1"), amount = TalerAmount("CHF:1"), creditFee = TalerAmount("CHF:0.2"), subject = "SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G", @@ -437,7 +445,7 @@ class RegistrationTest { debtorPayto = ibanPayto("CH7389144832588726658", "Mr Test") ), IncomingPayment( - bankId = "7371795e-62fa-42dd-93b7-da89cc120faa", + bankId = IncomingId("7371795e-62fa-42dd-93b7-da89cc120faa", "41103099704.0003", "ZV20241104/765447/1"), amount = TalerAmount("CHF:1"), creditFee = TalerAmount("CHF:0.2"), subject = "Random subject", @@ -445,12 +453,20 @@ class RegistrationTest { debtorPayto = ibanPayto("CH7389144832588726658", "Mr Test") ), IncomingPayment( - bankId = "f203fbb4-6e13-4c78-9b2a-d852fea6374a", + bankId = IncomingId("f203fbb4-6e13-4c78-9b2a-d852fea6374a", "41202060702.0001", "ZV20241202/778108/1"), amount = TalerAmount("CHF:0.15"), creditFee = TalerAmount("CHF:0.2"), subject = "mini", executionTime = dateToInstant("2024-12-02"), debtorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") + ), + IncomingPayment( + bankId = IncomingId("81b0d8c6-a677-4577-b75e-a639dcc03681", "41120636093.0001", "ZV20241121/773118/1"), + amount = TalerAmount("CHF:0.1"), + creditFee = TalerAmount("CHF:0.2"), + subject = "small transfer test", + executionTime = dateToInstant("2024-11-21"), + debtorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ) ), outgoing = listOf( diff --git a/nexus/src/test/kotlin/bench.kt b/nexus/src/test/kotlin/bench.kt @@ -99,16 +99,6 @@ class Bench { measureAction("register_kyc") { talerableKycIn(db) } - measureAction("register_reserve_missing_id") { - val incoming = genInPay("test with ${ShortHashCode.rand()} reserve pub") - registerIncomingPayment(db, ingestCfg, incoming.copy(bankId = null)) - registerIncomingPayment(db, ingestCfg, incoming) - } - measureAction("register_kyc_missing_id") { - val incoming = genInPay("test with KYC:${ShortHashCode.rand()} account pub") - registerIncomingPayment(db, ingestCfg, incoming.copy(bankId = null)) - registerIncomingPayment(db, ingestCfg, incoming) - } // Revenue API measureAction("transaction_revenue") { diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -106,7 +106,7 @@ fun genInPay( debtorPayto = ibanPayto("DE84500105177118117964"), subject = subject, executionTime = executionTime, - bankId = randEbicsId(), + bankId = IncomingId(null, randEbicsId(), null) ) /** Generates an outgoing payment, given its subject and end-to-end ID */ @@ -171,7 +171,7 @@ suspend fun talerableIn(db: Database, nullId: Boolean = false, amount: String = registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), genInPay("test with $reserve_pub reserve pub", amount).run { if (nullId) { - copy(bankId = null) + this//copy(bankId = null) } else { this } @@ -185,7 +185,7 @@ suspend fun talerableKycIn(db: Database, nullId: Boolean = false, amount: String registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), genInPay("test with KYC:$account_pub account pub", amount).run { if (nullId) { - copy(bankId = null) + this//copy(bankId = null) } else { this } diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -172,7 +172,7 @@ class IntegrationTest { debtorPayto = userPayTo, subject = "Error test $reservePub", executionTime = Instant.now(), - bankId = "reserve_error" + bankId = IncomingId(null, "reserve_error", null) ) assertException("ERROR: cashin failed: missing exchange account") { @@ -183,7 +183,7 @@ class IntegrationTest { registerIncomingPayment( db, cfg, reservePayment.copy( - bankId = "kyc", + bankId = IncomingId(null, "kyc", null), subject = "Error test KYC:${EddsaPublicKey.randEdsaKey()}" ) ) @@ -234,7 +234,7 @@ class IntegrationTest { // Check success val validPayment = reservePayment.copy( subject = "Success $reservePub", - bankId = "success" + bankId = IncomingId(null, "success", null), ) registerIncomingPayment(db, cfg, validPayment) db.checkCount(3, 1, 2)