/* * This file is part of LibEuFin. * Copyright (C) 2024 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 * published by the Free Software Foundation; either version 3, or * (at your option) any later version. * LibEuFin is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General * Public License for more details. * You should have received a copy of the GNU Affero General Public * License along with LibEuFin; see the file COPYING. If not, see * */ package tech.libeufin.nexus import tech.libeufin.common.* import java.io.InputStream import java.net.URLEncoder import java.time.* import java.time.format.* /** * Collects details to define the pain.001 namespace * XML attributes. */ data class Pain001Namespaces( val fullNamespace: String, val xsdFilename: String ) /** * Gets the amount number, also converting it from the * Taler-friendly 8 fractional digits to the more bank * friendly with 2. * * @param amount the Taler amount where to extract the number * @return [String] of the amount number without the currency. */ fun getAmountNoCurrency(amount: TalerAmount): String { if (amount.frac == 0) { return amount.value.toString() } else { val fractionFormat = amount.frac.toString().padStart(8, '0').dropLastWhile { it == '0' } if (fractionFormat.length > 2) throw Exception("Sub-cent amounts not supported") return "${amount.value}.${fractionFormat}" } } /** * Create a pain.001 document. It requires the debtor BIC. * * @param requestUid UID of this request, helps to make this request idempotent. * @param initiationTimestamp timestamp when the payment was initiated in the database. * Although this is NOT the pain.001 creation timestamp, it * will help making idempotent requests where one MsgId is * always associated with one, and only one creation timestamp. * @param debtorAccount [IbanPayto] bank account information of the EBICS subscriber that * sends this request. It's expected to contain IBAN, BIC, and NAME. * @param amount amount to pay. The caller is responsible for sanity-checking this * value to match the bank expectation. For example, that the decimal * part formats always to at most two digits. * @param wireTransferSubject wire transfer subject. * @param creditAccount payment receiver in [IbanPayto]. It should contain IBAN and NAME. * @return raw pain.001 XML, or throws if the debtor BIC is not found. */ fun createPain001( requestUid: String, initiationTimestamp: Instant, debitAccount: IbanAccountMetadata, amount: TalerAmount, wireTransferSubject: String, creditAccount: IbanAccountMetadata ): ByteArray { val namespace = Pain001Namespaces( fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", xsdFilename = "pain.001.001.09.ch.03.xsd" ) val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) val amountWithoutCurrency: String = getAmountNoCurrency(amount) return XmlBuilder.toBytes("Document") { attr("xmlns", namespace.fullNamespace) attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") attr("xsi:schemaLocation", "${namespace.fullNamespace} ${namespace.xsdFilename}") el("CstmrCdtTrfInitn") { el("GrpHdr") { el("MsgId", requestUid) el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp)) el("NbOfTxs", "1") el("CtrlSum", amountWithoutCurrency) el("InitgPty/Nm", debitAccount.name) } el("PmtInf") { el("PmtInfId", "NOTPROVIDED") el("PmtMtd", "TRF") el("BtchBookg", "false") el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) el("Dbtr/Nm", debitAccount.name) el("DbtrAcct/Id/IBAN", debitAccount.iban) if (debitAccount.bic != null) el("DbtrAgt/FinInstnId/BICFI", debitAccount.bic) el("CdtTrfTxInf") { el("PmtId") { el("InstrId", "NOTPROVIDED") el("EndToEndId", "NOTPROVIDED") } el("Amt/InstdAmt") { attr("Ccy", amount.currency) text(amountWithoutCurrency) } el("Cdtr/Nm", creditAccount.name) // TODO write credit account bic if we have it el("CdtrAcct/Id/IBAN", creditAccount.iban) el("RmtInf/Ustrd", wireTransferSubject) } } } } } data class CustomerAck( val actionType: HacAction, val orderId: String?, val code: ExternalStatusReasonCode?, val timestamp: Instant ) { fun msg(): String { var str = "${actionType}" if (code != null) str += " ${code.isoCode}" str += " - '${actionType.description}'" if (code != null) str += " '${code.description}'" return str } override fun toString(): String { var str = "${timestamp.fmtDateTime()}" if (orderId != null) str += " ${orderId}" return str + " ${msg()}" } } /** Parse HAC pain.002 XML file */ fun parseCustomerAck(xml: InputStream): List { return destructXml(xml, "Document") { one("CstmrPmtStsRpt").map("OrgnlPmtInfAndSts") { val actionType = one("OrgnlPmtInfId").enum() one("StsRsnInf") { var timestamp: Instant? = null var orderId: String? = null one("Orgtr").one("Id").one("OrgId").each("Othr") { val value = one("Id") val key = one("SchmeNm").one("Prtry").text() when (key) { "TimeStamp" -> { timestamp = value.dateTime().toInstant(ZoneOffset.UTC) } "OrderID" -> orderId = value.text() // TODO extract ids ? } } val code = opt("Rsn")?.one("Cd")?.enum() CustomerAck(actionType, orderId, code, timestamp!!) } } } } data class PaymentStatus( val msgId: String, val paymentId: String?, val txId: String?, val paymentCode: ExternalPaymentGroupStatusCode, val txCode: ExternalPaymentTransactionStatusCode?, val reasons: List ) { fun id(): String { var str = "$msgId" if (paymentId != null) str += ".$paymentId" if (txId != null) str += ".$txId" return str } fun code(): String = txCode?.isoCode ?: paymentCode.isoCode fun description(): String = txCode?.description ?: paymentCode.description fun msg(): String { return if (reasons.isEmpty()) { "${code()} '${description()}'" } else if (reasons.size == 1) { "${code()} ${reasons[0].code.isoCode} - '${description()}' '${reasons[0].code.description}'" } else { var str = "${code()} '${description()}' - " for (reason in reasons) { str += "${reason.code.isoCode} '${reason.code.description}' " } str } } override fun toString(): String = "${id()} ${msg()}" } data class Reason ( val code: ExternalStatusReasonCode, val information: String ) /** Parse pain.002 XML file */ fun parseCustomerPaymentStatusReport(xml: InputStream): PaymentStatus { fun XmlDestructor.reasons(): List { return map("StsRsnInf") { val code = one("Rsn").one("Cd").enum() // TODO parse information Reason(code, "") } } return destructXml(xml, "Document") { // TODO handle batch status one("CstmrPmtStsRpt") { val (msgId, msgCode, msgReasons) = one("OrgnlGrpInfAndSts") { val id = one("OrgnlMsgId").text() val code = opt("GrpSts")?.enum() val reasons = reasons() Triple(id, code, reasons) } opt("OrgnlPmtInfAndSts") { val payId = one("OrgnlPmtInfId").text() val payCode = one("PmtInfSts").enum() val payReasons = reasons() opt("TxInfAndSts") { val txId = one("OrgnlInstrId").text() val txCode = one("TxSts").enum() val txReasons = reasons() PaymentStatus(msgId, payId, txId, payCode, txCode, txReasons) } ?: PaymentStatus(msgId, payId, null, payCode, null, payReasons) } ?: PaymentStatus(msgId, null, null, msgCode!!, null, msgReasons) } } } sealed interface TxNotification { data class Incoming(val payment: IncomingPayment): TxNotification data class Outgoing(val payment: OutgoingPayment): TxNotification data class Reversal( val msgId: String, val reason: String? ): TxNotification } /** ISO20022 incoming payment */ data class IncomingPayment( val amount: TalerAmount, val wireTransferSubject: String, val debitPaytoUri: String, val executionTime: Instant, /** ISO20022 AccountServicerReference */ val bankId: String ) { override fun toString(): String { return "IN ${executionTime.fmtDate()} $amount '$bankId' debitor=$debitPaytoUri subject=$wireTransferSubject" } } /** ISO20022 outgoing payment */ data class OutgoingPayment( val amount: TalerAmount, val executionTime: Instant, /** ISO20022 MessageIdentification */ val messageId: String, val creditPaytoUri: String? = null, // not showing in camt.054 val wireTransferSubject: String? = null // not showing in camt.054 ) { override fun toString(): String { return "OUT ${executionTime.fmtDate()} $amount '$messageId' creditor=$creditPaytoUri subject=$wireTransferSubject" } } /** Parse camt.054 XML file */ fun parseTxNotif( notifXml: InputStream, acceptedCurrency: String ): List { fun notificationForEachTx( directionLambda: XmlDestructor.(Instant, Boolean, String?) -> Unit ) { destructXml(notifXml, "Document") { opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { each("Ntry") { val reversal = opt("RvslInd")?.bool() ?: false val info = opt("AddtlNtryInf")?.text() one("Sts") { if (text() != "BOOK") { one("Cd") { if (text() != "BOOK") throw Exception("Found non booked transaction, " + "stop parsing. Status was: ${text()}" ) } } } val bookDate: Instant = one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC) one("NtryDtls").each("TxDtls") { directionLambda(this, bookDate, reversal, info) } } } } } val notifications = mutableListOf() notificationForEachTx { bookDate, reversal, info -> val kind = one("CdtDbtInd").text() val amount: TalerAmount = one("Amt") { val currency = attr("Ccy") /** * FIXME: test by sending non-CHF to PoFi and see which currency gets here. */ if (currency != acceptedCurrency) throw Exception("Currency $currency not supported") TalerAmount("$currency:${text()}") } if (reversal) { require("CRDT" == kind) val msgId = one("Refs").opt("MsgId")?.text() if (msgId == null) { logger.debug("Unsupported reversal without message id") } else { notifications.add(TxNotification.Reversal( msgId = msgId, reason = info )) } return@notificationForEachTx } when (kind) { "CRDT" -> { val bankId: String = one("Refs").one("AcctSvcrRef").text() // Obtaining payment subject. val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") if (subject == null) { logger.debug("Skip notification '$bankId', missing subject") return@notificationForEachTx } // Obtaining the payer's details val debtorPayto = StringBuilder("payto://iban/") one("RltdPties") { one("DbtrAcct").one("Id").one("IBAN") { debtorPayto.append(text()) } // warn: it might need the postal address too.. one("Dbtr").opt("Pty")?.one("Nm") { val urlEncName = URLEncoder.encode(text(), "utf-8") debtorPayto.append("?receiver-name=$urlEncName") } } notifications.add(TxNotification.Incoming( IncomingPayment( amount = amount, bankId = bankId, debitPaytoUri = debtorPayto.toString(), executionTime = bookDate, wireTransferSubject = subject.toString() ) )) } "DBIT" -> { val messageId = one("Refs").one("MsgId").text() notifications.add(TxNotification.Outgoing( OutgoingPayment( amount = amount, messageId = messageId, executionTime = bookDate ) )) } else -> throw Exception("Unknown transaction notification kind '$kind'") } } return notifications }