libeufin

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

commit 916892f6ee1972adbfb96150fdc229e4df29d32f
parent c9a01d25d7df262f59fb63bf2dab328f7d7885b6
Author: Antoine A <>
Date:   Tue,  4 Mar 2025 11:42:10 +0100

nexus: use two ids for incoming transactions

Diffstat:
Mdatabase-versioning/libeufin-nexus-0010.sql | 9---------
Mdatabase-versioning/libeufin-nexus-0011.sql | 12++++++++++--
Mdatabase-versioning/libeufin-nexus-procedures.sql | 7+++++--
Mnexus/sample/platform/maerki_baumann_camt053.xml | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt | 11+++++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt | 25++++++++++++++++++-------
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 11++++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt | 224+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mnexus/src/test/kotlin/CliTest.kt | 1+
Mnexus/src/test/kotlin/DatabaseTest.kt | 18+++++++++++++-----
Mnexus/src/test/kotlin/Iso20022Test.kt | 52+++++++++++++++++++++++++++-------------------------
Mnexus/src/test/kotlin/RegistrationTest.kt | 53++++++++++++++++++++++++++++++++++-------------------
Mnexus/src/test/kotlin/bench.kt | 9+++++++++
Mnexus/src/test/kotlin/helpers.kt | 10++++++++--
14 files changed, 378 insertions(+), 164 deletions(-)

