use crate::xml::{Document, Node, XmlError};
pub mod ns {
pub const PAIN002_003_03: &str = "urn:iso:std:iso:20022:tech:xsd:pain.002.003.03";
pub const PAIN002_002_03: &str = "urn:iso:std:iso:20022:tech:xsd:pain.002.002.03";
pub const PAIN002_001_03: &str = "urn:iso:std:iso:20022:tech:xsd:pain.002.001.03";
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PaymentStatus {
Actc,
Accp,
Acsp,
Acsc,
Acwc,
Part,
Pdng,
Rjct,
Other(String),
}
impl PaymentStatus {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Actc => "ACTC",
Self::Accp => "ACCP",
Self::Acsp => "ACSP",
Self::Acsc => "ACSC",
Self::Acwc => "ACWC",
Self::Part => "PART",
Self::Pdng => "PDNG",
Self::Rjct => "RJCT",
Self::Other(s) => s,
}
}
#[inline]
#[must_use]
pub fn is_accepted(&self) -> bool {
matches!(
self,
Self::Actc | Self::Accp | Self::Acsp | Self::Acsc | Self::Acwc
)
}
#[inline]
#[must_use]
pub fn is_final(&self) -> bool {
matches!(self, Self::Acsc | Self::Rjct)
}
#[inline]
#[must_use]
pub fn is_rejected(&self) -> bool {
matches!(self, Self::Rjct)
}
fn from_code(s: &str) -> Self {
match s.to_ascii_uppercase().as_str() {
"ACTC" => Self::Actc,
"ACCP" => Self::Accp,
"ACSP" => Self::Acsp,
"ACSC" => Self::Acsc,
"ACWC" => Self::Acwc,
"PART" => Self::Part,
"PDNG" => Self::Pdng,
"RJCT" => Self::Rjct,
other => Self::Other(other.to_owned()),
}
}
}
impl std::fmt::Display for PaymentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
impl std::str::FromStr for PaymentStatus {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_code(s))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ReasonCode {
Ac01,
Ac04,
Ac06,
Ac13,
Am04,
Am05,
Md01,
Md02,
Md06,
Md07,
Ag01,
Ag02,
Rc01,
Rr01,
Rr02,
Rr03,
Rr04,
Ms02,
Ms03,
Ff01,
Ds02,
Narr,
Other(String),
}
impl ReasonCode {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
Self::Ac01 => "AC01",
Self::Ac04 => "AC04",
Self::Ac06 => "AC06",
Self::Ac13 => "AC13",
Self::Am04 => "AM04",
Self::Am05 => "AM05",
Self::Md01 => "MD01",
Self::Md02 => "MD02",
Self::Md06 => "MD06",
Self::Md07 => "MD07",
Self::Ag01 => "AG01",
Self::Ag02 => "AG02",
Self::Rc01 => "RC01",
Self::Rr01 => "RR01",
Self::Rr02 => "RR02",
Self::Rr03 => "RR03",
Self::Rr04 => "RR04",
Self::Ms02 => "MS02",
Self::Ms03 => "MS03",
Self::Ff01 => "FF01",
Self::Ds02 => "DS02",
Self::Narr => "NARR",
Self::Other(s) => s,
}
}
fn from_code(s: &str) -> Self {
match s.to_ascii_uppercase().as_str() {
"AC01" => Self::Ac01,
"AC04" => Self::Ac04,
"AC06" => Self::Ac06,
"AC13" => Self::Ac13,
"AM04" => Self::Am04,
"AM05" => Self::Am05,
"MD01" => Self::Md01,
"MD02" => Self::Md02,
"MD06" => Self::Md06,
"MD07" => Self::Md07,
"AG01" => Self::Ag01,
"AG02" => Self::Ag02,
"RC01" => Self::Rc01,
"RR01" => Self::Rr01,
"RR02" => Self::Rr02,
"RR03" => Self::Rr03,
"RR04" => Self::Rr04,
"MS02" => Self::Ms02,
"MS03" => Self::Ms03,
"FF01" => Self::Ff01,
"DS02" => Self::Ds02,
"NARR" => Self::Narr,
other => Self::Other(other.to_owned()),
}
}
}
impl std::fmt::Display for ReasonCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
impl std::str::FromStr for ReasonCode {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_code(s))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OriginalMessageType {
CreditTransfer,
DirectDebit,
Other(String),
}
impl OriginalMessageType {
fn from_msg_name_id(s: &str) -> Self {
let s = s.trim();
if s.starts_with("pain.001") {
Self::CreditTransfer
} else if s.starts_with("pain.008") {
Self::DirectDebit
} else {
Self::Other(s.to_owned())
}
}
}
impl std::fmt::Display for OriginalMessageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CreditTransfer => f.write_str("pain.001"),
Self::DirectDebit => f.write_str("pain.008"),
Self::Other(s) => f.write_str(s),
}
}
}
impl std::str::FromStr for OriginalMessageType {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_msg_name_id(s))
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TransactionStatus {
pub original_end_to_end_id: String,
pub status: PaymentStatus,
pub reason_codes: Vec<ReasonCode>,
pub additional_info: Option<String>,
pub original_amount_ct: Option<i64>,
pub original_debtor_name: Option<String>,
pub original_debtor_iban: Option<String>,
pub original_creditor_name: Option<String>,
pub original_creditor_iban: Option<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PaymentInfoStatus {
pub original_payment_info_id: String,
pub status: Option<PaymentStatus>,
pub transactions: Vec<TransactionStatus>,
}
impl PaymentInfoStatus {
#[must_use]
pub fn has_rejections(&self) -> bool {
self.transactions.iter().any(|t| t.status.is_rejected())
|| self.status.as_ref().is_some_and(PaymentStatus::is_rejected)
}
#[must_use]
pub fn rejection_reasons(&self) -> Vec<&ReasonCode> {
self.transactions
.iter()
.filter(|t| t.status.is_rejected())
.flat_map(|t| &t.reason_codes)
.collect()
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Pain002Document {
pub msg_id: String,
pub created_at: String,
pub forwarding_agent_bic: Option<String>,
pub namespace: Option<String>,
pub original_msg_id: String,
pub original_msg_type: OriginalMessageType,
pub group_status: Option<PaymentStatus>,
pub payment_info_statuses: Vec<PaymentInfoStatus>,
}
impl Pain002Document {
#[must_use]
pub fn is_fully_accepted(&self) -> bool {
self.group_status
.as_ref()
.is_some_and(PaymentStatus::is_accepted)
&& self
.payment_info_statuses
.iter()
.all(|p| !p.has_rejections())
}
#[must_use]
pub fn has_rejections(&self) -> bool {
self.group_status
.as_ref()
.is_some_and(PaymentStatus::is_rejected)
|| self
.payment_info_statuses
.iter()
.any(PaymentInfoStatus::has_rejections)
}
#[must_use]
pub fn rejected_transactions(&self) -> Vec<&TransactionStatus> {
self.payment_info_statuses
.iter()
.flat_map(|p| &p.transactions)
.filter(|t| t.status.is_rejected())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Pain002ParseError {
#[error(transparent)]
Xml(#[from] XmlError),
#[error("not a pain.002 document: root element <CstmrPmtStsRpt> not found")]
NotPain002,
#[error("missing required pain.002 element: <{tag}>")]
MissingElement {
tag: &'static str,
},
#[error("invalid amount value in pain.002: {raw:?}")]
InvalidAmount {
raw: String,
},
}
pub fn parse_pain002(xml: &str) -> Result<Pain002Document, Pain002ParseError> {
let doc = Document::parse(xml)?;
let namespace = doc.namespace;
let root = doc
.root
.child("CstmrPmtStsRpt")
.ok_or(Pain002ParseError::NotPain002)?;
let grp_hdr = root
.child("GrpHdr")
.ok_or(Pain002ParseError::MissingElement { tag: "GrpHdr" })?;
let msg_id = grp_hdr
.text_of("MsgId")
.ok_or(Pain002ParseError::MissingElement { tag: "MsgId" })?
.to_owned();
let created_at = grp_hdr.text_of("CreDtTm").unwrap_or_default().to_owned();
let forwarding_agent_bic = grp_hdr
.child("DbtrAgt")
.or_else(|| grp_hdr.child("CdtrAgt"))
.and_then(bic_of_agent)
.map(str::to_owned);
let orig_grp = root
.child("OrgnlGrpInfAndSts")
.ok_or(Pain002ParseError::MissingElement {
tag: "OrgnlGrpInfAndSts",
})?;
let original_msg_id = orig_grp
.text_of("OrgnlMsgId")
.ok_or(Pain002ParseError::MissingElement { tag: "OrgnlMsgId" })?
.to_owned();
let original_msg_type = orig_grp.text_of("OrgnlMsgNmId").map_or_else(
|| OriginalMessageType::Other(String::new()),
OriginalMessageType::from_msg_name_id,
);
let group_status = orig_grp.text_of("GrpSts").map(PaymentStatus::from_code);
let payment_info_statuses = root
.children_named("OrgnlPmtInfAndSts")
.map(parse_payment_info_status)
.collect();
Ok(Pain002Document {
msg_id,
created_at,
forwarding_agent_bic,
namespace,
original_msg_id,
original_msg_type,
group_status,
payment_info_statuses,
})
}
fn bic_of_agent(agent: &Node) -> Option<&str> {
let fin = agent.child("FinInstnId")?;
fin.text_of("BIC").or_else(|| fin.text_of("BICFI"))
}
fn parse_payment_info_status(block: &Node) -> PaymentInfoStatus {
PaymentInfoStatus {
original_payment_info_id: block
.text_of("OrgnlPmtInfId")
.unwrap_or("NOTPROVIDED")
.to_owned(),
status: block.text_of("PmtInfSts").map(PaymentStatus::from_code),
transactions: block
.children_named("TxInfAndSts")
.map(parse_transaction_status)
.collect(),
}
}
fn parse_transaction_status(tx: &Node) -> TransactionStatus {
let original_end_to_end_id = tx
.text_of("OrgnlEndToEndId")
.unwrap_or("NOTPROVIDED")
.to_owned();
let status = tx.text_of("TxSts").map_or_else(
|| PaymentStatus::Other("UNKNOWN".to_owned()),
PaymentStatus::from_code,
);
let mut reason_codes = Vec::new();
let mut additional_info: Option<String> = None;
for rsn_block in tx.children_named("StsRsnInf") {
if let Some(code) = rsn_block.child("Rsn").and_then(Node::code) {
reason_codes.push(ReasonCode::from_code(code));
}
if additional_info.is_none() {
additional_info = rsn_block.text_of("AddtlInf").map(str::to_owned);
}
}
let orig_tx_ref = tx.child("OrgnlTxRef");
let original_amount_ct = orig_tx_ref
.and_then(|r| {
r.text_at(&["Amt", "InstdAmt"])
.or_else(|| r.text_of_descendant("InstdAmt"))
})
.and_then(crate::ct_from_eur_str);
let party = |tags: [&str; 2]| -> (Option<String>, Option<String>) {
orig_tx_ref.map_or((None, None), |r| {
(
r.text_at(&[tags[0], "Nm"]).map(str::to_owned),
r.text_at(&[tags[1], "Id", "IBAN"]).map(str::to_owned),
)
})
};
let (original_debtor_name, original_debtor_iban) = party(["Dbtr", "DbtrAcct"]);
let (original_creditor_name, original_creditor_iban) = party(["Cdtr", "CdtrAcct"]);
TransactionStatus {
original_end_to_end_id,
status,
reason_codes,
additional_info,
original_amount_ct,
original_debtor_name,
original_debtor_iban,
original_creditor_name,
original_creditor_iban,
}
}
#[cfg(test)]
mod tests {
use super::*;
const PAIN002_ACTC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.003.03">
<CstmrPmtStsRpt>
<GrpHdr>
<MsgId>AAAADEBBXXX20260714000001</MsgId>
<CreDtTm>2026-07-14T10:20:30</CreDtTm>
<DbtrAgt><FinInstnId><BIC>COBADEFFXXX</BIC></FinInstnId></DbtrAgt>
</GrpHdr>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>CT-2026-07-001</OrgnlMsgId>
<OrgnlMsgNmId>pain.001</OrgnlMsgNmId>
<GrpSts>ACTC</GrpSts>
</OrgnlGrpInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
const PAIN002_PART_RJCT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.001.03">
<CstmrPmtStsRpt>
<GrpHdr>
<MsgId>AAAADEBBJJJJMMTT0000000001</MsgId>
<CreDtTm>2026-07-14T10:20:30</CreDtTm>
<DbtrAgt><FinInstnId><BIC>AAAADEBB</BIC></FinInstnId></DbtrAgt>
</GrpHdr>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>CT-BATCH-001</OrgnlMsgId>
<OrgnlMsgNmId>pain.001</OrgnlMsgNmId>
<GrpSts>PART</GrpSts>
</OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts>
<OrgnlPmtInfId>PMT-001</OrgnlPmtInfId>
<TxInfAndSts>
<OrgnlEndToEndId>E2E-001</OrgnlEndToEndId>
<TxSts>RJCT</TxSts>
<StsRsnInf>
<Orgtr><Id><OrgId><BICOrBEI>AAAADEBBXXX</BICOrBEI></OrgId></Id></Orgtr>
<Rsn><Cd>AC04</Cd></Rsn>
</StsRsnInf>
<OrgnlTxRef>
<Amt><InstdAmt Ccy="EUR">88.88</InstdAmt></Amt>
<Dbtr><Nm>Max Mustermann</Nm></Dbtr>
<DbtrAcct><Id><IBAN>DE99888888885555555555</IBAN></Id></DbtrAcct>
<Cdtr><Nm>Creditor GmbH</Nm></Cdtr>
<CdtrAcct><Id><IBAN>DE89370400440532013000</IBAN></Id></CdtrAcct>
</OrgnlTxRef>
</TxInfAndSts>
<TxInfAndSts>
<OrgnlEndToEndId>E2E-002</OrgnlEndToEndId>
<TxSts>RJCT</TxSts>
<StsRsnInf>
<Rsn><Cd>DS02</Cd></Rsn>
<AddtlInf>Customer order to stop</AddtlInf>
</StsRsnInf>
</TxInfAndSts>
</OrgnlPmtInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
#[test]
fn parse_actc_accepted() {
let doc = parse_pain002(PAIN002_ACTC).unwrap();
assert_eq!(doc.msg_id, "AAAADEBBXXX20260714000001");
assert_eq!(doc.created_at, "2026-07-14T10:20:30");
assert_eq!(doc.original_msg_id, "CT-2026-07-001");
assert_eq!(doc.group_status, Some(PaymentStatus::Actc));
assert!(doc.group_status.as_ref().unwrap().is_accepted());
assert_eq!(
doc.namespace.as_deref(),
Some("urn:iso:std:iso:20022:tech:xsd:pain.002.003.03")
);
assert_eq!(doc.original_msg_type, OriginalMessageType::CreditTransfer);
assert_eq!(doc.forwarding_agent_bic.as_deref(), Some("COBADEFFXXX"));
assert!(!doc.has_rejections());
assert!(doc.is_fully_accepted());
}
#[test]
fn parse_part_with_rejections() {
let doc = parse_pain002(PAIN002_PART_RJCT).unwrap();
assert_eq!(doc.group_status, Some(PaymentStatus::Part));
assert!(!doc.is_fully_accepted());
assert!(doc.has_rejections());
let rejected = doc.rejected_transactions();
assert_eq!(rejected.len(), 2);
let tx1 = &rejected[0];
assert_eq!(tx1.original_end_to_end_id, "E2E-001");
assert_eq!(tx1.reason_codes, vec![ReasonCode::Ac04]);
assert_eq!(tx1.original_amount_ct, Some(8888));
assert_eq!(
tx1.original_debtor_iban.as_deref(),
Some("DE99888888885555555555")
);
assert_eq!(
tx1.original_creditor_iban.as_deref(),
Some("DE89370400440532013000")
);
let tx2 = &rejected[1];
assert_eq!(tx2.reason_codes, vec![ReasonCode::Ds02]);
assert_eq!(
tx2.additional_info.as_deref(),
Some("Customer order to stop")
);
}
#[test]
fn parse_not_pain002() {
let err = parse_pain002("<Document><SomethingElse/></Document>").unwrap_err();
assert_eq!(err, Pain002ParseError::NotPain002);
}
#[test]
fn payment_status_codes() {
assert!(PaymentStatus::Actc.is_accepted());
assert!(PaymentStatus::Acsc.is_final());
assert!(PaymentStatus::Rjct.is_rejected());
assert!(PaymentStatus::Rjct.is_final());
assert!(!PaymentStatus::Pdng.is_final());
assert!(!PaymentStatus::Part.is_accepted());
}
#[test]
fn reason_code_roundtrip() {
assert_eq!(ReasonCode::from_code("AC01"), ReasonCode::Ac01);
assert_eq!(ReasonCode::from_code("md01"), ReasonCode::Md01);
assert_eq!(
ReasonCode::from_code("ZZZZ"),
ReasonCode::Other("ZZZZ".into())
);
assert_eq!(ReasonCode::Md06.as_code(), "MD06");
}
#[test]
fn payment_status_fromstr() {
assert_eq!(
"ACTC".parse::<PaymentStatus>().unwrap(),
PaymentStatus::Actc
);
assert_eq!(
"rjct".parse::<PaymentStatus>().unwrap(),
PaymentStatus::Rjct
);
assert_eq!(
"XXXX".parse::<PaymentStatus>().unwrap(),
PaymentStatus::Other("XXXX".into())
);
}
#[test]
fn reason_code_fromstr() {
assert_eq!("MD01".parse::<ReasonCode>().unwrap(), ReasonCode::Md01);
assert_eq!("am04".parse::<ReasonCode>().unwrap(), ReasonCode::Am04);
}
#[test]
fn payment_status_display() {
assert_eq!(PaymentStatus::Actc.to_string(), "ACTC");
assert_eq!(PaymentStatus::Rjct.to_string(), "RJCT");
assert_eq!(PaymentStatus::Other("CUST".into()).to_string(), "CUST");
}
#[test]
fn original_message_type_fromstr() {
assert_eq!(
"pain.001".parse::<OriginalMessageType>().unwrap(),
OriginalMessageType::CreditTransfer
);
assert_eq!(
"pain.001.003.03".parse::<OriginalMessageType>().unwrap(),
OriginalMessageType::CreditTransfer
);
assert_eq!(
"pain.008".parse::<OriginalMessageType>().unwrap(),
OriginalMessageType::DirectDebit
);
assert_eq!(
"pain.007".parse::<OriginalMessageType>().unwrap(),
OriginalMessageType::Other("pain.007".into())
);
}
#[test]
fn prtry_reason_code_per_block() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.003.03">
<CstmrPmtStsRpt>
<GrpHdr><MsgId>PRTRY-TEST</MsgId><CreDtTm>2026-07-14T10:00:00</CreDtTm></GrpHdr>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>ORIG-001</OrgnlMsgId>
<OrgnlMsgNmId>pain.008</OrgnlMsgNmId>
<GrpSts>PART</GrpSts>
</OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts>
<OrgnlPmtInfId>PMT-001</OrgnlPmtInfId>
<TxInfAndSts>
<OrgnlEndToEndId>E2E-PRTRY</OrgnlEndToEndId>
<TxSts>RJCT</TxSts>
<StsRsnInf>
<Rsn><Cd>AC04</Cd></Rsn>
</StsRsnInf>
<StsRsnInf>
<Rsn><Prtry>BANK-INTERNAL-007</Prtry></Rsn>
<AddtlInf>Proprietary bank reason</AddtlInf>
</StsRsnInf>
</TxInfAndSts>
</OrgnlPmtInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
let doc = parse_pain002(xml).unwrap();
let tx = &doc.payment_info_statuses[0].transactions[0];
assert_eq!(
tx.reason_codes.len(),
2,
"both Cd and Prtry must be captured"
);
assert_eq!(tx.reason_codes[0], ReasonCode::Ac04);
assert_eq!(
tx.reason_codes[1],
ReasonCode::Other("BANK-INTERNAL-007".into())
);
assert_eq!(
tx.additional_info.as_deref(),
Some("Proprietary bank reason")
);
}
#[test]
fn rsn_cd_path_is_precise() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.003.03">
<CstmrPmtStsRpt>
<GrpHdr><MsgId>RSN-PATH-TEST</MsgId><CreDtTm>2026-07-14T10:00:00</CreDtTm></GrpHdr>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>ORIG-001</OrgnlMsgId>
<OrgnlMsgNmId>pain.001</OrgnlMsgNmId>
<GrpSts>RJCT</GrpSts>
</OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts>
<OrgnlPmtInfId>PMT-001</OrgnlPmtInfId>
<TxInfAndSts>
<OrgnlEndToEndId>E2E-001</OrgnlEndToEndId>
<TxSts>RJCT</TxSts>
<StsRsnInf>
<Orgtr><Id><OrgId><BICOrBEI>BANKDEFF</BICOrBEI></OrgId></Id></Orgtr>
<Rsn><Cd>AM04</Cd></Rsn>
</StsRsnInf>
</TxInfAndSts>
</OrgnlPmtInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
let doc = parse_pain002(xml).unwrap();
let tx = &doc.payment_info_statuses[0].transactions[0];
assert_eq!(tx.reason_codes, vec![ReasonCode::Am04]);
}
#[test]
fn parse_prefixed_namespace() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ns2:Document xmlns:ns2="urn:iso:std:iso:20022:tech:xsd:pain.002.003.03">
<ns2:CstmrPmtStsRpt>
<ns2:GrpHdr>
<ns2:MsgId>MSG-PREFIX-001</ns2:MsgId>
<ns2:CreDtTm>2026-07-14T12:00:00</ns2:CreDtTm>
</ns2:GrpHdr>
<ns2:OrgnlGrpInfAndSts>
<ns2:OrgnlMsgId>ORIG-001</ns2:OrgnlMsgId>
<ns2:OrgnlMsgNmId>pain.008</ns2:OrgnlMsgNmId>
<ns2:GrpSts>ACTC</ns2:GrpSts>
</ns2:OrgnlGrpInfAndSts>
</ns2:CstmrPmtStsRpt>
</ns2:Document>"#;
let doc = parse_pain002(xml).unwrap();
assert_eq!(doc.msg_id, "MSG-PREFIX-001");
assert_eq!(doc.original_msg_id, "ORIG-001");
assert_eq!(doc.original_msg_type, OriginalMessageType::DirectDebit);
assert_eq!(doc.group_status, Some(PaymentStatus::Actc));
}
}