aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-01-30 18:32:04 +0100
committerAntoine A <>2024-01-30 19:09:54 +0100
commit16e0190f5337ce4c0c5c61df204e811e84960d75 (patch)
tree3b56b67cc2269a4093f6ea89727f0dc49dc7fb7f
parentd65afec46d35967c52fea9f145dab8ea0fffca3c (diff)
downloadlibeufin-16e0190f5337ce4c0c5c61df204e811e84960d75.tar.gz
libeufin-16e0190f5337ce4c0c5c61df204e811e84960d75.tar.bz2
libeufin-16e0190f5337ce4c0c5c61df204e811e84960d75.zip
Improve xml logic and fix ebics testbench
-rw-r--r--Makefile4
-rw-r--r--common/src/main/kotlin/time.kt25
-rw-r--r--ebics/src/main/kotlin/XMLUtil.kt10
-rw-r--r--ebics/src/main/kotlin/XmlCombinators.kt180
-rw-r--r--ebics/src/test/kotlin/XmlCombinatorsTest.kt50
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt6
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt321
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt2
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt4
-rw-r--r--nexus/src/test/kotlin/Parsing.kt15
-rw-r--r--testbench/src/main/kotlin/Main.kt11
-rw-r--r--testbench/src/test/kotlin/Iso20022Test.kt52
12 files changed, 268 insertions, 412 deletions
diff --git a/Makefile b/Makefile
index 9a63ae7e..d0665d94 100644
--- a/Makefile
+++ b/Makefile
@@ -110,6 +110,10 @@ bank-test: install-nobuild-bank-files
nexus-test: install-nobuild-nexus-files
./gradlew :nexus:test --tests $(test) -i
+.PHONY: ebics-test
+ebics-test:
+ ./gradlew :ebics:test --tests $(test) -i
+
.PHONY: testbench-test
testbench-test: install-nobuild-bank-files install-nobuild-nexus-files
./gradlew :testbench:test --tests $(test) -i
diff --git a/common/src/main/kotlin/time.kt b/common/src/main/kotlin/time.kt
index fee05d01..269a911e 100644
--- a/common/src/main/kotlin/time.kt
+++ b/common/src/main/kotlin/time.kt
@@ -86,31 +86,6 @@ fun Long.microsToJavaInstant(): Instant? {
}
/**
- * Parses timestamps found in camt.054 documents. They have
- * the following format: yyy-MM-ddThh:mm:ss, without any timezone.
- *
- * @param timeFromXml input time string from the XML
- * @return [Instant] in the UTC timezone
- */
-fun parseCamtTime(timeFromCamt: String): Instant {
- val t = LocalDateTime.parse(timeFromCamt)
- val utc = ZoneId.of("UTC")
- return t.toInstant(utc.rules.getOffset(t))
-}
-
-/**
- * Parses a date string as found in the booking date of
- * camt.054 reports. They have this format: yyyy-MM-dd.
- *
- * @param bookDate input to parse
- * @return [Instant] to the UTC.
- */
-fun parseBookDate(bookDate: String): Instant {
- val l = LocalDate.parse(bookDate)
- return Instant.from(l.atStartOfDay(ZoneId.of("UTC")))
-}
-
-/**
* Returns the minimum instant between two.
*
* @param a input [Instant]
diff --git a/ebics/src/main/kotlin/XMLUtil.kt b/ebics/src/main/kotlin/XMLUtil.kt
index c294b7ef..63dbf35b 100644
--- a/ebics/src/main/kotlin/XMLUtil.kt
+++ b/ebics/src/main/kotlin/XMLUtil.kt
@@ -405,6 +405,16 @@ class XMLUtil private constructor() {
return builder.parse(InputSource(xmlInputStream))
}
+ /** Parse [xml] into a XML DOM */
+ fun parseBytesIntoDom(xml: ByteArray): Document {
+ val factory = DocumentBuilderFactory.newInstance().apply {
+ isNamespaceAware = true
+ }
+ val xmlInputStream = ByteArrayInputStream(xml)
+ val builder = factory.newDocumentBuilder()
+ return builder.parse(InputSource(xmlInputStream))
+ }
+
fun signEbicsResponse(ebicsResponse: EbicsResponse, privateKey: RSAPrivateCrtKey): String {
val doc = convertJaxbToDocument(ebicsResponse)
signEbicsDocument(doc, privateKey)
diff --git a/ebics/src/main/kotlin/XmlCombinators.kt b/ebics/src/main/kotlin/XmlCombinators.kt
index d9cc77b2..f9f924b9 100644
--- a/ebics/src/main/kotlin/XmlCombinators.kt
+++ b/ebics/src/main/kotlin/XmlCombinators.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2020 Taler Systems S.A.
+ * Copyright (C) 2020, 2024 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
@@ -25,28 +25,27 @@ import org.w3c.dom.Element
import java.io.StringWriter
import javax.xml.stream.XMLOutputFactory
import javax.xml.stream.XMLStreamWriter
+import java.time.format.*
+import java.time.*
-class XmlElementBuilder(val w: XMLStreamWriter) {
- /**
- * First consumes all the path's components, and _then_ starts applying f.
- */
- fun element(path: MutableList<String>, f: XmlElementBuilder.() -> Unit = {}) {
- /* the wanted path got constructed, go on with f's logic now. */
- if (path.isEmpty()) {
- f()
- return
+class XmlBuilder(private val w: XMLStreamWriter) {
+ fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) {
+ path.splitToSequence('/').forEach {
+ w.writeStartElement(it)
+ }
+ lambda()
+ path.splitToSequence('/').forEach {
+ w.writeEndElement()
}
- w.writeStartElement(path.removeAt(0))
- this.element(path, f)
- w.writeEndElement()
}
- fun element(path: String, f: XmlElementBuilder.() -> Unit = {}) {
- val splitPath = path.trim('/').split("/").toMutableList()
- this.element(splitPath, f)
+ fun el(path: String, content: String) {
+ el(path) {
+ text(content)
+ }
}
- fun attribute(name: String, value: String) {
+ fun attr(name: String, value: String) {
w.writeAttribute(name, value)
}
@@ -55,130 +54,89 @@ class XmlElementBuilder(val w: XMLStreamWriter) {
}
}
-class XmlDocumentBuilder {
-
- private var maybeWriter: XMLStreamWriter? = null
- internal var writer: XMLStreamWriter
- get() {
- val w = maybeWriter
- return w ?: throw AssertionError("no writer set")
- }
- set(w: XMLStreamWriter) {
- maybeWriter = w
- }
-
- fun namespace(uri: String) {
- writer.setDefaultNamespace(uri)
- }
-
- fun namespace(prefix: String, uri: String) {
- writer.setPrefix(prefix, uri)
- }
-
- fun defaultNamespace(uri: String) {
- writer.setDefaultNamespace(uri)
- }
-
- fun root(name: String, f: XmlElementBuilder.() -> Unit) {
- val elementBuilder = XmlElementBuilder(writer)
- writer.writeStartElement(name)
- elementBuilder.f()
- writer.writeEndElement()
- }
-}
-
-fun constructXml(indent: Boolean = false, f: XmlDocumentBuilder.() -> Unit): String {
- val b = XmlDocumentBuilder()
+fun constructXml(root: String, f: XmlBuilder.() -> Unit): String {
val factory = XMLOutputFactory.newFactory()
- factory.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true)
val stream = StringWriter()
var writer = factory.createXMLStreamWriter(stream)
- if (indent) {
- writer = IndentingXMLStreamWriter(writer)
- }
- b.writer = writer
/**
* NOTE: commenting out because it wasn't obvious how to output the
* "standalone = 'yes' directive". Manual forge was therefore preferred.
*/
- // writer.writeStartDocument()
- f(b)
+ stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")
+ XmlBuilder(writer).el(root) {
+ this.f()
+ }
writer.writeEndDocument()
- return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n${stream.buffer.toString()}"
+ return stream.buffer.toString()
}
class DestructionError(m: String) : Exception(m)
-private fun Element.getChildElements(ns: String, tag: String): List<Element> {
- val elements = mutableListOf<Element>()
- for (i in 0..this.childNodes.length) {
- val el = this.childNodes.item(i)
+private fun Element.childrenByTag(tag: String): Sequence<Element> = sequence {
+ for (i in 0..childNodes.length) {
+ val el = childNodes.item(i)
if (el !is Element) {
continue
}
- if (ns != "*" && el.namespaceURI != ns) {
- continue
- }
- if (tag != "*" && el.localName != tag) {
+ if (el.localName != tag) {
continue
}
- elements.add(el)
+ yield(el)
}
- return elements
}
-class XmlElementDestructor internal constructor(val focusElement: Element) {
- fun <T> requireOnlyChild(f: XmlElementDestructor.(e: Element) -> T): T {
- val children = focusElement.getChildElements("*", "*")
- if (children.size != 1) throw DestructionError("expected singleton child tag")
- val destr = XmlElementDestructor(children[0])
- return f(destr, children[0])
- }
-
- fun <T> mapEachChildNamed(s: String, f: XmlElementDestructor.() -> T): List<T> {
- val res = mutableListOf<T>()
- val els = focusElement.getChildElements("*", s)
- for (child in els) {
- val destr = XmlElementDestructor(child)
- res.add(f(destr))
+class XmlDestructor internal constructor(private val el: Element) {
+ fun each(path: String, f: XmlDestructor.() -> Unit) {
+ el.childrenByTag(path).forEach {
+ f(XmlDestructor(it))
}
- return res
}
- fun <T> requireUniqueChildNamed(s: String, f: XmlElementDestructor.() -> T): T {
- val cl = focusElement.getChildElements("*", s)
- if (cl.size != 1) {
- throw DestructionError("expected exactly one unique $s child, got ${cl.size} instead at ${focusElement}")
- }
- val el = cl[0]
- val destr = XmlElementDestructor(el)
- return f(destr)
+ fun <T> map(path: String, f: XmlDestructor.() -> T): List<T> {
+ return el.childrenByTag(path).map {
+ f(XmlDestructor(it))
+ }.toList()
}
- fun <T> maybeUniqueChildNamed(s: String, f: XmlElementDestructor.() -> T): T? {
- val cl = focusElement.getChildElements("*", s)
- if (cl.size > 1) {
- throw DestructionError("expected at most one unique $s child, got ${cl.size} instead")
+ 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")
}
- if (cl.size == 1) {
- val el = cl[0]
- val destr = XmlElementDestructor(el)
- return f(destr)
+ val el = children.next()
+ if (children.hasNext()) {
+ throw DestructionError("expected a single $path child, got ${children.asSequence() + 1} instead at $el")
}
- return null
+ return XmlDestructor(el)
}
-}
-
-class XmlDocumentDestructor internal constructor(val d: Document) {
- fun <T> requireRootElement(name: String, f: XmlElementDestructor.() -> T): T {
- if (this.d.documentElement.tagName != name) {
- throw DestructionError("expected '$name' tag")
+ fun opt(path: String): XmlDestructor? {
+ val children = el.childrenByTag(path).iterator()
+ if (!children.hasNext()) {
+ return null
}
- val destr = XmlElementDestructor(d.documentElement)
- return f(destr)
+ val el = children.next()
+ if (children.hasNext()) {
+ throw DestructionError("expected an optional $path child, got ${children.asSequence().count() + 1} instead at $el")
+ }
+ return XmlDestructor(el)
}
+
+ fun <T> one(path: String, f: XmlDestructor.() -> T): T = f(one(path))
+ fun <T> opt(path: String, f: XmlDestructor.() -> T): T? = opt(path)?.run(f)
+
+ fun text(): String = el.textContent
+ fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE)
+ fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME)
+ inline fun <reified T : kotlin.Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text())
+
+ fun attr(index: String): String = el.getAttribute(index)
}
-fun <T> destructXml(d: Document, f: XmlDocumentDestructor.() -> T): T {
- return f(XmlDocumentDestructor(d))
+fun <T> destructXml(xml: ByteArray, root: String, f: XmlDestructor.() -> T): T {
+ val doc = XMLUtil.parseBytesIntoDom(xml)
+ if (doc.documentElement.tagName != root) {
+ throw DestructionError("expected root '$root' got '${doc.documentElement.tagName}'")
+ }
+ val destr = XmlDestructor(doc.documentElement)
+ return f(destr)
}
diff --git a/ebics/src/test/kotlin/XmlCombinatorsTest.kt b/ebics/src/test/kotlin/XmlCombinatorsTest.kt
index c261187a..df219857 100644
--- a/ebics/src/test/kotlin/XmlCombinatorsTest.kt
+++ b/ebics/src/test/kotlin/XmlCombinatorsTest.kt
@@ -18,58 +18,50 @@
*/
import org.junit.Test
-import tech.libeufin.ebics.XmlElementBuilder
+import tech.libeufin.ebics.XmlBuilder
import tech.libeufin.ebics.constructXml
+import kotlin.test.*
class XmlCombinatorsTest {
@Test
fun testWithModularity() {
- fun module(base: XmlElementBuilder) {
- base.element("module")
+ fun module(base: XmlBuilder) {
+ base.el("module")
}
- val s = constructXml {
- root("root") {
- module(this)
- }
+ val s = constructXml("root") {
+ module(this)
}
println(s)
+ assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><root><module/></root>", s)
}
@Test
fun testWithIterable() {
- val s = constructXml(indent = true) {
- namespace("iter", "able")
- root("iterable") {
- element("endOfDocument") {
- for (i in 1..10)
- element("$i") {
- element("$i$i") {
- text("$i$i$i")
- }
- }
- }
+ val s = constructXml("iterable") {
+ el("endOfDocument") {
+ for (i in 1..10)
+ el("$i/$i$i", "$i$i$i")
}
}
println(s)
+ assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><iterable><endOfDocument><1><11>111</11></1><2><22>222</22></2><3><33>333</33></3><4><44>444</44></4><5><55>555</55></5><6><66>666</66></6><7><77>777</77></7><8><88>888</88></8><9><99>999</99></9><10><1010>101010</1010></10></endOfDocument></iterable>", s)
}
@Test
fun testBasicXmlBuilding() {
- val s = constructXml(indent = true) {
- namespace("ebics", "urn:org:ebics:H004")
- root("ebics:ebicsRequest") {
- attribute("version", "H004")
- element("a/b/c") {
- attribute("attribute-of", "c")
- element("//d/e/f//") {
- attribute("nested", "true")
- element("g/h/")
- }
+ val s = constructXml("ebics:ebicsRequest") {
+ attr("version", "H004")
+ el("a/b/c") {
+ attr("attribute-of", "c")
+ el("//d/e/f//") {
+ attr("nested", "true")
+ el("g/h/")
}
- element("one more")
}
+ el("one more")
}
println(s)
+ assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><ebics:ebicsRequest version=\"H004\"><a><b><c attribute-of=\"c\"><><><d><e><f><>< nested=\"true\"><g><h></></h></g></></></f></e></d></></></c></b></a><one more/></ebics:ebicsRequest>", s)
}
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index 6fc7a7fb..12a8d451 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -261,7 +261,7 @@ suspend fun ingestIncomingPayment(
private fun ingestDocument(
db: Database,
currency: String,
- xml: String,
+ xml: ByteArray,
whichDocument: SupportedDocument
) {
when (whichDocument) {
@@ -315,7 +315,7 @@ private fun ingestDocuments(
throw Exception("Could not open any ZIP archive", e)
}
}
- SupportedDocument.PAIN_002_LOGS -> ingestDocument(db, currency, content.toString(Charsets.UTF_8), whichDocument)
+ SupportedDocument.PAIN_002_LOGS -> ingestDocument(db, currency, content, whichDocument)
else -> logger.warn("Not ingesting ${whichDocument}. Only camt.054 notifications supported.")
}
}
@@ -435,7 +435,7 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti
Database(dbCfg.dbConnStr).use { db ->
if (import) {
logger.debug("Reading from STDIN")
- val stdin = generateSequence(::readLine).joinToString("\n")
+ val stdin = generateSequence(::readLine).joinToString("\n").toByteArray()
ingestDocument(db, cfg.currency, stdin, whichDoc)
return@cliCmd
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index 005b010d..826ef4e2 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -21,10 +21,8 @@ package tech.libeufin.nexus
import tech.libeufin.common.*
import tech.libeufin.ebics.*
import java.net.URLEncoder
-import java.time.Instant
-import java.time.ZoneId
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
+import java.time.*
+import java.time.format.*
/**
@@ -85,82 +83,38 @@ fun createPain001(
)
val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC"))
val amountWithoutCurrency: String = getAmountNoCurrency(amount)
- return constructXml {
- root("Document") {
- attribute(
- "xmlns",
- namespace.fullNamespace
- )
- attribute(
- "xmlns:xsi",
- "http://www.w3.org/2001/XMLSchema-instance"
- )
- attribute(
- "xsi:schemaLocation",
- "${namespace.fullNamespace} ${namespace.xsdFilename}"
- )
- element("CstmrCdtTrfInitn") {
- element("GrpHdr") {
- element("MsgId") {
- text(requestUid)
- }
- element("CreDtTm") {
- val dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
- text(dateFormatter.format(zonedTimestamp))
- }
- element("NbOfTxs") {
- text("1")
+ return constructXml("Document") {
+ attr("xmlns", namespace.fullNamespace)
+ attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
+ attr("xsi:schemaLocation", "${namespace.fullNamespace} ${namespace.xsdFilename}")
+ el("CstmrCdtTrfInitn") {
+ el("GrpHdr") {
+ el("MsgId", requestUid)
+ el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp))
+ el("NbOfTxs", "1")
+ el("CtrlSum", amountWithoutCurrency)
+ el("InitgPty/Nm", debitAccount.name)
+ }
+ el("PmtInf") {
+ el("PmtInfId", "NOTPROVIDED")
+ el("PmtMtd", "TRF")
+ el("BtchBookg", "false")
+ el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp))
+ el("Dbtr/Nm", debitAccount.name)
+ el("DbtrAcct/Id/IBAN", debitAccount.iban)
+ el("DbtrAgt/FinInstnId/BICFI", debitAccount.bic)
+ el("CdtTrfTxInf") {
+ el("PmtId") {
+ el("InstrId", "NOTPROVIDED")
+ el("EndToEndId", "NOTPROVIDED")
}
- element("CtrlSum") {
+ el("Amt/InstdAmt") {
+ attr("Ccy", amount.currency)
text(amountWithoutCurrency)
}
- element("InitgPty/Nm") {
- text(debitAccount.name)
- }
- }
- element("PmtInf") {
- element("PmtInfId") {
- text("NOTPROVIDED")
- }
- element("PmtMtd") {
- text("TRF")
- }
- element("BtchBookg") {
- text("false")
- }
- element("ReqdExctnDt") {
- element("Dt") {
- text(DateTimeFormatter.ISO_DATE.format(zonedTimestamp))
- }
- }
- element("Dbtr/Nm") {
- text(debitAccount.name)
- }
- element("DbtrAcct/Id/IBAN") {
- text(debitAccount.iban)
- }
- element("DbtrAgt/FinInstnId/BICFI") {
- text(debitAccount.bic)
- }
- element("CdtTrfTxInf") {
- element("PmtId") {
- element("InstrId") { text("NOTPROVIDED") }
- element("EndToEndId") { text("NOTPROVIDED") }
- }
- element("Amt/InstdAmt") {
- attribute("Ccy", amount.currency)
- text(amountWithoutCurrency)
- }
- element("Cdtr/Nm") {
- text(creditAccount.receiverName)
- }
- element("CdtrAcct/Id/IBAN") {
- text(creditAccount.payto.iban)
- }
- element("RmtInf/Ustrd") {
- text(wireTransferSubject)
- }
- }
+ el("Cdtr/Nm", creditAccount.receiverName)
+ el("CdtrAcct/Id/IBAN", creditAccount.payto.iban)
+ el("RmtInf/Ustrd", wireTransferSubject)
}
}
}
@@ -185,48 +139,24 @@ data class CustomerAck(
*
* @param xml pain.002 input document
*/
-fun parseCustomerAck(xml: String): List<CustomerAck> {
- val notifDoc = XMLUtil.parseStringIntoDom(xml)
- return destructXml(notifDoc) {
- requireRootElement("Document") {
- requireUniqueChildNamed("CstmrPmtStsRpt") {
- mapEachChildNamed("OrgnlPmtInfAndSts") {
- val actionType = requireUniqueChildNamed("OrgnlPmtInfId") {
- focusElement.textContent
- }
-
- requireUniqueChildNamed("StsRsnInf") {
- var timestamp: Instant? = null;
- requireUniqueChildNamed("Orgtr") {
- requireUniqueChildNamed("Id") {
- requireUniqueChildNamed("OrgId") {
- mapEachChildNamed("Othr") {
- val value = requireUniqueChildNamed("Id") {
- focusElement.textContent
- }
- val key = requireUniqueChildNamed("SchmeNm") {
- requireUniqueChildNamed("Prtry") {
- focusElement.textContent
- }
- }
- when (key) {
- "TimeStamp" -> {
- timestamp = parseCamtTime(value.trimEnd('Z')) // TODO better date parsing
- }
- // TODO extract ids ?
- }
- }
- }
- }
- }
- val code = maybeUniqueChildNamed("Rsn") {
- requireUniqueChildNamed("Cd") {
- ExternalStatusReasonCode.valueOf(focusElement.textContent)
- }
+fun parseCustomerAck(xml: ByteArray): List<CustomerAck> {
+ return destructXml(xml, "Document") {
+ one("CstmrPmtStsRpt").map("OrgnlPmtInfAndSts") {
+ val actionType = one("OrgnlPmtInfId").text()
+ one("StsRsnInf") {
+ var timestamp: Instant? = null;
+ one("Orgtr").one("Id").one("OrgId").each("Othr") {
+ val value = one("Id")
+ val key = one("SchmeNm").one("Prtry").text()
+ when (key) {
+ "TimeStamp" -> {
+ timestamp = value.dateTime().toInstant(ZoneOffset.UTC)
}
- CustomerAck(actionType, code, timestamp!!)
+ // TODO extract ids ?
}
}
+ val code = opt("Rsn")?.one("Cd")?.enum<ExternalStatusReasonCode>()
+ CustomerAck(actionType, code, timestamp!!)
}
}
}
@@ -256,47 +186,34 @@ data class Reason (
*
* @param xml pain.002 input document
*/
-fun parseCustomerPaymentStatusReport(xml: String): PaymentStatus {
- val notifDoc = XMLUtil.parseStringIntoDom(xml)
- fun XmlElementDestructor.reasons(): List<Reason> {
- return mapEachChildNamed("StsRsnInf") {
- val code = requireUniqueChildNamed("Rsn") {
- requireUniqueChildNamed("Cd") {
- ExternalStatusReasonCode.valueOf(focusElement.textContent)
- }
- }
+fun parseCustomerPaymentStatusReport(xml: ByteArray): PaymentStatus {
+ fun XmlDestructor.reasons(): List<Reason> {
+ return map("StsRsnInf") {
+ val code = one("Rsn").one("Cd").enum<ExternalStatusReasonCode>()
// TODO parse information
Reason(code, "")
}
}
- return destructXml(notifDoc) {
- requireRootElement("Document") {
- requireUniqueChildNamed("CstmrPmtStsRpt") {
- val (msgId, msgCode, msgReasons) = requireUniqueChildNamed("OrgnlGrpInfAndSts") {
- val id = requireUniqueChildNamed("OrgnlMsgId") {
- focusElement.textContent
- }
- val code = maybeUniqueChildNamed("GrpSts") {
- ExternalPaymentGroupStatusCode.valueOf(focusElement.textContent)
- }
- val reasons = reasons()
- Triple(id, code, reasons)
- }
- val paymentInfo = maybeUniqueChildNamed("OrgnlPmtInfAndSts") {
- val code = requireUniqueChildNamed("PmtInfSts") {
- ExternalPaymentGroupStatusCode.valueOf(focusElement.textContent)
- }
- val reasons = reasons()
- Pair(code, reasons)
- }
+ return destructXml(xml, "Document") {
+ one("CstmrPmtStsRpt") {
+ val (msgId, msgCode, msgReasons) = one("OrgnlGrpInfAndSts") {
+ val id = one("OrgnlMsgId").text()
+ val code = opt("GrpSts")?.enum<ExternalPaymentGroupStatusCode>()
+ val reasons = reasons()
+ Triple(id, code, reasons)
+ }
+ val paymentInfo = opt("OrgnlPmtInfAndSts") {
+ val code = one("PmtInfSts").enum<ExternalPaymentGroupStatusCode>()
+ val reasons = reasons()
+ Pair(code, reasons)
+ }
- // TODO handle multi level code better
- if (paymentInfo != null) {
- val (code, reasons) = paymentInfo
- PaymentStatus(msgId, code, reasons)
- } else {
- PaymentStatus(msgId, msgCode!!, msgReasons)
- }
+ // TODO handle multi level code better
+ if (paymentInfo != null) {
+ val (code, reasons) = paymentInfo
+ PaymentStatus(msgId, code, reasons)
+ } else {
+ PaymentStatus(msgId, msgCode!!, msgReasons)
}
}
}
@@ -311,39 +228,26 @@ fun parseCustomerPaymentStatusReport(xml: String): PaymentStatus {
* @param outgoing list of outgoing payments
*/
fun parseTxNotif(
- notifXml: String,
+ notifXml: ByteArray,
acceptedCurrency: String,
incoming: MutableList<IncomingPayment>,
outgoing: MutableList<OutgoingPayment>
) {
notificationForEachTx(notifXml) { bookDate ->
- val kind = requireUniqueChildNamed("CdtDbtInd") {
- focusElement.textContent
- }
- val amount: TalerAmount = requireUniqueChildNamed("Amt") {
- val currency = focusElement.getAttribute("Ccy")
+ 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:${focusElement.textContent}")
+ TalerAmount("$currency:${text()}")
}
when (kind) {
"CRDT" -> {
- val bankId: String = requireUniqueChildNamed("Refs") {
- requireUniqueChildNamed("AcctSvcrRef") {
- focusElement.textContent
- }
- }
+ val bankId: String = one("Refs").one("AcctSvcrRef").text()
// Obtaining payment subject.
- val subject = maybeUniqueChildNamed("RmtInf") {
- val subject = StringBuilder()
- mapEachChildNamed("Ustrd") {
- val piece = focusElement.textContent
- subject.append(piece)
- }
- subject
- }
+ val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString()
if (subject == null) {
logger.debug("Skip notification '$bankId', missing subject")
return@notificationForEachTx
@@ -351,22 +255,14 @@ fun parseTxNotif(
// Obtaining the payer's details
val debtorPayto = StringBuilder("payto://iban/")
- requireUniqueChildNamed("RltdPties") {
- requireUniqueChildNamed("DbtrAcct") {
- requireUniqueChildNamed("Id") {
- requireUniqueChildNamed("IBAN") {
- debtorPayto.append(focusElement.textContent)
- }
- }
+ one("RltdPties") {
+ one("DbtrAcct").one("Id").one("IBAN") {
+ debtorPayto.append(text())
}
// warn: it might need the postal address too..
- requireUniqueChildNamed("Dbtr") {
- maybeUniqueChildNamed("Pty") {
- requireUniqueChildNamed("Nm") {
- val urlEncName = URLEncoder.encode(focusElement.textContent, "utf-8")
- debtorPayto.append("?receiver-name=$urlEncName")
- }
- }
+ one("Dbtr").opt("Pty")?.one("Nm") {
+ val urlEncName = URLEncoder.encode(text(), "utf-8")
+ debtorPayto.append("?receiver-name=$urlEncName")
}
}
incoming.add(
@@ -380,12 +276,7 @@ fun parseTxNotif(
)
}
"DBIT" -> {
- val messageId = requireUniqueChildNamed("Refs") {
- requireUniqueChildNamed("MsgId") {
- focusElement.textContent
- }
- }
-
+ val messageId = one("Refs").one("MsgId").text()
outgoing.add(
OutgoingPayment(
amount = amount,
@@ -403,40 +294,30 @@ fun parseTxNotif(
* Navigates the camt.054 (Detailavisierung) until its leaves, where
* then it invokes the related parser, according to the payment direction.
*
- * @param notifXml the input document.
- * @return any incoming payment as a list of [IncomingPayment]
+ * @param xml the input document.
*/
private fun notificationForEachTx(
- notifXml: String,
- directionLambda: XmlElementDestructor.(Instant) -> Unit
+ xml: ByteArray,
+ directionLambda: XmlDestructor.(Instant) -> Unit
) {
- val notifDoc = XMLUtil.parseStringIntoDom(notifXml)
- destructXml(notifDoc) {
- requireRootElement("Document") {
- requireUniqueChildNamed("BkToCstmrDbtCdtNtfctn") {
- mapEachChildNamed("Ntfctn") {
- mapEachChildNamed("Ntry") {
- requireUniqueChildNamed("Sts") {
- if (focusElement.textContent != "BOOK") {
- requireUniqueChildNamed("Cd") {
- if (focusElement.textContent != "BOOK")
- throw Exception("Found non booked transaction, " +
- "stop parsing. Status was: ${focusElement.textContent}"
- )
- }
- }
- }
- val bookDate: Instant = requireUniqueChildNamed("BookgDt") {
- requireUniqueChildNamed("Dt") {
- parseBookDate(focusElement.textContent)
- }
- }
- mapEachChildNamed("NtryDtls") {
- mapEachChildNamed("TxDtls") {
- directionLambda(this, bookDate)
+ destructXml(xml, "Document") {
+ one("BkToCstmrDbtCdtNtfctn") {
+ each("Ntfctn") {
+ each("Ntry") {
+ one("Sts") {
+ if (text() != "BOOK") {
+ one("Cd") {
+ if (text() != "BOOK")
+ throw Exception("Found non booked transaction, " +
+ "stop parsing. Status was: ${text()}"
+ )
}
}
}
+ val bookDate: Instant = one("BookgDt").one("Dt").dateTime().toInstant(ZoneOffset.UTC)
+ one("NtryDtls").one("TxDtls") {
+ directionLambda(this, bookDate)
+ }
}
}
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt
index ffd495f8..3299a16e 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt
@@ -68,7 +68,7 @@ class FileLogger(path: String?) {
} else {
// Write each ZIP entry in the combined dir.
content.unzipForEach { fileName, xmlContent ->
- subDir.resolve("${nowMs}_$fileName").writeText(xmlContent)
+ subDir.resolve("${nowMs}_$fileName").writeBytes(xmlContent)
}
}
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
index 167954d0..57991e2d 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
@@ -97,12 +97,12 @@ enum class SupportedDocument {
* @param lambda function that gets the (fileName, fileContent) pair
* for each entry in the ZIP archive as input.
*/
-fun ByteArray.unzipForEach(lambda: (String, String) -> Unit) {
+fun ByteArray.unzipForEach(lambda: (String, ByteArray) -> Unit) {
val mem = SeekableInMemoryByteChannel(this)
ZipFile(mem).use { file ->
file.getEntriesInPhysicalOrder().iterator().forEach {
lambda(
- it.name, file.getInputStream(it).readAllBytes().toString(Charsets.UTF_8)
+ it.name, file.getInputStream(it).readAllBytes()
)
}
}
diff --git a/nexus/src/test/kotlin/Parsing.kt b/nexus/src/test/kotlin/Parsing.kt
index c3ba4015..8778c4c4 100644
--- a/nexus/src/test/kotlin/Parsing.kt
+++ b/nexus/src/test/kotlin/Parsing.kt
@@ -20,27 +20,12 @@
import org.junit.Test
import tech.libeufin.nexus.*
import tech.libeufin.common.*
-import tech.libeufin.common.parseBookDate
-import tech.libeufin.common.parseCamtTime
import java.lang.StringBuilder
import kotlin.test.*
class Parsing {
@Test
- fun gregorianTime() {
- parseCamtTime("2023-11-06T20:00:00")
- assertFailsWith<Exception> { parseCamtTime("2023-11-06T20:00:00+01:00") }
- assertFailsWith<Exception> { parseCamtTime("2023-11-06T20:00:00Z") }
- }
-
- @Test
- fun bookDateTest() {
- parseBookDate("1970-01-01")
- assertFailsWith<Exception> { parseBookDate("1970-01-01T00:00:01Z") }
- }
-
- @Test
fun reservePublicKey() {
assertNull(removeSubjectNoise("does not contain any reserve"))
// 4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0
diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt
index 076d5001..4b999072 100644
--- a/testbench/src/main/kotlin/Main.kt
+++ b/testbench/src/main/kotlin/Main.kt
@@ -116,9 +116,6 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
val clientKeysPath = cfg.requirePath("nexus-ebics", "client_private_keys_file")
val bankKeysPath = cfg.requirePath("nexus-ebics", "bank_public_keys_file")
- var hasClientKeys = clientKeysPath.exists()
- var hasBankKeys = bankKeysPath.exists()
-
// Alternative payto ?
val payto = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans"
@@ -157,8 +154,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
put("reset-keys", suspend {
clientKeysPath.deleteIfExists()
bankKeysPath.deleteIfExists()
- hasClientKeys = false
- hasBankKeys = false
+ Unit
})
put("tx", suspend {
step("Test submit one transaction")
@@ -201,6 +197,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
}
while (true) {
+ var hasClientKeys = clientKeysPath.exists()
+ var hasBankKeys = bankKeysPath.exists()
+
if (!hasClientKeys) {
if (kind.test) {
step("Test INI order")
@@ -219,7 +218,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
.assertOk("ebics-setup should succeed the second time")
}
- val arg = ask("testbench >")!!.trim()
+ val arg = ask("testbench> ")!!.trim()
if (arg == "exit") break
val cmd = cmds[arg]
if (cmd != null) {
diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt
new file mode 100644
index 00000000..49d78d4d
--- /dev/null
+++ b/testbench/src/test/kotlin/Iso20022Test.kt
@@ -0,0 +1,52 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 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/>
+ */
+
+import tech.libeufin.nexus.*
+import org.junit.Test
+import java.nio.file.*
+import kotlin.io.path.*
+
+class Iso20022Test {
+ @Test
+ fun logs() {
+ for (platform in Path("test").listDirectoryEntries()) {
+ for (file in platform.listDirectoryEntries()) {
+ val fetch = file.resolve("fetch")
+ if (file.isDirectory() && fetch.exists()) {
+ for (log in fetch.listDirectoryEntries()) {
+ val str = log.readBytes()
+ val name = log.toString()
+ println(name)
+ if (name.contains("HAC")) {
+ parseCustomerAck(str)
+ } else if (name.contains("pain.002")) {
+ parseCustomerPaymentStatusReport(str)
+ } else {
+ try {
+ parseTxNotif(str, "CHF", mutableListOf(), mutableListOf())
+ } catch (e: Exception) {
+ println(e) // TODO import tx notif
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+} \ No newline at end of file