use crate::xml::{Document, Node, XmlError};
pub mod ns {
pub const PAIN002_001_10: &str = "urn:iso:std:iso:20022:tech:xsd:pain.002.001.10";
pub const PAIN002_001_03: &str = "urn:iso:std:iso:20022:tech:xsd:pain.002.001.03";
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";
}
#[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,
Rcvc,
Rvmc,
Rvnm,
Rvna,
Rvcm,
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum VerificationOutcome {
Match,
CloseMatch,
NoMatch,
NotApplicable,
}
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::Rcvc => "RCVC",
Self::Rvmc => "RVMC",
Self::Rvnm => "RVNM",
Self::Rvna => "RVNA",
Self::Rvcm => "RVCM",
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 const fn verification(&self) -> Option<VerificationOutcome> {
match self {
Self::Rcvc => Some(VerificationOutcome::Match),
Self::Rvmc => Some(VerificationOutcome::CloseMatch),
Self::Rvnm => Some(VerificationOutcome::NoMatch),
Self::Rvna => Some(VerificationOutcome::NotApplicable),
_ => None,
}
}
#[inline]
#[must_use]
pub const fn is_verification(&self) -> bool {
matches!(self, Self::Rvcm) || self.verification().is_some()
}
#[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,
"RCVC" => Self::Rcvc,
"RVMC" => Self::Rvmc,
"RVNM" => Self::Rvnm,
"RVNA" => Self::Rvna,
"RVCM" => Self::Rvcm,
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, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StatusCount {
pub status: PaymentStatus,
pub count: u64,
pub total_ct: Option<i64>,
}
fn parse_status_counts(block: &Node) -> Vec<StatusCount> {
block
.children_named("NbOfTxsPerSts")
.filter_map(|n| {
Some(StatusCount {
status: PaymentStatus::from_code(n.text_of("DtldSts")?),
count: n.text_of("DtldNbOfTxs")?.parse().ok()?,
total_ct: n
.text_of("DtldCtrlSum")
.and_then(|v| crate::ct_from_eur_str(v).ok()),
})
})
.collect()
}
fn party_name(reference: &Node, tag: &str) -> Option<String> {
let party = reference.child(tag)?;
party
.text_of("Nm")
.or_else(|| party.text_at(&["Pty", "Nm"]))
.map(str::to_owned)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TransactionStatus {
pub original_end_to_end_id: Option<String>,
pub original_instruction_id: Option<String>,
pub status: Option<PaymentStatus>,
pub reason_codes: Vec<ReasonCode>,
pub additional_info: Vec<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: Option<String>,
pub status: Option<PaymentStatus>,
pub status_counts: Vec<StatusCount>,
pub transactions: Vec<TransactionStatus>,
}
impl TransactionStatus {
#[must_use]
pub fn is_rejected(&self) -> bool {
self.status.as_ref().is_some_and(PaymentStatus::is_rejected)
}
}
impl PaymentInfoStatus {
#[must_use]
pub fn has_rejections(&self) -> bool {
self.transactions.iter().any(TransactionStatus::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.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: Option<OriginalMessageType>,
pub group_status: Option<PaymentStatus>,
pub group_status_counts: Vec<StatusCount>,
pub payment_info_statuses: Vec<PaymentInfoStatus>,
}
impl Pain002Document {
#[must_use]
pub fn is_fully_accepted(&self) -> bool {
let reported = self.group_status.is_some() || !self.payment_info_statuses.is_empty();
reported
&& self
.group_status
.as_ref()
.is_none_or(PaymentStatus::is_accepted)
&& self.payment_info_statuses.iter().all(|p| {
p.status.as_ref().is_none_or(PaymentStatus::is_accepted)
&& p.transactions
.iter()
.all(|t| t.status.as_ref().is_none_or(PaymentStatus::is_accepted))
})
}
#[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.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,
},
}
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(OriginalMessageType::from_msg_name_id);
let group_status = orig_grp.text_of("GrpSts").map(PaymentStatus::from_code);
let group_status_counts = parse_status_counts(orig_grp);
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,
group_status_counts,
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").map(str::to_owned),
status: block.text_of("PmtInfSts").map(PaymentStatus::from_code),
status_counts: parse_status_counts(block),
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").map(str::to_owned);
let original_instruction_id = tx.text_of("OrgnlInstrId").map(str::to_owned);
let status = tx.text_of("TxSts").map(PaymentStatus::from_code);
let mut reason_codes = Vec::new();
let mut additional_info: Vec<String> = Vec::new();
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));
}
additional_info.extend(
rsn_block
.children_named("AddtlInf")
.map(|n| n.text.clone())
.filter(|t| !t.is_empty()),
);
}
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(|raw| crate::ct_from_eur_str(raw).ok());
let party = |tags: [&str; 2]| -> (Option<String>, Option<String>) {
orig_tx_ref.map_or((None, None), |r| {
(
party_name(r, tags[0]),
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,
original_instruction_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,
Some(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.as_deref(), Some("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, ["Customer order to stop"]);
}
#[test]
fn omitted_optional_fields_read_as_absent_not_as_a_stand_in() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.001.10">
<CstmrPmtStsRpt>
<GrpHdr><MsgId>M</MsgId><CreDtTm>2026-07-14T10:00:00</CreDtTm></GrpHdr>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>CT-1</OrgnlMsgId>
<OrgnlMsgNmId>pain.001.001.09</OrgnlMsgNmId>
<GrpSts>PART</GrpSts>
</OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts>
<OrgnlPmtInfId>PMT-1</OrgnlPmtInfId>
<TxInfAndSts>
<OrgnlInstrId>INSTR-7</OrgnlInstrId>
<TxSts>RJCT</TxSts>
<StsRsnInf><Rsn><Cd>AC01</Cd></Rsn></StsRsnInf>
</TxInfAndSts>
<TxInfAndSts>
<OrgnlEndToEndId>E2E-2</OrgnlEndToEndId>
</TxInfAndSts>
</OrgnlPmtInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
let doc = parse_pain002(xml).unwrap();
let txs = &doc.payment_info_statuses[0].transactions;
assert_eq!(txs[0].original_end_to_end_id, None);
assert_eq!(txs[0].original_instruction_id.as_deref(), Some("INSTR-7"));
assert!(txs[0].is_rejected());
assert_eq!(txs[1].status, None);
assert!(!txs[1].is_rejected());
assert!(doc.has_rejections());
assert!(!doc.is_fully_accepted());
}
#[test]
fn full_acceptance_looks_at_all_three_status_levels() {
let report = |grp: &str, pmt: &str| {
format!(
r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.001.10">
<CstmrPmtStsRpt>
<GrpHdr><MsgId>M</MsgId><CreDtTm>T</CreDtTm></GrpHdr>
<OrgnlGrpInfAndSts><OrgnlMsgId>O</OrgnlMsgId><GrpSts>{grp}</GrpSts></OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts><OrgnlPmtInfId>P</OrgnlPmtInfId><PmtInfSts>{pmt}</PmtInfSts></OrgnlPmtInfAndSts>
</CstmrPmtStsRpt>
</Document>"#
)
};
assert!(
parse_pain002(&report("ACTC", "ACTC"))
.unwrap()
.is_fully_accepted()
);
assert!(
!parse_pain002(&report("ACTC", "RJCT"))
.unwrap()
.is_fully_accepted()
);
assert!(
!parse_pain002(&report("PART", "ACTC"))
.unwrap()
.is_fully_accepted()
);
for not_yet in ["PDNG", "PART", "ZZZZ"] {
assert!(
!parse_pain002(&report("ACTC", not_yet))
.unwrap()
.is_fully_accepted(),
"PmtInfSts {not_yet} is not an acceptance"
);
}
let silent = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.001.10">
<CstmrPmtStsRpt>
<GrpHdr><MsgId>M</MsgId><CreDtTm>T</CreDtTm></GrpHdr>
<OrgnlGrpInfAndSts><OrgnlMsgId>O</OrgnlMsgId></OrgnlGrpInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
let doc = parse_pain002(silent).unwrap();
assert_eq!(doc.group_status, None);
assert!(!doc.is_fully_accepted());
}
const VOP_REPORT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.002.001.10">
<CstmrPmtStsRpt>
<GrpHdr>
<MsgId>B78567267384</MsgId>
<CreDtTm>2025-11-10T09:31:30Z</CreDtTm>
<DbtrAgt><FinInstnId><BICFI>SPUEDE2UXXX</BICFI></FinInstnId></DbtrAgt>
</GrpHdr>
<OrgnlGrpInfAndSts>
<OrgnlMsgId>K563</OrgnlMsgId>
<OrgnlMsgNmId>pain.001</OrgnlMsgNmId>
<OrgnlNbOfTxs>462</OrgnlNbOfTxs>
<GrpSts>RVCM</GrpSts>
<StsRsnInf>
<AddtlInf>RVMC Message text e.g. with legal notice</AddtlInf>
<AddtlInf>RVMC continuation of the message text</AddtlInf>
</StsRsnInf>
<NbOfTxsPerSts><DtldNbOfTxs>454</DtldNbOfTxs><DtldSts>RCVC</DtldSts></NbOfTxsPerSts>
<NbOfTxsPerSts><DtldNbOfTxs>3</DtldNbOfTxs><DtldSts>RVNM</DtldSts></NbOfTxsPerSts>
<NbOfTxsPerSts><DtldNbOfTxs>2</DtldNbOfTxs><DtldSts>RVMC</DtldSts></NbOfTxsPerSts>
<NbOfTxsPerSts><DtldNbOfTxs>3</DtldNbOfTxs><DtldSts>RVNA</DtldSts></NbOfTxsPerSts>
</OrgnlGrpInfAndSts>
<OrgnlPmtInfAndSts>
<OrgnlPmtInfId>B001</OrgnlPmtInfId>
<OrgnlNbOfTxs>350</OrgnlNbOfTxs>
<NbOfTxsPerSts><DtldNbOfTxs>344</DtldNbOfTxs><DtldSts>RCVC</DtldSts></NbOfTxsPerSts>
<NbOfTxsPerSts><DtldNbOfTxs>1</DtldNbOfTxs><DtldSts>RVNM</DtldSts></NbOfTxsPerSts>
<TxInfAndSts>
<OrgnlEndToEndId>K563-B001-T021</OrgnlEndToEndId>
<TxSts>RVNM</TxSts>
<OrgnlTxRef>
<Cdtr><Pty><Nm>Creditor Name</Nm></Pty></Cdtr>
<CdtrAcct><Id><IBAN>DE21500500009876543210</IBAN></Id></CdtrAcct>
</OrgnlTxRef>
</TxInfAndSts>
<TxInfAndSts>
<OrgnlEndToEndId>K563-B001-T087</OrgnlEndToEndId>
<TxSts>RVMC</TxSts>
<StsRsnInf><AddtlInf>Peter Schmitz</AddtlInf></StsRsnInf>
<OrgnlTxRef>
<Cdtr><Pty><Nm>P. Schmitz</Nm></Pty></Cdtr>
<CdtrAcct><Id><IBAN>DE34500500009876543210</IBAN></Id></CdtrAcct>
</OrgnlTxRef>
</TxInfAndSts>
</OrgnlPmtInfAndSts>
</CstmrPmtStsRpt>
</Document>"#;
#[test]
fn a_verification_of_payee_report_is_read_as_such() {
let doc = parse_pain002(VOP_REPORT).unwrap();
assert_eq!(doc.group_status, Some(PaymentStatus::Rvcm));
assert!(doc.group_status.as_ref().unwrap().is_verification());
assert!(!doc.is_fully_accepted());
assert!(!doc.has_rejections(), "a name mismatch is not a rejection");
assert_eq!(doc.group_status_counts.len(), 4);
let matched = doc
.group_status_counts
.iter()
.find(|c| c.status == PaymentStatus::Rcvc)
.unwrap();
assert_eq!(matched.count, 454);
assert_eq!(
doc.group_status_counts.iter().map(|c| c.count).sum::<u64>(),
462
);
let block = &doc.payment_info_statuses[0];
assert_eq!(block.status_counts.len(), 2);
let no_match = &block.transactions[0];
assert_eq!(
no_match.status.as_ref().unwrap().verification(),
Some(VerificationOutcome::NoMatch)
);
assert_eq!(
no_match.original_creditor_name.as_deref(),
Some("Creditor Name")
);
assert_eq!(
no_match.original_creditor_iban.as_deref(),
Some("DE21500500009876543210")
);
let close = &block.transactions[1];
assert_eq!(
close.status.as_ref().unwrap().verification(),
Some(VerificationOutcome::CloseMatch)
);
assert_eq!(close.original_creditor_name.as_deref(), Some("P. Schmitz"));
assert_eq!(close.additional_info, ["Peter Schmitz"]);
}
#[test]
fn every_addtlinf_occurrence_is_kept() {
let doc = parse_pain002(VOP_REPORT).unwrap();
let _ = doc;
let xml = VOP_REPORT.replace(
"<StsRsnInf><AddtlInf>Peter Schmitz</AddtlInf></StsRsnInf>",
"<StsRsnInf><AddtlInf>Peter</AddtlInf><AddtlInf>Schmitz</AddtlInf></StsRsnInf>",
);
let doc = parse_pain002(&xml).unwrap();
assert_eq!(
doc.payment_info_statuses[0].transactions[1].additional_info,
["Peter", "Schmitz"]
);
}
#[test]
fn verification_statuses_are_not_acceptances() {
for code in ["RCVC", "RVMC", "RVNM", "RVNA", "RVCM"] {
let status = PaymentStatus::from_code(code);
assert!(!status.is_accepted(), "{code} is not an acceptance");
assert!(!status.is_rejected(), "{code} is not a rejection");
assert!(status.is_verification(), "{code} is a verification status");
assert_eq!(status.as_code(), code);
}
assert_eq!(
PaymentStatus::Rcvc.verification(),
Some(VerificationOutcome::Match)
);
assert_eq!(PaymentStatus::Rvcm.verification(), None);
assert!(!PaymentStatus::Actc.is_verification());
}
#[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, ["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,
Some(OriginalMessageType::DirectDebit)
);
assert_eq!(doc.group_status, Some(PaymentStatus::Actc));
}
}