libeufin

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

commit 66a6cda6d8dec57a75904392c63a87b191a676d8
parent ea283cf97c34318bc0c1d0726e659fc39e0c47f4
Author: MS <ms@taler.net>
Date:   Wed,  8 Nov 2023 21:55:08 +0100

nexus fetch: crafting camt.053 & pain.002 requests

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt | 11+++++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt | 4++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 53+++++++++++++++++++++++++++++++++++++----------------
Mnexus/src/test/kotlin/PostFinance.kt | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
5 files changed, 220 insertions(+), 32 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -4,6 +4,8 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import io.ktor.client.* +import org.apache.commons.compress.archivers.zip.ZipFile +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import tech.libeufin.util.ebics_h005.Ebics3Request import tech.libeufin.util.getXmlDate import java.time.Instant @@ -11,6 +13,27 @@ import kotlin.concurrent.fixedRateTimer import kotlin.system.exitProcess /** + * Unzips the ByteArray and runs the lambda over each entry. + * + * @param lambda function that gets the (fileName, fileContent) pair + * for each entry in the ZIP archive as input. + */ +fun ByteArray.unzipForEach(lambda: (String, String) -> Unit) { + if (this.isEmpty()) { + logger.warn("Empty archive") + return + } + val mem = SeekableInMemoryByteChannel(this) + val zipFile = ZipFile(mem) + zipFile.getEntriesInPhysicalOrder().iterator().forEach { + lambda( + it.name, zipFile.getInputStream(it).readAllBytes().toString(Charsets.UTF_8) + ) + } + zipFile.close() +} + +/** * Crafts a date range object, when the caller needs a time range. * * @param startDate inclusive starting date for the returned banking events. @@ -28,6 +51,72 @@ fun getEbics3DateRange( } /** + * Prepares the request for a pain.002 acknowledgement from the bank. + * + * @param startDate inclusive starting date for the returned acknowledgements. + * @param endDate inclusive ending date for the returned acknowledgements. NOTE: + * if startDate is NOT null and endDate IS null, endDate gets defaulted + * to the current UTC time. + * + * @return [Ebics3Request.OrderDetails.BTOrderParams] + */ +fun prepAckRequest( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics3Request.OrderDetails.BTOrderParams { + val service = Ebics3Request.OrderDetails.Service().apply { + serviceName = "PSR" + scope = "CH" + container = Ebics3Request.OrderDetails.Service.Container().apply { + containerType = "ZIP" + } + messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { + value = "pain.002" + version = "03" + } + } + return Ebics3Request.OrderDetails.BTOrderParams().apply { + this.service = service + this.dateRange = if (startDate != null) + getEbics3DateRange(startDate, endDate ?: Instant.now()) + else null + } +} + +/** + * Prepares the request for (a) camt.053/statement(s). + * + * @param startDate inclusive starting date for the returned banking events. + * @param endDate inclusive ending date for the returned banking events. NOTE: + * if startDate is NOT null and endDate IS null, endDate gets defaulted + * to the current UTC time. + * + * @return [Ebics3Request.OrderDetails.BTOrderParams] + */ +fun prepStatementRequest( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics3Request.OrderDetails.BTOrderParams { + val service = Ebics3Request.OrderDetails.Service().apply { + serviceName = "EOP" + scope = "CH" + container = Ebics3Request.OrderDetails.Service.Container().apply { + containerType = "ZIP" + } + messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { + value = "camt.053" + version = "08" + } + } + return Ebics3Request.OrderDetails.BTOrderParams().apply { + this.service = service + this.dateRange = if (startDate != null) + getEbics3DateRange(startDate, endDate ?: Instant.now()) + else null + } +} + +/** * Prepares the request for camt.052/intraday records. * * @param startDate inclusive starting date for the returned banking events. diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt @@ -24,6 +24,7 @@ package tech.libeufin.nexus.ebics import io.ktor.client.* +import org.bouncycastle.util.encoders.UTF8 import tech.libeufin.nexus.BankPublicKeysFile import tech.libeufin.nexus.ClientPrivateKeysFile import tech.libeufin.nexus.EbicsSetupConfig @@ -53,7 +54,7 @@ suspend fun doEbicsCustomDownload( clientKeys: ClientPrivateKeysFile, bankKeys: BankPublicKeysFile, client: HttpClient -): String? { +): ByteArray? { val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, messageType) return doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false) } @@ -77,19 +78,21 @@ suspend fun fetchBankAccounts( client: HttpClient ): HTDResponseOrderData? { val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, "HTD") - val xmlResp = doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false) - if (xmlResp == null) { + val bytesResp = doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false) + if (bytesResp == null) { logger.error("EBICS HTD transaction failed.") return null } + val xmlResp = bytesResp.toString(Charsets.UTF_8) return try { - logger.debug("Fetched accounts: $xmlResp") + logger.debug("Fetched accounts: $bytesResp") XMLUtil.convertStringToJaxb<HTDResponseOrderData>(xmlResp).value } catch (e: Exception) { logger.error("Could not parse the HTD payload, detail: ${e.message}") return null } } + /** * Creates a EBICS 2.5 download init. message. So far only used * to fetch the PostFinance bank accounts. diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt @@ -55,9 +55,9 @@ fun createEbics3DownloadReceiptPhase( fun createEbics3DownloadTransferPhase( cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile, - transactionId: String, + howManySegments: Int, segmentNumber: Int, - howManySegments: Int + transactionId: String ): String { val req = Ebics3Request.createForDownloadTransferPhase( cfg.ebicsHostId, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -63,7 +63,6 @@ import java.util.zip.DeflaterInputStream * @param encryptionInfo details related to the encrypted payload. * @param chunks the several chunks that constitute the whole encrypted payload. * @return the plain payload. Errors throw, so the caller must handle those. - * */ fun decryptAndDecompressPayload( clientEncryptionKey: RSAPrivateCrtKey, @@ -255,9 +254,14 @@ private fun areCodesOk(ebicsResponseContent: EbicsResponseContent) = * @param clientKeys client EBICS private keys. * @param bankKeys bank EBICS public keys. * @param reqXml raw EBICS XML request of the init phase. - * @return the bank response as an XML string, or null if one - * error took place. NOTE: any return code other than - * EBICS_OK constitutes an error. + * @param isEbics3 true for EBICS 3, false otherwise. + * @param tolerateEmptyResult true if the EC EBICS_NO_DOWNLOAD_DATA_AVAILABLE + * should be tolerated as the bank-technical error, false otherwise. + * @return the bank response as an uncompressed [ByteArray], or null if one error took place. + * If the request tolerates an empty download content, then the empty + * array is returned. If the request does not tolerate an empty response + * any non-EBICS_OK error as the EBICS- or bank-technical EC constitutes + * an error. */ suspend fun doEbicsDownload( client: HttpClient, @@ -265,13 +269,29 @@ suspend fun doEbicsDownload( clientKeys: ClientPrivateKeysFile, bankKeys: BankPublicKeysFile, reqXml: String, - isEbics3: Boolean -): String? { + isEbics3: Boolean, + tolerateEmptyResult: Boolean = false +): ByteArray? { 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.") + logger.debug("Download init phase done. EBICS- and bank-technical codes are: ${initResp.technicalReturnCode}, ${initResp.bankReturnCode}") + if (initResp.technicalReturnCode != EbicsReturnCode.EBICS_OK) { + logger.error("Download init phase has EBICS-technical error: ${initResp.technicalReturnCode}") + return null + } + if (initResp.bankReturnCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE && tolerateEmptyResult) { + logger.info("Download content is empty") + return ByteArray(0) + } + if (initResp.bankReturnCode != EbicsReturnCode.EBICS_OK) { + logger.error("Download init phase has bank-technical error: ${initResp.bankReturnCode}") return null } + val tId = initResp.transactionID + if (tId == null) { + tech.libeufin.nexus.logger.error("Transaction ID not found in the init response, cannot do transfer phase, failing.") + return null + } + logger.debug("EBICS download transaction got ID: $tId") val howManySegments = initResp.numSegments if (howManySegments == null) { tech.libeufin.nexus.logger.error("Init response lacks the quantity of segments, failing.") @@ -289,15 +309,13 @@ suspend fun doEbicsDownload( return null } ebicsChunks.add(firstDataChunk) - val tId = initResp.transactionID - if (tId == null) { - tech.libeufin.nexus.logger.error("Transaction ID not found in the init response, cannot do transfer phase, failing.") - return null - } // proceed with the transfer phase. for (x in 2 .. howManySegments) { // request segment number x. - val transReq = createEbics25DownloadTransferPhase(cfg, clientKeys, x, howManySegments, tId) + val transReq = if (isEbics3) + createEbics3DownloadTransferPhase(cfg, clientKeys, x, howManySegments, tId) + else createEbics25DownloadTransferPhase(cfg, clientKeys, x, howManySegments, tId) + 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.") @@ -317,7 +335,10 @@ suspend fun doEbicsDownload( ebicsChunks ) // payload reconstructed, ack to the bank. - val ackXml = createEbics25DownloadReceiptPhase(cfg, clientKeys, tId) + val ackXml = if (isEbics3) + createEbics3DownloadReceiptPhase(cfg, clientKeys, tId) + else createEbics25DownloadReceiptPhase(cfg, clientKeys, tId) + try { postEbics( client, @@ -332,7 +353,7 @@ suspend fun doEbicsDownload( } // receipt phase OK, can now return the payload as an XML string. return try { - payloadBytes.toString(Charsets.UTF_8) + payloadBytes } catch (e: Exception) { logger.error("Could not get the XML string out of payload bytes.") null diff --git a/nexus/src/test/kotlin/PostFinance.kt b/nexus/src/test/kotlin/PostFinance.kt @@ -3,9 +3,8 @@ import kotlinx.coroutines.runBlocking import org.junit.Ignore 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.submitPain001 +import tech.libeufin.nexus.ebics.* +import tech.libeufin.util.ebics_h005.Ebics3Request import tech.libeufin.util.parsePayto import java.io.File import java.time.Instant @@ -22,8 +21,86 @@ private fun prep(): EbicsSetupConfig { return EbicsSetupConfig(handle) } -@Ignore class Iso20022 { + // Asks a camt.052 report to the test platform. + + @Test + fun simulateIncoming() { + val cfg = prep() + val orderService: Ebics3Request.OrderDetails.Service = Ebics3Request.OrderDetails.Service().apply { + serviceName = "OTH" + scope = "BIL" + messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { + value = "csv" + } + serviceOption = "CH002LMF" + } + val instruction = """ + Product;Channel;Account;Currency;Amount;Reference;Name;Street;Number;Postcode;City;Country;DebtorAddressLine;DebtorAddressLine;DebtorAccount;ReferenceType;UltimateDebtorName;UltimateDebtorStreet;UltimateDebtorNumber;UltimateDebtorPostcode;UltimateDebtorTownName;UltimateDebtorCountry;UltimateDebtorAddressLine;UltimateDebtorAddressLine;RemittanceInformationText + QRR;PO;CH9789144829733648596;CHF;1;;D009;Musterstrasse;1;1111;Musterstadt;CH;;;;NON;D009;Musterstrasse;1;1111;Musterstadt;CH;;;Taler-Demo + """.trimIndent() + + runBlocking { + try { + doEbicsUpload( + HttpClient(), + cfg, + loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!, + loadBankKeys(cfg.bankPublicKeysFilename)!!, + orderService, + instruction.toByteArray(Charsets.UTF_8) + ) + } + catch (e: EbicsUploadException) { + logger.error(e.message) + logger.error("bank EC: ${e.bankErrorCode}, EBICS EC: ${e.ebicsErrorCode}") + } + } + } + + @Test // asks a pain.002 + fun getAck() { + val pain002 = download(prepAckRequest()) + println(pain002) + } + + @Test + fun getStatement() { + val inflatedBytes = download(prepStatementRequest()) + inflatedBytes?.unzipForEach { name, content -> + println(name) + println(content) + } + } + + @Test + fun getReport() { + println(download(prepReportRequest())) + } + + fun download(req: Ebics3Request.OrderDetails.BTOrderParams): ByteArray? { + val cfg = prep() + val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)!! + val myKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!! + val initXml = createEbics3DownloadInitialization( + cfg, + bankKeys, + myKeys, + orderParams = req + ) + return runBlocking { + doEbicsDownload( + HttpClient(), + cfg, + myKeys, + bankKeys, + initXml, + isEbics3 = true, + tolerateEmptyResult = true + ) + } + } + @Test fun sendPayment() { val cfg = prep() @@ -36,7 +113,6 @@ class Iso20022 { parsePayto("payto://iban/CH9300762011623852957?receiver-name=NotGiven")!! ) runBlocking { - // Not asserting, as it throws in case of errors. submitPain001( xml, @@ -49,7 +125,6 @@ class Iso20022 { } } -@Ignore class PostFinance { // Tests sending client keys to the PostFinance test platform. @Test @@ -77,25 +152,25 @@ class PostFinance { keys, HttpClient(), KeysOrderType.HPB - ) - ) + )) } } + // Arbitrary download request for manual tests. @Test fun customDownload() { val cfg = prep() val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) runBlocking { - val xml = doEbicsCustomDownload( + val bytes = doEbicsCustomDownload( messageType = "HTD", cfg = cfg, bankKeys = bankKeys!!, clientKeys = clientKeys!!, client = HttpClient() ) - println(xml) + println(bytes.toString()) } }