gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[libeufin] branch master updated (d65afec4 -> af6b09f3)


From: gnunet
Subject: [libeufin] branch master updated (d65afec4 -> af6b09f3)
Date: Tue, 30 Jan 2024 21:53:14 +0100

This is an automated email from the git hooks/post-receive script.

antoine pushed a change to branch master
in repository libeufin.

    from d65afec4 debian: changelog (remove botched version)
     new 16e0190f Improve xml logic and fix ebics testbench
     new 3e473d71 Improve EBICS pain.002 status extraction
     new af6b09f3 Fix notification parsing

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 Makefile                                           |   4 +
 common/import.py                                   |  66 ----
 common/src/main/kotlin/time.kt                     |  25 --
 ebics/codegen.py                                   |  71 ++++
 ebics/import.py                                    |  66 ----
 .../{EbicsCodeSets.kt => Iso20022CodeSets.kt}      |  21 ++
 .../src/main/kotlin/Iso20022Constants.kt           |  29 +-
 ebics/src/main/kotlin/XMLUtil.kt                   |  10 +
 ebics/src/main/kotlin/XmlCombinators.kt            | 181 ++++------
 ebics/src/test/kotlin/XmlCombinatorsTest.kt        |  50 ++-
 .../main/kotlin/tech/libeufin/nexus/EbicsFetch.kt  |  33 +-
 .../main/kotlin/tech/libeufin/nexus/Iso20022.kt    | 372 ++++++++-------------
 nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt   |   2 +-
 .../tech/libeufin/nexus/ebics/EbicsCommon.kt       |   4 +-
 nexus/src/test/kotlin/Parsing.kt                   |  15 -
 testbench/src/main/kotlin/Main.kt                  |  11 +-
 testbench/src/test/kotlin/Iso20022Test.kt          |  48 +++
 17 files changed, 432 insertions(+), 576 deletions(-)
 delete mode 100644 common/import.py
 create mode 100644 ebics/codegen.py
 delete mode 100644 ebics/import.py
 rename ebics/src/main/kotlin/{EbicsCodeSets.kt => Iso20022CodeSets.kt} (92%)
 copy common/src/main/kotlin/Config.kt => 
