diff options
author | Antoine A <> | 2024-04-18 10:10:39 +0900 |
---|---|---|
committer | Antoine A <> | 2024-04-18 10:10:54 +0900 |
commit | a8c67aad250c62f0db3baebe5ea8bb67571382a8 (patch) | |
tree | 07fe00342ed5963c5181cb2f9976e56f999a740a /nexus | |
parent | c4d1e58056d369c22e593c530d18024c282d666f (diff) | |
download | libeufin-a8c67aad250c62f0db3baebe5ea8bb67571382a8.tar.gz libeufin-a8c67aad250c62f0db3baebe5ea8bb67571382a8.tar.bz2 libeufin-a8c67aad250c62f0db3baebe5ea8bb67571382a8.zip |
More GLS dialect support
Diffstat (limited to 'nexus')
7 files changed, 200 insertions, 48 deletions
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt index fa1e104d..03c127c0 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -212,7 +212,26 @@ private suspend fun ingestDocument( db.initiated.bankMessage(status.msgId, msg) } } - SupportedDocument.CAMT_053, + SupportedDocument.CAMT_053 -> { + try { + parseTxStatement(xml, cfg.currency).forEach { + if (cfg.fetch.ignoreBefore != null && it.executionTime < cfg.fetch.ignoreBefore) { + logger.debug("IGNORE $it") + } else { + when (it) { + is IncomingPayment -> ingestIncomingPayment(db, it) + is OutgoingPayment -> ingestOutgoingPayment(db, it) + is TxNotification.Reversal -> { + logger.error("BOUNCE '${it.msgId}': ${it.reason}") + db.initiated.reversal(it.msgId, "Payment bounced: ${it.reason}") + } + } + } + } + } catch (e: Exception) { + throw Exception("Ingesting statements failed", e) + } + } SupportedDocument.CAMT_052 -> { // TODO parsing // TODO ingesting @@ -313,7 +332,7 @@ enum class EbicsDocument { /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 notification, /// Account statements - BankToCustomerStatement camt.053 - // statement, TODO add support + statement, ; fun shortDescription(): String = when (this) { @@ -321,7 +340,7 @@ enum class EbicsDocument { status -> "Payment status" //Document.report -> "Account intraday reports" notification -> "Debit & credit notifications" - //Document.statement -> "Account statements" + statement -> "Account statements" } fun fullDescription(): String = when (this) { @@ -329,7 +348,7 @@ enum class EbicsDocument { status -> "Payment status - CustomerPaymentStatusReport pain.002" //report -> "Account intraday reports - BankToCustomerAccountReport camt.052" notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054" - //statement -> "Account statements - BankToCustomerStatement camt.053" + statement -> "Account statements - BankToCustomerStatement camt.053" } fun doc(): SupportedDocument = when (this) { @@ -337,7 +356,14 @@ enum class EbicsDocument { status -> SupportedDocument.PAIN_002 //Document.report -> SupportedDocument.CAMT_052 notification -> SupportedDocument.CAMT_054 - //Document.statement -> SupportedDocument.CAMT_053 + statement -> SupportedDocument.CAMT_053 + } + + companion object { + fun defaults(dialect: Dialect) = when (dialect) { + Dialect.postfinance -> listOf(acknowledgement, status, notification) + Dialect.gls -> listOf(acknowledgement, status, statement) + } } } @@ -387,7 +413,7 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { null, FileLogger(ebicsLog) ) - val docs = if (documents.isEmpty()) EbicsDocument.entries else documents.toList() + val docs = if (documents.isEmpty()) EbicsDocument.defaults(cfg.dialect) else documents.toList() if (transient) { logger.info("Transient mode: fetching once and returning.") val pinnedStartVal = pinnedStart diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt index 9b1133a4..ccf91594 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -84,7 +84,8 @@ private suspend fun submitInitiatedPayment( amount = payment.amount, creditAccount = creditAccount, debitAccount = ctx.cfg.account, - wireTransferSubject = payment.wireTransferSubject + wireTransferSubject = payment.wireTransferSubject, + dialect = ctx.cfg.dialect ) ctx.fileLogger.logSubmit(xml) return doEbicsUpload( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt index 09fdf0b0..3ff3e76f 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -19,6 +19,7 @@ package tech.libeufin.nexus import tech.libeufin.common.* +import tech.libeufin.nexus.ebics.Dialect import java.io.InputStream import java.net.URLEncoder import java.time.* @@ -74,18 +75,16 @@ fun createPain001( debitAccount: IbanAccountMetadata, amount: TalerAmount, wireTransferSubject: String, - creditAccount: IbanAccountMetadata + creditAccount: IbanAccountMetadata, + dialect: Dialect ): ByteArray { - val namespace = Pain001Namespaces( - fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", - xsdFilename = "pain.001.001.09.ch.03.xsd" - ) + val version = "09" val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) val amountWithoutCurrency: String = getAmountNoCurrency(amount) return XmlBuilder.toBytes("Document") { - attr("xmlns", namespace.fullNamespace) + attr("xmlns", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version") attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") - attr("xsi:schemaLocation", "${namespace.fullNamespace} ${namespace.xsdFilename}") + 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) @@ -97,12 +96,26 @@ fun createPain001( el("PmtInf") { el("PmtInfId", "NOTPROVIDED") el("PmtMtd", "TRF") - el("BtchBookg", "false") + el("BtchBookg", "true") + el("NbOfTxs", "1") + el("CtrlSum", amountWithoutCurrency) + el("PmtTpInf/SvcLvl/Cd", + when (dialect) { + Dialect.postfinance -> "SDVA" + Dialect.gls -> "SEPA" + } + ) el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) el("Dbtr/Nm", debitAccount.name) el("DbtrAcct/Id/IBAN", debitAccount.iban) - if (debitAccount.bic != null) - el("DbtrAgt/FinInstnId/BICFI", debitAccount.bic) + el("DbtrAgt/FinInstnId") { + if (debitAccount.bic != null) { + el("BICFI", debitAccount.bic) + } else { + el("Othr/Id", "NOTPROVIDED") + } + } + el("ChrgBr", "SLEV") el("CdtTrfTxInf") { el("PmtId") { el("InstrId", "NOTPROVIDED") @@ -112,8 +125,15 @@ fun createPain001( attr("Ccy", amount.currency) text(amountWithoutCurrency) } - el("Cdtr/Nm", creditAccount.name) - // TODO write credit account bic if we have it + if (creditAccount.bic != null) el("CdtrAgt/FinInstnId/BICFI", creditAccount.bic) + el("Cdtr") { + el("Nm", creditAccount.name) + // Addr might become a requirement in the future + /*el("PstlAdr") { + el("TwnNm", "Bochum") + el("Ctry", "DE") + }*/ + } el("CdtrAcct/Id/IBAN", creditAccount.iban) el("RmtInf/Ustrd", wireTransferSubject) } @@ -126,20 +146,21 @@ data class CustomerAck( val actionType: HacAction, val orderId: String?, val code: ExternalStatusReasonCode?, + val info: String, val timestamp: Instant ) { - fun msg(): String { - var str = "${actionType}" - if (code != null) str += " ${code.isoCode}" - str += " - '${actionType.description}'" - if (code != null) str += " '${code.description}'" - return str + fun msg(): String = buildString { + append("${actionType}") + if (code != null) append(" ${code.isoCode}") + append(" - '${actionType.description}'") + if (code != null) append(" '${code.description}'") + if (info != "") append(" - '$info'") } - override fun toString(): String { - var str = "${timestamp.fmtDateTime()}" - if (orderId != null) str += " ${orderId}" - return str + " ${msg()}" + override fun toString(): String = buildString { + append("${timestamp.fmtDateTime()}") + if (orderId != null) append(" ${orderId}") + append(" ${msg()}") } } @@ -163,7 +184,8 @@ fun parseCustomerAck(xml: InputStream): List<CustomerAck> { } } val code = opt("Rsn")?.one("Cd")?.enum<ExternalStatusReasonCode>() - CustomerAck(actionType, orderId, code, timestamp!!) + val info = map("AddtlInf") { text() }.joinToString("") + CustomerAck(actionType, orderId, code, info, timestamp!!) } } } @@ -194,11 +216,12 @@ data class PaymentStatus( } else if (reasons.size == 1) { "${code()} ${reasons[0].code.isoCode} - '${description()}' '${reasons[0].code.description}'" } else { - var str = "${code()} '${description()}' - " - for (reason in reasons) { - str += "${reason.code.isoCode} '${reason.code.description}' " + buildString { + append("${code()} '${description()}' - ") + for (reason in reasons) { + append("${reason.code.isoCode} '${reason.code.description}' ") + } } - str } } @@ -262,7 +285,7 @@ data class IncomingPayment( val bankId: String ): TxNotification { override fun toString(): String { - return "IN ${executionTime.fmtDate()} $amount '$bankId' debitor=$debitPaytoUri subject=$wireTransferSubject" + return "IN ${executionTime.fmtDate()} $amount '$bankId' debitor=$debitPaytoUri subject=\"$wireTransferSubject\"" } } @@ -276,7 +299,7 @@ data class OutgoingPayment( val wireTransferSubject: String? = null // not showing in camt.054 ): TxNotification { override fun toString(): String { - return "OUT ${executionTime.fmtDate()} $amount '$messageId' creditor=$creditPaytoUri subject=$wireTransferSubject" + return "OUT ${executionTime.fmtDate()} $amount '$messageId' creditor=$creditPaytoUri subject=\"$wireTransferSubject\"" } } @@ -379,4 +402,104 @@ fun parseTxNotif( } } return notifications +} + +/** Parse camt.053 XML file */ +fun parseTxStatement( + notifXml: InputStream, + acceptedCurrency: String +): List<TxNotification> { + fun notificationForEachTx( + directionLambda: XmlDestructor.(Instant, Boolean, String?) -> Unit + ) { + XmlDestructor.fromStream(notifXml, "Document") { + one("BkToCstmrStmt").each("Stmt") { + one("Acct") { + // Sanity check on currency and IBAN + } + each("Ntry") { + val reversal = opt("RvslInd")?.bool() ?: false + val info = opt("AddtlNtryInf")?.text() + one("Sts") { + if (text() != "BOOK") { + throw Exception("Found non booked transaction, " + + "stop parsing. Status was: ${text()}" + ) + } + } + val bookDate: Instant = one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC) + directionLambda(this, bookDate, reversal, info) + } + } + } + } + + val notifications = mutableListOf<TxNotification>() + notificationForEachTx { bookDate, reversal, info -> + 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()}") + } + if (reversal) { + throw Exception("Reversal !!") + require("CRDT" == kind) + val msgId = one("Refs").opt("MsgId")?.text() + if (msgId == null) { + logger.debug("Unsupported reversal without message id") + } else { + notifications.add(TxNotification.Reversal( + msgId = msgId, + reason = info, + executionTime = bookDate + )) + } + return@notificationForEachTx + } + when (kind) { + "CRDT" -> { + val bankId: String = one("AcctSvcrRef").text() + one("NtryDtls").one("TxDtls") { + // Obtaining payment subject. + val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") + if (subject == null) { + logger.debug("Skip notification '$bankId', missing subject") + //return@notificationForEachTx + } + // Obtaining the payer's details + val debtorPayto = StringBuilder("payto://iban/") + one("RltdPties") { + one("DbtrAcct").one("Id").one("IBAN") { + debtorPayto.append(text()) + } + one("Dbtr").one("Nm") { + val urlEncName = URLEncoder.encode(text(), "utf-8") + debtorPayto.append("?receiver-name=$urlEncName") + } + } + notifications.add(IncomingPayment( + amount = amount, + bankId = bankId, + debitPaytoUri = debtorPayto.toString(), + executionTime = bookDate, + wireTransferSubject = subject.toString() + )) + } + } + "DBIT" -> { + /*val messageId = one("Refs").one("MsgId").text() + notifications.add(OutgoingPayment( + amount = amount, + messageId = messageId, + executionTime = bookDate + ))*/ + } + else -> throw Exception("Unknown transaction notification kind '$kind'") + } + } + return notifications }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt index 30714fb6..26eb080d 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt @@ -154,11 +154,11 @@ class XmlDestructor internal constructor(private val el: Element) { fun one(path: String): XmlDestructor { val children = el.childrenByTag(path).iterator() if (!children.hasNext()) { - throw DestructionError("expected a single $path child, got none instead at $el") + throw DestructionError("expected unique '${el.tagName}.$path', got none") } val el = children.next() if (children.hasNext()) { - throw DestructionError("expected a single $path child, got ${children.asSequence() + 1} instead at $el") + throw DestructionError("expected unique '${el.tagName}.$path', got ${children.asSequence() + 1}") } return XmlDestructor(el) } @@ -169,7 +169,7 @@ class XmlDestructor internal constructor(private val el: Element) { } val el = children.next() if (children.hasNext()) { - throw DestructionError("expected an optional $path child, got ${children.asSequence().count() + 1} instead at $el") + throw DestructionError("expected optional '${el.tagName}.$path', got ${children.asSequence().count() + 1}") } return XmlDestructor(el) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt index da1e3145..0ea66e1f 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt @@ -86,23 +86,23 @@ object EbicsAdministrative { OrderInfo( one("AdminOrderType").text(), opt("Service") { - var params = "" + var params = StringBuilder() opt("ServiceName")?.run { - params += " ${text()}" + params.append(" ${text()}") } opt("Scope")?.run { - params += " ${text()}" + params.append(" ${text()}") } opt("ServiceOption")?.run { - params += " ${text()}" + params.append(" ${text()}") } opt("MsgName")?.run { - params += " ${text()}" + params.append(" ${text()}") } opt("Container")?.run { - params += " ${attr("containerType")}" + params.append(" ${attr("containerType")}") } - params + params.toString() } ?: "", one("Description").text() ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt index 9210f4cc..cf00c90c 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt @@ -270,7 +270,9 @@ class EbicsBTS( private fun XmlBuilder.service(order: EbicsOrder.V3) { el("Service") { el("ServiceName", order.name!!) - el("Scope", order.scope!!) + if (order.scope != null) { + el("Scope", order.scope) + } if (order.option != null) { el("ServiceOption", order.option) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt index ba63a115..d6cced05 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt @@ -75,7 +75,7 @@ enum class Dialect { fun directDebit(): EbicsOrder { return when (this) { postfinance -> EbicsOrder.V3("BTU", "MCT", "CH", "pain.001", "09") - gls -> EbicsOrder.V3("BTU", "SCT", "DE", "pain.001", null, "XML") + gls -> EbicsOrder.V3("BTU", "SCT", null, "pain.001") } } }
\ No newline at end of file |