libeufin

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

commit c7bd6b10e09154a8ee0c71ca00ad701c7ba3d4a9
parent 50720217452d49dd88ae5992bc4a08f0732cd69f
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 10:37:59 +0200

ebics: hac parser

Diffstat:
Msrc/iso20022/camt.rs | 6+++---
Asrc/iso20022/hac.rs | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/iso20022/mod.rs | 15++++++++-------
Msrc/xml.rs | 1+
4 files changed, 215 insertions(+), 10 deletions(-)

diff --git a/src/iso20022/camt.rs b/src/iso20022/camt.rs @@ -338,7 +338,7 @@ fn bank_tx_code(n: Xml) -> xml::Result<BankTxCode> { } /** Parse camt files */ -pub fn parse_txs(xml: &str) -> xml::Result<Vec<AccountTransactions>> { +pub fn parse_camt(xml: &str) -> xml::Result<Vec<AccountTransactions>> { /* In ISO 20022 specifications, most fields are optional and the same information can be written several times in different places. For libeufin, we're only @@ -535,13 +535,13 @@ mod test { }; use crate::{ - iso20022::camt::{AccountId, parse_txs}, + iso20022::camt::{AccountId, parse_camt}, model::{InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, }; pub fn check_tx(path: &str, iban: &str, currency: Option<&str>, txs: &[Tx]) { let content = std::fs::read_to_string(path).unwrap(); - let res = parse_txs(&content).unwrap(); + let res = parse_camt(&content).unwrap(); assert_eq!(res.len(), 1); let first = &res[0]; diff --git a/src/iso20022/hac.rs b/src/iso20022/hac.rs @@ -0,0 +1,203 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin is free software; you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation; either version 3, or +* (at your option) any later version. + +* LibEuFin is distributed in the hope that it will be useful, but +* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +* Public License for more details. + +* You should have received a copy of the GNU Affero General Public +* License along with LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::fmt::Display; + +use compact_str::CompactString; +use jiff::Timestamp; +use taler_common::types::utils::date_time_to_utc_ts; + +use crate::{ + iso20022::{HacAction, external_code::ExternalStatusReasonCode}, + xml::{self, Xml, XmlAccess}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomerAck { + pub action: HacAction, + pub order_id: Option<CompactString>, + pub code: Option<ExternalStatusReasonCode>, + pub info: Box<str>, + pub timestamp: Timestamp, +} + +impl CustomerAck { + fn msg_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + action, code, info, .. + } = self; + write!(f, "{action}")?; + if let Some(code) = code { + write!(f, "{}", code.code())?; + } + write!(f, " - '{}'", action.description())?; + if let Some(code) = code { + write!(f, " '{}'", code.description())?; + } + if !info.is_empty() { + write!(f, " - '{info}'")?; + } + Ok(()) + } +} + +impl Display for CustomerAck { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + order_id, + timestamp, + .. + } = self; + write!(f, "{timestamp}")?; + if let Some(id) = order_id { + write!(f, "{id}")?; + } + write!(f, " {}", std::fmt::from_fn(|f| self.msg_fmt(f))) + } +} +/** Parse HAC pain.002 XML file */ +pub fn parse_hac(xml: &str) -> xml::Result<Vec<CustomerAck>> { + Xml::parse_str(xml, "Document", |root| { + root.one("CstmrPmtStsRpt")? + .many("OrgnlPmtInfAndSts") + .map(|n| { + let action: HacAction = n.one("OrgnlPmtInfId").parse()?; + let mut timestamp = None; + let mut order_id = None; + let info = n.one("StsRsnInf")?; + for entry in info.one("Orgtr").one("Id").one("OrgId")?.many("Othr") { + let value = entry.one("Id"); + let key = entry.one("SchmeNm").one("Prtry")?.text(); + match key { + "TimeStamp" => { + timestamp = Some(date_time_to_utc_ts( + &value.decode(|dt| dt.trim_end_matches('Z').parse())?, + )) + } + "OrderID" => order_id = Some(value.parse()?), + _ => {} + } + } + let code = info.opt("Rsn").one("Cd").parse()?; + let info: String = info.many("AddtlInf").map(|n| n.text()).collect(); + Ok(CustomerAck { + action, + order_id, + code, + info: info.into_boxed_str(), + timestamp: timestamp.unwrap(), + }) + }) + .collect() + }) +} + +#[cfg(test)] +mod test { + use taler_common::types::utils::date_time_to_utc_ts; + + use crate::iso20022::{ + HacAction, + external_code::ExternalStatusReasonCode, + hac::{CustomerAck, parse_hac}, + }; + + #[test] + fn hac() { + let xml = std::fs::read_to_string("libeufin-nexus/sample/platform/hac.xml").unwrap(); + let acks = parse_hac(&xml).unwrap(); + + pub fn ack( + action: HacAction, + order_id: Option<&str>, + code: Option<ExternalStatusReasonCode>, + info: &str, + timestamp: &str, + ) -> CustomerAck { + CustomerAck { + action, + order_id: order_id.map(Into::into), + code, + info: info.into(), + timestamp: date_time_to_utc_ts(&timestamp.trim_end_matches('Z').parse().unwrap()), + } + } + pretty_assertions::assert_eq!( + acks, + &[ + ack( + HacAction::FILE_DOWNLOAD, + None, + Some(ExternalStatusReasonCode::TransmissionSuccessful), + "", + "2024-09-02T15:47:30.350Z" + ), + ack( + HacAction::FILE_UPLOAD, + Some("ORDER_SUCCESS"), + Some(ExternalStatusReasonCode::TransmissionSuccessful), + "", + "2024-09-02T20:48:43.153Z" + ), + ack( + HacAction::ES_VERIFICATION, + Some("ORDER_SUCCESS"), + Some(ExternalStatusReasonCode::ElectronicSignaturesCorrect), + "", + "2024-09-02T20:48:43.153Z" + ), + ack( + HacAction::ORDER_HAC_FINAL_POS, + Some("ORDER_SUCCESS"), + None, + "Some multiline info", + "2024-09-02T20:48:43.153Z" + ), + ack( + HacAction::FILE_DOWNLOAD, + None, + Some(ExternalStatusReasonCode::NoDataAvailable), + "", + "2024-09-02T15:47:31.754Z" + ), + ack( + HacAction::FILE_UPLOAD, + Some("ORDER_FAILURE"), + Some(ExternalStatusReasonCode::TransmissionSuccessful), + "", + "2024-08-23T15:34:11.987Z" + ), + ack( + HacAction::ES_VERIFICATION, + Some("ORDER_FAILURE"), + Some(ExternalStatusReasonCode::IncorrectFileStructure), + "", + "2024-08-23T15:34:13.307Z" + ), + ack( + HacAction::ORDER_HAC_FINAL_NEG, + Some("ORDER_FAILURE"), + None, + "", + "2024-08-23T15:34:13.307Z" + ), + ] + ) + } +} diff --git a/src/iso20022/mod.rs b/src/iso20022/mod.rs @@ -22,6 +22,7 @@ use taler_enum_meta::EnumMeta; pub mod bank_tx_code; pub mod camt; pub mod external_code; +pub mod hac; #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] #[enum_meta(Description, Str)] @@ -73,7 +74,7 @@ pub enum ChargeBearer { pub mod test { use tracing::info; - use crate::iso20022::camt::parse_txs; + use crate::iso20022::{camt::parse_camt, hac::parse_hac}; #[test] pub fn sample() { @@ -92,15 +93,15 @@ pub mod test { } } for (path, name) in samples { - let content = std::fs::read_to_string(&path).unwrap(); + let xml = std::fs::read_to_string(&path).unwrap(); let name = name.to_string_lossy(); info!("Parse sample {path:?}"); if name.contains("hac") { - // TODO + parse_hac(&xml).unwrap(); } else if name.contains("camt") { - parse_txs(&content).unwrap(); + parse_camt(&xml).unwrap(); } else if name.contains("pain002") { // TODO } else if name.contains("pain001") { @@ -150,19 +151,19 @@ pub mod test { } } for path in logs { - let content = std::fs::read_to_string(&path).unwrap(); + let xml = std::fs::read_to_string(&path).unwrap(); let path = path.to_string_lossy(); info!("Parse sample {path:?}"); if path.contains("HAC") { - // TODO + parse_hac(&xml).unwrap(); } else if path.contains("HKD") { // TODO } else if path.contains("HAA") { // TODO } else if path.contains("camt") { - parse_txs(&content).unwrap(); + parse_camt(&xml).unwrap(); } else if path.contains("pain.002") { // TODO } else if path.contains("pain.001") { diff --git a/src/xml.rs b/src/xml.rs @@ -20,6 +20,7 @@ use std::{fmt::Display, str::FromStr}; use base64::{Engine, prelude::BASE64_STANDARD}; +use jiff::Timestamp; use roxmltree::{Document, Node}; #[macro_export]