commit a63f1ea71e22956cf9a0cd6b905626e254cc9b3f
parent d6198e9dbe7dd8a58e534ccc85df65d03c5a8814
Author: Florian Dold <florian.dold@gmail.com>
Date: Tue, 7 Jul 2020 13:21:20 +0530
package structure
Diffstat:
9 files changed, 734 insertions(+), 723 deletions(-)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
@@ -27,6 +27,7 @@ import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.transaction
+import tech.libeufin.nexus.iso20022.EntryStatus
import tech.libeufin.util.EbicsInitState
import tech.libeufin.util.amount
import java.sql.Connection
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -1,720 +0,0 @@
-/*
- * 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/>
- */
-
-/**
- * Parse and generate ISO 20022 messages
- */
-package tech.libeufin.nexus
-
-import com.fasterxml.jackson.annotation.JsonInclude
-import com.fasterxml.jackson.annotation.JsonValue
-import org.w3c.dom.Document
-import tech.libeufin.nexus.server.CurrencyAmount
-import tech.libeufin.util.*
-import java.time.Instant
-import java.time.ZoneId
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
-
-enum class CreditDebitIndicator {
- DBIT, CRDT
-}
-
-enum class EntryStatus {
- /**
- * Booked
- */
- BOOK,
-
- /**
- * Pending
- */
- PDNG,
-
- /**
- * Informational
- */
- INFO,
-}
-
-enum class CashManagementResponseType(@get:JsonValue val jsonName: String) {
- Report("report"), Statement("statement"), Notification("notification")
-}
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class CamtReport(
- val account: CashAccount,
- val entries: List<CamtBankAccountEntry>
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class GenericId(
- val id: String,
- val schemeName: String?,
- val proprietarySchemeName: String?,
- val issuer: String?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class CashAccount(
- val name: String?,
- val currency: String?,
- val iban: String?,
- 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.
- */
- val messageType: CashManagementResponseType,
- val creationDateTime: String
-)
-
-@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?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class AgentIdentification(
- val name: String?,
- val bic: String?,
- val otherId: GenericId?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class CurrencyExchange(
- val sourceCurrency: String,
- val targetCurrency: String,
- val unitCurrency: String?,
- val exchangeRate: String,
- val contractId: String?,
- val quotationDate: String?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class TransactionInfo(
- val batchPaymentInformationId: String?,
- val batchMessageId: String?,
-
- val debtor: PartyIdentification?,
- val debtorAccount: CashAccount?,
- val debtorAgent: AgentIdentification?,
- val creditor: PartyIdentification?,
- val creditorAccount: CashAccount?,
- val creditorAgent: AgentIdentification?,
-
- val endToEndId: String? = null,
- val paymentInformationId: String? = null,
- val messageId: String? = null,
-
- val amount: CurrencyAmount,
- val creditDebitIndicator: CreditDebitIndicator,
-
- val instructedAmount: CurrencyAmount?,
- val transactionAmount: CurrencyAmount?,
-
- val instructedAmountCurrencyExchange: CurrencyExchange?,
- val transactionAmountCurrencyExchange: CurrencyExchange?,
-
- /**
- * Unstructured remittance information (=subject line) of the transaction,
- * or the empty string if missing.
- */
- 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)
-data class CamtBankAccountEntry(
- val entryAmount: CurrencyAmount,
-
- /**
- * Is this entry debiting or crediting the account
- * it is reported for?
- */
- val creditDebitIndicator: CreditDebitIndicator,
-
- /**
- * Booked, pending, etc.
- */
- val status: EntryStatus,
-
- /**
- * Code that describes the type of bank transaction
- * in more detail
- */
-
- val bankTransactionCode: BankTransactionCode,
- /**
- * Transaction details, if this entry contains a single transaction.
- */
- val transactionInfos: List<TransactionInfo>,
- val valueDate: String?,
- val bookingDate: String?,
- val accountServicerRef: String?,
- val entryRef: String?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class BankTransactionCode(
- val domain: String?,
- val family: String?,
- val subfamily: String?,
- val proprietaryCode: String?,
- val proprietaryIssuer: String?
-)
-
-class CamtParsingError(msg: String) : Exception(msg)
-
-/**
- * Data that the LibEuFin nexus uses for payment initiation.
- * Subset of what ISO 20022 allows.
- */
-data class NexusPaymentInitiationData(
- val debtorIban: String,
- val debtorBic: String?,
- val debtorName: String,
- val messageId: String,
- val paymentInformationId: String,
- val endToEndId: String?,
- val amount: String,
- val currency: String,
- val subject: String,
- val preparationTimestamp: Long,
- val creditorName: String,
- val creditorIban: String,
- val instructionId: String?
-)
-
-/**
- * Create a PAIN.001 XML document according to the input data.
- * Needs to be called within a transaction block.
- */
-fun createPain001document(paymentData: NexusPaymentInitiationData): String {
- // Every PAIN.001 document contains at least three IDs:
- //
- // 1) MsgId: a unique id for the message itself
- // 2) PmtInfId: the unique id for the payment's set of information
- // 3) EndToEndId: a unique id to be shared between the debtor and
- // creditor that uniquely identifies the transaction
- //
- // For now and for simplicity, since every PAIN entry in the database
- // has a unique ID, and the three values aren't required to be mutually different,
- // we'll assign the SAME id (= the row id) to all the three aforementioned
- // PAIN id types.
-
- val s = constructXml(indent = true) {
- root("Document") {
- attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03")
- attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
- attribute(
- "xsi:schemaLocation",
- "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03 pain.001.001.03.xsd"
- )
- element("CstmrCdtTrfInitn") {
- element("GrpHdr") {
- element("MsgId") {
- text(paymentData.messageId)
- }
- element("CreDtTm") {
- val dateMillis = paymentData.preparationTimestamp
- val dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
- val instant = Instant.ofEpochSecond(dateMillis / 1000)
- val zoned = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
- text(dateFormatter.format(zoned))
- }
- element("NbOfTxs") {
- text("1")
- }
- element("CtrlSum") {
- text(paymentData.amount)
- }
- element("InitgPty/Nm") {
- text(paymentData.debtorName)
- }
- }
- element("PmtInf") {
- element("PmtInfId") {
- text(paymentData.paymentInformationId)
- }
- element("PmtMtd") {
- text("TRF")
- }
- element("BtchBookg") {
- text("true")
- }
- element("NbOfTxs") {
- text("1")
- }
- element("CtrlSum") {
- text(paymentData.amount)
- }
- element("PmtTpInf/SvcLvl/Cd") {
- text("SEPA")
- }
- element("ReqdExctnDt") {
- val dateMillis = paymentData.preparationTimestamp
- text(importDateFromMillis(dateMillis).toDashedDate())
- }
- element("Dbtr/Nm") {
- text(paymentData.debtorName)
- }
- element("DbtrAcct/Id/IBAN") {
- text(paymentData.debtorIban)
- }
- when (val b = paymentData.debtorBic) {
- null -> element("DbtrAgt/FinInstnId/Othr/Id") { text("NOTPROVIDED") }
- else -> element("DbtrAgt/FinInstnId/BIC") { text(b) }
- }
- element("ChrgBr") {
- text("SLEV")
- }
- element("CdtTrfTxInf") {
- element("PmtId") {
- paymentData.instructionId?.let {
- element("InstrId") { text(it) }
- }
- when (val eeid = paymentData.endToEndId) {
- null -> element("EndToEndId") { text("NOTPROVIDED") }
- else -> element("EndToEndId") { text(eeid) }
- }
- }
- element("Amt/InstdAmt") {
- attribute("Ccy", paymentData.currency)
- text(paymentData.amount)
- }
- element("Cdtr/Nm") {
- text(paymentData.creditorName)
- }
- element("CdtrAcct/Id/IBAN") {
- text(paymentData.creditorIban)
- }
- element("RmtInf/Ustrd") {
- text(paymentData.subject)
- }
- }
- }
- }
- }
- }
- return s
-}
-
-private fun XmlElementDestructor.extractDateOrDateTime(): String {
- return requireOnlyChild {
- when (it.localName) {
- "Dt" -> e.textContent
- "DtTm" -> e.textContent
- else -> throw Exception("Invalid date / time: ${e.localName}")
- }
- }
-}
-
-private fun XmlElementDestructor.extractAgent(): AgentIdentification {
- return AgentIdentification(
- name = maybeUniqueChildNamed("FinInstnId") {
- maybeUniqueChildNamed("Nm") {
- it.textContent
- }
- },
- bic = requireUniqueChildNamed("FinInstnId") {
- requireUniqueChildNamed("BIC") {
- it.textContent
- }
- },
- otherId = null
- )
-}
-
-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
- val currency: String? = maybeUniqueChildNamed("Ccy") { it.textContent }
- val name: String? = maybeUniqueChildNamed("Nm") { it.textContent }
- requireUniqueChildNamed("Id") {
- requireOnlyChild {
- when (it.localName) {
- "IBAN" -> {
- iban = it.textContent
- }
- "Othr" -> {
- otherId = extractGenericId()
- }
- else -> throw Error("invalid account identification")
- }
- }
- }
- return CashAccount(name, currency, iban, otherId)
-}
-
-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 = otherId,
- privateId = privateId,
- organizationId = organizationId,
- countryOfResidence = maybeUniqueChildNamed("CtryOfRes") { it.textContent }
- )
-}
-
-private fun XmlElementDestructor.extractCurrencyAmount(): CurrencyAmount {
- return CurrencyAmount(
- amount = requireUniqueChildNamed("Amt") { it.textContent },
- currency = requireUniqueChildNamed("Amt") { it.getAttribute("Ccy") }
- )
-}
-
-private fun XmlElementDestructor.maybeExtractCurrencyAmount(): CurrencyAmount? {
- return maybeUniqueChildNamed("Amt") {
- CurrencyAmount(
- it.textContent,
- it.getAttribute("Ccy")
- )
- }
-}
-
-private fun XmlElementDestructor.extractMaybeCurrencyExchange(): CurrencyExchange? {
- return maybeUniqueChildNamed("CcyXchg") {
- CurrencyExchange(
- sourceCurrency = requireUniqueChildNamed("SrcCcy") { it.textContent },
- targetCurrency = requireUniqueChildNamed("TgtCcy") { it.textContent },
- contractId = maybeUniqueChildNamed("CtrctId") { it.textContent },
- exchangeRate = requireUniqueChildNamed("XchgRate") { it.textContent },
- quotationDate = maybeUniqueChildNamed("QtnDt") { it.textContent },
- unitCurrency = maybeUniqueChildNamed("UnitCcy") { it.textContent }
- )
- }
-}
-
-
-private fun XmlElementDestructor.extractTransactionInfos(
- outerAmount: CurrencyAmount,
- outerCreditDebitIndicator: CreditDebitIndicator
-): List<TransactionInfo> {
-
- val numTxDtls = requireUniqueChildNamed("NtryDtls") {
- mapEachChildNamed("TxDtls") { Unit }
- }.count()
-
- return requireUniqueChildNamed("NtryDtls") {
- mapEachChildNamed("TxDtls") {
-
- val instructedAmount = maybeUniqueChildNamed("AmtDtls") {
- maybeUniqueChildNamed("InstrAmt") { extractCurrencyAmount() }
- }
-
- val transactionAmount = maybeUniqueChildNamed("AmtDtls") {
- maybeUniqueChildNamed("TxAmt") { extractCurrencyAmount() }
- }
-
- var amount = maybeExtractCurrencyAmount()
- var creditDebitIndicator = maybeUniqueChildNamed("CdtDbtInd") { it.textContent }?.let {
- CreditDebitIndicator.valueOf(it)
- }
- if (amount == null) {
- when {
- numTxDtls == 1 -> {
- amount = outerAmount
- creditDebitIndicator = outerCreditDebitIndicator
- }
- transactionAmount?.currency == outerAmount.currency -> {
- amount = transactionAmount
- creditDebitIndicator = outerCreditDebitIndicator
- }
- instructedAmount?.currency == outerAmount.currency -> {
- amount = instructedAmount
- creditDebitIndicator = outerCreditDebitIndicator
- }
- else -> {
- throw Error("invalid camt, no amount for transaction details of entry details")
- }
- }
- }
-
- if (creditDebitIndicator == null) {
- throw Error("invalid camt, no credit/debit indicator for transaction details of entry details")
- }
-
- TransactionInfo(
- batchMessageId = null,
- batchPaymentInformationId = null,
- amount = amount,
- creditDebitIndicator = creditDebitIndicator,
- instructedAmount = instructedAmount,
- instructedAmountCurrencyExchange = maybeUniqueChildNamed("AmtDtls") {
- maybeUniqueChildNamed("InstrAmt") { extractMaybeCurrencyExchange() }
- },
- transactionAmount = transactionAmount,
- transactionAmountCurrencyExchange = maybeUniqueChildNamed("AmtDtls") {
- maybeUniqueChildNamed("TxAmt") { extractMaybeCurrencyExchange() }
- },
- endToEndId = maybeUniqueChildNamed("Refs") {
- maybeUniqueChildNamed("EndToEndId") { it.textContent }
- },
- messageId = maybeUniqueChildNamed("Refs") {
- maybeUniqueChildNamed("MsgId") { it.textContent }
- },
- paymentInformationId = maybeUniqueChildNamed("Refs") {
- maybeUniqueChildNamed("PmtInfId") { it.textContent }
- },
- unstructuredRemittanceInformation = maybeUniqueChildNamed("RmtInf") {
- val chunks = mapEachChildNamed("Ustrd", { it.textContent })
- if (chunks.isEmpty()) {
- null
- } else {
- chunks.joinToString(separator = "")
- }
- } ?: "",
- creditorAgent = maybeUniqueChildNamed("CdtrAgt") { extractAgent() },
- debtorAgent = maybeUniqueChildNamed("DbtrAgt") { extractAgent() },
- debtorAccount = maybeUniqueChildNamed("DbtrAgt") { extractAccount() },
- creditorAccount = maybeUniqueChildNamed("CdtrAgt") { extractAccount() },
- debtor = maybeUniqueChildNamed("Dbtr") { 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") {
- val amount = requireUniqueChildNamed("Amt") { it.textContent }
- val currency = requireUniqueChildNamed("Amt") { it.getAttribute("Ccy") }
- val status = requireUniqueChildNamed("Sts") { it.textContent }.let {
- EntryStatus.valueOf(it)
- }
- val creditDebitIndicator = requireUniqueChildNamed("CdtDbtInd") { it.textContent }.let {
- CreditDebitIndicator.valueOf(it)
- }
- val btc = requireUniqueChildNamed("BkTxCd") {
- extractInnerBkTxCd()
- }
- val acctSvcrRef = maybeUniqueChildNamed("AcctSvcrRef") { it.textContent }
- val entryRef = maybeUniqueChildNamed("NtryRef") { it.textContent }
- // For now, only support account servicer reference as id
- val transactionInfos = extractTransactionInfos(CurrencyAmount(currency, amount), creditDebitIndicator)
- CamtBankAccountEntry(
- entryAmount = CurrencyAmount(currency, amount),
- status = status,
- creditDebitIndicator = creditDebitIndicator,
- bankTransactionCode = btc,
- transactionInfos = transactionInfos,
- bookingDate = maybeUniqueChildNamed("BookgDt") { extractDateOrDateTime() },
- valueDate = maybeUniqueChildNamed("ValDt") { extractDateOrDateTime() },
- accountServicerRef = acctSvcrRef,
- entryRef = entryRef
- )
- }
- return CamtReport(account, entries)
-}
-
-/**
- * Extract a list of transactions from an ISO20022 camt.052 / camt.053 message.
- */
-fun parseCamtMessage(doc: Document): CamtParseResult {
- return destructXml(doc) {
- requireRootElement("Document") {
- // Either bank to customer statement or report
- val reports = requireOnlyChild {
- when (it.localName) {
- "BkToCstmrAcctRpt" -> {
- mapEachChildNamed("Rpt") {
- extractInnerTransactions()
- }
- }
- "BkToCstmrStmt" -> {
- mapEachChildNamed("Stmt") {
- extractInnerTransactions()
- }
- }
- else -> {
- throw CamtParsingError("expected statement or report")
- }
- }
- }
-
- 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 }
- }
- }
- val creationDateTime = requireOnlyChild {
- requireUniqueChildNamed("GrpHdr") {
- requireUniqueChildNamed("CreDtTm") { it.textContent }
- }
- }
- val messageType = requireOnlyChild {
- when (it.localName) {
- "BkToCstmrAcctRpt" -> CashManagementResponseType.Report
- "BkToCstmrStmt" -> CashManagementResponseType.Statement
- else -> {
- throw CamtParsingError("expected statement or report")
- }
- }
- }
- CamtParseResult(reports, balances, messageId, messageType, creationDateTime)
- }
- }
-}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
@@ -39,6 +39,10 @@ import org.jetbrains.exposed.dao.id.IdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import tech.libeufin.nexus.bankaccount.addPaymentInitiation
+import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
+import tech.libeufin.nexus.iso20022.CreditDebitIndicator
+import tech.libeufin.nexus.iso20022.EntryStatus
+import tech.libeufin.nexus.iso20022.TransactionInfo
import tech.libeufin.nexus.server.Pain001Data
import tech.libeufin.nexus.server.authenticateRequest
import tech.libeufin.nexus.server.expectNonNull
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
@@ -29,6 +29,8 @@ import org.w3c.dom.Document
import tech.libeufin.nexus.*
import tech.libeufin.nexus.ebics.fetchEbicsBySpec
import tech.libeufin.nexus.ebics.submitEbicsPaymentInitiation
+import tech.libeufin.nexus.iso20022.CreditDebitIndicator
+import tech.libeufin.nexus.iso20022.parseCamtMessage
import tech.libeufin.nexus.server.FetchSpecJson
import tech.libeufin.nexus.server.Pain001Data
import tech.libeufin.nexus.server.requireBankConnection
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt
@@ -48,6 +48,8 @@ import org.jetbrains.exposed.sql.not
import org.jetbrains.exposed.sql.statements.api.ExposedBlob
import org.jetbrains.exposed.sql.transactions.transaction
import tech.libeufin.nexus.*
+import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData
+import tech.libeufin.nexus.iso20022.createPain001document
import tech.libeufin.nexus.logger
import tech.libeufin.nexus.server.*
import tech.libeufin.util.*
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt
@@ -0,0 +1,720 @@
+/*
+ * 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/>
+ */
+
+/**
+ * Parse and generate ISO 20022 messages
+ */
+package tech.libeufin.nexus.iso20022
+
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonValue
+import org.w3c.dom.Document
+import tech.libeufin.nexus.server.CurrencyAmount
+import tech.libeufin.util.*
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+enum class CreditDebitIndicator {
+ DBIT, CRDT
+}
+
+enum class EntryStatus {
+ /**
+ * Booked
+ */
+ BOOK,
+
+ /**
+ * Pending
+ */
+ PDNG,
+
+ /**
+ * Informational
+ */
+ INFO,
+}
+
+enum class CashManagementResponseType(@get:JsonValue val jsonName: String) {
+ Report("report"), Statement("statement"), Notification("notification")
+}
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class CamtReport(
+ val account: CashAccount,
+ val entries: List<CamtBankAccountEntry>
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class GenericId(
+ val id: String,
+ val schemeName: String?,
+ val proprietarySchemeName: String?,
+ val issuer: String?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class CashAccount(
+ val name: String?,
+ val currency: String?,
+ val iban: String?,
+ 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.
+ */
+ val messageType: CashManagementResponseType,
+ val creationDateTime: String
+)
+
+@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?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class AgentIdentification(
+ val name: String?,
+ val bic: String?,
+ val otherId: GenericId?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class CurrencyExchange(
+ val sourceCurrency: String,
+ val targetCurrency: String,
+ val unitCurrency: String?,
+ val exchangeRate: String,
+ val contractId: String?,
+ val quotationDate: String?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class TransactionInfo(
+ val batchPaymentInformationId: String?,
+ val batchMessageId: String?,
+
+ val debtor: PartyIdentification?,
+ val debtorAccount: CashAccount?,
+ val debtorAgent: AgentIdentification?,
+ val creditor: PartyIdentification?,
+ val creditorAccount: CashAccount?,
+ val creditorAgent: AgentIdentification?,
+
+ val endToEndId: String? = null,
+ val paymentInformationId: String? = null,
+ val messageId: String? = null,
+
+ val amount: CurrencyAmount,
+ val creditDebitIndicator: CreditDebitIndicator,
+
+ val instructedAmount: CurrencyAmount?,
+ val transactionAmount: CurrencyAmount?,
+
+ val instructedAmountCurrencyExchange: CurrencyExchange?,
+ val transactionAmountCurrencyExchange: CurrencyExchange?,
+
+ /**
+ * Unstructured remittance information (=subject line) of the transaction,
+ * or the empty string if missing.
+ */
+ 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)
+data class CamtBankAccountEntry(
+ val entryAmount: CurrencyAmount,
+
+ /**
+ * Is this entry debiting or crediting the account
+ * it is reported for?
+ */
+ val creditDebitIndicator: CreditDebitIndicator,
+
+ /**
+ * Booked, pending, etc.
+ */
+ val status: EntryStatus,
+
+ /**
+ * Code that describes the type of bank transaction
+ * in more detail
+ */
+
+ val bankTransactionCode: BankTransactionCode,
+ /**
+ * Transaction details, if this entry contains a single transaction.
+ */
+ val transactionInfos: List<TransactionInfo>,
+ val valueDate: String?,
+ val bookingDate: String?,
+ val accountServicerRef: String?,
+ val entryRef: String?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class BankTransactionCode(
+ val domain: String?,
+ val family: String?,
+ val subfamily: String?,
+ val proprietaryCode: String?,
+ val proprietaryIssuer: String?
+)
+
+class CamtParsingError(msg: String) : Exception(msg)
+
+/**
+ * Data that the LibEuFin nexus uses for payment initiation.
+ * Subset of what ISO 20022 allows.
+ */
+data class NexusPaymentInitiationData(
+ val debtorIban: String,
+ val debtorBic: String?,
+ val debtorName: String,
+ val messageId: String,
+ val paymentInformationId: String,
+ val endToEndId: String?,
+ val amount: String,
+ val currency: String,
+ val subject: String,
+ val preparationTimestamp: Long,
+ val creditorName: String,
+ val creditorIban: String,
+ val instructionId: String?
+)
+
+/**
+ * Create a PAIN.001 XML document according to the input data.
+ * Needs to be called within a transaction block.
+ */
+fun createPain001document(paymentData: NexusPaymentInitiationData): String {
+ // Every PAIN.001 document contains at least three IDs:
+ //
+ // 1) MsgId: a unique id for the message itself
+ // 2) PmtInfId: the unique id for the payment's set of information
+ // 3) EndToEndId: a unique id to be shared between the debtor and
+ // creditor that uniquely identifies the transaction
+ //
+ // For now and for simplicity, since every PAIN entry in the database
+ // has a unique ID, and the three values aren't required to be mutually different,
+ // we'll assign the SAME id (= the row id) to all the three aforementioned
+ // PAIN id types.
+
+ val s = constructXml(indent = true) {
+ root("Document") {
+ attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03")
+ attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
+ attribute(
+ "xsi:schemaLocation",
+ "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03 pain.001.001.03.xsd"
+ )
+ element("CstmrCdtTrfInitn") {
+ element("GrpHdr") {
+ element("MsgId") {
+ text(paymentData.messageId)
+ }
+ element("CreDtTm") {
+ val dateMillis = paymentData.preparationTimestamp
+ val dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
+ val instant = Instant.ofEpochSecond(dateMillis / 1000)
+ val zoned = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
+ text(dateFormatter.format(zoned))
+ }
+ element("NbOfTxs") {
+ text("1")
+ }
+ element("CtrlSum") {
+ text(paymentData.amount)
+ }
+ element("InitgPty/Nm") {
+ text(paymentData.debtorName)
+ }
+ }
+ element("PmtInf") {
+ element("PmtInfId") {
+ text(paymentData.paymentInformationId)
+ }
+ element("PmtMtd") {
+ text("TRF")
+ }
+ element("BtchBookg") {
+ text("true")
+ }
+ element("NbOfTxs") {
+ text("1")
+ }
+ element("CtrlSum") {
+ text(paymentData.amount)
+ }
+ element("PmtTpInf/SvcLvl/Cd") {
+ text("SEPA")
+ }
+ element("ReqdExctnDt") {
+ val dateMillis = paymentData.preparationTimestamp
+ text(importDateFromMillis(dateMillis).toDashedDate())
+ }
+ element("Dbtr/Nm") {
+ text(paymentData.debtorName)
+ }
+ element("DbtrAcct/Id/IBAN") {
+ text(paymentData.debtorIban)
+ }
+ when (val b = paymentData.debtorBic) {
+ null -> element("DbtrAgt/FinInstnId/Othr/Id") { text("NOTPROVIDED") }
+ else -> element("DbtrAgt/FinInstnId/BIC") { text(b) }
+ }
+ element("ChrgBr") {
+ text("SLEV")
+ }
+ element("CdtTrfTxInf") {
+ element("PmtId") {
+ paymentData.instructionId?.let {
+ element("InstrId") { text(it) }
+ }
+ when (val eeid = paymentData.endToEndId) {
+ null -> element("EndToEndId") { text("NOTPROVIDED") }
+ else -> element("EndToEndId") { text(eeid) }
+ }
+ }
+ element("Amt/InstdAmt") {
+ attribute("Ccy", paymentData.currency)
+ text(paymentData.amount)
+ }
+ element("Cdtr/Nm") {
+ text(paymentData.creditorName)
+ }
+ element("CdtrAcct/Id/IBAN") {
+ text(paymentData.creditorIban)
+ }
+ element("RmtInf/Ustrd") {
+ text(paymentData.subject)
+ }
+ }
+ }
+ }
+ }
+ }
+ return s
+}
+
+private fun XmlElementDestructor.extractDateOrDateTime(): String {
+ return requireOnlyChild {
+ when (it.localName) {
+ "Dt" -> e.textContent
+ "DtTm" -> e.textContent
+ else -> throw Exception("Invalid date / time: ${e.localName}")
+ }
+ }
+}
+
+private fun XmlElementDestructor.extractAgent(): AgentIdentification {
+ return AgentIdentification(
+ name = maybeUniqueChildNamed("FinInstnId") {
+ maybeUniqueChildNamed("Nm") {
+ it.textContent
+ }
+ },
+ bic = requireUniqueChildNamed("FinInstnId") {
+ requireUniqueChildNamed("BIC") {
+ it.textContent
+ }
+ },
+ otherId = null
+ )
+}
+
+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
+ val currency: String? = maybeUniqueChildNamed("Ccy") { it.textContent }
+ val name: String? = maybeUniqueChildNamed("Nm") { it.textContent }
+ requireUniqueChildNamed("Id") {
+ requireOnlyChild {
+ when (it.localName) {
+ "IBAN" -> {
+ iban = it.textContent
+ }
+ "Othr" -> {
+ otherId = extractGenericId()
+ }
+ else -> throw Error("invalid account identification")
+ }
+ }
+ }
+ return CashAccount(name, currency, iban, otherId)
+}
+
+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 = otherId,
+ privateId = privateId,
+ organizationId = organizationId,
+ countryOfResidence = maybeUniqueChildNamed("CtryOfRes") { it.textContent }
+ )
+}
+
+private fun XmlElementDestructor.extractCurrencyAmount(): CurrencyAmount {
+ return CurrencyAmount(
+ amount = requireUniqueChildNamed("Amt") { it.textContent },
+ currency = requireUniqueChildNamed("Amt") { it.getAttribute("Ccy") }
+ )
+}
+
+private fun XmlElementDestructor.maybeExtractCurrencyAmount(): CurrencyAmount? {
+ return maybeUniqueChildNamed("Amt") {
+ CurrencyAmount(
+ it.textContent,
+ it.getAttribute("Ccy")
+ )
+ }
+}
+
+private fun XmlElementDestructor.extractMaybeCurrencyExchange(): CurrencyExchange? {
+ return maybeUniqueChildNamed("CcyXchg") {
+ CurrencyExchange(
+ sourceCurrency = requireUniqueChildNamed("SrcCcy") { it.textContent },
+ targetCurrency = requireUniqueChildNamed("TgtCcy") { it.textContent },
+ contractId = maybeUniqueChildNamed("CtrctId") { it.textContent },
+ exchangeRate = requireUniqueChildNamed("XchgRate") { it.textContent },
+ quotationDate = maybeUniqueChildNamed("QtnDt") { it.textContent },
+ unitCurrency = maybeUniqueChildNamed("UnitCcy") { it.textContent }
+ )
+ }
+}
+
+
+private fun XmlElementDestructor.extractTransactionInfos(
+ outerAmount: CurrencyAmount,
+ outerCreditDebitIndicator: CreditDebitIndicator
+): List<TransactionInfo> {
+
+ val numTxDtls = requireUniqueChildNamed("NtryDtls") {
+ mapEachChildNamed("TxDtls") { Unit }
+ }.count()
+
+ return requireUniqueChildNamed("NtryDtls") {
+ mapEachChildNamed("TxDtls") {
+
+ val instructedAmount = maybeUniqueChildNamed("AmtDtls") {
+ maybeUniqueChildNamed("InstrAmt") { extractCurrencyAmount() }
+ }
+
+ val transactionAmount = maybeUniqueChildNamed("AmtDtls") {
+ maybeUniqueChildNamed("TxAmt") { extractCurrencyAmount() }
+ }
+
+ var amount = maybeExtractCurrencyAmount()
+ var creditDebitIndicator = maybeUniqueChildNamed("CdtDbtInd") { it.textContent }?.let {
+ CreditDebitIndicator.valueOf(it)
+ }
+ if (amount == null) {
+ when {
+ numTxDtls == 1 -> {
+ amount = outerAmount
+ creditDebitIndicator = outerCreditDebitIndicator
+ }
+ transactionAmount?.currency == outerAmount.currency -> {
+ amount = transactionAmount
+ creditDebitIndicator = outerCreditDebitIndicator
+ }
+ instructedAmount?.currency == outerAmount.currency -> {
+ amount = instructedAmount
+ creditDebitIndicator = outerCreditDebitIndicator
+ }
+ else -> {
+ throw Error("invalid camt, no amount for transaction details of entry details")
+ }
+ }
+ }
+
+ if (creditDebitIndicator == null) {
+ throw Error("invalid camt, no credit/debit indicator for transaction details of entry details")
+ }
+
+ TransactionInfo(
+ batchMessageId = null,
+ batchPaymentInformationId = null,
+ amount = amount,
+ creditDebitIndicator = creditDebitIndicator,
+ instructedAmount = instructedAmount,
+ instructedAmountCurrencyExchange = maybeUniqueChildNamed("AmtDtls") {
+ maybeUniqueChildNamed("InstrAmt") { extractMaybeCurrencyExchange() }
+ },
+ transactionAmount = transactionAmount,
+ transactionAmountCurrencyExchange = maybeUniqueChildNamed("AmtDtls") {
+ maybeUniqueChildNamed("TxAmt") { extractMaybeCurrencyExchange() }
+ },
+ endToEndId = maybeUniqueChildNamed("Refs") {
+ maybeUniqueChildNamed("EndToEndId") { it.textContent }
+ },
+ messageId = maybeUniqueChildNamed("Refs") {
+ maybeUniqueChildNamed("MsgId") { it.textContent }
+ },
+ paymentInformationId = maybeUniqueChildNamed("Refs") {
+ maybeUniqueChildNamed("PmtInfId") { it.textContent }
+ },
+ unstructuredRemittanceInformation = maybeUniqueChildNamed("RmtInf") {
+ val chunks = mapEachChildNamed("Ustrd", { it.textContent })
+ if (chunks.isEmpty()) {
+ null
+ } else {
+ chunks.joinToString(separator = "")
+ }
+ } ?: "",
+ creditorAgent = maybeUniqueChildNamed("CdtrAgt") { extractAgent() },
+ debtorAgent = maybeUniqueChildNamed("DbtrAgt") { extractAgent() },
+ debtorAccount = maybeUniqueChildNamed("DbtrAgt") { extractAccount() },
+ creditorAccount = maybeUniqueChildNamed("CdtrAgt") { extractAccount() },
+ debtor = maybeUniqueChildNamed("Dbtr") { 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") {
+ val amount = requireUniqueChildNamed("Amt") { it.textContent }
+ val currency = requireUniqueChildNamed("Amt") { it.getAttribute("Ccy") }
+ val status = requireUniqueChildNamed("Sts") { it.textContent }.let {
+ EntryStatus.valueOf(it)
+ }
+ val creditDebitIndicator = requireUniqueChildNamed("CdtDbtInd") { it.textContent }.let {
+ CreditDebitIndicator.valueOf(it)
+ }
+ val btc = requireUniqueChildNamed("BkTxCd") {
+ extractInnerBkTxCd()
+ }
+ val acctSvcrRef = maybeUniqueChildNamed("AcctSvcrRef") { it.textContent }
+ val entryRef = maybeUniqueChildNamed("NtryRef") { it.textContent }
+ // For now, only support account servicer reference as id
+ val transactionInfos = extractTransactionInfos(CurrencyAmount(currency, amount), creditDebitIndicator)
+ CamtBankAccountEntry(
+ entryAmount = CurrencyAmount(currency, amount),
+ status = status,
+ creditDebitIndicator = creditDebitIndicator,
+ bankTransactionCode = btc,
+ transactionInfos = transactionInfos,
+ bookingDate = maybeUniqueChildNamed("BookgDt") { extractDateOrDateTime() },
+ valueDate = maybeUniqueChildNamed("ValDt") { extractDateOrDateTime() },
+ accountServicerRef = acctSvcrRef,
+ entryRef = entryRef
+ )
+ }
+ return CamtReport(account, entries)
+}
+
+/**
+ * Extract a list of transactions from an ISO20022 camt.052 / camt.053 message.
+ */
+fun parseCamtMessage(doc: Document): CamtParseResult {
+ return destructXml(doc) {
+ requireRootElement("Document") {
+ // Either bank to customer statement or report
+ val reports = requireOnlyChild {
+ when (it.localName) {
+ "BkToCstmrAcctRpt" -> {
+ mapEachChildNamed("Rpt") {
+ extractInnerTransactions()
+ }
+ }
+ "BkToCstmrStmt" -> {
+ mapEachChildNamed("Stmt") {
+ extractInnerTransactions()
+ }
+ }
+ else -> {
+ throw CamtParsingError("expected statement or report")
+ }
+ }
+ }
+
+ 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 }
+ }
+ }
+ val creationDateTime = requireOnlyChild {
+ requireUniqueChildNamed("GrpHdr") {
+ requireUniqueChildNamed("CreDtTm") { it.textContent }
+ }
+ }
+ val messageType = requireOnlyChild {
+ when (it.localName) {
+ "BkToCstmrAcctRpt" -> CashManagementResponseType.Report
+ "BkToCstmrStmt" -> CashManagementResponseType.Statement
+ else -> {
+ throw CamtParsingError("expected statement or report")
+ }
+ }
+ }
+ CamtParseResult(reports, balances, messageId, messageType, creationDateTime)
+ }
+ }
+}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
@@ -24,9 +24,9 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeName
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.databind.JsonNode
-import tech.libeufin.nexus.CamtBankAccountEntry
-import tech.libeufin.nexus.CreditDebitIndicator
-import tech.libeufin.nexus.EntryStatus
+import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
+import tech.libeufin.nexus.iso20022.CreditDebitIndicator
+import tech.libeufin.nexus.iso20022.EntryStatus
import tech.libeufin.util.*
import java.time.Instant
import java.time.ZoneId
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
@@ -60,6 +60,7 @@ import tech.libeufin.nexus.OfferedBankAccountsTable.imported
import tech.libeufin.nexus.OfferedBankAccountsTable.offeredAccountId
import tech.libeufin.nexus.bankaccount.*
import tech.libeufin.nexus.ebics.*
+import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
import tech.libeufin.util.*
import tech.libeufin.nexus.logger
import java.lang.IllegalArgumentException
diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt
@@ -2,6 +2,7 @@ package tech.libeufin.nexus
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.Test
import org.w3c.dom.Document
+import tech.libeufin.nexus.iso20022.*
import tech.libeufin.util.XMLUtil
import kotlin.test.assertEquals