commit c7bd6b10e09154a8ee0c71ca00ad701c7ba3d4a9
parent 50720217452d49dd88ae5992bc4a08f0732cd69f
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:37:59 +0200
ebics: hac parser
Diffstat:
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(×tamp.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]