[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[libeufin] branch master updated (0ee8d69d -> 7dfdeeda)
From: |
gnunet |
Subject: |
[libeufin] branch master updated (0ee8d69d -> 7dfdeeda) |
Date: |
Mon, 13 Mar 2023 10:25:11 +0100 |
This is an automated email from the git hooks/post-receive script.
ms pushed a change to branch master
in repository libeufin.
from 0ee8d69d readme
new c094c812 /history/incoming: no negative start param.
new d13ac7ba Adapt tests to #7515.
new 14c2af86 Testing #7515.
new 0e7ebe41 Using #7515 constructs.
new 7dfdeeda Addressing #7515 (core change).
The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt | 1 -
nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt | 9 +-
.../tech/libeufin/nexus/server/NexusServer.kt | 2 +-
nexus/src/test/kotlin/MakeEnv.kt | 58 +++-----
nexus/src/test/kotlin/SandboxAccessApiTest.kt | 5 +-
nexus/src/test/kotlin/SandboxCircuitApiTest.kt | 14 +-
nexus/src/test/kotlin/TalerTest.kt | 4 +-
.../kotlin/tech/libeufin/sandbox/CircuitApi.kt | 8 +-
.../src/main/kotlin/tech/libeufin/sandbox/DB.kt | 131 +++++++++++++++--
.../tech/libeufin/sandbox/EbicsProtocolBackend.kt | 6 +-
.../main/kotlin/tech/libeufin/sandbox/Helpers.kt | 40 ++++-
.../src/main/kotlin/tech/libeufin/sandbox/Main.kt | 163 +++++++++++----------
.../kotlin/tech/libeufin/sandbox/bankAccount.kt | 19 +--
sandbox/src/test/kotlin/BalanceTest.kt | 21 ++-
sandbox/src/test/kotlin/DBTest.kt | 67 ++++++---
15 files changed, 358 insertions(+), 190 deletions(-)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
index f8ce7bd4..b1e47d3d 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
@@ -387,7 +387,6 @@ class FacadeEntity(id: EntityID<Long>) : LongEntity(id) {
return find { FacadesTable.facadeName eq name}.firstOrNull()
}
}
-
var facadeName by FacadesTable.facadeName
var type by FacadesTable.type
var creator by NexusUserEntity referencedOn FacadesTable.creator
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
index c9e73662..8f1d32f2 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
@@ -131,11 +131,14 @@ fun getComparisonOperator(delta: Int, start: Long, table:
IdTable<Long>): Op<Boo
}
}
-fun expectLong(param: String?): Long {
+fun expectLong(param: String?, allowNegative: Boolean = false): Long {
if (param == null) throw badRequest("'$param' is not Long")
- return try { param.toLong() } catch (e: Exception) {
+ val maybeLong = try { param.toLong() } catch (e: Exception) {
throw badRequest("'$param' is not Long")
}
+ if (!allowNegative && maybeLong < 0)
+ throw badRequest("Not expecting a negative: $param")
+ return maybeLong
}
// Helper handling 'start' being optional and its dependence on 'delta'.
@@ -480,7 +483,7 @@ private suspend fun historyOutgoing(call: ApplicationCall) {
// Handle a /taler-wire-gateway/history/incoming request.
private suspend fun historyIncoming(call: ApplicationCall) {
val facadeId = expectNonNull(call.parameters["fcid"])
- val username = call.request.requirePermission(
+ call.request.requirePermission(
PermissionQuery(
"facade",
facadeId,
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
index 2632100f..12b0dd26 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
@@ -190,7 +190,7 @@ val nexusApp: Application.() -> Unit = {
)
}
exception<UtilError> { call, cause ->
- logger.error("Exception while handling '${call.request.uri}'",
cause.message)
+ logger.error("Exception while handling '${call.request.uri}':
${cause.message}")
call.respond(
cause.statusCode,
message = ErrorResponse(
diff --git a/nexus/src/test/kotlin/MakeEnv.kt b/nexus/src/test/kotlin/MakeEnv.kt
index 31cc5704..9f8f5249 100644
--- a/nexus/src/test/kotlin/MakeEnv.kt
+++ b/nexus/src/test/kotlin/MakeEnv.kt
@@ -22,8 +22,6 @@ data class EbicsKeys(
val enc: CryptoUtil.RsaCrtKeyPair,
val sig: CryptoUtil.RsaCrtKeyPair
)
-const val TEST_DB_FILE = "/tmp/nexus-test.sqlite3"
-// const val TEST_DB_CONN = "jdbc:sqlite:$TEST_DB_FILE"
// Convenience DB connection to switch to Postgresql:
val currentUser = System.getProperty("user.name")
val TEST_DB_CONN =
"jdbc:postgresql://localhost:5432/libeufincheck?user=$currentUser"
@@ -63,23 +61,11 @@ inline fun <reified ExceptionType> assertException(
* Cleans up the DB file afterwards.
*/
fun withTestDatabase(f: () -> Unit) {
- File(TEST_DB_FILE).also {
- if (it.exists()) {
- it.delete()
- }
- }
Database.connect(TEST_DB_CONN)
TransactionManager.manager.defaultIsolationLevel =
java.sql.Connection.TRANSACTION_SERIALIZABLE
dbDropTables(TEST_DB_CONN)
tech.libeufin.sandbox.dbDropTables(TEST_DB_CONN)
- try { f() }
- finally {
- File(TEST_DB_FILE).also {
- if (it.exists()) {
- it.delete()
- }
- }
- }
+ f()
}
val reportSpec: String = jacksonObjectMapper().
@@ -169,19 +155,21 @@ fun prepNexusDb() {
}
}
-fun prepSandboxDb() {
+fun prepSandboxDb(usersDebtLimit: Int = 1000) {
tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN)
transaction {
- val demoBank = DemobankConfigEntity.new {
- currency = "TESTKUDOS"
- bankDebtLimit = 10000
- usersDebtLimit = 1000
- allowRegistrations = true
- name = "default"
- this.withSignupBonus = false
- captchaUrl = "http://example.com/" // unused
+ val config = DemobankConfig(
+ currency = "TESTKUDOS",
+ bankDebtLimit = 10000,
+ usersDebtLimit = usersDebtLimit,
+ allowRegistrations = true,
+ demobankName = "default",
+ withSignupBonus = false,
+ captchaUrl = "http://example.com/",
suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}"
- }
+ )
+ insertConfigPairs(config)
+ val demoBank = DemobankConfigEntity.new { name = "default" }
BankAccountEntity.new {
iban = BANK_IBAN
label = "admin" // used by the wire helper
@@ -275,15 +263,17 @@ fun withSandboxTestDatabase(f: () -> Unit) {
withTestDatabase {
tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN)
transaction {
- val d = DemobankConfigEntity.new {
- currency = "TESTKUDOS"
- bankDebtLimit = 10000
- usersDebtLimit = 1000
- allowRegistrations = true
- name = "default"
- this.withSignupBonus = false
+ val config = DemobankConfig(
+ currency = "TESTKUDOS",
+ bankDebtLimit = 10000,
+ usersDebtLimit = 1000,
+ allowRegistrations = true,
+ demobankName = "default",
+ withSignupBonus = false,
captchaUrl = "http://example.com/" // unused
- }
+ )
+ insertConfigPairs(config)
+ val d = DemobankConfigEntity.new { name = "default" }
// admin's bank account.
BankAccountEntity.new {
iban = BANK_IBAN
@@ -299,7 +289,7 @@ fun withSandboxTestDatabase(f: () -> Unit) {
fun newNexusBankTransaction(currency: String, value: String, subject: String) {
transaction {
- val inc = NexusBankTransactionEntity.new {
+ NexusBankTransactionEntity.new {
bankAccount = NexusBankAccountEntity.findByName("foo")!!
accountTransactionId = "mock"
creditDebitIndicator = "CRDT"
diff --git a/nexus/src/test/kotlin/SandboxAccessApiTest.kt
b/nexus/src/test/kotlin/SandboxAccessApiTest.kt
index e163a1bb..f924a5fe 100644
--- a/nexus/src/test/kotlin/SandboxAccessApiTest.kt
+++ b/nexus/src/test/kotlin/SandboxAccessApiTest.kt
@@ -5,6 +5,7 @@ import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.coroutines.runBlocking
+import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import org.junit.Test
import tech.libeufin.sandbox.*
@@ -66,11 +67,9 @@ class SandboxAccessApiTest {
@Test
fun highAmountWithdraw() {
withTestDatabase {
- prepSandboxDb()
- val b = getDefaultDemobank()
+ prepSandboxDb(usersDebtLimit = 900000000)
testApplication {
application(sandboxApp)
- transaction { b.usersDebtLimit = 900000000 }
// Create the operation.
val r =
client.post("/demobanks/default/access-api/accounts/foo/withdrawals") {
expectSuccess = true
diff --git a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
b/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
index 350c5e8e..0de4121c 100644
--- a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
+++ b/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
@@ -5,6 +5,7 @@ import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.coroutines.runBlocking
+import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.lowerCase
import org.jetbrains.exposed.sql.transactions.transaction
import org.junit.Ignore
@@ -221,8 +222,15 @@ class SandboxCircuitApiTest {
""".trimIndent())
}
// Give initial balance to the new account.
- val demobank = getDefaultDemobank()
- transaction { demobank.usersDebtLimit = 0 }
+ // Forcing different debt limit:
+ transaction {
+ val configRaw = DemobankConfigPairEntity.find {
+ DemobankConfigPairsTable.demobankName eq "default" and(
+ DemobankConfigPairsTable.configKey eq
"usersDebtLimit"
+ )
+ }.first()
+ configRaw.configValue = 0.toString()
+ }
val initialBalance = "TESTKUDOS:50.00"
val balanceAfterCashout = "TESTKUDOS:30.00"
wireTransfer(
@@ -514,7 +522,7 @@ class SandboxCircuitApiTest {
uCustomerProfile.delete()
}
val barBalanceUpdate = getBalance("bar")
- assert(barBalance == BigDecimal("3"))
+ assert(barBalanceUpdate == BigDecimal("3"))
}
}
}
diff --git a/nexus/src/test/kotlin/TalerTest.kt
b/nexus/src/test/kotlin/TalerTest.kt
index 7eadeb25..22493606 100644
--- a/nexus/src/test/kotlin/TalerTest.kt
+++ b/nexus/src/test/kotlin/TalerTest.kt
@@ -32,11 +32,13 @@ class TalerTest {
application(nexusApp)
runBlocking {
launch {
- val r =
client.get("/facades/taler/taler-wire-gateway/history/incoming?delta=5") {
+ val r =
client.get("/facades/taler/taler-wire-gateway/history/incoming?delta=5&start=0&long_poll_ms=3000")
{
expectSuccess = false
contentType(ContentType.Application.Json)
basicAuth("foo", "foo")
}
+ println("maybe response body: ${r.bodyAsText()}")
+ assert(r.status.value == HttpStatusCode.OK.value)
val j = mapper.readTree(r.readBytes())
val reservePubFromTwg =
j.get("incoming_transactions").get(0).get("reserve_pub").asText()
assert(reservePubFromTwg == reservePub)
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
index 2c325ccc..8ddbab47 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
@@ -371,9 +371,9 @@ fun circuitApi(circuitRoute: Route) {
val amountCredit = parseAmount(req.amount_credit) // amount after
rates, as expected by the client
val demobank = ensureDemobank(call)
// Currency check of the cash-out's circuit part.
- if (amountDebit.currency != demobank.currency)
+ if (amountDebit.currency != demobank.config.currency)
throw badRequest("'${req::amount_debit.name}'
(${req.amount_debit})" +
- " doesn't match the regional currency
(${demobank.currency})"
+ " doesn't match the regional currency
(${demobank.config.currency})"
)
// Currency check of the cash-out's fiat part.
if (amountCredit.currency != FIAT_CURRENCY)
@@ -415,7 +415,7 @@ fun circuitApi(circuitRoute: Route) {
// check that the balance is sufficient
val balance = getBalance(user, withPending = true)
val balanceCheck = balance - amountDebitAsNumber
- if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() >
BigDecimal(demobank.usersDebtLimit))
+ if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() >
BigDecimal(demobank.config.usersDebtLimit))
throw SandboxError(
HttpStatusCode.PreconditionFailed,
"Cash-out not possible due to insufficient funds. Balance
${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}"
@@ -567,7 +567,7 @@ fun circuitApi(circuitRoute: Route) {
val name = it.name
val balance = getBalanceForJson(
getBalance(it.username),
- getDefaultDemobank().currency
+ getDefaultDemobank().config.currency
)
val debitThreshold = getMaxDebitForUser(it.username)
})
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
index 3281b67a..00688082 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
@@ -19,6 +19,7 @@
package tech.libeufin.sandbox
+import io.ktor.http.*
import org.jetbrains.exposed.dao.Entity
import org.jetbrains.exposed.dao.EntityClass
import org.jetbrains.exposed.dao.IntEntity
@@ -32,7 +33,10 @@ import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.transaction
+import tech.libeufin.util.internalServerError
import java.sql.Connection
+import kotlin.reflect.*
+import kotlin.reflect.full.*
/**
* All the states to give a subscriber.
@@ -87,29 +91,122 @@ enum class KeyState {
RELEASED
}
+/**
+ * Stores one config object to the database. Each field
+ * name and value populate respectively the configKey and
+ * configValue columns. Rows are defined in the following way:
+ * demobankName | configKey | configValue
+ */
+fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) {
+ // Fill the config key-value pairs in the DB.
+ config::class.declaredMemberProperties.forEach { configField ->
+ val maybeValue = configField.getter.call(config)
+ if (override) {
+ val maybeConfigPair = DemobankConfigPairEntity.find {
+ DemobankConfigPairsTable.configKey eq configField.name
+ }.firstOrNull()
+ if (maybeConfigPair == null)
+ throw internalServerError("Cannot override config value
'${configField.name}' not found.")
+ maybeConfigPair.configValue = maybeValue?.toString()
+ return@forEach
+ }
+ DemobankConfigPairEntity.new {
+ this.demobankName = config.demobankName
+ this.configKey = configField.name
+ this.configValue = maybeValue?.toString()
+ }
+ }
+}
+
+object DemobankConfigPairsTable : LongIdTable() {
+ val demobankName = text("demobankName")
+ val configKey = text("configKey")
+ val configValue = text("configValue").nullable()
+}
+
+class DemobankConfigPairEntity(id: EntityID<Long>) : LongEntity(id) {
+ companion object :
LongEntityClass<DemobankConfigPairEntity>(DemobankConfigPairsTable)
+ var demobankName by DemobankConfigPairsTable.demobankName
+ var configKey by DemobankConfigPairsTable.configKey
+ var configValue by DemobankConfigPairsTable.configValue
+}
+
object DemobankConfigsTable : LongIdTable() {
- val currency = text("currency")
- val allowRegistrations = bool("allowRegistrations")
- val withSignupBonus = bool("withSignupBonus")
- val bankDebtLimit = integer("bankDebtLimit")
- val usersDebtLimit = integer("usersDebtLimit")
val name = text("hostname")
- val suggestedExchangeBaseUrl = text("suggestedExchangeBaseUrl").nullable()
- val suggestedExchangePayto = text("suggestedExchangePayto").nullable()
- val captchaUrl = text("captchaUrl").nullable()
+}
+
+// Helpers for handling config values in memory.
+typealias DemobankConfigKey = String
+typealias DemobankConfigValue = String?
+fun Pair<DemobankConfigKey, DemobankConfigValue>.expectValue(): String {
+ if (this.second == null) throw internalServerError("Config value for
'${this.first}' is null in the database.")
+ return this.second as String
}
class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) {
companion object :
LongEntityClass<DemobankConfigEntity>(DemobankConfigsTable)
- var currency by DemobankConfigsTable.currency
- var allowRegistrations by DemobankConfigsTable.allowRegistrations
- var withSignupBonus by DemobankConfigsTable.withSignupBonus
- var bankDebtLimit by DemobankConfigsTable.bankDebtLimit
- var usersDebtLimit by DemobankConfigsTable.usersDebtLimit
var name by DemobankConfigsTable.name
- var captchaUrl by DemobankConfigsTable.captchaUrl
- var suggestedExchangeBaseUrl by
DemobankConfigsTable.suggestedExchangeBaseUrl
- var suggestedExchangePayto by DemobankConfigsTable.suggestedExchangePayto
+ /**
+ * This object gets defined by parsing all the configuration
+ * values found in the DB for one demobank. Those values are
+ * retrieved from _another_ table.
+ */
+ val config: DemobankConfig by lazy {
+ // Getting all the values for this demobank.
+ val configPairs: List<Pair<DemobankConfigKey, DemobankConfigValue>> =
transaction {
+ val maybeConfigPairs = DemobankConfigPairEntity.find {
+ DemobankConfigPairsTable.demobankName.eq(name)
+ }
+ if (maybeConfigPairs.empty()) throw SandboxError(
+ HttpStatusCode.InternalServerError,
+ "No config values of $name were found in the database"
+ )
+ // Copying results to a DB-agnostic list, to later operate out of
"transaction {}"
+ maybeConfigPairs.map { Pair(it.configKey, it.configValue) }
+ }
+ // Building the args to instantiate a DemobankConfig (non-Exposed)
object.
+ val args = mutableMapOf<KParameter, Any?>()
+ // For each constructor parameter name, find the same-named database
entry.
+ val configClass = DemobankConfig::class
+ if (configClass.primaryConstructor == null) {
+ throw SandboxError(
+ HttpStatusCode.InternalServerError,
+ "${configClass.simpleName} primaryConstructor is null."
+ )
+ }
+ if (configClass.primaryConstructor?.parameters == null) {
+ throw SandboxError(
+ HttpStatusCode.InternalServerError,
+ "${configClass.simpleName} primaryConstructor" +
+ " arguments is null. Cannot set any config value."
+ )
+ }
+ // For each field in the config object, find the respective DB row.
+ configClass.primaryConstructor?.parameters?.forEach { par: KParameter
->
+ val configPairFromDb: Pair<DemobankConfigKey, DemobankConfigValue>?
+ = configPairs.firstOrNull {
+ configPair: Pair<DemobankConfigKey, DemobankConfigValue> ->
+ configPair.first == par.name
+ }
+ if (configPairFromDb == null) {
+ throw SandboxError(
+ HttpStatusCode.InternalServerError,
+ "Config key '${par.name}' not found in the database."
+ )
+ }
+ when(par.type) {
+ // non-nullable
+ typeOf<Boolean>() -> { args[par] =
configPairFromDb.expectValue().toBoolean() }
+ typeOf<Int>() -> { args[par] =
configPairFromDb.expectValue().toInt() }
+ // nullable
+ typeOf<Boolean?>() -> { args[par] =
configPairFromDb.second?.toBoolean() }
+ typeOf<Int?>() -> { args[par] =
configPairFromDb.second?.toInt() }
+ else -> args[par] = configPairFromDb.second
+ }
+ }
+ // Proceeding now to instantiate the config class, and make it a field
of this type.
+ configClass.primaryConstructor!!.callBy(args)
+ }
}
/**
@@ -538,6 +635,7 @@ fun dbDropTables(dbConnectionString: String) {
BankAccountReportsTable,
BankAccountStatementsTable,
DemobankConfigsTable,
+ DemobankConfigPairsTable,
TalerWithdrawalsTable,
DemobankCustomersTable,
CashoutOperationsTable
@@ -551,6 +649,7 @@ fun dbCreateTables(dbConnectionString: String) {
transaction {
SchemaUtils.create(
DemobankConfigsTable,
+ DemobankConfigPairsTable,
EbicsSubscribersTable,
EbicsHostsTable,
EbicsDownloadTransactionsTable,
diff --git
a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
index 86bb8f18..798a7761 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
@@ -300,7 +300,7 @@ fun buildCamtString(
val zonedDateTime = camtCreationTime.toZonedString()
val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli()
val messageId = "sandbox-${creationTimeMillis /
1000}-${getRandomString(10)}"
- val currency = getDefaultDemobank().currency
+ val currency = getDefaultDemobank().config.currency
val camtMessage = constructXml(indent = true) {
root("Document") {
@@ -741,7 +741,7 @@ private fun handleCct(paymentRequest: String,
return@transaction
}
val bankAccount = getBankAccountFromIban(parseResult.debtorIban)
- if (parseResult.currency != bankAccount.demoBank.currency) throw
EbicsRequestError(
+ if (parseResult.currency != bankAccount.demoBank.config.currency)
throw EbicsRequestError(
"[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not
supported.",
"091116"
)
@@ -1034,7 +1034,7 @@ private fun makePartnerInfo(subscriber:
EbicsSubscriberEntity): EbicsTypes.Partn
this.value = bankAccount.iban
}
)
- this.currency = bankAccount.demoBank.currency
+ this.currency = bankAccount.demoBank.config.currency
this.description = "Ordinary Bank Account"
this.bankCodeList = listOf(
EbicsTypes.GeneralBankCode().apply {
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
index 16f1d6b9..912c2ee6 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
@@ -19,6 +19,8 @@
package tech.libeufin.sandbox
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.SerializationFeature
import io.ktor.server.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.*
@@ -30,6 +32,20 @@ import java.security.interfaces.RSAPublicKey
import java.util.*
import java.util.zip.DeflaterInputStream
+data class DemobankConfig(
+ val allowRegistrations: Boolean,
+ val currency: String,
+ val bankDebtLimit: Int,
+ val usersDebtLimit: Int,
+ val withSignupBonus: Boolean,
+ val demobankName: String, // demobank name.
+ val captchaUrl: String? = null,
+ val smsTan: String? = null, // fixme: move the config subcommand
+ val emailTan: String? = null, // fixme: same as above.
+ val suggestedExchangeBaseUrl: String? = null,
+ val suggestedExchangePayto: String? = null
+)
+
/**
* Helps to communicate Camt values without having
* to parse the XML each time one is needed.
@@ -120,8 +136,8 @@ fun insertNewAccount(username: String,
this.demoBank = demobankFromDb
this.isPublic = isPublic
}
- if (demobankFromDb.withSignupBonus)
- newBankAccount.bonus("${demobankFromDb.currency}:100")
+ if (demobankFromDb.config.withSignupBonus)
+ newBankAccount.bonus("${demobankFromDb.config.currency}:100")
AccountPair(customer = newCustomer, bankAccount = newBankAccount)
}
}
@@ -220,6 +236,24 @@ fun getHistoryElementFromTransactionRow(dbRow:
BankAccountTransactionEntity): Ra
)
}
+fun printConfig(demobank: DemobankConfigEntity) {
+ val ret = ObjectMapper()
+ ret.configure(SerializationFeature.INDENT_OUTPUT, true)
+ println(
+ ret.writeValueAsString(object {
+ val currency = demobank.config.currency
+ val bankDebtLimit = demobank.config.bankDebtLimit
+ val usersDebtLimit = demobank.config.usersDebtLimit
+ val allowRegistrations = demobank.config.allowRegistrations
+ val name = demobank.name // always 'default'
+ val withSignupBonus = demobank.config.withSignupBonus
+ val captchaUrl = demobank.config.captchaUrl
+ val suggestedExchangeBaseUrl =
demobank.config.suggestedExchangeBaseUrl
+ val suggestedExchangePayto = demobank.config.suggestedExchangePayto
+ })
+ )
+}
+
fun getHistoryElementFromTransactionRow(
dbRow: BankAccountFreshTransactionEntity
): RawPayment {
@@ -440,4 +474,4 @@ fun prepareEbicsPayload(
val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub)
return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc)
-}
\ No newline at end of file
+}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
index 1f02728c..a266ccdd 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
@@ -61,6 +61,10 @@ import java.math.BigDecimal
import java.net.URL
import java.security.interfaces.RSAPublicKey
import javax.xml.bind.JAXBContext
+import kotlin.reflect.KProperty0
+import kotlin.reflect.KProperty1
+import kotlin.reflect.full.declaredMemberProperties
+import kotlin.reflect.full.declaredMembers
import kotlin.system.exitProcess
val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox")
@@ -81,11 +85,7 @@ data class SandboxErrorJson(val error:
SandboxErrorDetailJson)
data class SandboxErrorDetailJson(val type: String, val description: String)
class DefaultExchange : CliktCommand("Set default Taler exchange for a
demobank.") {
- init {
- context {
- helpFormatter = CliktHelpFormatter(showDefaultValues = true)
- }
- }
+ init { context { helpFormatter = CliktHelpFormatter(showDefaultValues =
true) } }
private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of
the default exchange")
private val exchangePayto by argument("EXCHANGE-PAYTO", "default
exchange's payto-address")
private val demobank by option("--demobank", help = "Which demobank
defaults to EXCHANGE").default("default")
@@ -102,20 +102,40 @@ class DefaultExchange : CliktCommand("Set default Taler
exchange for a demobank.
System.err.println("Error, demobank $demobank not found.")
exitProcess(1)
}
- maybeDemobank.suggestedExchangeBaseUrl = exchangeBaseUrl
- maybeDemobank.suggestedExchangePayto = exchangePayto
+ val config = maybeDemobank.config
+ /**
+ * Iterating over the config object's field that hold the
exchange
+ * base URL and Payto. The iteration is only used to retrieve
the
+ * correct names of the DB column 'configKey', because this is
named
+ * after such fields.
+ */
+ listOf(
+ Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl),
+ Pair(config::suggestedExchangePayto, exchangePayto)
+ ).forEach {
+ val maybeConfigPair = DemobankConfigPairEntity.find {
+ DemobankConfigPairsTable.demobankName eq demobank and(
+ DemobankConfigPairsTable.configKey eq
it.first.name)
+ }.firstOrNull()
+ /**
+ * The DB doesn't contain any column to hold the exchange
URL
+ * or Payto, fail. That should never happen, because the
DB row
+ * are created _after_ the DemobankConfig object that
_does_ contain
+ * such fields.
+ */
+ if (maybeConfigPair == null) {
+ System.err.println("Config key '${it.first.name}' for
demobank '$demobank' not found in DB.")
+ exitProcess(1)
+ }
+ maybeConfigPair.configValue = it.second
+ }
}
}
}
}
class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into
the database.") {
- init {
- context {
- helpFormatter = CliktHelpFormatter(showDefaultValues = true)
- }
- }
-
+ init { context { helpFormatter = CliktHelpFormatter(showDefaultValues =
true) } }
private val nameArgument by argument(
"NAME", help = "Name of this configuration. Currently, only 'default'
is admitted."
)
@@ -147,40 +167,40 @@ class Config : CliktCommand("Insert one configuration
(a.k.a. demobank) into the
}
execThrowableOrTerminate {
dbCreateTables(dbConnString)
- transaction {
- val maybeDemobank = BankAccountEntity.find(
- BankAccountsTable.label eq "admin"
- ).firstOrNull()
- if (showOption) {
- if (maybeDemobank != null) {
- val ret = ObjectMapper()
- ret.configure(SerializationFeature.INDENT_OUTPUT, true)
- println(
- ret.writeValueAsString(object {
- val currency = maybeDemobank.demoBank.currency
- val bankDebtLimit =
maybeDemobank.demoBank.bankDebtLimit
- val usersDebtLimit =
maybeDemobank.demoBank.usersDebtLimit
- val allowRegistrations =
maybeDemobank.demoBank.allowRegistrations
- val name = maybeDemobank.demoBank.name //
always 'default'
- val withSignupBonus =
maybeDemobank.demoBank.withSignupBonus
- val captchaUrl =
maybeDemobank.demoBank.captchaUrl
- })
- )
- return@transaction
- }
- println("Nothing to show.")
- return@transaction
+ val maybeDemobank = transaction { getDemobank(nameArgument) }
+ if (showOption) {
+ if (maybeDemobank != null) {
+ printConfig(maybeDemobank)
+ } else {
+ println("Demobank: $nameArgument not found.")
+ System.exit(1)
}
- if (maybeDemobank == null) {
- val demoBank = DemobankConfigEntity.new {
- currency = currencyOption
- bankDebtLimit = bankDebtLimitOption
- usersDebtLimit = usersDebtLimitOption
- allowRegistrations = allowRegistrationsOption
- name = nameArgument
- this.withSignupBonus = withSignupBonusOption
- captchaUrl = captchaUrlOption
- }
+ return@execThrowableOrTerminate
+ }
+ if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) {
+ System.err.println("Debt numbers can't be negative.")
+ exitProcess(1)
+ }
+ // The user asks to _set_ values, regardless of overriding or
creating.
+ val config = DemobankConfig(
+ currency = currencyOption,
+ bankDebtLimit = bankDebtLimitOption,
+ usersDebtLimit = usersDebtLimitOption,
+ allowRegistrations = allowRegistrationsOption,
+ demobankName = nameArgument,
+ withSignupBonus = withSignupBonusOption,
+ captchaUrl = captchaUrlOption
+ )
+ /**
+ * The demobank didn't exist. Now:
+ * 1, Store the config values in the database.
+ * 2, Store the demobank name in the database.
+ * 3, Create the admin bank account under this demobank.
+ */
+ if (maybeDemobank == null) {
+ transaction {
+ insertConfigPairs(config)
+ val demoBank = DemobankConfigEntity.new { this.name =
nameArgument }
BankAccountEntity.new {
iban = getIban()
label = "admin"
@@ -188,16 +208,10 @@ class Config : CliktCommand("Insert one configuration
(a.k.a. demobank) into the
// For now, the model assumes always one demobank
this.demoBank = demoBank
}
- return@transaction
}
- maybeDemobank.demoBank.currency = currencyOption
- maybeDemobank.demoBank.bankDebtLimit = bankDebtLimitOption
- maybeDemobank.demoBank.usersDebtLimit = usersDebtLimitOption
- maybeDemobank.demoBank.allowRegistrations =
allowRegistrationsOption
- maybeDemobank.demoBank.withSignupBonus = withSignupBonusOption
- maybeDemobank.demoBank.name = nameArgument
- maybeDemobank.demoBank.captchaUrl = captchaUrlOption
}
+ // Demobank exists: update its config values in the database.
+ else transaction { insertConfigPairs(config, override = true) }
}
}
}
@@ -391,10 +405,10 @@ class Serve : CliktCommand("Run sandbox HTTP server") {
private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank {
return Demobank(
- currency = fromDb.currency,
- userDebtLimit = fromDb.usersDebtLimit,
- bankDebtLimit = fromDb.bankDebtLimit,
- allowRegistrations = fromDb.allowRegistrations,
+ currency = fromDb.config.currency,
+ userDebtLimit = fromDb.config.usersDebtLimit,
+ bankDebtLimit = fromDb.config.bankDebtLimit,
+ allowRegistrations = fromDb.config.allowRegistrations,
name = fromDb.name
)
}
@@ -710,7 +724,7 @@ val sandboxApp: Application.() -> Unit = {
throw unauthorized("'${username}' has no rights over
'$label'")
val balance = getBalance(bankAccount, withPending = true)
object {
- val balance = "${bankAccount.demoBank.currency}:${balance}"
+ val balance =
"${bankAccount.demoBank.config.currency}:${balance}"
val iban = bankAccount.iban
val bic = bankAccount.bic
val label = bankAccount.label
@@ -755,7 +769,7 @@ val sandboxApp: Application.() -> Unit = {
this.account = account
direction = "CRDT"
this.demobank = demobank
- this.currency = demobank.currency
+ this.currency = demobank.config.currency
}
}
call.respond(object {})
@@ -901,7 +915,7 @@ val sandboxApp: Application.() -> Unit = {
this.account = account
direction = "CRDT"
this.demobank = demobank
- currency = demobank.currency
+ currency = demobank.config.currency
}
}
@@ -922,7 +936,7 @@ val sandboxApp: Application.() -> Unit = {
this.account = account
direction = "DBIT"
this.demobank = demobank
- currency = demobank.currency
+ currency = demobank.config.currency
}
}
}
@@ -939,7 +953,7 @@ val sandboxApp: Application.() -> Unit = {
val body = call.receive<EbicsSubscriberObsoleteApi>()
transaction {
// Check the host ID exists.
- val maybeHostId = EbicsHostEntity.find {
+ EbicsHostEntity.find {
EbicsHostsTable.hostID eq body.hostID
}.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not
found.")
// Check it exists first.
@@ -1086,6 +1100,7 @@ val sandboxApp: Application.() -> Unit = {
throw e
}
catch (e: Exception) {
+ logger.error(e.stackTraceToString())
throw EbicsProcessingError("Could not map error to EBICS code:
$e")
}
return@post
@@ -1168,7 +1183,7 @@ val sandboxApp: Application.() -> Unit = {
call.respond(SandboxConfig(
name = "taler-bank-integration",
version = SANDBOX_VERSION,
- currency = demobank.currency
+ currency = demobank.config.currency
))
return@get
}
@@ -1223,13 +1238,13 @@ val sandboxApp: Application.() -> Unit = {
)
}
val demobank = ensureDemobank(call)
- var captcha_page = demobank.captchaUrl
+ var captcha_page = demobank.config.captchaUrl
if (captcha_page == null) logger.warn("CAPTCHA URL not
found")
val ret = TalerWithdrawalStatus(
selection_done = maybeWithdrawalOp.selectionDone,
transfer_done = maybeWithdrawalOp.confirmationDone,
amount = maybeWithdrawalOp.amount,
- suggested_exchange = demobank.suggestedExchangeBaseUrl,
+ suggested_exchange =
demobank.config.suggestedExchangeBaseUrl,
aborted = maybeWithdrawalOp.aborted,
confirm_transfer_url = captcha_page
)
@@ -1305,8 +1320,8 @@ val sandboxApp: Application.() -> Unit = {
val req = call.receive<WithdrawalRequest>()
// Check for currency consistency
val amount = parseAmount(req.amount)
- if (amount.currency != demobank.currency)
- throw badRequest("Currency ${amount.currency} differs
from Demobank's: ${demobank.currency}")
+ if (amount.currency != demobank.config.currency)
+ throw badRequest("Currency ${amount.currency} differs
from Demobank's: ${demobank.config.currency}")
// Check funds are sufficient.
if (maybeDebit(maybeOwnedAccount.label,
BigDecimal(amount.amount))) {
logger.error("Account ${maybeOwnedAccount.label} would
surpass debit threshold. Not withdrawing")
@@ -1373,7 +1388,7 @@ val sandboxApp: Application.() -> Unit = {
*/
val demobank = ensureDemobank(call)
if (wo.selectedExchangePayto == null) {
- wo.selectedExchangePayto =
demobank.suggestedExchangePayto
+ wo.selectedExchangePayto =
demobank.config.suggestedExchangePayto
}
val exchangeBankAccount = getBankAccountFromPayto(
wo.selectedExchangePayto ?: throw
internalServerError(
@@ -1422,7 +1437,7 @@ val sandboxApp: Application.() -> Unit = {
val balance = getBalance(bankAccount, withPending = true)
call.respond(object {
val balance = object {
- val amount =
"${demobank.currency}:${balance.abs(). toPlainString()}"
+ val amount =
"${demobank.config.currency}:${balance.abs(). toPlainString()}"
val credit_debit_indicator = if (balance <
BigDecimal.ZERO) "debit" else "credit"
}
val paytoUri = buildIbanPaytoUri(
@@ -1524,7 +1539,7 @@ val sandboxApp: Application.() -> Unit = {
val balanceIter = getBalance(it, withPending =
true)
ret.publicAccounts.add(
PublicAccountInfo(
- balance =
"${demobank.currency}:$balanceIter",
+ balance =
"${demobank.config.currency}:$balanceIter",
iban = it.iban,
accountLabel = it.label
)
@@ -1550,7 +1565,7 @@ val sandboxApp: Application.() -> Unit = {
post("/testing/register") {
// Check demobank was created.
val demobank = ensureDemobank(call)
- if (!demobank.allowRegistrations) {
+ if (!demobank.config.allowRegistrations) {
throw SandboxError(
HttpStatusCode.UnprocessableEntity,
"The bank doesn't allow new registrations at the
moment."
@@ -1566,7 +1581,7 @@ val sandboxApp: Application.() -> Unit = {
)
val balance = getBalance(newAccount.bankAccount,
withPending = true)
call.respond(object {
- val balance = getBalanceForJson(balance,
demobank.currency)
+ val balance = getBalanceForJson(balance,
demobank.config.currency)
val paytoUri = buildIbanPaytoUri(
iban = newAccount.bankAccount.iban,
bic = newAccount.bankAccount.bic,
@@ -1618,4 +1633,4 @@ val sandboxApp: Application.() -> Unit = {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
index 5bd45a6f..0e7e64f6 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
@@ -21,8 +21,8 @@ fun maybeDebit(
)
val balance = getBalance(accountLabel, withPending = true)
val maxDebt = if (accountLabel == "admin") {
- demobank.bankDebtLimit
- } else demobank.usersDebtLimit
+ demobank.config.bankDebtLimit
+ } else demobank.config.usersDebtLimit
val balanceCheck = balance - requestedAmount
if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() >
BigDecimal.valueOf(maxDebt.toLong())) {
logger.warn("User '$accountLabel' would surpass the debit" +
@@ -34,10 +34,8 @@ fun maybeDebit(
fun getMaxDebitForUser(username: String): Int {
val bank = getDefaultDemobank()
- if (username == "admin") return bank.bankDebtLimit
- return bank.usersDebtLimit
-
-
+ if (username == "admin") return bank.config.bankDebtLimit
+ return bank.config.usersDebtLimit
}
fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson {
@@ -45,7 +43,6 @@ fun getBalanceForJson(value: BigDecimal, currency: String):
BalanceJson {
amount = "${currency}:${value.abs()}",
credit_debit_indicator = if (value < BigDecimal.ZERO) "DBIT" else
"CRDT"
)
-
}
/**
@@ -147,10 +144,10 @@ fun wireTransfer(
val amountAsNumber = BigDecimal(parsedAmount.amount)
if (amountAsNumber == BigDecimal.ZERO)
throw badRequest("Wire transfers of zero not possible.")
- if (parsedAmount.currency != demobank.currency)
+ if (parsedAmount.currency != demobank.config.currency)
throw badRequest(
"Won't wire transfer with currency: ${parsedAmount.currency}." +
- " Only ${demobank.currency} allowed."
+ " Only ${demobank.config.currency} allowed."
)
// Check funds are sufficient.
if (maybeDebit(debitAccount.label, amountAsNumber)) {
@@ -169,7 +166,7 @@ fun wireTransfer(
debtorName = getPersonNameFromCustomer(debitAccount.owner)
this.subject = subject
this.amount = parsedAmount.amount
- this.currency = demobank.currency
+ this.currency = demobank.config.currency
date = timeStamp
accountServicerReference = transactionRef
account = creditAccount
@@ -186,7 +183,7 @@ fun wireTransfer(
debtorName = getPersonNameFromCustomer(debitAccount.owner)
this.subject = subject
this.amount = parsedAmount.amount
- this.currency = demobank.currency
+ this.currency = demobank.config.currency
date = timeStamp
accountServicerReference = transactionRef
account = debitAccount
diff --git a/sandbox/src/test/kotlin/BalanceTest.kt
b/sandbox/src/test/kotlin/BalanceTest.kt
index 48497e98..cf9f3918 100644
--- a/sandbox/src/test/kotlin/BalanceTest.kt
+++ b/sandbox/src/test/kotlin/BalanceTest.kt
@@ -8,24 +8,21 @@ import java.math.BigDecimal
import java.time.LocalDateTime
class BalanceTest {
-
@Test
fun balanceTest() {
+ val config = DemobankConfig(
+ currency = "EUR",
+ bankDebtLimit = 1000000,
+ usersDebtLimit = 10000,
+ allowRegistrations = true,
+ demobankName = "default",
+ withSignupBonus = false
+ )
withTestDatabase {
transaction {
- SchemaUtils.create(
- BankAccountsTable,
- BankAccountTransactionsTable,
- BankAccountFreshTransactionsTable,
- BankAccountStatementsTable
- )
+ insertConfigPairs(config)
val demobank = DemobankConfigEntity.new {
- currency = "EUR"
- bankDebtLimit = 1000000
- usersDebtLimit = 10000
- allowRegistrations = true
name = "default"
- withSignupBonus = false
}
val one = BankAccountEntity.new {
iban = "IBAN 1"
diff --git a/sandbox/src/test/kotlin/DBTest.kt
b/sandbox/src/test/kotlin/DBTest.kt
index c74ebce1..c63efd6f 100644
--- a/sandbox/src/test/kotlin/DBTest.kt
+++ b/sandbox/src/test/kotlin/DBTest.kt
@@ -30,47 +30,72 @@ import java.time.LocalDateTime
* Cleans up the DB file afterwards.
*/
fun withTestDatabase(f: () -> Unit) {
- val dbfile = "jdbc:sqlite:/tmp/nexus-test.sqlite3"
- File(dbfile).also {
+ val dbFile = "/tmp/sandbox-test.sqlite3"
+ val dbConn = "jdbc:sqlite:${dbFile}"
+ File(dbFile).also {
if (it.exists()) {
it.delete()
}
}
- Database.connect("$dbfile")
- dbDropTables(dbfile)
- try {
- f()
- }
+ Database.connect(dbConn)
+ dbDropTables(dbConn)
+ dbCreateTables(dbConn)
+ try { f() }
finally {
- File(dbfile).also {
- if (it.exists()) {
+ File(dbFile).also {
+ if (it.exists())
it.delete()
- }
}
}
}
class DBTest {
+ private var config = DemobankConfig(
+ currency = "EUR",
+ bankDebtLimit = 1000000,
+ usersDebtLimit = 10000,
+ allowRegistrations = true,
+ demobankName = "default",
+ withSignupBonus = false,
+ )
+
+ /**
+ * Storing configuration values into the database,
+ * then extract them and check that they equal the
+ * configuration model object.
+ */
@Test
- fun exist() {
- println("x")
+ fun insertPairsTest() {
+ withTestDatabase {
+ // Config model.
+ val config = DemobankConfig(
+ currency = "EUR",
+ bankDebtLimit = 1,
+ usersDebtLimit = 2,
+ allowRegistrations = true,
+ demobankName = "default",
+ withSignupBonus = true
+ )
+ transaction {
+ DemobankConfigEntity.new { name = "default" }
+ insertConfigPairs(config)
+ val db = getDefaultDemobank()
+ /**
+ * db.config extracts config values from the database
+ * and puts them in a fresh config model object.
+ */
+ assert(config.hashCode() == db.config.hashCode())
+ }
+ }
}
@Test
fun betweenDates() {
withTestDatabase {
transaction {
- SchemaUtils.create(
- BankAccountTransactionsTable,
- BankAccountFreshTransactionsTable
- )
+ insertConfigPairs(config)
val demobank = DemobankConfigEntity.new {
- currency = "EUR"
- bankDebtLimit = 1000000
- usersDebtLimit = 10000
- allowRegistrations = true
name = "default"
- withSignupBonus = false
}
val bankAccount = BankAccountEntity.new {
iban = "iban"
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.
- [libeufin] branch master updated (0ee8d69d -> 7dfdeeda),
gnunet <=
- [libeufin] 05/05: Addressing #7515 (core change)., gnunet, 2023/03/13
- [libeufin] 04/05: Using #7515 constructs., gnunet, 2023/03/13
- [libeufin] 03/05: Testing #7515., gnunet, 2023/03/13
- [libeufin] 02/05: Adapt tests to #7515., gnunet, 2023/03/13
- [libeufin] 01/05: /history/incoming: no negative start param., gnunet, 2023/03/13