gnunet-svn
[Top][All Lists]
Advanced

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

[libeufin] branch master updated: nexus submit


From: gnunet
Subject: [libeufin] branch master updated: nexus submit
Date: Tue, 07 Nov 2023 17:46:01 +0100

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

ms pushed a commit to branch master
in repository libeufin.

The following commit(s) were added to refs/heads/master by this push:
     new fae2a018 nexus submit
fae2a018 is described below

commit fae2a018d0de3cc92e01ded53f2ac462999980de
Author: MS <ms@taler.net>
AuthorDate: Tue Nov 7 17:45:05 2023 +0100

    nexus submit
    
    more submission states plus some refactoring
---
 database-versioning/libeufin-nexus-0001.sql        |  22 ++-
 .../main/kotlin/tech/libeufin/nexus/Database.kt    |  53 +++++-
 .../main/kotlin/tech/libeufin/nexus/EbicsSetup.kt  |  26 +++
 .../main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 148 +++++++++++----
 .../main/kotlin/tech/libeufin/nexus/Iso20022.kt    |  11 +-
 nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt  |  28 +--
 .../kotlin/tech/libeufin/nexus/ebics/Ebics3.kt     |  16 +-
 .../tech/libeufin/nexus/ebics/EbicsCommon.kt       | 205 +++++++++++++--------
 nexus/src/test/kotlin/Common.kt                    |   4 +-
 nexus/src/test/kotlin/DatabaseTest.kt              |  16 +-
 nexus/src/test/kotlin/PostFinance.kt               |  12 +-
 11 files changed, 372 insertions(+), 169 deletions(-)

diff --git a/database-versioning/libeufin-nexus-0001.sql 
b/database-versioning/libeufin-nexus-0001.sql
index b320cbaf..4211f467 100644
--- a/database-versioning/libeufin-nexus-0001.sql
+++ b/database-versioning/libeufin-nexus-0001.sql
@@ -28,6 +28,22 @@ CREATE TYPE taler_amount
 COMMENT ON TYPE taler_amount
   IS 'Stores an amount, fraction is in units of 1/100000000 of the base value';
 
