[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[libeufin] 05/06: More "history logic" to the Sandbox.
From: |
gnunet |
Subject: |
[libeufin] 05/06: More "history logic" to the Sandbox. |
Date: |
Wed, 29 Apr 2020 21:44:44 +0200 |
This is an automated email from the git hooks/post-receive script.
marcello pushed a commit to branch master
in repository libeufin.
commit 793875b7099a3ef8284a4d03cec485bc970333ed
Author: Marcello Stanisci <address@hidden>
AuthorDate: Wed Apr 29 19:18:38 2020 +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.
---
nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt | 17 +-
nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 4 +-
.../src/main/kotlin/tech/libeufin/sandbox/DB.kt | 22 +-
.../tech/libeufin/sandbox/EbicsProtocolBackend.kt | 467 +++++++++++----------
util/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
index dca0806..390745a 100644
--- 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
index 59abf55..1f83c06 100644
--- 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
index d91f1b2..6f49a7d 100644
--- 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
index b7d309c..5788064 100644
--- 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
index e64d81b..09144fc 100644
--- 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 {
--
To stop receiving notification emails like this one, please contact
address@hidden.
- [libeufin] branch master updated (b6e9340 -> 13bfc9f), gnunet, 2020/04/29
- [libeufin] 02/06: remove empty lines, gnunet, 2020/04/29
- [libeufin] 03/06: Integration test., gnunet, 2020/04/29
- [libeufin] 06/06: Abstracting on "bank account"., gnunet, 2020/04/29
- [libeufin] 04/06: Integration test., gnunet, 2020/04/29
- [libeufin] 01/06: Remove notion of "bank customer" from Sandbox., gnunet, 2020/04/29
- [libeufin] 05/06: More "history logic" to the Sandbox.,
gnunet <=