commit a8c67aad250c62f0db3baebe5ea8bb67571382a8
parent c4d1e58056d369c22e593c530d18024c282d666f
Author: Antoine A <>
Date: Thu, 18 Apr 2024 10:10:39 +0900
More GLS dialect support
Diffstat:
11 files changed, 224 insertions(+), 65 deletions(-)
diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt
@@ -39,15 +39,14 @@ import org.slf4j.event.Level
private val logger: Logger = LoggerFactory.getLogger("libeufin-config")
-fun Throwable.fmt(): String{
- var msg = StringBuilder(message ?: this::class.simpleName)
+fun Throwable.fmt(): String = buildString {
+ append(message ?: this::class.simpleName)
var cause = cause
while (cause != null) {
- msg.append(": ")
- msg.append(cause.message ?: cause::class.simpleName)
+ append(": ")
+ append(cause.message ?: cause::class.simpleName)
cause = cause.cause
}
- return msg.toString()
}
fun Throwable.fmtLog(logger: Logger) {
diff --git a/common/src/main/kotlin/Encoding.kt b/common/src/main/kotlin/Encoding.kt
@@ -32,8 +32,7 @@ object Base32Crockford {
private var encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
- fun encode(data: ByteArray): String {
- val sb = StringBuilder()
+ fun encode(data: ByteArray): String = buildString {
var inputChunkBuffer = 0
var pendingBitsCount = 0
var inputCursor = 0
@@ -47,7 +46,7 @@ object Base32Crockford {
// Write symbols
while (pendingBitsCount >= 5) {
val symbolIndex = inputChunkBuffer.ushr(pendingBitsCount - 5) and 31
- sb.append(encTable[symbolIndex])
+ append(encTable[symbolIndex])
pendingBitsCount -= 5
}
}
@@ -56,18 +55,16 @@ object Base32Crockford {
if (pendingBitsCount > 0) {
val symbolIndex = (inputChunkNumber shl (5 - pendingBitsCount)) and 31
- sb.append(encTable[symbolIndex])
+ append(encTable[symbolIndex])
}
- val enc = sb.toString()
val oneMore = ((data.size * 8) % 5) > 0
val expectedLength = if (oneMore) {
((data.size * 8) / 5) + 1
} else {
(data.size * 8) / 5
}
- if (enc.length != expectedLength)
+ if (this.length != expectedLength)
throw Exception("base32 encoding has wrong length")
- return enc
}
/**
diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt
@@ -113,9 +113,15 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
val bankKeysPath = cfg.requirePath("nexus-ebics", "bank_public_keys_file")
val currency = cfg.requireString("nexus-ebics", "currency")
- // Alternative payto ?
- val payto = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans"
-
+ val payto = when (currency) {
+ "CHF" -> "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans"
+ "EUR" -> "payto://iban/GENODEM1GLS/DE76430609674126675300?receiver-name=Grothoff%20Hans"
+ else -> throw Exception("Missing test payto for $currency")
+ }
+ val recoverDoc = when (cfg.requireString("nexus-ebics", "bank_dialect")) {
+ "gls" -> "statement"
+ else -> "notification"
+ }
runBlocking {
step("Init ${kind.name}")
@@ -136,7 +142,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
})
}
put("reset-db", "dbinit -r $flags")
- put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 notification")
+ put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 $recoverDoc")
put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags")
put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement")
put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status")
@@ -153,7 +159,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
if (kind.test) {
put("tx", suspend {
step("Submit one transaction")
- nexusCmd.run("initiate-payment $flags \"$payto&amount=CHF:42&message=single%20transaction%20test\"")
+ nexusCmd.run("initiate-payment $flags \"$payto&amount=$currency:42&message=single%20transaction%20test\"")
nexusCmd.run("ebics-submit $ebicsFlags")
Unit
})
diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt
@@ -21,6 +21,7 @@ import org.junit.Test
import tech.libeufin.nexus.parseCustomerAck
import tech.libeufin.nexus.parseCustomerPaymentStatusReport
import tech.libeufin.nexus.parseTxNotif
+import tech.libeufin.nexus.parseTxStatement
import java.nio.file.Files
import kotlin.io.path.Path
import kotlin.io.path.exists
@@ -38,6 +39,8 @@ class Iso20022Test {
parseCustomerAck(content)
} else if (name.contains("pain.002")) {
parseCustomerPaymentStatusReport(content)
+ } else if (name.contains("C53")) {
+ parseTxStatement(content, "EUR")
} else {
parseTxNotif(content, "CHF")
}
@@ -60,6 +63,8 @@ class Iso20022Test {
parseCustomerAck(content)
} else if (name.contains("pain.002")) {
parseCustomerPaymentStatusReport(content)
+ } else if (name.contains("C53")) {
+ parseTxStatement(content, "EUR")
} else {
parseTxNotif(content, "CHF")
}