libeufin

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

commit 793875b7099a3ef8284a4d03cec485bc970333ed
parent f49ae7d5613adc3b6329d04d9c842a7801f97baf
Author: Marcello Stanisci <ms@taler.net>
Date:   Wed, 29 Apr 2020 19:18:38 +0200

More "history logic" to the Sandbox.

Upon receiving a pain.001 document, the sandbox
stores its details into the database and associates
the new record with the requesting subscriber.

As a consequence, the sandbox now queries the history
of payments related to the requesting subscriber.

Note: the resulting C53 response does NOT have all the
details from the payment YET.  The way it is, it
only tells _how many_ payments were initiated by the
requesting subscriber.

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt | 17+----------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 4++--
Msandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt | 22+++++++++++++++++++++-
Msandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt | 467++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mutil/src/main/kotlin/zip.kt | 26++++++++++++++------------
5 files changed, 274 insertions(+), 262 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt @@ -1,9 +1,6 @@ package tech.libeufin.nexus -import tech.libeufin.util.Amount -import tech.libeufin.util.EbicsDateRange -import tech.libeufin.util.EbicsOrderParams -import tech.libeufin.util.EbicsStandardOrderParams +import tech.libeufin.util.* import java.lang.NullPointerException import java.time.LocalDate @@ -147,18 +144,6 @@ data class Pain001Data( val subject: String ) -/** - * (Very) generic information about one payment. Can be - * derived from a CAMT response, or from a prepared PAIN - * document. - */ -data class RawPayment( - val creditorIban: String, - val debitorIban: String, - val amount: String, - val subject: String, - val date: String -) data class RawPayments( var payments: MutableList<RawPayment> = mutableListOf() ) \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -614,7 +614,7 @@ fun main() { client, subscriberDetails, "CCC", - painDoc.toByteArray(Charsets.UTF_8).zip(), + listOf(painDoc.toByteArray(Charsets.UTF_8)).zip(), EbicsStandardOrderParams() ) /* flow here == no errors occurred */ @@ -730,7 +730,7 @@ fun main() { * return all the "Ntry" elements into one single ZIP entry, or even unzipped * at all. */ - response.orderData.unzipWithLoop { + response.orderData.unzipWithLambda { val fileName = it.first val camt53doc = XMLUtil.parseStringIntoDom(it.second) transaction { diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt @@ -218,12 +218,32 @@ object EbicsUploadTransactionChunksTable : IdTable<String>() { val chunkIndex = integer("chunkIndex") val chunkContent = blob("chunkContent") } -class EbicsUploadTransactionChunkEntity(id : EntityID<String>): Entity<String>(id) { +class EbicsUploadTransactionChunkEntity(id: EntityID<String>) : Entity<String>(id) { companion object : EntityClass<String, EbicsUploadTransactionChunkEntity>(EbicsUploadTransactionChunksTable) var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex var chunkContent by EbicsUploadTransactionChunksTable.chunkContent } +/** + * Table that keeps all the payments initiated by pain. + */ +object PaymentsTable : IntIdTable() { + val creditorIban = text("creditorIban") + val debitorIban = text("debitorIban") + val subject = text("subject") + val amount = text("amount") + val date = long("date") + val ebicsSubscriber = reference("ebicsSubscriber", EbicsSubscribersTable) +} +class PaymentEntity(id: EntityID<Int>) : IntEntity(id) { + companion object : IntEntityClass<PaymentEntity>(PaymentsTable) + var creditorIban by PaymentsTable.creditorIban + var debitorIban by PaymentsTable.debitorIban + var subject by PaymentsTable.subject + var amount by PaymentsTable.amount + var date by PaymentsTable.date + var ebicsSubscriber by EbicsSubscriberEntity referencedOn PaymentsTable.ebicsSubscriber +} fun dbCreateTables() { Database.connect("jdbc:sqlite:libeufin-sandbox.sqlite3", "org.sqlite.JDBC") diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt @@ -54,6 +54,7 @@ import org.joda.time.DateTime import org.joda.time.Instant import java.io.BufferedInputStream import java.io.ByteArrayInputStream +import java.nio.charset.Charset import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter @@ -140,27 +141,12 @@ private suspend fun ApplicationCall.respondEbicsKeyManagement( respondText(text, ContentType.Application.Xml, HttpStatusCode.OK) } -fun buildCamtString(type: Int): String { - - /** - * Booking period: we keep two "lines" of booking periods; one for c52 and one for c53. - * Each line's period ends when the user requests the c52/c53, and a new period is started. - */ - - /** - * Checklist of data to be retrieved from the database. - * - * - IBAN(s): debitor and creditor's - * - last IDs (of all kinds) ? - */ - - /** - * What needs to be calculated before filling the document: - * - * - The balance _after_ all the transactions from the fresh - * booking period. - */ - +/** + * Returns a list of camt strings, representing each one payment + * accounted in the history. It is up to the caller to then construct + * the final ZIP file to return in the response. + */ +fun buildCamtString(type: Int, history: MutableList<RawPayment>): MutableList<String> { /** * ID types required: * @@ -173,248 +159,275 @@ fun buildCamtString(type: Int): String { * - Proprietary code of the bank transaction * - Id of the servicer (Issuer and Code) */ - val now = DateTime.now() - - return constructXml(indent = true) { - root("Document") { - attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02") - attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") - attribute("xsi:schemaLocation", "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02 camt.053.001.02.xsd") - element("BkToCstmrStmt") { - element("GrpHdr") { - element("MsgId") { - text("0") - } - element("CreDtTm") { - text(now.toZonedString()) - } - element("MsgPgntn") { - element("PgNb") { - text("001") - } - element("LastPgInd") { - text("true") - } - } - } - element(if (type == 52) "Rpt" else "Stmt") { - element("Id") { - text("0") - } - element("ElctrncSeqNb") { - text("0") - } - element("LglSeqNb") { - text("0") - } - element("CreDtTm") { - text(now.toZonedString()) - } - - element("Acct") { - // mandatory account identifier - element("Id/IBAN") { - text("GB33BUKB20201555555555") - } - element("Ccy") { - text("EUR") - } - element("Ownr/Nm") { - text("Max Mustermann") - } - element("Svcr/FinInstnId") { - element("BIC") { - text("GENODEM1GLS") + val ret = mutableListOf<String>() + history.forEach { + ret.add( + constructXml(indent = true) { + root("Document") { + attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02") + attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") + attribute("xsi:schemaLocation", "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02 camt.053.001.02.xsd") + element("BkToCstmrStmt") { + element("GrpHdr") { + element("MsgId") { + text("0") } - element("Nm") { - text("Libeufin Bank") + element("CreDtTm") { + text(now.toZonedString()) } - element("Othr") { - element("Id") { - text("0") + element("MsgPgntn") { + element("PgNb") { + text("001") } - element("Issr") { - text("XY") + element("LastPgInd") { + text("true") } } } - } - element("Bal") { - element("Tp/CdOrPrtry/Cd") { - /* Balance type, in a coded format. PRCD stands - for "Previously closed booked" and shows the - balance at the time _before_ all the entries - reported in this document were posted to the - involved bank account. */ - text("PRCD") - } - element("Amt") { - attribute("Ccy", "EUR") - text(Amount(1).toPlainString()) - } - element("CdtDbtInd") { - text("DBIT") - // CRDT or DBIT here - } - element("Dt/Dt") { - // date of this balance - text(now.toDashedDate()) - } - } - element("Bal") { - element("Tp/CdOrPrtry/Cd") { - /* CLBD stands for "Closing booked balance", and it - is calculated by summing the PRCD with all the - entries reported in this document */ - text("CLBD") - } - element("Amt") { - attribute("Ccy", "EUR") - text(Amount(1).toPlainString()) - } - element("CdtDbtInd") { - // CRDT or DBIT here - text("DBIT") - } - element("Dt/Dt") { - text(now.toDashedDate()) - } - } - // history.forEach { - element("Ntry") { - element("Amt") { - attribute("Ccy", "EUR") - text(Amount(1).toPlainString()) - } - element("CdtDbtInd") { - text("DBIT") + element(if (type == 52) "Rpt" else "Stmt") { + element("Id") { + text("0") } - element("Sts") { - /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.) - * From the original text: - * "Status of an entry on the books of the account servicer" */ - text("BOOK") + element("ElctrncSeqNb") { + text("0") } - element("BookgDt/Dt") { - text(now.toDashedDate()) - } // date of the booking - element("ValDt/Dt") { - text(now.toDashedDate()) - } // date of assets' actual (un)availability - element("AcctSvcrRef") { + element("LglSeqNb") { text("0") } - element("BkTxCd") { - /* "Set of elements used to fully identify the type of underlying - * transaction resulting in an entry". */ - element("Domn") { - element("Cd") { - text("PMNT") + element("CreDtTm") { + text(now.toZonedString()) + } + + element("Acct") { + // mandatory account identifier + element("Id/IBAN") { + text("GB33BUKB20201555555555") + } + element("Ccy") { + text("EUR") + } + element("Ownr/Nm") { + text("Max Mustermann") + } + element("Svcr/FinInstnId") { + element("BIC") { + text("GENODEM1GLS") } - element("Fmly") { - element("Cd") { - text("ICDT") + element("Nm") { + text("Libeufin Bank") + } + element("Othr") { + element("Id") { + text("0") } - element("SubFmlyCd") { - text("ESCT") + element("Issr") { + text("XY") } } } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } + } + element("Bal") { + element("Tp/CdOrPrtry/Cd") { + /* Balance type, in a coded format. PRCD stands + for "Previously closed booked" and shows the + balance at the time _before_ all the entries + reported in this document were posted to the + involved bank account. */ + text("PRCD") + } + element("Amt") { + attribute("Ccy", "EUR") + text(Amount(1).toPlainString()) + } + element("CdtDbtInd") { + text("DBIT") + // CRDT or DBIT here + } + element("Dt/Dt") { + // date of this balance + text(now.toDashedDate()) } } - element("NtryDtls/TxDtls") { - element("Refs") { - element("MsgId") { - text("0") - } - element("PmtInfId") { - text("0") - } - element("EndToEndId") { - text("NOTPROVIDED") - } + element("Bal") { + element("Tp/CdOrPrtry/Cd") { + /* CLBD stands for "Closing booked balance", and it + is calculated by summing the PRCD with all the + entries reported in this document */ + text("CLBD") } - element("AmtDtls/TxAmt/Amt") { + element("Amt") { attribute("Ccy", "EUR") text(Amount(1).toPlainString()) } - element("BkTxCd") { - element("Domn") { - element("Cd") { - text("PMNT") + element("CdtDbtInd") { + // CRDT or DBIT here + text("DBIT") + } + element("Dt/Dt") { + text(now.toDashedDate()) + } + } + /** + * NOTE: instead of looping here, please emulate GLS behaviour of + * creating ONE ZIP entry per CAMT document. */ + history.forEach { + element("Ntry") { + element("Amt") { + attribute("Ccy", "EUR") + text(Amount(1).toPlainString()) + } + element("CdtDbtInd") { + text("DBIT") + } + element("Sts") { + /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.) + * From the original text: + * "Status of an entry on the books of the account servicer" */ + text("BOOK") + } + element("BookgDt/Dt") { + text(now.toDashedDate()) + } // date of the booking + element("ValDt/Dt") { + text(now.toDashedDate()) + } // date of assets' actual (un)availability + element("AcctSvcrRef") { + text("0") + } + element("BkTxCd") { + /* "Set of elements used to fully identify the type of underlying + * transaction resulting in an entry". */ + element("Domn") { + element("Cd") { + text("PMNT") + } + element("Fmly") { + element("Cd") { + text("ICDT") + } + element("SubFmlyCd") { + text("ESCT") + } + } } - element("Fmly") { + element("Prtry") { element("Cd") { - text("ICDT") + text("0") } - element("SubFmlyCd") { - text("ESCT") + element("Issr") { + text("XY") } } } - element("Prtry") { - element("Cd") { - text("0") + element("NtryDtls/TxDtls") { + element("Refs") { + element("MsgId") { + text("0") + } + element("PmtInfId") { + text("0") + } + element("EndToEndId") { + text("NOTPROVIDED") + } } - element("Issr") { - text("XY") + element("AmtDtls/TxAmt/Amt") { + attribute("Ccy", "EUR") + text(Amount(1).toPlainString()) + } + element("BkTxCd") { + element("Domn") { + element("Cd") { + text("PMNT") + } + element("Fmly") { + element("Cd") { + text("ICDT") + } + element("SubFmlyCd") { + text("ESCT") + } + } + } + element("Prtry") { + element("Cd") { + text("0") + } + element("Issr") { + text("XY") + } + } + } + element("RltdPties") { + element("Dbtr/Nm") { + text("Max Mustermann") + } + element("DbtrAcct/Id/IBAN") { + text("GB33BUKB20201555555555") + } + element("Cdtr/Nm") { + text("Lina Musterfrau") + } + element("CdtrAcct/Id/IBAN") { + text("DE75512108001245126199") + } + } + element("RltdAgts") { + element("CdtrAgt/FinInstnId/BIC") { + text("GENODEM1GLS") + } + } + element("RmtInf/Ustrd") { + text("made up subject") } } - } - element("RltdPties") { - element("Dbtr/Nm") { - text("Max Mustermann") - } - element("DbtrAcct/Id/IBAN") { - text("GB33BUKB20201555555555") - } - element("Cdtr/Nm") { - text("Lina Musterfrau") - } - element("CdtrAcct/Id/IBAN") { - text("DE75512108001245126199") - } - } - element("RltdAgts") { - element("CdtrAgt/FinInstnId/BIC") { - text("GENODEM1GLS") + element("AddtlNtryInf") { + text("additional information not given") } } - element("RmtInf/Ustrd") { - text("made up subject") - } - } - element("AddtlNtryInf") { - text("additional information not given") } } - // } + } } } - } + ) } + return ret } /** * Builds CAMT response. * - * @param history the list of all the history elements * @param type 52 or 53. */ -private fun constructCamtResponse(type: Int, header: EbicsRequest.Header): String { +private fun constructCamtResponse( + type: Int, + header: EbicsRequest.Header, + subscriber: EbicsSubscriberEntity +): MutableList<String> { val dateRange = (header.static.orderDetails?.orderParams as EbicsRequest.StandardOrderParams).dateRange - val (start: org.joda.time.DateTime, end: org.joda.time.DateTime) = if (dateRange != null) { + val (start: DateTime, end: DateTime) = if (dateRange != null) { Pair(DateTime(dateRange.start.toGregorianCalendar().time), DateTime(dateRange.end.toGregorianCalendar().time)) } else Pair(DateTime(0), DateTime.now()) - return buildCamtString(type) + val history = mutableListOf<RawPayment>() + transaction { + PaymentEntity.find { + PaymentsTable.ebicsSubscriber eq subscriber.id.value + }.forEach { + history.add( + RawPayment( + subject = it.subject, + creditorIban = it.creditorIban, + debitorIban = it.debitorIban, + date = DateTime(it.date).toDashedDate(), + amount = it.amount + ) + ) + } + history + } + return buildCamtString(type, history) } private fun handleEbicsTSD(requestContext: RequestContext): ByteArray { @@ -428,7 +441,7 @@ private fun handleEbicsPTK(requestContext: RequestContext): ByteArray { /** * Process a payment request in the pain.001 format. */ -private fun handleCct(paymentRequest: String, ebicsSubscriberEntity: EbicsSubscriberEntity) { +private fun handleCct(paymentRequest: String, ebicsSubscriber: EbicsSubscriberEntity) { /** * NOTE: this function is ONLY required to store some details * to put then in the camt report. IBANs / amount / subject / names? @@ -440,33 +453,26 @@ private fun handleCct(paymentRequest: String, ebicsSubscriberEntity: EbicsSubscr val currency = painDoc.pickString("//*[local-name()='InstdAmt']/@ccy") val amount = painDoc.pickString("//*[local-name()='InstdAmt']") - /* transaction { PaymentEntity.new { this.creditorIban = creditorIban this.debitorIban = debitorIban this.subject = subject this.amount = "${currency}:${amount}" + this.ebicsSubscriber = ebicsSubscriber } - }*/ -} - -private fun handleEbicsC52(requestContext: RequestContext): ByteArray { - val subscriber = requestContext.subscriber - val camt = constructCamtResponse( - 52, - requestContext.requestObject.header - ) - return camt.toByteArray().zip() + } } private fun handleEbicsC53(requestContext: RequestContext): ByteArray { - val subscriber = requestContext.subscriber val camt = constructCamtResponse( 53, - requestContext.requestObject.header + requestContext.requestObject.header, + requestContext.subscriber ) - return camt.toByteArray().zip() + return camt.map { + it.toByteArray(Charsets.UTF_8) + }.zip() } private suspend fun ApplicationCall.handleEbicsHia(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { @@ -829,7 +835,6 @@ private fun handleEbicsDownloadTransactionInitialization(requestContext: Request "HTD" -> handleEbicsHtd() "HKD" -> handleEbicsHkd() /* Temporarily handling C52/C53 with same logic */ - "C52" -> handleEbicsC52(requestContext) "C53" -> handleEbicsC53(requestContext) "TSD" -> handleEbicsTSD(requestContext) "PTK" -> handleEbicsPTK(requestContext) diff --git a/util/src/main/kotlin/zip.kt b/util/src/main/kotlin/zip.kt @@ -8,18 +8,20 @@ import org.apache.commons.compress.archivers.zip.ZipFile import org.apache.commons.compress.utils.IOUtils import org.apache.commons.compress.utils.SeekableInMemoryByteChannel - -fun ByteArray.zip(): ByteArray { - +fun List<ByteArray>.zip(): ByteArray { val baos = ByteArrayOutputStream() - val asf = ArchiveStreamFactory().createArchiveOutputStream(ArchiveStreamFactory.ZIP, baos) - val zae = ZipArchiveEntry("File 1") - asf.putArchiveEntry(zae) // link Zip archive to output stream. - - val bais = ByteArrayInputStream(this) - IOUtils.copy(bais, asf) - bais.close() - asf.closeArchiveEntry() + val asf = ArchiveStreamFactory().createArchiveOutputStream( + ArchiveStreamFactory.ZIP, + baos + ) + for (fileIndex in this.indices) { + val zae = ZipArchiveEntry("File $fileIndex") + asf.putArchiveEntry(zae) + val bais = ByteArrayInputStream(this[fileIndex]) + IOUtils.copy(bais, asf) + bais.close() + asf.closeArchiveEntry() + } asf.finish() baos.close() return baos.toByteArray() @@ -37,7 +39,7 @@ fun ByteArray.prettyPrintUnzip(): String { return s.toString() } -fun ByteArray.unzipWithLoop(process: (Pair<String, String>) -> Unit) { +fun ByteArray.unzipWithLambda(process: (Pair<String, String>) -> Unit) { val mem = SeekableInMemoryByteChannel(this) val zipFile = ZipFile(mem) zipFile.getEntriesInPhysicalOrder().iterator().forEach {