commit 0312a6ef550f6ca3a5c615f573e86f16ecd05bf7
parent 99b1c08ebed2acd2efd0f907a5adcf57900bd075
Author: Antoine A <>
Date: Mon, 16 Jun 2025 17:18:00 +0200
nexus: add support for instant debit
Diffstat:
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