diff options
author | Antoine A <> | 2024-04-21 22:57:22 +0900 |
---|---|---|
committer | Antoine A <> | 2024-04-21 22:57:31 +0900 |
commit | da656c2d89d09e3829b7884d5dc5f976c78bc088 (patch) | |
tree | e68a9c4ebc5da2ca85250ea10a996cf2f359cdc5 | |
parent | af8fb100172f3f26d0e1a9a58c1ee3b614bd2d82 (diff) | |
download | libeufin-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.xml | 236 | ||||
-rw-r--r-- | nexus/sample/postfinance.xml | 173 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 251 | ||||
-rw-r--r-- | nexus/src/test/kotlin/Iso20022Test.kt | 108 | ||||
-rw-r--r-- | testbench/src/test/kotlin/Iso20022Test.kt | 29 |
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) } } } |