libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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 }