commit c9a01d25d7df262f59fb63bf2dab328f7d7885b6 parent 9252d28b3a9c8d332e9aacc3fe3802d4ccfdefef Author: Antoine A <> Date: Sat, 1 Mar 2025 14:35:33 +0100 nexus: support incomplete incomplete transactions Diffstat:
15 files changed, 319 insertions(+), 133 deletions(-)
diff --git a/common/src/main/kotlin/ApiError.kt b/common/src/main/kotlin/ApiError.kt @@ -57,21 +57,34 @@ fun ApplicationCall.logMsg(): String? = attributes.getOrNull(LOG_MSG) suspend fun ApplicationCall.err( status: HttpStatusCode, hint: String?, - error: TalerErrorCode + error: TalerErrorCode, + cause: Exception? ) { err( ApiException( httpStatus = status, talerError = TalerError( code = error.code, err = error, hint = hint ) - ) + ), + cause ) } suspend fun ApplicationCall.err( - err: ApiException + err: ApiException, + cause: Exception? ) { - attributes.put(LOG_MSG, "${err.talerError.err.name} ${err.talerError.hint}") + val fmt = buildString { + append(err.talerError.err.name) + append(" ") + append(err.talerError.hint) + val msg = cause?.message + if (msg != null) { + append("- ") + append(msg) + } + } + attributes.put(LOG_MSG, fmt) respond( status = err.httpStatus, message = err.talerError diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -131,6 +131,8 @@ class DecimalNumber { } } + fun isZero(): Boolean = value == 0L && frac == 0 + override fun equals(other: Any?): Boolean { return other is DecimalNumber && other.value == this.value && diff --git a/common/src/main/kotlin/api/server.kt b/common/src/main/kotlin/api/server.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 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 @@ -144,33 +144,37 @@ fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) { call.err( status, "There is no endpoint defined for the URL provided by the client. Check if you used the correct URL and/or file a report with the developers of the client software.", - TalerErrorCode.GENERIC_ENDPOINT_UNKNOWN + TalerErrorCode.GENERIC_ENDPOINT_UNKNOWN, + null ) } status(HttpStatusCode.MethodNotAllowed) { call, status -> call.err( status, "The HTTP method used is invalid for this endpoint. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.", - TalerErrorCode.GENERIC_METHOD_INVALID + TalerErrorCode.GENERIC_METHOD_INVALID, + null ) } exception<Exception> { call, cause -> - logger.debug("request failed", cause) // TODO nexus specific error code ?! + logger.trace("request failed", cause) when (cause) { - is ApiException -> call.err(cause) + is ApiException -> call.err(cause, null) is SQLException -> { if (SERIALIZATION_ERROR.contains(cause.sqlState)) { call.err( HttpStatusCode.InternalServerError, "Transaction serialization failure", - TalerErrorCode.BANK_SOFT_EXCEPTION + TalerErrorCode.BANK_SOFT_EXCEPTION, + cause ) } else { call.err( HttpStatusCode.InternalServerError, "Unexpected sql error with state ${cause.sqlState}", - TalerErrorCode.BANK_UNMANAGED_EXCEPTION + TalerErrorCode.BANK_UNMANAGED_EXCEPTION, + cause ) } } @@ -206,14 +210,16 @@ fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) { /* Here getting _some_ error message, by giving precedence * to the root cause, as otherwise JSON details would be lost. */ rootCause?.message - ) + ), + null ) } else -> { call.err( HttpStatusCode.InternalServerError, cause.message, - TalerErrorCode.BANK_UNMANAGED_EXCEPTION + TalerErrorCode.BANK_UNMANAGED_EXCEPTION, + cause ) } } diff --git a/database-versioning/libeufin-nexus-0010.sql b/database-versioning/libeufin-nexus-0010.sql @@ -19,6 +19,13 @@ SELECT _v.register_patch('libeufin-nexus-0010', NULL, NULL); SET search_path TO libeufin_nexus; +ALTER TABLE incoming_transactions + ALTER COLUMN subject DROP NOT NULL, + ALTER COLUMN debit_payto DROP NOT NULL; + +ALTER TABLE outgoing_transactions + ALTER COLUMN end_to_end_id DROP NOT NULL; + -- TODO migrate existing ids -- Better polymorphism schema diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -221,6 +221,7 @@ CREATE FUNCTION register_incoming( ,OUT out_reserve_pub_reuse BOOLEAN -- Success return ,OUT out_found BOOLEAN + ,OUT out_completed BOOLEAN ,OUT out_tx_id INT8 ) LANGUAGE plpgsql AS $$ @@ -229,6 +230,7 @@ local_ref TEXT; local_amount taler_amount; local_subject TEXT; local_debit_payto TEXT; +need_completion BOOLEAN; BEGIN IF in_credit_fee = (0, 0)::taler_amount THEN in_credit_fee = NULL; @@ -243,15 +245,32 @@ out_found=FOUND; IF out_found THEN local_ref=COALESCE(in_uetr::text, in_tx_id, in_acct_svcr_ref); -- Check metadata - IF local_subject != in_subject THEN - RAISE NOTICE 'incoming tx %: stored subject is ''%'' got ''%''', local_ref, local_subject, in_subject; + IF in_subject IS NOT NULL THEN + IF local_subject IS NULL THEN + need_completion=TRUE; + ELSIF local_subject != in_subject THEN + RAISE NOTICE 'incoming tx %: stored subject is ''%'' got ''%''', local_ref, local_subject, in_subject; + END IF; END IF; - IF local_debit_payto != in_debit_payto THEN - RAISE NOTICE 'incoming tx %: stored subject debit payto is % got %', local_ref, local_debit_payto, in_debit_payto; + IF in_debit_payto IS NOT NULL THEN + IF local_debit_payto IS NULL THEN + need_completion=TRUE; + ELSIF local_debit_payto != in_debit_payto THEN + RAISE NOTICE 'incoming tx %: stored subject debit payto is % got %', local_ref, local_debit_payto, in_debit_payto; + END IF; END IF; IF local_amount != in_amount THEN RAISE NOTICE 'incoming tx %: stored amount is % got %', local_ref, local_amount, in_amount; END IF; + IF need_completion THEN + UPDATE incoming_transactions + SET subject=COALESCE(subject, in_subject), debit_payto=COALESCE(debit_payto, in_debit_payto) + WHERE incoming_transaction_id = out_tx_id; + out_completed=COALESCE(in_subject, local_subject) IS NOT NULL AND COALESCE(in_debit_payto, local_debit_payto) IS NOT NULL; + IF out_completed THEN + PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text); + END IF; + END IF; ELSE out_reserve_pub_reuse=in_type = 'reserve' AND EXISTS(SELECT FROM talerable_incoming_transactions WHERE metadata = in_metadata AND type = 'reserve'); IF out_reserve_pub_reuse THEN @@ -277,7 +296,9 @@ ELSE ,in_tx_id ,in_acct_svcr_ref ) RETURNING incoming_transaction_id INTO out_tx_id; - PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text); + IF in_subject IS NOT NULL AND in_debit_payto IS NOT NULL THEN + PERFORM pg_notify('nexus_revenue_tx', out_tx_id::text); + END IF; END IF; -- Register as talerable @@ -309,6 +330,7 @@ CREATE FUNCTION register_and_bounce_incoming( ,IN in_now_date INT8 ,IN in_bounce_id TEXT ,OUT out_found BOOLEAN + ,OUT out_completed BOOLEAN ,OUT out_tx_id INT8 ,OUT out_bounce_id TEXT ) @@ -318,9 +340,9 @@ init_id INT8; bounce_amount taler_amount; BEGIN -- Register incoming transaction -SELECT reg.out_found, reg.out_tx_id +SELECT reg.out_found, reg.out_completed, reg.out_tx_id FROM register_incoming(in_amount, in_credit_fee, in_subject, in_execution_time, in_debit_payto, in_uetr, in_tx_id, in_acct_svcr_ref, NULL, NULL) as reg - INTO out_found, out_tx_id; + INTO out_found, out_completed, out_tx_id; -- Bounce incoming transaction SELECT bounce.out_bounce_id INTO out_bounce_id FROM bounce_incoming(out_tx_id, in_bounce_amount, in_bounce_id, in_now_date) AS bounce; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -81,6 +81,29 @@ suspend fun registerIncomingPayment( cfg: NexusIngestConfig, payment: IncomingPayment, ) { + fun logRes(res: InResult, kind: String = "", suffix: String = "") { + val fmt = buildString { + append(payment) + if (kind != "") { + append(" ") + append(kind) + } + if (res.completed) { + append(" completed") + } else if (!res.new) { + append(" already seen") + } + if (suffix != "") { + append(" ") + append(suffix) + } + } + if (res.completed || res.new) { + logger.info(fmt) + } else { + logger.debug(fmt) + } + } suspend fun bounce(msg: String) { if (payment.bankId == null) { logger.debug("{} ignored: missing bank ID", payment) @@ -90,67 +113,48 @@ suspend fun registerIncomingPayment( AccountType.exchange -> { 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) - } + logRes(res, suffix = "ignored bounce: $msg") } else { var bounceAmount = payment.amount if (payment.creditFee != null) { if (payment.creditFee > bounceAmount) { val res = db.payment.registerIncoming(payment) - if (res.new) { - logger.info("$payment skip bounce (fee higher than amount): $msg") - } else { - logger.debug("{} already seen and skip bounce (fee higher than amount): {}", payment, msg) - } + logRes(res, suffix = "skip bounce (fee higher than amount): $msg") return } bounceAmount -= payment.creditFee } - val result = db.payment.registerMalformedIncoming( + val res = db.payment.registerMalformedIncoming( payment, bounceAmount, 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) - } + logRes(res, suffix="bounced in ${res.bounceId}: $msg") } } AccountType.normal -> { val res = db.payment.registerIncoming(payment) - if (res.new) { - logger.info("$payment") - } else { - logger.debug("{} already seen", payment) - } + logRes(res) } } } + // Check we have enough info to handle this transaction + if (payment.subject == null || payment.debtorPayto == null) { + val res = db.payment.registerIncoming(payment) + logRes(res, kind = "incomplete") + return + } + // Else we try to parse the incoming subject runCatching { parseIncomingSubject(payment.subject) }.fold( onSuccess = { metadata -> if (metadata is IncomingSubject.AdminBalanceAdjust) { val res = db.payment.registerIncoming(payment) - if (res.new) { - logger.info("$payment admin balance adjust") - } else { - logger.debug("{} already seen admin balance adjust", payment) - } + logRes(res, kind = "admin balance adjust") } else { when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") - is IncomingRegistrationResult.Success -> { - if (res.new) { - logger.info("$payment") - } else { - logger.debug("{} already seen", payment) - } - } + is IncomingRegistrationResult.Success -> logRes(res) } } }, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -229,13 +229,21 @@ class ListCmd: CliktCommand("list") { ListKind.incoming -> { val txs = db.list.incoming() for (tx in txs) { - println("${tx.date} ${tx.id} ${tx.amount}-${tx.creditFee}") - println(" debtor: ${fmtPayto(tx.debtor)}") - println(" subject: ${tx.subject}") + if (tx.creditFee.isZero()) { + println("${tx.date} ${tx.id} ${tx.amount}") + } else { + println("${tx.date} ${tx.id} ${tx.amount}-${tx.creditFee}") + } + if (tx.debtor != null) { + println(" debtor: ${fmtPayto(tx.debtor)}") + } + if (tx.subject != null) { + println(" subject: ${tx.subject}") + } if (tx.talerable != null) { println(" talerable: ${tx.talerable}") } - if (tx.bounced != 0) { + if (tx.bounced != null) { println(" bounced: ${tx.bounced}") } println() diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt @@ -32,12 +32,12 @@ class ListDAO(private val db: Database) { suspend fun incoming(): List<IncomingTxMetadata> = db.serializable( """ SELECT - (amount).val AS amount_val - ,(amount).frac AS amount_frac + (incoming.amount).val AS amount_val + ,(incoming.amount).frac AS amount_frac ,(credit_fee).val AS credit_fee_val ,(credit_fee).frac AS credit_fee_frac - ,subject - ,bounced_transactions.initiated_outgoing_transaction_id AS bounced + ,incoming.subject + ,end_to_end_id AS bounced ,execution_time ,debit_payto ,COALESCE(uetr::text, tx_id, acct_svcr_ref) as bank_id @@ -46,6 +46,7 @@ class ListDAO(private val db: Database) { FROM incoming_transactions AS incoming LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) LEFT JOIN bounced_transactions USING (incoming_transaction_id) + LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) ORDER BY execution_time """ ) { @@ -58,7 +59,7 @@ class ListDAO(private val db: Database) { subject = it.getString("subject"), debtor = it.getString("debit_payto"), id = it.getString("bank_id"), - bounced = it.getInt("bounced"), + bounced = it.getString("bounced"), talerable = when (type) { null -> null IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}" @@ -141,11 +142,11 @@ data class IncomingTxMetadata( val date: Instant, val amount: DecimalNumber, val creditFee: DecimalNumber, - val subject: String, - val debtor: String, + val subject: String?, + val debtor: String?, val id: String?, val talerable: String?, - val bounced: Int + val bounced: String? ) /** Outgoing transaction metadata for debugging */ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -52,7 +52,7 @@ class PaymentDAO(private val db: Database) { setInt(2, payment.amount.frac) setString(3, payment.subject) setLong(4, executionTime) - setString(5, payment.creditorPayto.toString()) + setString(5, payment.creditorPayto?.toString()) setString(6, payment.endToEndId) setBytes(7, wtid?.raw) setString(8, baseUrl?.url) @@ -67,12 +67,18 @@ class PaymentDAO(private val db: Database) { } } + interface InResult { + val new: Boolean + val completed: Boolean + } + /** Incoming payments bounce registration result */ data class IncomingBounceRegistrationResult( val id: Long, val bounceId: String, - val new: Boolean - ) + override val new: Boolean, + override val completed: Boolean + ): InResult /** Register an incoming payment and bounce it */ suspend fun registerMalformedIncoming( @@ -82,7 +88,7 @@ class PaymentDAO(private val db: Database) { timestamp: Instant ): IncomingBounceRegistrationResult = db.serializable( """ - SELECT out_found, out_tx_id, out_bounce_id + SELECT out_found, out_tx_id, out_completed, out_bounce_id FROM register_and_bounce_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,(?,?)::taler_amount,?,?) """ ) { @@ -92,7 +98,7 @@ class PaymentDAO(private val db: Database) { setInt(4, payment.creditFee?.frac ?: 0) setString(5, payment.subject) setLong(6, payment.executionTime.micros()) - setString(7, payment.debtorPayto.toString()) + setString(7, payment.debtorPayto?.toString()) setObject(8, payment.bankId.uetr) setString(9, payment.bankId.txId) setString(10, payment.bankId.acctSvcrRef) @@ -104,14 +110,15 @@ class PaymentDAO(private val db: Database) { IncomingBounceRegistrationResult( it.getLong("out_tx_id"), it.getString("out_bounce_id"), - !it.getBoolean("out_found") + !it.getBoolean("out_found"), + it.getBoolean("out_completed") ) } } /** Incoming payments registration result */ sealed interface IncomingRegistrationResult { - data class Success(val id: Long, val new: Boolean): IncomingRegistrationResult + data class Success(val id: Long, override val new: Boolean, override val completed: Boolean): IncomingRegistrationResult, InResult data object ReservePubReuse: IncomingRegistrationResult } @@ -121,7 +128,7 @@ class PaymentDAO(private val db: Database) { metadata: IncomingSubject ): IncomingRegistrationResult = db.serializable( """ - SELECT out_reserve_pub_reuse, out_found, out_tx_id + SELECT out_reserve_pub_reuse, out_found, out_completed, out_tx_id FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?::taler_incoming_type,?) """ ) { @@ -132,7 +139,7 @@ class PaymentDAO(private val db: Database) { setInt(4, payment.creditFee?.frac ?: 0) setString(5, payment.subject) setLong(6, executionTime) - setString(7, payment.debtorPayto.toString()) + setString(7, payment.debtorPayto?.toString()) setObject(8, payment.bankId.uetr) setString(9, payment.bankId.txId) setString(10, payment.bankId.acctSvcrRef) @@ -143,7 +150,8 @@ class PaymentDAO(private val db: Database) { it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse else -> IncomingRegistrationResult.Success( it.getLong("out_tx_id"), - !it.getBoolean("out_found") + !it.getBoolean("out_found"), + it.getBoolean("out_completed") ) } } @@ -154,7 +162,7 @@ class PaymentDAO(private val db: Database) { payment: IncomingPayment ): IncomingRegistrationResult.Success = db.serializable( """ - SELECT out_found, out_tx_id + SELECT out_found, out_completed, out_tx_id FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL) """ ) { @@ -165,14 +173,15 @@ class PaymentDAO(private val db: Database) { setInt(4, payment.creditFee?.frac ?: 0) setString(5, payment.subject) setLong(6, executionTime) - setString(7, payment.debtorPayto.toString()) + setString(7, payment.debtorPayto?.toString()) setObject(8, payment.bankId.uetr) setString(9, payment.bankId.txId) setString(10, payment.bankId.acctSvcrRef) one { IncomingRegistrationResult.Success( it.getLong("out_tx_id"), - !it.getBoolean("out_found") + !it.getBoolean("out_found"), + it.getBoolean("out_completed") ) } } @@ -191,7 +200,8 @@ class PaymentDAO(private val db: Database) { ,(credit_fee).frac AS credit_fee_frac ,debit_payto ,subject - FROM incoming_transactions WHERE + FROM incoming_transactions + WHERE debit_payto IS NOT NULL AND subject IS NOT NULL AND """, "incoming_transaction_id") { RevenueIncomingBankTransaction( row_id = it.getLong("incoming_transaction_id"), diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -65,13 +65,30 @@ data class IncomingPayment( val bankId: IncomingId, val amount: TalerAmount, val creditFee: TalerAmount? = null, - val subject: String, + val subject: String?, override val executionTime: Instant, - val debtorPayto: IbanPayto + val debtorPayto: IbanPayto? ): TxNotification { - override fun toString(): String { - val fee = if (creditFee == null) "" else "-$creditFee" - return "IN ${executionTime.fmtDate()} $amount$fee ${bankId} debitor=$debtorPayto subject=\"$subject\"" + override fun toString(): String = buildString { + append("IN ") + append(executionTime.fmtDate()) + append(" ") + append(amount) + if (creditFee != null) { + append("-") + append(creditFee) + } + append(" ") + append(bankId) + if (debtorPayto != null) { + append(" debtor=") + append(debtorPayto) + } + if (subject != null) { + append(" subject='") + append(subject) + append("'") + } } } @@ -82,13 +99,30 @@ data class OutgoingPayment( /** ISO20022 MessageId */ val msgId: String? = null, val amount: TalerAmount, - val subject: String? = null, // Some implementation does not provide this for recovery + val subject: String?, override val executionTime: Instant, - val creditorPayto: IbanPayto? = null // Some implementation does not provide this for recovery + val creditorPayto: IbanPayto? ): TxNotification { - override fun toString(): String { - val msgIdFmt = if (msgId == null) "" else "$msgId." - return "OUT ${executionTime.fmtDate()} $amount $msgIdFmt$endToEndId creditor=$creditorPayto subject=\"$subject\"" + override fun toString(): String = buildString { + append("OUT ") + append(executionTime.fmtDate()) + append(" ") + append(amount) + append(" ") + if (msgId!=null) { + append(msgId) + append(".") + } + append(endToEndId) + if (creditorPayto != null) { + append(" creditor=") + append(creditorPayto) + } + if (subject != null) { + append(" subject='") + append(subject) + append("'") + } } } @@ -118,7 +152,7 @@ data class OutgoingReversal( } } -private class TxErr(val msg: String): Exception(msg) +private class IncompleteTx(val msg: String): Exception(msg) private enum class Kind { CRDT, @@ -373,7 +407,7 @@ data class AccountTransactions( val txs = txsInfos.mapNotNull { try { it.parse() - } catch (e: TxErr) { + } catch (e: IncompleteTx) { // TODO: add more info in doc or in log message? logger.warn("skip incomplete tx: ${e.msg}") null @@ -603,7 +637,7 @@ sealed interface TxInfo { return when (this) { is TxInfo.CreditReversal -> { if (id.endToEndId == null) - throw TxErr("missing end-to-end ID for Credit reversal $ref") + throw IncompleteTx("missing end-to-end ID for Credit reversal $ref") OutgoingReversal( endToEndId = id.endToEndId!!, msgId = id.msgId, @@ -613,11 +647,7 @@ sealed interface TxInfo { } is TxInfo.Credit -> { if (bankId == null) - throw TxErr("missing bank ID for Credit $ref") - if (subject == null) - throw TxErr("missing subject for Credit $ref") - if (debtorPayto == null) - throw TxErr("missing debtor info for Credit $ref") + throw IncompleteTx("missing unique ID for Credit $ref") IncomingPayment( amount = amount, creditFee = creditFee, @@ -629,7 +659,7 @@ sealed interface TxInfo { } is TxInfo.Debit -> { if (id.endToEndId == null && id.msgId == null) { - throw TxErr("missing end-to-end ID for Debit $ref") + throw IncompleteTx("missing end-to-end ID for Debit $ref") } else if (id.endToEndId != null) { OutgoingPayment( amount = amount, diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 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 @@ -165,9 +165,9 @@ class CliTest { talerableIn(db) talerableKycIn(db) check() - // Check with null id - talerableIn(db, true) - talerableKycIn(db, true) + // Check with incomplete + registerIncompleteIn(db) + talerableIncompleteIn(db) check() } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -195,9 +195,48 @@ class IncomingPaymentsTest { } } + // Test creating an incoming reserve transaction without and ID and reconcile it later again + @Test + fun simple() = setup { db, _ -> + val cfg = NexusIngestConfig.default(AccountType.exchange) + val subject = "test" + + // Register + val incoming = genInPay(subject) + registerIncomingPayment(db, cfg, incoming) + db.checkInCount(1, 1, 0) + + // Idempotent + registerIncomingPayment(db, cfg, incoming) + db.checkInCount(1, 1, 0) + + // No key reuse + registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9")) + registerIncomingPayment(db, cfg, genInPay("another $subject")) + db.checkInCount(3, 3, 0) + + // Admin balance adjust is ignored + registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) + db.checkInCount(4, 3, 0) + + val original = genInPay("test 2") + val incomplete = original.copy(subject = null, debtorPayto = null) + // Register incomplete transaction + registerIncomingPayment(db, cfg, incomplete) + db.checkInCount(5, 3, 0) + + // Idempotent + registerIncomingPayment(db, cfg, incomplete) + db.checkInCount(5, 3, 0) + + // Recover info when complete + registerIncomingPayment(db, cfg, original) + db.checkInCount(5, 4, 0) + } + // Test creating an incoming reserve taler transaction without and ID and reconcile it later again @Test - fun registerPayment() = setup { db, _ -> + fun talerable() = setup { db, _ -> val cfg = NexusIngestConfig.default(AccountType.exchange) val subject = "test with ${EddsaPublicKey.randEdsaKey()} reserve pub" @@ -206,7 +245,6 @@ class IncomingPaymentsTest { registerIncomingPayment(db, cfg, incoming) db.checkInCount(1, 0, 1) - // Idempotent registerIncomingPayment(db, cfg, incoming) db.checkInCount(1, 0, 1) @@ -219,6 +257,20 @@ class IncomingPaymentsTest { // Admin balance adjust is ignored registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) db.checkInCount(4, 2, 1) + + val original = genInPay("test 2 with ${EddsaPublicKey.randEdsaKey()} reserve pub") + val incomplete = original.copy(subject = null, debtorPayto = null) + // Register incomplete transaction + registerIncomingPayment(db, cfg, incomplete) + db.checkInCount(5, 2, 1) + + // Idempotent + registerIncomingPayment(db, cfg, incomplete) + db.checkInCount(5, 2, 1) + + // Recover info when complete + registerIncomingPayment(db, cfg, original) + db.checkInCount(5, 2, 2) } } diff --git a/nexus/src/test/kotlin/RevenueApiTest.kt b/nexus/src/test/kotlin/RevenueApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 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 @@ -40,20 +40,23 @@ class RevenueApiTest { url = "/taler-revenue/history", ids = { it.incoming_transactions.map { it.row_id } }, registered = listOf( - { - // Transactions using clean transfer logic - talerableIn(db) - }, - { - // Common credit transactions - registerIn(db) - } + // Transactions using clean transfer logic + { talerableIn(db) }, + { talerableCompletedIn(db) }, + + // Common credit transactions + { registerIn(db) }, + { registerCompletedIn(db) } ), ignored = listOf( - { - // Ignore debit transactions - talerableOut(db) - } + // Ignore debit transactions + { talerableOut(db) }, + + // Incomplete taler + { talerableIncompleteIn(db) }, + + // Ignore incomplete + { registerIncompleteIn(db) } ) ) } diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -201,6 +201,7 @@ class WireGatewayApiTest { // Reserve transactions using raw bank transaction logic { talerableIn(db) }, + { talerableCompletedIn(db) }, // KYC transactions using clean add incoming logic { addKyc("CHF:12") }, @@ -212,6 +213,15 @@ class WireGatewayApiTest { // Ignore malformed incoming transaction { registerIn(db) }, + // Ignore malformed incomplete + { registerIncompleteIn(db) }, + + // Ignore malformed completed + { registerCompletedIn(db) }, + + // Ignore incompleted + { talerableIncompleteIn(db) }, + // Ignore outgoing transaction { talerableOut(db) }, ) diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -166,30 +166,34 @@ suspend fun talerableOut(db: Database) { } /** Register a talerable reserve incoming transaction */ -suspend fun talerableIn(db: Database, nullId: Boolean = false, amount: String = "CHF:44") { +suspend fun talerableIn(db: Database, amount: String = "CHF:44") { val reserve_pub = EddsaPublicKey.randEdsaKey() registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), - genInPay("test with $reserve_pub reserve pub", amount).run { - if (nullId) { - this//copy(bankId = null) - } else { - this - } - } + genInPay("test with $reserve_pub reserve pub", amount) ) } +/** Register an incomplete talerable reserve incoming transaction */ +suspend fun talerableIncompleteIn(db: Database) { + val reserve_pub = EddsaPublicKey.randEdsaKey() + val incomplete = genInPay("test with $reserve_pub reserve pub").copy(subject = null, debtorPayto = null) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete) +} + +/** Register a completed talerable reserve incoming transaction */ +suspend fun talerableCompletedIn(db: Database) { + val reserve_pub = EddsaPublicKey.randEdsaKey() + val original = genInPay("test with $reserve_pub reserve pub") + val incomplete = original.copy(subject = null, debtorPayto = null) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), original) +} + /** Register a talerable KYC incoming transaction */ -suspend fun talerableKycIn(db: Database, nullId: Boolean = false, amount: String = "CHF:44") { +suspend fun talerableKycIn(db: Database, amount: String = "CHF:44") { val account_pub = EddsaPublicKey.randEdsaKey() registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), - genInPay("test with KYC:$account_pub account pub", amount).run { - if (nullId) { - this//copy(bankId = null) - } else { - this - } - } + genInPay("test with KYC:$account_pub account pub", amount) ) } @@ -198,6 +202,20 @@ suspend fun registerIn(db: Database) { registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), genInPay("ignored")) } +/** Register an incomplete incoming transaction */ +suspend fun registerIncompleteIn(db: Database) { + val incomplete = genInPay("ignored").copy(subject = null, debtorPayto = null) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete) +} + +/** Register a completed incoming transaction */ +suspend fun registerCompletedIn(db: Database) { + val original = genInPay("ignored") + val incomplete = original.copy(subject = null, debtorPayto = null) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), original) +} + /** Register an outgoing transaction */ suspend fun registerOut(db: Database) { registerOutgoingPayment(db, genOutPay("ignored"))