pain002.rs (9110B)
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 std::fmt::{Display, Formatter, Write, from_fn}; 21 22 use compact_str::CompactString; 23 24 use crate::{ 25 iso20022::status_code::{PaymentGroupStatus, PaymentTransactionStatus, StatusReason}, 26 xml::{self, Xml, XmlAccess}, 27 }; 28 29 fn fmt_msg( 30 f: &mut Formatter<'_>, 31 code: Option<&str>, 32 description: Option<&str>, 33 reasons: &[Reason], 34 ) -> std::fmt::Result { 35 if let Some(code) = code { 36 write!(f, "{code}")?; 37 if let Some(description) = description { 38 write!(f, " '{description}'")?; 39 } 40 if !reasons.is_empty() { 41 f.write_char(':')?; 42 } 43 } 44 for Reason { 45 code, 46 info: information, 47 } in reasons 48 { 49 if let Some(code) = code { 50 write!(f, " {} '{}'", code.code(), code.description())?; 51 } 52 if !information.is_empty() { 53 write!(f, " '{information}'")?; 54 } 55 } 56 Ok(()) 57 } 58 59 #[derive(Debug, Clone, PartialEq, Eq)] 60 pub struct Reason { 61 pub code: Option<StatusReason>, 62 pub info: Box<str>, 63 } 64 65 #[derive(Debug, Clone, PartialEq, Eq)] 66 pub struct TxStatus { 67 pub id: CompactString, 68 pub e2e_id: CompactString, 69 pub status: PaymentTransactionStatus, 70 pub reasons: Box<[Reason]>, 71 } 72 73 impl TxStatus { 74 fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 75 fmt_msg( 76 f, 77 Some(self.status.code()), 78 Some(self.status.description()), 79 &self.reasons, 80 ) 81 } 82 83 pub fn msg(&self) -> String { 84 format!("{}", from_fn(|f| self.fmt_msg(f))) 85 } 86 } 87 88 #[derive(Debug, Clone, PartialEq, Eq)] 89 pub struct PmtStatus { 90 pub id: CompactString, 91 pub status: Option<PaymentGroupStatus>, 92 pub reasons: Box<[Reason]>, 93 pub txs: Box<[TxStatus]>, 94 } 95 96 impl PmtStatus { 97 fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 98 fmt_msg( 99 f, 100 self.status.map(|it| it.code()), 101 self.status.map(|it| it.description()), 102 &self.reasons, 103 ) 104 } 105 pub fn msg(&self) -> String { 106 format!("{}", from_fn(|f| self.fmt_msg(f))) 107 } 108 } 109 110 #[derive(Debug, Clone, PartialEq, Eq)] 111 pub struct MsgStatus { 112 pub id: CompactString, 113 pub status: Option<PaymentGroupStatus>, 114 pub reasons: Box<[Reason]>, 115 pub payments: Box<[PmtStatus]>, 116 } 117 118 impl MsgStatus { 119 fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 120 fmt_msg( 121 f, 122 self.status.map(|it| it.code()), 123 self.status.map(|it| it.description()), 124 &self.reasons, 125 ) 126 } 127 pub fn msg(&self) -> String { 128 format!("{}", from_fn(|f| self.fmt_msg(f))) 129 } 130 } 131 132 impl Display for MsgStatus { 133 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 134 write!(f, "{} {}", self.id, from_fn(|f| self.fmt_msg(f)))?; 135 for p in &self.payments { 136 write!(f, "\n>{} {}", p.id, from_fn(|f| p.fmt_msg(f)))?; 137 for tx in &p.txs { 138 if tx.id != tx.e2e_id { 139 write!(f, "{} ", tx.id)?; 140 } 141 write!(f, "\n>>{} {}", tx.e2e_id, from_fn(|f| tx.fmt_msg(f)))?; 142 } 143 } 144 Ok(()) 145 } 146 } 147 148 /** Parse pain.002 XML file */ 149 pub fn parse_pain002(xml: &[u8]) -> xml::Result<MsgStatus> { 150 fn reasons(x: Xml) -> xml::Result<Box<[Reason]>> { 151 x.many("StsRsnInf") 152 .map(|n| { 153 let code = n.opt("Rsn").one("Cd").parse()?; 154 let info = n.many("AddtlInf").map(Xml::text).collect(); 155 Ok(Reason { code, info }) 156 }) 157 .collect() 158 } 159 160 Xml::parse(xml, "Document", |root| { 161 let n = root.one("CstmrPmtStsRpt")?; 162 let status = n.one("OrgnlGrpInfAndSts")?; 163 Ok(MsgStatus { 164 id: status.one("OrgnlMsgId").parse()?, 165 status: status.opt("GrpSts").parse()?, 166 reasons: reasons(status)?, 167 payments: n 168 .many("OrgnlPmtInfAndSts") 169 .map(|n| { 170 Ok(PmtStatus { 171 id: n.one("OrgnlPmtInfId").parse()?, 172 status: n.opt("PmtInfSts").parse()?, 173 reasons: reasons(n)?, 174 txs: n 175 .many("TxInfAndSts") 176 .map(|n| { 177 Ok(TxStatus { 178 id: n.one("OrgnlInstrId").parse()?, 179 e2e_id: n.one("OrgnlEndToEndId").parse()?, 180 status: n.one("TxSts").parse()?, 181 reasons: reasons(n)?, 182 }) 183 }) 184 .collect::<xml::Result<_>>()?, 185 }) 186 }) 187 .collect::<xml::Result<_>>()?, 188 }) 189 }) 190 } 191 192 #[cfg(test)] 193 mod test { 194 use crate::iso20022::{ 195 pain002::{MsgStatus, PmtStatus, Reason, TxStatus, parse_pain002}, 196 status_code::{PaymentGroupStatus, PaymentTransactionStatus, StatusReason}, 197 }; 198 199 #[test] 200 fn pain002() { 201 pretty_assertions::assert_eq!( 202 parse_pain002(&std::fs::read("sample/platform/pain002_part.xml").unwrap()).unwrap(), 203 MsgStatus { 204 id: "05BD4C5B4A2649B5B08F6EF6A31F197A".into(), 205 status: Some(PaymentGroupStatus::PartiallyAccepted), 206 reasons: Box::default(), 207 payments: Box::new([PmtStatus { 208 id: "NOTPROVIDED".into(), 209 status: Some(PaymentGroupStatus::PartiallyAccepted), 210 reasons: Box::new([ 211 Reason { 212 code: Some(StatusReason::ExecutionDateChanged), 213 info: "Due date is not a working day. Order will be executed on the next working day".into() 214 } 215 ]), 216 txs: Box::new([ 217 TxStatus { 218 id: "AQCXNCPWD8PHW5JTN65Y5XTF7R".into(), 219 e2e_id: "AQCXNCPWD8PHW5JTN65Y5XTF7R".into(), 220 status: PaymentTransactionStatus::Rejected, 221 reasons: Box::new([ 222 Reason { 223 code: Some(StatusReason::ClosedAccountNumber), 224 info: "Error message".into() 225 } 226 ]) 227 }, 228 TxStatus { 229 id: "EE9SX76FC5YSC657EK3GMVZ9TC".into(), 230 e2e_id: "EE9SX76FC5YSC657EK3GMVZ9TC".into(), 231 status: PaymentTransactionStatus::Rejected, 232 reasons: Box::new([ 233 Reason { 234 code: Some(StatusReason::NotSpecifiedReasonAgentGenerated), 235 info: "Error message".into() 236 } 237 ]) 238 }, 239 TxStatus { 240 id: "V5B3MXPEWES9VQW1JDRD6VAET4".into(), 241 e2e_id: "V5B3MXPEWES9VQW1JDRD6VAET4".into(), 242 status: PaymentTransactionStatus::Rejected, 243 reasons: Box::new([ 244 Reason { 245 code: Some(StatusReason::MissingDebtorNameOrAddress), 246 info: "Error message".into() 247 } 248 ]) 249 } 250 ]) 251 }]) 252 } 253 ); 254 pretty_assertions::assert_eq!( 255 parse_pain002(&std::fs::read("sample/platform/pain002_accp.xml").unwrap()).unwrap(), 256 MsgStatus { 257 id: "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U".into(), 258 status: Some(PaymentGroupStatus::AcceptedCustomerProfile), 259 reasons: Box::new([Reason { 260 code: None, 261 info: "PN10630020F0297329.20251030104613.EBTUAAAC.PN1.0002372".into() 262 }]), 263 payments: Box::default() 264 } 265 ); 266 } 267 }