libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit eb2f11f533bdde5b0c97335b025fd69847412bb5
parent 1426da597576efe54bc677df30246c966a38990a
Author: MS <ms@taler.net>
Date:   Fri, 27 Oct 2023 16:01:57 +0200

nexus submit

Fetching the unsubmitted payment initiations from the
database and submit them, in the loop or only once, according
to the configuration.

Diffstat:
Mcontrib/libeufin-nexus.conf | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt | 7+++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 14+++++++-------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mnexus/src/test/kotlin/ConfigLoading.kt | 26++++++++++++++++++++++++++
5 files changed, 170 insertions(+), 21 deletions(-)

diff --git a/contrib/libeufin-nexus.conf b/contrib/libeufin-nexus.conf @@ -21,7 +21,7 @@ PARTNER_ID = myorg SYSTEM_ID = banksys # Name given by the bank to the bank account driven by Nexus. -ACCOUNT_NUMBER = DE1234567890 +ACCOUNT_NUMBER = payto://iban/BIC/DE1234567890?receiver-name=Nexus-User # File that holds the bank EBICS keys. BANK_PUBLIC_KEYS_FILE = enc-auth-keys.json diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt @@ -7,6 +7,13 @@ import tech.libeufin.util.initializeDatabaseTables import tech.libeufin.util.resetDatabaseTables import kotlin.system.exitProcess +/** + * Runs the argument and fails the process, if that throws + * an exception. + * + * @param getLambda function that might return a value. + * @return the value from getLambda. + */ fun <T>doOrFail(getLambda: () -> T): T = try { getLambda() diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -264,7 +264,7 @@ suspend fun doKeysRequestAndUpdateState( * @param configFile location of the configuration entry point. * @return internal representation of the configuration. */ -private fun extractEbicsConfig(configFile: String?): EbicsSetupConfig { +fun extractEbicsConfig(configFile: String?): EbicsSetupConfig { val config = loadConfigOrFail(configFile) // Checking the config. val cfg = try { @@ -327,16 +327,16 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { * This function collects the main steps of setting up an EBICS access. */ override fun run() { - val cfg = extractEbicsConfig(this.configFile) + val cfg = doOrFail { extractEbicsConfig(this.configFile) } if (checkFullConfig) { doOrFail { - cfg.config.requireNumber("nexus-ebics-submit", "frequency").apply { - if (this < 0) throw Exception("section 'nexus-ebics-submit' has negative frequency") + cfg.config.requireString("nexus-ebics-submit", "frequency").apply { + checkFrequency(this) } - cfg.config.requireNumber("nexus-ebics-fetch", "frequency").apply { - if (this < 0) throw Exception("section 'nexus-ebics-fetch' has negative frequency") + cfg.config.requireString("nexus-ebics-fetch", "frequency").apply { + checkFrequency(this) } - cfg.config.requirePath("nexus-ebics-fetch", "statement-log-directory") + cfg.config.requirePath("nexus-ebics-fetch", "statement_log_directory") cfg.config.requireNumber("nexus-httpd", "port") cfg.config.requirePath("nexus-httpd", "unixpath") cfg.config.requireString("nexus-httpd", "serve") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -22,9 +22,13 @@ package tech.libeufin.nexus import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.option import io.ktor.client.* +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.time.delay import tech.libeufin.nexus.ebics.submitPayment -import tech.libeufin.util.IbanPayto +import tech.libeufin.util.getDatabaseName import tech.libeufin.util.parsePayto +import java.util.* +import kotlin.concurrent.fixedRateTimer import kotlin.system.exitProcess /** @@ -46,14 +50,14 @@ private suspend fun submitInitiatedPayment( cfg: EbicsSetupConfig, clientPrivateKeysFile: ClientPrivateKeysFile, bankPublicKeysFile: BankPublicKeysFile, - initiatedPayment: InitiatedPayment, - debtor: IbanPayto + initiatedPayment: InitiatedPayment ): Boolean { val creditor = parsePayto(initiatedPayment.creditPaytoUri) if (creditor?.receiverName == null) { logger.error("Won't create pain.001 without the receiver name") return false } + val debtor = cfg.accountNumber if (debtor.bic == null || debtor.receiverName == null) { logger.error("Won't create pain.001 without the debtor BIC and name") return false @@ -74,6 +78,98 @@ private suspend fun submitInitiatedPayment( return true } +/** + * 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 lastChar = trimmed.last() + val howManySeconds: Int = when (lastChar) { + '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.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) + if (frequencySeconds == null) { + throw Exception("Invalid frequency value") + } + if (frequencySeconds < 0) { + throw Exception("Configuration error: cannot operate with a negative submit frequency ($foundInConfig)") + } + return frequencySeconds +} + +private fun submitBatch( + cfg: EbicsSetupConfig, + db: Database, + httpClient: HttpClient, + clientKeys: ClientPrivateKeysFile, + bankKeys: BankPublicKeysFile +) { + runBlocking { + db.initiatedPaymentsUnsubmittedGet(cfg.currency).forEach { + val submitted = submitInitiatedPayment( + httpClient, + cfg, + clientKeys, + bankKeys, + it.value + ) + /** + * The following block tries to flag the initiated payment as submitted, + * but it does NOT fail the process if the flagging fails. This way, we + * do NOT block other payments to be submitted. + */ + if (submitted) { + val flagged = db.initiatedPaymentSetSubmitted(it.key) + if (!flagged) { + logger.warn("Initiated payment with row ID ${it.key} could not be flagged as submitted") + } + } + } + } +} + class EbicsSubmit : CliktCommand("Submits any initiated payment found in the database") { private val configFile by option( "--config", "-c", @@ -83,23 +179,43 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat /** * Submits any initiated payment that was not submitted * so far and -- according to the configuration -- returns - * or long-polls for new payments. + * or long-polls (currently not implemented) for new payments. */ override fun run() { - val cfg = loadConfigOrFail(configFile) + val cfg: EbicsSetupConfig = doOrFail { extractEbicsConfig(configFile) } val frequency: Int = doOrFail { - cfg.requireNumber("nexus-ebics-submit", "frequency") + val configValue = cfg.config.requireString("nexus-ebics-submit", "frequency") + return@doOrFail checkFrequency(configValue) + } - if (frequency < 0) { - logger.error("Configuration error: cannot operate with a negative submit frequency ($frequency)") + val dbCfg = cfg.config.extractDbConfigOrFail() + val db = Database(dbCfg.dbConnStr) + val httpClient = HttpClient() + val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) + if (bankKeys == null) { + logger.error("Could not find the bank keys at: ${cfg.bankPublicKeysFilename}") exitProcess(1) } - if (frequency == 0) { - logger.error("Long-polling not implemented, set frequency > 0") + if (!bankKeys.accepted) { + logger.error("Bank keys are not accepted, yet. Won't submit any payment.") exitProcess(1) } - val dbCfg = cfg.extractDbConfigOrFail() - val db = Database(dbCfg.dbConnStr) - throw NotImplementedError("to be done") + val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) + if (clientKeys == null) { + logger.error("Client private keys not found at: ${cfg.clientPrivateKeysFilename}") + exitProcess(1) + } + if (frequency == 0) { + logger.warn("Long-polling not implemented, submitting what is found and exit") + submitBatch(cfg, db, httpClient, clientKeys, bankKeys) + return + } + fixedRateTimer( + name = "ebics submit period", + period = (frequency * 1000).toLong(), + action = { + submitBatch(cfg, db, httpClient, clientKeys, bankKeys) + } + ) } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/ConfigLoading.kt b/nexus/src/test/kotlin/ConfigLoading.kt @@ -2,6 +2,9 @@ import org.junit.Test import org.junit.jupiter.api.assertThrows import tech.libeufin.nexus.EbicsSetupConfig import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE +import tech.libeufin.nexus.getFrequencyInSeconds +import kotlin.test.assertEquals +import kotlin.test.assertNull class ConfigLoading { /** @@ -16,6 +19,15 @@ class ConfigLoading { cfg._dump() } + @Test + fun loadPath() { + val handle = TalerConfig(NEXUS_CONFIG_SOURCE) + handle.load() + val cfg = EbicsSetupConfig(handle) + cfg.config.requirePath("nexus-ebics-fetch", "statement_log_directory") + } + + /** * Tests that if the configuration lacks at least one option, then * the config loader throws exception. @@ -32,4 +44,18 @@ class ConfigLoading { EbicsSetupConfig(handle) } } + + // Checks converting human-readable durations to seconds. + @Test + fun timeParsing() { + assertEquals(1, getFrequencyInSeconds("1s")) + assertEquals(10*60, getFrequencyInSeconds("10m")) + assertEquals(24*60*60, getFrequencyInSeconds("24h")) + assertEquals(60*60, getFrequencyInSeconds(" 1h ")) + assertEquals(60*60, getFrequencyInSeconds("01h")) + assertNull(getFrequencyInSeconds("1.1s")) + assertNull(getFrequencyInSeconds(" ")) + assertNull(getFrequencyInSeconds("m")) + assertNull(getFrequencyInSeconds("")) + } } \ No newline at end of file