libeufin

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

commit ef2124c9949991cd4a0fbb78356184d06564b5e0
parent 326a349eb491ab80f01e42619fec5c48b37d50da
Author: Antoine A <>
Date:   Fri,  3 May 2024 19:23:54 +0900

nexus: support normal account that never bounce

Diffstat:
Mcommon/src/main/kotlin/TalerConfig.kt | 4++--
Mcontrib/nexus.conf | 5+++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 17+++++++++++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 35++++++++++++++++++++++++-----------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 14+++++++-------
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 23+++++++++++++++++++++++
Mnexus/src/test/kotlin/RevenueApiTest.kt | 2+-
Mnexus/src/test/kotlin/WireGatewayApiTest.kt | 4++--
Mnexus/src/test/kotlin/helpers.kt | 7++++++-
Mtestbench/src/test/kotlin/IntegrationTest.kt | 12++++++------
10 files changed, 89 insertions(+), 34 deletions(-)

diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt @@ -444,8 +444,8 @@ class TalerConfig internal constructor( return str } - fun requireString(section: String, option: String, type: String = "string"): String = - lookupString(section, option) ?: throw TalerConfigError.missing(type, section, option) + fun requireString(section: String, option: String, type: String? = null): String = + lookupString(section, option) ?: throw TalerConfigError.missing(type ?: "string", section, option) fun requireNumber(section: String, option: String): Int { val raw = lookupString(section, option) ?: throw TalerConfigError.missing("number", section, option) diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -40,6 +40,11 @@ CLIENT_PRIVATE_KEYS_FILE = ${LIBEUFIN_NEXUS_HOME}/client-ebics-keys.json # Typically, it is named after the bank itself. BANK_DIALECT = postfinance +# Specify the account type and therefore the indexing behavior. +# This can either can be normal or exchange. +# Exchange accounts bounce invalid incoming Taler transactions. +ACCOUNT_TYPE = exchange + [libeufin-nexusdb-postgres] # Where are the SQL files to setup our tables? SQL_DIR = $DATADIR/sql/ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -37,7 +37,7 @@ class ApiConfig(config: TalerConfig, section: String) { /** Configuration for libeufin-nexus */ class NexusConfig(val config: TalerConfig) { - private fun requireString(option: String): String = config.requireString("nexus-ebics", option) + private fun requireString(option: String, type: String? = null): String = config.requireString("nexus-ebics", option, type) private fun requirePath(option: String): Path = config.requirePath("nexus-ebics", option) /** The bank's currency */ @@ -64,12 +64,16 @@ class NexusConfig(val config: TalerConfig) { val clientPrivateKeysPath = requirePath("client_private_keys_file") val fetch = NexusFetchConfig(config) - val dialect = when (val type = requireString("bank_dialect")) { + val dialect = when (val type = requireString("bank_dialect", "dialect")) { "postfinance" -> Dialect.postfinance "gls" -> Dialect.gls - else -> throw TalerConfigError.invalid("dialct", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'") + else -> throw TalerConfigError.invalid("bank dialect", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'") + } + val accountType = when (val type = requireString("account_type", "account type")) { + "normal" -> AccountType.normal + "exchange" -> AccountType.exchange + else -> throw TalerConfigError.invalid("account type", "libeufin-nexus", "account_type", "expected 'normal' or 'exchange' got '$type'") } - val wireGatewayApiCfg = config.apiConf("nexus-httpd-wire-gateway-api") val revenueApiCfg = config.apiConf("nexus-httpd-revenue-api") } @@ -104,4 +108,9 @@ fun TalerConfig.apiConf(section: String): ApiConfig? { sealed interface AuthMethod { data object None: AuthMethod data class Bearer(val token: String): AuthMethod +} + +enum class AccountType { + normal, + exchange } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -118,18 +118,31 @@ suspend fun ingestOutgoingPayment( */ suspend fun ingestIncomingPayment( db: Database, - payment: IncomingPayment + payment: IncomingPayment, + accountType: AccountType ) { suspend fun bounce(msg: String) { - val result = db.payment.registerMalformedIncoming( - payment, - payment.amount, - Instant.now() - ) - if (result.new) { - logger.info("$payment bounced in '${result.bounceId}': $msg") - } else { - logger.debug("$payment already seen and bounced in '${result.bounceId}': $msg") + when (accountType) { + AccountType.exchange -> { + val result = db.payment.registerMalformedIncoming( + payment, + payment.amount, + Instant.now() + ) + if (result.new) { + logger.info("$payment bounced in '${result.bounceId}': $msg") + } else { + logger.debug("$payment already seen and bounced in '${result.bounceId}': $msg") + } + } + AccountType.normal -> { + val res = db.payment.registerIncoming(payment) + if (res.new) { + logger.info("$payment") + } else { + logger.debug("$payment already seen") + } + } } } runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold( @@ -164,7 +177,7 @@ private suspend fun ingestDocument( logger.debug("IGNORE $it") } else { when (it) { - is IncomingPayment -> ingestIncomingPayment(db, it) + is IncomingPayment -> ingestIncomingPayment(db, it, cfg.accountType) is OutgoingPayment -> ingestOutgoingPayment(db, it) is TxNotification.Reversal -> { logger.error("BOUNCE '${it.msgId}': ${it.reason}") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -297,15 +297,14 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { ).convert { Payto.parse(it).expectIban() } override fun run() = cliCmd(logger, common.log) { - val cfg = loadConfig(common.config) - val dbCfg = cfg.dbConfig() - val currency = cfg.requireString("nexus-ebics", "currency") + val cfg = loadNexusConfig(common.config) + val dbCfg = cfg.config.dbConfig() val subject = payto.message ?: subject ?: throw Exception("Missing subject") val amount = payto.amount ?: amount ?: throw Exception("Missing amount") - if (amount.currency != currency) - throw Exception("Wrong currency: expected $currency got ${amount.currency}") + if (amount.currency != cfg.currency) + throw Exception("Wrong currency: expected ${cfg.currency} got ${amount.currency}") val bankId = run { val bytes = ByteArray(16) @@ -313,7 +312,7 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { Base32Crockford.encode(bytes) } - Database(dbCfg, currency).use { db -> + Database(dbCfg, amount.currency).use { db -> ingestIncomingPayment(db, IncomingPayment( amount = amount, @@ -321,7 +320,8 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { wireTransferSubject = subject, executionTime = Instant.now(), bankId = bankId - ) + ), + cfg.accountType ) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -142,6 +142,29 @@ class PaymentDAO(private val db: Database) { } } + /** Register an incoming payment */ + suspend fun registerIncoming( + paymentData: IncomingPayment + ): IncomingRegistrationResult.Success = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT out_found, out_tx_id + FROM register_incoming((?,?)::taler_amount,?,?,?,?) + """) + val executionTime = paymentData.executionTime.micros() + stmt.setLong(1, paymentData.amount.value) + stmt.setInt(2, paymentData.amount.frac) + stmt.setString(3, paymentData.wireTransferSubject) + stmt.setLong(4, executionTime) + stmt.setString(5, paymentData.debitPaytoUri) + stmt.setString(6, paymentData.bankId) + stmt.one { + IncomingRegistrationResult.Success( + it.getLong("out_tx_id"), + !it.getBoolean("out_found") + ) + } + } + /** Query history of incoming transactions */ suspend fun revenueHistory( params: HistoryParams diff --git a/nexus/src/test/kotlin/RevenueApiTest.kt b/nexus/src/test/kotlin/RevenueApiTest.kt @@ -46,7 +46,7 @@ class RevenueApiTest { }, { // Common credit transactions - ingestIncomingPayment(db, genInPay("ignored")) + ingestIn(db) } ), ignored = listOf( diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -134,7 +134,7 @@ class WireGatewayApiTest { ignored = listOf( { // Ignore malformed incoming transaction - ingestIncomingPayment(db, genInPay("ignored")) + ingestIn(db) }, { // Ignore outgoing transaction @@ -167,7 +167,7 @@ class WireGatewayApiTest { }, { // Ignore malformed incoming transaction - ingestIncomingPayment(db, genInPay("ignored")) + ingestIn(db) }, { // Ignore malformed outgoing transaction diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -149,7 +149,12 @@ suspend fun talerableOut(db: Database) { /** Ingest a talerable incoming transaction */ suspend fun talerableIn(db: Database) { val reserve_pub = ShortHashCode.rand() - ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub")) + ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub"), AccountType.exchange) +} + +/** Ingest an incoming transaction */ +suspend fun ingestIn(db: Database) { + ingestIncomingPayment(db, genInPay("ignored"), AccountType.normal) } diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -135,7 +135,7 @@ class IntegrationTest { } } - setup("conf/integration.conf") { db -> + setup("conf/integration.conf") { db -> val userPayTo = IbanPayto.rand() val fiatPayTo = IbanPayto.rand() @@ -156,14 +156,14 @@ class IntegrationTest { ) assertException("ERROR: cashin failed: missing exchange account") { - ingestIncomingPayment(db, payment) + ingestIncomingPayment(db, payment, AccountType.exchange) } // Create exchange account bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange") assertException("ERROR: cashin currency conversion failed: missing conversion rates") { - ingestIncomingPayment(db, payment) + ingestIncomingPayment(db, payment, AccountType.exchange) } // Start server @@ -199,7 +199,7 @@ class IntegrationTest { checkCount(db, 0, 0, 0) ingestIncomingPayment(db, payment.copy( amount = TalerAmount("EUR:0.01"), - )) + ), AccountType.exchange) checkCount(db, 1, 1, 0) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { basicAuth("exchange", "password") @@ -213,14 +213,14 @@ class IntegrationTest { executionTime = Instant.now(), bankId = "success" ) - ingestIncomingPayment(db, valid_payment) + ingestIncomingPayment(db, valid_payment, AccountType.exchange) checkCount(db, 2, 1, 1) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { basicAuth("exchange", "password") }.assertOkJson<BankAccountTransactionsResponse>() // Check idempotency - ingestIncomingPayment(db, valid_payment) + ingestIncomingPayment(db, valid_payment, AccountType.exchange) checkCount(db, 2, 1, 1) // TODO check double insert cashin with different subject }