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 }