pain001.kt (5610B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2024-2025 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 package tech.libeufin.nexus.iso20022 20 21 import tech.libeufin.common.* 22 import tech.libeufin.nexus.* 23 import tech.libeufin.ebics.XmlBuilder 24 import java.time.Instant 25 import java.time.ZoneId 26 import java.time.ZonedDateTime 27 import java.time.format.DateTimeFormatter 28 29 /** String representation of a Taler [amount] compatible with EBICS */ 30 fun getAmountNoCurrency(amount: TalerAmount): String { 31 if (amount.isSubCent()) { 32 throw Exception("Sub-cent amounts not supported") 33 } 34 return amount.number().toString() 35 } 36 37 /** pain.001 transaction metadata */ 38 data class Pain001Tx( 39 val creditor: IbanAccountMetadata, 40 val amount: TalerAmount, 41 val subject: String, 42 val endToEndId: String 43 ) 44 45 /** pain.001 message metadata */ 46 data class Pain001Msg( 47 val messageId: String, 48 val timestamp: Instant, 49 val debtor: IbanAccountMetadata, 50 val sum: TalerAmount, 51 val txs: List<Pain001Tx> 52 ) 53 54 /** Create a pain.001 XML document [msg] valid for [dialect] */ 55 fun createPain001( 56 msg: Pain001Msg, 57 dialect: Dialect, 58 instant: Boolean 59 ): ByteArray { 60 val version = "09" 61 val suffix = when (dialect.standard()) { 62 Standard.SIX -> ".ch.03" 63 Standard.GBIC -> "" 64 } 65 val zonedTimestamp = ZonedDateTime.ofInstant(msg.timestamp, ZoneId.of("UTC")) 66 val totalAmount = getAmountNoCurrency(msg.sum) 67 return XmlBuilder.toBytes("Document") { 68 attr("xmlns", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version") 69 attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") 70 attr("xsi:schemaLocation", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version pain.001.001.$version$suffix.xsd") 71 el("CstmrCdtTrfInitn") { 72 el("GrpHdr") { 73 // Used for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre-agreed period 74 // Used to uniquely identify batches of transactions in other files 75 el("MsgId", msg.messageId) 76 el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp)) 77 el("NbOfTxs", msg.txs.size.toString()) 78 el("CtrlSum", totalAmount) 79 el("InitgPty/Nm", msg.debtor.name) 80 /* TODO fail with GLS: ES_VERIFICATION IncorrectFileStructure - 'Signature verification' 'The file format is incomplete or invalid' 81 el("InitnSrc") { 82 el("Nm", "LibEuFin") 83 el("Prvdr", "Taler Systems SA") 84 el("Vrsn", VERSION) 85 }*/ 86 87 // LocalInstrument 88 } 89 el("PmtInf") { 90 el("PmtInfId", "NOTPROVIDED") 91 el("PmtMtd", "TRF") 92 el("BtchBookg", "false") 93 el("NbOfTxs", msg.txs.size.toString()) 94 el("CtrlSum", totalAmount) 95 if (dialect == Dialect.gls) { 96 el("PmtTpInf") { 97 el("SvcLvl/Cd", "SEPA") 98 if (instant) { 99 el("LclInstrm/Cd", "INST") 100 } 101 } 102 } 103 el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) 104 el("Dbtr/Nm", msg.debtor.name) 105 el("DbtrAcct/Id/IBAN", msg.debtor.iban.toString()) 106 el("DbtrAgt/FinInstnId") { 107 if (msg.debtor.bic != null) { 108 el("BICFI", msg.debtor.bic) 109 } else { 110 el("Othr/Id", "NOTPROVIDED") 111 } 112 } 113 el("ChrgBr", "SLEV") 114 for (tx in msg.txs) { 115 el("CdtTrfTxInf") { 116 el("PmtId") { 117 el("InstrId", tx.endToEndId) 118 // Used to uniquely identify transactions in other files 119 el("EndToEndId", tx.endToEndId) 120 } 121 el("Amt/InstdAmt") { 122 attr("Ccy", tx.amount.currency) 123 text(getAmountNoCurrency(tx.amount)) 124 } 125 if (tx.creditor.bic != null) el("CdtrAgt/FinInstnId/BICFI", tx.creditor.bic) 126 el("Cdtr") { 127 el("Nm", tx.creditor.name) 128 // Addr might become a requirement in the future 129 /*el("PstlAdr") { 130 el("TwnNm", "Bochum") 131 el("Ctry", "DE") 132 }*/ 133 } 134 el("CdtrAcct/Id/IBAN", tx.creditor.iban.toString()) 135 el("RmtInf/Ustrd", tx.subject) 136 } 137 } 138 } 139 } 140 } 141 }