libeufin

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

commit 0312a6ef550f6ca3a5c615f573e86f16ecd05bf7
parent 99b1c08ebed2acd2efd0f907a5adcf57900bd075
Author: Antoine A <>
Date:   Mon, 16 Jun 2025 17:18:00 +0200

nexus: add support for instant debit

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt | 26+++++++++++++++++++++++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt | 31++++++++++++++++++++++---------
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt | 51++++++++++++++++++++++++++++++++-------------------
6 files changed, 80 insertions(+), 34 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -440,7 +440,7 @@ class EbicsFetch: CliktCommand() { val docs = if (documents.isEmpty()) OrderDoc.entries else documents.toList() // EBICS order than should be fetched - val selectedOrder = docs.map { cfg.ebics.dialect.downloadDoc(it, false) } + val selectedOrder = docs.flatMap { cfg.ebics.dialect.downloadDoc(it, false) } // Try to obtain real-time notification channel if not transient val wssNotification = if (transient) { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt @@ -306,16 +306,36 @@ class EbicsSetup: CliktCommand() { logger.error("Expected IBAN ${cfg.ebics.account.iban} from config got $ibans from bank") } - val requireOrders = cfg.ebics.dialect.orders() + val instantDebitOrder = cfg.ebics.dialect.instantDirectDebit() + val debitOrder = cfg.ebics.dialect.directDebit() + val requireOrders = cfg.ebics.dialect.downloadOrders() + + val partnerOrders = partner.orders.map { it.order } + + // Check partner support for direct debit orders + if (instantDebitOrder != null && instantDebitOrder !in partnerOrders) { + logger.warn("Unsupported instant debit order: ${instantDebitOrder.description()}") + } + if (debitOrder !in partnerOrders) { + logger.warn("Unsupported debit order: ${debitOrder.description()}") + } // Check partner support required orders - val unsupportedOrder = requireOrders subtract partner.orders.map { it.order } + val unsupportedOrder = requireOrders subtract partnerOrders if (unsupportedOrder.isNotEmpty()) { logger.warn("Unsupported orders: {}", unsupportedOrder.map(EbicsOrder::description).joinToString(" ")) } - // Check user is authorized for required orders if (user != null) { + // Check user is authorized for direct debit orders + if (instantDebitOrder != null && instantDebitOrder in partnerOrders && instantDebitOrder !in user.permissions) { + logger.warn("Unauthorized instant debit order: ${instantDebitOrder.description()}") + } + if (debitOrder in partnerOrders && debitOrder !in user.permissions) { + logger.warn("Unauthorized debit order: ${debitOrder.description()}") + } + + // Check user is authorized for required orders val unauthorizedOrders = requireOrders subtract user.permissions subtract unsupportedOrder if (unauthorizedOrders.isNotEmpty()) { logger.warn("Unauthorized orders: {}", unauthorizedOrders.map(EbicsOrder::description).joinToString(" ")) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt @@ -62,6 +62,7 @@ fun batchToPain001Msg(account: IbanAccountMetadata, batch: PaymentBatch): Pain00 */ private suspend fun submitBatch( client: EbicsClient, + order: EbicsOrder, batch: PaymentBatch ): String { val ebicsCfg = client.cfg.ebics @@ -70,20 +71,32 @@ private suspend fun submitBatch( msg = msg, dialect = ebicsCfg.dialect ) - return client.upload( - ebicsCfg.dialect.directDebit(), - xml - ) + return client.upload(order, xml) } /** Submit all pending initiated payments using [client] */ -private suspend fun submitBatch(client: EbicsClient) { +private suspend fun submitAll(client: EbicsClient) { + // Find a supported debit order + var instantDebitOrder = client.cfg.ebics.dialect.instantDirectDebit() + val debitOrder = client.cfg.ebics.dialect.directDebit() + // Create batch if necessary client.db.initiated.batch(Instant.now(), randEbicsId()) - // Send submitable batches + // Send submittable batches client.db.initiated.submittable().forEach { batch -> logger.debug("Submitting batch {}", batch.messageId) - runCatching { submitBatch(client, batch) }.fold( + runCatching { + if (instantDebitOrder != null) { + try { + return@runCatching submitBatch(client, instantDebitOrder!!, batch) + } catch (e: EbicsError.Code) { + // No longer try to submit using the instant method for now + logger.debug("Failed to submit using instant credit order ${e.fmt()}") + instantDebitOrder = null + } + } + submitBatch(client, debitOrder, batch) + }.fold( onSuccess = { orderId -> client.db.initiated.batchSubmissionSuccess(batch.id, Instant.now(), orderId) val transactions = batch.payments.map { it.endToEndId }.joinToString(",") @@ -119,12 +132,12 @@ class EbicsSubmit : CliktCommand() { if (transient) { logger.info("Transient mode: submitting what found and returning.") - submitBatch(client) + submitAll(client) } else { logger.debug("Running with a frequency of ${cfg.submit.frequencyRaw}") while (true) { try { - submitBatch(client) + submitAll(client) } catch (e: Exception) { throw Exception("Failed to submit payments", e) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -120,7 +120,7 @@ class TxCheck: CliktCommand() { val (clientKeys, bankKeys) = expectFullKeys(cfg) val order = cfg.dialect.downloadDoc(OrderDoc.acknowledgement, false) val client = httpClient() - val result = tech.libeufin.nexus.test.txCheck(client, cfg, clientKeys, bankKeys, order, cfg.dialect.directDebit()) + val result = tech.libeufin.nexus.test.txCheck(client, cfg, clientKeys, bankKeys, order[0], cfg.dialect.directDebit()) println("$result") } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt @@ -81,7 +81,7 @@ object EbicsAdministrative { EbicsReturnCode.lookup(one("ReturnCode").text()) } val versions = map("VersionNumber") { - VersionNumber(text().toFloat(), attr("ProtocolVersion")!!) + VersionNumber(text().toFloat(), attr("ProtocolVersion")) } EbicsResponse( technicalCode = technicalCode, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt @@ -152,34 +152,39 @@ enum class Dialect { gls, maerki_baumann; - fun downloadDoc(doc: OrderDoc, ebics2: Boolean): EbicsOrder { + fun downloadDoc(doc: OrderDoc, ebics2: Boolean): List<EbicsOrder> { return when (this) { postfinance -> { if (ebics2) { when (doc) { - OrderDoc.acknowledgement -> EbicsOrder.V2_5("HAC", "DZHNN") - OrderDoc.status -> EbicsOrder.V2_5("Z01", "DZHNN") - OrderDoc.report -> EbicsOrder.V2_5("Z52", "DZHNN") - OrderDoc.statement -> EbicsOrder.V2_5("Z53", "DZHNN") - OrderDoc.notification -> EbicsOrder.V2_5("Z54", "DZHNN") + OrderDoc.acknowledgement -> listOf(EbicsOrder.V2_5("HAC", "DZHNN")) + OrderDoc.status -> listOf(EbicsOrder.V2_5("Z01", "DZHNN")) + OrderDoc.report -> listOf(EbicsOrder.V2_5("Z52", "DZHNN")) + OrderDoc.statement -> listOf(EbicsOrder.V2_5("Z53", "DZHNN")) + OrderDoc.notification -> listOf(EbicsOrder.V2_5("Z54", "DZHNN")) } } else { when (doc) { - OrderDoc.acknowledgement -> EbicsOrder.V3.HAC - OrderDoc.status -> EbicsOrder.V3("BTD", "PSR", "CH", "pain.002", "10", "ZIP") - OrderDoc.report -> EbicsOrder.V3("BTD", "STM", "CH", "camt.052", "08", "ZIP") - OrderDoc.statement -> EbicsOrder.V3("BTD", "EOP", "CH", "camt.053", "08", "ZIP") - OrderDoc.notification -> EbicsOrder.V3("BTD", "REP", "CH", "camt.054", "08", "ZIP") + OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC) + OrderDoc.status -> listOf(EbicsOrder.V3("BTD", "PSR", "CH", "pain.002", "10", "ZIP")) + OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "CH", "camt.052", "08", "ZIP")) + OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "CH", "camt.053", "08", "ZIP")) + OrderDoc.notification -> listOf(EbicsOrder.V3("BTD", "REP", "CH", "camt.054", "08", "ZIP")) } } } - // TODO for GLS we might have to fetch the same kind of files from multiple orders gls -> when (doc) { - OrderDoc.acknowledgement -> EbicsOrder.V3.HAC - OrderDoc.status -> EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT") - OrderDoc.report -> EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP") - OrderDoc.statement -> EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP") - OrderDoc.notification -> EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI") + OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC) + OrderDoc.status -> listOf( + EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCI"), + EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT") + ) + OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP")) + OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP")) + OrderDoc.notification -> listOf( + EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP"), + EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI") + ) } maerki_baumann -> throw IllegalArgumentException("Maerki Baumann does not have EBICS access") } @@ -193,11 +198,19 @@ enum class Dialect { } } + fun instantDirectDebit(): EbicsOrder? { + return when (this) { + postfinance -> null + gls -> EbicsOrder.V3("BTU", "SCI", null, "pain.001") + maerki_baumann -> throw IllegalArgumentException("Maerki Baumann does not have EBICS access") + } + } + /** All orders required for a dialect implementation to work */ - fun orders(): Set<EbicsOrder> = ( + fun downloadOrders(): Set<EbicsOrder> = ( // Administrative orders sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD) // and documents orders - + OrderDoc.entries.map { downloadDoc(it, false) } + + OrderDoc.entries.flatMap { downloadDoc(it, false) } ).toSet() } \ No newline at end of file