commit 473165493513f8b53d42b36f39e331679f65de45 parent 583787d4b40bcdc6c5ce3017dd495267b711136f Author: Antoine A <> Date: Mon, 22 Jul 2024 17:01:02 +0200 common: move cli commands to a module, clean cli args and clean imports Diffstat:
76 files changed, 1936 insertions(+), 1601 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -19,17 +19,12 @@ package tech.libeufin.bank import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import org.slf4j.Logger -import org.slf4j.LoggerFactory +import tech.libeufin.bank.db.Database import tech.libeufin.common.* import tech.libeufin.common.db.DatabaseConfig -import tech.libeufin.bank.db.Database import java.nio.file.Path import java.time.Duration -private val logger: Logger = LoggerFactory.getLogger("libeufin-bank") - /** Configuration for libeufin-bank */ data class BankConfig( private val cfg: TalerConfig, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -19,42 +19,20 @@ package tech.libeufin.bank -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.arguments.optional -import com.github.ajalt.clikt.parameters.groups.OptionGroup -import com.github.ajalt.clikt.parameters.groups.cooccurring -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.* import io.ktor.server.application.* import io.ktor.server.http.content.* import io.ktor.server.response.* import io.ktor.server.routing.* -import kotlinx.serialization.json.Json import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.bank.api.* -import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.bank.cli.LibeufinBank import tech.libeufin.bank.db.Database -import tech.libeufin.common.* -import tech.libeufin.common.api.serve import tech.libeufin.common.api.talerApi -import tech.libeufin.common.db.dbInit -import tech.libeufin.common.db.pgDataSource -import java.time.Instant -import kotlin.io.path.Path -import kotlin.io.path.exists -import kotlin.io.path.readText -private val logger: Logger = LoggerFactory.getLogger("libeufin-bank") +val logger: Logger = LoggerFactory.getLogger("libeufin-bank") - -/** - * Set up web server handlers for the Taler corebank API. - */ +/** Set up web server handlers for the Taler corebank API */ fun Application.corebankWebApp(db: Database, ctx: BankConfig) = talerApi(logger) { coreBankApi(db, ctx) conversionApi(db, ctx) @@ -69,266 +47,6 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) = talerApi(logger) } } -class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name = "dbinit") { - private val common by CommonOption() - private val reset by option( - "--reset", "-r", - help = "Reset database (DANGEROUS: All existing data is lost)" - ).flag() - - override fun run() = cliCmd(logger, common.log) { - val cfg = bankConfig(common.config) - val dbCfg = cfg.dbCfg - pgDataSource(dbCfg.dbConnStr).dbInit(dbCfg, "libeufin-bank", reset) - cfg.withDb { db, cfg -> - // Create admin account if missing - val res = createAdminAccount(db, cfg) - when (res) { - AccountCreationResult.BonusBalanceInsufficient -> {} - AccountCreationResult.LoginReuse -> {} - AccountCreationResult.PayToReuse -> - throw Exception("Failed to create admin's account") - is AccountCreationResult.Success -> - logger.info("Admin's account created") - } - } - } -} - -class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") { - private val common by CommonOption() - - override fun run() = cliCmd(logger, common.log) { - bankConfig(common.config).withDb { db, cfg -> - if (cfg.allowConversion) { - logger.info("Ensure exchange account exists") - val info = db.account.bankInfo("exchange", cfg.payto) - if (info == null) { - throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") - } else if (!info.isTalerExchange) { - throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") - } - logger.info("Ensure conversion is enabled") - val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-setup.sql") - if (!sqlProcedures.exists()) { - throw Exception("Missing libeufin-conversion-setup.sql file") - } - db.conn { it.execSQLUpdate(sqlProcedures.readText()) } - } else { - logger.info("Ensure conversion is disabled") - val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-drop.sql") - if (!sqlProcedures.exists()) { - throw Exception("Missing libeufin-conversion-drop.sql file") - } - db.conn { it.execSQLUpdate(sqlProcedures.readText()) } - // Remove conversion info from the database ? - } - serve(cfg.serverCfg) { - corebankWebApp(db, cfg) - } - } - } -} - -class ChangePw : CliktCommand("Change account password", name = "passwd") { - private val common by CommonOption() - private val username by argument("username", help = "Account username") - private val password by argument( - "password", - help = "Account password used for authentication" - ) - - override fun run() = cliCmd(logger, common.log) { - bankConfig(common.config).withDb { db, _ -> - val res = db.account.reconfigPassword(username, password, null, true) - when (res) { - AccountPatchAuthResult.UnknownAccount -> - throw Exception("Password change for '$username' account failed: unknown account") - AccountPatchAuthResult.OldPasswordMismatch, - AccountPatchAuthResult.TanRequired -> { /* Can never happen */ } - AccountPatchAuthResult.Success -> - logger.info("Password change for '$username' account succeeded") - } - } - } -} - - -class EditAccount : CliktCommand( - "Edit an existing account", - name = "edit-account" -) { - private val common by CommonOption() - private val username: String by argument( - "username", - help = "Account username" - ) - private val name: String? by option( - help = "Legal name of the account owner" - ) - private val exchange: Boolean? by option( - hidden = true - ).boolean() - private val is_public: Boolean? by option( - "--public", - help = "Make this account visible to anyone" - ).boolean() - private val email: String? by option(help = "E-Mail address used for TAN transmission") - private val phone: String? by option(help = "Phone number used for TAN transmission") - 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) { - bankConfig(common.config).withDb { db, cfg -> - val req = AccountReconfiguration( - name = name, - is_taler_exchange = exchange, - is_public = is_public, - contact_data = ChallengeContactData( - // PATCH semantic, if not given do not change, if empty remove - email = if (email == null) Option.None else Option.Some(if (email != "") email else null), - 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, - 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, cfg, req, username, true, true)) { - AccountPatchResult.Success -> - logger.info("Account '$username' edited") - AccountPatchResult.UnknownAccount -> - throw Exception("Account '$username' not found") - AccountPatchResult.MissingTanInfo -> - throw Exception("missing info for tan channel ${req.tan_channel.get()}") - AccountPatchResult.NonAdminName, - AccountPatchResult.NonAdminCashout, - AccountPatchResult.NonAdminDebtLimit, - AccountPatchResult.NonAdminMinCashout, - is AccountPatchResult.TanRequired -> { - // Unreachable as we edit account as admin - } - } - } - } -} - -class CreateAccountOption: OptionGroup() { - val username: String by option( - "--username", "-u", - help = "Account unique username" - ).required() - val password: String by option( - "--password", "-p", - help = "Account password used for authentication" - ).prompt(requireConfirmation = true, hideInput = true) - val name: String by option( - help = "Legal name of the account owner" - ).required() - val is_public: Boolean by option( - "--public", - help = "Make this account visible to anyone" - ).flag() - val exchange: Boolean by option( - help = "Make this account a taler exchange" - ).flag() - val email: String? by option(help = "E-Mail address used for TAN transmission") - val phone: String? by option(help = "Phone number used for TAN transmission") - val cashout_payto_uri: IbanPayto? by option( - help = "Payto URI of a fiant account who receive cashout amount" - ).convert { Payto.parse(it).expectIban() } - val payto_uri: Payto? by option( - help = "Payto URI of this account" - ).convert { Payto.parse(it) } - 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( - "Create an account, returning the payto://-URI associated with it", - name = "create-account" -) { - private val common by CommonOption() - private val json by argument().convert { Json.decodeFromString<RegisterAccountRequest>(it) }.optional() - private val options by CreateAccountOption().cooccurring() - - override fun run() = cliCmd(logger, common.log) { - // TODO support setting tan - bankConfig(common.config).withDb { db, cfg -> - val req = json ?: options?.run { - RegisterAccountRequest( - username = username, - password = password, - name = name, - is_public = is_public, - is_taler_exchange = exchange, - contact_data = ChallengeContactData( - email = Option.Some(email), - phone = Option.Some(phone), - ), - cashout_payto_uri = cashout_payto_uri, - payto_uri = payto_uri, - debit_threshold = debit_threshold, - min_cashout = min_cashout - ) - } - req?.let { - when (val result = createAccount(db, cfg, req, true)) { - AccountCreationResult.BonusBalanceInsufficient -> - throw Exception("Insufficient admin funds to grant bonus") - AccountCreationResult.LoginReuse -> - throw Exception("Account username reuse '${req.username}'") - AccountCreationResult.PayToReuse -> - throw Exception("Bank internalPayToUri reuse") - is AccountCreationResult.Success -> { - logger.info("Account '${req.username}' created") - println(result.payto) - } - } - } - } - } -} - -class GC : CliktCommand( - "Run garbage collection: abort expired operations and clean expired data", - name = "gc" -) { - private val common by CommonOption() - - override fun run() = cliCmd(logger, common.log) { - bankConfig(common.config).withDb { db, cfg -> - logger.info("Run garbage collection") - db.gc.collect(Instant.now(), cfg.gcAbortAfter, cfg.gcCleanAfter, cfg.gcDeleteAfter) - } - } -} - -class LibeufinBankCommand : CliktCommand() { - init { - versionOption(getVersion()) - subcommands(ServeBank(), BankDbInit(), CreateAccount(), EditAccount(), ChangePw(), GC(), CliConfigCmd(BANK_CONFIG_SOURCE)) - } - - override fun run() = Unit -} - fun main(args: Array<String>) { - LibeufinBankCommand().main(args) + LibeufinBank().main(args) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -30,7 +30,9 @@ import tech.libeufin.bank.* import tech.libeufin.bank.db.AbortResult import tech.libeufin.bank.db.Database import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult -import tech.libeufin.common.* +import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.conflict +import tech.libeufin.common.notFound fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { get("/taler-integration/config") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -28,7 +28,10 @@ import tech.libeufin.bank.auth.authAdmin import tech.libeufin.bank.db.ConversionDAO import tech.libeufin.bank.db.ConversionDAO.ConversionResult import tech.libeufin.bank.db.Database -import tech.libeufin.common.* +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.apiError +import tech.libeufin.common.conflict fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { get("/conversion-info/config") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt @@ -22,10 +22,14 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* -import tech.libeufin.bank.* +import tech.libeufin.bank.BankConfig +import tech.libeufin.bank.TokenScope import tech.libeufin.bank.auth.auth +import tech.libeufin.bank.bankInfo import tech.libeufin.bank.db.Database -import tech.libeufin.common.* +import tech.libeufin.common.HistoryParams +import tech.libeufin.common.RevenueConfig +import tech.libeufin.common.RevenueIncomingHistory fun Routing.revenueApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.revenue) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt @@ -23,10 +23,10 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import kotlinx.serialization.json.Json -import tech.libeufin.common.X_CHALLENGE_ID import tech.libeufin.bank.* import tech.libeufin.bank.db.Database import tech.libeufin.bank.db.TanDAO.Challenge +import tech.libeufin.common.X_CHALLENGE_ID import java.security.SecureRandom import java.text.DecimalFormat import java.time.Instant diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -25,12 +25,12 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.util.* import io.ktor.util.pipeline.* -import tech.libeufin.bank.* -import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.bank.BearerToken +import tech.libeufin.bank.TokenScope +import tech.libeufin.bank.db.AccountDAO.CheckPasswordResult import tech.libeufin.bank.db.Database import tech.libeufin.common.* -import tech.libeufin.common.api.* -import tech.libeufin.common.crypto.PwCrypto +import tech.libeufin.common.api.intercept import java.time.Instant /** Used to store if the currently authenticated user is admin */ diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt @@ -0,0 +1,53 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import tech.libeufin.bank.bankConfig +import tech.libeufin.bank.db.AccountDAO.AccountPatchAuthResult +import tech.libeufin.bank.logger +import tech.libeufin.bank.withDb +import tech.libeufin.common.CommonOption +import tech.libeufin.common.cliCmd + +class ChangePw : CliktCommand("Change account password", name = "passwd") { + private val common by CommonOption() + private val username by argument("username", help = "Account username") + private val password by argument( + "password", + help = "Account password used for authentication" + ) + + override fun run() = cliCmd(logger, common.log) { + bankConfig(common.config).withDb { db, _ -> + val res = db.account.reconfigPassword(username, password, null, true) + when (res) { + AccountPatchAuthResult.UnknownAccount -> + throw Exception("Password change for '$username' account failed: unknown account") + AccountPatchAuthResult.OldPasswordMismatch, + AccountPatchAuthResult.TanRequired -> { /* Can never happen */ } + AccountPatchAuthResult.Success -> + logger.info("Password change for '$username' account succeeded") + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt @@ -0,0 +1,121 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.convert +import com.github.ajalt.clikt.parameters.arguments.optional +import com.github.ajalt.clikt.parameters.groups.OptionGroup +import com.github.ajalt.clikt.parameters.groups.cooccurring +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.* +import io.ktor.server.application.* +import io.ktor.server.http.content.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.json.Json +import tech.libeufin.bank.* +import tech.libeufin.bank.api.* +import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.common.* + +class CreateAccountOption: OptionGroup() { + val username: String by option( + "--username", "-u", + help = "Account unique username" + ).required() + val password: String by option( + "--password", "-p", + help = "Account password used for authentication" + ).prompt(requireConfirmation = true, hideInput = true) + val name: String by option( + help = "Legal name of the account owner" + ).required() + val is_public: Boolean by option( + "--public", + help = "Make this account visible to anyone" + ).flag() + val exchange: Boolean by option( + help = "Make this account a taler exchange" + ).flag() + val email: String? by option(help = "E-Mail address used for TAN transmission") + val phone: String? by option(help = "Phone number used for TAN transmission") + val cashout_payto_uri: IbanPayto? by option( + help = "Payto URI of a fiant account who receive cashout amount" + ).convert { Payto.parse(it).expectIban() } + val payto_uri: Payto? by option( + help = "Payto URI of this account" + ).convert { Payto.parse(it) } + 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( + "Create an account, returning the payto://-URI associated with it", + name = "create-account" +) { + private val common by CommonOption() + private val json by argument().convert { Json.decodeFromString<RegisterAccountRequest>(it) }.optional() + private val options by CreateAccountOption().cooccurring() + + override fun run() = cliCmd(logger, common.log) { + // TODO support setting tan + bankConfig(common.config).withDb { db, cfg -> + val req = json ?: options?.run { + RegisterAccountRequest( + username = username, + password = password, + name = name, + is_public = is_public, + is_taler_exchange = exchange, + contact_data = ChallengeContactData( + email = Option.Some(email), + phone = Option.Some(phone), + ), + cashout_payto_uri = cashout_payto_uri, + payto_uri = payto_uri, + debit_threshold = debit_threshold, + min_cashout = min_cashout + ) + } + req?.let { + when (val result = createAccount(db, cfg, req, true)) { + AccountCreationResult.BonusBalanceInsufficient -> + throw Exception("Insufficient admin funds to grant bonus") + AccountCreationResult.LoginReuse -> + throw Exception("Account username reuse '${req.username}'") + AccountCreationResult.PayToReuse -> + throw Exception("Bank internalPayToUri reuse") + is AccountCreationResult.Success -> { + logger.info("Account '${req.username}' created") + println(result.payto) + } + } + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt @@ -0,0 +1,60 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.bank.bankConfig +import tech.libeufin.bank.createAdminAccount +import tech.libeufin.bank.db.AccountDAO.AccountCreationResult +import tech.libeufin.bank.logger +import tech.libeufin.bank.withDb +import tech.libeufin.common.CommonOption +import tech.libeufin.common.cliCmd +import tech.libeufin.common.db.dbInit +import tech.libeufin.common.db.pgDataSource + +class DbInit : CliktCommand("Initialize the libeufin-bank database", name = "dbinit") { + private val common by CommonOption() + private val reset by option( + "--reset", "-r", + help = "Reset database (DANGEROUS: All existing data is lost)" + ).flag() + + override fun run() = cliCmd(logger, common.log) { + val cfg = bankConfig(common.config) + val dbCfg = cfg.dbCfg + pgDataSource(dbCfg.dbConnStr).dbInit(dbCfg, "libeufin-bank", reset) + cfg.withDb { db, cfg -> + // Create admin account if missing + val res = createAdminAccount(db, cfg) + when (res) { + AccountCreationResult.BonusBalanceInsufficient -> {} + AccountCreationResult.LoginReuse -> {} + AccountCreationResult.PayToReuse -> + throw Exception("Failed to create admin's account") + is AccountCreationResult.Success -> + logger.info("Admin's account created") + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt @@ -0,0 +1,102 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.convert +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.boolean +import tech.libeufin.bank.* +import tech.libeufin.bank.api.patchAccount +import tech.libeufin.bank.db.AccountDAO.AccountPatchResult +import tech.libeufin.common.* + + +class EditAccount : CliktCommand( + "Edit an existing account", + name = "edit-account" +) { + private val common by CommonOption() + private val username: String by argument( + "username", + help = "Account username" + ) + private val name: String? by option( + help = "Legal name of the account owner" + ) + private val exchange: Boolean? by option( + hidden = true + ).boolean() + private val is_public: Boolean? by option( + "--public", + help = "Make this account visible to anyone" + ).boolean() + private val email: String? by option(help = "E-Mail address used for TAN transmission") + private val phone: String? by option(help = "Phone number used for TAN transmission") + 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) { + bankConfig(common.config).withDb { db, cfg -> + val req = AccountReconfiguration( + name = name, + is_taler_exchange = exchange, + is_public = is_public, + contact_data = ChallengeContactData( + // PATCH semantic, if not given do not change, if empty remove + email = if (email == null) Option.None else Option.Some(if (email != "") email else null), + 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, + 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, cfg, req, username, true, true)) { + AccountPatchResult.Success -> + logger.info("Account '$username' edited") + AccountPatchResult.UnknownAccount -> + throw Exception("Account '$username' not found") + AccountPatchResult.MissingTanInfo -> + throw Exception("missing info for tan channel ${req.tan_channel.get()}") + AccountPatchResult.NonAdminName, + AccountPatchResult.NonAdminCashout, + AccountPatchResult.NonAdminDebtLimit, + AccountPatchResult.NonAdminMinCashout, + is AccountPatchResult.TanRequired -> { + // Unreachable as we edit account as admin + } + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/Gc.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/Gc.kt @@ -0,0 +1,43 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import tech.libeufin.bank.bankConfig +import tech.libeufin.bank.logger +import tech.libeufin.bank.withDb +import tech.libeufin.common.CommonOption +import tech.libeufin.common.cliCmd +import java.time.Instant + +class GC : CliktCommand( + "Run garbage collection: abort expired operations and clean expired data", + name = "gc" +) { + private val common by CommonOption() + + override fun run() = cliCmd(logger, common.log) { + bankConfig(common.config).withDb { db, cfg -> + logger.info("Run garbage collection") + db.gc.collect(Instant.now(), cfg.gcAbortAfter, cfg.gcCleanAfter, cfg.gcDeleteAfter) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt @@ -0,0 +1,36 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.versionOption +import tech.libeufin.bank.BANK_CONFIG_SOURCE +import tech.libeufin.common.CliConfigCmd +import tech.libeufin.common.getVersion + +class LibeufinBank : CliktCommand() { + init { + versionOption(getVersion()) + subcommands(Serve(), DbInit(), CreateAccount(), EditAccount(), ChangePw(), GC(), CliConfigCmd(BANK_CONFIG_SOURCE)) + } + + override fun run() = Unit +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt @@ -0,0 +1,68 @@ +/* + * 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.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import tech.libeufin.bank.bankConfig +import tech.libeufin.bank.corebankWebApp +import tech.libeufin.bank.logger +import tech.libeufin.bank.withDb +import tech.libeufin.common.CommonOption +import tech.libeufin.common.api.serve +import tech.libeufin.common.cliCmd +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +class Serve: CliktCommand("Run libeufin-bank HTTP server", name = "serve") { + private val common by CommonOption() + + override fun run() = cliCmd(logger, common.log) { + bankConfig(common.config).withDb { db, cfg -> + if (cfg.allowConversion) { + logger.info("Ensure exchange account exists") + val info = db.account.bankInfo("exchange", cfg.payto) + if (info == null) { + throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") + } else if (!info.isTalerExchange) { + throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") + } + logger.info("Ensure conversion is enabled") + val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-setup.sql") + if (!sqlProcedures.exists()) { + throw Exception("Missing libeufin-conversion-setup.sql file") + } + db.conn { it.execSQLUpdate(sqlProcedures.readText()) } + } else { + logger.info("Ensure conversion is disabled") + val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-drop.sql") + if (!sqlProcedures.exists()) { + throw Exception("Missing libeufin-conversion-drop.sql file") + } + db.conn { it.execSQLUpdate(sqlProcedures.readText()) } + // Remove conversion info from the database ? + } + serve(cfg.serverCfg) { + corebankWebApp(db, cfg) + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -21,8 +21,12 @@ package tech.libeufin.bank.db import tech.libeufin.bank.ConversionRate import tech.libeufin.bank.RoundingMode -import tech.libeufin.common.* -import tech.libeufin.common.db.* +import tech.libeufin.common.DecimalNumber +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.db.getAmount +import tech.libeufin.common.db.one +import tech.libeufin.common.db.oneOrNull +import tech.libeufin.common.db.withStatement /** Data access logic for conversion */ class ConversionDAO(private val db: Database) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt @@ -19,8 +19,8 @@ package tech.libeufin.bank.db -import tech.libeufin.common.micros import tech.libeufin.common.db.withStatement +import tech.libeufin.common.micros import java.time.Duration import java.time.Instant diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -21,8 +21,8 @@ package tech.libeufin.bank.db import tech.libeufin.bank.Operation import tech.libeufin.bank.TanChannel -import tech.libeufin.common.db.oneOrNull import tech.libeufin.common.db.one +import tech.libeufin.common.db.oneOrNull import tech.libeufin.common.internalServerError import tech.libeufin.common.micros import java.time.Duration diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -21,7 +21,8 @@ package tech.libeufin.bank.db import org.slf4j.Logger import org.slf4j.LoggerFactory -import tech.libeufin.bank.* +import tech.libeufin.bank.BankAccountTransactionInfo +import tech.libeufin.bank.TransactionDirection import tech.libeufin.common.* import tech.libeufin.common.db.* import java.time.Instant diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -24,8 +24,14 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import tech.libeufin.bank.* -import tech.libeufin.common.* -import tech.libeufin.common.db.* +import tech.libeufin.common.EddsaPublicKey +import tech.libeufin.common.Payto +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.db.getAmount +import tech.libeufin.common.db.getOptAmount +import tech.libeufin.common.db.one +import tech.libeufin.common.db.oneOrNull +import tech.libeufin.common.micros import java.time.Instant import java.util.* diff --git a/bank/src/test/kotlin/CommonApiTest.kt b/bank/src/test/kotlin/CommonApiTest.kt @@ -20,7 +20,9 @@ import io.ktor.client.request.* import io.ktor.http.* import org.junit.Test -import tech.libeufin.common.* +import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.assertNotFound +import tech.libeufin.common.assertStatus class CommonApiTest { @Test diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -24,10 +24,10 @@ import io.ktor.server.testing.* import kotlinx.serialization.json.JsonElement import org.junit.Test import tech.libeufin.bank.* -import tech.libeufin.bank.auth.* +import tech.libeufin.bank.auth.TOKEN_PREFIX import tech.libeufin.common.* -import tech.libeufin.common.db.* -import tech.libeufin.common.crypto.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.db.one import java.time.Duration import java.time.Instant import java.util.* diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -23,9 +23,12 @@ import kotlinx.coroutines.launch import org.junit.Test import tech.libeufin.bank.createAdminAccount import tech.libeufin.bank.db.AccountDAO.AccountCreationResult +import tech.libeufin.common.TalerError +import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.assertOk import tech.libeufin.common.db.one import tech.libeufin.common.db.oneOrNull -import tech.libeufin.common.* +import tech.libeufin.common.json import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -19,7 +19,9 @@ import io.ktor.client.request.* import org.junit.Test -import tech.libeufin.bank.* +import tech.libeufin.bank.Operation +import tech.libeufin.bank.RegisterAccountResponse +import tech.libeufin.bank.TokenScope import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult import tech.libeufin.bank.db.ExchangeDAO.TransferResult import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -23,8 +23,8 @@ import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.CreditDebitInfo import tech.libeufin.bank.RelativeTime -import tech.libeufin.common.TalerProtocolTimestamp import tech.libeufin.common.TalerAmount +import tech.libeufin.common.TalerProtocolTimestamp import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt @@ -19,7 +19,8 @@ import io.ktor.http.* import org.junit.Test -import tech.libeufin.common.* +import tech.libeufin.common.RevenueIncomingHistory +import tech.libeufin.common.assertOk class RevenueApiTest { // GET /accounts/{USERNAME}/taler-revenue/config diff --git a/bank/src/test/kotlin/SecurityTest.kt b/bank/src/test/kotlin/SecurityTest.kt @@ -19,7 +19,7 @@ import io.ktor.client.request.* import io.ktor.http.* -import io.ktor.http.content.OutputStreamContent +import io.ktor.http.content.* import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.common.* diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -23,8 +23,11 @@ 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.assertOkJson import tech.libeufin.common.db.executeQueryCheck -import tech.libeufin.common.* +import tech.libeufin.common.micros import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt @@ -17,25 +17,23 @@ * <http://www.gnu.org/licenses/> */ -import org.junit.* -import io.ktor.client.* import io.ktor.client.request.* -import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.server.testing.* +import org.junit.Test +import org.postgresql.jdbc.PgConnection +import tech.libeufin.bank.* import tech.libeufin.common.* -import tech.libeufin.common.db.* import tech.libeufin.common.crypto.PwCrypto -import tech.libeufin.bank.* -import tech.libeufin.bank.db.* -import org.postgresql.jdbc.PgConnection -import kotlin.math.* -import kotlin.time.* -import kotlin.time.Duration.* -import java.util.UUID -import java.time.* -import kotlin.random.Random -import kotlin.math.min +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sqrt +import kotlin.time.DurationUnit +import kotlin.time.measureTime +import kotlin.time.toDuration class Bench { diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt @@ -81,7 +81,8 @@ fun cliCmd(logger: Logger, level: Level, lambda: suspend () -> Unit) { class CommonOption: OptionGroup() { val config by option( "--config", "-c", - help = "Specifies the configuration file" + help = "Specifies the configuration file", + metavar = "config_file" ).path() val log by option( "--log", "-L", diff --git a/common/src/main/kotlin/Table.kt b/common/src/main/kotlin/Table.kt @@ -19,8 +19,8 @@ package tech.libeufin.common -import kotlin.math.max import tech.libeufin.common.ANSI.displayLength +import kotlin.math.max data class ColumnStyle( val alignLeft: Boolean = true diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt @@ -19,18 +19,17 @@ package tech.libeufin.common +import kotlinx.serialization.json.Json import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.nio.file.* +import java.nio.file.AccessDeniedException +import java.nio.file.NoSuchFileException +import java.nio.file.NotDirectoryException +import java.nio.file.Path import java.time.Duration -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit import kotlin.io.path.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json private val logger: Logger = LoggerFactory.getLogger("libeufin-config") diff --git a/common/src/main/kotlin/crypto/CryptoUtil.kt b/common/src/main/kotlin/crypto/CryptoUtil.kt @@ -20,19 +20,25 @@ package tech.libeufin.common.crypto import org.bouncycastle.asn1.x500.X500Name -import org.bouncycastle.asn1.x509.* +import org.bouncycastle.asn1.x509.BasicConstraints import org.bouncycastle.asn1.x509.Extension -import org.bouncycastle.cert.jcajce.* +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder -import org.bouncycastle.crypto.generators.Argon2BytesGenerator; -import org.bouncycastle.crypto.params.Argon2Parameters; -import tech.libeufin.common.* +import tech.libeufin.common.encodeHex import java.io.ByteArrayOutputStream import java.io.InputStream import java.math.BigInteger -import java.security.* -import java.security.cert.* +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.security.interfaces.RSAPrivateCrtKey import java.security.interfaces.RSAPublicKey import java.security.spec.* diff --git a/common/src/main/kotlin/crypto/PwCrypto.kt b/common/src/main/kotlin/crypto/PwCrypto.kt @@ -19,7 +19,8 @@ package tech.libeufin.common.crypto -import tech.libeufin.common.* +import tech.libeufin.common.encodeBase64 +import tech.libeufin.common.rand import java.security.SecureRandom data class PasswordHashCheck( diff --git a/common/src/main/kotlin/db/DbPool.kt b/common/src/main/kotlin/db/DbPool.kt @@ -24,9 +24,7 @@ import com.zaxxer.hikari.HikariDataSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.postgresql.jdbc.PgConnection -import org.postgresql.util.PSQLState import tech.libeufin.common.MIN_VERSION -import java.sql.SQLException import java.sql.PreparedStatement open class DbPool(cfg: DatabaseConfig, schema: String) : java.io.Closeable { diff --git a/common/src/main/kotlin/db/transaction.kt b/common/src/main/kotlin/db/transaction.kt @@ -23,10 +23,10 @@ import org.postgresql.jdbc.PgConnection import org.postgresql.util.PSQLState import org.slf4j.Logger import org.slf4j.LoggerFactory +import tech.libeufin.common.SERIALIZATION_RETRY import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.SQLException -import tech.libeufin.common.SERIALIZATION_RETRY internal val logger: Logger = LoggerFactory.getLogger("libeufin-db") diff --git a/common/src/main/kotlin/db/types.kt b/common/src/main/kotlin/db/types.kt @@ -19,12 +19,7 @@ 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 tech.libeufin.common.* import java.sql.ResultSet fun ResultSet.getAmount(name: String, currency: String): TalerAmount { diff --git a/common/src/main/kotlin/helpers.kt b/common/src/main/kotlin/helpers.kt @@ -24,15 +24,15 @@ import java.io.FilterInputStream import java.io.InputStream import java.math.BigInteger import java.security.SecureRandom +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.* import java.util.zip.DeflaterInputStream import java.util.zip.InflaterInputStream import java.util.zip.ZipInputStream import kotlin.random.Random -import java.time.LocalDate -import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter /* ----- String ----- */ diff --git a/common/src/test/kotlin/AmountTest.kt b/common/src/test/kotlin/AmountTest.kt @@ -18,7 +18,7 @@ */ import org.junit.Test -import tech.libeufin.common.* +import tech.libeufin.common.TalerAmount import kotlin.test.assertEquals class AmountTest { diff --git a/common/src/test/kotlin/ConfigTest.kt b/common/src/test/kotlin/ConfigTest.kt @@ -17,18 +17,19 @@ * <http://www.gnu.org/licenses/> */ +import com.github.ajalt.clikt.testing.test import org.junit.Test import tech.libeufin.common.* -import tech.libeufin.common.db.* -import uk.org.webcompere.systemstubs.SystemStubs.* -import com.github.ajalt.clikt.testing.* +import tech.libeufin.common.db.currentUser +import tech.libeufin.common.db.jdbcFromPg +import uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariable import java.io.ByteArrayOutputStream import java.io.PrintStream -import kotlin.io.path.* -import kotlin.test.* import java.time.Duration -import java.time.LocalDate -import java.time.temporal.ChronoUnit +import kotlin.io.path.* +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith class ConfigTest { @Test diff --git a/common/src/test/kotlin/CryptoUtilTest.kt b/common/src/test/kotlin/CryptoUtilTest.kt @@ -17,17 +17,14 @@ * <http://www.gnu.org/licenses/> */ -import org.junit.Ignore import org.junit.Test import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.crypto.PwCrypto import tech.libeufin.common.crypto.PasswordHashCheck -import tech.libeufin.common.* -import java.util.* -import kotlin.io.path.Path -import kotlin.io.path.readBytes -import kotlin.io.path.readText -import kotlin.io.path.writeBytes +import tech.libeufin.common.crypto.PwCrypto +import tech.libeufin.common.decodeUpHex +import tech.libeufin.common.encodeBase64 +import tech.libeufin.common.encodeHex +import tech.libeufin.common.encodeUpHex import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/common/src/test/kotlin/EncodingTest.kt b/common/src/test/kotlin/EncodingTest.kt @@ -17,17 +17,12 @@ * <http://www.gnu.org/licenses/> */ -import org.junit.Ignore import org.junit.Test -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.crypto.PwCrypto -import tech.libeufin.common.* -import java.util.* -import kotlin.io.path.Path -import kotlin.io.path.readBytes -import kotlin.io.path.readText -import kotlin.io.path.writeBytes -import kotlin.test.* +import tech.libeufin.common.Base32Crockford +import tech.libeufin.common.rand +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class EncodingTest { @Test diff --git a/common/src/test/kotlin/TxMedataTest.kt b/common/src/test/kotlin/TxMedataTest.kt @@ -17,8 +17,11 @@ * <http://www.gnu.org/licenses/> */ -import tech.libeufin.common.* -import kotlin.test.* +import tech.libeufin.common.EddsaPublicKey +import tech.libeufin.common.parseIncomingTxMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails class TxMetadataTest{ fun assertFailsMsg(msg: String, lambda: () -> Unit) { diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -38,6 +38,7 @@ CLIENT_PRIVATE_KEYS_FILE = ${LIBEUFIN_NEXUS_HOME}/client-ebics-keys.json # Identifies the EBICS + ISO20022 style used by the bank. # Typically, it is named after the bank itself. +# This can either be postfinance or gls. BANK_DIALECT = postfinance # Specify the account type and therefore the indexing behavior. diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -20,7 +20,7 @@ package tech.libeufin.nexus import tech.libeufin.common.* -import tech.libeufin.common.db.* +import tech.libeufin.common.db.DatabaseConfig import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.ebics.Dialect import java.nio.file.Path diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt @@ -1,46 +0,0 @@ -/* - * 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 - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.option -import tech.libeufin.common.CommonOption -import tech.libeufin.common.cliCmd -import tech.libeufin.common.db.dbInit -import tech.libeufin.common.db.pgDataSource - -/** - * This subcommand tries to load the SQL files that define - * the Nexus DB schema. Admits the --reset option to delete - * the data first. - */ -class DbInit : CliktCommand("Initialize the libeufin-nexus database", name = "dbinit") { - private val common by CommonOption() - private val reset by option( - "--reset", "-r", - help = "Reset database (DANGEROUS: All existing data is lost)" - ).flag() - - override fun run() = cliCmd(logger, common.log) { - val cfg = dbConfig(common.config) - pgDataSource(cfg.dbConnStr).dbInit(cfg, "libeufin-nexus", reset) - } -} -\ 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 @@ -1,341 +0,0 @@ -/* - * 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 - -import com.github.ajalt.clikt.core.CliktCommand -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.* -import io.ktor.client.* -import io.ktor.client.plugins.* -import kotlinx.coroutines.* -import tech.libeufin.common.* -import tech.libeufin.nexus.db.* -import tech.libeufin.nexus.db.PaymentDAO.* -import tech.libeufin.nexus.ebics.* -import java.io.IOException -import java.io.InputStream -import java.time.Duration -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import kotlin.io.* -import kotlin.io.path.* -import kotlin.time.toKotlinDuration - -/** Ingests an outgoing [payment] into [db] */ -suspend fun ingestOutgoingPayment( - db: Database, - payment: OutgoingPayment -) { - 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") - else - logger.warn("$payment recovered") - } else { - logger.debug("{} already seen", payment) - } -} - -/** - * Ingest an incoming [payment] into [db] - * Stores the payment into valid talerable ones or bounces it, according to [accountType] . - */ -suspend fun ingestIncomingPayment( - db: Database, - payment: IncomingPayment, - accountType: AccountType -) { - suspend fun bounce(msg: String) { - if (payment.bankId == null) { - logger.debug("$payment ignored: missing bank ID") - return; - } - 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("{} already seen and bounced in '{}': {}", payment, result.bounceId, msg) - } - } - AccountType.normal -> { - val res = db.payment.registerIncoming(payment) - if (res.new) { - logger.info("$payment") - } else { - logger.debug("{} already seen", payment) - } - } - } - } - runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold( - onSuccess = { reservePub -> - when (val res = db.payment.registerTalerableIncoming(payment, reservePub)) { - IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") - is IncomingRegistrationResult.Success -> { - if (res.new) { - logger.info("$payment") - } else { - logger.debug("{} already seen", payment) - } - } - } - }, - onFailure = { e -> bounce(e.fmt())} - ) -} - -/** Ingest an EBICS [payload] of [document] into [db] */ -private suspend fun ingestPayload( - db: Database, - cfg: NexusConfig, - payload: InputStream, - document: SupportedDocument -) { - /** Ingest a single EBICS [xml] [document] into [db] */ - suspend fun ingest(xml: InputStream) { - when (document) { - 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, cfg.accountType) - is OutgoingPayment -> ingestOutgoingPayment(db, it) - is TxNotification.Reversal -> { - logger.error("BOUNCE '${it.msgId}': ${it.reason}") - db.initiated.reversal(it.msgId, "Payment bounced: ${it.reason}") - } - } - } - } - } catch (e: Exception) { - throw Exception("Ingesting notifications failed", e) - } - } - SupportedDocument.PAIN_002_LOGS -> { - val acks = parseCustomerAck(xml) - for (ack in acks) { - when (ack.actionType) { - HacAction.ORDER_HAC_FINAL_POS -> { - logger.debug("{}", ack) - db.initiated.logSuccess(ack.orderId!!)?.let { requestUID -> - logger.info("Payment '$requestUID' accepted at ${ack.timestamp.fmtDateTime()}") - } - } - HacAction.ORDER_HAC_FINAL_NEG -> { - logger.debug("{}", ack) - db.initiated.logFailure(ack.orderId!!)?.let { (requestUID, msg) -> - logger.error("Payment '$requestUID' refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}") - } - } - else -> { - logger.debug("{}", ack) - if (ack.orderId != null) { - db.initiated.logMessage(ack.orderId, ack.msg()) - } - } - } - } - } - SupportedDocument.PAIN_002 -> { - val status = parseCustomerPaymentStatusReport(xml) - val msg = status.msg() - logger.debug("{}", status) - if (status.paymentCode == ExternalPaymentGroupStatusCode.RJCT) { - db.initiated.bankFailure(status.msgId, msg) - logger.error("Transaction '${status.msgId}' was rejected : $msg") - } else { - db.initiated.bankMessage(status.msgId, msg) - } - } - } - } - - // Unzip payload if necessary - when (document) { - SupportedDocument.PAIN_002, - SupportedDocument.CAMT_052, - SupportedDocument.CAMT_053, - SupportedDocument.CAMT_054 -> { - try { - payload.unzipEach { fileName, xml -> - logger.trace("parse $fileName") - ingest(xml) - } - } catch (e: IOException) { - throw Exception("Could not open any ZIP archive", e) - } - } - SupportedDocument.PAIN_002_LOGS -> ingest(payload) - } -} - -/** - * Fetch and ingest banking records of type [docs] using EBICS [client] starting from [pinnedStart] - * - * If [pinnedStart] is null fetch new records. - * - * Return true if successful - */ -private suspend fun fetchEbicsDocuments( - client: EbicsClient, - docs: List<EbicsDocument>, - pinnedStart: Instant?, -): Boolean { - val lastExecutionTime: Instant? = pinnedStart - return docs.all { doc -> - try { - if (lastExecutionTime == null) { - logger.info("Fetching new '${doc.fullDescription()}'") - } else { - logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime") - } - // downloading the content - val doc = doc.doc() - val order = client.cfg.dialect.downloadDoc(doc, false) - client.download( - order, - lastExecutionTime, - null - ) { payload -> - ingestPayload(client.db, client.cfg, payload, doc) - } - true - } catch (e: Exception) { - e.fmtLog(logger) - false - } - } -} - -enum class EbicsDocument { - /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 - acknowledgement, - /// Payment status - CustomerPaymentStatusReport pain.002 - status, - /// 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" - } - - 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" - } - - 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 - } -} - -class EbicsFetch: CliktCommand("Fetches EBICS files") { - private val common by CommonOption() - private val transient by option( - "--transient", - help = "This flag fetches only once from the bank and returns, " + - "ignoring the 'frequency' configuration value" - ).flag(default = false) - private val documents: Set<EbicsDocument> by argument( - help = "Which documents should be fetched? If none are specified, all supported documents will be fetched", - helpTags = EbicsDocument.entries.associate { Pair(it.name, it.shortDescription()) } - ).enum<EbicsDocument>().multiple().unique() - 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 ebicsLog by option( - "--debug-ebics", - help = "Log EBICS content at SAVEDIR", - ) - - /** - * This function collects the main steps of fetching banking records. - * In this current version, it does not implement long polling, instead - * it runs transient if FREQUENCY is zero. Transient is also the default - * mode when no flags are passed to the invocation. - */ - override fun run() = cliCmd(logger, common.log) { - nexusConfig(common.config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val client = EbicsClient( - cfg, - httpClient(), - db, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - val docs = if (documents.isEmpty()) EbicsDocument.entries else documents.toList() - if (transient) { - logger.info("Transient mode: fetching once and returning.") - val pinnedStartVal = pinnedStart - val pinnedStartArg = if (pinnedStartVal != null) { - logger.debug("Pinning start date to: $pinnedStartVal") - dateToInstant(pinnedStartVal) - } else null - if (!fetchEbicsDocuments(client, docs, pinnedStartArg)) { - throw Exception("Failed to fetch documents") - } - } else { - logger.debug("Running with a frequency of ${cfg.fetch.frequencyRaw}") - if (cfg.fetch.frequency == Duration.ZERO) { - logger.warn("Long-polling not implemented, running therefore in transient mode") - } - do { - fetchEbicsDocuments(client, docs, null) - delay(cfg.fetch.frequency.toKotlinDuration()) - } while (cfg.fetch.frequency != Duration.ZERO) - } - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsLogger.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsLogger.kt @@ -19,8 +19,8 @@ package tech.libeufin.nexus -import tech.libeufin.nexus.ebics.EbicsOrder import tech.libeufin.common.* +import tech.libeufin.nexus.ebics.EbicsOrder import java.io.* import java.nio.file.* import java.time.* @@ -29,8 +29,7 @@ import kotlin.io.* import kotlin.io.path.* /** Log EBICS transactions steps and payload if [path] is not null */ -class EbicsLogger(path: String?) { - private val dir = if (path != null) Path(path) else null +class EbicsLogger(private val dir: Path?) { init { if (dir != null) { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -1,240 +0,0 @@ -/* - * This file is part of LibEuFin. - * 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 - * 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 - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.groups.* -import com.github.ajalt.clikt.parameters.options.* -import io.ktor.client.* -import tech.libeufin.common.* -import tech.libeufin.common.crypto.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.ebics.EbicsKeyMng.Order.* -import java.nio.file.* -import java.time.Instant -import kotlin.io.path.* - -/** Load client private keys at [path] or create new ones if missing */ -private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile { - // If exists load from disk - val current = loadClientKeys(path) - if (current != null) return current - // Else create new keys - val newKeys = generateNewKeys() - persistClientKeys(newKeys, path) - logger.info("New client private keys created at '$path'") - return newKeys -} - -/** - * Asks the user to accept the bank public keys. - * - * @param bankKeys bank public keys, in format stored on disk. - * @return true if the user accepted, false otherwise. - */ -private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean { - val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex() - val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex() - println("The bank has the following keys:") - println("Encryption key: ${encHash.fmtChunkByTwo()}") - println("Authentication key: ${authHash.fmtChunkByTwo()}") - print("type 'yes, accept' to accept them: ") - val userResponse: String? = readlnOrNull() - return userResponse == "yes, accept" -} - -/** - * Perform an EBICS key management [order] using [client] and update on disk - * keys - */ -suspend fun doKeysRequestAndUpdateState( - cfg: NexusConfig, - privs: ClientPrivateKeysFile, - client: HttpClient, - ebicsLogger: EbicsLogger, - order: EbicsKeyMng.Order -) { - val resp = keyManagement(cfg, privs, client, ebicsLogger, order) - - when (order) { - INI, HIA -> { - if (resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_OR_USER_STATE) { - throw Exception("$order status code ${resp.technicalCode}: either your IDs are incorrect, or you already have keys registered with this bank") - } - } - HPB -> { - if (resp.technicalCode == EbicsReturnCode.EBICS_AUTHENTICATION_FAILED) { - throw Exception("$order status code ${resp.technicalCode}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank") - } - } - } - - val orderData = resp.okOrFail(order.name) - when (order) { - INI -> privs.submitted_ini = true - HIA -> privs.submitted_hia = true - HPB -> { - val orderData = requireNotNull(orderData) { - "HPB: missing order data" - } - val (authPub, encPub) = EbicsKeyMng.parseHpbOrder(orderData) - val bankKeys = BankPublicKeysFile( - bank_authentication_public_key = authPub, - bank_encryption_public_key = encPub, - accepted = false - ) - try { - persistBankKeys(bankKeys, cfg.bankPublicKeysPath) - } catch (e: Exception) { - throw Exception("Could not update the $order state on disk", e) - } - } - } - if (order != HPB) { - try { - persistClientKeys(privs, cfg.clientPrivateKeysPath) - } catch (e: Exception) { - throw Exception("Could not update the $order state on disk", e) - } - } - -} - -/** - * Mere collector of the PDF generation steps. Fails the - * process if a problem occurs. - * - * @param privs client private keys. - * @param cfg configuration handle. - */ -private fun makePdf(privs: ClientPrivateKeysFile, cfg: NexusConfig) { - val pdf = generateKeysPdf(privs, cfg) - val path = Path("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf") - try { - path.writeBytes(pdf, StandardOpenOption.CREATE_NEW) - } catch (e: Exception) { - if (e is FileAlreadyExistsException) throw Exception("PDF file exists already at '$path', not overriding it") - throw Exception("Could not write PDF to '$path'", e) - } - println("PDF file with keys created at '$path'") -} - -/** - * CLI class implementing the "ebics-setup" subcommand. - */ -class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { - private val common by CommonOption() - private val forceKeysResubmission by option( - help = "Resubmits all the keys to the bank" - ).flag(default = false) - private val autoAcceptKeys by option( - help = "Accepts the bank keys without the user confirmation" - ).flag(default = false) - private val generateRegistrationPdf by option( - help = "Generates the PDF with the client public keys to send to the bank" - ).flag(default = false) - private val ebicsLog by option( - "--debug-ebics", - help = "Log EBICS content at SAVEDIR", - ) - /** - * This function collects the main steps of setting up an EBICS access. - */ - override fun run() = cliCmd(logger, common.log) { - val cfg = nexusConfig(common.config) - - val client = httpClient() - val ebicsLogger = EbicsLogger(ebicsLog) - - val clientKeys = loadOrGenerateClientKeys(cfg.clientPrivateKeysPath) - var bankKeys = loadBankKeys(cfg.bankPublicKeysPath) - - // Check EBICS 3 support - val versions = HEV(client, cfg, ebicsLogger) - logger.debug("HEV: {}", versions) - if (!versions.contains(VersionNumber(3.0f, "H005")) && !versions.contains(VersionNumber(3.02f, "H005"))) { - throw Exception("EBICS 3 is not supported by your bank") - } - - // Privs exist. Upload their pubs - val keysNotSub = !clientKeys.submitted_ini - if ((!clientKeys.submitted_ini) || forceKeysResubmission) - doKeysRequestAndUpdateState(cfg, clientKeys, client, ebicsLogger, INI) - // Eject PDF if the keys were submitted for the first time, or the user asked. - if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg) - if ((!clientKeys.submitted_hia) || forceKeysResubmission) - doKeysRequestAndUpdateState(cfg, clientKeys, client, ebicsLogger, HIA) - - // Checking if the bank keys exist on disk - if (bankKeys == null) { - doKeysRequestAndUpdateState(cfg, clientKeys, client, ebicsLogger, HPB) - logger.info("Bank keys stored at ${cfg.bankPublicKeysPath}") - bankKeys = loadBankKeys(cfg.bankPublicKeysPath)!! - } - - if (!bankKeys.accepted) { - // Finishing the setup by accepting the bank keys. - if (autoAcceptKeys) bankKeys.accepted = true - else bankKeys.accepted = askUserToAcceptKeys(bankKeys) - - if (!bankKeys.accepted) { - throw Exception("Cannot successfully finish the setup without accepting the bank keys") - } - try { - persistBankKeys(bankKeys, cfg.bankPublicKeysPath) - } catch (e: Exception) { - throw Exception("Could not set bank keys as accepted on disk", e) - } - } - - // Check account information - logger.info("Doing administrative request HKD") - try { - cfg.withDb { db, cfg -> - EbicsClient( - cfg, - client, - db, - ebicsLogger, - clientKeys, - bankKeys - ).download(EbicsOrder.V3("HKD"), null, null) { stream -> - val hkd = EbicsAdministrative.parseHKD(stream) - val account = hkd.account - // TODO parse and check more information - if (account.currency != null && account.currency != cfg.currency) - logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank") - if (account.iban != null && account.iban != cfg.account.iban) - logger.error("Expected IBAN '${cfg.account.iban}' from config got '${account.iban}' from bank") - if (account.name != null && account.name != cfg.account.name) - logger.warn("Expected NAME '${cfg.account.name}' from config got '${account.name}' from bank") - - for (order in hkd.orders) { - logger.debug("${order.type}${order.params}: ${order.description}") - } - } - } - } catch (e: Exception) { - logger.warn("HKD failed: ${e.fmt()}") - } - - println("setup ready") - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -1,139 +0,0 @@ -/* - * This file is part of LibEuFin. - * 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 - * 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 - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.groups.* -import com.github.ajalt.clikt.parameters.options.* -import io.ktor.client.* -import kotlinx.coroutines.* -import tech.libeufin.common.* -import tech.libeufin.nexus.db.* -import tech.libeufin.nexus.ebics.* -import java.time.* -import java.util.* -import kotlin.time.toKotlinDuration - -/** - * Submit an initiated [payment] using [client]. - * - * Parse creditor IBAN account metadata then perform an EBICS direct credit. - * - * Returns the orderID - */ -private suspend fun submitInitiatedPayment( - client: EbicsClient, - payment: InitiatedPayment -): String { - val creditAccount = try { - val payto = Payto.parse(payment.creditPaytoUri).expectIban() - IbanAccountMetadata( - iban = payto.iban.value, - bic = payto.bic, - name = payto.receiverName!! - ) - } catch (e: Exception) { - throw e // TODO handle payto error - } - - val xml = createPain001( - requestUid = payment.requestUid, - initiationTimestamp = payment.initiationTime, - amount = payment.amount, - creditAccount = creditAccount, - debitAccount = client.cfg.account, - wireTransferSubject = payment.wireTransferSubject, - dialect = client.cfg.dialect - ) - return client.upload( - client.cfg.dialect.directDebit(), - xml - ) -} - -/** Submit all pending initiated payments using [client] */ -private suspend fun submitBatch(client: EbicsClient) { - client.db.initiated.submittable(client.cfg.currency).forEach { - logger.debug("Submitting payment '${it.requestUid}'") - runCatching { submitInitiatedPayment(client, it) }.fold( - onSuccess = { orderId -> - client.db.initiated.submissionSuccess(it.id, Instant.now(), orderId) - logger.info("Payment '${it.requestUid}' submitted") - }, - onFailure = { e -> - client.db.initiated.submissionFailure(it.id, Instant.now(), e.message) - logger.error("Payment '${it.requestUid}' submission failure: ${e.fmt()}") - throw e - } - ) - } -} - -class EbicsSubmit : CliktCommand("Submits any initiated payment found in the database") { - private val common by CommonOption() - private val transient by option( - "--transient", - help = "This flag submits what is found in the database and returns, " + - "ignoring the 'frequency' configuration value" - ).flag(default = false) - private val ebicsLog by option( - "--debug-ebics", - help = "Log EBICS content at SAVEDIR", - ) - - /** - * Submits any initiated payment that was not submitted - * so far and -- according to the configuration -- returns - * or long-polls (currently not implemented) for new payments. - * FIXME: reduce code duplication with the fetch subcommand. - */ - override fun run() = cliCmd(logger, common.log) { - nexusConfig(common.config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val client = EbicsClient( - cfg, - httpClient(), - db, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - val frequency: Duration = if (transient) { - logger.info("Transient mode: submitting what found and returning.") - Duration.ZERO - } else { - logger.debug("Running with a frequency of ${cfg.submit.frequencyRaw}") - if (cfg.submit.frequency == Duration.ZERO) { - logger.warn("Long-polling not implemented, running therefore in transient mode") - } - cfg.submit.frequency - } - do { - try { - submitBatch(client) - } catch (e: Exception) { - throw Exception("Failed to submit payments", e) - } - // TODO take submitBatch taken time in the delay - delay(frequency.toKotlinDuration()) - } while (frequency != Duration.ZERO) - } - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -25,33 +25,14 @@ */ package tech.libeufin.nexus -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.ProgramResult -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.options.* -import com.github.ajalt.clikt.parameters.types.enum -import io.ktor.client.* -import io.ktor.client.plugins.* import io.ktor.server.application.* import org.slf4j.Logger import org.slf4j.LoggerFactory -import tech.libeufin.common.* -import tech.libeufin.common.api.serve import tech.libeufin.common.api.talerApi -import tech.libeufin.common.db.DatabaseConfig import tech.libeufin.nexus.api.revenueApi import tech.libeufin.nexus.api.wireGatewayApi +import tech.libeufin.nexus.cli.LibeufinNexus import tech.libeufin.nexus.db.Database -import tech.libeufin.nexus.db.InitiatedPayment -import tech.libeufin.nexus.ebics.EbicsOrder -import tech.libeufin.nexus.ebics.EbicsClient -import tech.libeufin.nexus.ebics.* -import java.nio.file.Path -import java.time.Instant -import kotlinx.coroutines.delay internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") @@ -67,356 +48,6 @@ fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(logger) { revenueApi(db, cfg) } -class InitiatePayment: CliktCommand("Initiate an outgoing payment") { - private val common by CommonOption() - private val amount by option( - "--amount", - help = "The amount to transfer, payto 'amount' parameter takes the precedence" - ).convert { TalerAmount(it) } - private val subject by option( - "--subject", - help = "The payment subject, payto 'message' parameter takes the precedence" - ) - private val requestUid by option( - "--request-uid", - help = "The payment request UID" - ) - private val payto by argument( - help = "The credited account IBAN payto URI" - ).convert { Payto.parse(it).expectIban() } - - override fun run() = cliCmd(logger, common.log) { - nexusConfig(common.config).withDb { db, cfg -> - val subject = requireNotNull(payto.message ?: subject) { "Missing subject" } - val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" } - - requireNotNull(payto.receiverName) { "Missing receiver name in creditor payto" } - require(amount.currency == cfg.currency) { - "Wrong currency: expected ${cfg.currency} got ${amount.currency}" - } - - val requestUid = requestUid ?: run { - val bytes = ByteArray(16).rand() - Base32Crockford.encode(bytes) - } - - db.initiated.create( - InitiatedPayment( - id = -1, - amount = amount, - wireTransferSubject = subject, - creditPaytoUri = payto.toString(), - initiationTime = Instant.now(), - requestUid = requestUid - ) - ) - } - } -} - -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, common.log) { - val cfg = nexusConfig(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) - } - } - - cfg.withDb { db, cfg -> - serve(cfg.serverCfg) { - nexusApi(db, cfg) - } - } - } -} - -class Wss: CliktCommand("Listen to EBICS instant notification over websocket") { - private val common by CommonOption() - private val ebicsLog by option( - "--debug-ebics", - help = "Log EBICS content at SAVEDIR", - ) - - override fun run() = cliCmd(logger, common.log) { - val backoff = ExpoBackoffDecorr() - nexusConfig(common.config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val httpClient = httpClient() - val client = EbicsClient( - cfg, - httpClient, - db, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - while (true) { - try { - logger.info("Fetch WSS params") - val params = client.wssParams() - logger.debug("{}", params) - logger.info("Start listening") - params.connect(httpClient) { - backoff.reset() - } - } catch (e: Exception) { - delay(backoff.next()) - } - } - } - } -} - -class FakeIncoming: CliktCommand("Genere a fake incoming payment") { - private val common by CommonOption() - private val amount by option( - "--amount", - help = "The amount to transfer, payto 'amount' parameter takes the precedence" - ).convert { TalerAmount(it) } - private val subject by option( - "--subject", - help = "The payment subject, payto 'message' parameter takes the precedence" - ) - private val payto by argument( - help = "The debited account IBAN payto URI" - ).convert { Payto.parse(it).expectIban() } - - override fun run() = cliCmd(logger, common.log) { - nexusConfig(common.config).withDb { db, cfg -> - val subject = requireNotNull(payto.message ?: subject) { "Missing subject" } - val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" } - - require(amount.currency == cfg.currency) { - "Wrong currency: expected ${cfg.currency} got ${amount.currency}" - } - - val bankId = run { - val bytes = ByteArray(16).rand() - Base32Crockford.encode(bytes) - } - - ingestIncomingPayment(db, - IncomingPayment( - amount = amount, - debitPaytoUri = payto.toString(), - wireTransferSubject = subject, - executionTime = Instant.now(), - bankId = bankId - ), - cfg.accountType - ) - } - } -} - -class TxCheck: CliktCommand("Check transaction semantic") { - private val common by CommonOption() - - override fun run() = cliCmd(logger, common.log) { - val cfg = nexusConfig(common.config) - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val doc = EbicsDocument.acknowledgement.doc() - val order = cfg.dialect.downloadDoc(doc, false) - val client = httpClient() - val result = tech.libeufin.nexus.test.txCheck(client, cfg, clientKeys, bankKeys, order, cfg.dialect.directDebit()) - println("$result") - } -} - -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) { - nexusConfig(common.config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val pinnedStartVal = pinnedStart - val pinnedStartArg = if (pinnedStartVal != null) { - logger.debug("Pinning start date to: $pinnedStartVal") - dateToInstant(pinnedStartVal) - } else null - val client = EbicsClient( - cfg, - httpClient(), - db, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - try { - client.download( - EbicsOrder.V3(type, name, scope, messageName, messageVersion, container, option), - pinnedStartArg, - null - ) { stream -> - if (container == "ZIP") { - stream.unzipEach { fileName, xmlContent -> - println(fileName) - println(xmlContent.readBytes().toString(Charsets.UTF_8)) - } - } else { - 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.associate { Pair(it.name, it.description()) } - ).enum<ListKind>() - - override fun run() = cliCmd(logger, common.log) { - nexusConfig(common.config).withDb { db, cfg -> - 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.toString(), - 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 - ) - } - ) - } - } - printTable(columnNames, rows) - } - } -} - -class TestingCmd : CliktCommand("Testing helper commands", name = "testing") { - init { - subcommands(FakeIncoming(), ListCmd(), EbicsDownload(), TxCheck(), Wss()) - } - - override fun run() = Unit -} - -/** - * Main CLI class that collects all the subcommands. - */ -class LibeufinNexusCommand : CliktCommand() { - init { - versionOption(getVersion()) - subcommands(EbicsSetup(), DbInit(), Serve(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) - } - override fun run() = Unit -} - fun main(args: Array<String>) { - LibeufinNexusCommand().main(args) + LibeufinNexus().main(args) } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt @@ -22,9 +22,11 @@ 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.* +import tech.libeufin.common.HistoryParams +import tech.libeufin.common.RevenueConfig +import tech.libeufin.common.RevenueIncomingHistory +import tech.libeufin.nexus.NexusConfig +import tech.libeufin.nexus.db.Database fun Routing.revenueApi(db: Database, cfg: NexusConfig) = authApi(cfg.revenueApiCfg) { get("/taler-revenue/config") { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt @@ -0,0 +1,43 @@ +/* + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.CommonOption +import tech.libeufin.common.cliCmd +import tech.libeufin.common.db.dbInit +import tech.libeufin.common.db.pgDataSource +import tech.libeufin.nexus.dbConfig +import tech.libeufin.nexus.logger + +class DbInit : CliktCommand("Initialize the libeufin-nexus database", name = "dbinit") { + private val common by CommonOption() + private val reset by option( + "--reset", "-r", + help = "Reset database (DANGEROUS: All existing data is lost)" + ).flag() + + override fun run() = cliCmd(logger, common.log) { + val cfg = dbConfig(common.config) + pgDataSource(cfg.dbConnStr).dbInit(cfg, "libeufin-nexus", reset) + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -0,0 +1,326 @@ +/* + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.arguments.unique +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.enum +import kotlinx.coroutines.delay +import tech.libeufin.common.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.db.Database +import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult +import tech.libeufin.nexus.ebics.EbicsClient +import tech.libeufin.nexus.ebics.SupportedDocument +import java.io.IOException +import java.io.InputStream +import java.time.Duration +import java.time.Instant +import kotlin.time.toKotlinDuration + +/** Ingests an outgoing [payment] into [db] */ +suspend fun ingestOutgoingPayment( + db: Database, + payment: OutgoingPayment +) { + 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") + else + logger.warn("$payment recovered") + } else { + logger.debug("{} already seen", payment) + } +} + +/** + * Ingest an incoming [payment] into [db] + * Stores the payment into valid talerable ones or bounces it, according to [accountType] . + */ +suspend fun ingestIncomingPayment( + db: Database, + payment: IncomingPayment, + accountType: AccountType +) { + suspend fun bounce(msg: String) { + if (payment.bankId == null) { + logger.debug("$payment ignored: missing bank ID") + return; + } + 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("{} already seen and bounced in '{}': {}", payment, result.bounceId, msg) + } + } + AccountType.normal -> { + val res = db.payment.registerIncoming(payment) + if (res.new) { + logger.info("$payment") + } else { + logger.debug("{} already seen", payment) + } + } + } + } + runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold( + onSuccess = { reservePub -> + when (val res = db.payment.registerTalerableIncoming(payment, reservePub)) { + IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") + is IncomingRegistrationResult.Success -> { + if (res.new) { + logger.info("$payment") + } else { + logger.debug("{} already seen", payment) + } + } + } + }, + onFailure = { e -> bounce(e.fmt())} + ) +} + +/** Ingest an EBICS [payload] of [document] into [db] */ +private suspend fun ingestPayload( + db: Database, + cfg: NexusConfig, + payload: InputStream, + document: SupportedDocument +) { + /** Ingest a single EBICS [xml] [document] into [db] */ + suspend fun ingest(xml: InputStream) { + when (document) { + 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, cfg.accountType) + is OutgoingPayment -> ingestOutgoingPayment(db, it) + is TxNotification.Reversal -> { + logger.error("BOUNCE '${it.msgId}': ${it.reason}") + db.initiated.reversal(it.msgId, "Payment bounced: ${it.reason}") + } + } + } + } + } catch (e: Exception) { + throw Exception("Ingesting notifications failed", e) + } + } + SupportedDocument.PAIN_002_LOGS -> { + val acks = parseCustomerAck(xml) + for (ack in acks) { + when (ack.actionType) { + HacAction.ORDER_HAC_FINAL_POS -> { + logger.debug("{}", ack) + db.initiated.logSuccess(ack.orderId!!)?.let { requestUID -> + logger.info("Payment '$requestUID' accepted at ${ack.timestamp.fmtDateTime()}") + } + } + HacAction.ORDER_HAC_FINAL_NEG -> { + logger.debug("{}", ack) + db.initiated.logFailure(ack.orderId!!)?.let { (requestUID, msg) -> + logger.error("Payment '$requestUID' refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}") + } + } + else -> { + logger.debug("{}", ack) + if (ack.orderId != null) { + db.initiated.logMessage(ack.orderId, ack.msg()) + } + } + } + } + } + SupportedDocument.PAIN_002 -> { + val status = parseCustomerPaymentStatusReport(xml) + val msg = status.msg() + logger.debug("{}", status) + if (status.paymentCode == ExternalPaymentGroupStatusCode.RJCT) { + db.initiated.bankFailure(status.msgId, msg) + logger.error("Transaction '${status.msgId}' was rejected : $msg") + } else { + db.initiated.bankMessage(status.msgId, msg) + } + } + } + } + + // Unzip payload if necessary + when (document) { + SupportedDocument.PAIN_002, + SupportedDocument.CAMT_052, + SupportedDocument.CAMT_053, + SupportedDocument.CAMT_054 -> { + try { + payload.unzipEach { fileName, xml -> + logger.trace("parse $fileName") + ingest(xml) + } + } catch (e: IOException) { + throw Exception("Could not open any ZIP archive", e) + } + } + SupportedDocument.PAIN_002_LOGS -> ingest(payload) + } +} + +/** + * Fetch and ingest banking records of type [docs] using EBICS [client] starting from [pinnedStart] + * + * If [pinnedStart] is null fetch new records. + * + * Return true if successful + */ +private suspend fun fetchEbicsDocuments( + client: EbicsClient, + docs: List<EbicsDocument>, + pinnedStart: Instant?, +): Boolean { + val lastExecutionTime: Instant? = pinnedStart + return docs.all { doc -> + try { + if (lastExecutionTime == null) { + logger.info("Fetching new '${doc.fullDescription()}'") + } else { + logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime") + } + // downloading the content + val doc = doc.doc() + val order = client.cfg.dialect.downloadDoc(doc, false) + client.download( + order, + lastExecutionTime, + null + ) { payload -> + ingestPayload(client.db, client.cfg, payload, doc) + } + true + } catch (e: Exception) { + e.fmtLog(logger) + false + } + } +} + +enum class EbicsDocument { + /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 + acknowledgement, + /// Payment status - CustomerPaymentStatusReport pain.002 + status, + /// 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" + } + + 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" + } + + 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 + } +} + +class EbicsFetch: CliktCommand("Downloads and parse EBICS files from the bank and ingest them into the database") { + private val common by CommonOption() + private val transient by transientOption() + private val documents: Set<EbicsDocument> by argument( + help = "Which documents should be fetched? If none are specified, all supported documents will be fetched", + helpTags = EbicsDocument.entries.associate { Pair(it.name, it.shortDescription()) }, + ).enum<EbicsDocument>().multiple().unique() + private val pinnedStart by option( + help = "Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents", + metavar = "YYYY-MM-DD" + ) + private val ebicsLog by ebicsLogOption() + + override fun run() = cliCmd(logger, common.log) { + nexusConfig(common.config).withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val client = EbicsClient( + cfg, + httpClient(), + db, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + val docs = if (documents.isEmpty()) EbicsDocument.entries else documents.toList() + if (transient) { + logger.info("Transient mode: fetching once and returning.") + val pinnedStartVal = pinnedStart + val pinnedStartArg = if (pinnedStartVal != null) { + logger.debug("Pinning start date to: $pinnedStartVal") + dateToInstant(pinnedStartVal) + } else null + if (!fetchEbicsDocuments(client, docs, pinnedStartArg)) { + throw Exception("Failed to fetch documents") + } + } else { + logger.debug("Running with a frequency of ${cfg.fetch.frequencyRaw}") + if (cfg.fetch.frequency == Duration.ZERO) { + logger.warn("Long-polling not implemented, running therefore in transient mode") + } + do { + fetchEbicsDocuments(client, docs, null) + delay(cfg.fetch.frequency.toKotlinDuration()) + } while (cfg.fetch.frequency != Duration.ZERO) + } + } + } +} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt @@ -0,0 +1,242 @@ +/* + * This file is part of LibEuFin. + * 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 + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import io.ktor.client.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.nexus.* +import tech.libeufin.nexus.ebics.* +import tech.libeufin.nexus.ebics.EbicsKeyMng.Order.* +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.time.Instant +import kotlin.io.path.Path +import kotlin.io.path.writeBytes + +/** Load client private keys at [path] or create new ones if missing */ +private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile { + // If exists load from disk + val current = loadClientKeys(path) + if (current != null) return current + // Else create new keys + val newKeys = generateNewKeys() + persistClientKeys(newKeys, path) + logger.info("New client private keys created at '$path'") + return newKeys +} + +/** + * Asks the user to accept the bank public keys. + * + * @param bankKeys bank public keys, in format stored on disk. + * @return true if the user accepted, false otherwise. + */ +private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean { + val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex() + val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex() + println("The bank has the following keys:") + println("Encryption key: ${encHash.fmtChunkByTwo()}") + println("Authentication key: ${authHash.fmtChunkByTwo()}") + print("type 'yes, accept' to accept them: ") + val userResponse: String? = readlnOrNull() + return userResponse == "yes, accept" +} + +/** + * Perform an EBICS key management [order] using [client] and update on disk + * keys + */ +suspend fun doKeysRequestAndUpdateState( + cfg: NexusConfig, + privs: ClientPrivateKeysFile, + client: HttpClient, + ebicsLogger: EbicsLogger, + order: EbicsKeyMng.Order +) { + val resp = keyManagement(cfg, privs, client, ebicsLogger, order) + + when (order) { + INI, HIA -> { + if (resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_OR_USER_STATE) { + throw Exception("$order status code ${resp.technicalCode}: either your IDs are incorrect, or you already have keys registered with this bank") + } + } + HPB -> { + if (resp.technicalCode == EbicsReturnCode.EBICS_AUTHENTICATION_FAILED) { + throw Exception("$order status code ${resp.technicalCode}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank") + } + } + } + + val orderData = resp.okOrFail(order.name) + when (order) { + INI -> privs.submitted_ini = true + HIA -> privs.submitted_hia = true + HPB -> { + val orderData = requireNotNull(orderData) { + "HPB: missing order data" + } + val (authPub, encPub) = EbicsKeyMng.parseHpbOrder(orderData) + val bankKeys = BankPublicKeysFile( + bank_authentication_public_key = authPub, + bank_encryption_public_key = encPub, + accepted = false + ) + try { + persistBankKeys(bankKeys, cfg.bankPublicKeysPath) + } catch (e: Exception) { + throw Exception("Could not update the $order state on disk", e) + } + } + } + if (order != HPB) { + try { + persistClientKeys(privs, cfg.clientPrivateKeysPath) + } catch (e: Exception) { + throw Exception("Could not update the $order state on disk", e) + } + } + +} + +/** + * Mere collector of the PDF generation steps. Fails the + * process if a problem occurs. + * + * @param privs client private keys. + * @param cfg configuration handle. + */ +private fun makePdf(privs: ClientPrivateKeysFile, cfg: NexusConfig) { + val pdf = generateKeysPdf(privs, cfg) + val path = Path("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf") + try { + path.writeBytes(pdf, StandardOpenOption.CREATE_NEW) + } catch (e: Exception) { + if (e is FileAlreadyExistsException) throw Exception("PDF file exists already at '$path', not overriding it") + throw Exception("Could not write PDF to '$path'", e) + } + println("PDF file with keys created at '$path'") +} + +/** + * CLI class implementing the "ebics-setup" subcommand. + */ +class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { + private val common by CommonOption() + private val forceKeysResubmission by option( + help = "Resubmits all the keys to the bank" + ).flag(default = false) + private val autoAcceptKeys by option( + help = "Accepts the bank keys without interactively asking the user" + ).flag(default = false) + private val generateRegistrationPdf by option( + help = "Generates the PDF with the client public keys to send to the bank" + ).flag(default = false) + private val ebicsLog by ebicsLogOption() + /** + * This function collects the main steps of setting up an EBICS access. + */ + override fun run() = cliCmd(logger, common.log) { + val cfg = nexusConfig(common.config) + + val client = httpClient() + val ebicsLogger = EbicsLogger(ebicsLog) + + val clientKeys = loadOrGenerateClientKeys(cfg.clientPrivateKeysPath) + var bankKeys = loadBankKeys(cfg.bankPublicKeysPath) + + // Check EBICS 3 support + val versions = HEV(client, cfg, ebicsLogger) + logger.debug("HEV: {}", versions) + if (!versions.contains(VersionNumber(3.0f, "H005")) && !versions.contains(VersionNumber(3.02f, "H005"))) { + throw Exception("EBICS 3 is not supported by your bank") + } + + // Privs exist. Upload their pubs + val keysNotSub = !clientKeys.submitted_ini + if ((!clientKeys.submitted_ini) || forceKeysResubmission) + doKeysRequestAndUpdateState(cfg, clientKeys, client, ebicsLogger, INI) + // Eject PDF if the keys were submitted for the first time, or the user asked. + if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg) + if ((!clientKeys.submitted_hia) || forceKeysResubmission) + doKeysRequestAndUpdateState(cfg, clientKeys, client, ebicsLogger, HIA) + + // Checking if the bank keys exist on disk + if (bankKeys == null) { + doKeysRequestAndUpdateState(cfg, clientKeys, client, ebicsLogger, HPB) + logger.info("Bank keys stored at ${cfg.bankPublicKeysPath}") + bankKeys = loadBankKeys(cfg.bankPublicKeysPath)!! + } + + if (!bankKeys.accepted) { + // Finishing the setup by accepting the bank keys. + if (autoAcceptKeys) bankKeys.accepted = true + else bankKeys.accepted = askUserToAcceptKeys(bankKeys) + + if (!bankKeys.accepted) { + throw Exception("Cannot successfully finish the setup without accepting the bank keys") + } + try { + persistBankKeys(bankKeys, cfg.bankPublicKeysPath) + } catch (e: Exception) { + throw Exception("Could not set bank keys as accepted on disk", e) + } + } + + // Check account information + logger.info("Doing administrative request HKD") + try { + cfg.withDb { db, cfg -> + EbicsClient( + cfg, + client, + db, + ebicsLogger, + clientKeys, + bankKeys + ).download(EbicsOrder.V3("HKD"), null, null) { stream -> + val hkd = EbicsAdministrative.parseHKD(stream) + val account = hkd.account + // TODO parse and check more information + if (account.currency != null && account.currency != cfg.currency) + logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank") + if (account.iban != null && account.iban != cfg.account.iban) + logger.error("Expected IBAN '${cfg.account.iban}' from config got '${account.iban}' from bank") + if (account.name != null && account.name != cfg.account.name) + logger.warn("Expected NAME '${cfg.account.name}' from config got '${account.name}' from bank") + + for (order in hkd.orders) { + logger.debug("${order.type}${order.params}: ${order.description}") + } + } + } + } catch (e: Exception) { + logger.warn("HKD failed: ${e.fmt()}") + } + + println("setup ready") + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt @@ -0,0 +1,128 @@ +/* + * This file is part of LibEuFin. + * 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 + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import kotlinx.coroutines.delay +import tech.libeufin.common.CommonOption +import tech.libeufin.common.Payto +import tech.libeufin.common.cliCmd +import tech.libeufin.common.fmt +import tech.libeufin.nexus.* +import tech.libeufin.nexus.db.InitiatedPayment +import tech.libeufin.nexus.ebics.EbicsClient +import java.time.Duration +import java.time.Instant +import kotlin.time.toKotlinDuration + +/** + * Submit an initiated [payment] using [client]. + * + * Parse creditor IBAN account metadata then perform an EBICS direct credit. + * + * Returns the orderID + */ +private suspend fun submitInitiatedPayment( + client: EbicsClient, + payment: InitiatedPayment +): String { + val creditAccount = try { + val payto = Payto.parse(payment.creditPaytoUri).expectIban() + IbanAccountMetadata( + iban = payto.iban.value, + bic = payto.bic, + name = payto.receiverName!! + ) + } catch (e: Exception) { + throw e // TODO handle payto error + } + + val xml = createPain001( + requestUid = payment.requestUid, + initiationTimestamp = payment.initiationTime, + amount = payment.amount, + creditAccount = creditAccount, + debitAccount = client.cfg.account, + wireTransferSubject = payment.wireTransferSubject, + dialect = client.cfg.dialect + ) + return client.upload( + client.cfg.dialect.directDebit(), + xml + ) +} + +/** Submit all pending initiated payments using [client] */ +private suspend fun submitBatch(client: EbicsClient) { + client.db.initiated.submittable(client.cfg.currency).forEach { + logger.debug("Submitting payment '${it.requestUid}'") + runCatching { submitInitiatedPayment(client, it) }.fold( + onSuccess = { orderId -> + client.db.initiated.submissionSuccess(it.id, Instant.now(), orderId) + logger.info("Payment '${it.requestUid}' submitted") + }, + onFailure = { e -> + client.db.initiated.submissionFailure(it.id, Instant.now(), e.message) + logger.error("Payment '${it.requestUid}' submission failure: ${e.fmt()}") + throw e + } + ) + } +} + +class EbicsSubmit : CliktCommand("Submits pending initiated payments found in the database") { + private val common by CommonOption() + private val transient by transientOption() + private val ebicsLog by ebicsLogOption() + + override fun run() = cliCmd(logger, common.log) { + nexusConfig(common.config).withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val client = EbicsClient( + cfg, + httpClient(), + db, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + val frequency: Duration = if (transient) { + logger.info("Transient mode: submitting what found and returning.") + Duration.ZERO + } else { + logger.debug("Running with a frequency of ${cfg.submit.frequencyRaw}") + if (cfg.submit.frequency == Duration.ZERO) { + logger.warn("Long-polling not implemented, running therefore in transient mode") + } + cfg.submit.frequency + } + do { + try { + submitBatch(client) + } catch (e: Exception) { + throw Exception("Failed to submit payments", e) + } + // TODO take submitBatch taken time in the delay + delay(frequency.toKotlinDuration()) + } while (frequency != Duration.ZERO) + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt @@ -0,0 +1,79 @@ +/* + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +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.options.convert +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.* +import tech.libeufin.nexus.db.InitiatedPayment +import tech.libeufin.nexus.logger +import tech.libeufin.nexus.nexusConfig +import tech.libeufin.nexus.withDb +import java.time.Instant + +class InitiatePayment: CliktCommand("Initiate an outgoing payment") { + private val common by CommonOption() + private val amount by option( + "--amount", + help = "The amount to transfer, payto 'amount' parameter takes the precedence" + ).convert { TalerAmount(it) } + private val subject by option( + "--subject", + help = "The payment subject, payto 'message' parameter takes the precedence" + ) + private val requestUid by option( + "--request-uid", + help = "The payment request UID" + ) + private val payto by argument( + help = "The credited account IBAN payto URI" + ).convert { Payto.parse(it).expectIban() } + + override fun run() = cliCmd(logger, common.log) { + nexusConfig(common.config).withDb { db, cfg -> + val subject = requireNotNull(payto.message ?: subject) { "Missing subject" } + val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" } + + requireNotNull(payto.receiverName) { "Missing receiver name in creditor payto" } + require(amount.currency == cfg.currency) { + "Wrong currency: expected ${cfg.currency} got ${amount.currency}" + } + + val requestUid = requestUid ?: run { + val bytes = ByteArray(16).rand() + Base32Crockford.encode(bytes) + } + + db.initiated.create( + InitiatedPayment( + id = -1, + amount = amount, + wireTransferSubject = subject, + creditPaytoUri = payto.toString(), + initiationTime = Instant.now(), + requestUid = requestUid + ) + ) + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt @@ -0,0 +1,50 @@ +/* + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.versionOption +import com.github.ajalt.clikt.parameters.types.path +import tech.libeufin.common.CliConfigCmd +import tech.libeufin.common.getVersion +import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE + +fun CliktCommand.ebicsLogOption() = option( + "--debug-ebics", + help = "Log EBICS transactions steps and payload at log_dir", + metavar = "log_dir" +).path() + +fun CliktCommand.transientOption() = option( + "--transient", + help = "Execute once and return, ignoring the 'FREQUENCY' configuration value" +).flag(default = false) + + +class LibeufinNexus : CliktCommand() { + init { + versionOption(getVersion()) + subcommands(EbicsSetup(), DbInit(), Serve(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) + } + override fun run() = Unit +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt @@ -0,0 +1,72 @@ +/* + * 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.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.CommonOption +import tech.libeufin.common.api.serve +import tech.libeufin.common.cliCmd +import tech.libeufin.nexus.logger +import tech.libeufin.nexus.nexusApi +import tech.libeufin.nexus.nexusConfig +import tech.libeufin.nexus.withDb + + +class Serve : CliktCommand("Run libeufin-nexus HTTP server", name = "serve") { + private val common by CommonOption() + private val check by option( + help = "Check whether an API is in use (if it's useful to start the HTTP server). Exit with 0 if at least one API is enabled, otherwise 1" + ).flag() + + override fun run() = cliCmd(logger, common.log) { + val cfg = nexusConfig(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) + } + } + + cfg.withDb { db, cfg -> + serve(cfg.serverCfg) { + nexusApi(db, cfg) + } + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -0,0 +1,289 @@ +/* + * 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.cli + +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.options.convert +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.enum +import kotlinx.coroutines.delay +import tech.libeufin.common.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.ebics.EbicsClient +import tech.libeufin.nexus.ebics.EbicsOrder +import tech.libeufin.nexus.ebics.connect +import tech.libeufin.nexus.ebics.wssParams +import java.time.Instant + +class Wss: CliktCommand("Listen to EBICS instant notification over websocket") { + private val common by CommonOption() + private val ebicsLog by ebicsLogOption() + + override fun run() = cliCmd(logger, common.log) { + val backoff = ExpoBackoffDecorr() + nexusConfig(common.config).withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val httpClient = httpClient() + val client = EbicsClient( + cfg, + httpClient, + db, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + while (true) { + try { + logger.info("Fetch WSS params") + val params = client.wssParams() + logger.debug("{}", params) + logger.info("Start listening") + params.connect(httpClient) { + backoff.reset() + } + } catch (e: Exception) { + delay(backoff.next()) + } + } + } + } +} + +class FakeIncoming: CliktCommand("Genere a fake incoming payment") { + private val common by CommonOption() + private val amount by option( + "--amount", + help = "The amount to transfer, payto 'amount' parameter takes the precedence" + ).convert { TalerAmount(it) } + private val subject by option( + "--subject", + help = "The payment subject, payto 'message' parameter takes the precedence" + ) + private val payto by argument( + help = "The debited account IBAN payto URI" + ).convert { Payto.parse(it).expectIban() } + + override fun run() = cliCmd(logger, common.log) { + nexusConfig(common.config).withDb { db, cfg -> + val subject = requireNotNull(payto.message ?: subject) { "Missing subject" } + val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" } + + require(amount.currency == cfg.currency) { + "Wrong currency: expected ${cfg.currency} got ${amount.currency}" + } + + val bankId = run { + val bytes = ByteArray(16).rand() + Base32Crockford.encode(bytes) + } + + ingestIncomingPayment(db, + IncomingPayment( + amount = amount, + debitPaytoUri = payto.toString(), + wireTransferSubject = subject, + executionTime = Instant.now(), + bankId = bankId + ), + cfg.accountType + ) + } + } +} + +class TxCheck: CliktCommand("Check transaction semantic") { + private val common by CommonOption() + + override fun run() = cliCmd(logger, common.log) { + val cfg = nexusConfig(common.config) + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val doc = EbicsDocument.acknowledgement.doc() + val order = cfg.dialect.downloadDoc(doc, false) + val client = httpClient() + val result = tech.libeufin.nexus.test.txCheck(client, cfg, clientKeys, bankKeys, order, cfg.dialect.directDebit()) + println("$result") + } +} + +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 ebicsLogOption() + 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) { + nexusConfig(common.config).withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val pinnedStartVal = pinnedStart + val pinnedStartArg = if (pinnedStartVal != null) { + logger.debug("Pinning start date to: $pinnedStartVal") + dateToInstant(pinnedStartVal) + } else null + val client = EbicsClient( + cfg, + httpClient(), + db, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + try { + client.download( + EbicsOrder.V3(type, name, scope, messageName, messageVersion, container, option), + pinnedStartArg, + null + ) { stream -> + if (container == "ZIP") { + stream.unzipEach { fileName, xmlContent -> + println(fileName) + println(xmlContent.readBytes().toString(Charsets.UTF_8)) + } + } else { + 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.associate { Pair(it.name, it.description()) } + ).enum<ListKind>() + + override fun run() = cliCmd(logger, common.log) { + nexusConfig(common.config).withDb { db, cfg -> + 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.toString(), + 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 + ) + } + ) + } + } + printTable(columnNames, rows) + } + } +} + +class TestingCmd : CliktCommand("Testing helper commands", name = "testing") { + init { + subcommands(FakeIncoming(), ListCmd(), EbicsDownload(), TxCheck(), Wss()) + } + + override fun run() = Unit +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt @@ -19,9 +19,7 @@ package tech.libeufin.nexus.db -import tech.libeufin.common.db.* -import tech.libeufin.common.* -import java.time.Instant +import tech.libeufin.common.db.oneOrNull /** Data access logic for EBICS transaction */ class EbicsDAO(private val db: Database) { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt @@ -25,7 +25,6 @@ import tech.libeufin.common.db.getAmount import tech.libeufin.common.db.oneOrNull import tech.libeufin.common.db.oneUniqueViolation import tech.libeufin.common.micros -import java.sql.ResultSet import java.time.Instant /** Data access logic for initiated outgoing payments */ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -19,8 +19,8 @@ package tech.libeufin.nexus.db -import tech.libeufin.common.db.* import tech.libeufin.common.* +import tech.libeufin.common.db.* import tech.libeufin.nexus.IncomingPayment import tech.libeufin.nexus.OutgoingPayment import java.time.Instant diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -20,18 +20,21 @@ package tech.libeufin.nexus.ebics import io.ktor.client.* -import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.utils.io.jvm.javaio.* -import kotlinx.coroutines.* +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import org.w3c.dom.Document import org.xml.sax.SAXException -import tech.libeufin.common.* -import tech.libeufin.common.crypto.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.deflate +import tech.libeufin.common.encodeBase64 +import tech.libeufin.common.inflate +import tech.libeufin.common.rand import tech.libeufin.nexus.* -import tech.libeufin.nexus.db.* +import tech.libeufin.nexus.db.Database import java.io.InputStream import java.io.SequenceInputStream import java.security.SecureRandom diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt @@ -19,17 +19,17 @@ package tech.libeufin.nexus.ebics -import tech.libeufin.common.* -import kotlinx.serialization.Serializable +import io.ktor.client.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.http.* import io.ktor.serialization.kotlinx.* import io.ktor.websocket.* +import kotlinx.serialization.Serializable import kotlinx.serialization.json.* import org.slf4j.Logger import org.slf4j.LoggerFactory -import io.ktor.http.* -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.plugins.websocket.* +import tech.libeufin.common.encodeBase64 private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus-ws") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt @@ -22,7 +22,6 @@ package tech.libeufin.nexus import io.ktor.client.* import io.ktor.client.plugins.* import java.time.Instant -import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt @@ -20,9 +20,16 @@ package tech.libeufin.nexus.test import io.ktor.client.* -import tech.libeufin.common.* -import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* +import tech.libeufin.common.fmt +import tech.libeufin.common.rand +import tech.libeufin.nexus.BankPublicKeysFile +import tech.libeufin.nexus.ClientPrivateKeysFile +import tech.libeufin.nexus.NexusConfig +import tech.libeufin.nexus.ebics.EbicsBTS +import tech.libeufin.nexus.ebics.EbicsOrder +import tech.libeufin.nexus.ebics.postBTS +import tech.libeufin.nexus.ebics.prepareUploadPayload +import tech.libeufin.nexus.logger data class TxCheckResult( var concurrentFetchAndFetch: Boolean = false, diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -21,13 +21,14 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.testing.test import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.nexus.* +import tech.libeufin.nexus.cli.LibeufinNexus import java.io.ByteArrayOutputStream import java.io.PrintStream import kotlin.io.path.* import kotlin.test.Test import kotlin.test.assertEquals -val nexusCmd = LibeufinNexusCommand() +val nexusCmd = LibeufinNexus() fun CliktCommand.testErr(cmd: String, msg: String) { val prevOut = System.err diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -18,17 +18,16 @@ */ import org.junit.Test -import tech.libeufin.common.* -import tech.libeufin.common.db.* +import tech.libeufin.common.ShortHashCode +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.db.one +import tech.libeufin.nexus.AccountType +import tech.libeufin.nexus.cli.ingestIncomingPayment +import tech.libeufin.nexus.cli.ingestOutgoingPayment +import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult -import tech.libeufin.nexus.db.* -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 +import kotlin.test.* class OutgoingPaymentsTest { @Test diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -26,10 +26,6 @@ import tech.libeufin.nexus.TxNotification.Reversal import tech.libeufin.nexus.ebics.Dialect import tech.libeufin.nexus.parseTx import java.nio.file.Files -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter import kotlin.io.path.Path import kotlin.test.assertEquals diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt @@ -19,7 +19,7 @@ import org.junit.Test import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.* +import tech.libeufin.common.fmtChunkByTwo import tech.libeufin.nexus.* import kotlin.io.path.Path import kotlin.io.path.deleteIfExists diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -21,7 +21,7 @@ import io.ktor.client.request.* import io.ktor.http.* import org.junit.Test import tech.libeufin.common.* -import tech.libeufin.nexus.ingestOutgoingPayment +import tech.libeufin.nexus.cli.ingestOutgoingPayment class WireGatewayApiTest { // GET /taler-wire-gateway/config diff --git a/nexus/src/test/kotlin/WsTest.kt b/nexus/src/test/kotlin/WsTest.kt @@ -17,27 +17,21 @@ * <http://www.gnu.org/licenses/> */ -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.testing.test -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* +import io.ktor.serialization.kotlinx.* import io.ktor.server.application.* -import io.ktor.server.testing.* import io.ktor.server.routing.* -import io.ktor.serialization.kotlinx.* +import io.ktor.server.testing.* import io.ktor.server.websocket.* import io.ktor.websocket.* -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* -import kotlinx.serialization.json.* -import kotlinx.serialization.* import kotlinx.coroutines.channels.ClosedReceiveChannelException -import java.io.ByteArrayOutputStream -import java.io.PrintStream -import kotlin.io.path.* -import kotlin.test.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import tech.libeufin.nexus.ebics.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs class WsTest { // WSS params example from the spec @@ -144,7 +138,7 @@ class WsTest { } } - /** Test our implemetation works with spec examples */ + /** Test our implementation works with spec examples */ @Test fun wss() { println(kotlinx.serialization.serializer<WssNotification>()) diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -17,17 +17,19 @@ * <http://www.gnu.org/licenses/> */ -import io.ktor.http.* import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.client.request.* import io.ktor.client.statement.* +import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.coroutines.runBlocking import tech.libeufin.common.* import tech.libeufin.common.db.dbInit import tech.libeufin.common.db.pgDataSource import tech.libeufin.nexus.* +import tech.libeufin.nexus.cli.ingestIncomingPayment +import tech.libeufin.nexus.cli.ingestOutgoingPayment import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedPayment import java.time.Instant diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -23,17 +23,17 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.ProgramResult import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.testing.test -import io.ktor.http.URLBuilder -import io.ktor.http.takeFrom import io.ktor.client.* import io.ktor.client.engine.cio.* +import io.ktor.http.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable -import tech.libeufin.nexus.* import tech.libeufin.common.ANSI +import tech.libeufin.nexus.* +import tech.libeufin.nexus.cli.* import kotlin.io.path.* -val nexusCmd = LibeufinNexusCommand() +val nexusCmd = Cli() val client = HttpClient(CIO) fun step(name: String) { diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -27,11 +27,20 @@ import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.coroutines.runBlocking import org.junit.Test -import tech.libeufin.bank.* +import tech.libeufin.bank.BankAccountTransactionsResponse +import tech.libeufin.bank.CashoutResponse +import tech.libeufin.bank.ConversionResponse +import tech.libeufin.bank.RegisterAccountResponse +import tech.libeufin.bank.cli.LibeufinBank import tech.libeufin.common.* import tech.libeufin.common.api.engine import tech.libeufin.common.db.one -import tech.libeufin.nexus.* +import tech.libeufin.nexus.AccountType +import tech.libeufin.nexus.IncomingPayment +import tech.libeufin.nexus.cli.LibeufinNexus +import tech.libeufin.nexus.cli.ingestIncomingPayment +import tech.libeufin.nexus.nexusConfig +import tech.libeufin.nexus.withDb import java.time.Instant import kotlin.io.path.Path import kotlin.io.path.readText @@ -88,8 +97,8 @@ inline fun assertException(msg: String, lambda: () -> Unit) { } class IntegrationTest { - val nexusCmd = LibeufinNexusCommand() - val bankCmd = LibeufinBankCommand() + val nexusCmd = LibeufinNexus() + val bankCmd = LibeufinBank() val client = HttpClient(CIO) @Test diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt @@ -18,11 +18,11 @@ */ import org.junit.Test +import tech.libeufin.nexus.ebics.Dialect +import tech.libeufin.nexus.nexusConfig import tech.libeufin.nexus.parseCustomerAck import tech.libeufin.nexus.parseCustomerPaymentStatusReport import tech.libeufin.nexus.parseTx -import tech.libeufin.nexus.nexusConfig -import tech.libeufin.nexus.ebics.Dialect import java.nio.file.Files import kotlin.io.path.Path import kotlin.io.path.exists