diff --git a/database-versioning/libeufin-nexus-0010.sql b/database-versioning/libeufin-nexus-0010.sql @@ -19,15 +19,6 @@ SELECT _v.register_patch('libeufin-nexus-0010', NULL, NULL); SET search_path TO libeufin_nexus; -ALTER TABLE incoming_transactions - ALTER COLUMN subject DROP NOT NULL, - ALTER COLUMN debit_payto DROP NOT NULL; - -ALTER TABLE outgoing_transactions - ALTER COLUMN end_to_end_id DROP NOT NULL; - --- 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 @@ -19,12 +19,20 @@ SELECT _v.register_patch('libeufin-nexus-0011', NULL, NULL); SET search_path TO libeufin_nexus; --- Better polymorphism schema +-- TODO migrate existing ids + +ALTER TABLE outgoing_transactions + ADD COLUMN acct_svcr_ref TEXT UNIQUE, + ALTER COLUMN end_to_end_id DROP NOT NULL, + ADD CONSTRAINT unique_id CHECK(COALESCE(end_to_end_id, acct_svcr_ref) IS NOT NULL); + 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); + ALTER COLUMN subject DROP NOT NULL, + ALTER COLUMN debit_payto DROP NOT NULL, + ADD CONSTRAINT unique_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 @@ -78,9 +78,10 @@ CREATE FUNCTION register_outgoing( ,IN in_execution_time INT8 ,IN in_credit_payto TEXT ,IN in_end_to_end_id TEXT + ,IN in_msg_id TEXT + ,IN in_acct_svcr_ref TEXT ,IN in_wtid BYTEA ,IN in_exchange_url TEXT - ,IN in_msg_id TEXT ,OUT out_tx_id INT8 ,OUT out_found BOOLEAN ,OUT out_initiated BOOLEAN @@ -101,7 +102,7 @@ SELECT outgoing_transaction_id, subject, credit_payto, (amount).val, (amount).fr INTO out_tx_id, local_subject, local_credit_payto, local_amount.val, local_amount.frac, local_wtid, local_exchange_base_url FROM outgoing_transactions LEFT JOIN talerable_outgoing_transactions USING (outgoing_transaction_id) - WHERE end_to_end_id = in_end_to_end_id; + WHERE end_to_end_id = in_end_to_end_id OR acct_svcr_ref = in_acct_svcr_ref; out_found=FOUND; IF out_found THEN -- Check metadata @@ -159,12 +160,14 @@ IF NOT out_found THEN ,execution_time ,credit_payto ,end_to_end_id + ,acct_svcr_ref ) VALUES ( in_amount ,in_subject ,in_execution_time ,in_credit_payto ,in_end_to_end_id + ,in_acct_svcr_ref ) RETURNING outgoing_transaction_id INTO out_tx_id; diff --git a/nexus/sample/platform/maerki_baumann_camt053.xml b/nexus/sample/platform/maerki_baumann_camt053.xml @@ -621,6 +621,106 @@ </NtryDtls> <AddtlNtryInf>Bank clearing payment Grothoff Hans</AddtlNtryInf> </Ntry> + <Ntry> + <Amt Ccy="CHF">3000</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts> + <Cd>BOOK</Cd> + </Sts> + <BookgDt> + <Dt>2024-12-20</Dt> + </BookgDt> + <ValDt> + <Dt>2024-12-20</Dt> + </ValDt> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>OTHR</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">3000</TtlAmt> + <CdtDbtInd>DBIT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <AcctSvcrRef>GB20241220/205792/1</AcctSvcrRef> + <InstrId>NOTPROVIDED</InstrId> + <EndToEndId>NOTPROVIDED</EndToEndId> + </Refs> + <Amt Ccy="CHF">3000</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">3000</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">3000</Amt> + </TxAmt> + </AmtDtls> + <AddtlTxInf>all-in one fee</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>all-in one fee</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="CHF">3003</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts> + <Cd>BOOK</Cd> + </Sts> + <BookgDt> + <Dt>2025-01-27</Dt> + </BookgDt> + <ValDt> + <Dt>2025-01-27</Dt> + </ValDt> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>OTHR</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">3003</TtlAmt> + <CdtDbtInd>CRDT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <AcctSvcrRef>ZV20250114/796191/1</AcctSvcrRef> + <EndToEndId>NOTPROVIDED</EndToEndId> + </Refs> + <Amt Ccy="CHF">3003</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">3003</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">3003</Amt> + </TxAmt> + </AmtDtls> + <RmtInf> + <Ustrd>Fix bad payment by MB.</Ustrd> + </RmtInf> + <AddtlTxInf>Transfer Taler Operations AG</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Transfer Taler Operations AG</AddtlNtryInf> + </Ntry> </Stmt> </BkToCstmrStmt> </Document> \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.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 @@ -22,7 +22,7 @@ package tech.libeufin.nexus.db import tech.libeufin.common.asInstant import tech.libeufin.common.db.* import tech.libeufin.common.micros -import tech.libeufin.nexus.iso20022.OutgoingPayment +import tech.libeufin.nexus.iso20022.* import java.time.Instant /** Data access logic for initiated outgoing payments */ @@ -288,8 +288,11 @@ class InitiatedDAO(private val db: Database) { setString(1, msgId) all { OutgoingPayment( - endToEndId = it.getString("end_to_end_id"), - msgId = msgId, + id = OutgoingId( + msgId = msgId, + endToEndId = it.getString("end_to_end_id"), + acctSvcrRef = null + ), amount = it.getAmount("amount", db.bankCurrency), subject = it.getString("subject"), executionTime = executionTime, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt @@ -21,10 +21,10 @@ package tech.libeufin.nexus.db import tech.libeufin.common.* import tech.libeufin.common.db.* -import tech.libeufin.nexus.iso20022.IncomingPayment -import tech.libeufin.nexus.iso20022.OutgoingPayment +import tech.libeufin.nexus.iso20022.* import java.sql.Types import java.time.Instant +import java.util.UUID /** Data access logic for metadata listing */ class ListDAO(private val db: Database) { @@ -40,9 +40,11 @@ class ListDAO(private val db: Database) { ,end_to_end_id AS bounced ,execution_time ,debit_payto - ,COALESCE(uetr::text, tx_id, acct_svcr_ref) as bank_id ,type ,metadata + ,uetr + ,tx_id + ,acct_svcr_ref FROM incoming_transactions AS incoming LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) LEFT JOIN bounced_transactions USING (incoming_transaction_id) @@ -53,12 +55,16 @@ class ListDAO(private val db: Database) { all { val type = it.getOptEnum<IncomingType>("type") IncomingTxMetadata( + id = IncomingId( + it.getObject("uetr") as UUID?, + it.getString("tx_id"), + it.getString("acct_svcr_ref"), + ), date = it.getLong("execution_time").asInstant(), amount = it.getDecimal("amount"), creditFee = it.getDecimal("credit_fee"), subject = it.getString("subject"), debtor = it.getString("debit_payto"), - id = it.getString("bank_id"), bounced = it.getString("bounced"), talerable = when (type) { null -> null @@ -80,6 +86,7 @@ class ListDAO(private val db: Database) { ,execution_time ,credit_payto ,end_to_end_id + ,acct_svcr_ref ,wtid ,exchange_base_url FROM outgoing_transactions @@ -89,11 +96,15 @@ class ListDAO(private val db: Database) { ) { all { OutgoingTxMetadata( + id = OutgoingId( + msgId = null, + endToEndId = it.getString("end_to_end_id"), + acctSvcrRef = it.getString("acct_svcr_ref"), + ), date = it.getLong("execution_time").asInstant(), amount = it.getDecimal("amount"), subject = it.getString("subject"), creditor = it.getString("credit_payto"), - id = it.getString("end_to_end_id"), wtid = it.getBytes("wtid")?.run { ShortHashCode(this) }, exchangeBaseUrl = it.getString("exchange_base_url") ) @@ -139,23 +150,23 @@ class ListDAO(private val db: Database) { /** Incoming transaction metadata for debugging */ data class IncomingTxMetadata( + val id: IncomingId, val date: Instant, val amount: DecimalNumber, val creditFee: DecimalNumber, val subject: String?, val debtor: String?, - val id: String?, val talerable: String?, val bounced: String? ) /** Outgoing transaction metadata for debugging */ data class OutgoingTxMetadata( + val id: OutgoingId, val date: Instant, val amount: DecimalNumber, val subject: String?, val creditor: String?, - val id: String, val wtid: ShortHashCode?, val exchangeBaseUrl: String? ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -43,7 +43,7 @@ class PaymentDAO(private val db: Database) { ): OutgoingRegistrationResult = db.serializable( """ SELECT out_tx_id, out_initiated, out_found - FROM register_outgoing((?,?)::taler_amount,?,?,?,?,?,?,?) + FROM register_outgoing((?,?)::taler_amount,?,?,?,?,?,?,?,?) """ ) { val executionTime = payment.executionTime.micros() @@ -53,10 +53,11 @@ class PaymentDAO(private val db: Database) { setString(3, payment.subject) setLong(4, executionTime) setString(5, payment.creditorPayto?.toString()) - setString(6, payment.endToEndId) - setBytes(7, wtid?.raw) - setString(8, baseUrl?.url) - setString(9, payment.msgId) + setString(6, payment.id.endToEndId) + setString(7, payment.id.msgId) + setString(8, payment.id.acctSvcrRef) + setBytes(9, wtid?.raw) + setString(10, baseUrl?.url) one { OutgoingRegistrationResult( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -31,13 +31,17 @@ sealed interface TxNotification { val executionTime: Instant } -/** Unique ID provided by banks */ +/** ID for incoming transactions */ data class IncomingId( + /** ISO20022 UETR */ val uetr: UUID? = null, + /** ISO20022 TxID */ val txId: String? = null, + /** ISO20022 AcctSvcrRef */ 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 { @@ -60,6 +64,77 @@ data class IncomingId( } } +sealed interface OutId {} + +/** ID for outgoing transactions */ +data class OutgoingId( + /** + * Unique msg ID generated by libeufin-nexus + * ISO20022 MessageId + **/ + val msgId: String? = null, + /** + * Unique end-to-end ID generated by libeufin-nexus + * ISO20022 EndToEndId or MessageId (retrocompatibility) + **/ + val endToEndId: String? = null, + /** + * Unique end-to-end ID generated by the bank + * ISO20022 AcctSvcrRef + **/ + val acctSvcrRef: String? = null, +): OutId { + fun ref(): String = endToEndId ?: acctSvcrRef ?: msgId!! + override fun toString(): String = buildString { + append('(') + if (msgId != null && msgId != endToEndId) { + append("msg=") + append(msgId.toString()) + } + if (endToEndId != null) { + if (length != 1) append(", ") + append("e2e=") + append(endToEndId) + } + if (acctSvcrRef != null) { + if (length != 1) append(", ") + append("ref=") + append(acctSvcrRef) + } + append(')') + } +} + +/** ID for outgoing batches */ +data class BatchId( + /** + * Unique msg ID generated by libeufin-nexus + * ISO20022 MessageId + **/ + val msgId: String, + /** + * Unique end-to-end ID generated by the bank + * ISO20022 AcctSvcrRef + **/ + val acctSvcrRef: String? = null, +): OutId { + fun ref(): String = msgId + override fun toString(): String = buildString { + append('(') + if (msgId != null) { + append("msg=") + append(msgId.toString()) + } + if (acctSvcrRef != null) { + if (length != 1) append(", ") + append("ref=") + append(acctSvcrRef) + } + append(')') + } +} + + /** ISO20022 incoming payment */ data class IncomingPayment( val bankId: IncomingId, @@ -94,10 +169,7 @@ data class IncomingPayment( /** ISO20022 outgoing payment */ data class OutgoingPayment( - /** ISO20022 EndToEndId or MessageId (retrocompatibility) */ - val endToEndId: String, - /** ISO20022 MessageId */ - val msgId: String? = null, + val id: OutgoingId, val amount: TalerAmount, val subject: String?, override val executionTime: Instant, @@ -109,11 +181,7 @@ data class OutgoingPayment( append(" ") append(amount) append(" ") - if (msgId!=null) { - append(msgId) - append(".") - } - append(endToEndId) + append(id) if (creditorPayto != null) { append(" creditor=") append(creditorPayto) @@ -159,16 +227,6 @@ private enum class Kind { DBIT } -/** Unique ID generated by libeufin-nexus */ -data class OutgoingId( - // Unique msg ID generated by libeufin-nexus - val msgId: String?, - // Unique end-to-end ID generated by libeufin-nexus - val endToEndId: String? -) { - fun ref(): String? = endToEndId ?: msgId -} - /** Parse a payto */ private fun XmlDestructor.payto(prefix: String): IbanPayto? { return opt("RltdPties") { @@ -205,26 +263,30 @@ private fun XmlDestructor.executionDate(): Instant { } /** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */ -private fun XmlDestructor.outgoingId(): OutgoingId = one("Refs") { - val endToEndId = opt("EndToEndId")?.text() - val msgId = opt("MsgId")?.text() - if (endToEndId == null) { - // This is a batch representation - OutgoingId(msgId, null) - } else if (endToEndId == "NOTPROVIDED") { - // If not set use MsgId as end-to-end ID for retrocompatibility - OutgoingId(msgId, msgId) - } else { - OutgoingId(msgId, endToEndId) - } -} +private fun XmlDestructor.outgoingId(ref: String?): OutId = + opt("Refs") { + val endToEndId = opt("EndToEndId")?.text() + val msgId = opt("MsgId")?.text() + val ref = if (ref != "NOTPROVIDED") ref else null + if (msgId != null && endToEndId == null) { + // This is a batch representation + BatchId(msgId, ref) + } else if (endToEndId == "NOTPROVIDED") { + // If not set use MsgId as end-to-end ID for retrocompatibility + OutgoingId(msgId, msgId, ref) + } else { + OutgoingId(msgId, endToEndId, ref) + } + } ?: OutgoingId(null, null, ref) /** 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) -} +private fun XmlDestructor.incomingId(ref: String?): IncomingId = + opt("Refs") { + val uetr = opt("UETR")?.uuid() + val txId = opt("TxId")?.text() + IncomingId(uetr, txId, ref) + } ?: IncomingId(null, null, ref) + /** Parse and format transaction return reasons */ private fun XmlDestructor.returnReason(): String = opt("RtrInf") { @@ -444,6 +506,7 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> */ logger.trace("Parse transactions camt file for $dialect") val accountTxs = mutableListOf<AccountTransactions>() + /** Common parsing logic for camt.052, camt.053 and camt.054 */ fun XmlDestructor.parseInner() { val (iban, currency) = account() @@ -497,12 +560,11 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> } if (code.isReversal() || reversal) { - val outgoingId = outgoingId() + val outgoingId = outgoingId(ref) when (kind) { Kind.CRDT -> { val reason = returnReason() txInfos.add(TxInfo.CreditReversal( - ref = ref, bookDate = bookDate, id = outgoingId, reason = reason, @@ -517,7 +579,6 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> 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(), @@ -535,7 +596,6 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> val id = incomingId(ref) val debtorPayto = payto("Dbtr") txInfos.add(TxInfo.Credit( - ref = ref, bookDate = bookDate, bankId = id, amount = amount.amount(), @@ -546,11 +606,10 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> )) } Kind.DBIT -> { - val outgoingId = outgoingId() + val outgoingId = outgoingId(ref) 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(), @@ -598,36 +657,25 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> } sealed interface TxInfo { - // Bank provider ref for debugging - val ref: String? - // When was this transaction booked - val bookDate: Instant - // ISO20022 bank transaction code - val code: BankTransactionCode data class CreditReversal( - override val ref: String?, - override val bookDate: Instant, - override val code: BankTransactionCode, - // Unique ID generated by libeufin-nexus - val id: OutgoingId, + val bookDate: Instant, + val code: BankTransactionCode, + val id: OutId, val reason: String ): TxInfo data class Credit( - override val ref: String?, - override val bookDate: Instant, - override val code: BankTransactionCode, - val bankId: IncomingId?, + val bookDate: Instant, + val code: BankTransactionCode, + val bankId: IncomingId, val amount: TalerAmount, val creditFee: TalerAmount?, val subject: String?, val debtorPayto: IbanPayto? ): TxInfo data class Debit( - override val ref: String?, - override val bookDate: Instant, - override val code: BankTransactionCode, - // Unique ID generated by libeufin-nexus - val id: OutgoingId, + val bookDate: Instant, + val code: BankTransactionCode, + val id: OutId, val amount: TalerAmount, val subject: String?, val creditorPayto: IbanPayto? @@ -636,8 +684,8 @@ sealed interface TxInfo { fun parse(): TxNotification { return when (this) { is TxInfo.CreditReversal -> { - if (id.endToEndId == null) - throw IncompleteTx("missing end-to-end ID for Credit reversal $ref") + if (id !is OutgoingId || id.endToEndId == null) + throw IncompleteTx("missing unique ID for Credit reversal $id") OutgoingReversal( endToEndId = id.endToEndId!!, msgId = id.msgId, @@ -646,8 +694,8 @@ sealed interface TxInfo { ) } is TxInfo.Credit -> { - if (bankId == null) - throw IncompleteTx("missing unique ID for Credit $ref") + if (bankId.uetr == null && bankId.txId == null && bankId.acctSvcrRef == null) + throw IncompleteTx("missing unique ID for Credit $bankId") IncomingPayment( amount = amount, creditFee = creditFee, @@ -658,22 +706,30 @@ sealed interface TxInfo { ) } is TxInfo.Debit -> { - if (id.endToEndId == null && id.msgId == null) { - throw IncompleteTx("missing end-to-end ID for Debit $ref") - } else if (id.endToEndId != null) { - OutgoingPayment( - amount = amount, - endToEndId = id.endToEndId, - msgId = id.msgId, - executionTime = bookDate, - creditorPayto = creditorPayto, - subject = subject - ) - } else { - OutgoingBatch( - msgId = id.msgId!!, - executionTime = bookDate, - ) + when (id) { + is OutgoingId -> { + if (id.endToEndId == null && id.msgId == null && id.acctSvcrRef == null) { + throw IncompleteTx("missing unique ID for Debit $id") + } else { + OutgoingPayment( + id = OutgoingId( + endToEndId = id.endToEndId, + acctSvcrRef = id.acctSvcrRef, + msgId = id.msgId, + ), + amount = amount, + executionTime = bookDate, + creditorPayto = creditorPayto, + subject = subject + ) + } + } + is BatchId -> { + OutgoingBatch( + msgId = id.msgId, + executionTime = bookDate, + ) + } } } } diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -168,6 +168,7 @@ class CliTest { // Check with incomplete registerIncompleteIn(db) talerableIncompleteIn(db) + registerIncompleteOut(db) check() } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -62,7 +62,7 @@ suspend fun Database.checkOutCount(nbIncoming: Int, nbTalerable: Int) = serializ class OutgoingPaymentsTest { @Test - fun registerTx() = setup { db, _ -> + fun register() = setup { db, _ -> // Register initiated transaction for (subject in sequenceOf( "initiated by nexus", @@ -70,7 +70,7 @@ class OutgoingPaymentsTest { )) { val pay = genOutPay(subject) assertIs<PaymentInitiationResult.Success>( - db.initiated.create(genInitPay(pay.endToEndId, subject)) + db.initiated.create(genInitPay(pay.id.endToEndId!!, subject)) ) val first = registerOutgoingPayment(db, pay) assertEquals(OutgoingRegistrationResult(id = first.id, initiated = true, new = true), first) @@ -78,8 +78,16 @@ class OutgoingPaymentsTest { OutgoingRegistrationResult(id = first.id, initiated = true, new = false), registerOutgoingPayment(db, pay) ) + + val refOnly = pay.copy(id = OutgoingId(null, null, acctSvcrRef = pay.id.endToEndId)) + val second = registerOutgoingPayment(db, refOnly) + assertEquals(OutgoingRegistrationResult(id = first.id + 1, initiated = false, new = true), second) + assertEquals( + OutgoingRegistrationResult(id = second.id, initiated = false, new = false), + registerOutgoingPayment(db, refOnly) + ) } - db.checkOutCount(nbIncoming = 2, nbTalerable = 1) + db.checkOutCount(nbIncoming = 4, nbTalerable = 1) // Register unknown for (subject in sequenceOf( @@ -94,7 +102,7 @@ class OutgoingPaymentsTest { registerOutgoingPayment(db, pay) ) } - db.checkOutCount(nbIncoming = 4, nbTalerable = 2) + db.checkOutCount(nbIncoming = 6, nbTalerable = 2) // Register wtid reuse val wtid = ShortHashCode.rand() @@ -110,7 +118,7 @@ class OutgoingPaymentsTest { db.payment.registerOutgoing(pay, null, null) ) } - db.checkOutCount(nbIncoming = 6, nbTalerable = 3) + db.checkOutCount(nbIncoming = 8, nbTalerable = 3) } @Test diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -146,8 +146,7 @@ class Iso20022Test { currency = "CHF", txs = listOf( OutgoingPayment( - endToEndId = "ZS1PGNTSV0ZNDFAJBBWWB8015G", - msgId = "ZS1PGNTSV0ZNDFAJBBWWB8015G", + id = OutgoingId("ZS1PGNTSV0ZNDFAJBBWWB8015G", "ZS1PGNTSV0ZNDFAJBBWWB8015G", null), amount = TalerAmount("CHF:3.00"), subject = null, executionTime = dateToInstant("2024-01-15"), @@ -220,8 +219,7 @@ class Iso20022Test { currency = "EUR", txs = listOf( OutgoingPayment( - endToEndId = "COMPAT_SUCCESS", - msgId = "COMPAT_SUCCESS", + id = OutgoingId("COMPAT_SUCCESS", "COMPAT_SUCCESS", "2024041801514102000"), amount = TalerAmount("EUR:2"), subject = "TestABC123", executionTime = dateToInstant("2024-04-18"), @@ -245,16 +243,14 @@ class Iso20022Test { executionTime = dateToInstant("2024-04-12") ), OutgoingPayment( - endToEndId = "FD622SMXKT5QWSAHDY0H8NYG3G", - msgId = "BATCH_SINGLE_SUCCESS", + id = OutgoingId("BATCH_SINGLE_SUCCESS", "FD622SMXKT5QWSAHDY0H8NYG3G", "2024090216552232000"), amount = TalerAmount("EUR:1.1"), subject = "single 2024-09-02T14:29:52.875253314Z", executionTime = dateToInstant("2024-09-02"), creditorPayto = ibanPayto("DE89500105173198527518", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "YF5QBARGQ0MNY0VK59S477VDG4", - msgId = "YF5QBARGQ0MNY0VK59S477VDG4", + id = OutgoingId("YF5QBARGQ0MNY0VK59S477VDG4", "YF5QBARGQ0MNY0VK59S477VDG4", "2024041810552821000"), amount = TalerAmount("EUR:1.1"), subject = "Simple tx", executionTime = dateToInstant("2024-04-18"), @@ -265,8 +261,7 @@ class Iso20022Test { executionTime = dateToInstant("2024-09-20"), ), OutgoingPayment( - endToEndId = "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", - msgId = "BATCH_SINGLE_RETURN", + id = OutgoingId("BATCH_SINGLE_RETURN", "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", "2024092100252498000"), amount = TalerAmount("EUR:0.42"), subject = "This should fail because bad iban", executionTime = dateToInstant("2024-09-23"), @@ -291,8 +286,7 @@ class Iso20022Test { currency = "EUR", txs = listOf( OutgoingPayment( - endToEndId = "COMPAT_SUCCESS", - msgId = "COMPAT_SUCCESS", + id = OutgoingId("COMPAT_SUCCESS", "COMPAT_SUCCESS", "2024041801514102000"), amount = TalerAmount("EUR:2"), subject = "TestABC123", executionTime = dateToInstant("2024-04-18"), @@ -320,16 +314,14 @@ class Iso20022Test { executionTime = dateToInstant("2024-04-12") ), OutgoingPayment( - endToEndId = "FD622SMXKT5QWSAHDY0H8NYG3G", - msgId = "BATCH_SINGLE_SUCCESS", + id = OutgoingId("BATCH_SINGLE_SUCCESS", "FD622SMXKT5QWSAHDY0H8NYG3G", "2024090216552232000"), amount = TalerAmount("EUR:1.1"), subject = "single 2024-09-02T14:29:52.875253314Z", executionTime = dateToInstant("2024-09-02"), creditorPayto = ibanPayto("DE89500105173198527518", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "YF5QBARGQ0MNY0VK59S477VDG4", - msgId = "YF5QBARGQ0MNY0VK59S477VDG4", + id = OutgoingId("YF5QBARGQ0MNY0VK59S477VDG4", "YF5QBARGQ0MNY0VK59S477VDG4", "2024041810552821000"), amount = TalerAmount("EUR:1.1"), subject = "Simple tx", executionTime = dateToInstant("2024-04-18"), @@ -386,32 +378,28 @@ class Iso20022Test { debtorPayto = ibanPayto("CH7389144832588726658", "Mr Test") ), OutgoingPayment( - endToEndId = "5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", - msgId = "BATCH_SINGLE_REPORTING", + id = OutgoingId("BATCH_SINGLE_REPORTING", "5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", "ZV20241121/773541/1"), amount = TalerAmount("CHF:0.1"), subject = "multi 0 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", - msgId = "BATCH_SINGLE_REPORTING", + id = OutgoingId("BATCH_SINGLE_REPORTING", "XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", "ZV20241121/773541/4"), amount = TalerAmount("CHF:0.13"), subject = "multi 3 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "A09R35EW0359SZ51464E7TC37A0P2CBK04", - msgId = "BATCH_SINGLE_REPORTING", + id = OutgoingId("BATCH_SINGLE_REPORTING", "A09R35EW0359SZ51464E7TC37A0P2CBK04", "ZV20241121/773541/3"), amount = TalerAmount("CHF:0.12"), subject = "multi 2 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", - msgId = "BATCH_SINGLE_REPORTING", + id = OutgoingId("BATCH_SINGLE_REPORTING", "UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", "ZV20241121/773541/2"), amount = TalerAmount("CHF:0.11"), subject = "multi 1 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), @@ -432,7 +420,21 @@ class Iso20022Test { subject = "small transfer test", executionTime = dateToInstant("2024-11-21"), debtorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") - ) + ), + OutgoingPayment( + id = OutgoingId(null, null, "GB20241220/205792/1"), + amount = TalerAmount("CHF:3000"), + subject = null, + executionTime = dateToInstant("2024-12-20"), + creditorPayto = null + ), + IncomingPayment( + bankId = IncomingId(null, null, "ZV20250114/796191/1"), + amount = TalerAmount("CHF:3003"), + subject = "Fix bad payment by MB.", + executionTime = dateToInstant("2025-01-27"), + debtorPayto = null + ), ) )) ) diff --git a/nexus/src/test/kotlin/RegistrationTest.kt b/nexus/src/test/kotlin/RegistrationTest.kt @@ -130,7 +130,7 @@ class RegistrationTest { creditFee = it.getAmount("credit_fee", this@check.bankCurrency).notZeroOrNull(), subject = it.getString("subject"), executionTime = it.getLong("execution_time").asInstant(), - debtorPayto = it.getIbanPayto("debit_payto"), + debtorPayto = it.getOptIbanPayto("debit_payto"), ) } } @@ -140,6 +140,7 @@ class RegistrationTest { val outgoing_tx = this.serializable( """ SELECT end_to_end_id + ,acct_svcr_ref ,(amount).val as amount_val ,(amount).frac AS amount_frac ,subject @@ -149,13 +150,13 @@ class RegistrationTest { ORDER BY outgoing_transaction_id """ ) { - all { + all { OutgoingPayment( - endToEndId = it.getString("end_to_end_id"), + id = OutgoingId(null, it.getString("end_to_end_id"), it.getString("acct_svcr_ref")), amount = it.getAmount("amount", this@check.bankCurrency), subject = it.getString("subject"), executionTime = it.getLong("execution_time").asInstant(), - creditorPayto = it.getIbanPayto("credit_payto"), + creditorPayto = it.getOptIbanPayto("credit_payto"), ) } } @@ -344,63 +345,63 @@ class RegistrationTest { ), outgoing = listOf( OutgoingPayment( - endToEndId = "COMPAT_SUCCESS", + id = OutgoingId(null, "COMPAT_SUCCESS", "2024041801514102000"), amount = TalerAmount("EUR:2"), subject = "TestABC123", executionTime = dateToInstant("2024-04-18"), creditorPayto = ibanPayto("DE20500105172419259181", "John Smith") ), OutgoingPayment( - endToEndId = "FD622SMXKT5QWSAHDY0H8NYG3G", + id = OutgoingId(null, "FD622SMXKT5QWSAHDY0H8NYG3G", "2024090216552232000"), amount = TalerAmount("EUR:1.1"), subject = "single 2024-09-02T14:29:52.875253314Z", executionTime = dateToInstant("2024-09-02"), creditorPayto = ibanPayto("DE89500105173198527518", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "YF5QBARGQ0MNY0VK59S477VDG4", + id = OutgoingId(null, "YF5QBARGQ0MNY0VK59S477VDG4", "2024041810552821000"), amount = TalerAmount("EUR:1.1"), subject = "Simple tx", executionTime = dateToInstant("2024-04-18"), creditorPayto = ibanPayto("DE20500105172419259181", "John Smith") ), OutgoingPayment( - endToEndId = "IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V", + id = OutgoingId(null, "IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V", null), amount = TalerAmount("EUR:44"), subject = "init payment", executionTime = dateToInstant("2024-09-20"), creditorPayto = ibanPayto("CH4189144589712575493", "Test") ), OutgoingPayment( - endToEndId = "CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW", + id = OutgoingId(null, "CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW", null), amount = TalerAmount("EUR:44"), subject = "init payment", executionTime = dateToInstant("2024-09-20"), creditorPayto = ibanPayto("CH4189144589712575493", "Test") ), OutgoingPayment( - endToEndId = "35M1268GW5ZFHS5JCB41UKDQNPMD40T849", + id = OutgoingId(null, "35M1268GW5ZFHS5JCB41UKDQNPMD40T849", null), amount = TalerAmount("EUR:44"), subject = "init payment", executionTime = dateToInstant("2024-09-20"), creditorPayto = ibanPayto("CH4189144589712575493", "Test") ), OutgoingPayment( - endToEndId = "HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1", + id = OutgoingId(null, "HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1", null), amount = TalerAmount("EUR:44"), subject = "init payment", executionTime = dateToInstant("2024-09-20"), creditorPayto = ibanPayto("CH4189144589712575493", "Test") ), OutgoingPayment( - endToEndId = "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", + id = OutgoingId(null, "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", "2024092100252498000"), amount = TalerAmount("EUR:0.42"), subject = "This should fail because bad iban", executionTime = dateToInstant("2024-09-23"), creditorPayto = ibanPayto("DE18500105173385245163", "John Smith") ), OutgoingPayment( - endToEndId = "27SK3166EG36SJ7VP7VFYP0MW8", + id = OutgoingId(null, "27SK3166EG36SJ7VP7VFYP0MW8", null), amount = TalerAmount("EUR:44"), subject = "init payment", executionTime = dateToInstant("2024-09-04"), @@ -467,37 +468,51 @@ class RegistrationTest { subject = "small transfer test", executionTime = dateToInstant("2024-11-21"), debtorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") - ) + ), + IncomingPayment( + bankId = IncomingId(null, null, "ZV20250114/796191/1"), + amount = TalerAmount("CHF:3003"), + subject = "Fix bad payment by MB.", + executionTime = dateToInstant("2025-01-27"), + debtorPayto = null + ), ), outgoing = listOf( OutgoingPayment( - endToEndId = "5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", + id = OutgoingId(null, "5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", "ZV20241121/773541/1"), amount = TalerAmount("CHF:0.1"), subject = "multi 0 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", + id = OutgoingId(null, "XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", "ZV20241121/773541/4"), amount = TalerAmount("CHF:0.13"), subject = "multi 3 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "A09R35EW0359SZ51464E7TC37A0P2CBK04", + id = OutgoingId(null, "A09R35EW0359SZ51464E7TC37A0P2CBK04", "ZV20241121/773541/3"), amount = TalerAmount("CHF:0.12"), subject = "multi 2 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") ), OutgoingPayment( - endToEndId = "UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", + id = OutgoingId(null, "UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", "ZV20241121/773541/2"), amount = TalerAmount("CHF:0.11"), subject = "multi 1 2024-11-21T15:21:59.8859234 63Z", executionTime = dateToInstant("2024-11-27"), creditorPayto = ibanPayto("CH7389144832588726658", "Grothoff Hans") - ) + ), + OutgoingPayment( + id = OutgoingId(null, null, "GB20241220/205792/1"), + amount = TalerAmount("CHF:3000"), + subject = null, + executionTime = dateToInstant("2024-12-20"), + creditorPayto = null + ), ) ) } diff --git a/nexus/src/test/kotlin/bench.kt b/nexus/src/test/kotlin/bench.kt @@ -90,9 +90,18 @@ class Bench { measureAction("register_in") { registerIn(db) } + measureAction("register_incomplete_in") { + registerIncompleteIn(db) + } + measureAction("register_completed_in") { + registerCompletedIn(db) + } measureAction("register_out") { registerOut(db) } + measureAction("register_incomplete_out") { + registerIncompleteOut(db) + } measureAction("register_reserve") { talerableIn(db) } diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -116,12 +116,11 @@ fun genOutPay( msgId: String? = null, executionTime: Instant = Instant.now() ) = OutgoingPayment( + id = OutgoingId(msgId, endToEndId ?: randEbicsId(), null), amount = TalerAmount(44, 0, "KUDOS"), creditorPayto = ibanPayto("CH4189144589712575493", "Test"), subject = subject, executionTime = executionTime, - endToEndId = endToEndId ?: randEbicsId(), - msgId = msgId ) /** Perform a taler outgoing transaction */ @@ -221,6 +220,13 @@ suspend fun registerOut(db: Database) { registerOutgoingPayment(db, genOutPay("ignored")) } +/** Register an incomplete outgoing transaction */ +suspend fun registerIncompleteOut(db: Database) { + val original = genOutPay("ignored") + val incomplete = original.copy(id = OutgoingId(null, null, original.id.endToEndId), creditorPayto = null) + registerOutgoingPayment(db, incomplete) +} + /* ----- Auth ----- */ /** Auto auth get request */