commit 8a6e053144a957df36fc0c253c8b3e4d8033e49b
parent 8bdc144e6d36f577d627e0330155f1d9aec4c020
Author: Antoine A <>
Date: Wed, 28 May 2025 18:00:17 +0200
nexus: improve camt amount logic
Diffstat:
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")