ebics/src/main/kotlin/Iso20022Constants.kt (53%)
 create mode 100644 testbench/src/test/kotlin/Iso20022Test.kt

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/import.py b/common/import.py
deleted file mode 100644
index 769f1a3d..00000000
--- a/common/import.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# Update EBICS constants file using latest external code sets files
-
-import requests
-from zipfile import ZipFile
-from io import BytesIO
-import polars as pl
-
-# Get XLSX zip file from server
-r = requests.get(
-    
"https://www.iso20022.org/sites/default/files/media/file/ExternalCodeSets_XLSX.zip";
-)
-assert r.status_code == 200
-
-# Unzip the XLSX file
-zip = ZipFile(BytesIO(r.content))
-files = zip.namelist()
-assert len(files) == 1
-file = zip.open(files[0])
-
-# Parse excel
-df = pl.read_excel(file, sheet_name="AllCodeSets")
-
-def extractCodeSet(setName: str, className: str) -> str:
-    out = f"enum class {className}(val isoCode: String, val description: 
String) {{"
-
-    for row in df.filter(pl.col("Code Set") == setName).sort("Code 
Value").rows(named=True):
-        (value, isoCode, description) = (
-            row["Code Value"],
-            row["Code Name"],
-            row["Code Definition"].split("\n", 1)[0].strip(),
-        )
-        out += f'\n\t{value}("{isoCode}", "{description}"),'
-
-    out += "\n}"
-    return out
-
-# Write kotlin file
-kt = f"""/*
- * 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/>
- */
-
-// THIS FILE IS GENERATED, DO NOT EDIT
-
-package tech.libeufin.common
-
-{extractCodeSet("ExternalStatusReason1Code", "ExternalStatusReasonCode")}
-
-{extractCodeSet("ExternalPaymentGroupStatus1Code", 
"ExternalPaymentGroupStatusCode")}
-"""
-with open("src/main/kotlin/EbicsCodeSets.kt", "w") as file1:
-    file1.write(kt)
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
@@ -85,31 +85,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.
  *
diff --git a/ebics/codegen.py b/ebics/codegen.py
new file mode 100644
index 00000000..4591ad16
--- /dev/null
+++ b/ebics/codegen.py
@@ -0,0 +1,71 @@
+# Update EBICS constants file using latest external code sets files
+
+import requests
+from zipfile import ZipFile
+from io import BytesIO
+import polars as pl
+
+def iso20022codegen():
+    # Get XLSX zip file from server
+    r = requests.get(
+        
"https://www.iso20022.org/sites/default/files/media/file/ExternalCodeSets_XLSX.zip";
+    )
+    assert r.status_code == 200
+
+    # Unzip the XLSX file
+    zip = ZipFile(BytesIO(r.content))
+    files = zip.namelist()
+    assert len(files) == 1
+    file = zip.open(files[0])
+
+    # Parse excel
+    df = pl.read_excel(file, sheet_name="AllCodeSets")
+
+    def extractCodeSet(setName: str, className: str) -> str:
+        out = f"enum class {className}(val isoCode: String, val description: 
String) {{"
+
+        for row in df.filter(pl.col("Code Set") == setName).sort("Code 
Value").rows(named=True):
+            (value, isoCode, description) = (
+                row["Code Value"],
+                row["Code Name"],
+                row["Code Definition"].split("\n", 1)[0].strip(),
+            )
+            out += f'\n\t{value}("{isoCode}", "{description}"),'
+
+        out += "\n}"
+        return out
+
+    # Write kotlin file
+    kt = f"""/*
+ * 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/>
+ */
+
+// THIS FILE IS GENERATED, DO NOT EDIT
+
+package tech.libeufin.ebics
+
+{extractCodeSet("ExternalStatusReason1Code", "ExternalStatusReasonCode")}
+
+{extractCodeSet("ExternalPaymentGroupStatus1Code", 
"ExternalPaymentGroupStatusCode")}
+
+{extractCodeSet("ExternalPaymentTransactionStatus1Code", 
"ExternalPaymentTransactionStatusCode")}
+"""
+    with open("src/main/kotlin/Iso20022CodeSets.kt", "w") as file1:
+        file1.write(kt)
+
+iso20022codegen()
diff --git a/ebics/import.py b/ebics/import.py
deleted file mode 100644
index f9e90e10..00000000
--- a/ebics/import.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# Update EBICS constants file using latest external code sets files
-
-import requests
-from zipfile import ZipFile
-from io import BytesIO
-import polars as pl
-
-# Get XLSX zip file from server
-r = requests.get(
-    
"https://www.iso20022.org/sites/default/files/media/file/ExternalCodeSets_XLSX.zip";
-)
-assert r.status_code == 200
-
-# Unzip the XLSX file
-zip = ZipFile(BytesIO(r.content))
-files = zip.namelist()
-assert len(files) == 1
-file = zip.open(files[0])
-
-# Parse excel
-df = pl.read_excel(file, sheet_name="AllCodeSets")
-
-def extractCodeSet(setName: str, className: str) -> str:
-    out = f"enum class {className}(val isoCode: String, val description: 
String) {{"
-
-    for row in df.filter(pl.col("Code Set") == setName).sort("Code 
Value").rows(named=True):
-        (value, isoCode, description) = (
-            row["Code Value"],
-            row["Code Name"],
-            row["Code Definition"].split("\n", 1)[0].strip(),
-        )
-        out += f'\n\t{value}("{isoCode}", "{description}"),'
-
-    out += "\n}"
-    return out
-
-# Write kotlin file
-kt = f"""/*
- * 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/>
- */
-
-// THIS FILE IS GENERATED, DO NOT EDIT
-
-package tech.libeufin.ebics
-
-{extractCodeSet("ExternalStatusReason1Code", "ExternalStatusReasonCode")}
-
-{extractCodeSet("ExternalPaymentGroupStatus1Code", 
"ExternalPaymentGroupStatusCode")}
-"""
-with open("src/main/kotlin/EbicsCodeSets.kt", "w") as file1:
-    file1.write(kt)
diff --git a/ebics/src/main/kotlin/EbicsCodeSets.kt 
b/ebics/src/main/kotlin/Iso20022CodeSets.kt
similarity index 92%
rename from ebics/src/main/kotlin/EbicsCodeSets.kt
rename to ebics/src/main/kotlin/Iso20022CodeSets.kt
index 5f8b2b08..f5e338df 100644
--- a/ebics/src/main/kotlin/EbicsCodeSets.kt
+++ b/ebics/src/main/kotlin/Iso20022CodeSets.kt
@@ -307,3 +307,24 @@ enum class ExternalPaymentGroupStatusCode(val isoCode: 
String, val description:
        RCVD("Received", "Payment initiation has been received by the receiving 
agent"),
        RJCT("Rejected", "Payment initiation or individual transaction included 
in the payment initiation has been rejected."),
 }
+
+enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val 
description: String) {
+       ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the 
creditor's account has been completed."),
+       ACCP("AcceptedCustomerProfile", "Preceding check of technical 
validation was successful. Customer profile check was also successful."),
+       ACFC("AcceptedFundsChecked", "Preceding check of technical validation 
and customer profile was successful and an automatic funds check was 
positive."),
+       ACIS("AcceptedandChequeIssued", "Payment instruction to issue a cheque 
has been accepted, and the cheque has been issued but not yet been deposited or 
cleared."),
+       ACPD("AcceptedClearingProcessed", "Status of transaction released from 
the Debtor Agent and accepted by the clearing."),
+       ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement 
completed."),
+       ACSP("AcceptedSettlementInProcess", "All preceding checks such as 
technical validation and customer profile were successful and therefore the 
payment instruction has been accepted for execution."),
+       ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and 
semantical validation are successful"),
+       ACWC("AcceptedWithChange", "Instruction is accepted but a change will 
be made, such as date or remittance not sent."),
+       ACWP("AcceptedWithoutPosting", "Payment instruction included in the 
credit transfer is accepted without being posted to the creditor customer’s 
account."),
+       BLCK("Blocked", "Payment transaction previously reported with status 
'ACWP' is blocked, for example, funds will neither be posted to the Creditor's 
account, nor be returned to the Debtor."),
+       CANC("Cancelled", "Payment initiation has been successfully cancelled 
after having received a request for cancellation."),
+       CPUC("CashPickedUpByCreditor", "Cash has been picked up by the 
Creditor."),
+       PATC("PartiallyAcceptedTechnicalCorrect", "Payment initiation needs 
multiple authentications, where some but not yet all have been performed. 
Syntactical and semantical validations are successful."),
+       PDNG("Pending", "Payment instruction is pending. Further checks and 
status update will be performed."),
+       PRES("Presented", "Request for Payment has been presented to the 
Debtor."),
+       RCVD("Received", "Payment instruction has been received."),
+       RJCT("Rejected", "Payment instruction has been rejected."),
+}
diff --git a/common/src/main/kotlin/Config.kt 
b/ebics/src/main/kotlin/Iso20022Constants.kt
similarity index 53%
copy from common/src/main/kotlin/Config.kt
copy to ebics/src/main/kotlin/Iso20022Constants.kt
index 95496839..961f5f90 100644
--- a/common/src/main/kotlin/Config.kt
+++ b/ebics/src/main/kotlin/Iso20022Constants.kt
@@ -17,20 +17,19 @@
  * <http://www.gnu.org/licenses/>
  */
 
-package tech.libeufin.common
+package tech.libeufin.ebics
 
-import ch.qos.logback.core.util.Loader
-
-/**
- * Putting those values into the 'attributes' container because they
- * are needed by the util routines that do NOT have Sandbox and Nexus
- * as dependencies, and therefore cannot access their global variables.
- *
- * Note: putting Sandbox and Nexus as Utils dependencies would result
- * into circular dependency.
- */
-fun getVersion(): String {
-    return Loader.getResource(
-        "version.txt", ClassLoader.getSystemClassLoader()
-    ).readText()
+enum class HacAction(val description: String) {
+       FILE_UPLOAD("File submitted to the bank"),
+       FILE_DOWNLOAD("File downloaded from the bank"),
+       ES_UPLOAD("Electronic signature submitted to the bank"),
+       ES_DOWNLOAD("Electronic signature downloaded from the bank"),
+       ES_VERIFICATION("Signature verification"),
+       VEU_FORWARDING("Forwarding to EDS"),
+       VEU_VERIFICATION("EDS signature verification"),
+       VEU_VERIFICATION_END("VEU_VERIFICATION_END"),
+       VEU_CANCEL_ORDER("Cancellation of EDS order"),
+       ADDITIONAL("Additional information"),
+       ORDER_HAC_FINAL_POS("HAC end of order (positive)"),
+       ORDER_HAC_FINAL_NEG("ORDER_HAC_FINAL_NEG")
 }
\ No newline at end of file
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..d2d4d25e 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,90 @@ 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 bool(): Boolean = el.textContent.toBoolean()
+    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..62d0e8ab 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -25,6 +25,7 @@ import io.ktor.client.*
 import kotlinx.coroutines.*
 import tech.libeufin.nexus.ebics.*
 import tech.libeufin.common.*
+import tech.libeufin.ebics.*
 import tech.libeufin.ebics.ebics_h005.Ebics3Request
 import java.io.IOException
 import java.time.Instant
@@ -235,9 +236,6 @@ suspend fun ingestIncomingPayment(
 ) {
     val reservePub = getTalerReservePub(payment)
     if (reservePub == null) {
-        logger.debug("Incoming payment with UID '${payment.bankId}'" +
-                " has invalid subject: ${payment.wireTransferSubject}."
-        )
         val result = db.registerMalformedIncoming(
             payment,
             payment.amount, 
@@ -261,7 +259,7 @@ suspend fun ingestIncomingPayment(
 private fun ingestDocument(
     db: Database,
     currency: String,
-    xml: String,
+    xml: ByteArray,
     whichDocument: SupportedDocument
 ) {
     when (whichDocument) {
@@ -286,12 +284,31 @@ private fun ingestDocument(
         SupportedDocument.PAIN_002_LOGS -> {
             val acks = parseCustomerAck(xml)
             for (ack in acks) {
-                println(ack)
+                when (ack.actionType) {
+                    HacAction.FILE_DOWNLOAD -> logger.trace("$ack")
+                    HacAction.ORDER_HAC_FINAL_POS -> {
+                        // TODO update pending transaction status
+                        logger.debug("$ack")
+                        logger.info("Order '${ack.orderId}' was accepted at 
${ack.timestamp.fmtDateTime()}")
+                    }
+                    HacAction.ORDER_HAC_FINAL_NEG -> {
+                        // TODO update pending transaction status
+                        logger.debug("$ack")
+                        logger.warn("Order '${ack.orderId}' was refused at 
${ack.timestamp.fmtDateTime()}")
+                    }
+                    else -> {
+                        // TODO update pending transaction status
+                        logger.debug("$ack")
+                    }
+                }
             }
         }
         SupportedDocument.PAIN_002 -> {
             val status = parseCustomerPaymentStatusReport(xml)
-            logger.debug("$status") // TODO ingest in db
+            if (status.paymentCode == ExternalPaymentGroupStatusCode.RJCT)
+                logger.warn("Transaction '${status.id()}' was rejected")
+            // TODO update pending transaction status
+            logger.debug("$status")
         }
         else -> logger.warn("Not ingesting ${whichDocument}.  Only camt.054 
notifications supported.")
     }
@@ -315,7 +332,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 +452,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..bd0bdcea 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)
                 }
             }
         }
@@ -168,15 +122,19 @@ fun createPain001(
 }
 
 data class CustomerAck(
-    val actionType: String,
+    val actionType: HacAction,
+    val orderId: String?,
     val code: ExternalStatusReasonCode?,
     val timestamp: Instant
 ) {
     override fun toString(): String {
-        return if (code != null)
-            "${timestamp.fmtDateTime()} ${actionType} ${code.isoCode} 
'${code.description}'"
-        else 
-            "${timestamp.fmtDateTime()} ${actionType}"
+        var str = "${timestamp.fmtDateTime()}"
+        if (orderId != null) str += " ${orderId}"
+        str += " ${actionType}"
+        if (code != null) str += " ${code.isoCode}"
+        str += " - '${actionType.description}'"
+        if (code != null) str += " '${code.description}'"
+        return str
     }
 }
 
@@ -185,48 +143,26 @@ 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").enum<HacAction>()
+            one("StsRsnInf") {
+                var timestamp: Instant? = null;
+                var orderId: String? = 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!!)
+                        "OrderID" -> orderId = value.text()
+                        // TODO extract ids ?
                     }
                 }
+                val code = 
opt("Rsn")?.one("Cd")?.enum<ExternalStatusReasonCode>()
+                CustomerAck(actionType, orderId, code, timestamp!!)
             }
         }
     }
@@ -234,15 +170,35 @@ fun parseCustomerAck(xml: String): List<CustomerAck> {
 
 data class PaymentStatus(
     val msgId: String,
-    val code: ExternalPaymentGroupStatusCode,
+    val paymentId: String?,
+    val txId: String?,
+    val paymentCode: ExternalPaymentGroupStatusCode,
+    val txCode: ExternalPaymentTransactionStatusCode?,
     val reasons: List<Reason>
-) {
+) { 
+    fun id(): String {
+        var str = "$msgId"
+        if (paymentId != null) str += ".$paymentId"
+        if (txId != null) str += ".$txId"
+        return str
+    }
+
+    fun code(): String = txCode?.isoCode ?: paymentCode.isoCode
+
+    fun description(): String = txCode?.description ?: paymentCode.description
+
     override fun toString(): String {
-        var builder = "'${msgId}' ${code.isoCode} '${code.description}'"
-        for (reason in reasons) {
-            builder += " - ${reason.code.isoCode} '${reason.code.description}'"
+        return if (reasons.isEmpty()) {
+            "'${id()}' ${code()} '${description()}'"
+        } else if (reasons.size == 1) {
+            "'${id()}' ${code()} ${reasons[0].code.isoCode} - 
'${description()}' '${reasons[0].code.description}'"
+        } else {
+            var str = "'${id()}' ${code()} '${description()}' - "
+            for (reason in reasons) {
+                str += "${reason.code.isoCode} '${reason.code.description}'"
+            }
+            str
         }
-        return builder
     }
 }
 
@@ -256,48 +212,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)
-                }
-
-                // TODO handle multi level code better 
-                if (paymentInfo != null) {
-                    val (code, reasons) = paymentInfo
-                    PaymentStatus(msgId, code, reasons)
-                } else {
-                    PaymentStatus(msgId, msgCode!!, msgReasons)
-                }
+    return destructXml(xml, "Document") {
+        // TODO handle batch status
+        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)
             }
+            opt("OrgnlPmtInfAndSts") {
+                val payId = one("OrgnlPmtInfId").text()
+                val payCode = 
one("PmtInfSts").enum<ExternalPaymentGroupStatusCode>()
+                val payReasons = reasons()
+                opt("TxInfAndSts") {
+                    val txId = one("OrgnlInstrId").text()
+                    val txCode = 
one("TxSts").enum<ExternalPaymentTransactionStatusCode>()
+                    val txReasons = reasons()
+                    PaymentStatus(msgId, payId, txId, payCode, txCode, 
txReasons)
+                } ?: PaymentStatus(msgId, payId, null, payCode, null, 
payReasons)
+            } ?: PaymentStatus(msgId, null, null, msgCode!!, null, msgReasons)
         }
     }
 }
@@ -311,39 +253,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 +280,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 +301,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 +319,32 @@ 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") {
+        opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") {
+            each("Ntry") {
+                if (opt("RvslInd")?.bool() ?: false) {
+                    logger.warn("Skip reversal transaction")
+                } else {
+                    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").date().atStartOfDay().toInstant(ZoneOffset.UTC)
+                    one("NtryDtls").each("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,26 +20,11 @@
 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"))
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..7d5077ad
--- /dev/null
+++ b/testbench/src/test/kotlin/Iso20022Test.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 {
+                            parseTxNotif(str, "CHF", mutableListOf(), 
mutableListOf())
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]