commit ff9452aea81692023d6429a688d1e9af992f312a parent 59e7c11203557fa5da4a5818d259ae6a88a82019 Author: Antoine A <> Date: Wed, 18 Sep 2024 16:15:54 +0200 Merge remote-tracking branch 'origin/master' into dev/antoine/nexus-dev Diffstat:
24 files changed, 301 insertions(+), 125 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -124,6 +124,16 @@ sealed class Option<out T> { return Some(valueSerializer.deserialize(decoder)) } } + + companion object { + fun <T> invert(optional: Option<T>?): Option<T?> { + return when (optional) { + null -> Option.None + is Option.None -> Option.Some(null) + is Option.Some -> Option.Some(optional.value) + } + } + } } @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -273,6 +273,12 @@ suspend fun patchAccount( "'admin' account cannot be public", TalerErrorCode.END ) + + if (username == "exchange" && req.is_taler_exchange == false) + throw conflict( + "'exchange' account must be a taler exchange account", + TalerErrorCode.END + ) if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) { throw unsupportedTanChannel(req.tan_channel.value) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt @@ -67,7 +67,9 @@ class CreateAccountOption: OptionGroup() { val min_cashout: TalerAmount? by option( help = "Custom minimum cashout amount for this account" ).convert { TalerAmount(it) } - + val tan_channel: TanChannel? by option( + help = "Enables 2FA and set the TAN channel used for challenges" + ).convert { TanChannel.valueOf(it) } } class CreateAccount : CliktCommand("create-account") { @@ -78,7 +80,6 @@ class CreateAccount : CliktCommand("create-account") { 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( @@ -94,7 +95,8 @@ class CreateAccount : CliktCommand("create-account") { cashout_payto_uri = cashout_payto_uri, payto_uri = payto_uri, debit_threshold = debit_threshold, - min_cashout = min_cashout + min_cashout = min_cashout, + tan_channel = tan_channel ) } req?.let { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt @@ -43,25 +43,39 @@ class EditAccount : CliktCommand("edit-account") { 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 exchange: Boolean? by option( + help = "Make this account a taler exchange" + ).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 { + 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)) } } + private val tan_channel: Option<TanChannel>? by option( + help = "Enables 2FA and set the TAN channel used for challenges" + ).convert { + if (it == "") { + Option.None + } else { + Option.Some(TanChannel.valueOf(it)) + } + } override fun run() = cliCmd(logger, common.log) { bankConfig(common.config).withDb { db, cfg -> @@ -76,11 +90,8 @@ class EditAccount : CliktCommand("edit-account") { ), 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) - } + min_cashout = Option.invert(min_cashout), + tan_channel = Option.invert(tan_channel) ) when (patchAccount(db, cfg, req, username, true, true)) { AccountPatchResult.Success -> diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt @@ -29,7 +29,7 @@ import tech.libeufin.common.getVersion class LibeufinBank : CliktCommand() { init { versionOption(getVersion()) - subcommands(Serve(), DbInit(), CreateAccount(), EditAccount(), ChangePw(), BenchPwh(), GC(), CliConfigCmd(BANK_CONFIG_SOURCE)) + subcommands(DbInit(), ChangePw(), Serve(), CreateAccount(), EditAccount(), GC(), BenchPwh(), CliConfigCmd(BANK_CONFIG_SOURCE)) } override fun run() = Unit diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -61,27 +61,36 @@ class AccountDAO(private val db: Database) { AND email IS NOT DISTINCT FROM ? AND phone IS NOT DISTINCT FROM ? AND cashout_payto IS NOT DISTINCT FROM ? + AND tan_channel IS NOT DISTINCT FROM ?::tan_enum AND (NOT ? OR internal_payto=?) AND is_public=? AND is_taler_exchange=? - AND tan_channel IS NOT DISTINCT FROM ?::tan_enum + AND max_debt=(?,?)::taler_amount + AND ${if (minCashout == null) "min_cashout IS NULL" else "min_cashout IS NOT DISTINCT FROM (?,?)::taler_amount"} ,internal_payto, name FROM customers JOIN bank_accounts ON customer_id=owning_customer_id WHERE username=? """) { - // TODO check max debt and min checkout ? setString(1, name) setString(2, email) setString(3, phone) setString(4, cashoutPayto?.full(name)) - setBoolean(5, checkPaytoIdempotent) - setString(6, internalPayto.canonical) - setBoolean(7, isPublic) - setBoolean(8, isTalerExchange) - setString(9, tanChannel?.name) - setString(10, username) + setString(5, tanChannel?.name) + setBoolean(6, checkPaytoIdempotent) + setString(7, internalPayto.canonical) + setBoolean(8, isPublic) + setBoolean(9, isTalerExchange) + setLong(10, maxDebt.value) + setInt(11, maxDebt.frac) + if (minCashout != null) { + setLong(12, minCashout.value) + setInt(13, minCashout.frac) + setString(14, username) + } else { + setString(12, username) + } oneOrNull { Pair( pwCrypto.checkpw(password, it.getString(1)).match && it.getBoolean(2), diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -769,6 +769,13 @@ class CoreBankAccountsApiTest { } }.assertConflict(TalerErrorCode.END) + // Exchange must be exchange + client.patchA("/accounts/exchange") { + json { + "is_taler_exchange" to false + } + }.assertConflict(TalerErrorCode.END) + // Check cashout payto receiver name logic client.post("/accounts") { json { diff --git a/build.gradle b/build.gradle @@ -9,7 +9,7 @@ plugins { } group = "tech.libeufin" -version = "0.12.0" +version = "0.14.1" if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)){ throw new GradleException( @@ -69,4 +69,4 @@ task libeufinVersion { classes { dependsOn versionFile -} -\ No newline at end of file +} diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt @@ -78,7 +78,7 @@ class CommonOption: OptionGroup() { class CliConfigCmd(configSource: ConfigSource) : CliktCommand("config") { init { - subcommands(CliConfigDump(configSource), CliConfigPathsub(configSource), CliConfigGet(configSource)) + subcommands(CliConfigGet(configSource), CliConfigDump(configSource), CliConfigPathsub(configSource)) } override fun help(context: Context) = "Inspect or change the configuration" diff --git a/contrib/bank-spa.lock b/contrib/bank-spa.lock @@ -1 +1 @@ -0.12.12-dev.1 +0.12.14-dev.2 diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -55,9 +55,13 @@ CONFIG = postgres:///libeufin [nexus-fetch] FREQUENCY = 30m + # Ignore all transactions prior to a certain date, useful when you want to use an existing account with old transactions that should not be bounced. # IGNORE_TRANSACTIONS_BEFORE = YYYY-MM-DD +# Ignore all malformed transactions prior to a certain date, useful when you want to import old transactions without bouncing the malformed ones a second time +# IGNORE_BOUNCES_BEFORE = YYYY-MM-DD + [nexus-submit] FREQUENCY = 30m diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql @@ -8,7 +8,6 @@ LANGUAGE plpgsql AS $$ now_date INT8; payto_uri TEXT; BEGIN - -- TODO should send to an exchange IF NEW.local_transaction IS NOT NULL THEN SELECT transaction_date INTO now_date FROM libeufin_bank.bank_account_transactions diff --git a/debian/changelog b/debian/changelog @@ -1,3 +1,15 @@ +libeufin (0.14.1) unstable; urgency=low + + * Release version 0.14.1 + + -- Christian Grothoff <grothoff@gnu.org> Tue, 17 Sep 2024 16:45:24 +0200 + +libeufin (0.13.0) unstable; urgency=low + + * Release version 0.13.0 + + -- Florian Dold <dold@taler.net> Wed, 28 Aug 2024 22:45:24 +0200 + libeufin (0.12.0) unstable; urgency=low * Release version 0.12.0 diff --git a/nexus/conf/skip.conf b/nexus/conf/skip.conf @@ -0,0 +1,19 @@ +[nexus-ebics] +CURRENCY = CHF +BANK_DIALECT = postfinance +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json +IBAN = CH7789144474425692816 +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 +BIC = BIC +NAME = myname + +[libeufin-nexusdb-postgres] +CONFIG = postgres:///libeufincheck + +[nexus-fetch] +IGNORE_TRANSACTIONS_BEFORE = 2024-04-04 +IGNORE_BOUNCES_BEFORE = 2024-06-12 +\ No newline at end of file diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf @@ -14,9 +14,6 @@ NAME = myname [libeufin-nexusdb-postgres] CONFIG = postgres:///libeufincheck -[nexus-fetch] -IGNORE_TRANSACTIONS_BEFORE = 2024-04-04 - [nexus-httpd-wire-gateway-api] ENABLED = YES AUTH_METHOD = bearer-token diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -24,14 +24,27 @@ import tech.libeufin.common.db.DatabaseConfig import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.ebics.Dialect import java.nio.file.Path +import java.time.Instant val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus") +data class NexusIngestConfig( + val accountType: AccountType, + val ignoreTransactionsBefore: Instant, + val ignoreBouncesBefore: Instant +) { + companion object { + fun default(accountType: AccountType) + = NexusIngestConfig(accountType, Instant.MIN, Instant.MIN) + } +} + class NexusFetchConfig(config: TalerConfig) { private val section = config.section("nexus-fetch") val frequency = section.duration("frequency").require() val frequencyRaw = section.string("frequency").require() - val ignoreBefore = section.date("ignore_transactions_before").orNull() + val ignoreTransactionsBefore = section.date("ignore_transactions_before").default(Instant.MIN) + val ignoreBouncesBefore = section.date("ignore_bounces_before").default(Instant.MIN) } class NexusSubmitConfig(config: TalerConfig) { @@ -75,6 +88,11 @@ class NexusEbicsConfig( val fetch = NexusFetchConfig(config) val submit = NexusSubmitConfig(config) + val ingest get() = NexusIngestConfig( + accountType, + fetch.ignoreTransactionsBefore, + fetch.ignoreBouncesBefore + ) } class ApiConfig(section: TalerConfigSection) { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -72,30 +72,39 @@ suspend fun registerOutgoingBatch( /** * Register an incoming [payment] into [db] - * Stores the payment into valid talerable ones or bounces it, according to [accountType] . + * Stores the payment into valid talerable ones or bounces it */ suspend fun registerIncomingPayment( db: Database, + cfg: NexusIngestConfig, payment: IncomingPayment, - accountType: AccountType ) { suspend fun bounce(msg: String) { if (payment.bankId == null) { logger.debug("{} ignored: missing bank ID", payment) return } - when (accountType) { + when (cfg.accountType) { AccountType.exchange -> { - val result = db.payment.registerMalformedIncoming( - payment, - payment.amount, - randEbicsId(), - Instant.now() - ) - if (result.new) { - logger.info("$payment bounced in '${result.bounceId}': $msg") + if (payment.executionTime < cfg.ignoreBouncesBefore) { + val res = db.payment.registerIncoming(payment) + if (res.new) { + logger.info("$payment ignored bounce: $msg") + } else { + logger.debug("{} already seen and ignored bounce: {}", payment, msg) + } } else { - logger.debug("{} already seen and bounced in '{}': {}", payment, result.bounceId, msg) + val result = db.payment.registerMalformedIncoming( + payment, + payment.amount, + randEbicsId(), + 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 -> { @@ -125,6 +134,27 @@ suspend fun registerIncomingPayment( ) } +/** Register a [tx] notification into [db] */ +suspend fun registerTransaction( + db: Database, + cfg: NexusIngestConfig, + tx: TxNotification, +) { + if (tx.executionTime < cfg.ignoreTransactionsBefore) { + logger.debug("IGNORE {}", tx) + } else { + when (tx) { + is IncomingPayment -> registerIncomingPayment(db, cfg, tx) + is OutgoingPayment -> registerOutgoingPayment(db, tx) + is OutgoingBatch -> registerOutgoingBatch(db, tx) + is OutgoingReversal -> { + logger.error("{}", tx) + db.initiated.txStatusUpdate(tx.endToEndId, tx.msgId, SubmissionState.permanent_failure, "Payment bounced: ${tx.reason}") + } + } + } +} + /** Register a single EBICS [xml] [document] into [db] */ suspend fun registerFile( db: Database, @@ -135,23 +165,11 @@ suspend fun registerFile( when (doc) { OrderDoc.report, OrderDoc.statement, OrderDoc.notification -> { 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 -> registerIncomingPayment(db, it, cfg.accountType) - is OutgoingBatch -> registerOutgoingBatch(db, it) - is OutgoingPayment -> registerOutgoingPayment(db, it) - is OutgoingReversal -> { - logger.error("{}", it) - db.initiated.txStatusUpdate(it.endToEndId, it.msgId, SubmissionState.permanent_failure, "Payment bounced: ${it.reason}") - } - } - } + parseTx(xml, cfg.currency, cfg.dialect).forEach { tx -> + registerTransaction(db, cfg.ingest, tx) } } catch (e: Exception) { - throw Exception("Notifications registration failed", e) + throw Exception("Ingesting notifications failed", e) } } OrderDoc.acknowledgement -> { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt @@ -44,7 +44,7 @@ fun CliktCommand.transientOption() = option( class LibeufinNexus : CliktCommand() { init { versionOption(getVersion()) - subcommands(EbicsSetup(), DbInit(), Serve(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) + subcommands(DbInit(), EbicsSetup(), EbicsSubmit(), EbicsFetch(), Serve(), 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/Testing.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -91,15 +91,14 @@ class FakeIncoming: CliktCommand() { "Wrong currency: expected ${cfg.currency} got ${amount.currency}" } - registerIncomingPayment(db, + registerIncomingPayment(db, cfg.ebics.ingest, IncomingPayment( amount = amount, debtorPayto = payto, subject = subject, executionTime = Instant.now(), bankId = randEbicsId() - ), - cfg.accountType + ) ) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -292,7 +292,12 @@ suspend fun keyManagement( ): EbicsResponse<InputStream?> { logger.info("Doing key request $order") val txLog = ebicsLogger.tx(order.name) - val req = EbicsKeyMng(cfg, privs, true).request(order) + val ebics3 = when (cfg.dialect) { + // TODO GLS needs EBICS 2.5 for key management + Dialect.gls -> false + Dialect.postfinance -> true + } + val req = EbicsKeyMng(cfg, privs, ebics3).request(order) val xml = client.postToBank(cfg.hostBaseUrl, req, order.name, txLog.step()) return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key) } diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -18,16 +18,16 @@ */ import org.junit.Test -import tech.libeufin.common.ShortHashCode -import tech.libeufin.common.TalerAmount +import tech.libeufin.common.* import tech.libeufin.common.db.* import tech.libeufin.nexus.AccountType +import tech.libeufin.nexus.NexusIngestConfig +import tech.libeufin.nexus.iso20022.* +import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.cli.* import tech.libeufin.nexus.db.* -import tech.libeufin.nexus.db.InitiatedDAO.* -import tech.libeufin.nexus.db.PaymentDAO.* -import tech.libeufin.nexus.ebics.randEbicsId -import tech.libeufin.nexus.iso20022.* +import tech.libeufin.nexus.db.PaymentDAO.OutgoingRegistrationResult +import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult import java.time.Instant import kotlin.test.* @@ -207,92 +207,144 @@ class IncomingPaymentsTest { // Test creating an incoming reserve taler transaction without and ID and reconcile it later again @Test fun reconcileMissingId() = setup { db, _ -> + val cfg = NexusIngestConfig.default(AccountType.exchange) val subject = "test with ${ShortHashCode.rand()} reserve pub" // Register with missing ID val incoming = genInPay(subject) val incomingMissingId = incoming.copy(bankId = null) - registerIncomingPayment(db, incomingMissingId, AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId) db.checkInCount(1, 0, 1) assertFalse(db.inTxExists(incoming.bankId!!)) // Idempotent - registerIncomingPayment(db, incomingMissingId, AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId) db.checkInCount(1, 0, 1) // Different metadata is bounced - registerIncomingPayment(db, genInPay(subject, "KUDOS:9"), AccountType.exchange) - registerIncomingPayment(db, genInPay("another $subject"), AccountType.exchange) + registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) + registerIncomingPayment(db, cfg, genInPay("another $subject")) db.checkInCount(3, 2, 1) // Different medata with missing id is ignored - registerIncomingPayment(db, incomingMissingId.copy(amount = TalerAmount("KUDOS:9")), AccountType.exchange) - registerIncomingPayment(db, incomingMissingId.copy(subject = "another $subject"), AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9"))) + registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "another $subject")) db.checkInCount(3, 2, 1) // Recover bank ID when metadata match - registerIncomingPayment(db, incoming, AccountType.exchange) + registerIncomingPayment(db, cfg, incoming) assertTrue(db.inTxExists(incoming.bankId!!)) // Idempotent - registerIncomingPayment(db, incoming, AccountType.exchange) + registerIncomingPayment(db, cfg, incoming) db.checkInCount(3, 2, 1) // Missing ID is ignored - registerIncomingPayment(db, incomingMissingId, AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId) db.checkInCount(3, 2, 1) // Other ID is bounced known that we know the id - registerIncomingPayment(db, incomingMissingId.copy(bankId = "NEW"), AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW")) db.checkInCount(4, 3, 1) } // Test creating an incoming kyc taler transaction without and ID and reconcile it later again @Test fun reconcileMissingIdKyc() = setup { db, _ -> + val cfg = NexusIngestConfig.default(AccountType.exchange) val subject = "test with KYC:${ShortHashCode.rand()} account pub" // Register with missing ID val incoming = genInPay(subject) val incomingMissingId = incoming.copy(bankId = null) - registerIncomingPayment(db, incomingMissingId, AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId) db.checkInCount(1, 0, 1) assertFalse(db.inTxExists(incoming.bankId!!)) // Idempotent - registerIncomingPayment(db, incomingMissingId, AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId) db.checkInCount(1, 0, 1) // Different metadata is accepted - registerIncomingPayment(db, genInPay(subject, "KUDOS:9"), AccountType.exchange) - registerIncomingPayment(db, genInPay("another $subject"), AccountType.exchange) + registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) + registerIncomingPayment(db, cfg, genInPay("another $subject")) db.checkInCount(3, 0, 3) // Different medata with missing id are accepted - registerIncomingPayment(db, incomingMissingId.copy(amount = TalerAmount("KUDOS:9.5")), AccountType.exchange) - registerIncomingPayment(db, incomingMissingId.copy(subject = "again another $subject"), AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9.5"))) + registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "again another $subject")) db.checkInCount(5, 0, 5) // Recover bank ID when metadata match - registerIncomingPayment(db, incoming, AccountType.exchange) + registerIncomingPayment(db, cfg, incoming) assertTrue(db.inTxExists(incoming.bankId!!)) // Idempotent - registerIncomingPayment(db, incoming, AccountType.exchange) + registerIncomingPayment(db, cfg, incoming) db.checkInCount(5, 0, 5) // Missing ID is ignored - registerIncomingPayment(db, incomingMissingId, AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId) db.checkInCount(5, 0, 5) // Other ID is accepted - registerIncomingPayment(db, incomingMissingId.copy(bankId = "NEW"), AccountType.exchange) + registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW")) db.checkInCount(6, 0, 6) } } class PaymentInitiationsTest { + // Test skipping transaction based on config + @Test + fun skipping() = setup("skip.conf") { db, cfg -> + suspend fun checkCount(nbTxs: Int, nbBounce: Int) { + db.serializable( + """ + SELECT (SELECT count(*) FROM incoming_transactions) + (SELECT count(*) FROM outgoing_transactions) AS transactions, + (SELECT count(*) FROM bounced_transactions) AS bounce + """ + ) { + one { + assertEquals( + Pair(nbTxs, nbBounce), + Pair(it.getInt("transactions"), it.getInt("bounce")) + ) + } + } + } + + suspend fun ingest(executionTime: Instant) { + for (tx in sequenceOf( + genInPay("test at $executionTime", executionTime = executionTime), + genOutPay("test at $executionTime", executionTime = executionTime) + )) { + registerTransaction(db, cfg.ebics.ingest, tx) + } + } + + assertEquals(cfg.ebics.fetch.ignoreTransactionsBefore, dateToInstant("2024-04-04")) + assertEquals(cfg.ebics.fetch.ignoreBouncesBefore, dateToInstant("2024-06-12")) + + // No transaction at the beginning + checkCount(0, 0) + + // Skipped transactions + ingest(cfg.ebics.fetch.ignoreTransactionsBefore.minusMillis(10)) + checkCount(0, 0) + + // Skipped bounces + ingest(cfg.ebics.fetch.ignoreTransactionsBefore) + ingest(cfg.ebics.fetch.ignoreTransactionsBefore.plusMillis(10)) + ingest(cfg.ebics.fetch.ignoreBouncesBefore.minusMillis(10)) + checkCount(6, 0) + + // Bounces + ingest(cfg.ebics.fetch.ignoreBouncesBefore) + ingest(cfg.ebics.fetch.ignoreBouncesBefore.plusMillis(10)) + checkCount(10, 2) + } + @Test fun status() = setup { db, _ -> suspend fun checkPart( diff --git a/nexus/src/test/kotlin/bench.kt b/nexus/src/test/kotlin/bench.kt @@ -77,6 +77,8 @@ class Bench { } println("Bench $ITER times with $AMOUNT rows") + val ingestCfg = NexusIngestConfig.default(AccountType.exchange) + bench(ITER) { serverSetup { db -> // Generate data db.conn { genData(it, AMOUNT) } @@ -99,13 +101,13 @@ class Bench { } measureAction("register_reserve_missing_id") { val incoming = genInPay("test with ${ShortHashCode.rand()} reserve pub") - registerIncomingPayment(db, incoming.copy(bankId = null), AccountType.exchange) - registerIncomingPayment(db, incoming, AccountType.exchange) + registerIncomingPayment(db, ingestCfg, incoming.copy(bankId = null)) + registerIncomingPayment(db, ingestCfg, incoming) } measureAction("register_kyc_missing_id") { val incoming = genInPay("test with KYC:${ShortHashCode.rand()} account pub") - registerIncomingPayment(db, incoming.copy(bankId = null), AccountType.exchange) - registerIncomingPayment(db, incoming, AccountType.exchange) + registerIncomingPayment(db, ingestCfg, incoming.copy(bankId = null)) + registerIncomingPayment(db, ingestCfg, incoming) } // Revenue API diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -99,12 +99,13 @@ fun genInitPay( /** Generates an incoming payment, given its subject */ fun genInPay( subject: String, - amount: String = "KUDOS:44" + amount: String = "KUDOS:44", + executionTime: Instant = Instant.now() ) = IncomingPayment( amount = TalerAmount(amount), debtorPayto = ibanPayto("DE84500105177118117964"), subject = subject, - executionTime = Instant.now(), + executionTime = executionTime, bankId = randEbicsId(), ) @@ -112,12 +113,13 @@ fun genInPay( fun genOutPay( subject: String, endToEndId: String? = null, - msgId: String? = null + msgId: String? = null, + executionTime: Instant = Instant.now() ) = OutgoingPayment( amount = TalerAmount(44, 0, "KUDOS"), creditorPayto = ibanPayto("CH4189144589712575493", "Test"), subject = subject, - executionTime = Instant.now(), + executionTime = executionTime, endToEndId = endToEndId ?: randEbicsId(), msgId = msgId ) @@ -166,30 +168,34 @@ suspend fun talerableOut(db: Database) { /** Register a talerable reserve incoming transaction */ suspend fun talerableIn(db: Database, nullId: Boolean = false, amount: String = "CHF:44") { val reserve_pub = ShortHashCode.rand() - registerIncomingPayment(db, genInPay("test with $reserve_pub reserve pub", amount).run { - if (nullId) { - copy(bankId = null) - } else { - this + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), + genInPay("test with $reserve_pub reserve pub", amount).run { + if (nullId) { + copy(bankId = null) + } else { + this + } } - }, AccountType.exchange) + ) } /** Register a talerable KYC incoming transaction */ suspend fun talerableKycIn(db: Database, nullId: Boolean = false, amount: String = "CHF:44") { val account_pub = ShortHashCode.rand() - registerIncomingPayment(db, genInPay("test with KYC:$account_pub account pub", amount).run { - if (nullId) { - copy(bankId = null) - } else { - this + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), + genInPay("test with KYC:$account_pub account pub", amount).run { + if (nullId) { + copy(bankId = null) + } else { + this + } } - }, AccountType.exchange) + ) } /** Register an incoming transaction */ suspend fun registerIn(db: Database) { - registerIncomingPayment(db, genInPay("ignored"), AccountType.normal) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.normal), genInPay("ignored")) } /** Register an outgoing transaction */ diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -35,7 +35,7 @@ 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.AccountType +import tech.libeufin.nexus.* import tech.libeufin.nexus.cli.LibeufinNexus import tech.libeufin.nexus.cli.registerIncomingPayment import tech.libeufin.nexus.iso20022.* @@ -150,6 +150,7 @@ class IntegrationTest { } setup("conf/integration.conf") { db -> + val cfg = NexusIngestConfig.default(AccountType.exchange) val userPayTo = IbanPayto.rand() // Load conversion setup manually as the server would refuse to start without an exchange account @@ -169,24 +170,23 @@ class IntegrationTest { ) assertException("ERROR: cashin failed: missing exchange account") { - registerIncomingPayment(db, reservePayment, AccountType.exchange) + registerIncomingPayment(db, cfg, reservePayment) } // But KYC works registerIncomingPayment( - db, + db, cfg, reservePayment.copy( bankId = "kyc", subject = "Error test KYC:${EddsaPublicKey.rand()}" - ), - AccountType.exchange + ) ) // Create exchange account bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange") assertException("ERROR: cashin currency conversion failed: missing conversion rates") { - registerIncomingPayment(db, reservePayment, AccountType.exchange) + registerIncomingPayment(db, cfg, reservePayment) } // Start server @@ -220,9 +220,9 @@ class IntegrationTest { // Too small amount db.checkCount(1, 0, 1) - registerIncomingPayment(db, reservePayment.copy( + registerIncomingPayment(db, cfg, reservePayment.copy( amount = TalerAmount("EUR:0.01"), - ), AccountType.exchange) + )) db.checkCount(2, 1, 1) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { basicAuth("exchange", "password") @@ -233,17 +233,17 @@ class IntegrationTest { subject = "Success $reservePub", bankId = "success" ) - registerIncomingPayment(db, validPayment, AccountType.exchange) + registerIncomingPayment(db, cfg, validPayment) db.checkCount(3, 1, 2) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { basicAuth("exchange", "password") }.assertOkJson<BankAccountTransactionsResponse>() // Check idempotency - registerIncomingPayment(db, validPayment, AccountType.exchange) - registerIncomingPayment(db, validPayment.copy( + registerIncomingPayment(db, cfg, validPayment) + registerIncomingPayment(db, cfg, validPayment.copy( subject="Success 2 $reservePub" - ), AccountType.exchange) + )) db.checkCount(3, 1, 2) } }