libeufin

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

commit 9437d034203690d0f536e428a0025f0416186a5b
parent a6513ad4a27426cdd24d233393ce8dc54495d079
Author: Florian Dold <florian.dold@gmail.com>
Date:   Thu, 11 Jun 2020 21:29:03 +0530

implement improved ISO20022 camt parsing

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt | 1-
Anexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anexus/src/test/kotlin/Iso20022Test.kt | 26++++++++++++++++++++++++++
Anexus/src/test/resources/iso20022-samples/camt.053.001.02.gesamtbeispiel.xml | 701+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mutil/src/main/kotlin/XmlCombinators.kt | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
5 files changed, 1126 insertions(+), 5 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt @@ -193,7 +193,6 @@ fun ingestBankMessagesIntoAccount( } acct.highestSeenBankMessageId = lastId } - } /** diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -0,0 +1,321 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2020 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/> + */ + +package tech.libeufin.nexus + +/** + * Parse ISO 20022 messages + */ + +import com.fasterxml.jackson.annotation.JsonInclude +import org.w3c.dom.Document +import tech.libeufin.util.XmlElementDestructor +import tech.libeufin.util.destructXml + +enum class CreditDebitIndicator { + DBIT, CRDT +} + +enum class TransactionStatus { + BOOK, PENDING +} + +data class TransactionDetails( + /** + * Related parties as JSON. + */ + val relatedParties: RelatedParties, + val amountDetails: AmountDetails, + val references: References, + /** + * Unstructured remittance information (=subject line) of the transaction, + * or the empty string if missing. + */ + val unstructuredRemittanceInformation: String +) + +data class BankTransaction( + val accountIdentifier: String, + /** + * Scheme used for the account identifier. + */ + val accountScheme: String, + val currency: String, + val amount: String, + /** + * Booked, pending, etc. + */ + val status: TransactionStatus, + /** + * Is this transaction debiting or crediting the account + * it is reported for? + */ + val creditDebitIndicator: CreditDebitIndicator, + /** + * Code that describes the type of bank transaction + * in more detail + */ + val bankTransactionCode: BankTransactionCode, + /** + * Is this a batch booking? + */ + val isBatch: Boolean, + val details: List<TransactionDetails> +) + +abstract class TypedEntity(val type: String) + +@JsonInclude(JsonInclude.Include.NON_NULL) +class Agent( + val name: String?, + val bic: String +) : TypedEntity("agent") + +@JsonInclude(JsonInclude.Include.NON_NULL) +class Party( + val name: String? +) : TypedEntity("party") + +@JsonInclude(JsonInclude.Include.NON_NULL) +class Account( + val iban: String? +) : TypedEntity("party") + + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BankTransactionCode( + /** + * Standardized bank transaction code, as "$domain/$family/$subfamily" + */ + val iso: String?, + + /** + * Proprietary code, as "$issuer/$code". + */ + val proprietary: String? +) + +data class AmountAndCurrencyExchangeDetails( + val amount: String, + val currency: String +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class AmountDetails( + val instructedAmount: AmountAndCurrencyExchangeDetails?, + val transactionAmount: AmountAndCurrencyExchangeDetails? +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class References( + val endToEndIdentification: String? +) + +/** + * This structure captures both "TransactionParties6" and "TransactionAgents5" + * of ISO 20022. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class RelatedParties( + val debtor: Party?, + val debtorAccount: Account?, + val debtorAgent: Agent?, + val creditor: Party?, + val creditorAccount: Account?, + val creditorAgent: Agent? +) + +class CamtParsingError(msg: String) : Exception(msg) + +private fun XmlElementDestructor.extractAgent(): Agent { + return Agent( + name = maybeUniqueChildNamed("FinInstnId") { + maybeUniqueChildNamed("Nm") { + it.textContent + } + }, + bic = requireUniqueChildNamed("FinInstnId") { + requireUniqueChildNamed("BIC") { + it.textContent + } + } + ) +} + +private fun XmlElementDestructor.extractAccount(): Account { + return Account( + iban = requireUniqueChildNamed("Id") { + maybeUniqueChildNamed("IBAN") { + it.textContent + } + } + ) +} + +private fun XmlElementDestructor.extractParty(): Party { + return Party( + name = maybeUniqueChildNamed("Nm") { it.textContent } + ) +} + +private fun XmlElementDestructor.extractPartiesAndAgents(): RelatedParties { + return RelatedParties( + debtor = maybeUniqueChildNamed("RltdPties") { + maybeUniqueChildNamed("Dbtr") { + extractParty() + } + }, + creditor = maybeUniqueChildNamed("RltdPties") { + maybeUniqueChildNamed("Cdtr") { + extractParty() + } + }, + creditorAccount = maybeUniqueChildNamed("RltdPties") { + maybeUniqueChildNamed("CdtrAcct") { + extractAccount() + } + }, + debtorAccount = maybeUniqueChildNamed("RltdPties") { + maybeUniqueChildNamed("DbtrAcct") { + extractAccount() + } + }, + creditorAgent = maybeUniqueChildNamed("RltdAgts") { + maybeUniqueChildNamed("CdtrAgt") { + extractAgent() + } + }, + debtorAgent = maybeUniqueChildNamed("RltdAgts") { + maybeUniqueChildNamed("DbtrAgt") { + extractAgent() + } + } + ) +} + +private fun XmlElementDestructor.extractAmountAndCurrencyExchangeDetails(): AmountAndCurrencyExchangeDetails { + return AmountAndCurrencyExchangeDetails( + amount = requireUniqueChildNamed("Amt") { it.textContent}, + currency = requireUniqueChildNamed("Amt") { it.getAttribute("Ccy") } + ) +} + +private fun XmlElementDestructor.extractTransactionDetails(): List<TransactionDetails> { + return requireUniqueChildNamed("NtryDtls") { + mapEachChildNamed("TxDtls") { + TransactionDetails( + relatedParties = extractPartiesAndAgents(), + amountDetails = maybeUniqueChildNamed("AmtDtls") { + AmountDetails( + instructedAmount = maybeUniqueChildNamed("InstrAmt") { extractAmountAndCurrencyExchangeDetails() }, + transactionAmount = maybeUniqueChildNamed("TxAmt") { extractAmountAndCurrencyExchangeDetails() } + ) + } ?: AmountDetails(null, null), + references = maybeUniqueChildNamed("Refs") { + References( + endToEndIdentification = maybeUniqueChildNamed("EndToEndId") { it.textContent } + ) + } ?: References(null), + unstructuredRemittanceInformation = maybeUniqueChildNamed("RmtInf") { + requireUniqueChildNamed("Ustrd") { it.textContent } + } ?: "" + ) + } + } +} + +private fun XmlElementDestructor.extractInnerTransactions(): List<BankTransaction> { + val iban = requireUniqueChildNamed("Acct") { + requireUniqueChildNamed("Id") { + requireUniqueChildNamed("IBAN") { + it.textContent + } + } + } + + return mapEachChildNamed("Ntry") { + val amount = requireUniqueChildNamed("Amt") { it.textContent } + val currency = requireUniqueChildNamed("Amt") { it.getAttribute("Ccy") } + val status = requireUniqueChildNamed("Sts") { it.textContent }.let { + TransactionStatus.valueOf(it) + } + val creditDebitIndicator = requireUniqueChildNamed("CdtDbtInd") { it.textContent }.let { + CreditDebitIndicator.valueOf(it) + } + val btc = requireUniqueChildNamed("BkTxCd") { + BankTransactionCode( + proprietary = maybeUniqueChildNamed("Prtry") { + val cd = requireUniqueChildNamed("Cd") { it.textContent } + val issr = requireUniqueChildNamed("Issr") { it.textContent } + "$issr:$cd" + }, + iso = maybeUniqueChildNamed("Domn") { + val cd = requireUniqueChildNamed("Cd") { it.textContent } + val r = requireUniqueChildNamed("Fmly") { + object { + val fmlyCd = requireUniqueChildNamed("Cd") { it.textContent } + val subFmlyCd = requireUniqueChildNamed("SubFmlyCd") { it.textContent } + } + } + "$cd/${r.fmlyCd}/${r.subFmlyCd}" + } + ) + } + val details = extractTransactionDetails() + BankTransaction( + accountIdentifier = iban, + accountScheme = "iban", + amount = amount, + currency = currency, + status = status, + creditDebitIndicator = creditDebitIndicator, + bankTransactionCode = btc, + details = details, + isBatch = details.size > 1 + ) + } +} + +/** + * Extract a list of transactions from an ISO20022 camt.052 / camt.053 message. + */ +fun getTransactions(doc: Document): List<BankTransaction> { + return destructXml(doc) { + requireRootElement("Document") { + // Either bank to customer statement or report + requireOnlyChild() { + when (it.localName) { + "BkToCstmrAcctRpt" -> { + mapEachChildNamed("Rpt") { + extractInnerTransactions() + } + } + "BkToCstmrStmt" -> { + mapEachChildNamed("Stmt") { + extractInnerTransactions() + } + } + else -> { + throw CamtParsingError("expected statement or report") + } + } + } + } + }.flatten() +} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -0,0 +1,25 @@ +package tech.libeufin.nexus +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.Test +import org.w3c.dom.Document +import tech.libeufin.util.XMLUtil + + +fun loadXmlResource(name: String): Document { + val classLoader = ClassLoader.getSystemClassLoader() + val res = classLoader.getResource(name) + if (res == null) { + throw Exception("resource $name not found"); + } + return XMLUtil.parseStringIntoDom(res.readText()) +} + +class Iso20022Test { + @Test + fun testTransactionsImport() { + val camt53 = loadXmlResource("iso20022-samples/camt.053.001.02.gesamtbeispiel.xml") + val txs = getTransactions(camt53) + println(jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(txs)) + } +} +\ No newline at end of file diff --git a/nexus/src/test/resources/iso20022-samples/camt.053.001.02.gesamtbeispiel.xml b/nexus/src/test/resources/iso20022-samples/camt.053.001.02.gesamtbeispiel.xml @@ -0,0 +1,701 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Mit XMLSpy v2008 rel. 2 sp2 (http://www.altova.com) im Mai 2016 von der SIZ GmbH (Wenzel) angepasst hinsichtlich Anlage 3, Version 3.0: --> +<!-- 1. BkTxCd Pflicht auf Entryebene, 2. Issuer nun "DK" (Statt "ZKA"), 3. Mapping GVC auf Domn, 4. Nichtdrehen bei R-Transaktionen illustriert 5. Schluss-Saldo angepasst (Löschung der DTAUS-Umsätze)--> +<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> + <GrpHdr> + <MsgId>27632364572</MsgId> + <CreDtTm>2016-05-11T19:30:47.0+01:00</CreDtTm> + <MsgRcpt> + <Id> + <OrgId> + <Othr> + <Id>BCS45678</Id> + </Othr> + </OrgId> + </Id> + </MsgRcpt> + <MsgPgntn> + <PgNb>1</PgNb> + <LastPgInd>true</LastPgInd> + </MsgPgntn> + </GrpHdr> + <Stmt> + <Id>2736482736482</Id> + <ElctrncSeqNb>101</ElctrncSeqNb> + <!--Folgendes Feld ist optional und könnte die Papier-KAZ enthalten--> + <LglSeqNb>32</LglSeqNb> + <CreDtTm>2016-05-11T17:30:47.0+01:00</CreDtTm> + <Acct> + <Id> + <IBAN>DE62210500001234567890</IBAN> + </Id> + <Ccy>EUR</Ccy> + <Ownr> + <Nm>Name Kontoinhaber</Nm> + </Ownr> + <Svcr> + <FinInstnId> + <BIC>BANKDEFFXXX</BIC> + <Othr> + <Id>DE123456789</Id> + <Issr>UmsStId</Issr> + </Othr> + </FinInstnId> + </Svcr> + </Acct> + <Bal> + <Tp> + <CdOrPrtry> + <Cd>PRCD</Cd> + </CdOrPrtry> + </Tp> + <Amt Ccy="EUR">112.72</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Dt> + <Dt>2016-05-11</Dt> + </Dt> + </Bal> + <Bal> + <Tp> + <CdOrPrtry> + <Cd>CLBD</Cd> + </CdOrPrtry> + </Tp> + <Amt Ccy="EUR">158530.32</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Dt> + <Dt>2016-05-11</Dt> + </Dt> + </Bal> + <!-- Beispiel 1: SEPA-Zahlungen (Ueberweisung, Lastschrift, R-Nachricht --> + <!--Gutschrift aufgrund eines SEPA-Ueberweisungseinganges--> + <Ntry> + <Amt Ccy="EUR">100.00</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>ESCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>166</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>Ende-zu-Ende-Id des Ueberweisenden</EndToEndId> + </Refs> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>ESCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NTRF+166</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Herr Ueberweisender</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE21500500001234567897</IBAN> + </Id> + </DbtrAcct> + <UltmtDbtr> + <Nm>Herr Debtor Reference Party</Nm> + </UltmtDbtr> + <Cdtr> + <Nm>Herr Kontoinhaber</Nm> + </Cdtr> + <UltmtCdtr> + <Nm>Herr Creditor Reference Party</Nm> + </UltmtCdtr> + </RltdPties> + <Purp> + <Cd>GDDS</Cd> + </Purp> + <RmtInf> + <Ustrd>Rechnungsnr. 4711 vom 20.04.2016</Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>SEPA GUTSCHRIFT</AddtlNtryInf> + </Ntry> + <!--Gutschrift aufgrund einer zurueckgekommenen SEPA-Ueberweisung--> + <Ntry> + <Amt Ccy="EUR">200.00</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>RRTN</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>159</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>Urspr. E2E-Id der Hintransaktion</EndToEndId> + </Refs> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>RRTN</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+159++901</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <RmtInf> + <Ustrd>Angabe des urspruenglichen Verwendungszweckes</Ustrd> + </RmtInf> + <!--Informationen zur Originaltransaktion. Da die Belegung von Domain optional ist, kann auch nur Prtry (GVC) vorhanden sein--> + <RtrInf> + <OrgnlBkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>ESCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NTRF+116</Cd> + <Issr>DK</Issr> + </Prtry> + </OrgnlBkTxCd> + <Orgtr> + <Id> + <OrgId> + <BICOrBEI>BANKDEHH</BICOrBEI> + </OrgId> + </Id> + </Orgtr> + <Rsn> + <Cd>AC01</Cd> + </Rsn> + <AddtlInf>IBAN FEHLERHAFT</AddtlInf> + </RtrInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>SEPA RUECKBUCHUNG</AddtlNtryInf> + </Ntry> + <!--Belastung aufgrund einer SEPA-Lastschrift--> + <Ntry> + <Amt Ccy="EUR">50.00</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>RDDT</Cd> + <SubFmlyCd>ESDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>105</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>E2E-Id vergeben vom Glaeubiger</EndToEndId> + <MndtId>Ref. des SEPA-Lastschriftmandats</MndtId> + </Refs> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>RDDT</Cd> + <SubFmlyCd>ESDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NDDT+105</Cd> + <Issr>DKA</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Herr Zahlungspflichtiger</Nm> + </Dbtr> + <UltmtDbtr> + <Nm>Herr Debtor Reference Party</Nm> + </UltmtDbtr> + <Cdtr> + <Nm>Glaeubigerfirma</Nm> + <Id> + <PrvtId> + <Othr> + <Id>Cdtr-Id des Glaeubigers</Id> + </Othr> + </PrvtId> + </Id> + </Cdtr> + </RltdPties> + <Purp> + <Cd>PHON</Cd> + </Purp> + <RmtInf> + <Ustrd>Telefonrechnung April 2016, Vertragsnummer 3536456345</Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>SEPA LASTSCHRIFT</AddtlNtryInf> + </Ntry> + <!-- Beispiel 2: DTAUS-Zahlungen (Ueberweisung, Lastschrift, Rueckgabe) BEISPIEL ENTFERNT --> + <!-- Beispiel 3a: Sammlerdarstellung mit Aufloesung innerhalb der Nachricht --> + <!--Belastung aufgrund von SEPA-Lastschriftrueckgaben (Sammelbuchung) mit Sammleraufloesung unter Transaction Details--> + <Ntry> + <Amt Ccy="EUR">276</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>IDDT</Cd> + <SubFmlyCd>UPDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>109</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <Btch> + <NbOfTxs>3</NbOfTxs> + </Btch> + <TxDtls> + <!-- Ab hier Aufloesung des Sammlers bestehend aus 3 Einzelumsaetzen --> + <Refs> + <EndToEndId>79892</EndToEndId> + <MndtId>10001</MndtId> + </Refs> + <AmtDtls> + <TxAmt> + <Amt Ccy="EUR">76</Amt> + </TxAmt> + </AmtDtls> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>IDDT</Cd> + <SubFmlyCd>UPDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+109++901</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <!--Weil bei R-Transaktionen nicht gedreht wird, stehen hier die Originals--> + <RltdPties> + <Dbtr> + <Nm>Herr Zahlungspflichtiger 1</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE83700202707777777777</IBAN> + </Id> + </DbtrAcct> + <Cdtr> + <Nm>Telefongesellschaft ABC</Nm> + <Id> + <PrvtId> + <Othr> + <Id>CdtrId des SEPA-Lastschrifteinr.</Id> + </Othr> + </PrvtId> + </Id> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE62210500001234567890</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <Purp> + <Cd>PHON</Cd> + </Purp> + <RmtInf> + <Ustrd>Telefonrechnung April 2016, Vertragsnummer 3536456345</Ustrd> + </RmtInf> + <RtrInf> + <Rsn> + <Cd>AC01</Cd> + </Rsn> + <AddtlInf>RUECKLASTSCHRIFT IBAN FEHLERHAFT</AddtlInf> + </RtrInf> + </TxDtls> + <TxDtls> + <Refs> + <EndToEndId>768768</EndToEndId> + <MndtId>10002</MndtId> + </Refs> + <AmtDtls> + <TxAmt> + <Amt Ccy="EUR">80</Amt> + </TxAmt> + </AmtDtls> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>IDDT</Cd> + <SubFmlyCd>UPDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+109++901</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Herr Zahlungspflichtiger 2</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE83700202704444444444</IBAN> + </Id> + </DbtrAcct> + <Cdtr> + <Nm>Telefongesellschaft ABC</Nm> + <Id> + <PrvtId> + <Othr> + <Id>CdtrId des SEPA-Lastschrifteinr.</Id> + </Othr> + </PrvtId> + </Id> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE62210500001234567890</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <Purp> + <Cd>PHON</Cd> + </Purp> + <RmtInf> + <Ustrd>Telefonrechnung April 2016, Vertragsnummer 3536456888</Ustrd> + </RmtInf> + <RtrInf> + <Rsn> + <Cd>AC01</Cd> + </Rsn> + <AddtlInf>RUECKLASTSCHRIFT IBAN FEHLERHAFT</AddtlInf> + </RtrInf> + </TxDtls> + <TxDtls> + <Refs> + <EndToEndId>45456465</EndToEndId> + <MndtId>10003</MndtId> + </Refs> + <AmtDtls> + <TxAmt> + <Amt Ccy="EUR">120</Amt> + </TxAmt> + </AmtDtls> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>IDDT</Cd> + <SubFmlyCd>UPDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+109++901</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Herr Zahlungspflichtiger 3</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE83700202703333333333</IBAN> + </Id> + </DbtrAcct> + <Cdtr> + <Nm>Telefongesellschaft ABC</Nm> + <Id> + <PrvtId> + <Othr> + <Id>CdtrId des SEPA-Lastschrifteinr.</Id> + </Othr> + </PrvtId> + </Id> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE62210500001234567890</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <Purp> + <Cd>PHON</Cd> + </Purp> + <RmtInf> + <Ustrd>Telefonrechnung April 2016, Vertragsnummer 3536456345</Ustrd> + </RmtInf> + <RtrInf> + <Rsn> + <Cd>AC01</Cd> + </Rsn> + <AddtlInf>RUECKLASTSCHRIFT IBAN FEHLERHAFT</AddtlInf> + </RtrInf> + </TxDtls> + </NtryDtls> + </Ntry> + <!-- Beispiel 3b: Sammlerdarstellung mit Verweis auf pain-Nachricht und separate camt.054.001.01-Nachricht --> + <!--Belastung aufgrund einer SEPA-Ueberweisung (Sammler) mit Verweis auf Original pain-Nachricht--> + <Ntry> + <Amt Ccy="EUR">100876.00</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>ESCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>191</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <Btch> + <MsgId>MsgId der pain-Nachricht</MsgId> + <PmtInfId>Sammler-Id dieser pain-Nachricht</PmtInfId> + </Btch> + <TxDtls> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>ESCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NTRF+191</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>SEPA Credit Transfer (Sammler-Soll)</AddtlNtryInf> + </Ntry> + <!--Belastung aufgrund von SEPA-Lastschriftrueckgaben (Sammelbuchung) mit Verweis auf separate camt.054.001.01-Nachricht--> + <Ntry> + <Amt Ccy="EUR">276.00</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>IDDT</Cd> + <SubFmlyCd>UPDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>109</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <AddtlInfInd> + <MsgNmId>camt.054.001.01</MsgNmId> + <MsgId>054-20160511-00034</MsgId> + <!-- siehe Bsp. camt54 Bsp 3b --> + </AddtlInfInd> + <NtryDtls> + <TxDtls> + <BkTxCd> + <Domn> + <Cd>PNMT</Cd> + <Fmly> + <Cd>IDDT</Cd> + <SubFmlyCd>UPDD</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+109++901</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + </TxDtls> + </NtryDtls> + </Ntry> + <!-- Beispiel 4: USD-Zahlung mit Gutschrift auf einem EUR-Konto --> + <!-- USD-Zahlung mit Gutschrift auf einem EUR-Konto --> + <Ntry> + <Amt Ccy="EUR">259595.60</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2016-05-11</Dt> + </BookgDt> + <ValDt> + <Dt>2016-05-11</Dt> + </ValDt> + <AcctSvcrRef>Bankreferenz</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>XBCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NTRF+202</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <TxDtls> + <AmtDtls> + <InstdAmt> + <Amt Ccy="USD">360873.97</Amt> + </InstdAmt> + <TxAmt> + <Amt Ccy="EUR">259595.60</Amt> + </TxAmt> + <CntrValAmt> + <Amt Ccy="EUR">259621.56</Amt> + <CcyXchg> + <SrcCcy>USD</SrcCcy> + <TrgtCcy>EUR</TrgtCcy> + <XchgRate>1.39</XchgRate> + </CcyXchg> + </CntrValAmt> + </AmtDtls> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>XBCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NTRF+202</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <Chrgs> + <Amt Ccy="EUR">25.96</Amt> + </Chrgs> + <RltdPties> + <Dbtr> + <Nm>West Coast Ltd.</Nm> + <PstlAdr> + <Ctry>US</Ctry> + <AdrLine>52, Main Street</AdrLine> + <AdrLine>3733 San Francisco</AdrLine> + </PstlAdr> + </Dbtr> + <DbtrAcct> + <Id> + <Othr> + <Id>546237687</Id> + </Othr> + </Id> + </DbtrAcct> + </RltdPties> + <RltdAgts> + <DbtrAgt> + <FinInstnId> + <BIC>BANKUSNY</BIC> + </FinInstnId> + </DbtrAgt> + </RltdAgts> + <RmtInf> + <Ustrd>Invoice No. 4545</Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>AZV-UEBERWEISUNGSGUTSCHRIFT</AddtlNtryInf> + </Ntry> + </Stmt> + </BkToCstmrStmt> +</Document> diff --git a/util/src/main/kotlin/XmlCombinators.kt b/util/src/main/kotlin/XmlCombinators.kt @@ -1,6 +1,8 @@ package tech.libeufin.util import com.sun.xml.txw2.output.IndentingXMLStreamWriter +import org.w3c.dom.Document +import org.w3c.dom.Element import java.io.StringWriter import javax.xml.stream.XMLOutputFactory import javax.xml.stream.XMLStreamWriter @@ -49,6 +51,7 @@ class XmlDocumentBuilder { fun namespace(uri: String) { writer.setDefaultNamespace(uri) } + fun namespace(prefix: String, uri: String) { writer.setPrefix(prefix, uri) } @@ -85,10 +88,80 @@ fun constructXml(indent: Boolean = false, f: XmlDocumentBuilder.() -> Unit): Str return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n${stream.buffer.toString()}" } -class XmlDocumentDestructor { +class DestructionError(m: String) : Exception(m) + +private fun Element.getChildElements(ns: String, tag: String): List<Element> { + val elements = mutableListOf<Element>() + for (i in 0..this.childNodes.length) { + val el = this.childNodes.item(i) + if (el !is Element) { + continue + } + if (ns != "*" && el.namespaceURI != ns) { + continue + } + if (tag != "*" && el.localName != tag) { + continue + } + elements.add(el) + } + return elements +} + +class XmlElementDestructor internal constructor(val d: Document, val e: Element) { + fun <T> requireOnlyChild(f: XmlElementDestructor.(e: Element) -> T): T { + val child = + e.getChildElements("*", "*").elementAtOrNull(0) + ?: throw DestructionError("expected singleton child tag") + val destr = XmlElementDestructor(d, child) + return f(destr, child) + } + + fun <T> mapEachChildNamed(s: String, f: XmlElementDestructor.(e: Element) -> T): List<T> { + val res = mutableListOf<T>() + val els = e.getChildElements("*", s) + for (child in els) { + val destr = XmlElementDestructor(d, child) + res.add(f(destr, child)) + } + return res + } + + fun <T> requireUniqueChildNamed(s: String, f: XmlElementDestructor.(e: Element) -> T): T { + val cl = e.getChildElements("*", s) + if (cl.size != 1) { + throw DestructionError("expected exactly one unique $s child, got ${cl.size} instead") + } + val el = cl[0] + val destr = XmlElementDestructor(d, el) + return f(destr, el) + } + + fun <T> maybeUniqueChildNamed(s: String, f: XmlElementDestructor.(e: Element) -> T): T? { + val cl = e.getChildElements("*", s) + if (cl.size > 1) { + throw DestructionError("expected at most one unique $s child, got ${cl.size} instead") + } + if (cl.size == 1) { + val el = cl[0] + val destr = XmlElementDestructor(d, el) + println("found child $s") + return f(destr, el) + } + return null + } +} + +class XmlDocumentDestructor internal constructor(val d: Document) { + fun <T> requireRootElement(name: String, f: XmlElementDestructor.(e: Element) -> T): T { + if (this.d.documentElement.tagName != name) { + throw DestructionError("expected '$name' tag") + } + val destr = XmlElementDestructor(d, d.documentElement) + return f(destr, this.d.documentElement) + } } -fun <T> destructXml(f: XmlDocumentDestructor.() -> T): T { - val d = XmlDocumentDestructor() - return f(d) +fun <T> destructXml(d: Document, f: XmlDocumentDestructor.() -> T): T { + return f(XmlDocumentDestructor(d)) }