[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.
- [libeufin] branch master updated: nexus submit,
gnunet <=