libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit fae2a018d0de3cc92e01ded53f2ac462999980de
parent 70157b68c559073179410f62469eb8b504e4b35b
Author: MS <ms@taler.net>
Date:   Tue,  7 Nov 2023 17:45:05 +0100

nexus submit

more submission states plus some refactoring

Diffstat:
Mdatabase-versioning/libeufin-nexus-0001.sql | 22++++++++++++++++++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 53++++++++++++++++++++++++++++++++++++++++++++++-------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 26++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 148++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 11+++++++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 28++++++++++++++++------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt | 16+++++++---------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 205++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mnexus/src/test/kotlin/Common.kt | 4++--
Mnexus/src/test/kotlin/DatabaseTest.kt | 16+++++++---------
Mnexus/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 @@ -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 @@ -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 @@ -36,6 +36,32 @@ 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 * silently what's found under the given location! diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -24,14 +24,43 @@ 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 * submits it via EBICS to the bank. @@ -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 @@ -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 @@ -69,6 +69,22 @@ data class IbanAccountMetadata( ) /** + * 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. * If even one of the fields could not be instantiated, then @@ -169,23 +185,11 @@ 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 @@ -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 @@ -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() @@ -450,6 +466,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 * transaction, hence this function stays in this file. @@ -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 @@ -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 @@ -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 @@ -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() - )) + ) } } }