summaryrefslogtreecommitdiff
path: root/nexus
diff options
context:
space:
mode:
authorAntoine A <>2024-04-23 15:39:42 +0900
committerAntoine A <>2024-04-23 15:39:42 +0900
commit0c7b0d7ce73c43eff03302f6c986689c8091fb1f (patch)
tree06814a20352c78bed5d2cb9f981037903b49d58a /nexus
parentda656c2d89d09e3829b7884d5dc5f976c78bc088 (diff)
downloadlibeufin-0c7b0d7ce73c43eff03302f6c986689c8091fb1f.tar.gz
libeufin-0c7b0d7ce73c43eff03302f6c986689c8091fb1f.tar.bz2
libeufin-0c7b0d7ce73c43eff03302f6c986689c8091fb1f.zip
Use better unique bank provided ID for incoming transactions and parse return's reason
Diffstat (limited to 'nexus')
-rw-r--r--nexus/codegen.py3
-rw-r--r--nexus/sample/platform/gls.xml (renamed from nexus/sample/gls.xml)0
-rw-r--r--nexus/sample/platform/postfinance_camt053.xml109
-rw-r--r--nexus/sample/platform/postfinance_camt054.xml (renamed from nexus/sample/postfinance.xml)2
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt22
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt384
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt110
-rw-r--r--nexus/src/test/kotlin/Iso20022Test.kt39
8 files changed, 497 insertions, 172 deletions
diff --git a/nexus/codegen.py b/nexus/codegen.py
index 634a9842..51708648 100644
--- a/nexus/codegen.py
+++ b/nexus/codegen.py
@@ -65,6 +65,9 @@ package tech.libeufin.nexus
{extractCodeSet("ExternalPaymentGroupStatus1Code", "ExternalPaymentGroupStatusCode")}
{extractCodeSet("ExternalPaymentTransactionStatus1Code", "ExternalPaymentTransactionStatusCode")}
+
+{extractCodeSet("ExternalReturnReason1Code", "ExternalReturnReasonCode")}
+
"""
with open("src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt", "w") as file1:
file1.write(kt)
diff --git a/nexus/sample/gls.xml b/nexus/sample/platform/gls.xml
index 28dc691c..28dc691c 100644
--- a/nexus/sample/gls.xml
+++ b/nexus/sample/platform/gls.xml
diff --git a/nexus/sample/platform/postfinance_camt053.xml b/nexus/sample/platform/postfinance_camt053.xml
new file mode 100644
index 00000000..d0b7659a
--- /dev/null
+++ b/nexus/sample/platform/postfinance_camt053.xml
@@ -0,0 +1,109 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08 camt.053.001.08.xsd">
+ <BkToCstmrStmt>
+ <Stmt>
+ <Ntry>
+ <Amt Ccy="CHF">1.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <RvslInd>true</RvslInd>
+ <Sts>
+ <Cd>BOOK</Cd>
+ </Sts>
+ <BookgDt>
+ <Dt>2023-11-22</Dt>
+ </BookgDt>
+ <AcctSvcrRef>32632000B04CYPIK</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>889d1a80-1267-49bd-8fcc-85701a</MsgId>
+ <AcctSvcrRef>231122CH0B04CYPI</AcctSvcrRef>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <InstrId>NOTPROVIDED</InstrId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <UETR>3e53516e-c0d3-450d-b6fc-69161ebe5942</UETR>
+ </Refs>
+ <Amt Ccy="CHF">1.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <RtrInf>
+ <Rsn>
+ <Cd>BE01</Cd>
+ </Rsn>
+ <AddtlInf>Keine Uebereinstimmung von Kontonummer und Kontoinhaber</AddtlInf>
+ </RtrInf>
+ </TxDtls>
+ </NtryDtls>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="CHF">1.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <RvslInd>true</RvslInd>
+ <Sts>
+ <Cd>BOOK</Cd>
+ </Sts>
+ <BookgDt>
+ <Dt>2023-11-22</Dt>
+ </BookgDt>
+ <AcctSvcrRef>32632000B04KLEEK</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>4cc61cc7-6230-49c2-b5e2-b40bbb</MsgId>
+ <AcctSvcrRef>231122CH0B04KLEE</AcctSvcrRef>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <InstrId>NOTPROVIDED</InstrId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <UETR>635d1493-cf6c-4ece-91b9-926daf2d4213</UETR>
+ </Refs>
+ <Amt Ccy="CHF">1.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <RtrInf>
+ <Rsn>
+ <Cd>RR03</Cd>
+ </Rsn>
+ <AddtlInf>Postadresse des Kreditors fehlt oder ist unvollständig</AddtlInf>
+ </RtrInf>
+ </TxDtls>
+ </NtryDtls>
+ </Ntry>
+ </Stmt>
+ </BkToCstmrStmt>
+</Document> \ No newline at end of file
diff --git a/nexus/sample/postfinance.xml b/nexus/sample/platform/postfinance_camt054.xml
index c075f31c..c4c0a456 100644
--- a/nexus/sample/postfinance.xml
+++ b/nexus/sample/platform/postfinance_camt054.xml
@@ -65,6 +65,7 @@
<Refs>
<AcctSvcrRef>231121CH0AZWCR9T</AcctSvcrRef>
<EndToEndId>NOTPROVIDED</EndToEndId>
+ <UETR>62e2b511-7313-4ccd-8d40-c9d8e612cd71</UETR>
</Refs>
<Amt Ccy="CHF">10.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
@@ -103,6 +104,7 @@
<Refs>
<AcctSvcrRef>231121CH0AZWCVR1</AcctSvcrRef>
<EndToEndId>NOTPROVIDED</EndToEndId>
+ <UETR>62e2b511-7313-4ccd-8d40-c9d8e612cd71</UETR>
</Refs>
<Amt Ccy="CHF">2.53</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index 978c7d2d..96710648 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -158,7 +158,7 @@ private suspend fun ingestDocument(
when (whichDocument) {
SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> {
try {
- parseTx(xml, cfg.currency).forEach {
+ parseTx(xml, cfg.currency, cfg.dialect).forEach {
if (cfg.fetch.ignoreBefore != null && it.executionTime < cfg.fetch.ignoreBefore) {
logger.debug("IGNORE $it")
} else {
@@ -307,8 +307,6 @@ enum class EbicsDocument {
acknowledgement,
/// Payment status - CustomerPaymentStatusReport pain.002
status,
- /// Account intraday reports - BankToCustomerAccountReport camt.052
- // report, TODO add support
/// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054
notification,
/// Account statements - BankToCustomerStatement camt.053
@@ -318,32 +316,22 @@ enum class EbicsDocument {
fun shortDescription(): String = when (this) {
acknowledgement -> "EBICS acknowledgement"
status -> "Payment status"
- //Document.report -> "Account intraday reports"
- notification -> "Debit & credit notifications"
statement -> "Account statements"
+ notification -> "Debit & credit notifications"
}
fun fullDescription(): String = when (this) {
acknowledgement -> "EBICS acknowledgement - CustomerAcknowledgement HAC pain.002"
status -> "Payment status - CustomerPaymentStatusReport pain.002"
- //report -> "Account intraday reports - BankToCustomerAccountReport camt.052"
- notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054"
statement -> "Account statements - BankToCustomerStatement camt.053"
+ notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054"
}
fun doc(): SupportedDocument = when (this) {
acknowledgement -> SupportedDocument.PAIN_002_LOGS
status -> SupportedDocument.PAIN_002
- //Document.report -> SupportedDocument.CAMT_052
- notification -> SupportedDocument.CAMT_054
statement -> SupportedDocument.CAMT_053
- }
-
- companion object {
- fun defaults(dialect: Dialect) = when (dialect) {
- Dialect.postfinance -> listOf(acknowledgement, status, notification)
- Dialect.gls -> listOf(acknowledgement, status, statement)
- }
+ notification -> SupportedDocument.CAMT_054
}
}
@@ -393,7 +381,7 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") {
null,
FileLogger(ebicsLog)
)
- val docs = if (documents.isEmpty()) EbicsDocument.defaults(cfg.dialect) else documents.toList()
+ val docs = if (documents.isEmpty()) EbicsDocument.entries else documents.toList()
if (transient) {
logger.info("Transient mode: fetching once and returning.")
val pinnedStartVal = pinnedStart
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index e5a665ac..c907b9aa 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -89,7 +89,7 @@ fun createPain001(
el("PmtInf") {
el("PmtInfId", "NOTPROVIDED")
el("PmtMtd", "TRF")
- el("BtchBookg", "true")
+ el("BtchBookg", "false")
el("NbOfTxs", "1")
el("CtrlSum", amountWithoutCurrency)
el("PmtTpInf/SvcLvl/Cd",
@@ -312,116 +312,218 @@ private fun XmlDestructor.payto(prefix: String): String? {
}
}
+private class TxErr(val msg: String): Exception(msg)
+
+private enum class Kind {
+ CRDT,
+ DBIT
+}
+
/** Parse camt.054 or camt.053 file */
fun parseTx(
notifXml: InputStream,
- acceptedCurrency: String
+ acceptedCurrency: String,
+ dialect: Dialect
): List<TxNotification> {
/*
In ISO 20022 specifications, most fields are optional and the same information
can be written several times in different places. For libeufin, we're only
interested in a subset of the available values that can be found in both camt.053
and camt.054. As there are many similarities between these files, we use the same
- function to share as much code as possible. This function should not fail on
- legitimate files and should simply warn when available informations are insufficient.
+ function to share code. This function should not fail on legitimate files and should
+ simply warn when available informations are insufficient.
*/
/** Assert that transaction status is BOOK */
- fun XmlDestructor.assertBooked() {
+ fun XmlDestructor.assertBooked(ref: String?) {
one("Sts") {
val status = opt("Cd")?.text() ?: text()
require(status == "BOOK") {
- "Found non booked transaction, stop parsing: expected BOOK got $status"
+ "Found non booked entry $ref, stop parsing: expected BOOK got $status"
}
}
}
- /** Parse information commonly founded a the top of the XML tree */
- fun XmlDestructor.parseHead(): TxHead = TxHead(
- reversal = opt("RvslInd")?.bool() ?: false,
- entryInfo = opt("AddtlNtryInf")?.text(),
- date = one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC),
- entryRef = opt("AcctSvcrRef")?.text()
- )
- /** Parse transaction code */
- fun XmlDestructor.parseCode(head: TxHead) {
- opt("BkTxCd") {
- opt("Domn") {
- // TODO automate enum generation for all those code
- val domainCode = one("Cd")
- one("Fmly") {
- val familyCode = one("Cd")
- val subFamilyCode = one("SubFmlyCd").text()
- if (subFamilyCode == "RRTN" || subFamilyCode == "RPCR") {
- head.reversal = true
- }
- }
+
+ fun XmlDestructor.bookDate() =
+ one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC)
+
+ /** Check if transaction code is reversal */
+ fun XmlDestructor.isReversalCode(): Boolean {
+ return one("BkTxCd").one("Domn") {
+ // TODO automate enum generation for all those code
+ val domainCode = one("Cd").text()
+ one("Fmly") {
+ val familyCode = one("Cd").text()
+ val subFamilyCode = one("SubFmlyCd").text()
+
+ subFamilyCode == "RRTN" || subFamilyCode == "RPCR"
}
}
}
- /** Parse information commonly founded a the bottom or the top of the XML tree */
- fun XmlDestructor.parseMid(): TxMid = TxMid(
- kind = one("CdtDbtInd").text(),
- amount = one("Amt") {
- val currency = attr("Ccy")
- /** FIXME: test by sending non-CHF to PoFi and see which currency gets here. */
- if (currency != acceptedCurrency) throw Exception("Currency $currency not supported")
- TalerAmount("$currency:${text()}")
- }
- )
- /** Parse information commonly founded a the bottom of the XML tree */
- fun XmlDestructor.parseBtm(): TxBtm = TxBtm(
- msgId = opt("Refs")?.opt("MsgId")?.text(),
- ref = opt("Refs")?.opt("AcctSvcrRef")?.text(),
- subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString(""),
- // TODO RltdAgts can have more info on debtor and creditor
- debtorPayto = opt("RltdPties") { payto("Dbtr") },
- creditorPayto = opt("RltdPties") { payto("Cdtr") },
- )
- /** Parse camt.054 entry */
- fun XmlDestructor.parseNotif(): List<RawTx> {
- assertBooked()
- val head = parseHead()
- parseCode(head)
- return one("NtryDtls").map("TxDtls") {
- val mid = parseMid()
- val btm = parseBtm()
- RawTx(head, mid, btm)
- }
- }
- /** Parse camt.053 entry */
- fun XmlDestructor.parseStatement(): RawTx {
- assertBooked()
- val head = parseHead()
- val mid = parseMid()
- return one("NtryDtls").one("TxDtls") {
- parseCode(head)
- val btm = parseBtm()
- RawTx(head, mid, btm)
+
+ val txsInfo = mutableListOf<TxInfo>()
+
+ XmlDestructor.fromStream(notifXml, "Document") { when (dialect) {
+ Dialect.gls -> {
+ opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
+ opt("Acct") {
+ // Sanity check on currency and IBAN ?
+ }
+ each("Ntry") {
+ val entryRef = opt("AcctSvcrRef")?.text()
+ assertBooked(entryRef)
+ val bookDate = bookDate()
+ val kind = one("CdtDbtInd").enum<Kind>()
+ val amount = one("Amt") {
+ val currency = attr("Ccy")
+ /** FIXME: test by sending non-CHF to PoFi and see which currency gets here. */
+ if (currency != acceptedCurrency) throw Exception("Currency $currency not supported")
+ TalerAmount("$currency:${text()}")
+ }
+ one("NtryDtls").one("TxDtls") {
+ val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
+ val reversal = isReversalCode()
+ val nexusId = opt("Refs")?.opt("MsgId")?.text() // TODO and end-to-end ID
+ if (reversal) {
+ if (kind == Kind.CRDT) {
+ val reason = one("RtrInf") {
+ val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>()
+ val info = opt("AddtlInf")?.text()
+ buildString {
+ append("${code.isoCode} '${code.description}'")
+ if (info != null) {
+ append(" - '$info'")
+ }
+ }
+ }
+ txsInfo.add(TxInfo.CreditReversal(
+ ref = nexusId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ nexusId = nexusId,
+ reason = reason
+ ))
+ }
+ } else {
+ val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
+ when (kind) {
+ Kind.CRDT -> {
+ val bankId = one("Refs").opt("TxId")?.text()
+ val debtorPayto = opt("RltdPties") { payto("Dbtr") }
+ txsInfo.add(TxInfo.Credit(
+ ref = bankId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ bankId = bankId,
+ amount = amount,
+ subject = subject,
+ debtorPayto = debtorPayto
+ ))
+ }
+ Kind.DBIT -> {
+ val creditorPayto = opt("RltdPties") { payto("Cdtr") }
+ txsInfo.add(TxInfo.Debit(
+ ref = nexusId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ nexusId = nexusId,
+ amount = amount,
+ subject = subject,
+ creditorPayto = creditorPayto
+ ))
+ }
+ }
+ }
+ }
+ }
+ }
}
- }
- val raws = mutableListOf<RawTx>()
- XmlDestructor.fromStream(notifXml, "Document") {
- opt("BkToCstmrDbtCdtNtfctn") { // Camt.054
- each("Ntfctn") {
+ Dialect.postfinance -> {
+ opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
opt("Acct") {
// Sanity check on currency and IBAN ?
}
each("Ntry") {
- raws.addAll(parseNotif())
+ val entryRef = opt("AcctSvcrRef")?.text()
+ assertBooked(entryRef)
+ val bookDate = bookDate()
+ if (isReversalCode()) {
+ one("NtryDtls").one("TxDtls") {
+ val kind = one("CdtDbtInd").enum<Kind>()
+ if (kind == Kind.CRDT) {
+ val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
+ val nexusId = opt("Refs")?.opt("MsgId")?.text() // TODO and end-to-end ID
+ val reason = one("RtrInf") {
+ val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>()
+ val info = opt("AddtlInf")?.text()
+ buildString {
+ append("${code.isoCode} '${code.description}'")
+ if (info != null) {
+ append(" - '$info'")
+ }
+ }
+ }
+ txsInfo.add(TxInfo.CreditReversal(
+ ref = nexusId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ nexusId = nexusId,
+ reason = reason
+ ))
+ }
+ }
+ }
}
}
- } ?: opt("BkToCstmrStmt") { // Camt.053
- each("Stmt") {
+ opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
opt("Acct") {
// Sanity check on currency and IBAN ?
}
each("Ntry") {
- raws.add(parseStatement())
+ val entryRef = opt("AcctSvcrRef")?.text()
+ assertBooked(entryRef)
+ val bookDate = bookDate()
+ if (!isReversalCode()) {
+ one("NtryDtls").each("TxDtls") {
+ val kind = one("CdtDbtInd").enum<Kind>()
+ val amount = one("Amt") {
+ val currency = attr("Ccy")
+ /** FIXME: test by sending non-CHF to PoFi and see which currency gets here. */
+ if (currency != acceptedCurrency) throw Exception("Currency $currency not supported")
+ TalerAmount("$currency:${text()}")
+ }
+ val txRef = one("Refs").opt("AcctSvcrRef")?.text()
+ val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
+ when (kind) {
+ Kind.CRDT -> {
+ val bankId = one("Refs").opt("UETR")?.text()
+ val debtorPayto = opt("RltdPties") { payto("Dbtr") }
+ txsInfo.add(TxInfo.Credit(
+ ref = bankId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ bankId = bankId,
+ amount = amount,
+ subject = subject,
+ debtorPayto = debtorPayto
+ ))
+ }
+ Kind.DBIT -> {
+ val nexusId = opt("Refs")?.opt("MsgId")?.text() // TODO and end-to-end ID
+ val creditorPayto = opt("RltdPties") { payto("Cdtr") }
+ txsInfo.add(TxInfo.Debit(
+ ref = nexusId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ nexusId = nexusId,
+ amount = amount,
+ subject = subject,
+ creditorPayto = creditorPayto
+ ))
+ }
+ }
+ }
+ }
}
}
- } ?: throw Exception("Missing BkToCstmrDbtCdtNtfctn or BkToCstmrStmt")
- }
- return raws.mapNotNull { it ->
+ }
+ }}
+
+ return txsInfo.mapNotNull { it ->
try {
parseTxLogic(it)
} catch (e: TxErr) {
@@ -432,78 +534,74 @@ fun parseTx(
}
}
+private sealed interface TxInfo {
+ // Bank provider ref for debugging
+ val ref: String?
+ // When was this transaction booked
+ val bookDate: Instant
+ data class CreditReversal(
+ override val ref: String?,
+ override val bookDate: Instant,
+ // Unique ID generated by libeufin-nexus
+ val nexusId: String?,
+ val reason: String?
+ ): TxInfo
+ data class Credit(
+ override val ref: String?,
+ override val bookDate: Instant,
+ // Unique ID generated by payment provider
+ val bankId: String?,
+ val amount: TalerAmount,
+ val subject: String?,
+ val debtorPayto: String?
+ ): TxInfo
+ data class Debit(
+ override val ref: String?,
+ override val bookDate: Instant,
+ // Unique ID generated by libeufin-nexus
+ val nexusId: String?,
+ val amount: TalerAmount,
+ val subject: String?,
+ val creditorPayto: String?
+ ): TxInfo
+}
-private data class TxHead(
- var reversal: Boolean,
- val entryInfo: String?,
- val date: Instant,
- val entryRef: String?
-)
-
-private data class TxMid(
- val kind: String,
- val amount: TalerAmount,
-)
-
-private data class TxBtm(
- val msgId: String?,
- val ref: String?,
- val subject: String?,
- val debtorPayto: String?,
- val creditorPayto: String?
-)
-
-private data class RawTx(
- val head: TxHead,
- val mid: TxMid,
- val btm: TxBtm
-)
-
-private class TxErr(val msg: String): Exception(msg)
-
-private fun parseTxLogic(raw: RawTx): TxNotification {
- val (reversal, entryInfo, date, entryRef) = raw.head
- val (kind, amount) = raw.mid
- val (msgId, ref, subject, debtorPayto, creditorPayto) = raw.btm
- val dbgRef = ref ?: entryRef
- if (reversal) {
- // TODO parse reason code if present
- require("CRDT" == kind) // TODO handle DBIT reversal
- if (msgId == null)
- throw TxErr("missing msg ID for Credit reversal $dbgRef")
- return TxNotification.Reversal(
- msgId = msgId,
- reason = entryInfo,
- executionTime = date
- )
- }
- return when (kind) {
- "CRDT" -> {
- if (dbgRef == null)
- throw TxErr("missing ref for Credit $dbgRef")
- if (subject == null)
- throw TxErr("missing subject for Credit $dbgRef")
- if (debtorPayto == null)
- throw TxErr("missing debtor info for Credit $dbgRef")
+private fun parseTxLogic(info: TxInfo): TxNotification {
+ return when (info) {
+ is TxInfo.CreditReversal -> {
+ if (info.nexusId == null)
+ throw TxErr("missing nexus ID for Credit reversal ${info.ref}")
+ TxNotification.Reversal(
+ msgId = info.nexusId,
+ reason = info.reason,
+ executionTime = info.bookDate
+ )
+ }
+ is TxInfo.Credit -> {
+ if (info.bankId == null)
+ throw TxErr("missing bank ID for Credit ${info.ref}")
+ if (info.subject == null)
+ throw TxErr("missing subject for Credit ${info.ref}")
+ if (info.debtorPayto == null)
+ throw TxErr("missing debtor info for Credit ${info.ref}")
IncomingPayment(
- amount = amount,
- bankId = dbgRef,
- debitPaytoUri = debtorPayto,
- executionTime = date,
- wireTransferSubject = subject
+ amount = info.amount,
+ bankId = info.bankId,
+ debitPaytoUri = info.debtorPayto,
+ executionTime = info.bookDate,
+ wireTransferSubject = info.subject
)
}
- "DBIT" -> {
- if (msgId == null)
- throw TxErr("missing msg ID for Debit $dbgRef")
+ is TxInfo.Debit -> {
+ if (info.nexusId == null)
+ throw TxErr("missing nexus ID for Debit ${info.ref}")
OutgoingPayment(
- amount = amount,
- messageId = msgId,
- executionTime = date,
- creditPaytoUri = creditorPayto,
- wireTransferSubject = subject
+ amount = info.amount,
+ messageId = info.nexusId,
+ executionTime = info.bookDate,
+ creditPaytoUri = info.creditorPayto,
+ wireTransferSubject = info.subject
)
}
- else -> throw Exception("Unknown transaction notification kind '$kind'")
}
} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt
index 624a6b0e..2d21b234 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt
@@ -327,4 +327,112 @@ enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val descrip
PRES("Presented", "Request for Payment has been presented to the Debtor."),
RCVD("Received", "Payment instruction has been received."),
RJCT("Rejected", "Payment instruction has been rejected."),
-} \ No newline at end of file
+}
+
+enum class ExternalReturnReasonCode(val isoCode: String, val description: String) {
+ AC01("IncorrectAccountNumber", "Format of the account number specified is not correct"),
+ AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing."),
+ AC03("InvalidCreditorAccountNumber", "Wrong IBAN in SCT"),
+ AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books"),
+ AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."),
+ AC07("ClosedCreditorAccountNumber", "Creditor account number closed."),
+ AC13("InvalidDebtorAccountType", "Debtor account type is missing or invalid"),
+ AC14("InvalidAgent", "An agent in the payment chain is invalid."),
+ AC15("AccountDetailsChanged", "Account details have changed."),
+ AC16("AccountInSequestration", "Account is in sequestration."),
+ AC17("AccountInLiquidation", "Account is in liquidation."),
+ AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"),
+ AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"),
+ AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."),
+ AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect."),
+ AM01("ZeroAmount", "Specified message amount is equal to zero"),
+ AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"),
+ AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"),
+ AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."),
+ AM05("Duplication", "Duplication"),
+ AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."),
+ AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."),
+ AM09("WrongAmount", "Amount received is not the amount agreed or expected"),
+ AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."),
+ ARDT("AlreadyReturnedTransaction", "Already returned original SCT"),
+ BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number, organisation ID or private ID."),
+ BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."),
+ BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"),
+ BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"),
+ BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."),
+ BE08("BankError", "Returned as a result of a bank error."),
+ BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid."),
+ BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid."),
+ BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid."),
+ BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid."),
+ CN01("AuthorisationCancelled", "Authorisation is cancelled."),
+ CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"),
+ CNPC("CashNotPickedUp", "Cash not picked up by Creditor or cash could not be delivered to Creditor"),
+ CURR("IncorrectCurrency", "Currency of the payment is incorrect"),
+ CUST("RequestedByCustomer", "Cancellation requested by the Debtor"),
+ DC04("NoCustomerCreditTransferReceived", "Return of Covering Settlement due to the underlying Credit Transfer details not being received."),
+ DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"),
+ DS28("ReturnForTechnicalReason", "Return following technical problems resulting in erroneous transaction."),
+ DT01("InvalidDate", "Invalid date (eg, wrong settlement date)"),
+ DT02("ChequeExpired", "Cheque has been issued but not deposited and is considered expired."),
+ DT04("FutureDateNotSupported", "Future date not supported."),
+ DUPL("DuplicatePayment", "Payment is a duplicate of another payment."),
+ ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."),
+ ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"),
+ ED05("SettlementFailed", "Settlement of the transaction has failed."),
+ EMVL("EMVLiabilityShift", "The card payment is fraudulent and was not processed with EMV technology for an EMV card."),
+ ERIN("ERIOptionNotSupported", "The Extended Remittance Information (ERI) option is not supported."),
+ FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."),
+ FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid."),
+ FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"),
+ FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid."),
+ FF07("InvalidPurpose", "Purpose is missing or invalid."),
+ FOCR("FollowingCancellationRequest", "Return following a cancellation request"),
+ FR01("Fraud", "Returned as a result of fraud."),
+ FRTR("FinalResponseMandateCancelled", "Final response/tracking is recalled as mandate is cancelled."),
+ G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."),
+ MD01("NoMandate", "No Mandate"),
+ MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."),
+ MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit."),
+ MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"),
+ MD07("EndCustomerDeceased", "End customer is deceased."),
+ MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"),
+ MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."),
+ NARR("Narrative", "Reason is provided as narrative information in the additional reason information."),
+ NOAS("NoAnswerFromCustomer", "No response from Beneficiary"),
+ NOCM("NotCompliant", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."),
+ NOOR("NoOriginalTransactionReceived", "Original SCT never received"),
+ PINL("PINLiabilityShift", "The card payment is fraudulent (lost and stolen fraud) and was processed as EMV transaction without PIN verification."),
+ RC01("BankIdentifierIncorrect", "Bank Identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."),
+ RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing."),
+ RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing."),
+ RC07("InvalidCreditorBICIdentifier", "Incorrrect BIC of the beneficiary Bank in the SCTR"),
+ RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."),
+ RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing."),
+ RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."),
+ RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"),
+ RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."),
+ RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."),
+ RR04("RegulatoryReason", "Regulatory Reason"),
+ RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."),
+ RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."),
+ RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."),
+ RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."),
+ RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."),
+ RR11("InvalidDebtorAgentServiceIdentification", "Invalid or missing identification of a bank proprietary service."),
+ RR12("InvalidPartyIdentification", "Invalid or missing identification required within a particular country or payment type."),
+ RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."),
+ SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent"),
+ SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent"),
+ SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."),
+ 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."),
+ SP01("PaymentStopped", "Payment is stopped by account holder."),
+ SP02("PreviouslyStopped", "Previously stopped by means of a stop payment advise."),
+ SVNR("ServiceNotRendered", "The card payment is returned since a cash amount rendered was not correct or goods or a service was not rendered to the customer, e.g. in an e-commerce situation."),
+ TM01("CutOffTime", "Associated message was received after agreed processing cut-off time."),
+ TRAC("RemovedFromTracking", "Return following direct debit being removed from tracking process."),
+ UPAY("UnduePayment", "Payment is not justified."),
+}
+
diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt
index 1b52d0cc..f3bb15e3 100644
--- a/nexus/src/test/kotlin/Iso20022Test.kt
+++ b/nexus/src/test/kotlin/Iso20022Test.kt
@@ -20,6 +20,7 @@
import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.nexus.*
+import tech.libeufin.nexus.ebics.*
import tech.libeufin.nexus.TxNotification.*
import java.nio.file.Files
import java.time.LocalDate
@@ -37,9 +38,9 @@ private fun instant(date: String): Instant =
class Iso20022Test {
@Test
- fun postfinance() {
- val content = Files.newInputStream(Path("sample/postfinance.xml"))
- val txs = parseTx(content, "CHF")
+ fun postfinance_camt054() {
+ val content = Files.newInputStream(Path("sample/platform/postfinance_camt054.xml"))
+ val txs = parseTx(content, "CHF", Dialect.postfinance)
assertEquals(
listOf(
OutgoingPayment(
@@ -50,23 +51,39 @@ class Iso20022Test {
creditPaytoUri = null
),
IncomingPayment(
- bankId = "231121CH0AZWCR9T",
+ bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71",
amount = TalerAmount("CHF:10"),
wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG",
executionTime = instant("2023-12-19"),
debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test"
),
IncomingPayment(
- bankId = "231121CH0AZWCVR1",
+ bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71",
amount = TalerAmount("CHF:2.53"),
wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB",
executionTime = instant("2023-12-19"),
debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test"
+ )
+ ),
+ txs
+ )
+ }
+
+ @Test
+ fun postfinance_camt053() {
+ val content = Files.newInputStream(Path("sample/platform/postfinance_camt053.xml"))
+ val txs = parseTx(content, "CHF", Dialect.postfinance)
+ assertEquals(
+ listOf(
+ Reversal(
+ msgId = "889d1a80-1267-49bd-8fcc-85701a",
+ reason = "InconsistenWithEndCustomer 'Identification of end customer is not consistent with associated account number, organisation ID or private ID.' - 'Keine Uebereinstimmung von Kontonummer und Kontoinhaber'",
+ executionTime = instant("2023-11-22")
),
Reversal(
- msgId = "50820f78-9024-44ff-978d-63a18c",
- reason = "RETOURE IHRER ZAHLUNG VOM 15.01.2024 ... GRUND: KEINE UEBEREINSTIMMUNG VON KONTONUMMER UND KONTOINHABER",
- executionTime = instant("2024-01-15")
+ msgId = "4cc61cc7-6230-49c2-b5e2-b40bbb",
+ reason = "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing.' - 'Postadresse des Kreditors fehlt oder ist unvollständig'",
+ executionTime = instant("2023-11-22")
)
),
txs
@@ -75,8 +92,8 @@ class Iso20022Test {
@Test
fun gls() {
- val content = Files.newInputStream(Path("sample/gls.xml"))
- val txs = parseTx(content, "EUR")
+ val content = Files.newInputStream(Path("sample/platform/gls.xml"))
+ val txs = parseTx(content, "EUR", Dialect.gls)
assertEquals(
listOf(
OutgoingPayment(
@@ -94,7 +111,7 @@ class Iso20022Test {
creditPaytoUri = "payto://iban/DE20500105172419259181"
),
IncomingPayment(
- bankId = "2024041210041357000",
+ bankId = "BYLADEM1WOR-G2910276709458A2",
amount = TalerAmount("EUR:3"),
wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
executionTime = instant("2024-04-12"),