libeufin

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

commit 8a6e053144a957df36fc0c253c8b3e4d8033e49b
parent 8bdc144e6d36f577d627e0330155f1d9aec4c020
Author: Antoine A <>
Date:   Wed, 28 May 2025 18:00:17 +0200

nexus: improve camt amount logic

Diffstat:
Mnexus/sample/platform/maerki_baumann_camt053.xml | 15++++++++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt | 1+
Mnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt | 154++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mnexus/src/test/kotlin/Iso20022Test.kt | 2+-
Mnexus/src/test/kotlin/RegistrationTest.kt | 4++--
5 files changed, 91 insertions(+), 85 deletions(-)

diff --git a/nexus/sample/platform/maerki_baumann_camt053.xml b/nexus/sample/platform/maerki_baumann_camt053.xml @@ -814,7 +814,7 @@ <AddtlNtryInf>Transfer Taler Operations AG</AddtlNtryInf> </Ntry> <Ntry> - <Amt Ccy="CHF">9.8</Amt> + <Amt Ccy="CHF">1.18</Amt> <CdtDbtInd>CRDT</CdtDbtInd> <RvslInd>false</RvslInd> <Sts>BOOK</Sts> @@ -837,7 +837,7 @@ <NtryDtls> <Btch> <NbOfTxs>1</NbOfTxs> - <TtlAmt Ccy="CHF">9.8</TtlAmt> + <TtlAmt Ccy="CHF">1.18</TtlAmt> <CdtDbtInd>CRDT</CdtDbtInd> </Btch> <TxDtls> @@ -846,14 +846,19 @@ <EndToEndId>6b515f17ecc9408191f7b9b1d755faf7</EndToEndId> <TxId>F000787951230001</TxId> </Refs> - <Amt Ccy="CHF">9.8</Amt> + <Amt Ccy="CHF">1.18</Amt> <CdtDbtInd>CRDT</CdtDbtInd> <AmtDtls> <InstdAmt> - <Amt Ccy="CHF">10</Amt> + <Amt Ccy="EUR">1.5</Amt> </InstdAmt> <TxAmt> - <Amt Ccy="CHF">10</Amt> + <Amt Ccy="EUR">1.5</Amt> + <CcyXchg> + <SrcCcy>CHF</SrcCcy> + <TrgtCcy>EUR</TrgtCcy> + <XchgRate>.917876</XchgRate> + </CcyXchg> </TxAmt> </AmtDtls> <Chrgs> diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt @@ -179,6 +179,7 @@ class XmlDestructor internal constructor(private val el: Element) { fun uuid(): UUID = UUID.fromString(text()) fun text(): String = el.textContent fun bool(): Boolean = el.textContent.toBoolean() + fun float(): Float = el.textContent.toFloat() fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -314,63 +314,37 @@ private fun XmlDestructor.amount() = one("Amt") { TalerAmount(concat) } -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 - } - } - +data class ComplexAmount( + // Transaction amount in the original currency + val converted: TalerAmount, + // Transaction amount in the bank account currency + val amount: TalerAmount, + // The applied credit fees + private val creditFee: TalerAmount +) { /// The credit fee to register in database - fun creditFee(): TalerAmount? { - return when (this) { - is Simple, is Converted -> null - is Charged -> creditFee - } - } + fun creditFee(): TalerAmount? = if (creditFee.isZero()) { null } else { 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 - } + if (other.amount.currency != this.amount.currency && other.amount == this.converted) { + return this + } else if (other.creditFee == this.creditFee) { + require(other.amount == this.amount) { "$this != $other" } + 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 } } } private fun XmlDestructor.complexAmount(): ComplexAmount? { var overflow = false; - val received = opt("Amt") { + val amount = opt("Amt") { val currency = attr("Ccy") var amount = text() overflow = amount.startsWith('-') @@ -382,39 +356,65 @@ private fun XmlDestructor.complexAmount(): ComplexAmount? { } TalerAmount(concat) } - if (received == null) return null - - val sent = opt("AmtDtls")?.opt("TxAmt") { - amount() - } ?: received + 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) + } else { + computed = tmp + } + } 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") + } + } + } + } + } - var creditFee: TalerAmount? = null + 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 = creditFee?.let { it + amount } ?: amount + creditFee += amount } } - - 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) + println("FUUCK $amount $converted $computed $creditFee $overflow") + if (computed != null && (amount != computed || overflow)) { + val diff = if (overflow) { + computed + amount } else { - val diff = requireNotNull(creditFee) - return ComplexAmount.Charged(received, diff) + 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) } } @@ -542,7 +542,7 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> requireNotNull(txAmount) { "Missing batch tx amount" } } if (entryAmount != null) { - totalAmount = totalAmount?.let { it + amount.amount() } ?: amount.amount() + totalAmount = totalAmount?.let { it + amount.amount } ?: amount.amount } // Ref @@ -576,11 +576,11 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> val debtor = payto("Dbtr") val creditFee = amount.creditFee() requireNotNull(creditFee) { "Do not support failed debit without credit fee" } - require(creditFee > amount.amount()) + require(creditFee > amount.amount) txInfos.add(TxInfo.Credit( bookDate = bookDate, id = id, - amount = amount.amount(), + amount = amount.amount, subject = subject, debtor = debtor, code = code, @@ -597,7 +597,7 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> txInfos.add(TxInfo.Credit( bookDate = bookDate, id = id, - amount = amount.amount(), + amount = amount.amount, subject = subject, debtor = debtor, code = code, @@ -611,7 +611,7 @@ fun parseTx(notifXml: InputStream, dialect: Dialect): List<AccountTransactions> txInfos.add(TxInfo.Debit( bookDate = bookDate, id = outgoingId, - amount = amount.amount(), + amount = amount.amount, subject = subject, creditor = creditor, code = code diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -444,7 +444,7 @@ class Iso20022Test { ), IncomingPayment( id = IncomingId(null, "F000787951230001", "ZV20250526/852733/1"), - amount = TalerAmount("CHF:10"), + amount = TalerAmount("CHF:1.38"), creditFee = TalerAmount("CHF:0.2"), subject = "Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G", executionTime = dateToInstant("2025-05-26"), diff --git a/nexus/src/test/kotlin/RegistrationTest.kt b/nexus/src/test/kotlin/RegistrationTest.kt @@ -516,7 +516,7 @@ class RegistrationTest { ), IncomingPayment( id = IncomingId(null, "F000787951230001", "ZV20250526/852733/1"), - amount = TalerAmount("CHF:10"), + amount = TalerAmount("CHF:1.38"), creditFee = TalerAmount("CHF:0.2"), subject = "Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G", executionTime = dateToInstant("2025-05-26"), @@ -591,7 +591,7 @@ class RegistrationTest { ), OutgoingPayment( id = OutgoingId(null, null, null), - amount = TalerAmount("CHF:10"), + amount = TalerAmount("CHF:1.38"), subject = "bounce F000787951230001: restricted account", executionTime = Instant.EPOCH, creditor = ibanPayto("DE20500105172419259181", "Mr German")