summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--API_CHANGES.md6
-rw-r--r--Makefile2
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Config.kt13
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Constants.kt5
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Main.kt49
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt59
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt27
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt81
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt1
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt30
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt5
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt3
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt10
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/helpers.kt16
-rw-r--r--bank/src/test/kotlin/AmountTest.kt1
-rw-r--r--bank/src/test/kotlin/ConversionApiTest.kt2
-rw-r--r--bank/src/test/kotlin/CoreBankApiTest.kt56
-rw-r--r--bank/src/test/kotlin/RevenueApiTest.kt1
-rw-r--r--bank/src/test/kotlin/StatsTest.kt4
-rw-r--r--bank/src/test/kotlin/WireGatewayApiTest.kt13
-rw-r--r--bank/src/test/kotlin/helpers.kt54
-rw-r--r--bank/src/test/kotlin/routines.kt122
-rw-r--r--common/src/main/kotlin/Cli.kt2
-rw-r--r--common/src/main/kotlin/Client.kt9
-rw-r--r--common/src/main/kotlin/Config.kt13
-rw-r--r--common/src/main/kotlin/Constants.kt3
-rw-r--r--common/src/main/kotlin/TalerCommon.kt94
-rw-r--r--common/src/main/kotlin/TalerConfig.kt4
-rw-r--r--common/src/main/kotlin/TalerErrorCode.kt378
-rw-r--r--common/src/main/kotlin/TalerMessage.kt28
-rw-r--r--common/src/main/kotlin/TxMedatada.kt10
-rw-r--r--common/src/main/kotlin/api/route.kt41
-rw-r--r--common/src/main/kotlin/api/server.kt24
-rw-r--r--common/src/main/kotlin/db/helpers.kt51
-rw-r--r--common/src/main/kotlin/db/notifications.kt7
-rw-r--r--common/src/main/kotlin/db/transaction.kt9
-rw-r--r--common/src/main/kotlin/db/types.kt20
-rw-r--r--common/src/main/kotlin/test/helpers.kt65
-rw-r--r--common/src/main/kotlin/test/routines.kt142
-rw-r--r--contrib/nexus.conf27
-rw-r--r--database-versioning/libeufin-bank-0004.sql25
-rw-r--r--database-versioning/libeufin-bank-procedures.sql44
-rw-r--r--database-versioning/libeufin-nexus-0001.sql14
-rw-r--r--database-versioning/libeufin-nexus-0003.sql36
-rw-r--r--database-versioning/libeufin-nexus-procedures.sql82
-rw-r--r--debian/changelog6
-rw-r--r--debian/control1
-rw-r--r--debian/libeufin-nexus.libeufin-nexus-httpd.service14
-rw-r--r--debian/libeufin-nexus.target1
-rwxr-xr-xdebian/rules3
-rw-r--r--nexus/conf/mini.conf15
-rw-r--r--nexus/conf/test.conf12
-rw-r--r--nexus/sample/platform/gls_camt052.xml314
-rw-r--r--nexus/sample/platform/gls_camt053.xml (renamed from nexus/sample/platform/gls.xml)0
-rw-r--r--nexus/sample/platform/gls_camt054.xml70
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt49
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt61
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt6
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt6
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt58
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt358
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt45
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt62
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt65
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt41
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt128
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt15
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt195
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt3
-rw-r--r--nexus/src/test/kotlin/CliTest.kt34
-rw-r--r--nexus/src/test/kotlin/DatabaseTest.kt53
-rw-r--r--nexus/src/test/kotlin/Iso20022Test.kt69
-rw-r--r--nexus/src/test/kotlin/RevenueApiTest.kt65
-rw-r--r--nexus/src/test/kotlin/WireGatewayApiTest.kt166
-rw-r--r--nexus/src/test/kotlin/helpers.kt117
-rw-r--r--nexus/src/test/kotlin/routines.kt73
-rw-r--r--testbench/src/main/kotlin/Main.kt6
-rw-r--r--testbench/src/test/kotlin/IntegrationTest.kt24
78 files changed, 2966 insertions, 857 deletions
diff --git a/API_CHANGES.md b/API_CHANGES.md
index 33d0cc51..54faa0d3 100644
--- a/API_CHANGES.md
+++ b/API_CHANGES.md
@@ -35,7 +35,7 @@ This files contains all the API changes for the current release:
- POST /accounts/USERNAME/cashouts: remove tan_channel field
- POST /accounts/USERNAME/cashouts/CASHOUT_ID: remove confirmation_time,
tan_channel, tan_info and status fields
-- POST /accounts/$USERNAME/cashouts: remove status field
+- POST /accounts/USERNAME/cashouts: remove status field
- POST /cashouts: remove status field
- PATCH /accounts/USERNAME: add tan_channel
- GET /accounts/USERNAME: add tan_channel
@@ -52,6 +52,10 @@ This files contains all the API changes for the current release:
- GET /accounts/USERNAME: new status field
- GET /monitor: new date_s params
- GET /config: new base_url field for the advertised base URL
+- POST /accounts: add min_cashout field for the custom minimum cashout amount
+- PATCH /accounts/USERNAME: add min_cashout field for the custom minimum cashout amount
+- GET /accounts: add min_cashout field for the custom minimum cashout amount
+- GET /accounts/USERNAME: add min_cashout field for the custom minimum cashout amount
## bank cli
diff --git a/Makefile b/Makefile
index 4d60926f..e02eb992 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
# This Makefile has been placed under the public domain
-include build-system/config.mk
+-include build-system/config.mk
# Default target, must be at the top.
# Should be changed with care to not break (Debian) packaging.
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
index 9cdfcf0f..17fef641 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
@@ -68,11 +68,6 @@ data class ConversionRate (
val cashout_min_amount: TalerAmount,
)
-sealed interface ServerConfig {
- data class Unix(val path: String, val mode: Int): ServerConfig
- data class Tcp(val addr: String, val port: Int): ServerConfig
-}
-
fun talerConfig(configPath: Path?): TalerConfig = BANK_CONFIG_SOURCE.fromFile(configPath)
fun TalerConfig.loadDbConfig(): DatabaseConfig {
@@ -82,14 +77,6 @@ fun TalerConfig.loadDbConfig(): DatabaseConfig {
)
}
-fun TalerConfig.loadServerConfig(): ServerConfig {
- return when (val method = requireString("libeufin-bank", "serve")) {
- "tcp" -> ServerConfig.Tcp(lookupString("libeufin-bank", "address") ?: requireString("libeufin-bank", "bind_to"), requireNumber("libeufin-bank", "port"))
- "unix" -> ServerConfig.Unix(requireString("libeufin-bank", "unixpath"), requireNumber("libeufin-bank", "unixpath_mode"))
- else -> throw TalerConfigError.invalid("server method", "libeufin-bank", "serve", "expected 'tcp' or 'unix' got '$method'")
- }
-}
-
fun TalerConfig.loadBankConfig(): BankConfig {
val regionalCurrency = requireString("libeufin-bank", "currency")
var fiatCurrency: String? = null
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
index d91d779d..209ea700 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -37,7 +37,6 @@ val RESERVED_ACCOUNTS = setOf("admin", "bank")
const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5
// API version
-const val COREBANK_API_VERSION: String = "4:7:0"
-const val CONVERSION_API_VERSION: String = "0:0:0"
+const val COREBANK_API_VERSION: String = "4:8:0"
+const val CONVERSION_API_VERSION: String = "0:1:0"
const val INTEGRATION_API_VERSION: String = "2:0:2"
-const val REVENUE_API_VERSION: String = "0:0:0" \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 8733b907..1f3ebbc8 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -62,8 +62,7 @@ import kotlin.io.path.exists
import kotlin.io.path.readText
private val logger: Logger = LoggerFactory.getLogger("libeufin-bank")
-// Dirty local variable to stop the server in test TODO remove this ugly hack
-var engine: ApplicationEngine? = null
+
/**
@@ -117,7 +116,7 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve")
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
val dbCfg = cfg.loadDbConfig()
- val serverCfg = cfg.loadServerConfig()
+ val serverCfg = cfg.loadServerConfig("libeufin-bank")
Database(dbCfg, ctx.regionalCurrency, ctx.fiatCurrency).use { db ->
if (ctx.allowConversion) {
logger.info("Ensure exchange account exists")
@@ -142,25 +141,9 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve")
db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
// Remove conversion info from the database ?
}
-
- val env = applicationEngineEnvironment {
- when (serverCfg) {
- is ServerConfig.Tcp -> {
- for (addr in InetAddress.getAllByName(serverCfg.addr)) {
- connector {
- port = serverCfg.port
- host = addr.hostAddress
- }
- }
- }
- is ServerConfig.Unix ->
- throw Exception("Can only serve libeufin-bank via TCP")
- }
- module { corebankWebApp(db, ctx) }
+ serve(serverCfg) {
+ corebankWebApp(db, ctx)
}
- val local = embeddedServer(Netty, env)
- engine = local
- local.start(wait = true)
}
}
}
@@ -216,7 +199,14 @@ class EditAccount : CliktCommand(
private val tan_channel: String? by option(help = "which channel TAN challenges should be sent to")
private val cashout_payto_uri: IbanPayto? by option(help = "Payto URI of a fiant account who receive cashout amount").convert { Payto.parse(it).expectIban() }
private val debit_threshold: TalerAmount? by option(help = "Max debit allowed for this account").convert { TalerAmount(it) }
-
+ private val min_cashout: Option<TalerAmount>? by option(help = "Custom minimum cashout amount for this account").convert {
+ if (it == "") {
+ Option.None
+ } else {
+ Option.Some(TalerAmount(it))
+ }
+ }
+
override fun run() = cliCmd(logger, common.log) {
val cfg = talerConfig(common.config)
val ctx = cfg.loadBankConfig()
@@ -232,7 +222,12 @@ class EditAccount : CliktCommand(
phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null),
),
cashout_payto_uri = Option.Some(cashout_payto_uri),
- debit_threshold = debit_threshold
+ debit_threshold = debit_threshold,
+ min_cashout = when (val tmp = min_cashout) {
+ null -> Option.None
+ is Option.None -> Option.Some(null)
+ is Option.Some -> Option.Some(tmp.value)
+ }
)
when (patchAccount(db, ctx, req, username, true, true)) {
AccountPatchResult.Success ->
@@ -244,6 +239,7 @@ class EditAccount : CliktCommand(
AccountPatchResult.NonAdminName,
AccountPatchResult.NonAdminCashout,
AccountPatchResult.NonAdminDebtLimit,
+ AccountPatchResult.NonAdminMinCashout,
is AccountPatchResult.TanRequired -> {
// Unreachable as we edit account as admin
}
@@ -282,6 +278,10 @@ class CreateAccountOption: OptionGroup() {
val debit_threshold: TalerAmount? by option(
help = "Max debit allowed for this account"
).convert { TalerAmount(it) }
+ val min_cashout: TalerAmount? by option(
+ help = "Custom minimum cashout amount for this account"
+ ).convert { TalerAmount(it) }
+
}
class CreateAccount : CliktCommand(
@@ -312,7 +312,8 @@ class CreateAccount : CliktCommand(
),
cashout_payto_uri = cashout_payto_uri,
payto_uri = payto_uri,
- debit_threshold = debit_threshold
+ debit_threshold = debit_threshold,
+ min_cashout = min_cashout
)
}
req?.let {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
index 5966cba9..57c3fda8 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
@@ -37,65 +37,6 @@ import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
-@Serializable(with = DecimalNumber.Serializer::class)
-class DecimalNumber {
- val value: Long
- val frac: Int
-
- constructor(value: Long, frac: Int) {
- this.value = value
- this.frac = frac
- }
- constructor(encoded: String) {
- val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format")
- val (value, frac) = match.destructured
- this.value = value.toLongOrNull() ?: throw badRequest("Invalid value")
- if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large")
- this.frac = if (frac.isEmpty()) {
- 0
- } else {
- var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value")
- repeat(8 - frac.length) {
- tmp *= 10
- }
- tmp
- }
- }
-
- override fun equals(other: Any?): Boolean {
- return other is DecimalNumber &&
- other.value == this.value &&
- other.frac == this.frac
- }
-
- override fun toString(): String {
- if (frac == 0) {
- return "$value"
- } else {
- return "$value.${frac.toString().padStart(8, '0')}"
- .dropLastWhile { it == '0' } // Trim useless fractional trailing 0
- }
- }
-
- internal object Serializer : KSerializer<DecimalNumber> {
- override val descriptor: SerialDescriptor =
- PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING)
-
- override fun serialize(encoder: Encoder, value: DecimalNumber) {
- encoder.encodeString(value.toString())
- }
-
- override fun deserialize(decoder: Decoder): DecimalNumber {
- return DecimalNumber(decoder.decodeString())
- }
- }
-
- companion object {
- private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?")
- }
-}
-
-
/**
* Internal representation of relative times. The
* "forever" case is represented with Long.MAX_VALUE.
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
index 2527bae6..cca56e57 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -182,6 +182,7 @@ data class RegisterAccountRequest(
val cashout_payto_uri: IbanPayto? = null,
val payto_uri: Payto? = null,
val debit_threshold: TalerAmount? = null,
+ val min_cashout: TalerAmount? = null,
val tan_channel: TanChannel? = null,
) {
init {
@@ -209,6 +210,7 @@ data class AccountReconfiguration(
val name: String? = null,
val is_public: Boolean? = null,
val debit_threshold: TalerAmount? = null,
+ val min_cashout: Option<TalerAmount?> = Option.None,
val tan_channel: Option<TanChannel?> = Option.None,
val is_taler_exchange: Boolean? = null,
)
@@ -327,14 +329,6 @@ data class TalerIntegrationConfigResponse(
val version: String = INTEGRATION_API_VERSION
}
-@Serializable
-data class RevenueConfig(
- val currency: String
-) {
- val name: String = "taler-revenue"
- val version: String = REVENUE_API_VERSION
-}
-
enum class CreditDebitInfo {
credit, debit
}
@@ -355,6 +349,7 @@ data class AccountMinimalData(
val payto_uri: String,
val balance: Balance,
val debit_threshold: TalerAmount,
+ val min_cashout: TalerAmount? = null,
val is_public: Boolean,
val is_taler_exchange: Boolean,
val row_id: Long,
@@ -378,6 +373,7 @@ data class AccountData(
val balance: Balance,
val payto_uri: String,
val debit_threshold: TalerAmount,
+ val min_cashout: TalerAmount? = null,
val contact_data: ChallengeContactData? = null,
val cashout_payto_uri: String? = null,
val tan_channel: TanChannel? = null,
@@ -546,21 +542,6 @@ data class ConversionResponse(
val amount_credit: TalerAmount,
)
-@Serializable
-data class RevenueIncomingHistory(
- val incoming_transactions : List<RevenueIncomingBankTransaction>,
- val credit_account: String
-)
-
-@Serializable
-data class RevenueIncomingBankTransaction(
- val row_id: Long,
- val date: TalerProtocolTimestamp,
- val amount: TalerAmount,
- val debit_account: String,
- val subject: String
-)
-
/**
* Response to GET /public-accounts
*/
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
index c659b0f0..db8c2cc8 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -160,6 +160,12 @@ suspend fun createAccount(
"only admin account can choose the debit limit",
TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
)
+
+ if (req.min_cashout != null)
+ throw conflict(
+ "only admin account can choose the minimum cashout amount",
+ TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT
+ )
if (req.tan_channel != null)
throw conflict(
@@ -188,31 +194,33 @@ suspend fun createAccount(
TalerErrorCode.END
)
+ suspend fun doDb(internalPayto: Payto) = db.account.create(
+ login = req.username,
+ name = req.name,
+ email = req.contact_data?.email?.get(),
+ phone = req.contact_data?.phone?.get(),
+ cashoutPayto = req.cashout_payto_uri,
+ password = req.password,
+ internalPayto = internalPayto,
+ isPublic = req.is_public,
+ isTalerExchange = req.is_taler_exchange,
+ maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit,
+ bonus = if (!req.is_taler_exchange) cfg.registrationBonus
+ else TalerAmount(0, 0, cfg.regionalCurrency),
+ tanChannel = req.tan_channel,
+ checkPaytoIdempotent = req.payto_uri != null,
+ ctx = cfg.payto,
+ minCashout = req.min_cashout
+ )
+
when (cfg.wireMethod) {
WireMethod.IBAN -> {
- if (req.payto_uri != null && !(req.payto_uri is IbanPayto))
- throw badRequest("Expected an IBAN payto uri")
+ req.payto_uri?.expectRequestIban()
var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0
while (true) {
val internalPayto = req.payto_uri ?: IbanPayto.rand() as Payto
- val res = db.account.create(
- login = req.username,
- name = req.name,
- email = req.contact_data?.email?.get(),
- phone = req.contact_data?.phone?.get(),
- cashoutPayto = req.cashout_payto_uri,
- password = req.password,
- internalPayto = internalPayto,
- isPublic = req.is_public,
- isTalerExchange = req.is_taler_exchange,
- maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit,
- bonus = if (!req.is_taler_exchange) cfg.registrationBonus
- else TalerAmount(0, 0, cfg.regionalCurrency),
- tanChannel = req.tan_channel,
- checkPaytoIdempotent = req.payto_uri != null,
- ctx = cfg.payto
- )
+ val res = doDb(internalPayto)
// Retry with new IBAN
if (res == AccountCreationResult.PayToReuse && retry > 0) {
retry--
@@ -223,31 +231,13 @@ suspend fun createAccount(
}
WireMethod.X_TALER_BANK -> {
if (req.payto_uri != null) {
- if (!(req.payto_uri is XTalerBankPayto))
- throw badRequest("Expected an IBAN payto uri")
- else if (req.payto_uri.username != req.username)
- throw badRequest("Expected a payto uri for '${req.username}' got one for '${req.payto_uri.username}'")
+ val payto = req.payto_uri.expectRequestXTalerBank()
+ if (payto.username != req.username)
+ throw badRequest("Expected a payto uri for '${req.username}' got one for '${payto.username}'")
}
val internalPayto = XTalerBankPayto.forUsername(req.username)
-
- return db.account.create(
- login = req.username,
- name = req.name,
- email = req.contact_data?.email?.get(),
- phone = req.contact_data?.phone?.get(),
- cashoutPayto = req.cashout_payto_uri,
- password = req.password,
- internalPayto = internalPayto,
- isPublic = req.is_public,
- isTalerExchange = req.is_taler_exchange,
- maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit,
- bonus = if (!req.is_taler_exchange) cfg.registrationBonus
- else TalerAmount(0, 0, cfg.regionalCurrency),
- tanChannel = req.tan_channel,
- checkPaytoIdempotent = req.payto_uri != null,
- ctx = cfg.payto
- )
+ return doDb(internalPayto)
}
}
}
@@ -283,6 +273,7 @@ suspend fun patchAccount(
tan_channel = req.tan_channel,
isPublic = req.is_public,
debtLimit = req.debit_threshold,
+ minCashout = req.min_cashout,
isAdmin = isAdmin,
is2fa = is2fa,
faChannel = channel,
@@ -367,6 +358,10 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
"non-admin user cannot change their debt limit",
TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
)
+ AccountPatchResult.NonAdminMinCashout -> throw conflict(
+ "non-admin user cannot change their min cashout amount",
+ TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT
+ )
AccountPatchResult.MissingTanInfo -> throw conflict(
"missing info for tan channel ${req.tan_channel.get()}",
TalerErrorCode.BANK_MISSING_TAN_INFO
@@ -595,6 +590,10 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio
"Wrong currency conversion",
TalerErrorCode.BANK_BAD_CONVERSION
)
+ CashoutCreationResult.UnderMin -> throw conflict(
+ "Amount of currency conversion it less than the minimum allowed",
+ TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL
+ )
CashoutCreationResult.AccountIsExchange -> throw conflict(
"Exchange account cannot perform cashout operation",
TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
index c0ed1100..ee8cbe3d 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
@@ -28,6 +28,7 @@ import io.ktor.util.pipeline.*
import tech.libeufin.bank.*
import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
+import tech.libeufin.common.api.*
import tech.libeufin.common.crypto.PwCrypto
import java.time.Instant
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
index 036ec116..1bad947a 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -47,6 +47,7 @@ class AccountDAO(private val db: Database) {
isPublic: Boolean,
isTalerExchange: Boolean,
maxDebt: TalerAmount,
+ minCashout: TalerAmount?,
bonus: TalerAmount,
tanChannel: TanChannel?,
// Whether to check [internalPaytoUri] for idempotency
@@ -223,6 +224,7 @@ class AccountDAO(private val db: Database) {
data object NonAdminName: AccountPatchResult
data object NonAdminCashout: AccountPatchResult
data object NonAdminDebtLimit: AccountPatchResult
+ data object NonAdminMinCashout: AccountPatchResult
data object MissingTanInfo: AccountPatchResult
data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult
data object Success: AccountPatchResult
@@ -238,6 +240,7 @@ class AccountDAO(private val db: Database) {
tan_channel: Option<TanChannel?>,
isPublic: Boolean?,
debtLimit: TalerAmount?,
+ minCashout: Option<TalerAmount?>,
isAdmin: Boolean,
is2fa: Boolean,
faChannel: TanChannel?,
@@ -248,6 +251,7 @@ class AccountDAO(private val db: Database) {
val checkName = !isAdmin && !allowEditName && name != null
val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome()
val checkDebtLimit = !isAdmin && debtLimit != null
+ val checkMinCashout = !isAdmin && minCashout.isSome()
data class CurrentAccount(
val id: Long,
@@ -257,6 +261,7 @@ class AccountDAO(private val db: Database) {
val name: String,
val cashoutPayTo: String?,
val debtLimit: TalerAmount,
+ val minCashout: TalerAmount?
)
// Get user ID and current data
@@ -265,6 +270,8 @@ class AccountDAO(private val db: Database) {
customer_id, tan_channel, phone, email, name, cashout_payto
,(max_debt).val AS max_debt_val
,(max_debt).frac AS max_debt_frac
+ ,(min_cashout).val AS min_cashout_val
+ ,(min_cashout).frac AS min_cashout_frac
FROM customers
JOIN bank_accounts
ON customer_id=owning_customer_id
@@ -280,6 +287,7 @@ class AccountDAO(private val db: Database) {
name = it.getString("name"),
cashoutPayTo = it.getString("cashout_payto"),
debtLimit = it.getAmount("max_debt", db.bankCurrency),
+ minCashout = it.getOptAmount("min_cashout", db.bankCurrency),
)
} ?: return@transaction AccountPatchResult.UnknownAccount
}
@@ -310,10 +318,11 @@ class AccountDAO(private val db: Database) {
return@transaction AccountPatchResult.NonAdminCashout
if (checkDebtLimit && debtLimit != curr.debtLimit)
return@transaction AccountPatchResult.NonAdminDebtLimit
+ if (checkMinCashout && minCashout.get() != curr.minCashout)
+ return@transaction AccountPatchResult.NonAdminMinCashout
if (patchChannel != null && newInfo == null)
return@transaction AccountPatchResult.MissingTanInfo
-
// Tan channel verification
if (!isAdmin) {
// Check performed 2fa check
@@ -340,11 +349,24 @@ class AccountDAO(private val db: Database) {
sequence {
if (isPublic != null) yield("is_public=?")
if (debtLimit != null) yield("max_debt=(?, ?)::taler_amount")
+ minCashout.some {
+ if (it != null) {
+ yield("min_cashout=(?, ?)::taler_amount")
+ } else {
+ yield("min_cashout=null")
+ }
+ }
},
"WHERE owning_customer_id = ?",
sequence {
isPublic?.let { yield(it) }
debtLimit?.let { yield(it.value); yield(it.frac) }
+ minCashout.some {
+ if (it != null) {
+ yield(it.value)
+ yield(it.frac)
+ }
+ }
yield(curr.id)
}
)
@@ -464,6 +486,8 @@ class AccountDAO(private val db: Database) {
,has_debt
,(max_debt).val AS max_debt_val
,(max_debt).frac AS max_debt_frac
+ ,(min_cashout).val AS min_cashout_val
+ ,(min_cashout).frac AS min_cashout_frac
,is_public
,is_taler_exchange
,CASE
@@ -496,6 +520,7 @@ class AccountDAO(private val db: Database) {
}
),
debit_threshold = it.getAmount("max_debt", db.bankCurrency),
+ min_cashout = it.getOptAmount("min_cashout", db.bankCurrency),
is_public = it.getBoolean("is_public"),
is_taler_exchange = it.getBoolean("is_taler_exchange"),
status = AccountStatus.valueOf(it.getString("status"))
@@ -557,6 +582,8 @@ class AccountDAO(private val db: Database) {
has_debt AS balance_has_debt,
(max_debt).val as max_debt_val,
(max_debt).frac as max_debt_frac
+ ,(min_cashout).val AS min_cashout_val
+ ,(min_cashout).frac AS min_cashout_frac
,is_public
,is_taler_exchange
,internal_payto_uri
@@ -587,6 +614,7 @@ class AccountDAO(private val db: Database) {
}
),
debit_threshold = it.getAmount("max_debt", db.bankCurrency),
+ min_cashout = it.getOptAmount("min_cashout", db.bankCurrency),
is_public = it.getBoolean("is_public"),
is_taler_exchange = it.getBoolean("is_taler_exchange"),
payto_uri = it.getBankPayto("internal_payto_uri", "name", ctx),
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
index 799d466a..d8779613 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -29,6 +29,7 @@ class CashoutDAO(private val db: Database) {
/** Result of cashout operation creation */
sealed interface CashoutCreationResult {
data class Success(val id: Long): CashoutCreationResult
+ data object UnderMin: CashoutCreationResult
data object BadConversion: CashoutCreationResult
data object AccountNotFound: CashoutCreationResult
data object AccountIsExchange: CashoutCreationResult
@@ -57,7 +58,8 @@ class CashoutDAO(private val db: Database) {
out_request_uid_reuse,
out_no_cashout_payto,
out_tan_required,
- out_cashout_id
+ out_cashout_id,
+ out_under_min
FROM cashout_create(?,?,(?,?)::taler_amount,(?,?)::taler_amount,?,?,?)
""")
stmt.setString(1, login)
@@ -73,6 +75,7 @@ class CashoutDAO(private val db: Database) {
when {
!it.next() ->
throw internalServerError("No result from DB procedure cashout_create")
+ it.getBoolean("out_under_min") -> CashoutCreationResult.UnderMin
it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BadConversion
it.getBoolean("out_account_not_found") -> CashoutCreationResult.AccountNotFound
it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.AccountIsExchange
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
index 04317b68..8afd4f7f 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
@@ -20,7 +20,6 @@
package tech.libeufin.bank.db
import tech.libeufin.bank.ConversionRate
-import tech.libeufin.bank.DecimalNumber
import tech.libeufin.bank.RoundingMode
import tech.libeufin.common.*
import tech.libeufin.common.db.*
@@ -110,7 +109,7 @@ class ConversionDAO(private val db: Database) {
/** Perform [direction] conversion of [amount] using in-db [function] */
private suspend fun conversion(amount: TalerAmount, direction: String, function: String): ConversionResult = db.conn { conn ->
- val stmt = conn.prepareStatement("SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?)")
+ val stmt = conn.prepareStatement("SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?, (0, 0)::taler_amount)")
stmt.setLong(1, amount.value)
stmt.setInt(2, amount.frac)
stmt.setString(3, direction)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
index 9efb821a..1236b1ad 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
@@ -96,13 +96,13 @@ class Database(dbConfig: DatabaseConfig, internal val bankCurrency: String, inte
/** Listen for new bank transactions for [account] */
suspend fun <R> listenBank(account: Long, lambda: suspend (Flow<Long>) -> R): R
= listen(bankTxFlows, account, lambda)
- /** Listen for new taler outgoing transactions from [account] */
+ /** Listen for new taler outgoing transactions from [exchange] */
suspend fun <R> listenOutgoing(exchange: Long, lambda: suspend (Flow<Long>) -> R): R
= listen(outgoingTxFlows, exchange, lambda)
- /** Listen for new taler incoming transactions to [account] */
+ /** Listen for new taler incoming transactions to [exchange] */
suspend fun <R> listenIncoming(exchange: Long, lambda: suspend (Flow<Long>) -> R): R
= listen(incomingTxFlows, exchange, lambda)
- /** Listen for new taler outgoing transactions to [account] */
+ /** Listen for new incoming transactions to [merchant] */
suspend fun <R> listenRevenue(merchant: Long, lambda: suspend (Flow<Long>) -> R): R
= listen(revenueTxFlows, merchant, lambda)
/** Listen for new withdrawal confirmations */
@@ -163,8 +163,4 @@ enum class AbortResult {
Success,
UnknownOperation,
AlreadyConfirmed
-}
-
-fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{
- return TalerProtocolTimestamp(getLong(name).asInstant())
} \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index 094b7996..8c7bd21b 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -37,6 +37,7 @@ import tech.libeufin.bank.auth.username
import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
+import tech.libeufin.common.api.*
import java.util.*
fun ApplicationCall.uuidPath(name: String): UUID {
@@ -128,24 +129,11 @@ suspend fun createAdminAccount(db: Database, cfg: BankConfig, pw: String? = null
phone = null,
cashoutPayto = null,
tanChannel = null,
+ minCashout = null,
ctx = cfg.payto
)
}
-fun Route.intercept(callback: Route.() -> Unit, interceptor: suspend PipelineContext<Unit, ApplicationCall>.() -> Unit): Route {
- val subRoute = createChild(object : RouteSelector() {
- override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
- RouteSelectorEvaluation.Constant
- })
- subRoute.intercept(ApplicationCallPipeline.Plugins) {
- interceptor()
- proceed()
- }
-
- callback(subRoute)
- return subRoute
-}
-
fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route =
intercept(callback) {
if (!implemented) {
diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt
index 9f7b14aa..a93a10ba 100644
--- a/bank/src/test/kotlin/AmountTest.kt
+++ b/bank/src/test/kotlin/AmountTest.kt
@@ -18,7 +18,6 @@
*/
import org.junit.Test
-import tech.libeufin.bank.DecimalNumber
import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult
import tech.libeufin.common.*
diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt
index 0954d3fa..c4aa0096 100644
--- a/bank/src/test/kotlin/ConversionApiTest.kt
+++ b/bank/src/test/kotlin/ConversionApiTest.kt
@@ -45,7 +45,7 @@ class ConversionApiTest {
}
// Too small
- client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:0.08")
+ client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:0.0008")
.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION)
// No amount
client.get("/conversion-info/cashout-rate")
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
index 6e8af912..274c9c78 100644
--- a/bank/src/test/kotlin/CoreBankApiTest.kt
+++ b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -1,6 +1,6 @@
/*
* This file is part of LibEuFin.
- * Copyright (C) 2023 Taler Systems S.A.
+ * Copyright (C) 2023-2024 Taler Systems S.A.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
@@ -226,11 +226,27 @@ class CoreBankAccountsApiTest {
}.assertOk()
}
- // Check admin only tan_channel
+ // Check admin only min_cashout
obj {
"username" to "bat2"
"password" to "password"
"name" to "Bat"
+ "min_cashout" to "KUDOS:42"
+ }.let { req ->
+ client.post("/accounts") {
+ json(req)
+ }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT)
+ client.post("/accounts") {
+ json(req)
+ pwAuth("admin")
+ }.assertOk()
+ }
+
+ // Check admin only tan_channel
+ obj {
+ "username" to "bat3"
+ "password" to "password"
+ "name" to "Bat"
"contact_data" to obj {
"phone" to "+456"
}
@@ -459,8 +475,6 @@ class CoreBankAccountsApiTest {
}.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
}
-
-
@Test
fun softDelete() = bankSetup { db ->
// Create all kind of operations
@@ -600,6 +614,10 @@ class CoreBankAccountsApiTest {
obj(req) { "debit_threshold" to "KUDOS:100" },
TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
)
+ checkAdminOnly(
+ obj(req) { "min_cashout" to "KUDOS:100" },
+ TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT
+ )
// Check currency
client.patch("/accounts/merchant") {
@@ -1319,6 +1337,36 @@ class CoreBankCashoutApiTest {
}
}.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION)
+ // Check min amount
+ client.postA("/accounts/customer/cashouts") {
+ json(req) {
+ "request_uid" to ShortHashCode.rand()
+ "amount_debit" to "KUDOS:0.09"
+ "amount_credit" to convert("KUDOS:0.09")
+ }
+ }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL)
+
+ // Check custom min account
+ client.patch("/accounts/customer") {
+ pwAuth("admin")
+ json {
+ "min_cashout" to "KUDOS:10"
+ }
+ }.assertNoContent()
+ client.postA("/accounts/customer/cashouts") {
+ json(req) {
+ "request_uid" to ShortHashCode.rand()
+ "amount_debit" to "KUDOS:5"
+ "amount_credit" to convert("KUDOS:5")
+ }
+ }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL)
+ client.patch("/accounts/customer") {
+ pwAuth("admin")
+ json {
+ "min_cashout" to (null as String?)
+ }
+ }.assertNoContent()
+
// Check wrong currency
client.postA("/accounts/customer/cashouts") {
json(req) {
diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt
index 6c694842..3c692f90 100644
--- a/bank/src/test/kotlin/RevenueApiTest.kt
+++ b/bank/src/test/kotlin/RevenueApiTest.kt
@@ -19,7 +19,6 @@
import io.ktor.http.*
import org.junit.Test
-import tech.libeufin.bank.RevenueIncomingHistory
import tech.libeufin.common.*
class RevenueApiTest {
diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt
index f5ec3161..240c23a4 100644
--- a/bank/src/test/kotlin/StatsTest.kt
+++ b/bank/src/test/kotlin/StatsTest.kt
@@ -23,10 +23,8 @@ import tech.libeufin.bank.MonitorParams
import tech.libeufin.bank.MonitorResponse
import tech.libeufin.bank.MonitorWithConversion
import tech.libeufin.bank.Timeframe
-import tech.libeufin.common.ShortHashCode
-import tech.libeufin.common.TalerAmount
import tech.libeufin.common.db.executeQueryCheck
-import tech.libeufin.common.micros
+import tech.libeufin.common.*
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt
index 1c6c60ef..10482bdb 100644
--- a/bank/src/test/kotlin/WireGatewayApiTest.kt
+++ b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -30,7 +30,7 @@ class WireGatewayApiTest {
client.getA("/accounts/merchant/taler-wire-gateway/config").assertOk()
}
- // Testing the POST /transfer call from the TWG API.
+ // POST /accounts/{USERNAME}/taler-wire-gateway/transfer
@Test
fun transfer() = bankSetup { _ ->
val valid_req = obj {
@@ -121,9 +121,7 @@ class WireGatewayApiTest {
}.assertBadRequest()
}
- /**
- * Testing the /history/incoming call from the TWG API.
- */
+ // GET /accounts/{USERNAME}/taler-wire-gateway/history/incoming
@Test
fun historyIncoming() = bankSetup {
// Give Foo reasonable debt allowance:
@@ -159,10 +157,7 @@ class WireGatewayApiTest {
)
}
-
- /**
- * Testing the /history/outgoing call from the TWG API.
- */
+ // GET /accounts/{USERNAME}/taler-wire-gateway/history/outgoing
@Test
fun historyOutgoing() = bankSetup {
setMaxDebt("exchange", "KUDOS:1000000")
@@ -193,7 +188,7 @@ class WireGatewayApiTest {
)
}
- // Testing the /admin/add-incoming call from the TWG API.
+ // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming
@Test
fun addIncoming() = bankSetup { _ ->
val valid_req = obj {
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index 73240c15..0a253929 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -96,6 +96,7 @@ fun bankSetup(
phone = null,
cashoutPayto = null,
tanChannel = null,
+ minCashout = null,
ctx = cfg.payto
))
assertIs<AccountCreationResult.Success>(db.account.create(
@@ -112,6 +113,7 @@ fun bankSetup(
phone = null,
cashoutPayto = null,
tanChannel = null,
+ minCashout = null,
ctx = cfg.payto
))
assertIs<AccountCreationResult.Success>(db.account.create(
@@ -128,6 +130,7 @@ fun bankSetup(
phone = null,
cashoutPayto = null,
tanChannel = null,
+ minCashout = null,
ctx = cfg.payto
))
// Create admin account
@@ -343,15 +346,6 @@ suspend fun HttpResponse.assertChallenge(
}
}
-suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) {
- val start = System.currentTimeMillis()
- lambda()
- val end = System.currentTimeMillis()
- val time = end - start
- assert(time >= min) { "Expected to last at least $min ms, lasted $time" }
- assert(time <= max) { "Expected to last at most $max ms, lasted $time" }
-}
-
fun assertException(msg: String, lambda: () -> Unit) {
try {
lambda()
@@ -361,47 +355,6 @@ fun assertException(msg: String, lambda: () -> Unit) {
}
}
-suspend inline fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B) -> List<Long>): B {
- assertOk()
- val body = json<B>()
- val history = ids(body)
- val params = PageParams.extract(call.request.url.parameters)
-
- // testing the size is like expected.
- assertEquals(size, history.size, "bad history length: $history")
- if (params.delta < 0) {
- // testing that the first id is at most the 'start' query param.
- assert(history[0] <= params.start) { "bad history start: $params $history" }
- // testing that the id decreases.
- if (history.size > 1)
- assert(history.windowed(2).all { (a, b) -> a > b }) { "bad history order: $history" }
- } else {
- // testing that the first id is at least the 'start' query param.
- assert(history[0] >= params.start) { "bad history start: $params $history" }
- // testing that the id increases.
- if (history.size > 1)
- assert(history.windowed(2).all { (a, b) -> a < b }) { "bad history order: $history" }
- }
-
- return body
-}
-
-/* ----- Body helper ----- */
-
-suspend inline fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B {
- assertOk()
- val body = json<B>()
- lambda(body)
- return body
-}
-
-suspend inline fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> Unit = {}): B {
- assertAccepted()
- val body = json<B>()
- lambda(body)
- return body
-}
-
/* ----- Auth ----- */
/** Auto auth get request */
@@ -446,7 +399,6 @@ fun HttpRequestBuilder.pwAuth(username: String? = null) {
val login = url.pathSegments[2]
basicAuth("$login", "$login-password")
}
-
}
/* ----- Random data generation ----- */
diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt
index d49a3324..93e619ed 100644
--- a/bank/src/test/kotlin/routines.kt
+++ b/bank/src/test/kotlin/routines.kt
@@ -28,6 +28,7 @@ import kotlinx.serialization.json.JsonObject
import tech.libeufin.bank.BankAccountCreateWithdrawalResponse
import tech.libeufin.bank.WithdrawalStatus
import tech.libeufin.common.*
+import tech.libeufin.common.test.*
import kotlin.test.assertEquals
// Test endpoint is correctly authenticated
@@ -40,6 +41,17 @@ suspend fun ApplicationTestBuilder.authRoutine(
allowAdmin: Boolean = false
) {
// No body when authentication must happen before parsing the body
+
+ // No header
+ client.request(path) {
+ this.method = method
+ }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING)
+
+ // Bad header
+ client.request(path) {
+ this.method = method
+ headers["Authorization"] = "WTF"
+ }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED)
// Unknown account
client.request(path) {
@@ -60,7 +72,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
}.assertUnauthorized()
if (requireAdmin) {
- // Not exchange account
+ // Not exchange account
client.request(path) {
this.method = method
pwAuth("merchant")
@@ -91,117 +103,11 @@ suspend inline fun <reified B> ApplicationTestBuilder.historyRoutine(
polling: Boolean = true,
auth: String? = null
) {
- // Get history
- val history: suspend (String) -> HttpResponse = { params: String ->
+ abstractHistoryRoutine(ids, registered, ignored, polling) { params: String ->
client.get("$url?$params") {
pwAuth(auth)
}
}
- // Check history is following specs
- val assertHistory: suspend HttpResponse.(Int) -> Unit = { size: Int ->
- assertHistoryIds<B>(size, ids)
- }
- // Get latest registered id
- val latestId: suspend () -> Long = {
- history("delta=-1").assertOkJson<B>().run { ids(this)[0] }
- }
-
- // Check error when no transactions
- history("delta=7").assertNoContent()
-
- // Run interleaved registered and ignore transactions
- val registered_iter = registered.iterator()
- val ignored_iter = ignored.iterator()
- while (registered_iter.hasNext() || ignored_iter.hasNext()) {
- if (registered_iter.hasNext()) registered_iter.next()()
- if (ignored_iter.hasNext()) ignored_iter.next()()
- }
-
-
- val nbRegistered = registered.size
- val nbIgnored = ignored.size
- val nbTotal = nbRegistered + nbIgnored
-
- // Check ignored
- history("delta=$nbTotal").assertHistory(nbRegistered)
- // Check skip ignored
- history("delta=$nbRegistered").assertHistory(nbRegistered)
-
- if (polling) {
- // Check no polling when we cannot have more transactions
- assertTime(0, 100) {
- history("delta=-${nbRegistered+1}&long_poll_ms=1000")
- .assertHistory(nbRegistered)
- }
- // Check no polling when already find transactions even if less than delta
- assertTime(0, 100) {
- history("delta=${nbRegistered+1}&long_poll_ms=1000")
- .assertHistory(nbRegistered)
- }
-
- // Check polling
- coroutineScope {
- val id = latestId()
- launch { // Check polling succeed
- assertTime(100, 200) {
- history("delta=2&start=$id&long_poll_ms=1000")
- .assertHistory(1)
- }
- }
- launch { // Check polling timeout
- assertTime(200, 300) {
- history("delta=1&start=${id+nbTotal*3}&long_poll_ms=200")
- .assertNoContent()
- }
- }
- delay(100)
- registered[0]()
- }
-
- // Test triggers
- for (register in registered) {
- coroutineScope {
- val id = latestId()
- launch {
- assertTime(100, 200) {
- history("delta=7&start=$id&long_poll_ms=1000")
- .assertHistory(1)
- }
- }
- delay(100)
- register()
- }
- }
-
- // Test doesn't trigger
- coroutineScope {
- val id = latestId()
- launch {
- assertTime(200, 300) {
- history("delta=7&start=$id&long_poll_ms=200")
- .assertNoContent()
- }
- }
- delay(100)
- for (ignore in ignored) {
- ignore()
- }
- }
- }
-
- // Testing ranges.
- repeat(20) {
- registered[0]()
- }
- val id = latestId()
- // Default
- history("").assertHistory(20)
- // forward range:
- history("delta=10").assertHistory(10)
- history("delta=10&start=4").assertHistory(10)
- // backward range:
- history("delta=-10").assertHistory(10)
- history("delta=-10&start=${id-4}").assertHistory(10)
}
suspend inline fun <reified B> ApplicationTestBuilder.statusRoutine(
diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt
index 43491642..c4fdd486 100644
--- a/common/src/main/kotlin/Cli.kt
+++ b/common/src/main/kotlin/Cli.kt
@@ -70,6 +70,8 @@ fun cliCmd(logger: Logger, level: Level, lambda: suspend () -> Unit) {
}
})
}
+ } catch (e: ProgramResult) {
+ throw e
} catch (e: Throwable) {
e.fmtLog(logger)
throw ProgramResult(1)
diff --git a/common/src/main/kotlin/Client.kt b/common/src/main/kotlin/Client.kt
index a3fc8c00..ef431e25 100644
--- a/common/src/main/kotlin/Client.kt
+++ b/common/src/main/kotlin/Client.kt
@@ -63,7 +63,14 @@ suspend inline fun <reified B> HttpResponse.json(): B =
Json.decodeFromString(kotlinx.serialization.serializer<B>(), bodyAsText())
suspend inline fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B {
- assertEquals(HttpStatusCode.OK, status)
+ assertOk()
+ val body = json<B>()
+ lambda(body)
+ return body
+}
+
+suspend inline fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> Unit = {}): B {
+ assertAccepted()
val body = json<B>()
lambda(body)
return body
diff --git a/common/src/main/kotlin/Config.kt b/common/src/main/kotlin/Config.kt
index 95496839..536e88ff 100644
--- a/common/src/main/kotlin/Config.kt
+++ b/common/src/main/kotlin/Config.kt
@@ -33,4 +33,17 @@ fun getVersion(): String {
return Loader.getResource(
"version.txt", ClassLoader.getSystemClassLoader()
).readText()
+}
+
+sealed interface ServerConfig {
+ data class Unix(val path: String, val mode: Int): ServerConfig
+ data class Tcp(val addr: String, val port: Int): ServerConfig
+}
+
+fun TalerConfig.loadServerConfig(section: String): ServerConfig {
+ return when (val method = requireString(section, "serve")) {
+ "tcp" -> ServerConfig.Tcp(lookupString(section, "address") ?: requireString(section, "bind_to"), requireNumber(section, "port"))
+ "unix" -> ServerConfig.Unix(requireString(section, "unixpath"), requireNumber(section, "unixpath_mode"))
+ else -> throw TalerConfigError.invalid("server method", section, "serve", "expected 'tcp' or 'unix' got '$method'")
+ }
} \ No newline at end of file
diff --git a/common/src/main/kotlin/Constants.kt b/common/src/main/kotlin/Constants.kt
index 0e48cf1e..f6760a44 100644
--- a/common/src/main/kotlin/Constants.kt
+++ b/common/src/main/kotlin/Constants.kt
@@ -26,4 +26,5 @@ const val SERIALIZATION_RETRY: Int = 10
const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB
// API version
-const val WIRE_GATEWAY_API_VERSION: String = "0:2:0" \ No newline at end of file
+const val WIRE_GATEWAY_API_VERSION: String = "0:2:0"
+const val REVENUE_API_VERSION: String = "0:0:0" \ No newline at end of file
diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt
index 7ff59968..0d82b958 100644
--- a/common/src/main/kotlin/TalerCommon.kt
+++ b/common/src/main/kotlin/TalerCommon.kt
@@ -20,6 +20,7 @@
package tech.libeufin.common
import io.ktor.http.*
+import io.ktor.server.plugins.BadRequestException
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
@@ -67,7 +68,6 @@ data class TalerProtocolTimestamp(
} else {
encoder.encodeLong(value.epochSecond)
}
-
}
override fun deserialize(decoder: Decoder): Instant {
@@ -115,6 +115,64 @@ class ExchangeUrl {
}
}
+@Serializable(with = DecimalNumber.Serializer::class)
+class DecimalNumber {
+ val value: Long
+ val frac: Int
+
+ constructor(value: Long, frac: Int) {
+ this.value = value
+ this.frac = frac
+ }
+ constructor(encoded: String) {
+ val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format")
+ val (value, frac) = match.destructured
+ this.value = value.toLongOrNull() ?: throw badRequest("Invalid value")
+ if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large")
+ this.frac = if (frac.isEmpty()) {
+ 0
+ } else {
+ var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value")
+ repeat(8 - frac.length) {
+ tmp *= 10
+ }
+ tmp
+ }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return other is DecimalNumber &&
+ other.value == this.value &&
+ other.frac == this.frac
+ }
+
+ override fun toString(): String {
+ if (frac == 0) {
+ return "$value"
+ } else {
+ return "$value.${frac.toString().padStart(8, '0')}"
+ .dropLastWhile { it == '0' } // Trim useless fractional trailing 0
+ }
+ }
+
+ internal object Serializer : KSerializer<DecimalNumber> {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: DecimalNumber) {
+ encoder.encodeString(value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): DecimalNumber {
+ return DecimalNumber(decoder.decodeString())
+ }
+ }
+
+ companion object {
+ private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?")
+ }
+}
+
@Serializable(with = TalerAmount.Serializer::class)
class TalerAmount {
val value: Long
@@ -149,6 +207,8 @@ class TalerAmount {
}
}
+ fun number(): DecimalNumber = DecimalNumber(value, frac)
+
override fun equals(other: Any?): Boolean {
return other is TalerAmount &&
other.value == this.value &&
@@ -232,10 +292,7 @@ sealed class Payto {
/** Transform a payto URI to its bank form, using [name] as the receiver-name and the bank [ctx] */
fun bank(name: String, ctx: BankPaytoCtx): String = when (this) {
- is IbanPayto -> {
- val bic = if (ctx.bic != null) "${ctx.bic}/" else ""
- "payto://iban/$bic$iban?receiver-name=${name.encodeURLParameter()}"
- }
+ is IbanPayto -> IbanPayto.build(iban.toString(), ctx.bic, name)
is XTalerBankPayto -> "payto://x-taler-bank/${ctx.hostname ?: "localhost"}/$username?receiver-name=${name.encodeURLParameter()}"
}
@@ -246,6 +303,14 @@ sealed class Payto {
}
}
+ fun expectRequestIban(): IbanPayto {
+ try {
+ return expectIban()
+ } catch (e: Exception) {
+ throw BadRequestException(e.message ?: "", e)
+ }
+ }
+
fun expectXTalerBank(): XTalerBankPayto {
return when (this) {
is XTalerBankPayto -> this
@@ -253,6 +318,14 @@ sealed class Payto {
}
}
+ fun expectRequestXTalerBank(): XTalerBankPayto {
+ try {
+ return expectXTalerBank()
+ } catch (e: Exception) {
+ throw BadRequestException(e.message ?: "", e)
+ }
+ }
+
internal object Serializer : KSerializer<Payto> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Payto", PrimitiveKind.STRING)
@@ -333,10 +406,7 @@ class IbanPayto internal constructor(
override fun toString(): String = parsed.toString()
/** Transform an IBAN payto URI to its full form, using [defaultName] if receiver-name is missing */
- fun full(defaultName: String): String {
- val bic = if (this.bic != null) "$bic/" else ""
- return "payto://iban/$bic$iban?receiver-name=${(receiverName ?: defaultName).encodeURLParameter()}"
- }
+ fun full(defaultName: String): String = build(iban.toString(), bic, receiverName ?: defaultName)
internal object Serializer : KSerializer<IbanPayto> {
override val descriptor: SerialDescriptor =
@@ -352,6 +422,12 @@ class IbanPayto internal constructor(
}
companion object {
+ fun build(iban: String, bic: String?, name: String?): String {
+ val bic = if (bic != null) "$bic/" else ""
+ val name = if (name != null) "?receiver-name=${name.encodeURLParameter()}" else ""
+ return "payto://iban/$bic$iban$name"
+ }
+
fun rand(): IbanPayto {
return parse("payto://iban/SANDBOXX/${IBAN.rand()}").expectIban()
}
diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt
index 84d212d3..ded9b47d 100644
--- a/common/src/main/kotlin/TalerConfig.kt
+++ b/common/src/main/kotlin/TalerConfig.kt
@@ -444,8 +444,8 @@ class TalerConfig internal constructor(
return str
}
- fun requireString(section: String, option: String): String =
- lookupString(section, option) ?: throw TalerConfigError.missing("string", section, option)
+ fun requireString(section: String, option: String, type: String? = null): String =
+ lookupString(section, option) ?: throw TalerConfigError.missing(type ?: "string", section, option)
fun requireNumber(section: String, option: String): Int {
val raw = lookupString(section, option) ?: throw TalerConfigError.missing("number", section, option)
diff --git a/common/src/main/kotlin/TalerErrorCode.kt b/common/src/main/kotlin/TalerErrorCode.kt
index e37f2eb5..367daca0 100644
--- a/common/src/main/kotlin/TalerErrorCode.kt
+++ b/common/src/main/kotlin/TalerErrorCode.kt
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- Copyright (C) 2012-2020 Taler Systems SA
+ Copyright (C) 2012-2024 Taler Systems SA
GNU Taler is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published
@@ -34,7 +34,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * A non-integer error code was returned in the JSON response.
+ * An error response did not include an error code in the format expected by the client. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -42,7 +42,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * An internal failure happened on the client side.
+ * An internal failure happened on the client side. Details should be in the local logs. Check if you are using the latest available version or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -50,7 +50,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The response we got from the server was not even in JSON format.
+ * The response we got from the server was not in the expected format. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -58,7 +58,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * An operation timed out.
+ * The operation timed out. Trying again might help. Check the network connection.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -66,7 +66,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The version string given does not follow the expected CURRENT:REVISION:AGE Format.
+ * The protocol version given by the server does not follow the required format. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -74,7 +74,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service responded with a reply that was in JSON but did not satsify the protocol. Note that invalid cryptographic signatures should have signature-specific error codes.
+ * The service responded with a reply that was in the right data format, but the content did not satisfy the protocol. Please file a bug report.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -82,7 +82,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * There is an error in the client-side configuration, for example the base URL specified is malformed.
+ * There is an error in the client-side configuration, for example an option is set to an invalid value. Check the logs and fix the local configuration.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -90,7 +90,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The client made a request to a service, but received an error response it does not know how to handle.
+ * The client made a request to a service, but received an error response it does not know how to handle. Please file a bug report.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -98,7 +98,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The token used by the client to authorize the request does not grant the required permissions for the request.
+ * The token used by the client to authorize the request does not grant the required permissions for the request. Check the requirements and obtain a suitable authorization token to proceed.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -106,7 +106,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The HTTP method used is invalid for this endpoint.
+ * The HTTP method used is invalid for this endpoint. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_METHOD_NOT_ALLOWED (405).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -114,7 +114,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * There is no endpoint defined for the URL provided by the client.
+ * There is no endpoint defined for the URL provided by the client. Check if you used the correct URL and/or file a report with the developers of the client software.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -122,7 +122,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The JSON in the client's request was malformed (generic parse error).
+ * The JSON in the client's request was malformed. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -130,7 +130,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * Some of the HTTP headers provided by the client caused the server to not be able to handle the request.
+ * Some of the HTTP headers provided by the client were malformed and caused the server to not be able to handle the request. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -138,7 +138,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The payto:// URI provided by the client is malformed.
+ * The payto:// URI provided by the client is malformed. Check that you are using the correct syntax as of RFC 8905 and/or that you entered the bank account number correctly.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -146,7 +146,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * A required parameter in the request was missing.
+ * A required parameter in the request was missing. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -154,7 +154,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * A parameter in the request was malformed.
+ * A parameter in the request was malformed. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -162,7 +162,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The reserve public key given as part of a /reserves/ endpoint was malformed.
+ * The reserve public key was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -170,7 +170,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The body in the request could not be decompressed by the server.
+ * The body in the request could not be decompressed by the server. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -178,7 +178,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The currency involved in the operation is not acceptable for this backend.
+ * The currency involved in the operation is not acceptable for this server. Check your configuration and make sure the currency specified for a given service provider is one of the currencies supported by that provider.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -186,7 +186,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The URI is longer than the longest URI the HTTP server is willing to parse.
+ * The URI is longer than the longest URI the HTTP server is willing to parse. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit.
* Returned with an HTTP status code of #MHD_HTTP_URI_TOO_LONG (414).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -194,7 +194,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The body is too large to be permissible for the endpoint.
+ * The body is too large to be permissible for the endpoint. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit.
* Returned with an HTTP status code of #MHD_HTTP_CONTENT_TOO_LARGE (413).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -242,7 +242,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service failed initialize its connection to the database.
+ * The service failed initialize its connection to the database. The system administrator should check that the service has permissions to access the database and that the database is running.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -250,7 +250,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service encountered an error event to just start the database transaction.
+ * The service encountered an error event to just start the database transaction. The system administrator should check that the database is running.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -258,7 +258,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service failed to store information in its database.
+ * The service failed to store information in its database. The system administrator should check that the database is running and review the service logs.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -266,7 +266,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service failed to fetch information from its database.
+ * The service failed to fetch information from its database. The system administrator should check that the database is running and review the service logs.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -274,7 +274,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service encountered an error event to commit the database transaction (hard, unrecoverable error).
+ * The service encountered an unrecoverable error trying to commit a transaction to the database. The system administrator should check that the database is running and review the service logs.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -282,7 +282,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service encountered an error event to commit the database transaction, even after repeatedly retrying it there was always a conflicting transaction. (This indicates a repeated serialization error; should only happen if some client maliciously tries to create conflicting concurrent transactions.)
+ * The service encountered an error event to commit the database transaction, even after repeatedly retrying it there was always a conflicting transaction. This indicates a repeated serialization error; it should only happen if some client maliciously tries to create conflicting concurrent transactions. It could also be a sign of a missing index. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -290,7 +290,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service's database is inconsistent and violates service-internal invariants.
+ * The service's database is inconsistent and violates service-internal invariants. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -298,7 +298,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The HTTP server experienced an internal invariant failure (bug).
+ * The HTTP server experienced an internal invariant failure (bug). Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -306,7 +306,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service could not compute a cryptographic hash over some JSON value.
+ * The service could not compute a cryptographic hash over some JSON value. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -314,7 +314,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The service could not compute an amount.
+ * The service could not compute an amount. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -322,7 +322,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The HTTP server had insufficient memory to parse the request.
+ * The HTTP server had insufficient memory to parse the request. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -330,7 +330,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The HTTP server failed to allocate memory.
+ * The HTTP server failed to allocate memory. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -338,7 +338,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The HTTP server failed to allocate memory for building JSON reply.
+ * The HTTP server failed to allocate memory for building JSON reply. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -346,7 +346,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The HTTP server failed to allocate memory for making a CURL request.
+ * The HTTP server failed to allocate memory for making a CURL request. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -354,7 +354,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The backend could not locate a required template to generate an HTML reply.
+ * The backend could not locate a required template to generate an HTML reply. The system administrator should check if the resource files are installed in the correct location and are readable to the service.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -362,7 +362,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The backend could not expand the template to generate an HTML reply.
+ * The backend could not expand the template to generate an HTML reply. The system administrator should investigate the logs and check if the templates are well-formed.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2347,7 +2347,7 @@ enum class TalerErrorCode(val code: Int) {
/**
* After considering deposit and wire fees, the payment is insufficient to satisfy the required amount for the contract. The client should revisit the logic used to calculate fees it must cover.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_DUE_TO_FEES(2155),
@@ -2355,7 +2355,7 @@ enum class TalerErrorCode(val code: Int) {
/**
* Even if we do not consider deposit and wire fees, the payment is insufficient to satisfy the required amount for the contract.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_POST_ORDERS_ID_PAY_PAYMENT_INSUFFICIENT(2156),
@@ -2506,6 +2506,62 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * The payment requires the wallet to select a choice from the choices array and pass it in the 'choice_index' field of the request.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISSING(2176),
+
+
+ /**
+ * The 'choice_index' field is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_OUT_OF_BOUNDS(2177),
+
+
+ /**
+ * The provided 'tokens' array does not match with the required input tokens of the order.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_INPUT_TOKENS_MISMATCH(2178),
+
+
+ /**
+ * Invalid token issue signature (blindly signed by merchant) for provided token.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_TOKEN_ISSUE_SIG_INVALID(2179),
+
+
+ /**
+ * Invalid token use signature (EdDSA, signed by wallet) for provided token.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_TOKEN_USE_SIG_INVALID(2180),
+
+
+ /**
+ * The provided number of tokens does not match the required number.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_TOKEN_COUNT_MISMATCH(2181),
+
+
+ /**
+ * The provided number of token envelopes does not match the specified number.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_TOKEN_ENVELOPE_COUNT_MISMATCH(2182),
+
+
+ /**
* The contract hash does not match the given order ID.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
@@ -2522,6 +2578,22 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * A token family with this ID but conflicting data exists.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_TOKEN_FAMILY_CONFLICT(2225),
+
+
+ /**
+ * The backend is unaware of a token family with the given ID.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PATCH_TOKEN_FAMILY_NOT_FOUND(2226),
+
+
+ /**
* The merchant failed to send the exchange the refund request.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
@@ -2570,6 +2642,62 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * We are waiting for the exchange to provide us with key material before checking the wire transfer.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_AWAITING_KEYS(2258),
+
+
+ /**
+ * We are waiting for the exchange to provide us with the list of aggregated transactions.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_AWAITING_LIST(2259),
+
+
+ /**
+ * The endpoint indicated in the wire transfer does not belong to a GNU Taler exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_OK (200).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_FATAL_NO_EXCHANGE(2260),
+
+
+ /**
+ * The exchange indicated in the wire transfer claims to know nothing about the wire transfer.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_FATAL_NOT_FOUND(2261),
+
+
+ /**
+ * The interaction with the exchange is delayed due to rate limiting.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_RATE_LIMITED(2262),
+
+
+ /**
+ * We experienced a transient failure in our interaction with the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_TRANSIENT_FAILURE(2263),
+
+
+ /**
+ * The response from the exchange was unacceptable and should be reviewed with an auditor.
+ * Returned with an HTTP status code of #MHD_HTTP_OK (200).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_HARD_FAILURE(2264),
+
+
+ /**
* We could not claim the order because the backend is unaware of it.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
@@ -2706,7 +2834,7 @@ enum class TalerErrorCode(val code: Int) {
/**
- * The order ceration request is invalid because the given payment deadline is in the past.
+ * The order creation request is invalid because the given payment deadline is in the past.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
@@ -2786,6 +2914,14 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * The token family slug provided in this order could not be found in the merchant database.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN(2533),
+
+
+ /**
* The exchange says it does not know this transfer.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
* (A value of 0 indicates that the error is generated client-side).
@@ -2850,62 +2986,6 @@ enum class TalerErrorCode(val code: Int) {
/**
- * We are waiting for the exchange to provide us with key material before checking the wire transfer.
- * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_AWAITING_KEYS(2258),
-
-
- /**
- * We are waiting for the exchange to provide us with the list of aggregated transactions.
- * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_AWAITING_LIST(2259),
-
-
- /**
- * The endpoint indicated in the wire transfer does not belong to a GNU Taler exchange.
- * Returned with an HTTP status code of #MHD_HTTP_OK (200).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_FATAL_NO_EXCHANGE(2260),
-
-
- /**
- * The exchange indicated in the wire transfer claims to know nothing about the wire transfer.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_FATAL_NOT_FOUND(2261),
-
-
- /**
- * The interaction with the exchange is delayed due to rate limiting.
- * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_RATE_LIMITED(2262),
-
-
- /**
- * We experienced a transient failure in our interaction with the exchange.
- * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_TRANSIENT_FAILURE(2263),
-
-
- /**
- * The response from the exchange was unacceptable and should be reviewed with an auditor.
- * Returned with an HTTP status code of #MHD_HTTP_OK (200).
- * (A value of 0 indicates that the error is generated client-side).
- */
- MERCHANT_EXCHANGE_TRANSFERS_HARD_FAILURE(2264),
-
-
- /**
* The amount transferred differs between what was submitted and what the exchange claimed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
@@ -3170,6 +3250,22 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * The requested resource could not be found.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ AUDITOR_RESOURCE_NOT_FOUND(3102),
+
+
+ /**
+ * The URI is missing a path component.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ AUDITOR_URI_MISSING_PATH_COMPONENT(3103),
+
+
+ /**
* Wire transfer attempted with credit and debit party being the same bank account.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
@@ -3522,6 +3618,22 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * A non-admin user has tried to set their minimum cashout amount.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_SET_MIN_CASHOUT(5146),
+
+
+ /**
+ * Amount of currency conversion it less than the minimum allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_CONVERSION_AMOUNT_TO_SMALL(5147),
+
+
+ /**
* The sync service failed find the account in its database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
@@ -3898,6 +4010,46 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * An exchange that is required for some request is currently not available.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_EXCHANGE_UNAVAILABLE(7032),
+
+
+ /**
+ * An exchange entry is still used by the exchange, thus it can't be deleted without purging.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_EXCHANGE_ENTRY_USED(7033),
+
+
+ /**
+ * The wallet database is unavailable and the wallet thus is not operational.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_DB_UNAVAILABLE(7034),
+
+
+ /**
+ * A taler:// URI is malformed and can't be parsed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_TALER_URI_MALFORMED(7035),
+
+
+ /**
+ * A wallet-core request was cancelled and thus can't provide a response.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_CORE_REQUEST_CANCELLED(7036),
+
+
+ /**
* We encountered a timeout with our payment backend.
* Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).
* (A value of 0 indicates that the error is generated client-side).
@@ -4426,6 +4578,38 @@ enum class TalerErrorCode(val code: Int) {
/**
+ * The Donau failed to perform the operation as it could not find the private keys. This is a problem with the Donau setup, not with the client's request.
+ * Returned with an HTTP status code of #MHD_HTTP_SERVICE_UNAVAILABLE (503).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_GENERIC_KEYS_MISSING(8607),
+
+
+ /**
+ * The signature of the charity key is not valid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_CHARITY_SIGNATURE_INVALID(8608),
+
+
+ /**
+ * The charity is unknown.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_CHARITY_NOT_FOUND(8609),
+
+
+ /**
+ * The donation amount specified in the request exceeds the limit of the charity.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_EXCEEDING_DONATION_LIMIT(8610),
+
+
+ /**
* A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
diff --git a/common/src/main/kotlin/TalerMessage.kt b/common/src/main/kotlin/TalerMessage.kt
index 6610376f..58723b4e 100644
--- a/common/src/main/kotlin/TalerMessage.kt
+++ b/common/src/main/kotlin/TalerMessage.kt
@@ -75,6 +75,7 @@ data class IncomingHistory(
val credit_account: String
)
+/** Inner request GET /taler-wire-gateway/history/incoming */
@Serializable
data class IncomingReserveTransaction(
val type: String = "RESERVE",
@@ -92,6 +93,7 @@ data class OutgoingHistory(
val debit_account: String
)
+/** Inner request GET /taler-wire-gateway/history/outgoing */
@Serializable
data class OutgoingTransaction(
val row_id: Long, // DB row ID of the payment.
@@ -101,3 +103,29 @@ data class OutgoingTransaction(
val wtid: ShortHashCode,
val exchange_base_url: String,
)
+
+/** Response GET /taler-revenue/config */
+@Serializable
+data class RevenueConfig(
+ val currency: String
+) {
+ val name: String = "taler-revenue"
+ val version: String = REVENUE_API_VERSION
+}
+
+/** Request GET /taler-revenue/history */
+@Serializable
+data class RevenueIncomingHistory(
+ val incoming_transactions : List<RevenueIncomingBankTransaction>,
+ val credit_account: String
+)
+
+/** Inner request GET /taler-revenue/history */
+@Serializable
+data class RevenueIncomingBankTransaction(
+ val row_id: Long,
+ val date: TalerProtocolTimestamp,
+ val amount: TalerAmount,
+ val debit_account: String,
+ val subject: String
+) \ No newline at end of file
diff --git a/common/src/main/kotlin/TxMedatada.kt b/common/src/main/kotlin/TxMedatada.kt
index f6741447..7a2646a4 100644
--- a/common/src/main/kotlin/TxMedatada.kt
+++ b/common/src/main/kotlin/TxMedatada.kt
@@ -18,10 +18,16 @@
*/
package tech.libeufin.common
-private val PATTERN = Regex("[a-z0-9A-Z]{52}")
+private val BASE32_32B_PATTERN = Regex("[a-z0-9A-Z]{52}")
/** Extract the reserve public key from an incoming Taler transaction subject */
fun parseIncomingTxMetadata(subject: String): EddsaPublicKey {
- val match = PATTERN.find(subject)?.value ?: throw Exception("Missing reserve public key")
+ val match = BASE32_32B_PATTERN.find(subject)?.value ?: throw Exception("Missing reserve public key")
return EddsaPublicKey(match)
+}
+
+/** Extract the reserve public key from an incoming Taler transaction subject */
+fun parseOutgoingTxMetadata(subject: String): Pair<ShortHashCode, ExchangeUrl> {
+ val (wtid, baseUrl) = subject.splitOnce(" ") ?: throw Exception("Malformed outgoing subject")
+ return Pair(EddsaPublicKey(wtid), ExchangeUrl(baseUrl))
} \ No newline at end of file
diff --git a/common/src/main/kotlin/api/route.kt b/common/src/main/kotlin/api/route.kt
new file mode 100644
index 00000000..f3b5952f
--- /dev/null
+++ b/common/src/main/kotlin/api/route.kt
@@ -0,0 +1,41 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.util.*
+import io.ktor.util.pipeline.*
+
+fun Route.intercept(callback: Route.() -> Unit, interceptor: suspend PipelineContext<Unit, ApplicationCall>.() -> Unit): Route {
+ val subRoute = createChild(object : RouteSelector() {
+ override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
+ RouteSelectorEvaluation.Constant
+ })
+ subRoute.intercept(ApplicationCallPipeline.Plugins) {
+ interceptor()
+ proceed()
+ }
+
+ callback(subRoute)
+ return subRoute
+} \ No newline at end of file
diff --git a/common/src/main/kotlin/api/server.kt b/common/src/main/kotlin/api/server.kt
index ba6f2f61..43e37186 100644
--- a/common/src/main/kotlin/api/server.kt
+++ b/common/src/main/kotlin/api/server.kt
@@ -222,4 +222,28 @@ fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) {
}
}
routing { routes() }
+}
+
+// Dirty local variable to stop the server in test TODO remove this ugly hack
+var engine: ApplicationEngine? = null
+
+fun serve(cfg: ServerConfig, api: Application.() -> Unit) {
+ val env = applicationEngineEnvironment {
+ when (cfg) {
+ is ServerConfig.Tcp -> {
+ for (addr in InetAddress.getAllByName(cfg.addr)) {
+ connector {
+ port = cfg.port
+ host = addr.hostAddress
+ }
+ }
+ }
+ is ServerConfig.Unix ->
+ throw Exception("Can only serve via TCP")
+ }
+ module { api() }
+ }
+ val local = embeddedServer(Netty, env)
+ engine = local
+ local.start(wait = true)
} \ No newline at end of file
diff --git a/common/src/main/kotlin/db/helpers.kt b/common/src/main/kotlin/db/helpers.kt
index 13e0ace0..6225f89c 100644
--- a/common/src/main/kotlin/db/helpers.kt
+++ b/common/src/main/kotlin/db/helpers.kt
@@ -110,4 +110,55 @@ suspend fun <T> DbPool.poolHistory(
} else {
load()
}
+}
+
+/**
+* The following function returns the list of transactions, according
+* to the history parameters and perform long polling when necessary
+*/
+suspend fun <T> DbPool.poolHistoryGlobal(
+ params: HistoryParams,
+ listen: suspend (suspend (Flow<Long>) -> List<T>) -> List<T>,
+ query: String,
+ idColumnValue: String,
+ map: (ResultSet) -> T
+): List<T> {
+
+ suspend fun load(): List<T> = page(
+ params.page,
+ idColumnValue,
+ query,
+ map=map
+ )
+
+
+ // TODO do we want to handle polling when going backward and there is no transactions yet ?
+ // When going backward there is always at least one transaction or none
+ return if (params.page.delta >= 0 && params.polling.poll_ms > 0) {
+ listen { flow ->
+ coroutineScope {
+ // Start buffering notification before loading transactions to not miss any
+ val polling = launch {
+ withTimeoutOrNull(params.polling.poll_ms) {
+ flow.first { it > params.page.start } // Always forward so >
+ }
+ }
+ // Initial loading
+ val init = load()
+ // Long polling if we found no transactions
+ if (init.isEmpty()) {
+ if (polling.join() != null) {
+ load()
+ } else {
+ init
+ }
+ } else {
+ polling.cancel()
+ init
+ }
+ }
+ }
+ } else {
+ load()
+ }
} \ No newline at end of file
diff --git a/common/src/main/kotlin/db/notifications.kt b/common/src/main/kotlin/db/notifications.kt
index 3f2fb753..f0373d32 100644
--- a/common/src/main/kotlin/db/notifications.kt
+++ b/common/src/main/kotlin/db/notifications.kt
@@ -30,7 +30,10 @@ import java.util.*
import java.util.concurrent.ConcurrentHashMap
// SharedFlow that are manually counted for manual garbage collection
-class CountedSharedFlow<T>(val flow: MutableSharedFlow<T>, var count: Int)
+class CountedSharedFlow<T> {
+ val flow: MutableSharedFlow<T> = MutableSharedFlow()
+ var count: Int = 0
+}
fun watchNotifications(
pgSource: PGSimpleDataSource,
@@ -73,7 +76,7 @@ fun watchNotifications(
suspend fun <R, K, V> listen(map: ConcurrentHashMap<K, CountedSharedFlow<V>>, key: K, lambda: suspend (Flow<V>) -> R): R {
// Register listener, create a new flow if missing
val flow = map.compute(key) { _, v ->
- val tmp = v ?: CountedSharedFlow(MutableSharedFlow(), 0)
+ val tmp = v ?: CountedSharedFlow()
tmp.count++
tmp
}!!.flow
diff --git a/common/src/main/kotlin/db/transaction.kt b/common/src/main/kotlin/db/transaction.kt
index f06c4e4e..5483ad04 100644
--- a/common/src/main/kotlin/db/transaction.kt
+++ b/common/src/main/kotlin/db/transaction.kt
@@ -52,6 +52,15 @@ fun <T> PreparedStatement.oneOrNull(lambda: (ResultSet) -> T): T? {
fun <T> PreparedStatement.one(lambda: (ResultSet) -> T): T =
requireNotNull(oneOrNull(lambda)) { "Missing result to database query" }
+fun <T> PreparedStatement.oneUniqueViolation(err: T, lambda: (ResultSet) -> T): T {
+ return try {
+ one(lambda)
+ } catch (e: SQLException) {
+ if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) return err
+ throw e // rethrowing, not to hide other types of errors.
+ }
+}
+
fun <T> PreparedStatement.all(lambda: (ResultSet) -> T): List<T> {
executeQuery().use {
val ret = mutableListOf<T>()
diff --git a/common/src/main/kotlin/db/types.kt b/common/src/main/kotlin/db/types.kt
index 6986897f..cc7e55ff 100644
--- a/common/src/main/kotlin/db/types.kt
+++ b/common/src/main/kotlin/db/types.kt
@@ -22,6 +22,9 @@ package tech.libeufin.common.db
import tech.libeufin.common.BankPaytoCtx
import tech.libeufin.common.Payto
import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.DecimalNumber
+import tech.libeufin.common.TalerProtocolTimestamp
+import tech.libeufin.common.asInstant
import java.sql.ResultSet
fun ResultSet.getAmount(name: String, currency: String): TalerAmount {
@@ -32,6 +35,23 @@ fun ResultSet.getAmount(name: String, currency: String): TalerAmount {
)
}
+fun ResultSet.getOptAmount(name: String, currency: String): TalerAmount? {
+ val amount = getAmount(name, currency)
+ if (wasNull()) return null
+ return amount
+}
+
+fun ResultSet.getDecimal(name: String): DecimalNumber {
+ return DecimalNumber(
+ getLong("${name}_val"),
+ getInt("${name}_frac")
+ )
+}
+
+fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{
+ return TalerProtocolTimestamp(getLong(name).asInstant())
+}
+
fun ResultSet.getBankPayto(payto: String, name: String, ctx: BankPaytoCtx): String {
return Payto.parse(getString(payto)).bank(getString(name), ctx)
} \ No newline at end of file
diff --git a/common/src/main/kotlin/test/helpers.kt b/common/src/main/kotlin/test/helpers.kt
new file mode 100644
index 00000000..eb89ef93
--- /dev/null
+++ b/common/src/main/kotlin/test/helpers.kt
@@ -0,0 +1,65 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common.test
+
+import io.ktor.client.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import tech.libeufin.common.*
+
+/* ----- Assert ----- */
+
+suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) {
+ val start = System.currentTimeMillis()
+ lambda()
+ val end = System.currentTimeMillis()
+ val time = end - start
+ assert(time >= min) { "Expected to last at least $min ms, lasted $time" }
+ assert(time <= max) { "Expected to last at most $max ms, lasted $time" }
+}
+
+suspend inline fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B) -> List<Long>): B {
+ assertOk()
+ val body = json<B>()
+ val history = ids(body)
+ val params = PageParams.extract(call.request.url.parameters)
+
+ // testing the size is like expected.
+ assertEquals(size, history.size, "bad history length: $history")
+ if (params.delta < 0) {
+ // testing that the first id is at most the 'start' query param.
+ assert(history[0] <= params.start) { "bad history start: $params $history" }
+ // testing that the id decreases.
+ if (history.size > 1)
+ assert(history.windowed(2).all { (a, b) -> a > b }) { "bad history order: $history" }
+ } else {
+ // testing that the first id is at least the 'start' query param.
+ assert(history[0] >= params.start) { "bad history start: $params $history" }
+ // testing that the id increases.
+ if (history.size > 1)
+ assert(history.windowed(2).all { (a, b) -> a < b }) { "bad history order: $history" }
+ }
+
+ return body
+} \ No newline at end of file
diff --git a/common/src/main/kotlin/test/routines.kt b/common/src/main/kotlin/test/routines.kt
new file mode 100644
index 00000000..74368c41
--- /dev/null
+++ b/common/src/main/kotlin/test/routines.kt
@@ -0,0 +1,142 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common.test
+
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import tech.libeufin.common.*
+
+suspend inline fun <reified B> ApplicationTestBuilder.abstractHistoryRoutine(
+ crossinline ids: (B) -> List<Long>,
+ registered: List<suspend () -> Unit>,
+ ignored: List<suspend () -> Unit> = listOf(),
+ polling: Boolean = true,
+ crossinline history: suspend (String) -> HttpResponse,
+) {
+ // Check history is following specs
+ val assertHistory: suspend HttpResponse.(Int) -> Unit = { size: Int ->
+ assertHistoryIds<B>(size, ids)
+ }
+ // Get latest registered id
+ val latestId: suspend () -> Long = {
+ history("delta=-1").assertOkJson<B>().run { ids(this)[0] }
+ }
+
+ // Check error when no transactions
+ history("delta=7").assertNoContent()
+
+ // Run interleaved registered and ignore transactions
+ val registered_iter = registered.iterator()
+ val ignored_iter = ignored.iterator()
+ while (registered_iter.hasNext() || ignored_iter.hasNext()) {
+ if (registered_iter.hasNext()) registered_iter.next()()
+ if (ignored_iter.hasNext()) ignored_iter.next()()
+ }
+
+ val nbRegistered = registered.size
+ val nbIgnored = ignored.size
+ val nbTotal = nbRegistered + nbIgnored
+
+ // Check ignored
+ history("delta=$nbTotal").assertHistory(nbRegistered)
+ // Check skip ignored
+ history("delta=$nbRegistered").assertHistory(nbRegistered)
+
+ if (polling) {
+ // Check no polling when we cannot have more transactions
+ assertTime(0, 100) {
+ history("delta=-${nbRegistered+1}&long_poll_ms=1000")
+ .assertHistory(nbRegistered)
+ }
+ // Check no polling when already find transactions even if less than delta
+ assertTime(0, 100) {
+ history("delta=${nbRegistered+1}&long_poll_ms=1000")
+ .assertHistory(nbRegistered)
+ }
+
+ // Check polling
+ coroutineScope {
+ val id = latestId()
+ launch { // Check polling succeed
+ assertTime(100, 200) {
+ history("delta=2&start=$id&long_poll_ms=1000")
+ .assertHistory(1)
+ }
+ }
+ launch { // Check polling timeout
+ assertTime(200, 300) {
+ history("delta=1&start=${id+nbTotal*3}&long_poll_ms=200")
+ .assertNoContent()
+ }
+ }
+ delay(100)
+ registered[0]()
+ }
+
+ // Test triggers
+ for (register in registered) {
+ coroutineScope {
+ val id = latestId()
+ launch {
+ assertTime(100, 200) {
+ history("delta=7&start=$id&long_poll_ms=1000")
+ .assertHistory(1)
+ }
+ }
+ delay(100)
+ register()
+ }
+ }
+
+ // Test doesn't trigger
+ coroutineScope {
+ val id = latestId()
+ launch {
+ assertTime(200, 300) {
+ history("delta=7&start=$id&long_poll_ms=200")
+ .assertNoContent()
+ }
+ }
+ delay(100)
+ for (ignore in ignored) {
+ ignore()
+ }
+ }
+ }
+
+ // Testing ranges.
+ repeat(20) {
+ registered[0]()
+ }
+ val id = latestId()
+ // Default
+ history("").assertHistory(20)
+ // forward range:
+ history("delta=10").assertHistory(10)
+ history("delta=10&start=4").assertHistory(10)
+ // backward range:
+ history("delta=-10").assertHistory(10)
+ history("delta=-10&start=${id-4}").assertHistory(10)
+} \ No newline at end of file
diff --git a/contrib/nexus.conf b/contrib/nexus.conf
index c240df5f..b34f3c2c 100644
--- a/contrib/nexus.conf
+++ b/contrib/nexus.conf
@@ -40,6 +40,11 @@ CLIENT_PRIVATE_KEYS_FILE = ${LIBEUFIN_NEXUS_HOME}/client-ebics-keys.json
# Typically, it is named after the bank itself.
BANK_DIALECT = postfinance
+# Specify the account type and therefore the indexing behavior.
+# This can either can be normal or exchange.
+# Exchange accounts bounce invalid incoming Taler transactions.
+ACCOUNT_TYPE = exchange
+
[libeufin-nexusdb-postgres]
# Where are the SQL files to setup our tables?
SQL_DIR = $DATADIR/sql/
@@ -56,15 +61,27 @@ FREQUENCY = 30m
FREQUENCY = 30m
[nexus-httpd]
-PORT = 8080
+# How "libeufin-nexus serve" serves its API, this can either be tcp or unix
SERVE = tcp
+# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp.
+PORT = 8080
+
+# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Can also be given as a hostname. Only used if SERVE is tcp.
+BIND_TO = 0.0.0.0
+
+# Which unix domain path should we bind to? Only used if SERVE is unix.
+# UNIXPATH = libeufin-nexus.sock
+
+# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix.
+# UNIXPATH_MODE = 660
+
[nexus-httpd-wire-gateway-api]
ENABLED = NO
-AUTH_METHOD = token
-AUTH_TOKEN =
+AUTH_METHOD = bearer-token
+AUTH_BEARER_TOKEN =
[nexus-httpd-revenue-api]
ENABLED = NO
-AUTH_METHOD = token
-AUTH_TOKEN =
+AUTH_METHOD = bearer-token
+AUTH_BEARER_TOKEN =
diff --git a/database-versioning/libeufin-bank-0004.sql b/database-versioning/libeufin-bank-0004.sql
new file mode 100644
index 00000000..4d72ea70
--- /dev/null
+++ b/database-versioning/libeufin-bank-0004.sql
@@ -0,0 +1,25 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2024 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+BEGIN;
+
+SELECT _v.register_patch('libeufin-bank-0004', NULL, NULL);
+SET search_path TO libeufin_bank;
+
+ALTER TABLE bank_accounts ADD min_cashout taler_amount;
+COMMENT ON COLUMN bank_accounts.min_cashout
+ IS 'Custom minimum cashout amount for this account';
+
+COMMIT;
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
index 0dceea3c..4de1c261 100644
--- a/database-versioning/libeufin-bank-procedures.sql
+++ b/database-versioning/libeufin-bank-procedures.sql
@@ -984,7 +984,7 @@ SELECT bank_account_id
-- Perform conversion
SELECT (converted).val, (converted).frac, too_small, no_config
INTO converted_amount.val, converted_amount.frac, out_too_small, out_no_config
- FROM conversion_to(in_amount, 'cashin'::text);
+ FROM conversion_to(in_amount, 'cashin'::text, null);
IF out_too_small OR out_no_config THEN
RETURN;
END IF;
@@ -1036,6 +1036,7 @@ CREATE FUNCTION cashout_create(
OUT out_request_uid_reuse BOOLEAN,
OUT out_no_cashout_payto BOOLEAN,
OUT out_tan_required BOOLEAN,
+ OUT out_under_min BOOLEAN,
-- Success return
OUT out_cashout_id INT8
)
@@ -1044,19 +1045,20 @@ DECLARE
account_id INT8;
admin_account_id INT8;
tx_id INT8;
+custom_min_cashout taler_amount;
BEGIN
--- check conversion
-SELECT too_small OR no_config OR in_amount_credit!=converted INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text);
-IF out_bad_conversion THEN
- RETURN;
-END IF;
-- Check account exists, has all info and if 2FA is required
SELECT
- bank_account_id, is_taler_exchange, cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL)
- INTO account_id, out_account_is_exchange, out_no_cashout_payto, out_tan_required
+ bank_account_id, is_taler_exchange,
+ (min_cashout).val, (min_cashout).frac,
+ cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL)
+ INTO
+ account_id, out_account_is_exchange,
+ custom_min_cashout.val, custom_min_cashout.frac,
+ out_no_cashout_payto, out_tan_required
FROM bank_accounts
- JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id
+ JOIN customers ON owning_customer_id=customer_id
WHERE login=in_login;
IF NOT FOUND THEN
out_account_not_found=TRUE;
@@ -1065,6 +1067,17 @@ ELSIF out_account_is_exchange OR out_no_cashout_payto THEN
RETURN;
END IF;
+-- check conversion TODO use custom min
+IF custom_min_cashout.val IS NULL THEN
+ custom_min_cashout = NULL;
+END IF;
+SELECT under_min, too_small OR no_config OR in_amount_credit!=converted
+ INTO out_under_min, out_bad_conversion
+ FROM conversion_to(in_amount_debit, 'cashout'::text, custom_min_cashout);
+IF out_bad_conversion THEN
+ RETURN;
+END IF;
+
-- Retrieve admin account id
SELECT bank_account_id
INTO admin_account_id
@@ -1487,8 +1500,10 @@ COMMENT ON FUNCTION conversion_revert_ratio
CREATE FUNCTION conversion_to(
IN amount taler_amount,
IN direction TEXT,
+ IN custom_min_amount taler_amount,
OUT converted taler_amount,
OUT too_small BOOLEAN,
+ OUT under_min BOOLEAN,
OUT no_config BOOLEAN
)
LANGUAGE plpgsql AS $$
@@ -1505,8 +1520,13 @@ BEGIN
no_config = true;
RETURN;
END IF;
+ IF custom_min_amount IS NOT NULL THEN
+ min_amount = custom_min_amount;
+ END IF;
+
SELECT NOT ok INTO too_small FROM amount_left_minus_right(amount, min_amount);
IF too_small THEN
+ under_min = true;
converted = (0, 0);
RETURN;
END IF;
@@ -1528,8 +1548,10 @@ END $$;
CREATE FUNCTION conversion_from(
IN amount taler_amount,
IN direction TEXT,
+ IN custom_min_amount taler_amount,
OUT converted taler_amount,
OUT too_small BOOLEAN,
+ OUT under_min BOOLEAN,
OUT no_config BOOLEAN
)
LANGUAGE plpgsql AS $$
@@ -1554,8 +1576,12 @@ BEGIN
-- Check min amount
SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount';
+ IF custom_min_amount IS NOT NULL THEN
+ min_amount = custom_min_amount;
+ END IF;
SELECT NOT ok INTO too_small FROM amount_left_minus_right(converted, min_amount);
IF too_small THEN
+ under_min = true;
converted = (0, 0);
END IF;
END $$;
diff --git a/database-versioning/libeufin-nexus-0001.sql b/database-versioning/libeufin-nexus-0001.sql
index aae5d065..a6899843 100644
--- a/database-versioning/libeufin-nexus-0001.sql
+++ b/database-versioning/libeufin-nexus-0001.sql
@@ -41,7 +41,7 @@ COMMENT ON TYPE submission_state
never_heard_back is a fallback state, in case one successful submission did
never get confirmed via camt.5x or pain.002.';
-CREATE TABLE IF NOT EXISTS incoming_transactions
+CREATE TABLE incoming_transactions
(incoming_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,amount taler_amount NOT NULL
,wire_transfer_subject TEXT NOT NULL
@@ -53,12 +53,12 @@ COMMENT ON COLUMN incoming_transactions.bank_id
IS 'ISO20022 AccountServicerReference';
-- only active in exchange mode. Note: duplicate keys are another reason to bounce.
-CREATE TABLE IF NOT EXISTS talerable_incoming_transactions
+CREATE TABLE talerable_incoming_transactions
(incoming_transaction_id INT8 NOT NULL UNIQUE REFERENCES incoming_transactions(incoming_transaction_id) ON DELETE CASCADE
,reserve_public_key BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_public_key)=32)
);
-CREATE TABLE IF NOT EXISTS outgoing_transactions
+CREATE TABLE outgoing_transactions
(outgoing_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,amount taler_amount NOT NULL
,wire_transfer_subject TEXT
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS outgoing_transactions
COMMENT ON COLUMN outgoing_transactions.message_id
IS 'ISO20022 MessageIdentification';
-CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions
+CREATE TABLE initiated_outgoing_transactions
(initiated_outgoing_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
,amount taler_amount NOT NULL
,wire_transfer_subject TEXT NOT NULL
@@ -93,15 +93,15 @@ value will be used as a unique identifier for its related pain.001 document.
For this reason, it must have at most 35 characters';
-- only active in exchange mode.
-CREATE TABLE IF NOT EXISTS bounced_transactions
+CREATE TABLE bounced_transactions
(incoming_transaction_id INT8 NOT NULL UNIQUE REFERENCES incoming_transactions(incoming_transaction_id) ON DELETE CASCADE
,initiated_outgoing_transaction_id INT8 NOT NULL UNIQUE REFERENCES initiated_outgoing_transactions(initiated_outgoing_transaction_id) ON DELETE CASCADE
);
-CREATE INDEX IF NOT EXISTS incoming_transaction_timestamp
+CREATE INDEX incoming_transaction_timestamp
ON incoming_transactions (execution_time);
-CREATE INDEX IF NOT EXISTS outgoing_transaction_timestamp
+CREATE INDEX outgoing_transaction_timestamp
ON outgoing_transactions (execution_time);
COMMIT;
diff --git a/database-versioning/libeufin-nexus-0003.sql b/database-versioning/libeufin-nexus-0003.sql
new file mode 100644
index 00000000..0b1781c1
--- /dev/null
+++ b/database-versioning/libeufin-nexus-0003.sql
@@ -0,0 +1,36 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2024 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+BEGIN;
+
+SELECT _v.register_patch('libeufin-nexus-0003', NULL, NULL);
+
+SET search_path TO libeufin_nexus;
+
+CREATE TABLE talerable_outgoing_transactions
+ ( outgoing_transaction_id INT8 UNIQUE NOT NULL REFERENCES outgoing_transactions(outgoing_transaction_id) ON DELETE CASCADE
+ ,wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32)
+ ,exchange_base_url TEXT NOT NULL
+ );
+
+CREATE TABLE transfer_operations
+ ( initiated_outgoing_transaction_id INT8 UNIQUE NOT NULL REFERENCES initiated_outgoing_transactions(initiated_outgoing_transaction_id) ON DELETE CASCADE
+ ,request_uid BYTEA UNIQUE NOT NULL CHECK (LENGTH(request_uid)=64)
+ ,wtid BYTEA UNIQUE NOT NULL CHECK (LENGTH(wtid)=32)
+ ,exchange_base_url TEXT NOT NULL
+ );
+COMMENT ON TABLE transfer_operations
+ IS 'Operation table for idempotent wire gateway transfers.';
+COMMIT;
diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql
index 8e50916f..db256da7 100644
--- a/database-versioning/libeufin-nexus-procedures.sql
+++ b/database-versioning/libeufin-nexus-procedures.sql
@@ -33,6 +33,8 @@ CREATE FUNCTION register_outgoing(
,IN in_execution_time INT8
,IN in_credit_payto_uri TEXT
,IN in_message_id TEXT
+ ,IN in_wtid BYTEA
+ ,IN in_exchange_url TEXT
,OUT out_tx_id INT8
,OUT out_found BOOLEAN
,OUT out_initiated BOOLEAN
@@ -79,6 +81,19 @@ ELSE
WHERE request_uid = in_message_id
RETURNING true INTO out_initiated;
END IF;
+
+-- Register as talerable if contains wtid and exchange URL
+IF in_wtid IS NOT NULL OR in_exchange_url IS NOT NULL THEN
+ INSERT INTO talerable_outgoing_transactions (
+ outgoing_transaction_id,
+ wtid,
+ exchange_base_url
+ ) VALUES (out_tx_id, in_wtid, in_exchange_url)
+ ON CONFLICT (wtid) DO NOTHING;
+ IF FOUND THEN
+ PERFORM pg_notify('outgoing_tx', out_tx_id::text);
+ END IF;
+END IF;
END $$;
COMMENT ON FUNCTION register_outgoing
IS 'Register an outgoing transaction and optionally reconciles the related initiated transaction with it';
@@ -116,6 +131,7 @@ ELSE
,in_debit_payto_uri
,in_bank_id
) RETURNING incoming_transaction_id INTO out_tx_id;
+ PERFORM pg_notify('revenue_tx', out_tx_id::text);
END IF;
END $$;
COMMENT ON FUNCTION register_incoming
@@ -216,7 +232,7 @@ BEGIN
-- Check conflict
IF EXISTS (
SELECT FROM talerable_incoming_transactions
- JOIN incoming_transactions ON talerable_incoming_transactions.incoming_transaction_id=incoming_transactions.incoming_transaction_id
+ JOIN incoming_transactions USING(incoming_transaction_id)
WHERE reserve_public_key = in_reserve_public_key
AND bank_id != in_bank_id
) THEN
@@ -239,9 +255,71 @@ IF NOT EXISTS(SELECT 1 FROM talerable_incoming_transactions WHERE incoming_trans
out_tx_id
,in_reserve_public_key
);
+ PERFORM pg_notify('incoming_tx', out_tx_id::text);
END IF;
END $$;
COMMENT ON FUNCTION register_incoming_and_talerable IS '
Creates one row in the incoming transactions table and one row
in the talerable transactions table. The talerable row links the
-incoming one.'; \ No newline at end of file
+incoming one.';
+
+CREATE FUNCTION taler_transfer(
+ IN in_request_uid BYTEA,
+ IN in_wtid BYTEA,
+ IN in_subject TEXT,
+ IN in_amount taler_amount,
+ IN in_exchange_base_url TEXT,
+ IN in_credit_account_payto TEXT,
+ IN in_bank_id TEXT,
+ IN in_timestamp INT8,
+ -- Error status
+ OUT out_request_uid_reuse BOOLEAN,
+ -- Success return
+ OUT out_tx_row_id INT8,
+ OUT out_timestamp INT8
+)
+LANGUAGE plpgsql AS $$
+BEGIN
+-- Check for idempotence and conflict
+SELECT (amount != in_amount
+ OR credit_payto_uri != in_credit_account_payto
+ OR exchange_base_url != in_exchange_base_url
+ OR wtid != in_wtid)
+ ,transfer_operations.initiated_outgoing_transaction_id, initiation_time
+ INTO out_request_uid_reuse, out_tx_row_id, out_timestamp
+ FROM transfer_operations
+ JOIN initiated_outgoing_transactions
+ ON transfer_operations.initiated_outgoing_transaction_id=initiated_outgoing_transactions.initiated_outgoing_transaction_id
+ WHERE transfer_operations.request_uid = in_request_uid;
+IF FOUND THEN
+ RETURN;
+END IF;
+-- Initiate bank transfer
+INSERT INTO initiated_outgoing_transactions (
+ amount
+ ,wire_transfer_subject
+ ,credit_payto_uri
+ ,initiation_time
+ ,request_uid
+) VALUES (
+ in_amount
+ ,in_subject
+ ,in_credit_account_payto
+ ,in_timestamp
+ ,in_bank_id
+) RETURNING initiated_outgoing_transaction_id INTO out_tx_row_id;
+-- Register outgoing transaction
+INSERT INTO transfer_operations(
+ initiated_outgoing_transaction_id
+ ,request_uid
+ ,wtid
+ ,exchange_base_url
+) VALUES (
+ out_tx_row_id
+ ,in_request_uid
+ ,in_wtid
+ ,in_exchange_base_url
+);
+out_timestamp = in_timestamp;
+PERFORM pg_notify('outgoing_tx', out_tx_row_id::text);
+END $$;
diff --git a/debian/changelog b/debian/changelog
index ca262609..1a534634 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+libeufin (0.10.3) unstable; urgency=low
+
+ * Package v0.10.3 with support for instant payments.
+
+ -- Christian Grothoff <grothoff@gnu.org> Mon, 6 May 2024 11:18:26 +0200
+
libeufin (0.10.2) unstable; urgency=low
* Package v0.10.2
diff --git a/debian/control b/debian/control
index 4f287d2b..694e995d 100644
--- a/debian/control
+++ b/debian/control
@@ -6,7 +6,6 @@ Uploaders: Christian Grothoff <grothoff@gnu.org>, Florian Dold <dold@taler.net>
Build-Depends:
debhelper-compat (= 12),
unzip,
- python3-distutils,
openjdk-17-jdk-headless | openjdk-17-jdk | openjdk-18-jdk-headless | openjdk-18-jdk | openjdk-19-jdk-headless | openjdk-19-jdk | openjdk-20-jdk-headless | openjdk-20-jdk | openjdk-21-jdk-headless | openjdk-21-jdk
Standards-Version: 4.1.0
Vcs-Git: https://git.taler.net/libeufin.git
diff --git a/debian/libeufin-nexus.libeufin-nexus-httpd.service b/debian/libeufin-nexus.libeufin-nexus-httpd.service
new file mode 100644
index 00000000..b6809c72
--- /dev/null
+++ b/debian/libeufin-nexus.libeufin-nexus-httpd.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=LibEuFin Nexus Server Service
+After=postgres.service network.target
+PartOf=libeufin-nexus.target
+
+[Service]
+User=libeufin-nexus
+ExecStart=/usr/bin/libeufin-nexus serve -c /etc/libeufin/libeufin-nexus.conf
+ExecCondition=/usr/bin/libeufin-nexus serve -c /etc/libeufin/libeufin-nexus.conf --check
+Restart=on-failure
+RestartSec=1s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/libeufin-nexus.target b/debian/libeufin-nexus.target
index 1715a749..024bc288 100644
--- a/debian/libeufin-nexus.target
+++ b/debian/libeufin-nexus.target
@@ -4,6 +4,7 @@ After=postgres.service network.target
Wants=libeufin-nexus-ebics-fetch.service
Wants=libeufin-nexus-ebics-submit.service
+Wants=libeufin-nexus-httpd.service
[Install]
WantedBy=multi-user.target \ No newline at end of file
diff --git a/debian/rules b/debian/rules
index 682794dd..a5c9b0b6 100755
--- a/debian/rules
+++ b/debian/rules
@@ -39,12 +39,13 @@ override_dh_install:
override_dh_installsystemd:
# Need to specify units manually, since we have multiple
# and dh_installsystemd by default only looks for "<package>.service".
- dh_installsystemd -plibeufin-bank --name=libeufin-bank --no-start --no-enable --no-stop-on-upgrade
+ dh_installsystemd -plibeufin-bank --name=libeufin-bank.service --no-start --no-enable --no-stop-on-upgrade
dh_installsystemd -plibeufin-bank --name=libeufin-bank-gc --no-start --no-enable --no-stop-on-upgrade
dh_installsystemd -plibeufin-bank --name=libeufin-bank-gc.timer --no-start --no-enable --no-stop-on-upgrade
dh_installsystemd -plibeufin-bank --name=libeufin-bank --no-start --no-enable --no-stop-on-upgrade
dh_installsystemd -plibeufin-nexus --name=libeufin-nexus-ebics-submit --no-start --no-enable --no-stop-on-upgrade
dh_installsystemd -plibeufin-nexus --name=libeufin-nexus-ebics-fetch --no-start --no-enable --no-stop-on-upgrade
+ dh_installsystemd -plibeufin-nexus --name=libeufin-nexus-httpd --no-start --no-enable --no-stop-on-upgrade
dh_installsystemd -plibeufin-nexus --name=libeufin-nexus --no-start --no-enable --no-stop-on-upgrade
# final invocation to generate daemon reload
dh_installsystemd
diff --git a/nexus/conf/mini.conf b/nexus/conf/mini.conf
new file mode 100644
index 00000000..1b52e17f
--- /dev/null
+++ b/nexus/conf/mini.conf
@@ -0,0 +1,15 @@
+[nexus-ebics]
+CURRENCY = CHF
+BANK_DIALECT = postfinance
+HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb
+BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json
+CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json
+IBAN = CH7789144474425692816
+HOST_ID = PFEBICS
+USER_ID = PFC00563
+PARTNER_ID = PFC00563
+BIC = BIC
+NAME = myname
+
+[libeufin-nexusdb-postgres]
+CONFIG = postgres:///libeufincheck \ No newline at end of file
diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf
index e6d52fff..fc4b0946 100644
--- a/nexus/conf/test.conf
+++ b/nexus/conf/test.conf
@@ -15,4 +15,14 @@ NAME = myname
CONFIG = postgres:///libeufincheck
[nexus-fetch]
-IGNORE_TRANSACTIONS_BEFORE = 2024-04-04 \ No newline at end of file
+IGNORE_TRANSACTIONS_BEFORE = 2024-04-04
+
+[nexus-httpd-wire-gateway-api]
+ENABLED = YES
+AUTH_METHOD = bearer-token
+AUTH_BEARER_TOKEN = secret-token
+
+[nexus-httpd-revenue-api]
+ENABLED = YES
+AUTH_METHOD = bearer-token
+AUTH_BEARER_TOKEN = secret-token \ No newline at end of file
diff --git a/nexus/sample/platform/gls_camt052.xml b/nexus/sample/platform/gls_camt052.xml
new file mode 100644
index 00000000..94bdfe5e
--- /dev/null
+++ b/nexus/sample/platform/gls_camt052.xml
@@ -0,0 +1,314 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02 camt.052.001.02.xsd">
+ <BkToCstmrAcctRpt>
+ <Rpt>
+ <Ntry>
+ <Amt Ccy="EUR">2.00</Amt>
+ <CdtDbtInd>DBIT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-18</Dt>
+ </BookgDt>
+ <AcctSvcrRef>2024041801514102000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>G059N0SR5V0WZ0XSFY1H92QBZ0</MsgId>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>2024041785403105090200000010000001</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">2.00</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ <Prtry>
+ <Cd>NTRF+177+08381</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>Mr Test</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>John Smith</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <CdtrAgt>
+ <FinInstnId>
+ <BIC>BYLADEM1WOR</BIC>
+ </FinInstnId>
+ </CdtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>TestABC123</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Ãœberweisungsauftrag</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="EUR">1.10</Amt>
+ <CdtDbtInd>DBIT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-18</Dt>
+ </BookgDt>
+ <ValDt>
+ <Dt>2024-04-18</Dt>
+ </ValDt>
+ <AcctSvcrRef>2024041810552821000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ <Prtry>
+ <Cd>NTRF+177+08381</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <MsgId>YF5QBARGQ0MNY0VK59S477VDG4</MsgId>
+ <PmtInfId>NOTPROVIDED</PmtInfId>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>2024041885917775090200000010000001</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">1.10</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ <Prtry>
+ <Cd>NTRF+177+08381</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>Mr Test</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>John Smith</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <CdtrAgt>
+ <FinInstnId>
+ <BIC>INGDDEFFXXX</BIC>
+ </FinInstnId>
+ </CdtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>This should fail because dummy</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Ãœberweisungsauftrag</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="EUR">3.00</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-12</Dt>
+ </BookgDt>
+ <AcctSvcrRef>2024041210041357000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>BYLADEM1WOR-G2910276709458A2</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">3.00</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RCDT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>John Smith</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>Mr Test</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <DbtrAgt>
+ <FinInstnId>
+ <BIC>BYLADEM1WOR</BIC>
+ </FinInstnId>
+ </DbtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Ãœberweisungsgutschr.</AddtlNtryInf>
+ </Ntry>
+ <Ntry>
+ <Amt Ccy="EUR">1.10</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <Sts>BOOK</Sts>
+ <BookgDt>
+ <Dt>2024-04-12</Dt>
+ </BookgDt>
+ <AcctSvcrRef>2024041210041357000</AcctSvcrRef>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <EndToEndId>G27KNKZAR5DV7HRB085YMA9GB4</EndToEndId>
+ <TxId>2024042288942205090200000010000001</TxId>
+ </Refs>
+ <AmtDtls>
+ <TxAmt>
+ <Amt Ccy="EUR">1.10</Amt>
+ </TxAmt>
+ </AmtDtls>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>ICDT</Cd>
+ <SubFmlyCd>RRTN</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <RltdPties>
+ <Dbtr>
+ <Nm>John Smith</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>Mr Test</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RtrInf>
+ <OrgnlBkTxCd>
+ <Prtry>
+ <Cd>116</Cd>
+ <Issr>DK</Issr>
+ </Prtry>
+ </OrgnlBkTxCd>
+ <Orgtr>
+ <Id>
+ <OrgId>
+ <BICOrBEI>GENODEM1GLS</BICOrBEI>
+ </OrgId>
+ </Id>
+ </Orgtr>
+ <Rsn>
+ <Cd>AC01</Cd>
+ </Rsn>
+ <AddtlInf>IBAN ...</AddtlInf>
+ </RtrInf>
+ </TxDtls>
+ </NtryDtls>
+ <AddtlNtryInf>Ãœberweisungsgutschr.</AddtlNtryInf>
+ </Ntry>
+ </Rpt>
+ </BkToCstmrAcctRpt>
+</Document>
+ \ No newline at end of file
diff --git a/nexus/sample/platform/gls.xml b/nexus/sample/platform/gls_camt053.xml
index ec40c54a..ec40c54a 100644
--- a/nexus/sample/platform/gls.xml
+++ b/nexus/sample/platform/gls_camt053.xml
diff --git a/nexus/sample/platform/gls_camt054.xml b/nexus/sample/platform/gls_camt054.xml
new file mode 100644
index 00000000..4b789f91
--- /dev/null
+++ b/nexus/sample/platform/gls_camt054.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08">
+ <BkToCstmrDbtCdtNtfctn>
+ <GrpHdr>
+ <MsgId>IS11PGENODEFF2DA8899900378806</MsgId>
+ </GrpHdr>
+ <Ntfctn>
+ <Ntry>
+ <Amt Ccy="EUR">2.50</Amt>
+ <CdtDbtInd>CRDT</CdtDbtInd>
+ <Sts>
+ <Cd>BOOK</Cd>
+ </Sts>
+ <ValDt>
+ <Dt>2024-05-05</Dt>
+ </ValDt>
+ <BkTxCd>
+ <Domn>
+ <Cd>PMNT</Cd>
+ <Fmly>
+ <Cd>RRCT</Cd>
+ <SubFmlyCd>ESCT</SubFmlyCd>
+ </Fmly>
+ </Domn>
+ </BkTxCd>
+ <NtryDtls>
+ <TxDtls>
+ <Refs>
+ <EndToEndId>NOTPROVIDED</EndToEndId>
+ <TxId>IS11PGENODEFF2DA8899900378806</TxId>
+ </Refs>
+ <RltdPties>
+ <Dbtr>
+ <Nm>Mr Test</Nm>
+ </Dbtr>
+ <DbtrAcct>
+ <Id>
+ <IBAN>DE84500105177118117964</IBAN>
+ </Id>
+ </DbtrAcct>
+ <Cdtr>
+ <Nm>John Smith</Nm>
+ </Cdtr>
+ <CdtrAcct>
+ <Id>
+ <IBAN>DE20500105172419259181</IBAN>
+ </Id>
+ </CdtrAcct>
+ </RltdPties>
+ <RltdAgts>
+ <DbtrAgt>
+ <FinInstnId>
+ <BICFI>BYLADEM1WOR</BICFI>
+ </FinInstnId>
+ </DbtrAgt>
+ <CdtrAgt>
+ <FinInstnId>
+ <BICFI>GENODEM1GLS</BICFI>
+ </FinInstnId>
+ </CdtrAgt>
+ </RltdAgts>
+ <RmtInf>
+ <Ustrd>Test ICT</Ustrd>
+ </RmtInf>
+ </TxDtls>
+ </NtryDtls>
+ </Ntry>
+ </Ntfctn>
+ </BkToCstmrDbtCdtNtfctn>
+</Document> \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt
index 59094204..823ed449 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt
@@ -31,9 +31,13 @@ class NexusFetchConfig(config: TalerConfig) {
val ignoreBefore = config.lookupDate("nexus-fetch", "ignore_transactions_before")
}
+class ApiConfig(config: TalerConfig, section: String) {
+ val authMethod = config.requireAuthMethod(section)
+}
+
/** Configuration for libeufin-nexus */
class NexusConfig(val config: TalerConfig) {
- private fun requireString(option: String): String = config.requireString("nexus-ebics", option)
+ private fun requireString(option: String, type: String? = null): String = config.requireString("nexus-ebics", option, type)
private fun requirePath(option: String): Path = config.requirePath("nexus-ebics", option)
/** The bank's currency */
@@ -52,17 +56,26 @@ class NexusConfig(val config: TalerConfig) {
bic = requireString("bic"),
name = requireString("name")
)
+ /** Bank account payto */
+ val payto = IbanPayto.build(account.iban, account.bic, account.name)
/** Path where we store the bank public keys */
val bankPublicKeysPath = requirePath("bank_public_keys_file")
/** Path where we store our private keys */
val clientPrivateKeysPath = requirePath("client_private_keys_file")
val fetch = NexusFetchConfig(config)
- val dialect = when (val type = requireString("bank_dialect")) {
+ val dialect = when (val type = requireString("bank_dialect", "dialect")) {
"postfinance" -> Dialect.postfinance
"gls" -> Dialect.gls
- else -> throw TalerConfigError.invalid("dialct", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'")
+ else -> throw TalerConfigError.invalid("bank dialect", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'")
}
+ val accountType = when (val type = requireString("account_type", "account type")) {
+ "normal" -> AccountType.normal
+ "exchange" -> AccountType.exchange
+ else -> throw TalerConfigError.invalid("account type", "libeufin-nexus", "account_type", "expected 'normal' or 'exchange' got '$type'")
+ }
+ val wireGatewayApiCfg = config.apiConf("nexus-httpd-wire-gateway-api")
+ val revenueApiCfg = config.apiConf("nexus-httpd-revenue-api")
}
fun NexusConfig.checkCurrency(amount: TalerAmount) {
@@ -70,4 +83,34 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) {
"Wrong currency: expected regional $currency got ${amount.currency}",
TalerErrorCode.GENERIC_CURRENCY_MISMATCH
)
+}
+
+fun TalerConfig.requireAuthMethod(section: String): AuthMethod {
+ return when (val method = requireString(section, "auth_method", "auth method")) {
+ "none" -> AuthMethod.None
+ "bearer-token" -> {
+ val token = requireString(section, "auth_bearer_token")
+ AuthMethod.Bearer(token)
+ }
+ else -> throw TalerConfigError.invalid("auth method target type", section, "auth_method", "expected 'bearer-token' or 'none' got '$method'")
+ }
+}
+
+fun TalerConfig.apiConf(section: String): ApiConfig? {
+ val enabled = requireBoolean(section, "enabled")
+ return if (enabled) {
+ return ApiConfig(this, section)
+ } else {
+ null
+ }
+}
+
+sealed interface AuthMethod {
+ data object None: AuthMethod
+ data class Bearer(val token: String): AuthMethod
+}
+
+enum class AccountType {
+ normal,
+ exchange
} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index 96710648..f1e85513 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -95,7 +95,10 @@ suspend fun ingestOutgoingPayment(
db: Database,
payment: OutgoingPayment
) {
- val result = db.payment.registerOutgoing(payment)
+ val metadata: Pair<ShortHashCode, ExchangeUrl>? = payment.wireTransferSubject?.let {
+ runCatching { parseOutgoingTxMetadata(it) }.getOrNull()
+ }
+ val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second)
if (result.new) {
if (result.initiated)
logger.info("$payment")
@@ -106,8 +109,6 @@ suspend fun ingestOutgoingPayment(
}
}
-private val PATTERN = Regex("[a-z0-9A-Z]{52}")
-
/**
* Ingests an incoming payment. Stores the payment into valid talerable ones
* or bounces it, according to the subject.
@@ -117,18 +118,31 @@ private val PATTERN = Regex("[a-z0-9A-Z]{52}")
*/
suspend fun ingestIncomingPayment(
db: Database,
- payment: IncomingPayment
+ payment: IncomingPayment,
+ accountType: AccountType
) {
suspend fun bounce(msg: String) {
- val result = db.payment.registerMalformedIncoming(
- payment,
- payment.amount,
- Instant.now()
- )
- if (result.new) {
- logger.info("$payment bounced in '${result.bounceId}': $msg")
- } else {
- logger.debug("$payment already seen and bounced in '${result.bounceId}': $msg")
+ when (accountType) {
+ AccountType.exchange -> {
+ val result = db.payment.registerMalformedIncoming(
+ payment,
+ payment.amount,
+ Instant.now()
+ )
+ if (result.new) {
+ logger.info("$payment bounced in '${result.bounceId}': $msg")
+ } else {
+ logger.debug("$payment already seen and bounced in '${result.bounceId}': $msg")
+ }
+ }
+ AccountType.normal -> {
+ val res = db.payment.registerIncoming(payment)
+ if (res.new) {
+ logger.info("$payment")
+ } else {
+ logger.debug("$payment already seen")
+ }
+ }
}
}
runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold(
@@ -156,14 +170,14 @@ private suspend fun ingestDocument(
whichDocument: SupportedDocument
) {
when (whichDocument) {
- SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> {
+ SupportedDocument.CAMT_052, SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> {
try {
parseTx(xml, cfg.currency, cfg.dialect).forEach {
if (cfg.fetch.ignoreBefore != null && it.executionTime < cfg.fetch.ignoreBefore) {
logger.debug("IGNORE $it")
} else {
when (it) {
- is IncomingPayment -> ingestIncomingPayment(db, it)
+ is IncomingPayment -> ingestIncomingPayment(db, it, cfg.accountType)
is OutgoingPayment -> ingestOutgoingPayment(db, it)
is TxNotification.Reversal -> {
logger.error("BOUNCE '${it.msgId}': ${it.reason}")
@@ -212,10 +226,6 @@ private suspend fun ingestDocument(
db.initiated.bankMessage(status.msgId, msg)
}
}
- SupportedDocument.CAMT_052 -> {
- // TODO parsing
- // TODO ingesting
- }
}
}
@@ -307,15 +317,18 @@ enum class EbicsDocument {
acknowledgement,
/// Payment status - CustomerPaymentStatusReport pain.002
status,
- /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054
- notification,
+ /// Account intraday reports - BankToCustomerAccountReport camt.052
+ report,
/// Account statements - BankToCustomerStatement camt.053
statement,
+ /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054
+ notification,
;
fun shortDescription(): String = when (this) {
acknowledgement -> "EBICS acknowledgement"
status -> "Payment status"
+ report -> "Account intraday reports"
statement -> "Account statements"
notification -> "Debit & credit notifications"
}
@@ -323,6 +336,7 @@ enum class EbicsDocument {
fun fullDescription(): String = when (this) {
acknowledgement -> "EBICS acknowledgement - CustomerAcknowledgement HAC pain.002"
status -> "Payment status - CustomerPaymentStatusReport pain.002"
+ report -> "Account intraday reports - BankToCustomerAccountReport camt.052"
statement -> "Account statements - BankToCustomerStatement camt.053"
notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054"
}
@@ -330,6 +344,7 @@ enum class EbicsDocument {
fun doc(): SupportedDocument = when (this) {
acknowledgement -> SupportedDocument.PAIN_002_LOGS
status -> SupportedDocument.PAIN_002
+ report -> SupportedDocument.CAMT_052
statement -> SupportedDocument.CAMT_053
notification -> SupportedDocument.CAMT_054
}
@@ -363,10 +378,10 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") {
* mode when no flags are passed to the invocation.
*/
override fun run() = cliCmd(logger, common.log) {
- val cfg = extractEbicsConfig(common.config)
+ val cfg = loadNexusConfig(common.config)
val dbCfg = cfg.config.dbConfig()
- Database(dbCfg).use { db ->
+ Database(dbCfg, cfg.currency).use { db ->
val (clientKeys, bankKeys) = expectFullKeys(cfg)
val ctx = FetchContext(
cfg,
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
index 1c9ea902..7da7da07 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
@@ -155,7 +155,7 @@ suspend fun doKeysRequestAndUpdateState(
* @param configFile location of the configuration entry point.
* @return internal representation of the configuration.
*/
-fun extractEbicsConfig(configFile: Path?): NexusConfig {
+fun loadNexusConfig(configFile: Path?): NexusConfig {
val config = loadConfig(configFile)
return NexusConfig(config)
}
@@ -197,8 +197,8 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") {
* This function collects the main steps of setting up an EBICS access.
*/
override fun run() = cliCmd(logger, common.log) {
- val cfg = extractEbicsConfig(common.config)
- val client = HttpClient {
+ val cfg = loadNexusConfig(common.config)
+ val client = HttpClient {
install(HttpTimeout) {
// It can take a lot of time for the bank to generate documents
socketTimeoutMillis = 5 * 60 * 1000
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
index 8bde6d60..c6a6ceef 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
@@ -65,7 +65,7 @@ data class SubmissionContext(
private suspend fun submitInitiatedPayment(
ctx: SubmissionContext,
payment: InitiatedPayment
-): String {
+): String {
val creditAccount = try {
val payto = Payto.parse(payment.creditPaytoUri).expectIban()
IbanAccountMetadata(
@@ -147,7 +147,7 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat
* FIXME: reduce code duplication with the fetch subcommand.
*/
override fun run() = cliCmd(logger, common.log) {
- val cfg = extractEbicsConfig(common.config)
+ val cfg = loadNexusConfig(common.config)
val dbCfg = cfg.config.dbConfig()
val (clientKeys, bankKeys) = expectFullKeys(cfg)
val ctx = SubmissionContext(
@@ -157,7 +157,7 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat
httpClient = HttpClient(),
fileLogger = FileLogger(ebicsLog)
)
- Database(dbCfg).use { db ->
+ Database(dbCfg, cfg.currency).use { db ->
val frequency: Duration = if (transient) {
logger.info("Transient mode: submitting what found and returning.")
Duration.ZERO
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index 192d7375..fce0b224 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -300,13 +300,9 @@ data class OutgoingPayment(
private fun XmlDestructor.payto(prefix: String): String? {
val iban = opt("${prefix}Acct")?.one("Id")?.one("IBAN")?.text()
return if (iban != null) {
- val payto = StringBuilder("payto://iban/$iban")
val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() }
- if (name != null) {
- val urlEncName = URLEncoder.encode(name, "utf-8")
- payto.append("?receiver-name=$urlEncName")
- }
- return payto.toString()
+ // Parse bic ?
+ IbanPayto.build(iban, null, name)
} else {
null
}
@@ -343,8 +339,10 @@ fun parseTx(
}
}
- fun XmlDestructor.bookDate() =
- one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC)
+ fun XmlDestructor.executionDate(): Instant {
+ // Value date if present else booking date
+ return (opt("ValDt") ?: one("BookgDt")).one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC)
+ }
fun XmlDestructor.nexusId(): String? =
opt("Refs") { opt("EndToEndId")?.textProvided() ?: opt("MsgId")?.text() }
@@ -385,14 +383,14 @@ fun parseTx(
XmlDestructor.fromStream(notifXml, "Document") { when (dialect) {
Dialect.gls -> {
- opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
+ fun XmlDestructor.parseGlsInner() {
opt("Acct") {
// Sanity check on currency and IBAN ?
}
each("Ntry") {
val entryRef = opt("AcctSvcrRef")?.text()
assertBooked(entryRef)
- val bookDate = bookDate()
+ val bookDate = executionDate()
val kind = one("CdtDbtInd").enum<Kind>()
val amount = amount(acceptedCurrency)
one("NtryDtls").one("TxDtls") { // TODO handle batches
@@ -440,6 +438,42 @@ fun parseTx(
}
}
}
+ opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052
+ parseGlsInner()
+ }
+ opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
+ parseGlsInner()
+ }
+ opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
+ opt("Acct") {
+ // Sanity check on currency and IBAN ?
+ }
+ each("Ntry") {
+ val entryRef = opt("AcctSvcrRef")?.text()
+ assertBooked(entryRef)
+ val bookDate = executionDate()
+ val kind = one("CdtDbtInd").enum<Kind>()
+ val amount = amount(acceptedCurrency)
+ if (!isReversalCode()) {
+ one("NtryDtls").one("TxDtls") {
+ val txRef = one("Refs").opt("AcctSvcrRef")?.text()
+ val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("")
+ if (kind == Kind.CRDT) {
+ val bankId = one("Refs").opt("TxId")?.text()
+ val debtorPayto = opt("RltdPties") { payto("Dbtr") }
+ txsInfo.add(TxInfo.Credit(
+ ref = bankId ?: txRef ?: entryRef,
+ bookDate = bookDate,
+ bankId = bankId,
+ amount = amount,
+ subject = subject,
+ debtorPayto = debtorPayto
+ ))
+ }
+ }
+ }
+ }
+ }
}
Dialect.postfinance -> {
opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
@@ -449,7 +483,7 @@ fun parseTx(
each("Ntry") {
val entryRef = opt("AcctSvcrRef")?.text()
assertBooked(entryRef)
- val bookDate = bookDate()
+ val bookDate = executionDate()
if (isReversalCode()) {
one("NtryDtls").one("TxDtls") {
val kind = one("CdtDbtInd").enum<Kind>()
@@ -475,7 +509,7 @@ fun parseTx(
each("Ntry") {
val entryRef = opt("AcctSvcrRef")?.text()
assertBooked(entryRef)
- val bookDate = bookDate()
+ val bookDate = executionDate()
if (!isReversalCode()) {
one("NtryDtls").each("TxDtls") {
val kind = one("CdtDbtInd").enum<Kind>()
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index b3153a8e..f93b1829 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -27,14 +27,17 @@ package tech.libeufin.nexus
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
-import com.github.ajalt.clikt.parameters.arguments.argument
-import com.github.ajalt.clikt.parameters.arguments.convert
-import com.github.ajalt.clikt.parameters.groups.provideDelegate
+import com.github.ajalt.clikt.parameters.arguments.*
+import com.github.ajalt.clikt.parameters.groups.*
import com.github.ajalt.clikt.parameters.options.*
-import com.github.ajalt.clikt.parameters.types.path
+import com.github.ajalt.clikt.parameters.types.*
+import com.github.ajalt.clikt.core.ProgramResult
+import io.ktor.client.*
+import io.ktor.client.plugins.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.Serializable
import kotlin.io.path.*
+import kotlin.math.max
import io.ktor.server.application.*
import org.slf4j.Logger
import org.slf4j.event.Level
@@ -44,11 +47,11 @@ import tech.libeufin.common.api.*
import tech.libeufin.common.crypto.*
import tech.libeufin.common.db.DatabaseConfig
import tech.libeufin.nexus.api.*
+import tech.libeufin.nexus.ebics.*
import tech.libeufin.nexus.db.Database
import tech.libeufin.nexus.db.InitiatedPayment
import java.nio.file.Path
-import java.time.Instant
-import java.time.ZoneId
+import java.time.*
import java.time.format.DateTimeFormatter
import javax.crypto.EncryptedPrivateKeyInfo
@@ -73,6 +76,7 @@ fun Instant.fmtDateTime(): String =
fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(logger) {
wireGatewayApi(db, cfg)
+ revenueApi(db, cfg)
}
/**
@@ -130,7 +134,7 @@ class InitiatePayment: CliktCommand("Initiate an outgoing payment") {
Base32Crockford.encode(bytes)
}
- Database(dbCfg).use { db ->
+ Database(dbCfg, currency).use { db ->
db.initiated.create(
InitiatedPayment(
id = -1,
@@ -145,100 +149,41 @@ class InitiatePayment: CliktCommand("Initiate an outgoing payment") {
}
}
-class ConvertBackup: CliktCommand("Convert an old backup to the new config format") {
- private val backupPath by argument(
- "backup",
- help = "Specifies the backup file"
- ).path()
-
- @Serializable
- data class EbicsKeysBackupJson(
- val userID: String,
- val partnerID: String,
- val hostID: String,
- val ebicsURL: String,
- val authBlob: String,
- val encBlob: String,
- val sigBlob: String
- )
+class Serve : CliktCommand("Run libeufin-nexus HTTP server", name = "serve") {
+ private val common by CommonOption()
+ private val check by option().flag()
- override fun run() = cliCmd(logger, Level.INFO) {
- val raw = backupPath.readText()
- val backup = Json.decodeFromString<EbicsKeysBackupJson>(raw)
-
- val (authBlob, encBlob, sigBlob) = Triple(
- EncryptedPrivateKeyInfo(backup.authBlob.decodeBase64()),
- EncryptedPrivateKeyInfo(backup.encBlob.decodeBase64()),
- EncryptedPrivateKeyInfo(backup.sigBlob.decodeBase64())
- )
- lateinit var keys: ClientPrivateKeysFile
- while (true) {
- val passphrase = prompt("Enter the backup password", hideInput = true)!!
- try {
- val (authKey, encKey, sigKey) = Triple(
- CryptoUtil.decryptKey(authBlob, passphrase),
- CryptoUtil.decryptKey(encBlob, passphrase),
- CryptoUtil.decryptKey(sigBlob, passphrase)
- )
- keys = ClientPrivateKeysFile(
- signature_private_key = sigKey,
- encryption_private_key = encKey,
- authentication_private_key = authKey,
- submitted_ini = false,
- submitted_hia = false
- )
- break
- } catch (e: Exception) {
- e.fmtLog(logger)
+ override fun run() = cliCmd(logger, common.log) {
+ val cfg = loadNexusConfig(common.config)
+
+ if (check) {
+ // Check if the server is to be started
+ val apis = listOf(
+ cfg.wireGatewayApiCfg to "Wire Gateway API",
+ cfg.revenueApiCfg to "Revenue API"
+ )
+ var startServer = false
+ for ((api, name) in apis) {
+ if (api != null) {
+ startServer = true
+ logger.info("$name is enabled: starting the server")
+ }
+ }
+ if (!startServer) {
+ logger.info("All APIs are disabled: not starting the server")
+ throw ProgramResult(1)
+ } else {
+ throw ProgramResult(0)
}
}
-
-
- println("# KEYS")
- println(JSON.encodeToString(kotlinx.serialization.serializer<ClientPrivateKeysFile>(), keys))
- println("# CONFIG")
- println("""
-[nexus-ebics]
-CURRENCY = CHF
-
-HOST_BASE_URL = ${backup.ebicsURL}
-BANK_DIALECT = postfinance
-
-
-HOST_ID = ${backup.hostID}
-USER_ID = ${backup.userID}
-PARTNER_ID = ${backup.partnerID}
-SYSTEM_ID =
-
-IBAN =
-BIC =
-NAME =
-""")
-
- /*val (authKey, encKey, sigKey) = try {
- Triple(
- CryptoUtil.decryptKey(
- EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.authBlob)),
- passphrase
- ),
- CryptoUtil.decryptKey(
- EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.encBlob)),
- passphrase
- ),
- CryptoUtil.decryptKey(
- EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.sigBlob)),
- passphrase
- )
- )
- } catch (e: Exception) {
- e.printStackTrace()
- logger.info("Restoring keys failed, probably due to wrong passphrase")
- throw NexusError(
- HttpStatusCode.BadRequest,
- "Bad backup given"
- )
- }*/
+ val dbCfg = cfg.config.dbConfig()
+ val serverCfg = cfg.config.loadServerConfig("nexus-httpd")
+ Database(dbCfg, cfg.currency).use { db ->
+ serve(serverCfg) {
+ nexusApi(db, cfg)
+ }
+ }
}
}
@@ -257,15 +202,14 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") {
).convert { Payto.parse(it).expectIban() }
override fun run() = cliCmd(logger, common.log) {
- val cfg = loadConfig(common.config)
- val dbCfg = cfg.dbConfig()
- val currency = cfg.requireString("nexus-ebics", "currency")
+ val cfg = loadNexusConfig(common.config)
+ val dbCfg = cfg.config.dbConfig()
val subject = payto.message ?: subject ?: throw Exception("Missing subject")
val amount = payto.amount ?: amount ?: throw Exception("Missing amount")
- if (amount.currency != currency)
- throw Exception("Wrong currency: expected $currency got ${amount.currency}")
+ if (amount.currency != cfg.currency)
+ throw Exception("Wrong currency: expected ${cfg.currency} got ${amount.currency}")
val bankId = run {
val bytes = ByteArray(16)
@@ -273,7 +217,7 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") {
Base32Crockford.encode(bytes)
}
- Database(dbCfg).use { db ->
+ Database(dbCfg, amount.currency).use { db ->
ingestIncomingPayment(db,
IncomingPayment(
amount = amount,
@@ -281,15 +225,217 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") {
wireTransferSubject = subject,
executionTime = Instant.now(),
bankId = bankId
- )
+ ),
+ cfg.accountType
)
}
}
}
+enum class ListKind {
+ incoming,
+ outgoing,
+ initiated;
+
+ fun description(): String = when (this) {
+ incoming -> "Incoming transactions"
+ outgoing -> "Outgoing transactions"
+ initiated -> "Initiated transactions"
+ }
+}
+
+class EbicsDownload: CliktCommand("Perform EBICS requests", name = "ebics-btd") {
+ private val common by CommonOption()
+ private val type by option().default("BTD")
+ private val name by option()
+ private val scope by option()
+ private val messageName by option()
+ private val messageVersion by option()
+ private val container by option()
+ private val option by option()
+ private val ebicsLog by option(
+ "--debug-ebics",
+ help = "Log EBICS content at SAVEDIR",
+ )
+ private val pinnedStart by option(
+ help = "Constant YYYY-MM-DD date for the earliest document" +
+ " to download (only consumed in --transient mode). The" +
+ " latest document is always until the current time."
+ )
+ private val dryRun by option().flag()
+
+ class DryRun: Exception()
+
+ override fun run() = cliCmd(logger, common.log) {
+ val cfg = loadNexusConfig(common.config)
+ val (clientKeys, bankKeys) = expectFullKeys(cfg)
+ val pinnedStartVal = pinnedStart
+ val pinnedStartArg = if (pinnedStartVal != null) {
+ logger.debug("Pinning start date to: $pinnedStartVal")
+ // Converting YYYY-MM-DD to Instant.
+ LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant()
+ } else null
+ val client = HttpClient {
+ install(HttpTimeout) {
+ // It can take a lot of time for the bank to generate documents
+ socketTimeoutMillis = 5 * 60 * 1000
+ }
+ }
+ val fileLogger = FileLogger(ebicsLog)
+ try {
+ ebicsDownload(
+ client,
+ cfg,
+ clientKeys,
+ bankKeys,
+ EbicsOrder.V3(type, name, scope, messageName, messageVersion, container, option),
+ pinnedStartArg,
+ null
+ ) { stream ->
+ if (container == "ZIP") {
+ val stream = fileLogger.logFetch(stream, false)
+ stream.unzipEach { fileName, xmlContent ->
+ println(fileName)
+ println(xmlContent.readBytes().toString(Charsets.UTF_8))
+ }
+ } else {
+ val stream = fileLogger.logFetch(stream, true) // TODO better name
+ println(stream.readBytes().toString(Charsets.UTF_8))
+ }
+ if (dryRun) throw DryRun()
+ }
+ } catch (e: DryRun) {
+ // We throw DryRun to not consume files while testing
+ }
+ }
+}
+
+class ListCmd: CliktCommand("List nexus transactions", name = "list") {
+ private val common by CommonOption()
+ private val kind: ListKind by argument(
+ help = "Which list to print",
+ helpTags = ListKind.entries.map { Pair(it.name, it.description()) }.toMap()
+ ).enum<ListKind>()
+
+ override fun run() = cliCmd(logger, common.log) {
+ val cfg = loadConfig(common.config)
+ val dbCfg = cfg.dbConfig()
+ val currency = cfg.requireString("nexus-ebics", "currency")
+
+ Database(dbCfg, currency).use { db ->
+ fun fmtPayto(payto: String?): String {
+ if (payto == null) return ""
+ try {
+ val parsed = Payto.parse(payto).expectIban()
+ return buildString {
+ append(parsed.iban.toString())
+ if (parsed.bic != null) append(" ${parsed.bic}")
+ if (parsed.receiverName != null) append(" ${parsed.receiverName}")
+ }
+ } catch (e: Exception) {
+ return payto
+ }
+ }
+ val (columnNames, rows) = when (kind) {
+ ListKind.incoming -> {
+ val txs = db.payment.metadataIncoming()
+ Pair(
+ listOf(
+ "transaction", "id", "reserve_pub", "debtor", "subject"
+ ),
+ txs.map {
+ listOf(
+ "${it.date} ${it.amount}",
+ it.id,
+ it.reservePub?.toString() ?: "",
+ fmtPayto(it.debtor),
+ it.subject
+ )
+ }
+ )
+ }
+ ListKind.outgoing -> {
+ val txs = db.payment.metadataOutgoing()
+ Pair(
+ listOf(
+ "transaction", "id", "creditor", "wtid", "exchange URL", "subject"
+ ),
+ txs.map {
+ listOf(
+ "${it.date} ${it.amount}",
+ it.id,
+ fmtPayto(it.creditor),
+ it.wtid?.toString() ?: "",
+ it.exchangeBaseUrl ?: "",
+ it.subject ?: "",
+ )
+ }
+ )
+ }
+ ListKind.initiated -> {
+ val txs = db.payment.metadataInitiated()
+ Pair(
+ listOf(
+ "transaction", "id", "submission", "creditor", "status", "subject"
+ ),
+ txs.map {
+ listOf(
+ "${it.date} ${it.amount}",
+ it.id,
+ "${it.submissionTime} ${it.submissionCounter}",
+ fmtPayto(it.creditor),
+ "${it.status} ${it.msg ?: ""}".trim(),
+ it.subject
+ )
+ }
+ )
+ }
+ }
+ val cols: List<Pair<String, Int>> = columnNames.mapIndexed { i, name ->
+ val maxRow: Int = rows.asSequence().map { it[i].length }.maxOrNull() ?: 0
+ Pair(name, max(name.length, maxRow))
+ }
+ val table = buildString {
+ fun padding(length: Int) {
+ repeat(length) { append (" ") }
+ }
+ var first = true
+ for ((name, len) in cols) {
+ if (!first) {
+ append("|")
+ } else {
+ first = false
+ }
+ val pad = len - name.length
+ padding(pad / 2)
+ append(name)
+ padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 })
+ }
+ append("\n")
+ for (row in rows) {
+ var first = true
+ for ((met, str) in cols.zip(row)) {
+ if (!first) {
+ append("|")
+ } else {
+ first = false
+ }
+ val (name, len) = met
+ val pad = len - str.length
+ append(str)
+ padding(pad)
+ }
+ append("\n")
+ }
+ }
+ print(table)
+ }
+ }
+}
+
class TestingCmd : CliktCommand("Testing helper commands", name = "testing") {
init {
- subcommands(FakeIncoming(), ConvertBackup())
+ subcommands(FakeIncoming(), ListCmd(), EbicsDownload())
}
override fun run() = Unit
@@ -301,7 +447,7 @@ class TestingCmd : CliktCommand("Testing helper commands", name = "testing") {
class LibeufinNexusCommand : CliktCommand() {
init {
versionOption(getVersion())
- subcommands(EbicsSetup(), DbInit(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd())
+ subcommands(EbicsSetup(), DbInit(), Serve(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd())
}
override fun run() = Unit
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt
new file mode 100644
index 00000000..e1435a44
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt
@@ -0,0 +1,45 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+package tech.libeufin.nexus.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.db.*
+import tech.libeufin.common.*
+
+fun Routing.revenueApi(db: Database, cfg: NexusConfig) = authApi(cfg.revenueApiCfg) {
+ get("/taler-revenue/config") {
+ call.respond(RevenueConfig(
+ currency = cfg.currency
+ ))
+ }
+ get("/taler-revenue/history") {
+ val params = HistoryParams.extract(context.request.queryParameters)
+ val items = db.payment.revenueHistory(params)
+
+ if (items.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(RevenueIncomingHistory(items, cfg.payto))
+ }
+ }
+} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt
index f7374204..d645b953 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt
@@ -29,10 +29,12 @@ import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.db.PaymentDAO.*
+import tech.libeufin.nexus.db.InitiatedDAO.*
+import tech.libeufin.nexus.db.ExchangeDAO.*
import java.time.Instant
-fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) {
+fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = authApi(cfg.wireGatewayApiCfg) {
get("/taler-wire-gateway/config") {
call.respond(WireGatewayConfig(
currency = cfg.currency
@@ -41,69 +43,52 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) {
post("/taler-wire-gateway/transfer") {
val req = call.receive<TransferRequest>()
cfg.checkCurrency(req.amount)
- // TODO
- /*val res = db.exchange.transfer(
- req = req,
- login = username,
- now = Instant.now()
+ req.credit_account.expectRequestIban()
+ val bankId = run {
+ val bytes = ByteArray(16)
+ kotlin.random.Random.nextBytes(bytes)
+ Base32Crockford.encode(bytes)
+ }
+ val res = db.exchange.transfer(
+ req,
+ bankId,
+ Instant.now()
)
when (res) {
- is TransferResult.UnknownExchange -> throw unknownAccount(username)
- is TransferResult.NotAnExchange -> throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
- )
- is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical)
- is TransferResult.BothPartyAreExchange -> throw conflict(
- "Wire transfer attempted with credit and debit party being both exchange account",
- TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
- )
- is TransferResult.ReserveUidReuse -> throw conflict(
+ TransferResult.RequestUidReuse -> throw conflict(
"request_uid used already",
TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
)
- is TransferResult.BalanceInsufficient -> throw conflict(
- "Insufficient balance for exchange",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
is TransferResult.Success -> call.respond(
TransferResponse(
timestamp = res.timestamp,
row_id = res.id
)
)
- }*/
+ }
}
- /*suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint(
+ suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint(
reduce: (List<T>, String) -> Any,
- dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T>
+ dbLambda: suspend ExchangeDAO.(HistoryParams) -> List<T>
) {
val params = HistoryParams.extract(context.request.queryParameters)
- val bankAccount = call.bankInfo(db, ctx.payto)
-
- if (!bankAccount.isTalerExchange)
- throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
- )
-
- val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto)
-
+ val items = db.exchange.dbLambda(params)
if (items.isEmpty()) {
call.respond(HttpStatusCode.NoContent)
} else {
- call.respond(reduce(items, bankAccount.payto))
+ call.respond(reduce(items, cfg.payto))
}
- }*/
- /*get("/taler-wire-gateway/history/incoming") {
+ }
+ get("/taler-wire-gateway/history/incoming") {
historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory)
}
get("/taler-wire-gateway/history/outgoing") {
historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory)
- }*/
+ }
post("/taler-wire-gateway/admin/add-incoming") {
val req = call.receive<AddIncomingRequest>()
cfg.checkCurrency(req.amount)
+ req.debit_account.expectRequestIban()
val timestamp = Instant.now()
val bankId = run {
val bytes = ByteArray(16)
@@ -122,7 +107,6 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) {
"reserve_pub used already",
TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
)
- // TODO timestamp when idempotent
is IncomingRegistrationResult.Success -> call.respond(
AddIncomingResponse(
timestamp = TalerProtocolTimestamp(timestamp),
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt
new file mode 100644
index 00000000..df5acb83
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt
@@ -0,0 +1,65 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.nexus.api
+
+import tech.libeufin.nexus.*
+import tech.libeufin.common.*
+import tech.libeufin.common.api.*
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.util.*
+import io.ktor.util.pipeline.*
+
+/** Apply api configuration for a route: conditional access and authentication */
+fun Route.authApi(cfg: ApiConfig?, callback: Route.() -> Unit): Route =
+ intercept(callback) {
+ if (cfg == null) {
+ throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END)
+ }
+ val header = context.request.headers["Authorization"]
+ // Basic auth challenge
+ when (cfg.authMethod) {
+ AuthMethod.None -> {}
+ is AuthMethod.Bearer -> {
+ if (header == null) {
+ context.response.header(HttpHeaders.WWWAuthenticate, "Bearer")
+ throw unauthorized(
+ "Authorization header not found",
+ TalerErrorCode.GENERIC_PARAMETER_MISSING
+ )
+ }
+ val (scheme, content) = header.splitOnce(" ") ?: throw badRequest(
+ "Authorization is invalid",
+ TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
+ )
+ when (scheme) {
+ "Bearer" -> {
+ // TODO choose between one of those
+ if (content != cfg.authMethod.token) {
+ throw unauthorized("Unknown token")
+ }
+ }
+ else -> throw unauthorized("Authorization method wrong or not supported")
+ }
+ }
+ }
+ } \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt
index b6422612..25cfaa59 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt
@@ -18,9 +18,12 @@
*/
package tech.libeufin.nexus.db
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.db.DatabaseConfig
-import tech.libeufin.common.db.DbPool
+import tech.libeufin.common.db.*
import java.time.Instant
/**
@@ -39,7 +42,39 @@ data class InitiatedPayment(
/**
* Collects database connection steps and any operation on the Nexus tables.
*/
-class Database(dbConfig: DatabaseConfig): DbPool(dbConfig, "libeufin_nexus") {
+class Database(dbConfig: DatabaseConfig, val bankCurrency: String): DbPool(dbConfig, "libeufin_nexus") {
val payment = PaymentDAO(this)
val initiated = InitiatedDAO(this)
+ val exchange = ExchangeDAO(this)
+
+ private val outgoingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow()
+ private val incomingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow()
+ private val revenueTxFlows: MutableSharedFlow<Long> = MutableSharedFlow()
+
+ init {
+ watchNotifications(pgSource, "libeufin_nexus", LoggerFactory.getLogger("libeufin-nexus-db-watcher"), mapOf(
+ "revenue_tx" to {
+ val id = it.toLong()
+ revenueTxFlows.emit(id)
+ },
+ "outgoing_tx" to {
+ val id = it.toLong()
+ outgoingTxFlows.emit(id)
+ },
+ "incoming_tx" to {
+ val id = it.toLong()
+ incomingTxFlows.emit(id)
+ }
+ ))
+ }
+
+ /** Listen for new taler outgoing transactions */
+ suspend fun <R> listenOutgoing(lambda: suspend (Flow<Long>) -> R): R
+ = lambda(outgoingTxFlows)
+ /** Listen for new taler incoming transactions */
+ suspend fun <R> listenIncoming(lambda: suspend (Flow<Long>) -> R): R
+ = lambda(incomingTxFlows)
+ /** Listen for new incoming transactions */
+ suspend fun <R> listenRevenue(lambda: suspend (Flow<Long>) -> R): R
+ = lambda(revenueTxFlows)
} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt
new file mode 100644
index 00000000..6f3a3a3a
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt
@@ -0,0 +1,128 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.nexus.db
+
+import tech.libeufin.common.db.*
+import tech.libeufin.common.*
+import java.sql.ResultSet
+import java.time.Instant
+
+/** Data access logic for exchange specific logic */
+class ExchangeDAO(private val db: Database) {
+ /** Query history of taler incoming transactions */
+ suspend fun incomingHistory(
+ params: HistoryParams
+ ): List<IncomingReserveTransaction>
+ = db.poolHistoryGlobal(params, db::listenIncoming, """
+ SELECT
+ incoming_transaction_id
+ ,execution_time
+ ,(amount).val AS amount_val
+ ,(amount).frac AS amount_frac
+ ,debit_payto_uri
+ ,reserve_public_key
+ FROM talerable_incoming_transactions
+ JOIN incoming_transactions USING(incoming_transaction_id)
+ WHERE
+ """, "incoming_transaction_id") {
+ IncomingReserveTransaction(
+ row_id = it.getLong("incoming_transaction_id"),
+ date = it.getTalerTimestamp("execution_time"),
+ amount = it.getAmount("amount", db.bankCurrency),
+ debit_account = it.getString("debit_payto_uri"),
+ reserve_pub = EddsaPublicKey(it.getBytes("reserve_public_key")),
+ )
+ }
+
+ /** Query [exchangeId] history of taler outgoing transactions */
+ suspend fun outgoingHistory(
+ params: HistoryParams
+ ): List<OutgoingTransaction>
+ = db.poolHistoryGlobal(params, db::listenOutgoing, """
+ SELECT
+ outgoing_transaction_id
+ ,execution_time AS execution_time
+ ,(amount).val AS amount_val
+ ,(amount).frac AS amount_frac
+ ,credit_payto_uri AS credit_payto_uri
+ ,wtid
+ ,exchange_base_url
+ FROM talerable_outgoing_transactions
+ JOIN outgoing_transactions USING(outgoing_transaction_id)
+ WHERE
+ """, "outgoing_transaction_id") {
+ OutgoingTransaction(
+ row_id = it.getLong("outgoing_transaction_id"),
+ date = it.getTalerTimestamp("execution_time"),
+ amount = it.getAmount("amount", db.bankCurrency),
+ credit_account = it.getString("credit_payto_uri"),
+ wtid = ShortHashCode(it.getBytes("wtid")),
+ exchange_base_url = it.getString("exchange_base_url")
+ )
+ }
+
+ /** Result of taler transfer transaction creation */
+ sealed interface TransferResult {
+ /** Transaction [id] and wire transfer [timestamp] */
+ data class Success(val id: Long, val timestamp: TalerProtocolTimestamp): TransferResult
+ data object RequestUidReuse: TransferResult
+ }
+
+ /** Perform a Taler transfer */
+ suspend fun transfer(
+ req: TransferRequest,
+ bankId: String,
+ now: Instant
+ ): TransferResult = db.serializable { conn ->
+ val subject = "${req.wtid} ${req.exchange_base_url.url}"
+ val stmt = conn.prepareStatement("""
+ SELECT
+ out_request_uid_reuse
+ ,out_tx_row_id
+ ,out_timestamp
+ FROM
+ taler_transfer (
+ ?, ?, ?,
+ (?,?)::taler_amount,
+ ?, ?, ?, ?
+ );
+ """)
+
+ stmt.setBytes(1, req.request_uid.raw)
+ stmt.setBytes(2, req.wtid.raw)
+ stmt.setString(3, subject)
+ stmt.setLong(4, req.amount.value)
+ stmt.setInt(5, req.amount.frac)
+ stmt.setString(6, req.exchange_base_url.url)
+ stmt.setString(7, req.credit_account.canonical)
+ stmt.setString(8, bankId)
+ stmt.setLong(9, now.micros())
+
+ stmt.one {
+ when {
+ it.getBoolean("out_request_uid_reuse") -> TransferResult.RequestUidReuse
+ else -> TransferResult.Success(
+ id = it.getLong("out_tx_row_id"),
+ timestamp = it.getTalerTimestamp("out_timestamp")
+ )
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt
index 04fd3965..052b75f9 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt
@@ -22,6 +22,7 @@ package tech.libeufin.nexus.db
import tech.libeufin.common.asInstant
import tech.libeufin.common.db.all
import tech.libeufin.common.db.executeUpdateViolation
+import tech.libeufin.common.db.oneUniqueViolation
import tech.libeufin.common.db.getAmount
import tech.libeufin.common.db.oneOrNull
import tech.libeufin.common.micros
@@ -32,9 +33,9 @@ import java.time.Instant
class InitiatedDAO(private val db: Database) {
/** Outgoing payments initiation result */
- enum class PaymentInitiationResult {
- REQUEST_UID_REUSE,
- SUCCESS
+ sealed interface PaymentInitiationResult {
+ data class Success(val id: Long): PaymentInitiationResult
+ data object RequestUidReuse: PaymentInitiationResult
}
/** Register a new pending payment in the database */
@@ -47,16 +48,18 @@ class InitiatedDAO(private val db: Database) {
,initiation_time
,request_uid
) VALUES ((?,?)::taler_amount,?,?,?,?)
+ RETURNING initiated_outgoing_transaction_id
""")
+ // TODO check payto uri
stmt.setLong(1, paymentData.amount.value)
stmt.setInt(2, paymentData.amount.frac)
stmt.setString(3, paymentData.wireTransferSubject)
stmt.setString(4, paymentData.creditPaytoUri.toString())
stmt.setLong(5, paymentData.initiationTime.micros())
stmt.setString(6, paymentData.requestUid)
- if (stmt.executeUpdateViolation())
- return@conn PaymentInitiationResult.SUCCESS
- return@conn PaymentInitiationResult.REQUEST_UID_REUSE
+ stmt.oneUniqueViolation(PaymentInitiationResult.RequestUidReuse) {
+ PaymentInitiationResult.Success(it.getLong("initiated_outgoing_transaction_id"))
+ }
}
/** Register EBICS submission success */
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
index 05548b99..a07857cf 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
@@ -19,10 +19,8 @@
package tech.libeufin.nexus.db
-import tech.libeufin.common.EddsaPublicKey
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.db.one
-import tech.libeufin.common.micros
+import tech.libeufin.common.db.*
+import tech.libeufin.common.*
import tech.libeufin.nexus.IncomingPayment
import tech.libeufin.nexus.OutgoingPayment
import java.time.Instant
@@ -37,10 +35,14 @@ class PaymentDAO(private val db: Database) {
)
/** Register an outgoing payment reconciling it with its initiated payment counterpart if present */
- suspend fun registerOutgoing(paymentData: OutgoingPayment): OutgoingRegistrationResult = db.conn {
+ suspend fun registerOutgoing(
+ paymentData: OutgoingPayment,
+ wtid: ShortHashCode?,
+ baseUrl: ExchangeUrl?,
+ ): OutgoingRegistrationResult = db.conn {
val stmt = it.prepareStatement("""
SELECT out_tx_id, out_initiated, out_found
- FROM register_outgoing((?,?)::taler_amount,?,?,?,?)
+ FROM register_outgoing((?,?)::taler_amount,?,?,?,?,?,?)
""")
val executionTime = paymentData.executionTime.micros()
stmt.setLong(1, paymentData.amount.value)
@@ -49,6 +51,17 @@ class PaymentDAO(private val db: Database) {
stmt.setLong(4, executionTime)
stmt.setString(5, paymentData.creditPaytoUri)
stmt.setString(6, paymentData.messageId)
+ if (wtid != null) {
+ stmt.setBytes(7, wtid.raw)
+ } else {
+ stmt.setNull(7, java.sql.Types.NULL)
+ }
+ if (baseUrl != null) {
+ stmt.setString(8, baseUrl.url)
+ } else {
+ stmt.setNull(8, java.sql.Types.NULL)
+ }
+
stmt.one {
OutgoingRegistrationResult(
it.getLong("out_tx_id"),
@@ -128,4 +141,172 @@ class PaymentDAO(private val db: Database) {
}
}
}
-} \ No newline at end of file
+
+ /** Register an incoming payment */
+ suspend fun registerIncoming(
+ paymentData: IncomingPayment
+ ): IncomingRegistrationResult.Success = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT out_found, out_tx_id
+ FROM register_incoming((?,?)::taler_amount,?,?,?,?)
+ """)
+ val executionTime = paymentData.executionTime.micros()
+ stmt.setLong(1, paymentData.amount.value)
+ stmt.setInt(2, paymentData.amount.frac)
+ stmt.setString(3, paymentData.wireTransferSubject)
+ stmt.setLong(4, executionTime)
+ stmt.setString(5, paymentData.debitPaytoUri)
+ stmt.setString(6, paymentData.bankId)
+ stmt.one {
+ IncomingRegistrationResult.Success(
+ it.getLong("out_tx_id"),
+ !it.getBoolean("out_found")
+ )
+ }
+ }
+
+ /** Query history of incoming transactions */
+ suspend fun revenueHistory(
+ params: HistoryParams
+ ): List<RevenueIncomingBankTransaction>
+ = db.poolHistoryGlobal(params, db::listenRevenue, """
+ SELECT
+ incoming_transaction_id
+ ,execution_time
+ ,(amount).val AS amount_val
+ ,(amount).frac AS amount_frac
+ ,debit_payto_uri
+ ,wire_transfer_subject
+ FROM incoming_transactions WHERE
+ """, "incoming_transaction_id") {
+ RevenueIncomingBankTransaction(
+ row_id = it.getLong("incoming_transaction_id"),
+ date = it.getTalerTimestamp("execution_time"),
+ amount = it.getAmount("amount", db.bankCurrency),
+ debit_account = it.getString("debit_payto_uri"),
+ subject = it.getString("wire_transfer_subject")
+ )
+ }
+
+ /** List incoming transaction metadata for debugging */
+ suspend fun metadataIncoming(): List<IncomingTxMetadata> = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ (amount).val as amount_val
+ ,(amount).frac AS amount_frac
+ ,wire_transfer_subject
+ ,execution_time
+ ,debit_payto_uri
+ ,bank_id
+ ,reserve_public_key
+ FROM incoming_transactions
+ LEFT OUTER JOIN talerable_incoming_transactions using (incoming_transaction_id)
+ ORDER BY execution_time
+ """)
+ stmt.all {
+ IncomingTxMetadata(
+ date = it.getLong("execution_time").asInstant(),
+ amount = it.getDecimal("amount"),
+ subject = it.getString("wire_transfer_subject"),
+ debtor = it.getString("debit_payto_uri"),
+ id = it.getString("bank_id"),
+ reservePub = it.getBytes("reserve_public_key")?.run { EddsaPublicKey(this) }
+ )
+ }
+ }
+
+ /** List outgoing transaction metadata for debugging */
+ suspend fun metadataOutgoing(): List<OutgoingTxMetadata> = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ (amount).val as amount_val
+ ,(amount).frac AS amount_frac
+ ,wire_transfer_subject
+ ,execution_time
+ ,credit_payto_uri
+ ,message_id
+ ,wtid
+ ,exchange_base_url
+ FROM outgoing_transactions
+ LEFT OUTER JOIN talerable_outgoing_transactions using (outgoing_transaction_id)
+ ORDER BY execution_time
+ """)
+ stmt.all {
+ OutgoingTxMetadata(
+ date = it.getLong("execution_time").asInstant(),
+ amount = it.getDecimal("amount"),
+ subject = it.getString("wire_transfer_subject"),
+ creditor = it.getString("credit_payto_uri"),
+ id = it.getString("message_id"),
+ wtid = it.getBytes("wtid")?.run { ShortHashCode(this) },
+ exchangeBaseUrl = it.getString("exchange_base_url")
+ )
+ }
+ }
+
+ /** List initiated transaction metadata for debugging */
+ suspend fun metadataInitiated(): List<InitiatedTxMetadata> = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ (amount).val as amount_val
+ ,(amount).frac AS amount_frac
+ ,wire_transfer_subject
+ ,initiation_time
+ ,last_submission_time
+ ,submission_counter
+ ,credit_payto_uri
+ ,submitted
+ ,request_uid
+ ,failure_message
+ FROM initiated_outgoing_transactions
+ ORDER BY initiation_time
+ """)
+ stmt.all {
+ InitiatedTxMetadata(
+ date = it.getLong("initiation_time").asInstant(),
+ amount = it.getDecimal("amount"),
+ subject = it.getString("wire_transfer_subject"),
+ creditor = it.getString("credit_payto_uri"),
+ id = it.getString("request_uid"),
+ status = it.getString("submitted"),
+ msg = it.getString("failure_message"),
+ submissionTime = it.getLong("last_submission_time").asInstant(),
+ submissionCounter = it.getInt("submission_counter")
+ )
+ }
+ }
+}
+
+/** Incoming transaction metadata for debugging */
+data class IncomingTxMetadata(
+ val date: Instant,
+ val amount: DecimalNumber,
+ val subject: String,
+ val debtor: String,
+ val id: String,
+ val reservePub: EddsaPublicKey?
+)
+
+/** Outgoing transaction metadata for debugging */
+data class OutgoingTxMetadata(
+ val date: Instant,
+ val amount: DecimalNumber,
+ val subject: String?,
+ val creditor: String?,
+ val id: String,
+ val wtid: ShortHashCode?,
+ val exchangeBaseUrl: String?
+)
+
+/** Initiated metadata for debugging */
+data class InitiatedTxMetadata(
+ val date: Instant,
+ val amount: DecimalNumber,
+ val subject: String,
+ val creditor: String,
+ val id: String,
+ val status: String,
+ val msg: String?,
+ val submissionTime: Instant,
+ val submissionCounter: Int
+) \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt
index d6cced05..40830093 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt
@@ -62,11 +62,12 @@ enum class Dialect {
}
}
}
+ // TODO for GLS we might have to fetch the same kind of files from multiple orders
gls -> when (doc) {
SupportedDocument.PAIN_002 -> EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT")
SupportedDocument.CAMT_052 -> EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP")
SupportedDocument.CAMT_053 -> EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP")
- SupportedDocument.CAMT_054 -> EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP")
+ SupportedDocument.CAMT_054 -> EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI")
SupportedDocument.PAIN_002_LOGS -> EbicsOrder.V3("HAC")
}
}
diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt
index 19bc0853..52b131cc 100644
--- a/nexus/src/test/kotlin/CliTest.kt
+++ b/nexus/src/test/kotlin/CliTest.kt
@@ -118,4 +118,38 @@ class CliTest {
nexusCmd.testErr("ebics-setup -c $conf", "Could not write client private keys at '$clientKeysPath': permission denied on '${clientKeysPath.parent}'")
}
}
+
+ /** Test server check */
+ @Test
+ fun serveCheck() {
+ val confs = listOf(
+ "mini" to 1,
+ "test" to 0
+ )
+ for ((conf, statusCode) in confs) {
+ val result = nexusCmd.test("serve --check -c conf/$conf.conf")
+ assertEquals(statusCode, result.statusCode)
+ }
+ }
+
+ /** Test list cmds */
+ @Test
+ fun listCheck() = setup { db, _ ->
+ fun check() {
+ for (list in listOf("incoming", "outgoing", "initiated")) {
+ val result = nexusCmd.test("testing list $list -c conf/test.conf")
+ assertEquals(0, result.statusCode)
+ }
+ }
+ // Check empty
+ check()
+ // Check with transactions
+ ingestIn(db)
+ ingestOut(db)
+ check()
+ // Check with taler transactions
+ talerableOut(db)
+ talerableIn(db)
+ check()
+ }
} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt
index 66bbe564..29a79799 100644
--- a/nexus/src/test/kotlin/DatabaseTest.kt
+++ b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -18,10 +18,12 @@
*/
import org.junit.Test
-import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.*
import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult
+import tech.libeufin.nexus.*
import java.time.Instant
import kotlin.test.assertEquals
+import kotlin.test.assertIs
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -30,32 +32,43 @@ class OutgoingPaymentsTest {
@Test
fun register() = setup { db, _ ->
// With reconciling
- genOutPay("paid by nexus", "first").run {
- assertEquals(
- PaymentInitiationResult.SUCCESS,
- db.initiated.create(genInitPay("waiting for reconciliation", "first"))
+ genOutPay("paid by nexus").run {
+ assertIs<PaymentInitiationResult.Success>(
+ db.initiated.create(genInitPay("waiting for reconciliation", messageId))
)
- db.payment.registerOutgoing(this).run {
- assertTrue(new,)
+ db.payment.registerOutgoing(this, null, null).run {
+ assertTrue(new)
assertTrue(initiated)
}
- db.payment.registerOutgoing(this).run {
+ db.payment.registerOutgoing(this, null, null).run {
assertFalse(new)
assertTrue(initiated)
}
}
// Without reconciling
- genOutPay("not paid by nexus", "second").run {
- db.payment.registerOutgoing(this).run {
+ genOutPay("not paid by nexus").run {
+ db.payment.registerOutgoing(this, null, null).run {
assertTrue(new)
assertFalse(initiated)
}
- db.payment.registerOutgoing(this).run {
+ db.payment.registerOutgoing(this, null, null).run {
assertFalse(new)
assertFalse(initiated)
}
}
}
+
+ @Test
+ fun talerable() = setup { db, _ ->
+ val wtid = ShortHashCode.rand()
+ val url = "https://exchange.com"
+ genOutPay("$wtid $url").run {
+ assertIs<PaymentInitiationResult.Success>(
+ db.initiated.create(genInitPay("waiting for reconciliation", messageId))
+ )
+ ingestOutgoingPayment(db, this)
+ }
+ }
}
class IncomingPaymentsTest {
@@ -117,8 +130,7 @@ class PaymentInitiationsTest {
@Test
fun status() = setup { db, _ ->
- assertEquals(
- PaymentInitiationResult.SUCCESS,
+ assertIs<PaymentInitiationResult.Success>(
db.initiated.create(genInitPay(requestUid = "PAY1"))
)
db.initiated.submissionFailure(1, Instant.now(), "First failure")
@@ -126,8 +138,7 @@ class PaymentInitiationsTest {
db.initiated.submissionSuccess(1, Instant.now(), "ORDER1")
assertEquals(Pair("PAY1", null), db.initiated.logFailure("ORDER1"))
- assertEquals(
- PaymentInitiationResult.SUCCESS,
+ assertIs<PaymentInitiationResult.Success>(
db.initiated.create(genInitPay(requestUid = "PAY2"))
)
db.initiated.submissionFailure(2, Instant.now(), "First failure")
@@ -135,8 +146,7 @@ class PaymentInitiationsTest {
db.initiated.logMessage("ORDER2", "status msg")
assertEquals(Pair("PAY2", "status msg"), db.initiated.logFailure("ORDER2"))
- assertEquals(
- PaymentInitiationResult.SUCCESS,
+ assertIs<PaymentInitiationResult.Success>(
db.initiated.create(genInitPay(requestUid = "PAY3"))
)
db.initiated.submissionSuccess(3, Instant.now(), "ORDER3")
@@ -146,15 +156,13 @@ class PaymentInitiationsTest {
assertNull(db.initiated.logSuccess("ORDER_X"))
assertNull(db.initiated.logFailure("ORDER_X"))
- assertEquals(
- PaymentInitiationResult.SUCCESS,
+ assertIs<PaymentInitiationResult.Success>(
db.initiated.create(genInitPay(requestUid = "PAY4"))
)
db.initiated.bankMessage("PAY4", "status progress")
db.initiated.bankFailure("PAY4", "status failure")
- assertEquals(
- PaymentInitiationResult.SUCCESS,
+ assertIs<PaymentInitiationResult.Success>(
db.initiated.create(genInitPay(requestUid = "PAY5"))
)
db.initiated.bankMessage("PAY5", "status progress")
@@ -164,8 +172,7 @@ class PaymentInitiationsTest {
@Test
fun submittable() = setup { db, _ ->
for (i in 0..5) {
- assertEquals(
- PaymentInitiationResult.SUCCESS,
+ assertIs<PaymentInitiationResult.Success>(
db.initiated.create(genInitPay(requestUid = "PAY$i"))
)
}
diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt
index c0ff4b98..c0327d69 100644
--- a/nexus/src/test/kotlin/Iso20022Test.kt
+++ b/nexus/src/test/kotlin/Iso20022Test.kt
@@ -55,14 +55,14 @@ class Iso20022Test {
amount = TalerAmount("CHF:10"),
wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG",
executionTime = instant("2023-12-19"),
- debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test"
+ debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr%20Test"
),
IncomingPayment(
bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71",
amount = TalerAmount("CHF:2.53"),
wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB",
executionTime = instant("2023-12-19"),
- debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test"
+ debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr%20Test"
)
),
txs
@@ -91,8 +91,8 @@ class Iso20022Test {
}
@Test
- fun gls() {
- val content = Files.newInputStream(Path("sample/platform/gls.xml"))
+ fun gls_camt052() {
+ val content = Files.newInputStream(Path("sample/platform/gls_camt052.xml"))
val txs = parseTx(content, "EUR", Dialect.gls)
assertEquals(
listOf(
@@ -101,21 +101,21 @@ class Iso20022Test {
amount = TalerAmount("EUR:2"),
wireTransferSubject = "TestABC123",
executionTime = instant("2024-04-18"),
- creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John+Smith"
+ creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith"
),
OutgoingPayment(
messageId = "YF5QBARGQ0MNY0VK59S477VDG4",
amount = TalerAmount("EUR:1.1"),
wireTransferSubject = "This should fail because dummy",
executionTime = instant("2024-04-18"),
- creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John+Smith"
+ creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith"
),
IncomingPayment(
bankId = "BYLADEM1WOR-G2910276709458A2",
amount = TalerAmount("EUR:3"),
wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
executionTime = instant("2024-04-12"),
- debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John+Smith"
+ debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John%20Smith"
),
Reversal(
msgId = "G27KNKZAR5DV7HRB085YMA9GB4",
@@ -126,4 +126,59 @@ class Iso20022Test {
txs
)
}
+
+ @Test
+ fun gls_camt053() {
+ val content = Files.newInputStream(Path("sample/platform/gls_camt053.xml"))
+ val txs = parseTx(content, "EUR", Dialect.gls)
+ assertEquals(
+ listOf(
+ OutgoingPayment(
+ messageId = "G059N0SR5V0WZ0XSFY1H92QBZ0",
+ amount = TalerAmount("EUR:2"),
+ wireTransferSubject = "TestABC123",
+ executionTime = instant("2024-04-18"),
+ creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith"
+ ),
+ OutgoingPayment(
+ messageId = "YF5QBARGQ0MNY0VK59S477VDG4",
+ amount = TalerAmount("EUR:1.1"),
+ wireTransferSubject = "This should fail because dummy",
+ executionTime = instant("2024-04-18"),
+ creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith"
+ ),
+ IncomingPayment(
+ bankId = "BYLADEM1WOR-G2910276709458A2",
+ amount = TalerAmount("EUR:3"),
+ wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
+ executionTime = instant("2024-04-12"),
+ debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John%20Smith"
+ ),
+ Reversal(
+ msgId = "G27KNKZAR5DV7HRB085YMA9GB4",
+ reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'",
+ executionTime = instant("2024-04-12")
+ )
+ ),
+ txs
+ )
+ }
+
+ @Test
+ fun gls_camt054() {
+ val content = Files.newInputStream(Path("sample/platform/gls_camt054.xml"))
+ val txs = parseTx(content, "EUR", Dialect.gls)
+ assertEquals(
+ listOf(
+ IncomingPayment(
+ bankId = "IS11PGENODEFF2DA8899900378806",
+ amount = TalerAmount("EUR:2.5"),
+ wireTransferSubject = "Test ICT",
+ executionTime = instant("2024-05-05"),
+ debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=Mr%20Test"
+ )
+ ),
+ txs
+ )
+ }
} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/RevenueApiTest.kt b/nexus/src/test/kotlin/RevenueApiTest.kt
new file mode 100644
index 00000000..ec7d37d8
--- /dev/null
+++ b/nexus/src/test/kotlin/RevenueApiTest.kt
@@ -0,0 +1,65 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import io.ktor.http.*
+import org.junit.Test
+import tech.libeufin.common.*
+import tech.libeufin.nexus.*
+
+class RevenueApiTest {
+ // GET /taler-revenue/config
+ @Test
+ fun config() = serverSetup {
+ authRoutine(HttpMethod.Get, "/taler-revenue/config")
+
+ client.getA("/taler-revenue/config").assertOk()
+ }
+
+ // GET /taler-revenue/history
+ @Test
+ fun history() = serverSetup { db ->
+ authRoutine(HttpMethod.Get, "/taler-revenue/history")
+
+ historyRoutine<RevenueIncomingHistory>(
+ url = "/taler-revenue/history",
+ ids = { it.incoming_transactions.map { it.row_id } },
+ registered = listOf(
+ {
+ // Transactions using clean transfer logic
+ talerableIn(db)
+ },
+ {
+ // Common credit transactions
+ ingestIn(db)
+ }
+ ),
+ ignored = listOf(
+ {
+ // Ignore debit transactions
+ talerableOut(db)
+ }
+ )
+ )
+ }
+
+ @Test
+ fun noApi() = serverSetup("mini.conf") {
+ client.getA("/taler-revenue/config").assertNotImplemented()
+ }
+} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt
index a8d94b2f..d7b11536 100644
--- a/nexus/src/test/kotlin/WireGatewayApiTest.kt
+++ b/nexus/src/test/kotlin/WireGatewayApiTest.kt
@@ -24,47 +24,42 @@ import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.Test
import tech.libeufin.common.*
+import tech.libeufin.nexus.*
class WireGatewayApiTest {
- // GET /accounts/{USERNAME}/taler-wire-gateway/config
+ // GET /taler-wire-gateway/config
@Test
fun config() = serverSetup { _ ->
- //authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/config")
+ authRoutine(HttpMethod.Get, "/taler-wire-gateway/config")
- client.get("/taler-wire-gateway/config").assertOk()
+ client.getA("/taler-wire-gateway/config").assertOk()
}
- // Testing the POST /transfer call from the TWG API.
- /*@Test
- fun transfer() = bankSetup { _ ->
+ // POST /taler-wire-gateway/transfer
+ @Test
+ fun transfer() = serverSetup { _ ->
val valid_req = obj {
"request_uid" to HashCode.rand()
- "amount" to "KUDOS:55"
+ "amount" to "CHF:55"
"exchange_base_url" to "http://exchange.example.com/"
"wtid" to ShortHashCode.rand()
- "credit_account" to merchantPayto.canonical
+ "credit_account" to grothoffPayto
}
- authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req)
-
- // Checking exchange debt constraint.
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
- json(valid_req)
- }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
+ authRoutine(HttpMethod.Post, "/taler-wire-gateway/transfer")
- // Giving debt allowance and checking the OK case.
- setMaxDebt("exchange", "KUDOS:1000")
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ // Check OK
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req)
}.assertOk()
// check idempotency
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req)
}.assertOk()
// Trigger conflict due to reused request_uid
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"wtid" to ShortHashCode.rand()
"exchange_base_url" to "http://different-exchange.example.com/"
@@ -72,132 +67,117 @@ class WireGatewayApiTest {
}.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
// Currency mismatch
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"amount" to "EUR:33"
}
}.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
- // Unknown account
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
- json(valid_req) {
- "request_uid" to HashCode.rand()
- "wtid" to ShortHashCode.rand()
- "credit_account" to unknownPayto
- }
- }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR)
-
- // Same account
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
- json(valid_req) {
- "request_uid" to HashCode.rand()
- "wtid" to ShortHashCode.rand()
- "credit_account" to exchangePayto
- }
- }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE)
-
// Bad BASE32 wtid
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"wtid" to "I love chocolate"
}
}.assertBadRequest()
// Bad BASE32 len wtid
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
- "wtid" to randBase32Crockford(31)
+ "wtid" to Base32Crockford.encode(ByteArray(31).rand())
}
}.assertBadRequest()
// Bad BASE32 request_uid
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"request_uid" to "I love chocolate"
}
}.assertBadRequest()
// Bad BASE32 len wtid
- client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
+ client.postA("/taler-wire-gateway/transfer") {
+ json(valid_req) {
+ "request_uid" to Base32Crockford.encode(ByteArray(65).rand())
+ }
+ }.assertBadRequest()
+
+ // Bad payto kind
+ client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
- "request_uid" to randBase32Crockford(65)
+ "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar"
}
}.assertBadRequest()
- }*/
- /*
- /**
- * Testing the /history/incoming call from the TWG API.
- */
+ }
+
+ // GET /taler-wire-gateway/history/incoming
@Test
- fun historyIncoming() = serverSetup {
- // Give Foo reasonable debt allowance:
- setMaxDebt("merchant", "KUDOS:1000")
- authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/incoming")
+ fun historyIncoming() = serverSetup { db ->
+ authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming")
historyRoutine<IncomingHistory>(
- url = "/accounts/exchange/taler-wire-gateway/history/incoming",
+ url = "/taler-wire-gateway/history/incoming",
ids = { it.incoming_transactions.map { it.row_id } },
registered = listOf(
{
- // Transactions using clean add incoming logic
- addIncoming("KUDOS:10")
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
+ json {
+ "amount" to "CHF:12"
+ "reserve_pub" to EddsaPublicKey.rand()
+ "debit_account" to grothoffPayto
+ }
+ }.assertOk()
},
{
// Transactions using raw bank transaction logic
- tx("merchant", "KUDOS:10", "exchange", "history test with ${ShortHashCode.rand()} reserve pub")
- },
- {
- // Transaction using withdraw logic
- withdrawal("KUDOS:9")
+ talerableIn(db)
}
),
ignored = listOf(
{
// Ignore malformed incoming transaction
- tx("merchant", "KUDOS:10", "exchange", "ignored")
+ ingestIn(db)
},
{
- // Ignore malformed outgoing transaction
- tx("exchange", "KUDOS:10", "merchant", "ignored")
+ // Ignore outgoing transaction
+ talerableOut(db)
}
)
)
}
-
- /**
- * Testing the /history/outgoing call from the TWG API.
- */
+ // GET /taler-wire-gateway/history/outgoing
@Test
- fun historyOutgoing() = serverSetup {
- setMaxDebt("exchange", "KUDOS:1000000")
- authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/outgoing")
+ fun historyOutgoing() = serverSetup { db ->
+ authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing")
historyRoutine<OutgoingHistory>(
- url = "/accounts/exchange/taler-wire-gateway/history/outgoing",
+ url = "/taler-wire-gateway/history/outgoing",
ids = { it.outgoing_transactions.map { it.row_id } },
registered = listOf(
- {
- // Transactions using clean add incoming logic
- transfer("KUDOS:10")
+ {
+ talerableOut(db)
}
),
ignored = listOf(
{
- // gnore manual incoming transaction
- tx("exchange", "KUDOS:10", "merchant", "${ShortHashCode.rand()} http://exchange.example.com/")
+ // Ignore pending transfers
+ transfer()
+ },
+ {
+ // Ignore manual incoming transaction
+ talerableIn(db)
},
{
// Ignore malformed incoming transaction
- tx("merchant", "KUDOS:10", "exchange", "ignored")
+ ingestIn(db)
},
{
// Ignore malformed outgoing transaction
- tx("exchange", "KUDOS:10", "merchant", "ignored")
+ ingestOutgoingPayment(db, genOutPay("ignored"))
}
)
)
- }*/
+ }
- // Testing the /admin/add-incoming call from the TWG API.
+ // POST /taler-wire-gateway/admin/add-incoming
@Test
fun addIncoming() = serverSetup { _ ->
val valid_req = obj {
@@ -206,35 +186,47 @@ class WireGatewayApiTest {
"debit_account" to grothoffPayto
}
- //authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true)
+ authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/add-incoming")
// Check OK
- client.post("/taler-wire-gateway/admin/add-incoming") {
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
json(valid_req)
}.assertOk()
// Trigger conflict due to reused reserve_pub
- client.post("/taler-wire-gateway/admin/add-incoming") {
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
json(valid_req)
}.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
// Currency mismatch
- client.post("/taler-wire-gateway/admin/add-incoming") {
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
json(valid_req) { "amount" to "EUR:33" }
}.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
// Bad BASE32 reserve_pub
- client.post("/taler-wire-gateway/admin/add-incoming") {
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
json(valid_req) {
"reserve_pub" to "I love chocolate"
}
}.assertBadRequest()
// Bad BASE32 len reserve_pub
- client.post("/taler-wire-gateway/admin/add-incoming") {
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
json(valid_req) {
"reserve_pub" to Base32Crockford.encode(ByteArray(31).rand())
}
}.assertBadRequest()
+
+ // Bad payto kind
+ client.postA("/taler-wire-gateway/admin/add-incoming") {
+ json(valid_req) {
+ "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar"
+ }
+ }.assertBadRequest()
+ }
+
+ @Test
+ fun noApi() = serverSetup("mini.conf") { _ ->
+ client.get("/taler-wire-gateway/config").assertNotImplemented()
}
} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt
index e6c4b1a7..0f428230 100644
--- a/nexus/src/test/kotlin/helpers.kt
+++ b/nexus/src/test/kotlin/helpers.kt
@@ -24,10 +24,8 @@ import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.coroutines.runBlocking
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.db.dbInit
-import tech.libeufin.common.db.pgDataSource
-import tech.libeufin.common.fromFile
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.Database
import tech.libeufin.nexus.db.InitiatedPayment
@@ -49,17 +47,17 @@ fun setup(
) = runBlocking {
val config = NEXUS_CONFIG_SOURCE.fromFile(Path("conf/$conf"))
val dbCfg = config.dbConfig()
- val ctx = NexusConfig(config)
+ val cfg = NexusConfig(config)
pgDataSource(dbCfg.dbConnStr).dbInit(dbCfg, "libeufin-nexus", true)
- Database(dbCfg).use {
- lambda(it, ctx)
+ Database(dbCfg, cfg.currency).use {
+ lambda(it, cfg)
}
}
fun serverSetup(
conf: String = "test.conf",
lambda: suspend ApplicationTestBuilder.(Database) -> Unit
-) = setup { db, cfg ->
+) = setup(conf) { db, cfg ->
testApplication {
application {
nexusApi(db, cfg)
@@ -79,7 +77,7 @@ fun getMockedClient(
followRedirects = false
engine {
addHandler {
- request -> handler(request)
+ request -> handler(request)
}
}
}
@@ -98,21 +96,106 @@ fun genInitPay(
)
// Generates an incoming payment, given its subject.
-fun genInPay(subject: String) =
- IncomingPayment(
- amount = TalerAmount(44, 0, "KUDOS"),
+fun genInPay(subject: String, amount: String = "KUDOS:44"): IncomingPayment {
+ val bankId = run {
+ val bytes = ByteArray(16)
+ kotlin.random.Random.nextBytes(bytes)
+ Base32Crockford.encode(bytes)
+ }
+ return IncomingPayment(
+ amount = TalerAmount(amount),
debitPaytoUri = "payto://iban/not-used",
wireTransferSubject = subject,
executionTime = Instant.now(),
- bankId = "entropic"
+ bankId = bankId
)
+}
// Generates an outgoing payment, given its subject and messageId
-fun genOutPay(subject: String, messageId: String) =
- OutgoingPayment(
+fun genOutPay(subject: String, messageId: String? = null): OutgoingPayment {
+ val id = messageId ?: run {
+ val bytes = ByteArray(16)
+ kotlin.random.Random.nextBytes(bytes)
+ Base32Crockford.encode(bytes)
+ }
+ return OutgoingPayment(
amount = TalerAmount(44, 0, "KUDOS"),
creditPaytoUri = "payto://iban/CH4189144589712575493?receiver-name=Test",
wireTransferSubject = subject,
executionTime = Instant.now(),
- messageId = messageId
- ) \ No newline at end of file
+ messageId = id
+ )
+}
+
+/** Perform a taler outgoing transaction */
+suspend fun ApplicationTestBuilder.transfer() {
+ client.postA("/taler-wire-gateway/transfer") {
+ json {
+ "request_uid" to HashCode.rand()
+ "amount" to "CHF:55"
+ "exchange_base_url" to "http://exchange.example.com/"
+ "wtid" to ShortHashCode.rand()
+ "credit_account" to grothoffPayto
+ }
+ }.assertOk()
+}
+
+/** Ingest a talerable outgoing transaction */
+suspend fun talerableOut(db: Database) {
+ val wtid = ShortHashCode.rand()
+ ingestOutgoingPayment(db, genOutPay("$wtid http://exchange.example.com/"))
+}
+
+/** Ingest a talerable incoming transaction */
+suspend fun talerableIn(db: Database) {
+ val reserve_pub = ShortHashCode.rand()
+ ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub"), AccountType.exchange)
+}
+
+/** Ingest an incoming transaction */
+suspend fun ingestIn(db: Database) {
+ ingestIncomingPayment(db, genInPay("ignored"), AccountType.normal)
+}
+
+/** Ingest an outgoing transaction */
+suspend fun ingestOut(db: Database) {
+ ingestOutgoingPayment(db, genOutPay("ignored"))
+}
+
+/* ----- Auth ----- */
+
+/** Auto auth get request */
+suspend inline fun HttpClient.getA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
+ return get(url) {
+ auth()
+ builder(this)
+ }
+}
+
+/** Auto auth post request */
+suspend inline fun HttpClient.postA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
+ return post(url) {
+ auth()
+ builder(this)
+ }
+}
+
+/** Auto auth patch request */
+suspend inline fun HttpClient.patchA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
+ return patch(url) {
+ auth()
+ builder(this)
+ }
+}
+
+/** Auto auth delete request */
+suspend inline fun HttpClient.deleteA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
+ return delete(url) {
+ auth()
+ builder(this)
+ }
+}
+
+fun HttpRequestBuilder.auth() {
+ headers["Authorization"] = "Bearer secret-token"
+} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/routines.kt b/nexus/src/test/kotlin/routines.kt
new file mode 100644
index 00000000..7b92dea7
--- /dev/null
+++ b/nexus/src/test/kotlin/routines.kt
@@ -0,0 +1,73 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonObject
+import tech.libeufin.common.*
+import tech.libeufin.common.test.*
+import kotlin.test.assertEquals
+
+
+// Test endpoint is correctly authenticated
+suspend fun ApplicationTestBuilder.authRoutine(
+ method: HttpMethod,
+ path: String
+) {
+ // No header
+ client.request(path) {
+ this.method = method
+ }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING)
+
+ // Bad header
+ client.request(path) {
+ this.method = method
+ headers["Authorization"] = "WTF"
+ }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED)
+
+ // Bad token
+ client.request(path) {
+ this.method = method
+ headers["Authorization"] = "Bearer bad-token"
+ }.assertUnauthorized()
+
+ // GLS deployment
+ // - testing did work ?
+ // token - basic bearer
+ // libeufin-nexus
+ // - wire gateway try camt.052 files
+}
+
+
+suspend inline fun <reified B> ApplicationTestBuilder.historyRoutine(
+ url: String,
+ crossinline ids: (B) -> List<Long>,
+ registered: List<suspend () -> Unit>,
+ ignored: List<suspend () -> Unit> = listOf(),
+ polling: Boolean = true
+) {
+ abstractHistoryRoutine(ids, registered, ignored, polling) { params: String ->
+ client.getA("$url?$params")
+ }
+} \ No newline at end of file
diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt
index 3b4a6957..8f92d860 100644
--- a/testbench/src/main/kotlin/Main.kt
+++ b/testbench/src/main/kotlin/Main.kt
@@ -135,7 +135,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
val payto = benchCfg.payto[currency] ?: dummyPayto
?: throw Exception("Missing test payto for $currency")
- val recoverDoc = "notification statement"
+ val recoverDoc = "report statement notification"
runBlocking {
step("Init ${kind.name}")
@@ -160,8 +160,12 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags")
put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement")
put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status")
+ put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report")
put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification")
put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement")
+ put("list-incoming", "List incoming transaction", "testing list $flags incoming")
+ put("list-outgoing", "List outgoing transaction", "testing list $flags outgoing")
+ put("list-initiated", "List initiated payments", "testing list $flags initiated")
put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags")
put("setup", "Setup", "ebics-setup $flags")
put("reset-keys", suspend {
diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt
index f24e6d92..76637495 100644
--- a/testbench/src/test/kotlin/IntegrationTest.kt
+++ b/testbench/src/test/kotlin/IntegrationTest.kt
@@ -29,6 +29,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.Test
import tech.libeufin.bank.*
import tech.libeufin.common.*
+import tech.libeufin.common.api.engine
import tech.libeufin.common.db.one
import tech.libeufin.nexus.*
import java.time.Instant
@@ -68,8 +69,10 @@ fun server(lambda: () -> Unit) {
fun setup(conf: String, lambda: suspend (NexusDb) -> Unit) {
try {
runBlocking {
- val cfg = loadConfig(Path(conf)).dbConfig()
- NexusDb(cfg).use {
+ val cfg = loadConfig(Path(conf))
+ val dbCfg = cfg.dbConfig()
+ val currency = cfg.requireString("nexus-ebics", "currency")
+ NexusDb(dbCfg, currency).use {
lambda(it)
}
}
@@ -109,6 +112,11 @@ class IntegrationTest {
}
bankCmd.run("gc $flags")
+
+ server {
+ nexusCmd.run("serve $flags")
+ }
+ engine?.stop(0, 0)
}
@Test
@@ -127,7 +135,7 @@ class IntegrationTest {
}
}
- setup("conf/integration.conf") { db ->
+ setup("conf/integration.conf") { db ->
val userPayTo = IbanPayto.rand()
val fiatPayTo = IbanPayto.rand()
@@ -148,14 +156,14 @@ class IntegrationTest {
)
assertException("ERROR: cashin failed: missing exchange account") {
- ingestIncomingPayment(db, payment)
+ ingestIncomingPayment(db, payment, AccountType.exchange)
}
// Create exchange account
bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange")
assertException("ERROR: cashin currency conversion failed: missing conversion rates") {
- ingestIncomingPayment(db, payment)
+ ingestIncomingPayment(db, payment, AccountType.exchange)
}
// Start server
@@ -191,7 +199,7 @@ class IntegrationTest {
checkCount(db, 0, 0, 0)
ingestIncomingPayment(db, payment.copy(
amount = TalerAmount("EUR:0.01"),
- ))
+ ), AccountType.exchange)
checkCount(db, 1, 1, 0)
client.get("http://0.0.0.0:8080/accounts/exchange/transactions") {
basicAuth("exchange", "password")
@@ -205,14 +213,14 @@ class IntegrationTest {
executionTime = Instant.now(),
bankId = "success"
)
- ingestIncomingPayment(db, valid_payment)
+ ingestIncomingPayment(db, valid_payment, AccountType.exchange)
checkCount(db, 2, 1, 1)
client.get("http://0.0.0.0:8080/accounts/exchange/transactions") {
basicAuth("exchange", "password")
}.assertOkJson<BankAccountTransactionsResponse>()
// Check idempotency
- ingestIncomingPayment(db, valid_payment)
+ ingestIncomingPayment(db, valid_payment, AccountType.exchange)
checkCount(db, 2, 1, 1)
// TODO check double insert cashin with different subject
}