libeufin

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

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 }