commit ede4361df87afd180e4856c29749b6da699d11a5 parent d499620973bca3310c544d835aa7b163eb1a29b4 Author: Antoine A <> Date: Thu, 13 Nov 2025 11:52:28 +0100 nexus: simplify and fix fee handling Diffstat:
16 files changed, 854 insertions(+), 138 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -92,7 +92,8 @@ class ExchangeDAO(private val db: Database) { amount = it.getAmount("amount", db.bankCurrency), credit_account = it.getBankPayto("creditor_payto", "creditor_name", db.ctx), wtid = ShortHashCode(it.getBytes("wtid")), - exchange_base_url = it.getString("exchange_base_url") + exchange_base_url = it.getString("exchange_base_url"), + debit_fee = null ) } diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -244,7 +244,7 @@ class DecimalNumber { } @Serializable(with = TalerAmount.Serializer::class) -class TalerAmount { +class TalerAmount: Comparable<TalerAmount> { val value: Long val frac: Int val currency: String @@ -311,7 +311,7 @@ class TalerAmount { return TalerAmount(value, frac, currency) } - operator fun compareTo(other: TalerAmount) = compareValuesBy(this, other, { it.value }, { it.frac }) + override operator fun compareTo(other: TalerAmount) = compareValuesBy(this, other, { it.value }, { it.frac }) operator fun plus(increment: TalerAmount): TalerAmount { require(this.currency == increment.currency) { "currency mismatch ${this.currency} != ${increment.currency}" } diff --git a/common/src/main/kotlin/TalerMessage.kt b/common/src/main/kotlin/TalerMessage.kt @@ -176,6 +176,7 @@ data class OutgoingTransaction( val credit_account: String, val wtid: ShortHashCode, val exchange_base_url: String, + val debit_fee: TalerAmount? = null ) /** Response GET /taler-wire-gateway/account/check */ diff --git a/database-versioning/libeufin-nexus-0013.sql b/database-versioning/libeufin-nexus-0013.sql @@ -0,0 +1,23 @@ +-- +-- 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-0013', NULL, NULL); + +SET search_path TO libeufin_nexus; + +ALTER TABLE outgoing_transactions ADD COLUMN debit_fee taler_amount; +COMMIT; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -74,6 +74,7 @@ COMMENT ON FUNCTION amount_add CREATE FUNCTION register_outgoing( IN in_amount taler_amount + ,IN in_debit_fee taler_amount ,IN in_subject TEXT ,IN in_execution_time INT8 ,IN in_credit_payto TEXT @@ -156,6 +157,7 @@ IF NOT out_found THEN -- Store the transaction in the database INSERT INTO outgoing_transactions ( amount + ,debit_fee ,subject ,execution_time ,credit_payto @@ -163,6 +165,7 @@ IF NOT out_found THEN ,acct_svcr_ref ) VALUES ( in_amount + ,in_debit_fee ,in_subject ,in_execution_time ,in_credit_payto diff --git a/nexus/conf/valiant.conf b/nexus/conf/valiant.conf @@ -0,0 +1,19 @@ +[nexus-ebics] +CURRENCY = CHF + +HOST_BASE_URL = https://www.ebics.swisscom.com/ebics-server/ebics.aspx +BANK_DIALECT = valiant + +BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json + +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +IBAN = CH7389144832588726658 +BIC = BIC +NAME = myname + +[libeufin-nexusdb-postgres] +CONFIG = postgres:///libeufincheck +\ No newline at end of file diff --git a/nexus/sample/platform/valiant_camt052.xml b/nexus/sample/platform/valiant_camt052.xml @@ -4,7 +4,7 @@ <Rpt> <Acct> <Id> - <IBAN>CH9289144596463965762</IBAN> + <IBAN>CH7389144832588726658</IBAN> </Id> <Ccy>CHF</Ccy> </Acct> @@ -379,6 +379,462 @@ </NtryDtls> <AddtlNtryInf>Vergütung</AddtlNtryInf> </Ntry> + <Ntry> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2025-10-30</Dt> + </BookgDt> + <ValDt> + <Dt>2025-10-30</Dt> + </ValDt> + <AcctSvcrRef>ZV20251030/524078</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>OTHR</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">.21</TtlAmt> + <CdtDbtInd>DBIT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <MsgId>X166701F6RV59LP71RVWVIW9SV2AFZYLG4</MsgId> + <AcctSvcrRef>ZV20251030/524078/1</AcctSvcrRef> + <PmtInfId>NOTPROVIDED</PmtInfId> + <InstrId>R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX</InstrId> + <EndToEndId>R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX</EndToEndId> + </Refs> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.21</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.21</Amt> + </TxAmt> + </AmtDtls> + <RltdPties> + <Cdtr> + <Nm>John Smith</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>CH6208704048981247126</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RltdAgts> + <CdtrAgt> + <FinInstnId> + <Nm>AEK BANK 1826 Genossenschaft</Nm> + <PstlAdr> + <AdrLine>Hofstettenstrasse 2</AdrLine> + <AdrLine>3601 Thun</AdrLine> + </PstlAdr> + </FinInstnId> + </CdtrAgt> + </RltdAgts> + <RmtInf> + <Ustrd>bad name 2025-10-30T12:03:24.997478 811Z</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Vergütung</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="CHF">.31</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2025-10-30</Dt> + </BookgDt> + <ValDt> + <Dt>2025-10-30</Dt> + </ValDt> + <AcctSvcrRef>ZV20251030/524079</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>OTHR</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>2</NbOfTxs> + <TtlAmt Ccy="CHF">.31</TtlAmt> + <CdtDbtInd>DBIT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <MsgId>6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X</MsgId> + <AcctSvcrRef>ZV20251030/524079/1</AcctSvcrRef> + <PmtInfId>NOTPROVIDED</PmtInfId> + <InstrId>02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ</InstrId> + <EndToEndId>02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ</EndToEndId> + </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> + <Cdtr> + <Nm>Grothoff Hans</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>CH7389144832588726658</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RmtInf> + <Ustrd>single 2025-10-30T12:04:00.37042083 6Z</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + <TxDtls> + <Refs> + <MsgId>6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X</MsgId> + <AcctSvcrRef>ZV20251030/524079/2</AcctSvcrRef> + <PmtInfId>NOTPROVIDED</PmtInfId> + <InstrId>XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y</InstrId> + <EndToEndId>XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y</EndToEndId> + </Refs> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.21</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.21</Amt> + </TxAmt> + </AmtDtls> + <RltdPties> + <Cdtr> + <Nm>John Smith</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>CH6208704048981247126</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RmtInf> + <Ustrd>bad name 2025-10-30T12:03:53.042190 686Z</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Vergütung</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2025-10-30</Dt> + </BookgDt> + <ValDt> + <Dt>2025-10-30</Dt> + </ValDt> + <AcctSvcrRef>ZV20251030/525730</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>RRTN</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">.21</TtlAmt> + <CdtDbtInd>CRDT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <AcctSvcrRef>ZV20251030/525730/1</AcctSvcrRef> + <EndToEndId>XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y</EndToEndId> + <TxId>51030658148.0001</TxId> + </Refs> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.21</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.21</Amt> + </TxAmt> + </AmtDtls> + <RltdPties> + <Dbtr> + <Nm>AEK BANK 1826 GENOSSENSCHAFT</Nm> + <PstlAdr> + <AdrLine>HOFSTETTENSTRASSE 2</AdrLine> + <AdrLine>3602 THUN</AdrLine> + <AdrLine>SWITZERLAND</AdrLine> + </PstlAdr> + </Dbtr> + <Cdtr> + <Nm>VERD Finanzas AG</Nm> + <PstlAdr> + <AdrLine>3011 Bern</AdrLine> + </PstlAdr> + </Cdtr> + </RltdPties> + <RmtInf> + <Ustrd>Error msg in german</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Vergütung</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2025-10-30</Dt> + </BookgDt> + <ValDt> + <Dt>2025-10-30</Dt> + </ValDt> + <AcctSvcrRef>ZV20251030/525968</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>RRTN</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">.21</TtlAmt> + <CdtDbtInd>CRDT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <AcctSvcrRef>ZV20251030/525968/1</AcctSvcrRef> + <EndToEndId>R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX</EndToEndId> + <TxId>51030658165.0001</TxId> + </Refs> + <Amt Ccy="CHF">.21</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.21</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.21</Amt> + </TxAmt> + </AmtDtls> + <RmtInf> + <Ustrd>Error msg in german</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Vergütung</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="CHF">5.23</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2025-10-30</Dt> + </BookgDt> + <ValDt> + <Dt>2025-10-30</Dt> + </ValDt> + <AcctSvcrRef>ZV20251030/524077</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>OTHR</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <Chrgs> + <TtlChrgsAndTaxAmt Ccy="CHF">5</TtlChrgsAndTaxAmt> + <Rcrd> + <Amt Ccy="CHF">5</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <ChrgInclInd>true</ChrgInclInd> + <Tp> + <Prtry> + <Id>ZV-Ausland.Zlg</Id> + </Prtry> + </Tp> + <Br>DEBT</Br> + </Rcrd> + </Chrgs> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">5.23</TtlAmt> + <CdtDbtInd>DBIT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <MsgId>OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P</MsgId> + <AcctSvcrRef>ZV20251030/524077/1</AcctSvcrRef> + <PmtInfId>NOTPROVIDED</PmtInfId> + <InstrId>TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7</InstrId> + <EndToEndId>TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7</EndToEndId> + </Refs> + <Amt Ccy="CHF">5.23</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.23</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.23</Amt> + </TxAmt> + </AmtDtls> + <RltdPties> + <Cdtr> + <Nm>Christian Grothoff</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE48330605920000686018</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RltdAgts> + <CdtrAgt> + <FinInstnId> + <BICFI>GENODED1SPW</BICFI> + </FinInstnId> + </CdtrAgt> + </RltdAgts> + <RmtInf> + <Ustrd>foreign iban 2025-10-30T12:03:44.0972 63765Z</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Vergütung</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="CHF">5.23</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <RvslInd>false</RvslInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2025-10-30</Dt> + </BookgDt> + <ValDt> + <Dt>2025-10-30</Dt> + </ValDt> + <AcctSvcrRef>ZV20251030/524080</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>OTHR</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <Chrgs> + <TtlChrgsAndTaxAmt Ccy="CHF">5</TtlChrgsAndTaxAmt> + <Rcrd> + <Amt Ccy="CHF">5</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <ChrgInclInd>true</ChrgInclInd> + <Tp> + <Prtry> + <Id>ZV-Ausland.Zlg</Id> + </Prtry> + </Tp> + <Br>DEBT</Br> + </Rcrd> + </Chrgs> + <NtryDtls> + <Btch> + <NbOfTxs>1</NbOfTxs> + <TtlAmt Ccy="CHF">5.23</TtlAmt> + <CdtDbtInd>DBIT</CdtDbtInd> + </Btch> + <TxDtls> + <Refs> + <MsgId>6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X</MsgId> + <AcctSvcrRef>ZV20251030/524080/1</AcctSvcrRef> + <PmtInfId>NOTPROVIDED</PmtInfId> + <InstrId>GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE</InstrId> + <EndToEndId>GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE</EndToEndId> + </Refs> + <Amt Ccy="CHF">5.23</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="CHF">.23</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="CHF">.23</Amt> + </TxAmt> + </AmtDtls> + <RltdPties> + <Cdtr> + <Nm>Christian Grothoff</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE48330605920000686018</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RltdAgts> + <CdtrAgt> + <FinInstnId> + <BICFI>GENODED1SPW</BICFI> + </FinInstnId> + </CdtrAgt> + </RltdAgts> + <RmtInf> + <Ustrd>foreign iban 2025-10-30T12:03:58.0046 73747Z</Ustrd> + </RmtInf> + <AddtlTxInf>Vergütung</AddtlTxInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Vergütung</AddtlNtryInf> + </Ntry> </Rpt> </BkToCstmrAcctRpt> </Document> \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -252,7 +252,7 @@ suspend fun registerFile( try { registerTxs(db, cfg, xml) } catch (e: Exception) { - throw Exception("Ingesting notifications failed", e) + throw Exception("Ingesting transactions files failed", e) } } OrderDoc.acknowledgement -> { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -76,6 +76,8 @@ class ExchangeDAO(private val db: Database) { ,execution_time ,(amount).val AS amount_val ,(amount).frac AS amount_frac + ,(debit_fee).val AS debit_fee_val + ,(debit_fee).frac AS debit_fee_frac ,credit_payto ,wtid ,exchange_base_url @@ -87,6 +89,7 @@ class ExchangeDAO(private val db: Database) { row_id = it.getLong("outgoing_transaction_id"), date = it.getTalerTimestamp("execution_time"), amount = it.getAmount("amount", db.currency), + debit_fee = it.getAmount("debit_fee", db.currency).notZeroOrNull(), credit_account = it.getString("credit_payto"), wtid = ShortHashCode(it.getBytes("wtid")), exchange_base_url = it.getString("exchange_base_url") 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,12 +43,13 @@ 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,(?,?)::taler_amount,?,?,?,?,?,?,?,?) """ ) { val executionTime = payment.executionTime.micros() bind(payment.amount) + bind(payment.debitFee ?: TalerAmount.zero(db.currency)) bind(payment.subject) bind(executionTime) bind(payment.creditor?.toString()) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt @@ -34,4 +34,11 @@ enum class HacAction(val description: String) { ORDER_HAC_FINAL_NEG("HAC end of order (negative)"), // Not in the spec but Credit Suisse test suite use it ORDER_HAC_FINAL("HAC end of order") +} + +enum class ChargeBearer(val description: String) { + DEBT("BorneByDebtor"), + CRED("BorneByCreditor"), + SHAR("Shared"), + SLEV("SLEV") } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt @@ -136,8 +136,11 @@ enum class ExternalStatusReasonCode(val isoCode: String, val description: String CH20("DecimalPointsNotCompatibleWithCurrency", "Number of decimal points not compatible with the currency"), CH21("RequiredCompulsoryElementMissing", "Mandatory element is missing"), CH22("COREandB2BwithinOnemessage", "SDD CORE and B2B not permitted within one message"), + CHCO("UnacceptedChargeCodeType", "Related to a Charge message to convey that the code in Charge Breakdown / Type / Code is not accepted by the receiving party."), CHQC("ChequeSettledOnCreditorAccount", "Cheque has been presented in cheque clearing and settled on the creditor’s account."), + CHRG("UnderlyingChargeBearerWasNotDebt", "Related to a Charge message to convey that the charge bearer code used in the corresponding Payment message was not debt."), CN01("AuthorisationCancelled", "Authorisation is cancelled."), + CNNS("CreditNotesNotSupported", "Credit notes are not supported."), CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"), CURR("IncorrectCurrency", "Currency of the payment is incorrect"), CUST("RequestedByCustomer", "Cancellation requested by the Debtor"), @@ -191,7 +194,7 @@ enum class ExternalStatusReasonCode(val isoCode: String, val description: String DU03("DuplicateTransaction", "Transaction is not unique."), DU04("DuplicateEndToEndID", "End To End ID is not unique."), DU05("DuplicateInstructionID", "Instruction ID is not unique."), - DUPL("DuplicatePayment", "Payment is a duplicate of another payment"), + DUPL("DuplicatePaymentOrCharge", "Payment or charge is a duplicate of another payment or charge."), ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."), ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"), ED05("SettlementFailed", "Settlement of the transaction has failed."), @@ -199,6 +202,7 @@ enum class ExternalStatusReasonCode(val isoCode: String, val description: String EDNA("ExecutionDateNotAccepted", "Requested execution date of the payment is not accepted."), EDTL("ExpiryDateTooLong", "Expiry date time of the request-to-pay is too far in the future."), EDTR("ExpiryDateTimeReached", "Expiry date time of the request-to-pay is already reached."), + EOL1("EndOfLife", "Expiration of the payment authorisation due to no use for too long."), ERIN("ERIOptionNotSupported", "Extended Remittance Information (ERI) option is not supported."), FF01("InvalidFileFormat", "File Format incomplete or invalid"), FF02("SyntaxError", "Syntax error reason is provided as narrative information in the additional reason information."), @@ -227,6 +231,7 @@ enum class ExternalStatusReasonCode(val isoCode: String, val description: String IEDT("IncorrectExpiryDateTime", "Expiry date time of the request-to-pay is incorrect."), INAR("InvalidActivationReference", "Payer’s activation reference is invalid."), INDT("InvalidDetails", "Details not valid for this field."), + IPNS("InstalmentPaymentsNotSupported", "Payments in instalments are not supported."), IRNR("InitialRTPNeverReceived", "No initial request-to-pay has been received."), ISWS("InvalidSettlementWindow", "Cannot schedule instruction for Night Window."), MD01("NoMandate", "No Mandate"), @@ -294,6 +299,10 @@ enum class ExternalStatusReasonCode(val isoCode: String, val description: String SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."), SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."), SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."), + SL15("MaximumNumberOfCreditTransactionsExceeded", "Maximum number of credit transactions allowed by the account servicer per service period exceeded."), + SL16("MaximumCreditTransactionsAmountExceeded", "Maximum total credit amount allowed by the account servicer per service period exceeded."), + SL17("DebtorNotOnWhitelistOfCreditorSide", "Whitelisting service offered by payment system operator or financial institution. Debtor is not included on the Creditor side whitelist."), + SL18("DebtorOnBlacklistOfCreditorSide", "Blacklisting service offered by payment system operator or financial institution. Debtor included on the Creditor side blacklist."), SNRD("ServiceNotRendered", "Services are not yet rendered by the Payee Participant (Creditor)."), SPII("RTPServiceProviderIdentifierIncorrect", "Identifier of the request-to-pay service provider is incorrect."), TA01("TransmissonAborted", "The transmission of the file was not successful – it had to be aborted (for technical reasons)"), @@ -325,14 +334,18 @@ enum class ExternalPaymentGroupStatusCode(val isoCode: String, val description: ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), PART("PartiallyAccepted", "A number of transactions have been accepted, whereas another number of transactions have not yet achieved"), PDNG("Pending", "Payment initiation or individual transaction included in the payment initiation is pending. Further checks and status update will be performed."), + RCVC("ReceivedVerificationCompleted", "Verification of Payee check have been applied to received transactions stating to be complete without mismatching data."), RCVD("Received", "Payment initiation has been received by the receiving agent"), RJCT("Rejected", "Payment initiation or individual transaction included in the payment initiation has been rejected."), + RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transactions stating to be complete containing mismatching data."), + RVNC("ReceivedVerificationNotCompleted", "Verification of party check on transactions received is not yet completed."), } enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val description: String) { ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), ACFC("AcceptedFundsChecked", "Preceding check of technical validation and customer profile was successful and an automatic funds check was positive."), + ACFW("AcceptedFundsCheckedWaitingConfirmation", "Preceding check of technical validation and customer profile was successful, and an automatic funds check was positive, but an explicit confirmation by the initiating party is outstanding."), ACIS("AcceptedandChequeIssued", "Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared."), ACPD("AcceptedClearingProcessed", "Status of transaction released from the Debtor Agent and accepted by the clearing."), ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement completed."), @@ -346,8 +359,14 @@ enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val descrip PATC("PartiallyAcceptedTechnicalCorrect", "Payment initiation needs multiple authentications, where some but not yet all have been performed. Syntactical and semantical validations are successful."), PDNG("Pending", "Payment instruction is pending. Further checks and status update will be performed."), PRES("Presented", "Request for Payment has been presented to the Debtor."), + RCVC("ReceivedVerificationCompleted", "Verification of Payee check has been applied to received transaction stating to be complete without mismatching data."), RCVD("Received", "Payment instruction has been received."), RJCT("Rejected", "Payment instruction has been rejected."), + RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transaction stating to be completed containing mismatching data."), + RVMC("ReceivedVerificationCompletedMatchClosely", "Verification of Payee check has been applied to received transaction stating to be complete with data matching closely."), + RVNA("ReceivedVerificationCompletedNotApplicable", "Verification of Payee check has been applied to received transaction stating to be complete with not applicable data."), + RVNC("ReceivedVerificationNotCompleted", "Verification of party check on the transaction is not yet completed."), + RVNM("ReceivedVerificationCompletedNoMatch", "Verification of Payee check has been applied to received transaction stating to be complete with mismatching data."), } enum class ExternalReturnReasonCode(val isoCode: String, val description: String) { @@ -457,3 +476,6 @@ enum class ExternalReturnReasonCode(val isoCode: String, val description: String UPAY("UnduePayment", "Payment is not justified."), } +enum class ChargeBearerTypeCode(val isoCode: String, val description: String) { +} + diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -167,6 +167,7 @@ data class IncomingPayment( data class OutgoingPayment( val id: OutgoingId, val amount: TalerAmount, + val debitFee: TalerAmount? = null, val subject: String?, override val executionTime: Instant, val creditor: IbanPayto? @@ -176,6 +177,10 @@ data class OutgoingPayment( append(executionTime.fmtDate()) append(" ") append(amount) + if (debitFee != null) { + append("-") + append(debitFee) + } append(" ") append(id) if (creditor != null) { @@ -311,106 +316,75 @@ private fun XmlDestructor.amount() = one("Amt") { } data class ComplexAmount( - // Transaction amount in the original currency - val converted: TalerAmount, - // Transaction amount in the bank account currency + // Transaction amount val amount: TalerAmount, - // The applied credit fees - private val creditFee: TalerAmount + // The applied fee + private val fee: TalerAmount, ) { - /// The credit fee to register in database - fun creditFee(): TalerAmount? = if (creditFee.isZero()) { null } else { creditFee } + /// The fees to register in database + fun fee(): TalerAmount? = if (fee.isZero()) { null } else { fee } /// Check that entry and tx amount are compatible and return the result - fun resolve(other: ComplexAmount): ComplexAmount { - if (other.amount.currency != this.amount.currency && other.amount == this.converted) { + fun resolve(child: ComplexAmount): ComplexAmount { + // Most time transaction will match + if (this.amount == child.amount && this.fee == child.fee) { return this - } else if (other.creditFee == this.creditFee) { - require(other.amount == this.amount) { "$this != $other" } + } + + // Or one of the level is missing the fee + if ( + (child.amount > child.fee && child.amount - child.fee == this.amount) || + this.amount - this.fee == child.amount + ) { + if (child.fee.isZero()) { + return this + } else { + return child + } + } + + // Or the conversion information are only present at the entry layer + if (child.amount.currency != this.amount.currency) { return this - } else { - require(this.creditFee.isZero() || other.creditFee.isZero()) { "$this != $other" } - val simple = if (creditFee.isZero()) this else other; - val complex = if (creditFee.isZero()) other else this; - require(simple.amount + complex.creditFee == complex.amount ) { "$this != $other" } - return complex } + + throw Error("Ammount mismatch, got ${this} in the entry and ${child} in the tx") } -} +} -private fun XmlDestructor.complexAmount(): ComplexAmount? { - var overflow = false; - val amount = opt("Amt") { +private fun XmlDestructor.complexAmount(charges: List<ChargeRecord>): ComplexAmount? { + // Amount before charges + var amount = 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 (amount == null) return null - - // The amount sent before conversion - var converted: TalerAmount? = null - // The amount sent after conversion but before fees - var computed: TalerAmount? = null - - // Run conversion logic - opt("AmtDtls") { - for (parts in sequenceOf("InstdAmt", "TxAmt", "CntrValAmt")) { - opt(parts) { - var tmp = amount() - if (tmp.currency == amount.currency) { - if (computed != null) { - require(computed == tmp) + // In case of fee overflow it's possible to have a negative amount here + // We ignore this as it will be handled elsewhere correctly + val amount = text().trimStart('-') + TalerAmount("$currency:0$amount") + } ?: return null + + var fee: TalerAmount = TalerAmount.zero(amount.currency) + + for (chr in charges) { + if (chr.included) { + fee += chr.amount + if (chr.kind == Kind.DBIT) { + if (chr.bearer == ChargeBearer.DEBT) { + if (chr.amount > amount) { + // This can happen when an incoming transaction fail because of debit fee + amount = chr.amount - amount } else { - computed = tmp + amount -= chr.amount } + } else if (chr.bearer == ChargeBearer.CRED) { + amount += chr.amount } else { - if (converted != null) { - require(converted == tmp) - } else { - converted = tmp - } - } - opt("CcyXchg") { - val srcCcy = one("SrcCcy").text() - val trgtCcy = opt("TrgtCcy")?.text() - val rate = one("XchgRate").float() - if (computed == null && tmp.currency == trgtCcy) { - val original = tmp.number().toString().toFloat() - val rounded = Math.round(original * rate * 100.0) / 100.0 - val result = TalerAmount("$srcCcy:$rounded") - computed = TalerAmount("$srcCcy:$rounded") - } + throw Error("Included charge ${chr.kind} with bearer ${chr.bearer}") } } } } - - var creditFee: TalerAmount = TalerAmount.zero(amount.currency) - opt("Chrgs")?.each("Rcrd") { - val amount = amount() - if (!amount.isZero() && one("ChrgInclInd").bool() && one("CdtDbtInd").text() == "DBIT") { - creditFee += amount - } - } - if (computed != null && (amount != computed || overflow)) { - val diff = if (overflow) { - computed + amount - } else { - computed - amount - } - require(creditFee.isZero() || creditFee == diff) - val real = if (overflow) amount else computed - return ComplexAmount(converted ?: real, real, diff) - } else { - return ComplexAmount(converted ?: amount, amount, creditFee) - } + + return ComplexAmount(amount, fee) } /** Parse bank transaction code */ @@ -454,6 +428,20 @@ private fun XmlDestructor.account(): Pair<String, String?> = one("Acct") { ) } +private data class ChargeRecord( + val amount: TalerAmount, + val kind: Kind, + val included: Boolean, + val bearer: ChargeBearer +) +private fun XmlDestructor.charges(): List<ChargeRecord> = opt("Chrgs")?.map("Rcrd") { + val amount = amount() + val kind = opt("CdtDbtInd")?.enum<Kind>() ?: Kind.CRDT + val included = opt("ChrgInclInd")?.bool() ?: true // TODO not clear in spec + val bearer = opt("Br")?.enum<ChargeBearer>() ?: ChargeBearer.SHAR + ChargeRecord(amount, kind, included, bearer) +} ?: emptyList() + data class AccountTransactions( val iban: String?, val currency: String?, @@ -511,45 +499,37 @@ fun parseTx(notifXml: InputStream): List<AccountTransactions> { val entryCode = bankTransactionCode() val reversal = opt("RvslInd")?.text() == "true" val entryKind = opt("CdtDbtInd")?.enum<Kind>(); - val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each - val unique = tmp.size == 1 val entryRef = opt("AcctSvcrRef")?.text() val bookDate = executionDate() - val entryAmount = complexAmount()!! - var totalAmount: TalerAmount? = null + val entryCharges = charges() + val entryAmount = complexAmount(entryCharges)!! + + // When an entry only contain a single transactions information will sometimes only be stored at the entry level + val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each + val unique = tmp.size == 1 + 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 + // Check information are present and coherent + val kind = requireNotNull(opt("CdtDbtInd")?.enum<Kind>() ?: entryKind) { "WTF" } + + // Sometimes the transaction level have a more precise bank transaction code val code = optBankTransactionCode() ?: entryCode // Amount - val txAmount = complexAmount() val amount = if (unique) { - if (txAmount != null) { - entryAmount.resolve(txAmount) - } else { - entryAmount - } - } else { - requireNotNull(txAmount) { "Missing batch tx amount" } - } - 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 - } + // When unique the charges can be only at the entry level + val txCharges = charges() + val txAmount = complexAmount(if (txCharges.isEmpty()) entryCharges else txCharges) + // Check coherence + if (txAmount != null) entryAmount.resolve(txAmount) else entryAmount } else { - txRef + // When many inner transaction the entry level is an aggregate of them + // We only use the transaction level information + requireNotNull(complexAmount(charges())) { "Missing tx amount" } } + + // We can only use the entry ref as the transaction ref if there is a single transaction in the batch + val ref = opt("Refs")?.opt("AcctSvcrRef")?.text() ?: if (unique) entryRef else null if (code.isReversal() || reversal) { val outgoingId = outgoingId(ref) @@ -567,9 +547,7 @@ fun parseTx(notifXml: InputStream): List<AccountTransactions> { val id = incomingId(ref) val subject = wireTransferSubject() val debtor = payto("Dbtr") - val creditFee = amount.creditFee() - requireNotNull(creditFee) { "Do not support failed debit without credit fee" } - require(creditFee > amount.amount) + val fee = amount.fee() txInfos.add(TxInfo.Credit( bookDate = bookDate, id = id, @@ -577,7 +555,7 @@ fun parseTx(notifXml: InputStream): List<AccountTransactions> { subject = subject, debtor = debtor, code = code, - creditFee = creditFee + creditFee = fee )) } } @@ -594,28 +572,25 @@ fun parseTx(notifXml: InputStream): List<AccountTransactions> { subject = subject, debtor = debtor, code = code, - creditFee = amount.creditFee() + creditFee = amount.fee() )) } Kind.DBIT -> { val outgoingId = outgoingId(ref) val creditor = payto("Cdtr") - require(amount.creditFee() == null) { "Do not support debit with credit fees" } txInfos.add(TxInfo.Debit( bookDate = bookDate, id = outgoingId, amount = amount.amount, subject = subject, creditor = creditor, - code = code + code = code, + debitFee = amount.fee() )) } } } }} - if (totalAmount != null) { - //require(totalAmount == entryAmount.amount()) { "Entry amount doesn't match batch amount sum $entryAmount != $totalAmount" } - } } accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos)) } @@ -651,6 +626,7 @@ sealed interface TxInfo { val code: BankTransactionCode, val id: OutId, val amount: TalerAmount, + val debitFee: TalerAmount?, val subject: String?, val creditor: IbanPayto? ): TxInfo @@ -692,6 +668,7 @@ sealed interface TxInfo { msgId = id.msgId, ), amount = amount, + debitFee = debitFee, executionTime = bookDate, creditor = creditor, subject = subject diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -215,7 +215,7 @@ class Iso20022Test { assertContentEquals( parseTx(Path("sample/platform/valiant_camt052.xml").inputStream()), listOf(AccountTransactions( - iban = "CH9289144596463965762", + iban = "CH7389144832588726658", currency = "CHF", txs = listOf( OutgoingPayment( @@ -267,6 +267,53 @@ class Iso20022Test { executionTime = dateToInstant("2025-10-30"), debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), ), + OutgoingPayment( + id = OutgoingId("X166701F6RV59LP71RVWVIW9SV2AFZYLG4", "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", "ZV20251030/524078/1"), + amount = TalerAmount("CHF:0.21"), + creditor = ibanPayto("CH6208704048981247126", "John Smith"), + subject = "bad name 2025-10-30T12:03:24.997478 811Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", "02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ", "ZV20251030/524079/1"), + amount = TalerAmount("CHF:0.1"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "single 2025-10-30T12:04:00.37042083 6Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", "ZV20251030/524079/2"), + amount = TalerAmount("CHF:0.21"), + creditor = ibanPayto("CH6208704048981247126", "John Smith"), + subject = "bad name 2025-10-30T12:03:53.042190 686Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingReversal( + endToEndId = "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", + reason = "Error msg in german", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingReversal( + endToEndId = "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", + reason = "Error msg in german", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId("OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P", "TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7", "ZV20251030/524077/1"), + amount = TalerAmount("CHF:0.23"), + debitFee = TalerAmount("CHF:5"), + creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"), + subject = "foreign iban 2025-10-30T12:03:44.0972 63765Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", "GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE", "ZV20251030/524080/1"), + amount = TalerAmount("CHF:0.23"), + debitFee = TalerAmount("CHF:5"), + creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"), + subject = "foreign iban 2025-10-30T12:03:58.0046 73747Z", + executionTime = dateToInstant("2025-10-30") + ), ) )) ) @@ -476,7 +523,7 @@ class Iso20022Test { ), IncomingPayment( id = IncomingId("f203fbb4-6e13-4c78-9b2a-d852fea6374a", "41202060702.0001", "ZV20241202/778108/1"), - amount = TalerAmount("CHF:0.15"), + amount = TalerAmount("CHF:0.05"), creditFee = TalerAmount("CHF:0.2"), subject = "mini", executionTime = dateToInstant("2024-12-02"), diff --git a/nexus/src/test/kotlin/RegistrationTest.kt b/nexus/src/test/kotlin/RegistrationTest.kt @@ -144,6 +144,8 @@ class RegistrationTest { ,acct_svcr_ref ,(amount).val as amount_val ,(amount).frac AS amount_frac + ,(debit_fee).val AS debit_fee_val + ,(debit_fee).frac AS debit_fee_frac ,subject ,execution_time ,credit_payto @@ -155,6 +157,7 @@ class RegistrationTest { OutgoingPayment( id = OutgoingId(null, it.getString("end_to_end_id"), it.getString("acct_svcr_ref")), amount = it.getAmount("amount", this@check.currency), + debitFee = it.getAmount("debit_fee", this@check.currency).notZeroOrNull(), subject = it.getString("subject"), executionTime = it.getLong("execution_time").asInstant(), creditor = it.getOptIbanPayto("credit_payto"), @@ -185,7 +188,6 @@ class RegistrationTest { ) } } - println(bounced_tx) assertContentEquals(bounced, bounced_tx) } @@ -286,6 +288,161 @@ class RegistrationTest { ) } + /** Valiant dialect test */ + @Test + fun valiant() = setup("valiant.conf") { db, cfg -> + db.batches(mapOf( + "MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L" to listOf( + genInitPay("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"), + ), + "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U" to listOf( + genInitPay("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), + genInitPay("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), + genInitPay("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), + genInitPay("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), + ), + "X166701F6RV59LP71RVWVIW9SV2AFZYLG4" to listOf( + genInitPay("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"), + ), + "OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P" to listOf( + genInitPay("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"), + ), + "6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X" to listOf( + genInitPay("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), + genInitPay("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), + genInitPay("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE") + ), + )) + + // Register camt files + db.register(cfg, "sample/platform/valiant_camt052.xml", OrderDoc.report) + + // Check state + db.check( + status = mapOf( + "MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L" to Pair(SubmissionState.success, mapOf( + "4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE" to SubmissionState.success + )), + "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U" to Pair(SubmissionState.success, mapOf( + "SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8" to SubmissionState.success, + "RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN" to SubmissionState.success, + "GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7" to SubmissionState.success, + "PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28" to SubmissionState.success + )), + "X166701F6RV59LP71RVWVIW9SV2AFZYLG4" to Pair(SubmissionState.success, mapOf( + "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX" to SubmissionState.late_failure + )), + "OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P" to Pair(SubmissionState.success, mapOf( + "TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7" to SubmissionState.success + )), + "6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X" to Pair(SubmissionState.success, mapOf( + "02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ" to SubmissionState.success, + "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y" to SubmissionState.late_failure, + "GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE" to SubmissionState.success + )), + ), + incoming = listOf( + IncomingPayment( + id = IncomingId(null, "51030655601.0001", "ZV20251030/514778/1"), + amount = TalerAmount("CHF:0.85"), + subject = "fun stuff", + executionTime = dateToInstant("2025-10-30"), + debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + ), + IncomingPayment( + id = IncomingId(null, "51030655601.0002", "ZV20251030/514779/1"), + amount = TalerAmount("CHF:0.95"), + subject = "Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0", + executionTime = dateToInstant("2025-10-30"), + debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + ), + ), + outgoing = listOf( + OutgoingPayment( + id = OutgoingId(null, "4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE", "ZV20251030/511372/1"), + amount = TalerAmount("CHF:0.1"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "single 2025-10-30T09:46:04.55293090 9Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8", "ZV20251030/511373/1"), + amount = TalerAmount("CHF:0.1"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "multi 0 2025-10-30T09:46:10.3877961 30Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN", "ZV20251030/511373/2"), + amount = TalerAmount("CHF:0.11"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "multi 1 2025-10-30T09:46:10.3877961 30Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7", "ZV20251030/511373/3"), + amount = TalerAmount("CHF:0.12"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "multi 2 2025-10-30T09:46:10.3877961 30Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28", "ZV20251030/511373/4"), + amount = TalerAmount("CHF:0.13"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "multi 3 2025-10-30T09:46:10.3877961 30Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", "ZV20251030/524078/1"), + amount = TalerAmount("CHF:0.21"), + creditor = ibanPayto("CH6208704048981247126", "John Smith"), + subject = "bad name 2025-10-30T12:03:24.997478 811Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ", "ZV20251030/524079/1"), + amount = TalerAmount("CHF:0.1"), + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"), + subject = "single 2025-10-30T12:04:00.37042083 6Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", "ZV20251030/524079/2"), + amount = TalerAmount("CHF:0.21"), + creditor = ibanPayto("CH6208704048981247126", "John Smith"), + subject = "bad name 2025-10-30T12:03:53.042190 686Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7", "ZV20251030/524077/1"), + amount = TalerAmount("CHF:0.23"), + debitFee = TalerAmount("CHF:5"), + creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"), + subject = "foreign iban 2025-10-30T12:03:44.0972 63765Z", + executionTime = dateToInstant("2025-10-30") + ), + OutgoingPayment( + id = OutgoingId(null, "GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE", "ZV20251030/524080/1"), + amount = TalerAmount("CHF:0.23"), + debitFee = TalerAmount("CHF:5"), + creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"), + subject = "foreign iban 2025-10-30T12:03:58.0046 73747Z", + executionTime = dateToInstant("2025-10-30") + ), + ), + bounced = listOf( + OutgoingPayment( + id = OutgoingId(null, null, null), + amount = TalerAmount("CHF:0.85"), + subject = "bounce 51030655601.0001: missing reserve public key", + executionTime = Instant.EPOCH, + creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans") + ), + ) + ) + } + /** GLS dialect test */ @Test fun gls() = setup("gls.conf") { db, cfg -> @@ -493,7 +650,7 @@ class RegistrationTest { ), IncomingPayment( id = IncomingId("f203fbb4-6e13-4c78-9b2a-d852fea6374a", "41202060702.0001", "ZV20241202/778108/1"), - amount = TalerAmount("CHF:0.15"), + amount = TalerAmount("CHF:0.05"), creditFee = TalerAmount("CHF:0.2"), subject = "mini", executionTime = dateToInstant("2024-12-02"), @@ -577,7 +734,7 @@ class RegistrationTest { ), OutgoingPayment( id = OutgoingId(null, null, null), - amount = TalerAmount("CHF:0.15"), + amount = TalerAmount("CHF:0.05"), subject = "bounce f203fbb4-6e13-4c78-9b2a-d852fea6374a: missing reserve public key", executionTime = Instant.EPOCH, creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans") diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt @@ -118,9 +118,7 @@ class Iso20022Test { EbicsAdministrative.parseHKD(content) } else if (name.contains("pain.002")) { parseCustomerPaymentStatusReport(content) - } else if ( - !name.contains("camt.052") && !name.contains("_C52_") && !name.contains("_Z01_") - ) { + } else { parseTx(content) } }