/*
* This file is part of LibEuFin.
* Copyright (C) 2023 Stanisci and Dold.
* 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
*
*/
/**
* This file collects all the CLI subcommands and runs
* them. The actual implementation of each subcommand is
* kept in their respective files.
*/
package tech.libeufin.nexus
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.groups.*
import com.github.ajalt.clikt.parameters.options.*
import io.ktor.client.*
import io.ktor.util.*
import kotlinx.coroutines.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import tech.libeufin.common.*
import tech.libeufin.nexus.ebics.*
import tech.libeufin.nexus.db.*
import java.nio.file.Path
import java.util.*
import java.time.*
import java.time.format.*
val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus")
internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus")
/**
* Triple identifying one IBAN bank account.
*/
data class IbanAccountMetadata(
val iban: String,
val bic: String?,
val name: String
)
/**
* Contains the frequency of submit or fetch iterations.
*/
data class NexusFrequency(
/**
* Value in seconds of the FREQUENCY configuration
* value, found either under [nexus-fetch] or [nexus-submit]
*/
val inSeconds: Int,
/**
* Copy of the value found in the configuration. Used
* for logging.
*/
val fromConfig: String
)
/**
* Converts human-readable duration in how many seconds. Supports
* the suffixes 's' (seconds), 'm' (minute), 'h' (hours). A valid
* duration is therefore, for example, Nm, where N is the number of
* minutes.
*
* @param trimmed duration
* @return how many seconds is the duration input, or null if the input
* is not valid.
*/
fun getFrequencyInSeconds(humanFormat: String): Int? {
val trimmed = humanFormat.trim()
if (trimmed.isEmpty()) {
logger.error("Input was empty")
return null
}
val howManySeconds: Int = when (val lastChar = trimmed.last()) {
's' -> {1}
'm' -> {60}
'h' -> {60 * 60}
else -> {
logger.error("Duration symbol not one of s, m, h. '$lastChar' was found instead")
return null
}
}
val maybeNumber = trimmed.dropLast(1)
val howMany = try {
maybeNumber.trimEnd().toInt()
} catch (e: Exception) {
logger.error("Prefix was not a valid input: '$maybeNumber'")
return null
}
if (howMany == 0) return 0
val ret = howMany * howManySeconds
if (howMany != ret / howManySeconds) {
logger.error("Result overflew")
return null
}
return ret
}
/**
* Sanity-checks the frequency found in the configuration and
* either returns it or fails the process. Note: the returned
* value is also guaranteed to be non-negative.
*
* @param foundInConfig frequency value as found in the configuration.
* @return the duration in seconds of the value found in the configuration.
*/
fun checkFrequency(foundInConfig: String): Int {
val frequencySeconds = getFrequencyInSeconds(foundInConfig)
?: throw Exception("Invalid frequency value in config section nexus-submit: $foundInConfig")
if (frequencySeconds < 0) {
throw Exception("Configuration error: cannot operate with a negative submit frequency ($foundInConfig)")
}
return frequencySeconds
}
fun Instant.fmtDate(): String =
DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.of("UTC")).format(this)
fun Instant.fmtDateTime(): String =
DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.of("UTC")).format(this)
/**
* Keeps all the options of the ebics-setup subcommand. The
* caller has to handle TalerConfigError if values are missing.
* If even one of the fields could not be instantiated, then
* throws TalerConfigError.
*/
class EbicsSetupConfig(val config: TalerConfig) {
// abstracts the section name.
private val ebicsSetupRequireString = { option: String ->
config.requireString("nexus-ebics", option)
}
private val ebicsSetupRequirePath = { option: String ->
config.requirePath("nexus-ebics", option)
}
// debug utility to inspect what was loaded.
fun _dump() {
this.javaClass.declaredFields.forEach {
println("cfg obj: ${it.name} -> ${it.get(this)}")
}
}
/**
* The bank's currency.
*/
val currency = ebicsSetupRequireString("currency")
/**
* The bank base URL.
*/
val hostBaseUrl = ebicsSetupRequireString("host_base_url")
/**
* The bank EBICS host ID.
*/
val ebicsHostId = ebicsSetupRequireString("host_id")
/**
* EBICS user ID.
*/
val ebicsUserId = ebicsSetupRequireString("user_id")
/**
* EBICS partner ID.
*/
val ebicsPartnerId = ebicsSetupRequireString("partner_id")
/**
* Bank account metadata.
*/
val myIbanAccount = IbanAccountMetadata(
iban = ebicsSetupRequireString("iban"),
bic = ebicsSetupRequireString("bic"),
name = ebicsSetupRequireString("name")
)
/**
* Filename where we store the bank public keys.
*/
val bankPublicKeysFilename = ebicsSetupRequirePath("bank_public_keys_file")
/**
* Filename where we store our private keys.
*/
val clientPrivateKeysFilename = ebicsSetupRequirePath("client_private_keys_file")
/**
* A name that identifies the EBICS and ISO20022 flavour
* that Nexus should honor in the communication with the
* bank.
*/
val bankDialect: String = ebicsSetupRequireString("bank_dialect").run {
if (this != "postfinance") throw Exception("Only 'postfinance' dialect is supported.")
return@run this
}
}
/**
* Abstracts the config loading
*
* @param configFile potentially NULL configuration file location.
* @return the configuration handle.
*/
fun loadConfig(configFile: Path?): TalerConfig = NEXUS_CONFIG_SOURCE.fromFile(configFile)
/**
* Abstracts fetching the DB config values to set up Nexus.
*/
fun TalerConfig.dbConfig(): DatabaseConfig =
DatabaseConfig(
dbConnStr = lookupString("libeufin-nexusdb-postgres", "config") ?: requireString("nexus-postgres", "config"),
sqlDir = requirePath("libeufin-nexusdb-postgres", "sql_dir")
)
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) {
val cfg = loadConfig(common.config)
val dbCfg = cfg.dbConfig()
val currency = cfg.requireString("nexus-ebics", "currency")
val subject = payto.message ?: subject ?: throw Exception("Missing subject")
val amount = payto.amount ?: amount ?: throw Exception("Missing amount")
if (payto.receiverName == null)
throw Exception("Missing receiver name in creditor payto")
if (amount.currency != currency)
throw Exception("Wrong currency: expected $currency got ${amount.currency}")
val requestUid = requestUid ?: run {
val bytes = ByteArray(16)
kotlin.random.Random.nextBytes(bytes)
Base32Crockford.encode(bytes)
}
Database(dbCfg.dbConnStr).use { db ->
db.initiated.create(
InitiatedPayment(
id = -1,
amount = amount,
wireTransferSubject = subject,
creditPaytoUri = payto.toString(),
initiationTime = Instant.now(),
requestUid = requestUid
)
)
}
}
}
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) {
val cfg = loadConfig(common.config)
val dbCfg = cfg.dbConfig()
val currency = cfg.requireString("nexus-ebics", "currency")
val subject = payto.message ?: subject ?: throw Exception("Missing subject")
val amount = payto.amount ?: amount ?: throw Exception("Missing amount")
if (amount.currency != currency)
throw Exception("Wrong currency: expected $currency got ${amount.currency}")
val bankId = run {
val bytes = ByteArray(16)
kotlin.random.Random.nextBytes(bytes)
Base32Crockford.encode(bytes)
}
Database(dbCfg.dbConnStr).use { db ->
ingestIncomingPayment(db,
IncomingPayment(
amount = amount,
debitPaytoUri = payto.toString(),
wireTransferSubject = subject,
executionTime = Instant.now(),
bankId = bankId
)
)
}
}
}
class TestingCmd : CliktCommand("Testing helper commands", name = "testing") {
init {
subcommands(FakeIncoming())
}
override fun run() = Unit
}
/**
* Main CLI class that collects all the subcommands.
*/
class LibeufinNexusCommand : CliktCommand() {
init {
versionOption(getVersion())
subcommands(EbicsSetup(), DbInit(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd())
}
override fun run() = Unit
}
fun main(args: Array) {
LibeufinNexusCommand().main(args)
}