pain001.rs (8683B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 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 use jiff::{Timestamp, Zoned, tz::TimeZone}; 21 use taler_common::types::{ 22 amount::{Amount, Decimal}, 23 payto::FullIbanPayto, 24 }; 25 26 use crate::{ 27 dialect::{Dialect, Standard}, 28 ebics::EbicsErrKind, 29 xml, 30 xml::XmlWriter, 31 }; 32 33 /** pain.001 transaction metadata */ 34 pub struct Pain001Tx<'a> { 35 pub creditor: &'a FullIbanPayto, 36 pub amount: Amount, 37 pub subject: &'a str, 38 pub e2e_id: &'a str, 39 } 40 41 /** pain.001 message metadata */ 42 pub struct Pain001Msg<'a> { 43 pub msg_id: &'a str, 44 pub timestamp: &'a Timestamp, 45 pub debtor: &'a FullIbanPayto, 46 pub sum: Amount, 47 pub txs: Vec<Pain001Tx<'a>>, 48 } 49 50 /** Check EBICS compatibility of an amount */ 51 fn ebics_amount(amount: &Amount) -> Result<Decimal, EbicsErrKind> { 52 if amount.is_sub_cent() { 53 return Err(EbicsErrKind::Custom( 54 "Sub-cent amounts not supported".into(), 55 )); 56 } 57 Ok(amount.decimal()) 58 } 59 60 /** Create a pain.001 XML document [msg] valid for [dialect] */ 61 pub fn create_pain001( 62 msg: &Pain001Msg, 63 dialect: &Dialect, 64 instant: bool, 65 ) -> Result<String, EbicsErrKind> { 66 let version = "09"; 67 let suffix = match dialect.standard() { 68 Standard::SIX => ".ch.03", 69 Standard::GBIC => "", 70 }; 71 let total = ebics_amount(&msg.sum)?; 72 Ok(xml!( 73 "Document" 74 "xmlns"=(format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version}")) 75 "xmlns:xsi"=(format_args!("http://www.w3.org/2001/XMLSchema-instance")) 76 "xsi:schemaLocation"=(format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version} pain.001.001.{version}{suffix}.xsd")) 77 { 78 "CstmrCdtTrfInitn" { 79 "GrpHdr" { 80 // Used for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre-agreed period 81 // Used to uniquely identify batches of transactions in other files 82 "MsgId": msg.msg_id, 83 "CreDtTm": msg.timestamp, 84 "NbOfTxs": msg.txs.len(), 85 "CtrlSum": total, 86 "InitgPty" { 87 "Nm": msg.debtor.name 88 }/* 89 // TODO fail with GLS: ES_VERIFICATION IncorrectFileStructure - 'Signature verification' 'The file format is incomplete or invalid' 90 "InitnSrc" { 91 "Nm": "LibEuFin", 92 "Prvdr": "Taler Systems SA", 93 "Vrsn": taler_build::long_version() 94 }*/ 95 }, 96 "PmtInf" { 97 "PmtInfId": "NOTPROVIDED", 98 "PmtMtd": "TRF", 99 "BtchBookg": "false", 100 "NbOfTxs": msg.txs.len(), 101 "CtrlSum": total, 102 @ |w: &mut XmlWriter| if dialect.standard() == Standard::GBIC { 103 xml!(w => "PmtTpInf" { 104 "SvcLvl" { 105 "Cd": "SEPA" 106 }, 107 @ |w: &mut XmlWriter| if instant { 108 xml!(w => "LclInstrm" { 109 "Cd": "INST" 110 }) 111 } 112 }) 113 }, 114 "ReqdExctnDt" { 115 "Dt": Zoned::new(*msg.timestamp, TimeZone::UTC).date().to_string() + "Z" 116 }, 117 "Dbtr" { 118 "Nm": msg.debtor.name 119 }, 120 "DbtrAcct" { 121 "Id" { 122 "IBAN": msg.debtor.iban 123 } 124 }, 125 "DbtrAgt" { 126 "FinInstnId" { 127 @ |w: &mut XmlWriter| if let Some(bic) = &msg.debtor.bic { 128 xml!(w => "BICFI": bic) 129 } else { 130 xml!(w => "Othr" { 131 "Id": "NOTPROVIDED" 132 }) 133 } 134 } 135 136 }, 137 "ChrgBr": "SLEV", 138 @ |w: &mut XmlWriter| for tx in &msg.txs { 139 xml!(w => "CdtTrfTxInf" { 140 "PmtId" { 141 "InstrId": tx.e2e_id, 142 // Used to uniquely identify transactions in other files 143 "EndToEndId": tx.e2e_id 144 }, 145 "Amt" { 146 "InstdAmt" "Ccy"=(tx.amount.currency) : ebics_amount(&tx.amount).unwrap() 147 }, 148 @ |w: &mut XmlWriter| if let Some(bic) = &tx.creditor.bic { 149 xml!(w => "CdtrAgt" { 150 "FinInstnId" { 151 "BICFI": bic 152 } 153 }) 154 }, 155 "Cdtr" { 156 "Nm": tx.creditor.name 157 // Addr might become a requirement in the future 158 /*"PstlAdr" { 159 "TwnNm": "Bochum", 160 "Ctry": "DE" 161 }*/ 162 }, 163 "CdtrAcct" { 164 "Id" { 165 "IBAN": tx.creditor.iban 166 } 167 }, 168 "RmtInf" { 169 "Ustrd": tx.subject 170 } 171 }) 172 } 173 } 174 } 175 } 176 )) 177 } 178 179 #[cfg(test)] 180 mod test { 181 use jiff::civil::Date; 182 use taler_common::types::{ 183 amount::amount, 184 payto::{BankID, FullIbanPayto}, 185 utils::date_to_utc_ts, 186 }; 187 188 use crate::{ 189 dialect::Dialect, 190 iso20022::pain001::{Pain001Msg, Pain001Tx, create_pain001}, 191 }; 192 193 #[test] 194 fn pain001() { 195 let creditor = &FullIbanPayto::new( 196 BankID { 197 iban: "CH4189144589712575493".parse().expect("invalid IBAN"), 198 bic: None, 199 }, 200 "Test", 201 ); 202 203 let msg = Pain001Msg { 204 msg_id: "MESSAGE_ID", 205 timestamp: &date_to_utc_ts(&Date::constant(2024, 09, 09)), 206 debtor: &FullIbanPayto::new( 207 BankID { 208 iban: "CH7789144474425692816".parse().expect("invalid IBAN"), 209 bic: Some("AAAABBCC123".parse().expect("invalid BIC")), 210 }, 211 "myname", 212 ), 213 sum: amount("CHF:47.32"), 214 txs: vec![ 215 Pain001Tx { 216 creditor, 217 amount: amount("CHF:42"), 218 subject: "Test 42", 219 e2e_id: "TX_FIRST", 220 }, 221 Pain001Tx { 222 creditor, 223 amount: amount("CHF:5.11"), 224 subject: "Test 5.11", 225 e2e_id: "TX_SECOND", 226 }, 227 Pain001Tx { 228 creditor, 229 amount: amount("CHF:0.21"), 230 subject: "Test 0.21", 231 e2e_id: "TX_THIRD", 232 }, 233 ], 234 }; 235 for dialect in Dialect::entries { 236 pretty_assertions::assert_eq!( 237 std::fs::read_to_string(format!("sample/platform/{dialect}_pain001.xml")).unwrap(), 238 create_pain001(&msg, dialect, false).unwrap() 239 ); 240 } 241 } 242 }