libeufin

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

commit 94215e32b2333d507ebbb61a8bfc7f498fd3d187
parent 7d1908e185d3d189a6d9fe08eb2e8e200d9a0aef
Author: Florian Dold <florian.dold@gmail.com>
Date:   Mon,  6 Jul 2020 16:26:02 +0530

iso20022

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mnexus/src/test/kotlin/Iso20022Test.kt | 42+++++++++++++++++++++++++++---------------
Mnexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml | 171++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 349 insertions(+), 48 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -67,6 +67,7 @@ data class CamtReport( data class GenericId( val id: String, val schemeName: String?, + val proprietarySchemeName: String?, val issuer: String? ) @@ -78,8 +79,19 @@ data class CashAccount( val otherId: GenericId? ) +data class Balance( + val type: String?, + val subtype: String?, + val proprietaryType: String?, + val proprietarySubtype: String?, + val date: String, + val creditDebitIndicator: CreditDebitIndicator, + val amount: CurrencyAmount +) + data class CamtParseResult( val reports: List<CamtReport>, + val balances: List<Balance>, val messageId: String, /** * Message type in form of the ISO 20022 message name. @@ -88,13 +100,36 @@ data class CamtParseResult( val creationDateTime: String ) -enum class PartyType(@get:JsonValue val jsonName: String) { - PRIVATE("private"), ORGANIZATION("organization") -} +@JsonInclude(JsonInclude.Include.NON_NULL) +data class PrivateIdentification( + val birthDate: String?, + val provinceOfBirth: String?, + val cityOfBirth: String?, + val countryOfBirth: String? +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class OrganizationIdentification( + val bic: String?, + val lei: String? +) +/** + * Identification of a party, which can be a private party + * or an organiation. + * + * Mapping of ISO 20022 PartyIdentification135. + */ @JsonInclude(JsonInclude.Include.NON_NULL) data class PartyIdentification( val name: String?, + val countryOfResidence: String?, + val privateId: PrivateIdentification?, + val organizationId: OrganizationIdentification?, + + /** + * Identification that applies to both private parties and organizations. + */ val otherId: GenericId? ) @@ -144,7 +179,17 @@ data class TransactionInfo( * Unstructured remittance information (=subject line) of the transaction, * or the empty string if missing. */ - val unstructuredRemittanceInformation: String + val unstructuredRemittanceInformation: String, + val returnInfo: ReturnInfo? +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class ReturnInfo( + val originalBankTransactionCode: BankTransactionCode?, + val originator: PartyIdentification?, + val reason: String?, + val proprietaryReason: String?, + val additionalInfo: String? ) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -349,6 +394,19 @@ private fun XmlElementDestructor.extractAgent(): AgentIdentification { ) } +private fun XmlElementDestructor.extractGenericId(): GenericId { + return GenericId( + id = requireUniqueChildNamed("Id") { it.textContent }, + schemeName = maybeUniqueChildNamed("SchmeNm") { + maybeUniqueChildNamed("Cd") { it.textContent } + }, + issuer = maybeUniqueChildNamed("Issr") { it.textContent }, + proprietarySchemeName = maybeUniqueChildNamed("SchmeNm") { + maybeUniqueChildNamed("Prtry") { it.textContent } + } + ) +} + private fun XmlElementDestructor.extractAccount(): CashAccount { var iban: String? = null var otherId: GenericId? = null @@ -361,11 +419,7 @@ private fun XmlElementDestructor.extractAccount(): CashAccount { iban = it.textContent } "Othr" -> { - otherId = GenericId( - id = requireUniqueChildNamed("Id") { it.textContent }, - schemeName = maybeUniqueChildNamed("SchmeNm") { it.textContent }, - issuer = maybeUniqueChildNamed("Issr") { it.textContent } - ) + otherId = extractGenericId() } else -> throw Error("invalid account identification") } @@ -375,9 +429,43 @@ private fun XmlElementDestructor.extractAccount(): CashAccount { } private fun XmlElementDestructor.extractParty(): PartyIdentification { + val otherId: GenericId? = maybeUniqueChildNamed("Id") { + (maybeUniqueChildNamed("PrvtId") { it } ?: maybeUniqueChildNamed("OrgId") { it })?.run { + maybeUniqueChildNamed("Othr") { + extractGenericId() + } + } + } + + val privateId = maybeUniqueChildNamed("Id") { + maybeUniqueChildNamed("PrvtId") { + maybeUniqueChildNamed("DtAndPlcOfBirth") { + PrivateIdentification( + birthDate = maybeUniqueChildNamed("BirthDt") { it.textContent}, + cityOfBirth = maybeUniqueChildNamed("CityOfBirth") { it.textContent}, + countryOfBirth = maybeUniqueChildNamed("CtryOfBirth") { it.textContent}, + provinceOfBirth = maybeUniqueChildNamed("PrvcOfBirth") { it.textContent} + ) + } + } + } + + val organizationId = maybeUniqueChildNamed("Id") { + maybeUniqueChildNamed("OrgId") { + OrganizationIdentification( + bic = maybeUniqueChildNamed("BICOrBEI") { it.textContent} ?: maybeUniqueChildNamed("AnyBIC") { it.textContent}, + lei = maybeUniqueChildNamed("LEI") { it.textContent} + ) + } + } + + return PartyIdentification( name = maybeUniqueChildNamed("Nm") { it.textContent }, - otherId = null + otherId = otherId, + privateId = privateId, + organizationId = organizationId, + countryOfResidence = maybeUniqueChildNamed("CtryOfRes") { it.textContent } ) } @@ -486,7 +574,7 @@ private fun XmlElementDestructor.extractTransactionInfos( if (chunks.isEmpty()) { null } else { - chunks.joinToString() + chunks.joinToString(separator = "") } } ?: "", creditorAgent = maybeUniqueChildNamed("CdtrAgt") { extractAgent() }, @@ -494,12 +582,45 @@ private fun XmlElementDestructor.extractTransactionInfos( debtorAccount = maybeUniqueChildNamed("DbtrAgt") { extractAccount() }, creditorAccount = maybeUniqueChildNamed("CdtrAgt") { extractAccount() }, debtor = maybeUniqueChildNamed("Dbtr") { extractParty() }, - creditor = maybeUniqueChildNamed("Cdtr") { extractParty() } + creditor = maybeUniqueChildNamed("Cdtr") { extractParty() }, + returnInfo = maybeUniqueChildNamed("RtrInf") { + ReturnInfo( + originalBankTransactionCode = maybeUniqueChildNamed("OrgnlBkTxCd") { + extractInnerBkTxCd() + }, + originator = maybeUniqueChildNamed("Orgtr") { extractParty() }, + reason = maybeUniqueChildNamed("Rsn") { maybeUniqueChildNamed("Cd") { it.textContent } }, + proprietaryReason = maybeUniqueChildNamed("Rsn") { maybeUniqueChildNamed("Prtry") { it.textContent } }, + additionalInfo = maybeUniqueChildNamed("AddtlInf") { it.textContent } + ) + } ) } } } +private fun XmlElementDestructor.extractInnerBkTxCd(): BankTransactionCode { + return BankTransactionCode( + domain = maybeUniqueChildNamed("Domn") { maybeUniqueChildNamed("Cd") { it.textContent } }, + family = maybeUniqueChildNamed("Domn") { + maybeUniqueChildNamed("Fmly") { + maybeUniqueChildNamed("Cd") { it.textContent } + } + }, + subfamily = maybeUniqueChildNamed("Domn") { + maybeUniqueChildNamed("Fmly") { + maybeUniqueChildNamed("SubFmlyCd") { it.textContent } + } + }, + proprietaryCode = maybeUniqueChildNamed("Prtry") { + maybeUniqueChildNamed("Cd") { it.textContent } + }, + proprietaryIssuer = maybeUniqueChildNamed("Prtry") { + maybeUniqueChildNamed("Issr") { it.textContent } + } + ) +} + private fun XmlElementDestructor.extractInnerTransactions(): CamtReport { val account = requireUniqueChildNamed("Acct") { extractAccount() } val entries = mapEachChildNamed("Ntry") { @@ -512,25 +633,7 @@ private fun XmlElementDestructor.extractInnerTransactions(): CamtReport { CreditDebitIndicator.valueOf(it) } val btc = requireUniqueChildNamed("BkTxCd") { - BankTransactionCode( - domain = maybeUniqueChildNamed("Domn") { maybeUniqueChildNamed("Cd") { it.textContent } }, - family = maybeUniqueChildNamed("Domn") { - maybeUniqueChildNamed("Fmly") { - maybeUniqueChildNamed("Cd") { it.textContent } - } - }, - subfamily = maybeUniqueChildNamed("Domn") { - maybeUniqueChildNamed("Fmly") { - maybeUniqueChildNamed("SubFmlyCd") { it.textContent } - } - }, - proprietaryCode = maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("Cd") { it.textContent } - }, - proprietaryIssuer = maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("Issr") { it.textContent } - } - ) + extractInnerBkTxCd() } val acctSvcrRef = maybeUniqueChildNamed("AcctSvcrRef") { it.textContent } val entryRef = maybeUniqueChildNamed("NtryRef") { it.textContent } @@ -575,6 +678,23 @@ fun parseCamtMessage(doc: Document): CamtParseResult { } } } + + val balances = requireOnlyChild { + mapEachChildNamed("Bal") { + Balance( + type = maybeUniqueChildNamed("Tp") { maybeUniqueChildNamed("Cd") { it.textContent } }, + proprietaryType = maybeUniqueChildNamed("Tp") { maybeUniqueChildNamed("Prtry") { it.textContent } }, + date = extractDateOrDateTime(), + creditDebitIndicator = requireUniqueChildNamed("CdtDbtInd") { it.textContent }.let { + CreditDebitIndicator.valueOf(it) + }, + subtype = maybeUniqueChildNamed("SubTp") { maybeUniqueChildNamed("Cd") { it.textContent } }, + proprietarySubtype = maybeUniqueChildNamed("SubTp") { maybeUniqueChildNamed("Prtry") { it.textContent } }, + amount = extractCurrencyAmount() + ) + } + } + val messageId = requireOnlyChild { requireUniqueChildNamed("GrpHdr") { requireUniqueChildNamed("MsgId") { it.textContent } @@ -594,7 +714,7 @@ fun parseCamtMessage(doc: Document): CamtParseResult { } } } - CamtParseResult(reports, messageId, messageType, creationDateTime) + CamtParseResult(reports, balances, messageId, messageType, creationDateTime) } } } diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -19,21 +19,33 @@ class Iso20022Test { fun testTransactionsImport() { val camt53 = loadXmlResource("iso20022-samples/camt.053/de.camt.053.001.02.xml") val r = parseCamtMessage(camt53) - assertEquals(r.messageId, "msg-001") - assertEquals(r.creationDateTime, "2020-07-03T12:44:40+05:30") - assertEquals(r.messageType, CashManagementResponseType.Statement) - assertEquals(r.reports.size, 1) - assertEquals(r.reports[0].entries[0].entryAmount.amount, "100.00") - assertEquals(r.reports[0].entries[0].entryAmount.currency, "EUR") - assertEquals(r.reports[0].entries[0].status, EntryStatus.BOOK) - assertEquals(r.reports[0].entries[0].entryRef, null) - assertEquals(r.reports[0].entries[0].accountServicerRef, "acctsvcrref-001") - assertEquals(r.reports[0].entries[0].bankTransactionCode.domain, "PMNT") - assertEquals(r.reports[0].entries[0].bankTransactionCode.family, "RCDT") - assertEquals(r.reports[0].entries[0].bankTransactionCode.subfamily, "ESCT") - assertEquals(r.reports[0].entries[0].bankTransactionCode.proprietaryCode, "166") - assertEquals(r.reports[0].entries[0].bankTransactionCode.proprietaryIssuer, "DK") - assertEquals(r.reports[0].entries[0].transactionInfos.size, 1) + assertEquals("msg-001", r.messageId) + assertEquals("2020-07-03T12:44:40+05:30", r.creationDateTime) + assertEquals(CashManagementResponseType.Statement, r.messageType) + assertEquals(1, r.reports.size) + + // First Entry + assertEquals("100.00", r.reports[0].entries[0].entryAmount.amount) + assertEquals("EUR", r.reports[0].entries[0].entryAmount.currency) + assertEquals(CreditDebitIndicator.CRDT, r.reports[0].entries[0].creditDebitIndicator) + assertEquals(EntryStatus.BOOK, r.reports[0].entries[0].status) + assertEquals(null, r.reports[0].entries[0].entryRef) + assertEquals("acctsvcrref-001", r.reports[0].entries[0].accountServicerRef) + assertEquals("PMNT", r.reports[0].entries[0].bankTransactionCode.domain) + assertEquals("RCDT", r.reports[0].entries[0].bankTransactionCode.family) + assertEquals("ESCT", r.reports[0].entries[0].bankTransactionCode.subfamily) + assertEquals("166", r.reports[0].entries[0].bankTransactionCode.proprietaryCode) + assertEquals("DK", r.reports[0].entries[0].bankTransactionCode.proprietaryIssuer) + assertEquals(1, r.reports[0].entries[0].transactionInfos.size) + assertEquals("EUR", r.reports[0].entries[0].transactionInfos[0].amount.currency) + assertEquals("100.00", r.reports[0].entries[0].transactionInfos[0].amount.amount) + assertEquals(CreditDebitIndicator.CRDT, r.reports[0].entries[0].transactionInfos[0].creditDebitIndicator) + assertEquals("unstructured info one", r.reports[0].entries[0].transactionInfos[0].unstructuredRemittanceInformation) + + // Second Entry + assertEquals("unstructured info across lines", r.reports[0].entries[1].transactionInfos[0].unstructuredRemittanceInformation) + + // Third Entry // Make sure that round-tripping of entry CamtBankAccountEntry JSON works for (entry in r.reports.flatMap { it.entries }) { diff --git a/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml b/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml @@ -28,6 +28,8 @@ <Dt>2020-07-03</Dt> </Dt> </Bal> + + <!-- Credit due to incoming SCT --> <Ntry> <Amt Ccy="EUR">100.00</Amt> <CdtDbtInd>CRDT</CdtDbtInd> @@ -52,7 +54,6 @@ <Issr>DK</Issr> </Prtry> </BkTxCd> - <!-- Credit due to incoming SCT --> <NtryDtls> <TxDtls> <Refs> @@ -100,6 +101,174 @@ </NtryDtls> <AddtlNtryInf>SEPA GUTSCHRIFT</AddtlNtryInf> </Ntry> + + <!-- Entry to illustrate multiple ustrd elements --> + <Ntry> + <Amt Ccy="EUR">50.00</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2020-07-02</Dt> + </BookgDt> + <ValDt> + <Dt>2020-07-04</Dt> + </ValDt> + <AcctSvcrRef>acctsvcrref-002</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>RCDT</Cd> + <SubFmlyCd>ESCT</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>166</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <!-- Credit due to incoming SCT --> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>e2e-002</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>Debtor One</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE52123456789473323175</IBAN> + </Id> + </DbtrAcct> + <Cdtr> + <Nm>Creditor One</Nm> + </Cdtr> + </RltdPties> + <RmtInf> + <Ustrd>unstructured </Ustrd> + <Ustrd>info </Ustrd> + <Ustrd>across </Ustrd> + <Ustrd>lines</Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + </Ntry> + + <!-- + Credit due to a return resulting from a batch payment initiation where only one payment failed. + This data was obtained by doing a transaction on a GLS Bank account, but we've replaced + the account's IBAN with a random one. + Note how the original creditor and debtor are preserved and not flipped. + Unfortunately the original payment didn't have an end-to-end ID, so it would be harder + to correlate this message to the original payment initiation --> + <Ntry> + <Amt Ccy="EUR">1.12</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2020-06-30</Dt> + </BookgDt> + <ValDt> + <Dt>2020-06-30</Dt> + </ValDt> + <AcctSvcrRef>2020063011423362000</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>RRTN</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+159+00931</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>NOTPROVIDED</EndToEndId> + </Refs> + <AmtDtls> + <TxAmt> + <Amt Ccy="EUR">1.12</Amt> + </TxAmt> + </AmtDtls> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>RRTN</SubFmlyCd> + </Fmly> + </Domn> + <Prtry> + <Cd>NRTI+159+00931</Cd> + <Issr>DK</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Account Owner</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE54123456784713474163</IBAN> + </Id> + </DbtrAcct> + <Cdtr> + <Nm>Nonexistant Creditor</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE24500105177398216438</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RmtInf> + <Ustrd>Retoure SEPA Ueberweisung vom 29.06.2020, Rueckgabegrund: AC01 IBAN fehlerhaft und ungültig SVWZ: RETURN, Sammelposten Nummer Zwei IBAN: DE2</Ustrd> + <Ustrd>4500105177398216438 BIC: INGDDEFFXXX</Ustrd> + </RmtInf> + <RtrInf> + <OrgnlBkTxCd> + <Prtry> + <Cd>116</Cd> + <Issr>DK</Issr> + </Prtry> + </OrgnlBkTxCd> + <Orgtr> + <Id> + <OrgId> + <BICOrBEI>GENODEM1GLS</BICOrBEI> + </OrgId> + </Id> + </Orgtr> + <Rsn> + <Cd>AC01</Cd> + </Rsn> + <AddtlInf>IBAN fehlerhaft und ungültig</AddtlInf> + </RtrInf> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Retouren</AddtlNtryInf> + </Ntry> + <!-- Credit due to incoming USD transfer --> <Ntry> <Amt Ccy="EUR">1000</Amt>