libeufin

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

commit a335eb9c62046978bb4c442ffbc801e598f603b9
parent 8db1a45e62b8b1f117ee90af0e1db6ee53d6c008
Author: MS <ms@taler.net>
Date:   Sat, 11 Nov 2023 11:31:21 +0100

nexus fetch

wiring the EBICS 2 support into the logic, not
triggering it yet.

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 362+++++++++++++++++++------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 40++++++++++++++++++++++++++++++++++++++++
Mnexus/src/test/kotlin/PostFinance.kt | 8++++----
Mutil/src/main/kotlin/Ebics.kt | 13++-----------
Mutil/src/main/kotlin/ebics_h005/Ebics3Request.kt | 2--
7 files changed, 439 insertions(+), 301 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -7,9 +7,8 @@ import io.ktor.client.* import kotlinx.coroutines.runBlocking import org.apache.commons.compress.archivers.zip.ZipFile import org.apache.commons.compress.utils.SeekableInMemoryByteChannel -import tech.libeufin.nexus.ebics.EbicsSideException -import tech.libeufin.nexus.ebics.createEbics3DownloadInitialization -import tech.libeufin.nexus.ebics.doEbicsDownload +import tech.libeufin.nexus.ebics.* +import tech.libeufin.util.EbicsOrderParams import tech.libeufin.util.ebics_h005.Ebics3Request import tech.libeufin.util.getXmlDate import tech.libeufin.util.toDbMicros @@ -20,224 +19,85 @@ import java.time.LocalDate import java.time.ZoneId import kotlin.concurrent.fixedRateTimer import kotlin.io.path.createDirectories +import kotlin.reflect.typeOf 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. - * @param endDate inclusive ending date for the returned banking events. - * @return [Ebics3Request.DateRange] + * Necessary data to perform a download. */ -fun getEbics3DateRange( - startDate: Instant, - endDate: Instant -): Ebics3Request.DateRange { - return Ebics3Request.DateRange().apply { - start = getXmlDate(startDate) - end = getXmlDate(endDate) - } -} - -/** - * Prepares the request for a camt.054 notification from the bank. - * Notifications inform the subscriber that some new events occurred - * on their account. One main difference with reports/statements is - * that notifications - according to the ISO20022 documentation - do - * NOT contain any balance. - * - * @param startDate inclusive starting date for the returned notification(s). - * @param endDate inclusive ending date for the returned notification(s). NOTE: - * if startDate is NOT null and endDate IS null, endDate gets defaulted - * to the current UTC time. - * @param isAppendix if true, the responded camt.054 will be an appendix of - * another camt.053 document, not therefore strictly acting as a notification. - * For example, camt.053 may omit wire transfer subjects and its related - * camt.054 appendix would instead contain those. - * - * @return [Ebics3Request.OrderDetails.BTOrderParams] - */ -fun prepNotificationRequest( - startDate: Instant? = null, - endDate: Instant? = null, - isAppendix: Boolean -): Ebics3Request.OrderDetails.BTOrderParams { - val service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "REP" - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.054" - version = "08" - } - if (!isAppendix) - serviceOption = "XDCI" - } - 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 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 = "10" - } - } - 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. - * @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 prepReportRequest( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics3Request.OrderDetails.BTOrderParams { - val service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "STM" - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.052" - version = "08" - } - } - return Ebics3Request.OrderDetails.BTOrderParams().apply { - this.service = service - this.dateRange = if (startDate != null) - getEbics3DateRange(startDate, endDate ?: Instant.now()) - else null - } -} +data class FetchContext( + /** + * Config handle. + */ + val cfg: EbicsSetupConfig, + /** + * HTTP client handle to reach the bank + */ + val httpClient: HttpClient, + /** + * EBICS subscriber private keys. + */ + val clientKeys: ClientPrivateKeysFile, + /** + * Bank public keys. + */ + val bankKeys: BankPublicKeysFile, + /** + * Type of document to download. + */ + val whichDocument: SupportedDocument, + /** + * EBICS version. + */ + val ebicsVersion: EbicsVersion = EbicsVersion.three, + /** + * Start date of the returned documents. Only + * used in --transient mode. + */ + var pinnedStart: Instant? = null +) /** * Downloads content via EBICS, according to the order params passed * by the caller. * - * @param cfg configuration handle. - * @param bankKeys bank public keys. - * @param clientKeys EBICS subscriber private keys. - * @param httpClient handle to the HTTP layer. + * @param T [Ebics2Request] for EBICS 2 or [Ebics3Request.OrderDetails.BTOrderParams] for EBICS 3 + * @param ctx [FetchContext] * @param req contains the instructions for the download, namely * which document is going to be downloaded from the bank. * @return the [ByteArray] payload. On an empty response, the array * length is zero. It returns null, if the bank assigned an * error to the EBICS transaction. */ -private suspend fun downloadHelper( - cfg: EbicsSetupConfig, - bankKeys: BankPublicKeysFile, - clientKeys: ClientPrivateKeysFile, - httpClient: HttpClient, - req: Ebics3Request.OrderDetails.BTOrderParams +private suspend inline fun downloadHelper( + ctx: FetchContext, + lastExecutionTime: Instant? = null ): ByteArray? { - val initXml = createEbics3DownloadInitialization( - cfg, - bankKeys, - clientKeys, - orderParams = req - ) + val initXml = if (ctx.ebicsVersion == EbicsVersion.three) { + createEbics3DownloadInitialization( + ctx.cfg, + ctx.bankKeys, + ctx.clientKeys, + prepEbics3Document(ctx.whichDocument, lastExecutionTime) + ) + } else { + val ebics2Req = prepEbics2Document(ctx.whichDocument, lastExecutionTime) + createEbics25DownloadInit( + ctx.cfg, + ctx.clientKeys, + ctx.bankKeys, + ebics2Req.messageType, + ebics2Req.orderParams + ) + } try { return doEbicsDownload( - httpClient, - cfg, - clientKeys, - bankKeys, + ctx.httpClient, + ctx.cfg, + ctx.clientKeys, + ctx.bankKeys, initXml, - isEbics3 = true, + isEbics3 = ctx.ebicsVersion == EbicsVersion.three, tolerateEmptyResult = true ) } catch (e: EbicsSideException) { @@ -298,65 +158,25 @@ fun maybeLogFile(cfg: EbicsSetupConfig, content: ByteArray) { * What this function does NOT do (now): linking documents between * different camt.05x formats and/or pain.002 acknowledgements. * - * @param cfg config handle. * @param db database connection - * @param httpClient HTTP client handle to reach the bank - * @param clientKeys EBICS subscriber private keys. - * @param bankKeys bank public keys. + * @param ctx [FetchContext] * @param pinnedStart explicit start date for the downloaded documents. * This parameter makes the last incoming transaction timestamp in * the database IGNORED. Only useful when running in --transient * mode to download past documents / debug. */ private suspend fun fetchDocuments( - cfg: EbicsSetupConfig, db: Database, - httpClient: HttpClient, - clientKeys: ClientPrivateKeysFile, - bankKeys: BankPublicKeysFile, - whichDocument: SupportedDocument = SupportedDocument.CAMT_054, - pinnedStart: Instant? = null + ctx: FetchContext ) { // maybe get last execution_date. - val lastExecutionTime: Instant? = pinnedStart ?: db.incomingPaymentLastExecTime() + val lastExecutionTime: Instant? = ctx.pinnedStart ?: db.incomingPaymentLastExecTime() logger.debug("Fetching documents from timestamp: $lastExecutionTime") - val req = when(whichDocument) { - SupportedDocument.PAIN_002 -> prepAckRequest(lastExecutionTime) - SupportedDocument.CAMT_052 -> prepReportRequest(lastExecutionTime) - SupportedDocument.CAMT_053 -> prepStatementRequest(lastExecutionTime) - SupportedDocument.CAMT_054 -> prepNotificationRequest(lastExecutionTime, isAppendix = true) - } - val maybeContent = downloadHelper( - cfg, - bankKeys, - clientKeys, - httpClient, - req - ) ?: exitProcess(1) // client is wrong, failing. - + val maybeContent = downloadHelper(ctx, lastExecutionTime) ?: exitProcess(1) // client is wrong, failing. if (maybeContent.isEmpty()) return - maybeLogFile(cfg, maybeContent) + maybeLogFile(ctx.cfg, maybeContent) } -/** - * Turns a YYYY-MM-DD date string into Instant. Used - * to parse the --pinned-start CLI options. Fails the - * process, if the input is invalid. - * - * @param dashedDate pinned start command line option. - * @return [Instant] - */ -fun parseDashedDate(dashedDate: String): Instant = - doOrFail { - LocalDate.parse(dashedDate).atStartOfDay(ZoneId.of("UTC")).toInstant() - } - -enum class SupportedDocument { - PAIN_002, - CAMT_053, - CAMT_052, - CAMT_054 -} class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 notifications") { private val configFile by option( "--config", "-c", @@ -411,30 +231,33 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti logger.error("Client private keys not found at: ${cfg.clientPrivateKeysFilename}") exitProcess(1) } - val httpClient = HttpClient() - + // Deciding what to download. var whichDoc = SupportedDocument.CAMT_054 if (onlyAck) whichDoc = SupportedDocument.PAIN_002 if (onlyReports) whichDoc = SupportedDocument.CAMT_052 if (onlyStatements) whichDoc = SupportedDocument.CAMT_053 + val ctx = FetchContext( + cfg, + HttpClient(), + clientKeys, + bankKeys, + whichDoc + ) + if (transient) { logger.info("Transient mode: fetching once and returning.") val pinnedStartVal = pinnedStart val pinnedStartArg = if (pinnedStartVal != null) { logger.debug("Pinning start date to: $pinnedStartVal") - parseDashedDate(pinnedStartVal) + doOrFail { + // Converting YYYY-MM-DD to Instant. + LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() + } } else null + ctx.pinnedStart = pinnedStartArg runBlocking { - fetchDocuments( - cfg, - db, - httpClient, - clientKeys, - bankKeys, - whichDoc, - pinnedStartArg - ) + fetchDocuments(db, ctx) } return } @@ -447,14 +270,7 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti if (frequency.inSeconds == 0) { logger.warn("Long-polling not implemented, running therefore in transient mode") runBlocking { - fetchDocuments( - cfg, - db, - httpClient, - clientKeys, - bankKeys, - whichDoc - ) + fetchDocuments(db, ctx) } return } @@ -463,16 +279,9 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti period = (frequency.inSeconds * 1000).toLong(), action = { runBlocking { - fetchDocuments( - cfg, - db, - httpClient, - clientKeys, - bankKeys, - whichDoc - ) + fetchDocuments(db, ctx) } } ) } -} -\ No newline at end of file +} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt @@ -30,7 +30,9 @@ import tech.libeufin.nexus.ClientPrivateKeysFile import tech.libeufin.nexus.EbicsSetupConfig import tech.libeufin.util.* import tech.libeufin.util.ebics_h004.* +import tech.libeufin.util.ebics_h005.Ebics3Request import java.security.interfaces.RSAPrivateCrtKey +import java.time.Instant import java.time.ZoneId import java.util.* import javax.xml.datatype.DatatypeFactory @@ -161,7 +163,7 @@ fun createEbics25DownloadReceiptPhase( * * @param cfg configuration handle. * @param clientKeys user EBICS private keys. - * @param segNumber which segment we ask to the bank. + * @param segNumber which segment we ask the bank. * @param totalSegments how many segments compose the whole EBICS transaction. * @param transactionId ID of the EBICS transaction that transports all the segments. * @return raw XML string of the request. @@ -286,4 +288,118 @@ fun generateHpbMessage(cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile) val doc = XMLUtil.convertJaxbToDocument(hpbRequest) XMLUtil.signEbicsDocument(doc, clientKeys.authentication_private_key) return XMLUtil.convertDomToString(doc) -} -\ No newline at end of file +} + +/** + * Collects message type and date range of an EBICS 2 request. + */ +data class Ebics2Request( + val messageType: String, + val orderParams: EbicsOrderParams +) + +/** + * Prepares an EBICS 2 request to get pain.002 acknowledgements + * about submitted pain.001 documents. + * + * @param startDate earliest timestamp of the returned document(s). If + * null, it defaults to download the unseen documents. + * @param endDate latest timestamp of the returned document(s). If + * null, it defaults to the current time. + * @return [Ebics2Request] object to be first converted in XML and + * then be passed to the EBICS downloader. + */ +private fun prepAckRequest2( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics2Request { + val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null + return Ebics2Request( + messageType = "Z01", + orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) + ) +} + +/** + * Prepares an EBICS 2 request to get intraday camt.052 reports. + * + * @param startDate earliest timestamp of the returned document(s). If + * null, it defaults to download the unseen documents. + * @param endDate latest timestamp of the returned document(s). If + * null, it defaults to the current time. + * @return [Ebics2Request] object to be first converted in XML and + * then be passed to the EBICS downloader. + */ +private fun prepReportRequest2( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics2Request { + val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null + return Ebics2Request( + messageType = "Z52", + orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) + ) +} + +/** + * Prepares an EBICS 2 request to get daily camt.053 statements. + * + * @param startDate earliest timestamp of the returned document(s). If + * null, it defaults to download the unseen documents. + * @param endDate latest timestamp of the returned document(s). If + * null, it defaults to the current time. + * @return [Ebics2Request] object to be first converted in XML and + * then be passed to the EBICS downloader. + */ +private fun prepStatementRequest2( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics2Request { + val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null + return Ebics2Request( + messageType = "Z53", + orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) + ) +} + +/** + * Prepares an EBICS 2 request to get camt.054 notifications. + * + * @param startDate earliest timestamp of the returned document(s). If + * null, it defaults to download the unseen documents. + * @param endDate latest timestamp of the returned document(s). If + * null, it defaults to the current time. + * @return [Ebics2Request] object to be first converted in XML and + * then be passed to the EBICS downloader. + */ +private fun prepNotificationRequest2( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics2Request { + val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null + return Ebics2Request( + messageType = "Z54", // ZS2 is the non-appendix type + orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) + ) +} + +/** + * Abstracts EBICS 2 request creation of a download init phase. + * + * @param whichDoc type of wanted document. + * @param startDate earliest timestamp of the document(s) to download. + * If null, it gets the unseen documents. If defined, + * the latest timestamp defaults to the current time. + * @return [Ebics2Request] to be converted to XML string and passed to + * the EBICS downloader. + */ +fun prepEbics2Document( + whichDoc: SupportedDocument, + startDate: Instant? = null +): Ebics2Request = + when(whichDoc) { + SupportedDocument.PAIN_002 -> prepAckRequest2(startDate) + SupportedDocument.CAMT_052 -> prepReportRequest2(startDate) + SupportedDocument.CAMT_053 -> prepStatementRequest2(startDate) + SupportedDocument.CAMT_054 -> prepNotificationRequest2(startDate) + } +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt @@ -9,13 +9,15 @@ import tech.libeufin.util.PreparedUploadData import tech.libeufin.util.XMLUtil import tech.libeufin.util.ebics_h005.Ebics3Request import tech.libeufin.util.getNonce +import tech.libeufin.util.getXmlDate import java.math.BigInteger +import java.time.Instant import java.util.* import javax.xml.datatype.DatatypeFactory /** - * Crafts an EBICS request for the receipt phase of a - * download transaction. + * Crafts an EBICS request for the receipt phase of a download + * transaction. * * @param cfg config handle * @param clientKeys subscriber private keys. @@ -232,4 +234,187 @@ suspend fun submitPain001( " EBICS technical code is: ${maybeUploaded.technicalReturnCode}," + " bank technical return code is: ${maybeUploaded.bankReturnCode}" ) -} -\ No newline at end of file +} + +/** + * Crafts a date range object, when the caller needs a time range. + * + * @param startDate inclusive starting date for the returned banking events. + * @param endDate inclusive ending date for the returned banking events. + * @return [Ebics3Request.DateRange] + */ +private fun getEbics3DateRange( + startDate: Instant, + endDate: Instant +): Ebics3Request.DateRange { + return Ebics3Request.DateRange().apply { + start = getXmlDate(startDate) + end = getXmlDate(endDate) + } +} + +/** + * Prepares the request for a camt.054 notification from the bank, + * via EBICS 3. + * Notifications inform the subscriber that some new events occurred + * on their account. One main difference with reports/statements is + * that notifications - according to the ISO20022 documentation - do + * NOT contain any balance. + * + * @param startDate inclusive starting date for the returned notification(s). + * @param endDate inclusive ending date for the returned notification(s). NOTE: + * if startDate is NOT null and endDate IS null, endDate gets defaulted + * to the current UTC time. + * @param isAppendix if true, the responded camt.054 will be an appendix of + * another camt.053 document, not therefore strictly acting as a notification. + * For example, camt.053 may omit wire transfer subjects and its related + * camt.054 appendix would instead contain those. + * + * @return [Ebics3Request.OrderDetails.BTOrderParams] + */ +private fun prepNotificationRequest3( + startDate: Instant? = null, + endDate: Instant? = null, + isAppendix: Boolean +): Ebics3Request.OrderDetails.BTOrderParams { + val service = Ebics3Request.OrderDetails.Service().apply { + serviceName = "REP" + scope = "CH" + container = Ebics3Request.OrderDetails.Service.Container().apply { + containerType = "ZIP" + } + messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { + value = "camt.054" + version = "08" + } + if (!isAppendix) + serviceOption = "XDCI" + } + 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 pain.002 acknowledgement from the bank, via + * EBICS 3. + * + * @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] + */ +private fun prepAckRequest3( + 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 = "10" + } + } + 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) via EBICS 3. + * + * @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] + */ +private fun prepStatementRequest3( + 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 via EBICS 3. + * + * @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] + */ +private fun prepReportRequest3( + startDate: Instant? = null, + endDate: Instant? = null +): Ebics3Request.OrderDetails.BTOrderParams { + val service = Ebics3Request.OrderDetails.Service().apply { + serviceName = "STM" + scope = "CH" + container = Ebics3Request.OrderDetails.Service.Container().apply { + containerType = "ZIP" + } + messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { + value = "camt.052" + version = "08" + } + } + return Ebics3Request.OrderDetails.BTOrderParams().apply { + this.service = service + this.dateRange = if (startDate != null) + getEbics3DateRange(startDate, endDate ?: Instant.now()) + else null + } +} + +/** + * Abstracts EBICS 3 request creation of a download init phase. + * + * @param whichDoc type of wanted document. + * @param startDate earliest timestamp of the document(s) to download. + * If null, it gets the unseen documents. If defined, + * the latest timestamp defaults to the current time. + * @return [Ebics2Request] to be converted to XML string and passed to + * the EBICS downloader. + */ +fun prepEbics3Document( + whichDoc: SupportedDocument, + startDate: Instant? = null +): Ebics3Request.OrderDetails.BTOrderParams = + when(whichDoc) { + SupportedDocument.PAIN_002 -> prepAckRequest3(startDate) + SupportedDocument.CAMT_052 -> prepReportRequest3(startDate) + SupportedDocument.CAMT_053 -> prepStatementRequest3(startDate) + SupportedDocument.CAMT_054 -> prepReportRequest3(startDate) + } +\ 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 @@ -42,18 +42,58 @@ import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* +import org.apache.commons.compress.archivers.zip.ZipFile +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import tech.libeufin.nexus.* import tech.libeufin.util.* import tech.libeufin.util.ebics_h005.Ebics3Request import tech.libeufin.util.logger import java.io.ByteArrayOutputStream import java.security.interfaces.RSAPrivateCrtKey +import java.time.Instant import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.* import java.util.zip.DeflaterInputStream /** + * Available EBICS versions. + */ +enum class EbicsVersion { two, three } + +/** + * Which documents can be downloaded via EBICS. + */ +enum class SupportedDocument { + PAIN_002, + CAMT_053, + CAMT_052, + CAMT_054 +} + + +/** + * 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()) { + tech.libeufin.nexus.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() +} + +/** * Decrypts and decompresses the business payload that was * transported within an EBICS message from the bank * diff --git a/nexus/src/test/kotlin/PostFinance.kt b/nexus/src/test/kotlin/PostFinance.kt @@ -29,7 +29,7 @@ class Iso20022 { @Test // asks a pain.002, links with pain.001's MsgId fun getAck() { - download(prepAckRequest(startDate = yesterday) + download(prepAckRequest3(startDate = yesterday) )?.unzipForEach { name, content -> println(name) println(content) @@ -42,7 +42,7 @@ class Iso20022 { */ @Test fun getStatement() { - val inflatedBytes = download(prepStatementRequest()) + val inflatedBytes = download(prepStatementRequest3()) inflatedBytes?.unzipForEach { name, content -> println(name) println(content) @@ -52,7 +52,7 @@ class Iso20022 { @Test fun getNotification() { val inflatedBytes = download( - prepNotificationRequest( + prepNotificationRequest3( // startDate = yesterday, isAppendix = true ) @@ -68,7 +68,7 @@ class Iso20022 { */ @Test fun getReport() { - download(prepReportRequest(yesterday))?.unzipForEach { name, content -> + download(prepReportRequest3(yesterday))?.unzipForEach { name, content -> println(name) println(content) } diff --git a/util/src/main/kotlin/Ebics.kt b/util/src/main/kotlin/Ebics.kt @@ -59,8 +59,8 @@ data class EbicsProtocolError( ) : Exception(reason) data class EbicsDateRange( - val start: ZonedDateTime, - val end: ZonedDateTime + val start: Instant, + val end: Instant ) sealed class EbicsOrderParams @@ -152,15 +152,6 @@ fun makeOrderParams(orderParams: EbicsOrderParams): EbicsRequest.OrderParams { } } -fun makeEbics3DateRange(ebicsDateRange: EbicsDateRange?): Ebics3Request.DateRange? { - return if (ebicsDateRange != null) - return Ebics3Request.DateRange().apply { - this.start = getXmlDate(ebicsDateRange.start) - this.end = getXmlDate(ebicsDateRange.end) - } - else null -} - fun signOrder( orderBlob: ByteArray, signKey: RSAPrivateCrtKey, diff --git a/util/src/main/kotlin/ebics_h005/Ebics3Request.kt b/util/src/main/kotlin/ebics_h005/Ebics3Request.kt @@ -2,8 +2,6 @@ package tech.libeufin.util.ebics_h005 import org.apache.xml.security.binding.xmldsig.SignatureType import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.EbicsStandardOrderParams -import tech.libeufin.util.makeEbics3DateRange import java.math.BigInteger import java.security.interfaces.RSAPublicKey import java.util.*