summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-04-21 22:57:22 +0900
committerAntoine A <>2024-04-21 22:57:31 +0900
commitda656c2d89d09e3829b7884d5dc5f976c78bc088 (patch)
treee68a9c4ebc5da2ca85250ea10a996cf2f359cdc5
parentaf8fb100172f3f26d0e1a9a58c1ee3b614bd2d82 (diff)
downloadlibeufin-da656c2d89d09e3829b7884d5dc5f976c78bc088.tar.gz
libeufin-da656c2d89d09e3829b7884d5dc5f976c78bc088.tar.bz2
libeufin-da656c2d89d09e3829b7884d5dc5f976c78bc088.zip
Improve and fix camt parsing, and set end-to-end ID
-rw-r--r--nexus/sample/gls.xml236
-rw-r--r--nexus/sample/postfinance.xml173
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt251
-rw-r--r--nexus/src/test/kotlin/Iso20022Test.kt108
-rw-r--r--testbench/src/test/kotlin/Iso20022Test.kt29
5 files changed, 653 insertions, 144 deletions
diff --git a/nexus/sample/gls.xml b/nexus/sample/gls.xml
new file mode 100644
index 00000000..28dc691c
--- /dev/null
+++ b/nexus/sample/gls.xml
@@ -0,0 +1,236 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02 camt.053.001.02.xsd">
+ <BkToCstmrStmt>
+ <Stmt>
+ <Ntry>
+ <Amt Ccy="EUR">2.00</Amt>
+ <CdtDbtInd>DBIT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-18</Dt>
+ </BookgDt>
+ <AcctSvcrRef>2024041801514102000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>G059N0SR5V0WZ0XSFY1H92QBZ0</MsgId>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>2024041785403105090200000010000001</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">2.00</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ <Prtry>
+ <Cd>NTRF+177+08381</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>Mr Test</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>John Smith</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <CdtrAgt>
+ <FinInstnId>
+ <BIC>BYLADEM1WOR</BIC>
+ </FinInstnId>
+ </CdtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>TestABC123</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Überweisungsauftrag</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="EUR">1.10</Amt>
+ <CdtDbtInd>DBIT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-18</Dt>
+ </BookgDt>
+ <ValDt>
+ <Dt>2024-04-18</Dt>
+ </ValDt>
+ <AcctSvcrRef>2024041810552821000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ <Prtry>
+ <Cd>NTRF+177+08381</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>YF5QBARGQ0MNY0VK59S477VDG4</MsgId>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>2024041885917775090200000010000001</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">1.10</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ <Prtry>
+ <Cd>NTRF+177+08381</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>Mr Test</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>John Smith</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <CdtrAgt>
+ <FinInstnId>
+ <BIC>INGDDEFFXXX</BIC>
+ </FinInstnId>
+ </CdtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>This should fail because dummy</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Überweisungsauftrag</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="EUR">3.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-12</Dt>
+ </BookgDt>
+ <AcctSvcrRef>2024041210041357000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>BYLADEM1WOR-G2910276709458A2</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">3.00</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>John Smith</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>Mr Test</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <DbtrAgt>
+ <FinInstnId>
+ <BIC>BYLADEM1WOR</BIC>
+ </FinInstnId>
+ </DbtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Überweisungsgutschr.</AddtlNtryInf>
+ </Ntry>
+ </Stmt>
+ </BkToCstmrStmt>
+</Document>
+ \ No newline at end of file
diff --git a/nexus/sample/postfinance.xml b/nexus/sample/postfinance.xml
new file mode 100644
index 00000000..c075f31c
--- /dev/null
+++ b/nexus/sample/postfinance.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08 camt.054.001.08.xsd">
+ <BkToCstmrDbtCdtNtfctn>
+ <GrpHdr>
+ <MsgId>20240115375204422237387</MsgId>
+ </GrpHdr>
+ <Ntfctn>
+ <Ntry>
+ <Amt Ccy="CHF">3.00</Amt>
+ <CdtDbtInd>DBIT</CdtDbtInd>
+ <RvslInd>false</RvslInd>
+ <Sts>
+ <Cd>BOOK</Cd>
+ </Sts>
+ <BookgDt>
+ <Dt>2024-01-15</Dt>
+ </BookgDt>
+ <AcctSvcrRef>01542000B8GTEONK</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>DMCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>ZS1PGNTSV0ZNDFAJBBWWB8015G</MsgId>
+ <AcctSvcrRef>NOTPROVIDED</AcctSvcrRef>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <InstrId>NOTPROVIDED</InstrId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ </Refs>
+ <Amt Ccy="CHF">3.00</Amt>
+ <CdtDbtInd>DBIT</CdtDbtInd>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>LASTSCHRIFT ...</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <RvslInd>false</RvslInd>
+ <Sts>
+ <Cd>BOOK</Cd>
+ </Sts>
+ <BookgDt>
+ <Dt>2023-12-19</Dt>
+ </BookgDt>
+ <AcctSvcrRef>35332000B4R5BCIU</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>AUTT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <AcctSvcrRef>231121CH0AZWCR9T</AcctSvcrRef>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ </Refs>
+ <Amt Ccy="CHF">10.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>ATXN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Pty>
+ <Nm>Mr Test</Nm>
+ </Pty>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>CH7389144832588726658</IBAN>
+ </Id>
+ </DbtrAcct>
+ <CdtrAcct>
+ <Id>
+ <IBAN>CH9289144596463965762</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RmtInf>
+ <Ustrd>G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ</Ustrd>
+ <Ustrd>7JVGV543XZCB27YBG</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ <TxDtls>
+ <Refs>
+ <AcctSvcrRef>231121CH0AZWCVR1</AcctSvcrRef>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ </Refs>
+ <Amt Ccy="CHF">2.53</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>ATXN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <!-- TODO remove when supported -->
+ <RltdPties>
+ <Dbtr>
+ <Pty>
+ <Nm>Mr Test</Nm>
+ </Pty>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>CH7389144832588726658</IBAN>
+ </Id>
+ </DbtrAcct>
+ </RltdPties>
+ <RmtInf>
+ <Ustrd>G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ</Ustrd>
+ <Ustrd>7JVGV543XZCB27YB</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>SAMMELGUTSCHRIFT FÜR KONTO: ...</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="CHF">1.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <RvslInd>true</RvslInd>
+ <Sts>
+ <Cd>BOOK</Cd>
+ </Sts>
+ <BookgDt>
+ <Dt>2024-01-15</Dt>
+ </BookgDt>
+ <AcctSvcrRef>01542000B8K95YTK</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>50820f78-9024-44ff-978d-63a18c</MsgId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ </Refs>
+ <Amt Ccy="CHF">1.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>RETOURE IHRER ZAHLUNG VOM 15.01.2024 ... GRUND: KEINE UEBEREINSTIMMUNG VON KONTONUMMER UND KONTOINHABER</AddtlNtryInf>
+ </Ntry>
+ </Ntfctn>
+ </BkToCstmrDbtCdtNtfctn>
+</Document> \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index 429c21bf..e5a665ac 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -26,15 +26,6 @@ import java.time.*
import java.time.format.*
/**
- * Collects details to define the pain.001 namespace
- * XML attributes.
- */
-data class Pain001Namespaces(
- val fullNamespace: String,
- val xsdFilename: String
-)
-
-/**
* Gets the amount number, also converting it from the
* Taler-friendly 8 fractional digits to the more bank
* friendly with 2.
@@ -87,7 +78,9 @@ fun createPain001(
attr("xsi:schemaLocation", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version pain.001.001.$version.xsd")
el("CstmrCdtTrfInitn") {
el("GrpHdr") {
- el("MsgId", requestUid)
+ // Use for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre- agreed period
+ // This is especially important for bounces
+ el("MsgId", requestUid)
el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp))
el("NbOfTxs", "1")
el("CtrlSum", amountWithoutCurrency)
@@ -119,7 +112,8 @@ fun createPain001(
el("CdtTrfTxInf") {
el("PmtId") {
el("InstrId", "NOTPROVIDED")
- el("EndToEndId", "NOTPROVIDED")
+ // Used to identify this transaction in CAMT files when MsgId is not present
+ el("EndToEndId", requestUid)
}
el("Amt/InstdAmt") {
attr("Ccy", amount.currency)
@@ -291,7 +285,7 @@ data class IncomingPayment(
/** ISO20022 outgoing payment */
data class OutgoingPayment(
- /** ISO20022 MessageIdentification */
+ /** ISO20022 MessageIdentification & EndToEndId */
val messageId: String,
val amount: TalerAmount,
val wireTransferSubject: String? = null, // not showing in camt.054
@@ -323,18 +317,33 @@ fun parseTx(
notifXml: InputStream,
acceptedCurrency: String
): List<TxNotification> {
- fun XmlDestructor.parseNotif(): List<RawTx> {
+ /*
+ 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.
+ */
+
+ /** Assert that transaction status is BOOK */
+ fun XmlDestructor.assertBooked() {
one("Sts") {
- if (text() != "BOOK") {
- one("Cd") {
- if (text() != "BOOK")
- throw Exception("Found non booked transaction, " +
- "stop parsing. Status was: ${text()}"
- )
- }
+ val status = opt("Cd")?.text() ?: text()
+ require(status == "BOOK") {
+ "Found non booked transaction, stop parsing: expected BOOK got $status"
}
}
- var reversal = opt("RvslInd")?.bool() ?: false
+ }
+ /** 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
@@ -343,105 +352,67 @@ fun parseTx(
val familyCode = one("Cd")
val subFamilyCode = one("SubFmlyCd").text()
if (subFamilyCode == "RRTN" || subFamilyCode == "RPCR") {
- reversal = true
+ head.reversal = true
}
}
}
}
- val info = opt("AddtlNtryInf")?.text()
- val bookDate: Instant = one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC)
- val ref = opt("AcctSvcrRef")?.text()
- return one("NtryDtls").map("TxDtls") {
- val kind = one("CdtDbtInd").text()
- val amount: TalerAmount = 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()}")
- }
- var msgId = opt("Refs")?.opt("MsgId")?.text()
- val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
- var debtorPayto = opt("RltdPties") { payto("Dbtr") }
- var creditorPayto = opt("RltdPties") { payto("Cdtr") }
- RawTx(
- kind,
- bookDate,
- amount,
- reversal,
- info,
- ref,
- msgId,
- subject,
- debtorPayto,
- creditorPayto
- )
- }
}
- fun XmlDestructor.parseStatement(): RawTx {
- one("Sts") {
- if (text() != "BOOK") {
- one("Cd") {
- if (text() != "BOOK")
- throw Exception("Found non booked transaction, " +
- "stop parsing. Status was: ${text()}"
- )
- }
- }
- }
- var reversal = opt("RvslInd")?.bool() ?: false
- val info = opt("AddtlNtryInf")?.text()
- val bookDate: Instant = one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC)
- val kind = one("CdtDbtInd").text()
- val amount: TalerAmount = one("Amt") {
+ /** 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()}")
}
- val ref = opt("AcctSvcrRef")?.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") {
- 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") {
- reversal = true
- }
- }
- }
- }
- var msgId = opt("Refs")?.opt("MsgId")?.text()
- val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
- var debtorPayto = opt("RltdPties") { payto("Dbtr") }
- var creditorPayto = opt("RltdPties") { payto("Cdtr") }
- RawTx(
- kind,
- bookDate,
- amount,
- reversal,
- info,
- ref,
- msgId,
- subject,
- debtorPayto,
- creditorPayto
- )
+ parseCode(head)
+ val btm = parseBtm()
+ RawTx(head, mid, btm)
}
}
val raws = mutableListOf<RawTx>()
XmlDestructor.fromStream(notifXml, "Document") {
- opt("BkToCstmrDbtCdtNtfctn") {
+ opt("BkToCstmrDbtCdtNtfctn") { // Camt.054
each("Ntfctn") {
+ opt("Acct") {
+ // Sanity check on currency and IBAN ?
+ }
each("Ntry") {
raws.addAll(parseNotif())
}
}
- } ?: opt("BkToCstmrStmt") {
+ } ?: opt("BkToCstmrStmt") { // Camt.053
each("Stmt") {
- one("Acct") {
+ opt("Acct") {
// Sanity check on currency and IBAN ?
}
each("Ntry") {
@@ -461,58 +432,78 @@ fun parseTx(
}
}
-private data class RawTx(
+
+private data class TxHead(
+ var reversal: Boolean,
+ val entryInfo: String?,
+ val date: Instant,
+ val entryRef: String?
+)
+
+private data class TxMid(
val kind: String,
- val bookDate: Instant,
val amount: TalerAmount,
- val reversal: Boolean,
- val info: String?,
- val ref: String?,
+)
+
+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 {
- if (raw.reversal) {
- require("CRDT" == raw.kind) // TODO handle DBIT reversal
- if (raw.msgId == null)
- throw TxErr("missing msg ID for Credit reversal ${raw.ref}")
+ 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 = raw.msgId,
- reason = raw.info,
- executionTime = raw.bookDate
+ msgId = msgId,
+ reason = entryInfo,
+ executionTime = date
)
}
- return when (raw.kind) {
+ return when (kind) {
"CRDT" -> {
- if (raw.ref == null)
- throw TxErr("missing subject for Credit ${raw.ref}")
- if (raw.subject == null)
- throw TxErr("missing subject for Credit ${raw.ref}")
- if (raw.debtorPayto == null)
- throw TxErr("missing debtor info for Credit ${raw.ref}")
+ 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")
IncomingPayment(
- amount = raw.amount,
- bankId = raw.ref,
- debitPaytoUri = raw.debtorPayto,
- executionTime = raw.bookDate,
- wireTransferSubject = raw.subject
+ amount = amount,
+ bankId = dbgRef,
+ debitPaytoUri = debtorPayto,
+ executionTime = date,
+ wireTransferSubject = subject
)
}
"DBIT" -> {
- if (raw.msgId == null)
- throw TxErr("missing msg ID for Debit ${raw.ref}")
+ if (msgId == null)
+ throw TxErr("missing msg ID for Debit $dbgRef")
OutgoingPayment(
- amount = raw.amount,
- messageId = raw.msgId,
- executionTime = raw.bookDate,
- creditPaytoUri = raw.creditorPayto
+ amount = amount,
+ messageId = msgId,
+ executionTime = date,
+ creditPaytoUri = creditorPayto,
+ wireTransferSubject = subject
)
}
- else -> throw Exception("Unknown transaction notification kind '${raw.kind}'")
+ else -> throw Exception("Unknown transaction notification kind '$kind'")
}
} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt
new file mode 100644
index 00000000..1b52d0cc
--- /dev/null
+++ b/nexus/src/test/kotlin/Iso20022Test.kt
@@ -0,0 +1,108 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import org.junit.Test
+import tech.libeufin.common.*
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.TxNotification.*
+import java.nio.file.Files
+import java.time.LocalDate
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import kotlin.io.path.Path
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+import kotlin.test.assertEquals
+
+private fun instant(date: String): Instant =
+ LocalDate.parse(date, DateTimeFormatter.ISO_DATE).atStartOfDay().toInstant(ZoneOffset.UTC)
+
+class Iso20022Test {
+ @Test
+ fun postfinance() {
+ val content = Files.newInputStream(Path("sample/postfinance.xml"))
+ val txs = parseTx(content, "CHF")
+ assertEquals(
+ listOf(
+ OutgoingPayment(
+ messageId = "ZS1PGNTSV0ZNDFAJBBWWB8015G",
+ amount = TalerAmount("CHF:3.00"),
+ wireTransferSubject = null,
+ executionTime = instant("2024-01-15"),
+ creditPaytoUri = null
+ ),
+ IncomingPayment(
+ bankId = "231121CH0AZWCR9T",
+ amount = TalerAmount("CHF:10"),
+ wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG",
+ executionTime = instant("2023-12-19"),
+ debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test"
+ ),
+ IncomingPayment(
+ bankId = "231121CH0AZWCVR1",
+ amount = TalerAmount("CHF:2.53"),
+ wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB",
+ executionTime = instant("2023-12-19"),
+ debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test"
+ ),
+ 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")
+ )
+ ),
+ txs
+ )
+ }
+
+ @Test
+ fun gls() {
+ val content = Files.newInputStream(Path("sample/gls.xml"))
+ val txs = parseTx(content, "EUR")
+ assertEquals(
+ listOf(
+ OutgoingPayment(
+ messageId = "G059N0SR5V0WZ0XSFY1H92QBZ0",
+ amount = TalerAmount("EUR:2"),
+ wireTransferSubject = "TestABC123",
+ executionTime = instant("2024-04-18"),
+ creditPaytoUri = "payto://iban/DE20500105172419259181"
+ ),
+ OutgoingPayment(
+ messageId = "YF5QBARGQ0MNY0VK59S477VDG4",
+ amount = TalerAmount("EUR:1.1"),
+ wireTransferSubject = "This should fail because dummy",
+ executionTime = instant("2024-04-18"),
+ creditPaytoUri = "payto://iban/DE20500105172419259181"
+ ),
+ IncomingPayment(
+ bankId = "2024041210041357000",
+ amount = TalerAmount("EUR:3"),
+ wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
+ executionTime = instant("2024-04-12"),
+ debitPaytoUri = "payto://iban/DE84500105177118117964"
+ ),
+ // TODO add reversal
+ ),
+ txs
+ )
+ }
+} \ No newline at end of file
diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt
index 55a0f6d7..c51bfd64 100644
--- a/testbench/src/test/kotlin/Iso20022Test.kt
+++ b/testbench/src/test/kotlin/Iso20022Test.kt
@@ -50,22 +50,23 @@ class Iso20022Test {
val root = Path("test")
if (!root.exists()) return
for (platform in root.listDirectoryEntries()) {
+ if (!platform.isDirectory()) continue
for (file in platform.listDirectoryEntries()) {
+ if (!file.isDirectory()) continue
val fetch = file.resolve("fetch")
- if (file.isDirectory() && fetch.exists()) {
- val cfg = loadConfig(platform.resolve("ebics.conf"))
- val currency = cfg.requireString("nexus-ebics", "currency")
- for (log in fetch.listDirectoryEntries()) {
- val content = Files.newInputStream(log)
- val name = log.toString()
- println(name)
- if (name.contains("HAC")) {
- parseCustomerAck(content)
- } else if (name.contains("pain.002")) {
- parseCustomerPaymentStatusReport(content)
- } else if (!name.contains("camt.052") && !name.contains("_C52_") && !name.contains("_Z01_")) {
- parseTx(content, currency)
- }
+ if (!fetch.exists()) continue
+ val cfg = loadConfig(platform.resolve("ebics.conf"))
+ val currency = cfg.requireString("nexus-ebics", "currency")
+ for (log in fetch.listDirectoryEntries()) {
+ val content = Files.newInputStream(log)
+ val name = log.toString()
+ println(name)
+ if (name.contains("HAC")) {
+ parseCustomerAck(content)
+ } else if (name.contains("pain.002")) {
+ parseCustomerPaymentStatusReport(content)
+ } else if (!name.contains("camt.052") && !name.contains("_C52_") && !name.contains("_Z01_")) {
+ parseTx(content, currency)
}
}
}