libeufin

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

EbicsSubmit.kt (5977B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 package tech.libeufin.nexus.cli
     21 
     22 import com.github.ajalt.clikt.core.CliktCommand
     23 import com.github.ajalt.clikt.core.Context
     24 import com.github.ajalt.clikt.parameters.groups.provideDelegate
     25 import kotlinx.coroutines.delay
     26 import tech.libeufin.common.*
     27 import tech.libeufin.ebics.*
     28 import tech.libeufin.nexus.*
     29 import tech.libeufin.nexus.db.*
     30 import tech.libeufin.nexus.iso20022.*
     31 import java.time.Instant
     32 import kotlin.time.toKotlinDuration
     33 
     34 fun batchToPain001Msg(account: IbanAccountMetadata, batch: PaymentBatch): Pain001Msg {
     35     return Pain001Msg(
     36         messageId = batch.messageId,
     37         timestamp = batch.creationDate,
     38         debtor = account,
     39         sum = batch.sum,
     40         txs = batch.payments.map { payment ->
     41             val payto = payment.creditor
     42             if (payto.receiverName == null) {
     43                 logger.warn("Missing receiver-name for payto $payto")
     44             }
     45             Pain001Tx(
     46                 creditor = IbanAccountMetadata(
     47                     iban = payto.iban,
     48                     bic = payto.bic,
     49                     name = payto.receiverName ?: "Unknown"
     50                 ),
     51                 amount = payment.amount,
     52                 subject = payment.subject,
     53                 endToEndId = payment.endToEndId
     54             )
     55         }
     56     )
     57 }
     58 
     59 /** 
     60  * Submit an initiated payments [batch] using [client].
     61  * 
     62  * Parse creditor IBAN account metadata then perform an EBICS direct credit
     63  * 
     64  * Returns the orderID
     65  */
     66 private suspend fun submitBatch(
     67     client: EbicsClient,
     68     order: EbicsOrder,
     69     batch: PaymentBatch,
     70     cfg: NexusConfig,
     71     instant: Boolean,
     72 ): String {
     73     val ebicsCfg = cfg.ebics
     74     val msg = batchToPain001Msg(ebicsCfg.account, batch)
     75     val xml = createPain001(
     76         msg = msg,
     77         dialect = ebicsCfg.dialect,
     78         instant = instant
     79     )
     80     return client.upload(order, xml)
     81 }
     82 
     83 /** Submit all pending initiated payments using [client] */
     84 private suspend fun submitAll(client: EbicsClient, requireAck: Boolean, cfg: NexusConfig, db: Database) {
     85     // Find a supported debit order
     86     var instantDebitOrder = cfg.ebics.dialect.instantDirectDebit()
     87     val debitOrder = cfg.ebics.dialect.directDebit()
     88     
     89     // Create batch if necessary
     90     db.initiated.batch(Instant.now(), randEbicsId(), requireAck)
     91     // Send submittable batches
     92     db.initiated.submittable().forEach { batch ->
     93         logger.debug("Submitting batch {}", batch.messageId)
     94         runCatching {
     95             if (instantDebitOrder != null) {
     96                 try {
     97                     return@runCatching submitBatch(client, instantDebitOrder!!, batch, cfg, true)
     98                 } catch (e: EbicsError.Code) {
     99                     // No longer try to submit using the instant method for now
    100                     logger.debug("Failed to submit using instant credit order ${e.fmt()}")
    101                     instantDebitOrder = null
    102                 }
    103             }
    104             submitBatch(client, debitOrder, batch, cfg, false)
    105         }.fold(
    106             onSuccess = { orderId -> 
    107                 db.initiated.batchSubmissionSuccess(batch.id, Instant.now(), orderId)
    108                 val transactions = batch.payments.joinToString(",") { it.endToEndId }
    109                 if (instantDebitOrder == null) {
    110                     logger.info("Batch ${batch.messageId} submitted as order $orderId: $transactions")
    111                 } else {
    112                     logger.info("Instant batch ${batch.messageId} submitted as order $orderId: $transactions")
    113                 }  
    114             },
    115             onFailure = { e ->
    116                 db.initiated.batchSubmissionFailure(batch.id, Instant.now(), e.message)
    117                 logger.error("Batch ${batch.messageId} submission failure: ${e.fmt()}")
    118                 throw e
    119             }
    120         )
    121     }
    122 }
    123 
    124 class EbicsSubmit : EbicsCmd() {
    125     override fun help(context: Context) = "Submits pending initiated payments found in the database"
    126 
    127     override fun run() = cliCmd(logger) {
    128         nexusConfig(config).withDb { db, cfg ->
    129             val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics)
    130             val client = EbicsClient(
    131                 cfg.ebics.host,
    132                 httpClient(),
    133                 db.ebics,
    134                 EbicsLogger(ebicsLog),
    135                 clientKeys,
    136                 bankKeys
    137             )
    138 
    139             if (transient) {
    140                 logger.info("Transient mode: submitting what found and returning.")
    141                 submitAll(client, cfg.submit.requireAck, cfg, db)
    142             } else {
    143                 logger.debug("Running with a frequency of ${cfg.submit.frequencyRaw}")
    144                 while (true) {
    145                     val now = Instant.now();
    146                     val success = try {
    147                         submitAll(client, cfg.submit.requireAck, cfg, db)
    148                         true
    149                     } catch (e: Exception) {
    150                         e.fmtLog(logger)
    151                         false
    152                     }
    153                     db.kv.updateTaskStatus(SUBMIT_TASK_KEY, now, success)
    154                     // TODO take submitBatch taken time in the delay
    155                     delay(cfg.submit.frequency.toKotlinDuration())
    156                 }
    157             }
    158         }
    159     }
    160 }