+CREATE TYPE submission_state AS ENUM
+  ('unsubmitted'
+  ,'transient_failure'
+  ,'permanent_failure'
+  ,'success'
+  ,'never_heard_back'
+  );
+COMMENT ON TYPE submission_state
+  IS 'expresses the state of an initiated outgoing transaction, where
+  unsubmitted is the default.  transient_failure suggests that the submission
+  should be retried, in contrast to the permanent_failure state.  success
+  means that the submission itself was successful, but in no way means that
+  the bank will fulfill the request.  That must be asked via camt.5x or 
pain.002.
+  never_heard_back is a fallback state, in case one successful submission did
+  never get confirmed via camt.5x or pain.002.';
+
 CREATE TABLE IF NOT EXISTS incoming_transactions
   (incoming_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
   ,amount taler_amount NOT NULL
@@ -55,11 +71,13 @@ CREATE TABLE IF NOT EXISTS outgoing_transactions
 CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions
   (initiated_outgoing_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY 
UNIQUE
   ,amount taler_amount NOT NULL
-  ,wire_transfer_subject TEXT
+  ,wire_transfer_subject TEXT NOT NULL
   ,initiation_time INT8 NOT NULL
+  ,last_submission_time INT8
+  ,submission_counter INT NOT NULL DEFAULT 0
   ,credit_payto_uri TEXT NOT NULL
   ,outgoing_transaction_id INT8 REFERENCES outgoing_transactions 
(outgoing_transaction_id)
-  ,submitted BOOL DEFAULT FALSE 
+  ,submitted submission_state DEFAULT 'unsubmitted'
   ,hidden BOOL DEFAULT FALSE -- FIXME: explain this.
   ,request_uid TEXT NOT NULL UNIQUE CHECK (char_length(request_uid) <= 35)
   ,failure_message TEXT -- NOTE: that may mix soon failures (those found at 
initiation time), or late failures (those found out along a fetch operation)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
index 77be0131..13e60cde 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
@@ -47,13 +47,34 @@ data class IncomingPayment(
 
 // INITIATED PAYMENTS STRUCTS
 
+enum class DatabaseSubmissionState {
+    /**
+     * Submission got both EBICS_OK.
+     */
+    success,
+    /**
+     * Submission can be retried (network issue, for example)
+     */
+    transient_failure,
+    /**
+     * Submission got at least one error code which was not
+     * EBICS_OK.
+     */
+    permanent_failure,
+    /**
+     * The submitted payment was never witnessed by a camt.5x
+     * or pain.002 report.
+     */
+    never_heard_back
+}
+
 /**
  * Minimal set of information to initiate a new payment in
  * the database.
  */
 data class InitiatedPayment(
     val amount: TalerAmount,
-    val wireTransferSubject: String?,
+    val wireTransferSubject: String,
     val creditPaytoUri: String,
     val initiationTime: Instant,
     val requestUid: String
@@ -323,19 +344,37 @@ class Database(dbConfig: String): java.io.Closeable {
     // INITIATED PAYMENTS METHODS
 
     /**
-     * Sets payment initiation as submitted.
+     * Represents all the states but "unsubmitted" related to an
+     * initiated payment.  Unsubmitted gets set by default by the
+     * database and there's no case where it has to be reset to an
+     * initiated payment.
+     */
+
+    /**
+     * Sets the submission state of an initiated payment.  Transparently
+     * sets the last_submission_time column too, as this corresponds to the
+     * time when we set the state.
      *
      * @param rowId row ID of the record to set.
+     * @param submissionState which state to set.
      * @return true on success, false if no payment was affected.
      */
-    suspend fun initiatedPaymentSetSubmitted(rowId: Long): Boolean = runConn { 
conn ->
+    suspend fun initiatedPaymentSetSubmittedState(
+        rowId: Long,
+        submissionState: DatabaseSubmissionState
+    ): Boolean = runConn { conn ->
         val stmt = conn.prepareStatement("""
              UPDATE initiated_outgoing_transactions
-                      SET submitted = true
-                      WHERE initiated_outgoing_transaction_id=?
+                      SET submitted = submission_state(?), 
last_submission_time = ?
+                      WHERE initiated_outgoing_transaction_id = ?
              """
         )
-        stmt.setLong(1, rowId)
+        val now = Instant.now()
+        stmt.setString(1, submissionState.name)
+        stmt.setLong(2, now.toDbMicros() ?: run {
+            throw Exception("Submission time could not be converted to 
microseconds for the database.")
+        })
+        stmt.setLong(3, rowId)
         return@runConn stmt.maybeUpdate()
     }
 
@@ -376,7 +415,7 @@ class Database(dbConfig: String): java.io.Closeable {
              ,initiation_time
              ,request_uid
              FROM initiated_outgoing_transactions
-             WHERE submitted=false;
+             WHERE submitted='unsubmitted';
         """)
         val maybeMap = mutableMapOf<Long, InitiatedPayment>()
         stmt.executeQuery().use {
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
index 04c5306f..fce3ccf9 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
@@ -35,6 +35,32 @@ import tech.libeufin.util.ebics_h004.HTDResponseOrderData
 import java.time.Instant
 import kotlin.reflect.typeOf
 
+/**
+ * Checks the configuration to secure that the key exchange between
+ * the bank and the subscriber took place.  Helps to fail before starting
+ * to talk EBICS to the bank.
+ *
+ * @param cfg configuration handle.
+ * @return true if the keying was made before, false otherwise.
+ */
+fun isKeyingComplete(cfg: EbicsSetupConfig): Boolean {
+    val maybeClientKeys = 
loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
+    if (maybeClientKeys == null ||
+        (!maybeClientKeys.submitted_ini) ||
+        (!maybeClientKeys.submitted_hia)) {
+        logger.error("Cannot operate without or with unsubmitted subscriber 
keys." +
+                "  Run 'libeufin-nexus ebics-setup' first.")
+        return false
+    }
+    val maybeBankKeys = loadBankKeys(cfg.bankPublicKeysFilename)
+    if (maybeBankKeys == null || (!maybeBankKeys.accepted)) {
+        logger.error("Cannot operate without or with unaccepted bank keys." +
+                "  Run 'libeufin-nexus ebics-setup' until accepting the bank 
keys.")
+        return false
+    }
+    return true
+}
+
 /**
  * Writes the JSON content to disk.  Used when we create or update
  * keys and other metadata JSON content to disk.  WARNING: this overrides
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
index 948ee4e6..b90c2879 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
@@ -24,13 +24,42 @@ import com.github.ajalt.clikt.parameters.options.flag
 import com.github.ajalt.clikt.parameters.options.option
 import io.ktor.client.*
 import kotlinx.coroutines.runBlocking
-import tech.libeufin.nexus.ebics.submitPayment
+import tech.libeufin.nexus.ebics.EbicsEarlyErrorCode
+import tech.libeufin.nexus.ebics.EbicsEarlyException
+import tech.libeufin.nexus.ebics.EbicsUploadException
+import tech.libeufin.nexus.ebics.submitPain001
 import tech.libeufin.util.parsePayto
 import java.time.Instant
 import java.util.*
+import javax.xml.crypto.Data
 import kotlin.concurrent.fixedRateTimer
 import kotlin.system.exitProcess
 
+/**
+ * Possible stages when an error may occur.  These stages
+ * help to decide the retry policy.
+ */
+enum class NexusSubmissionStage {
+    pain,
+    ebics,
+    /**
+     * Includes both non-200 responses and network issues.
+     * They are both considered transient (non-200 responses
+     * can be fixed by changing and reloading the configuration).
+     */
+    http
+}
+
+/**
+ * Expresses one error that occurred while submitting one pain.001
+ * document via EBICS.
+ */
+class NexusSubmitException(
+    msg: String? = null,
+    cause: Throwable? = null,
+    val stage: NexusSubmissionStage
+) : Exception(msg, cause)
+
 /**
  * Takes the initiated payment data, as it was returned from the
  * database, sanity-checks it, makes the pain.001 document and finally
@@ -51,16 +80,13 @@ private suspend fun submitInitiatedPayment(
     clientPrivateKeysFile: ClientPrivateKeysFile,
     bankPublicKeysFile: BankPublicKeysFile,
     initiatedPayment: InitiatedPayment
-): Boolean {
+) {
     val creditor = parsePayto(initiatedPayment.creditPaytoUri)
-    if (creditor?.receiverName == null) {
-        logger.error("Won't create pain.001 without the receiver name")
-        return false
-    }
-    if (initiatedPayment.wireTransferSubject == null) {
-        logger.error("Won't create pain.001 without the wire transfer subject")
-        return false
-    }
+    if (creditor?.receiverName == null)
+        throw NexusSubmitException(
+            "Won't create pain.001 without the receiver name",
+            stage = NexusSubmissionStage.pain
+        )
     val xml = createPain001(
         requestUid = initiatedPayment.requestUid,
         initiationTimestamp = initiatedPayment.initiationTime,
@@ -69,7 +95,38 @@ private suspend fun submitInitiatedPayment(
         debitAccount = cfg.myIbanAccount,
         wireTransferSubject = initiatedPayment.wireTransferSubject
     )
-    return submitPayment(xml, cfg, clientPrivateKeysFile, bankPublicKeysFile, 
httpClient)
+    try {
+        submitPain001(
+            xml,
+            cfg,
+            clientPrivateKeysFile,
+            bankPublicKeysFile,
+            httpClient
+        )
+    } catch (early: EbicsEarlyException) {
+        val errorStage = when (early.earlyEc) {
+            EbicsEarlyErrorCode.HTTP_POST_FAILED ->
+                NexusSubmissionStage.http // transient error
+            /**
+             * Any other [EbicsEarlyErrorCode] should be treated as permanent,
+             * as they involve invalid signatures or an unexpected response
+             * format.  For this reason, they get the "ebics" stage assigned
+             * below, that will cause the payment as permanently failed and
+             * not to be retried.
+             */
+            else ->
+                NexusSubmissionStage.ebics // permanent error
+        }
+        throw NexusSubmitException(
+            stage = errorStage,
+            cause = early
+        )
+    } catch (permanent: EbicsUploadException) {
+        throw NexusSubmitException(
+            stage = NexusSubmissionStage.ebics,
+            cause = permanent
+        )
+    }
 }
 
 /**
@@ -124,9 +181,7 @@ fun getFrequencyInSeconds(humanFormat: String): Int? {
  */
 fun checkFrequency(foundInConfig: String): Int {
     val frequencySeconds = getFrequencyInSeconds(foundInConfig)
-    if (frequencySeconds == null) {
-        throw Exception("Invalid frequency value in config section 
nexus-submit: $foundInConfig")
-    }
+        ?: throw Exception("Invalid frequency value in config section 
nexus-submit: $foundInConfig")
     if (frequencySeconds < 0) {
         throw Exception("Configuration error: cannot operate with a negative 
submit frequency ($foundInConfig)")
     }
@@ -144,32 +199,43 @@ private fun submitBatch(
     runBlocking {
         db.initiatedPaymentsUnsubmittedGet(cfg.currency).forEach {
             logger.debug("Submitting payment initiation with row ID: 
${it.key}")
-            val submitted = submitInitiatedPayment(
-                httpClient,
-                cfg,
-                clientKeys,
-                bankKeys,
-                it.value
-            )
-            /**
-             * The following block tries to flag the initiated payment as 
submitted,
-             * but it does NOT fail the process if the flagging fails.  This 
way, we
-             * do NOT block other payments to be submitted.
-             */
-            if (submitted) {
-                val flagged = db.initiatedPaymentSetSubmitted(it.key)
-                if (!flagged) {
-                    logger.warn("Initiated payment with row ID ${it.key} could 
not be flagged as submitted")
+            val submissionState = try {
+                submitInitiatedPayment(
+                    httpClient,
+                    cfg,
+                    clientKeys,
+                    bankKeys,
+                    initiatedPayment = it.value
+                )
+                DatabaseSubmissionState.success
+            } catch (e: NexusSubmitException) {
+                logger.error(e.message)
+                when (e.stage) {
+                    /**
+                     * Permanent failure: the pain.001 was invalid.  For 
example a Payto
+                     * URI was missing the receiver name, or the currency was 
wrong.  Must
+                     * not be retried.
+                     */
+                    NexusSubmissionStage.pain -> 
DatabaseSubmissionState.permanent_failure
+                    /**
+                     * Transient failure: HTTP or network failed, either 
because one party
+                     * was offline / unreachable, or because the bank URL is 
wrong.  In both
+                     * cases, the initiated payment stored in the database may 
still be correct,
+                     * therefore we set this error as transient, and it'll be 
retried.
+                     */
+                    NexusSubmissionStage.http -> 
DatabaseSubmissionState.transient_failure
+                    /**
+                     * As in the pain.001 case, there is a fundamental problem 
in the document
+                     * being submitted, so it should not be retried.
+                     */
+                    NexusSubmissionStage.ebics -> 
DatabaseSubmissionState.permanent_failure
                 }
-            } else
-                logger.warn("Initiated payment with row ID ${it.key} could not 
be submitted")
+            }
+            db.initiatedPaymentSetSubmittedState(it.key, submissionState)
         }
     }
 }
-data class SubmitFrequency(
-    val inSeconds: Int,
-    val fromConfig: String
-)
+
 class EbicsSubmit : CliktCommand("Submits any initiated payment found in the 
database") {
     private val configFile by option(
         "--config", "-c",
@@ -187,11 +253,15 @@ class EbicsSubmit : CliktCommand("Submits any initiated 
payment found in the dat
      * or long-polls (currently not implemented) for new payments.
      */
     override fun run() {
-        val cfg: EbicsSetupConfig = doOrFail { extractEbicsConfig(configFile) }
-        val frequency: SubmitFrequency = doOrFail {
+        val cfg: EbicsSetupConfig = doOrFail {
+            extractEbicsConfig(configFile)
+        }
+        // Fail now if keying is incomplete.
+        if (!isKeyingComplete(cfg)) exitProcess(1)
+        val frequency: NexusFrequency = doOrFail {
             val configValue = cfg.config.requireString("nexus-submit", 
"frequency")
             val frequencySeconds = checkFrequency(configValue)
-            return@doOrFail SubmitFrequency(frequencySeconds, configValue)
+            return@doOrFail NexusFrequency(frequencySeconds, configValue)
         }
         val dbCfg = cfg.config.extractDbConfigOrFail()
         val db = Database(dbCfg.dbConnStr)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index ed13abdb..cf25787c 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -44,10 +44,17 @@ fun createPain001(
     )
     val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, 
ZoneId.of("UTC"))
     val amountWithoutCurrency: String = amount.stringify().split(":").run {
-        if (this.size != 2) throw Exception("Invalid stringified amount: 
$amount")
+        if (this.size != 2) throw NexusSubmitException(
+            "Invalid stringified amount: $amount",
+            stage=NexusSubmissionStage.pain
+        )
         return@run this[1]
     }
-    val creditorName: String = creditAccount.receiverName ?: throw 
Exception("Cannot operate without the creditor name")
+    val creditorName: String = creditAccount.receiverName
+        ?: throw NexusSubmitException(
+            "Cannot operate without the creditor name",
+            stage=NexusSubmissionStage.pain
+        )
     return constructXml(indent = true) {
         root("Document") {
             attribute("xmlns", namespace.fullNamespace)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index 4879041c..f31af304 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -68,6 +68,22 @@ data class IbanAccountMetadata(
     val name: String
 )
 
+/**
+ * Contains the frequency of submit or fetch iterations.
+ */
+data class NexusFrequency(
+    /**
+     * Value in seconds of the FREQUENCY configuration
+     * value, found either under [nexus-fetch] or [nexus-submit]
+     */
+    val inSeconds: Int,
+    /**
+     * Copy of the value found in the configuration.  Used
+     * for logging.
+     */
+    val fromConfig: String
+)
+
 /**
  * Keeps all the options of the ebics-setup subcommand.  The
  * caller has to handle TalerConfigError if values are missing.
@@ -168,24 +184,12 @@ object RSAPrivateCrtKeySerializer : 
KSerializer<RSAPrivateCrtKey> {
     }
 }
 
-/**
- * Structure of the file that holds the bank account
- * metadata.
- */
-@Serializable
-data class BankAccountMetadataFile(
-    val account_holder_iban: String,
-    val bank_code: String?,
-    val account_holder_name: String
-)
-
 /**
  * Structure of the JSON file that contains the client
  * private keys on disk.
  */
 @Serializable
 data class ClientPrivateKeysFile(
-    // FIXME: centralize the @Contextual use.
     @Contextual val signature_private_key: RSAPrivateCrtKey,
     @Contextual val encryption_private_key: RSAPrivateCrtKey,
     @Contextual val authentication_private_key: RSAPrivateCrtKey,
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
index 5f40a07f..982763ce 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
@@ -96,7 +96,11 @@ fun createEbics3RequestForUploadTransferPhase(
 
 /**
  * Collects all the steps to prepare the submission of a pain.001
- * document to the bank, and finally send it.
+ * document to the bank, and finally send it.  Indirectly throws
+ * [EbicsEarlyException] or [EbicsUploadException].  The first means
+ * that the bank sent an invalid response or signature, the second
+ * that a proper EBICS or business error took place.  The caller must
+ * catch those exceptions and decide the retry policy.
  *
  * @param pain001xml pain.001 document in XML.  The caller should
  *                   ensure its validity.
@@ -104,15 +108,14 @@ fun createEbics3RequestForUploadTransferPhase(
  * @param clientKeys client private keys.
  * @param bankkeys bank public keys.
  * @param httpClient HTTP client to connect to the bank.
- * @return true on success, false otherwise.
  */
-suspend fun submitPayment(
+suspend fun submitPain001(
     pain001xml: String,
     cfg: EbicsSetupConfig,
     clientKeys: ClientPrivateKeysFile,
     bankkeys: BankPublicKeysFile,
     httpClient: HttpClient
-): Boolean {
+) {
     logger.debug("Submitting pain.001: $pain001xml")
     val orderService: Ebics3Request.OrderDetails.Service = 
Ebics3Request.OrderDetails.Service().apply {
         serviceName = "MCT"
@@ -130,13 +133,8 @@ suspend fun submitPayment(
         orderService,
         pain001xml.toByteArray(Charsets.UTF_8)
     )
-    if (maybeUploaded == null) {
-        logger.error("Could not send the pain.001 document to the bank.")
-        return false
-    }
     logger.debug("Payment submitted, report text is: 
${maybeUploaded.reportText}," +
             " EBICS technical code is: ${maybeUploaded.technicalReturnCode}," +
             " bank technical return code is: ${maybeUploaded.bankReturnCode}"
     )
-    return true
 }
\ No newline at end of file
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 ac7a90e4..50b8005d 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
@@ -215,49 +215,37 @@ fun generateKeysPdf(
  * @param tolerateBankReturnCode Business return code that may be accepted 
instead of
  *                               EBICS_OK.  Typically, 
EBICS_NO_DOWNLOAD_DATA_AVAILABLE is tolerated
  *                               when asking for new incoming payments.
- * @return the internal representation of an EBICS response IF both return 
codes
- *         were EBICS_OK, or null otherwise.
+ * @return [EbicsResponseContent] or throws [EbicsEarlyException]
  */
-suspend fun postEbicsAndCheckReturnCodes(
+suspend fun postEbics(
     client: HttpClient,
     cfg: EbicsSetupConfig,
     bankKeys: BankPublicKeysFile,
     xmlReq: String,
-    isEbics3: Boolean,
-    tolerateEbicsReturnCode: EbicsReturnCode? = null,
-    tolerateBankReturnCode: EbicsReturnCode? = null
-): EbicsResponseContent? {
+    isEbics3: Boolean
+): EbicsResponseContent {
     val respXml = client.postToBank(cfg.hostBaseUrl, xmlReq)
-    if (respXml == null) {
-        tech.libeufin.nexus.logger.error("EBICS init phase failed.  Aborting 
the HTD operation.")
-        return null
-    }
-    val respObj: EbicsResponseContent = parseAndValidateEbicsResponse(
+        ?: throw EbicsEarlyException(
+            "POSTing to ${cfg.hostBaseUrl} failed",
+            earlyEc = EbicsEarlyErrorCode.HTTP_POST_FAILED
+        )
+    return parseAndValidateEbicsResponse(
         bankKeys,
         respXml,
         isEbics3
-    ) ?: return null // helper logged the cause already.
-
-    var isEbicsCodeTolerated = false
-    if (tolerateEbicsReturnCode != null)
-       isEbicsCodeTolerated = respObj.technicalReturnCode == 
tolerateEbicsReturnCode
+    )
+}
 
-    // EBICS communication error.
-    if ((respObj.technicalReturnCode != EbicsReturnCode.EBICS_OK) && 
(!isEbicsCodeTolerated)) {
-        tech.libeufin.nexus.logger.error("EBICS return code is 
${respObj.technicalReturnCode}, failing.")
-        return null
-    }
-    var isBankCodeTolerated = false
-    if (tolerateBankReturnCode != null)
-        isBankCodeTolerated = respObj.bankReturnCode == tolerateBankReturnCode
+/**
+ * Checks that EBICS- and bank-technical return codes are both EBICS_OK.
+ *
+ * @param ebicsResponseContent valid response gotten from the bank.
+ * @return true only if both codes are EBICS_OK.
+ */
+private fun areCodesOk(ebicsResponseContent: EbicsResponseContent) =
+    ebicsResponseContent.technicalReturnCode == EbicsReturnCode.EBICS_OK &&
+            ebicsResponseContent.bankReturnCode == EbicsReturnCode.EBICS_OK
 
-    // Business error, although EBICS itself was correct.
-    if ((respObj.bankReturnCode != EbicsReturnCode.EBICS_OK) && 
(!isBankCodeTolerated)) {
-        tech.libeufin.nexus.logger.error("Bank-technical return code is 
${respObj.technicalReturnCode}, failing.")
-        return null
-    }
-    return respObj
-}
 /**
  * Collects all the steps of an EBICS download transaction.  Namely,
  * it conducts: init -> transfer -> receipt phases.
@@ -279,8 +267,8 @@ suspend fun doEbicsDownload(
     reqXml: String,
     isEbics3: Boolean
 ): String? {
-    val initResp = postEbicsAndCheckReturnCodes(client, cfg, bankKeys, reqXml, 
isEbics3)
-    if (initResp == null) {
+    val initResp = postEbics(client, cfg, bankKeys, reqXml, isEbics3)
+    if (!areCodesOk(initResp)) {
         tech.libeufin.nexus.logger.error("EBICS download: could not get past 
the EBICS init phase, failing.")
         return null
     }
@@ -310,8 +298,8 @@ suspend fun doEbicsDownload(
     for (x in 2 .. howManySegments) {
         // request segment number x.
         val transReq = createEbics25TransferPhase(cfg, clientKeys, x, 
howManySegments, tId)
-        val transResp = postEbicsAndCheckReturnCodes(client, cfg, bankKeys, 
transReq, isEbics3)
-        if (transResp == null) {
+        val transResp = postEbics(client, cfg, bankKeys, transReq, isEbics3)
+        if (!areCodesOk(transResp)) { // FIXME: consider tolerating 
EBICS_NO_DOWNLOAD_DATA_AVAILABLE.
             tech.libeufin.nexus.logger.error("EBICS transfer segment #$x 
failed.")
             return null
         }
@@ -330,16 +318,16 @@ suspend fun doEbicsDownload(
     )
     // payload reconstructed, ack to the bank.
     val ackXml = createEbics25ReceiptPhase(cfg, clientKeys, tId)
-    val ackResp = postEbicsAndCheckReturnCodes(
-        client,
-        cfg,
-        bankKeys,
-        ackXml,
-        isEbics3,
-        tolerateEbicsReturnCode = 
EbicsReturnCode.EBICS_DOWNLOAD_POSTPROCESS_DONE
-    )
-    if (ackResp == null) {
-        tech.libeufin.nexus.logger.error("EBICS receipt phase failed.")
+    try {
+        postEbics(
+            client,
+            cfg,
+            bankKeys,
+            ackXml,
+            isEbics3
+        )
+    } catch (e: EbicsEarlyException) {
+        logger.error("Download receipt phase failed: " + e.message)
         return null
     }
     // receipt phase OK, can now return the payload as an XML string.
@@ -351,6 +339,32 @@ suspend fun doEbicsDownload(
     }
 }
 
+enum class EbicsEarlyErrorCode {
+    BANK_SIGNATURE_DIDNT_VERIFY,
+    BANK_RESPONSE_IS_INVALID,
+    /**
+     * That's the bank fault, as this value should be there even
+     * if there was an error.
+     */
+    EBICS_UPLOAD_TRANSACTION_ID_MISSING,
+    /**
+     * May be caused by a connection issue OR the HTTP response
+     * code was not 200 OK.  Both cases should lead to retry as
+     * they are fixable or transient.
+     */
+    HTTP_POST_FAILED
+}
+
+/**
+ * Those errors happen before getting to validate the bank response
+ * and successfully verify its signature.  They bring therefore NO
+ * business meaning and may be retried.
+ */
+class EbicsEarlyException(
+    msg: String,
+    val earlyEc: EbicsEarlyErrorCode
+) : Exception(msg)
+
 /**
  * Parses the bank response from the raw XML and verifies
  * the bank signature.
@@ -358,27 +372,30 @@ suspend fun doEbicsDownload(
  * @param bankKeys provides the bank auth pub, to verify the signature.
  * @param responseStr raw XML response from the bank
  * @param withEbics3 true if the communication is EBICS 3, false otherwise.
- * @return libeufin internal representation of EBICS responses.  Null
- *         in case of errors.
+ * @return [EbicsResponseContent] or throw [EbicsEarlyException]
  */
 fun parseAndValidateEbicsResponse(
     bankKeys: BankPublicKeysFile,
     responseStr: String,
     withEbics3: Boolean
-): EbicsResponseContent? {
+): EbicsResponseContent {
     val responseDocument = try {
         XMLUtil.parseStringIntoDom(responseStr)
     } catch (e: Exception) {
-        tech.libeufin.nexus.logger.error("Bank response apparently invalid.")
-        return null
+        throw EbicsEarlyException(
+            "Bank response apparently invalid",
+            earlyEc = EbicsEarlyErrorCode.BANK_RESPONSE_IS_INVALID
+        )
     }
     if (!XMLUtil.verifyEbicsDocument(
             responseDocument,
             bankKeys.bank_authentication_public_key,
             withEbics3
-        )) {
-        tech.libeufin.nexus.logger.error("Bank signature did not verify.")
-        return null
+    )) {
+        throw EbicsEarlyException(
+            "Bank signature did not verify",
+            earlyEc = EbicsEarlyErrorCode.BANK_SIGNATURE_DIDNT_VERIFY
+        )
     }
     if (withEbics3)
         return ebics3toInternalRepr(responseStr)
@@ -395,7 +412,7 @@ fun parseAndValidateEbicsResponse(
  * @param isEbics3 true if the payload travels on EBICS 3.
  * @return [PreparedUploadData]
  */
-fun prepareUloadPayload(
+fun prepareUploadPayload(
     cfg: EbicsSetupConfig,
     clientKeys: ClientPrivateKeysFile,
     bankKeys: BankPublicKeysFile,
@@ -428,8 +445,7 @@ fun prepareUloadPayload(
         userSignatureDataEncrypted
     }
     val plainTransactionKey = encryptionResult.plainTransactionKey
-    if (plainTransactionKey == null)
-        throw Exception("Could not generate the transaction key, cannot 
encrypt the payload!")
+        ?: throw Exception("Could not generate the transaction key, cannot 
encrypt the payload!")
     // Then only E002 symmetric (with ephemeral key) encrypt.
     val compressedInnerPayload = DeflaterInputStream(
         payload.inputStream()
@@ -449,6 +465,32 @@ fun prepareUloadPayload(
     )
 }
 
+/**
+ * Possible states of an EBICS transaction.
+ */
+enum class EbicsPhase {
+    initialization,
+    transmission,
+    receipt
+}
+
+/**
+ * Witnesses a failure in an EBICS communication.  That
+ * implies that the bank response and its signature were
+ * both valid.
+ */
+class EbicsUploadException(
+    msg: String,
+    val phase: EbicsPhase,
+    val ebicsErrorCode: EbicsReturnCode,
+    /**
+     * If the error was EBICS-technical, then we might not
+     * even have interest on the business error code, therefore
+     * the value below may be null.
+     */
+    val bankErrorCode: EbicsReturnCode? = null
+) : Exception(msg)
+
 /**
  * Collects all the steps of an EBICS 3 upload transaction.
  * NOTE: this function could conveniently be reused for an EBICS 2.x
@@ -459,7 +501,7 @@ fun prepareUloadPayload(
  * @param clientKeys client EBICS private keys.
  * @param bankKeys bank EBICS public keys.
  * @param payload binary business paylaod.
- * @return [EbicsResponseContent] or null upon errors.
+ * @return [EbicsResponseContent] or throws [EbicsUploadException]
  */
 suspend fun doEbicsUpload(
     client: HttpClient,
@@ -468,8 +510,8 @@ suspend fun doEbicsUpload(
     bankKeys: BankPublicKeysFile,
     orderService: Ebics3Request.OrderDetails.Service,
     payload: ByteArray
-): EbicsResponseContent? {
-    val preparedPayload = prepareUloadPayload(cfg, clientKeys, bankKeys, 
payload, isEbics3 = true)
+): EbicsResponseContent {
+    val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, 
payload, isEbics3 = true)
     val initXml = createEbics3RequestForUploadInitialization(
         cfg,
         preparedPayload,
@@ -477,41 +519,44 @@ suspend fun doEbicsUpload(
         clientKeys,
         orderService
     )
-    val initResp = postEbicsAndCheckReturnCodes(
-        client,
-        cfg,
-        bankKeys,
-        initXml,
-        isEbics3 = true
+    val initResp = postEbics( // may throw EbicsEarlyException
+            client,
+            cfg,
+            bankKeys,
+            initXml,
+            isEbics3 = true
+    )
+    if (!areCodesOk(initResp)) throw EbicsUploadException(
+        "EBICS upload init failed",
+        phase = EbicsPhase.initialization,
+        ebicsErrorCode = initResp.technicalReturnCode,
+        bankErrorCode = initResp.bankReturnCode
     )
-    if (initResp == null) {
-        tech.libeufin.nexus.logger.error("EBICS upload init phase failed.")
-        return null
-    }
-
     // Init phase OK, proceeding with the transfer phase.
     val tId = initResp.transactionID
-    if (tId == null) {
-        logger.error("EBICS upload init phase did not return a transaction ID, 
cannot do the transfer phase.")
-        return null
-    }
+        ?: throw EbicsEarlyException(
+            "EBICS upload init phase did not return a transaction ID, cannot 
do the transfer phase.",
+            earlyEc = EbicsEarlyErrorCode.EBICS_UPLOAD_TRANSACTION_ID_MISSING
+        )
     val transferXml = createEbics3RequestForUploadTransferPhase(
         cfg,
         clientKeys,
         tId,
         preparedPayload
     )
-    val transferResp = postEbicsAndCheckReturnCodes(
+    val transferResp = postEbics(
         client,
         cfg,
         bankKeys,
         transferXml,
         isEbics3 = true
     )
-    if (transferResp == null) {
-        tech.libeufin.nexus.logger.error("EBICS transfer phase failed.")
-        return null
-    }
+    if (!areCodesOk(transferResp)) throw EbicsUploadException(
+        "EBICS upload transfer failed",
+        phase = EbicsPhase.transmission,
+        ebicsErrorCode = initResp.technicalReturnCode,
+        bankErrorCode = initResp.bankReturnCode
+    )
     // EBICS- and bank-technical codes were both EBICS_OK, success!
     return transferResp
 }
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt
index e9a05e81..0ae3847d 100644
--- a/nexus/src/test/kotlin/Common.kt
+++ b/nexus/src/test/kotlin/Common.kt
@@ -78,7 +78,7 @@ fun getPofiConfig(
 """.trimIndent()
 
 // Generates a payment initiation, given its subject.
-fun genInitPay(subject: String? = null, rowUid: String = "unique") =
+fun genInitPay(subject: String = "init payment", rowUid: String = "unique") =
     InitiatedPayment(
         amount = TalerAmount(44, 0, "KUDOS"),
         creditPaytoUri = "payto://iban/TEST-IBAN?receiver-name=Test",
@@ -88,7 +88,7 @@ fun genInitPay(subject: String? = null, rowUid: String = 
"unique") =
     )
 
 // Generates an incoming payment, given its subject.
-fun genIncPay(subject: String? = null) =
+fun genIncPay(subject: String = "test wire transfer") =
     IncomingPayment(
         amount = TalerAmount(44, 0, "KUDOS"),
         debitPaytoUri = "payto://iban/not-used",
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt 
b/nexus/src/test/kotlin/DatabaseTest.kt
index 8477d8f3..9a5cd79e 100644
--- a/nexus/src/test/kotlin/DatabaseTest.kt
+++ b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -164,22 +164,22 @@ class PaymentInitiationsTest {
         runBlocking {
             // Creating the record first.  Defaults to submitted == false.
             assertEquals(
+                PaymentInitiationOutcome.SUCCESS,
                 db.initiatedPaymentCreate(genInitPay("not submitted, has row 
ID == 1")),
-                PaymentInitiationOutcome.SUCCESS
             )
             // Asserting on the false default submitted state.
             db.runConn { conn ->
                 val isSubmitted = conn.execSQLQuery(getRowOne)
                 assertTrue(isSubmitted.next())
-                assertFalse(isSubmitted.getBoolean("submitted"))
+                assertEquals("unsubmitted", isSubmitted.getString("submitted"))
             }
-            // Switching the submitted state to true.
-            assertTrue(db.initiatedPaymentSetSubmitted(1))
+            // Switching the submitted state to success.
+            assertTrue(db.initiatedPaymentSetSubmittedState(1, 
DatabaseSubmissionState.success))
             // Asserting on the submitted state being TRUE now.
             db.runConn { conn ->
                 val isSubmitted = conn.execSQLQuery(getRowOne)
                 assertTrue(isSubmitted.next())
-                assertTrue(isSubmitted.getBoolean("submitted"))
+                assertEquals("success", isSubmitted.getString("submitted"))
             }
         }
     }
@@ -222,24 +222,22 @@ class PaymentInitiationsTest {
             assertEquals(db.initiatedPaymentCreate(genInitPay("#2", 
"unique2")), PaymentInitiationOutcome.SUCCESS)
             assertEquals(db.initiatedPaymentCreate(genInitPay("#3", 
"unique3")), PaymentInitiationOutcome.SUCCESS)
             assertEquals(db.initiatedPaymentCreate(genInitPay("#4", 
"unique4")), PaymentInitiationOutcome.SUCCESS)
-            assertEquals(db.initiatedPaymentCreate(genInitPay(rowUid = 
"unique5")), PaymentInitiationOutcome.SUCCESS) // checking the nullable subject
 
             // Marking one as submitted, hence not expecting it in the results.
             db.runConn { conn ->
                 conn.execSQLUpdate("""
                     UPDATE initiated_outgoing_transactions
-                      SET submitted = true
+                      SET submitted='success'
                       WHERE initiated_outgoing_transaction_id=3;
                 """.trimIndent())
             }
 
             // Expecting all the payments BUT the #3 in the result.
             db.initiatedPaymentsUnsubmittedGet("KUDOS").apply {
-                assertEquals(4, this.size)
+                assertEquals(3, this.size)
                 assertEquals("#1", this[1]?.wireTransferSubject)
                 assertEquals("#2", this[2]?.wireTransferSubject)
                 assertEquals("#4", this[4]?.wireTransferSubject)
-                assertNull(this[5]?.wireTransferSubject)
             }
         }
     }
diff --git a/nexus/src/test/kotlin/PostFinance.kt 
b/nexus/src/test/kotlin/PostFinance.kt
index ea80cd49..55a59464 100644
--- a/nexus/src/test/kotlin/PostFinance.kt
+++ b/nexus/src/test/kotlin/PostFinance.kt
@@ -5,14 +5,10 @@ import org.junit.Test
 import tech.libeufin.nexus.*
 import tech.libeufin.nexus.ebics.doEbicsCustomDownload
 import tech.libeufin.nexus.ebics.fetchBankAccounts
-import tech.libeufin.nexus.ebics.submitPayment
-import tech.libeufin.util.IbanPayto
+import tech.libeufin.nexus.ebics.submitPain001
 import tech.libeufin.util.parsePayto
 import java.io.File
 import java.time.Instant
-import java.time.ZoneId
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
 
@@ -40,13 +36,15 @@ class Iso20022 {
             
parsePayto("payto://iban/CH9300762011623852957?receiver-name=NotGiven")!!
         )
         runBlocking {
-            assertTrue(submitPayment(
+
+            // Not asserting, as it throws in case of errors.
+            submitPain001(
                 xml,
                 cfg,
                 loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!,
                 loadBankKeys(cfg.bankPublicKeysFilename)!!,
                 HttpClient()
-            ))
+            )
         }
     }
 }

-- 
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]