summaryrefslogtreecommitdiff
path: root/nexus/src/main/kotlin/tech
diff options
context:
space:
mode:
authorAntoine A <>2024-04-18 10:10:39 +0900
committerAntoine A <>2024-04-18 10:10:54 +0900
commita8c67aad250c62f0db3baebe5ea8bb67571382a8 (patch)
tree07fe00342ed5963c5181cb2f9976e56f999a740a /nexus/src/main/kotlin/tech
parentc4d1e58056d369c22e593c530d18024c282d666f (diff)
downloadlibeufin-a8c67aad250c62f0db3baebe5ea8bb67571382a8.tar.gz
libeufin-a8c67aad250c62f0db3baebe5ea8bb67571382a8.tar.bz2
libeufin-a8c67aad250c62f0db3baebe5ea8bb67571382a8.zip
More GLS dialect support
Diffstat (limited to 'nexus/src/main/kotlin/tech')
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt38
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt3
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt181
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt6
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt14
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt4
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt2
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