open-standards/openpay/crates/op-iso20022/src/statement.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

227 lines
8.3 KiB
Rust

//! A neutral, flattened view of a `camt.053` / `camt.054` statement.
//!
//! ISO 20022 statement entries are deeply nested (`Stmt[] -> Ntry[] ->
//! NtryDtls[] -> TxDtls[] -> Refs.EndToEndId`). Downstream consumers
//! (reconciliation) shouldn't have to learn that tree or take a
//! direct dependency on the per-family `open-payments-iso20022-camt`
//! crate. This module walks the tree once and yields a flat
//! [`StatementEntry`] per booked line, keeping ISO 20022 knowledge
//! behind the `op-iso20022` facade.
use crate::message::Message;
/// One flattened statement line.
///
/// Dates are left as the raw ISO 20022 strings (`YYYY-MM-DD` or an
/// xsd:dateTime); converting them to a timestamp is the consumer's
/// concern (it owns the calendar/timezone policy).
#[derive(Clone, Debug, PartialEq)]
pub struct StatementEntry {
/// `NtryRef`, falling back to `AcctSvcrRef`. The bank's own id
/// for this booked line.
pub reference: Option<String>,
/// The end-to-end id echoed from the original payment, dug out of
/// `NtryDtls -> TxDtls -> Refs.EndToEndId`. The strong join key
/// for reconciliation; `None` for lines the bank originated
/// (sweeps, fees) that carry no customer reference.
pub end_to_end_id: Option<String>,
/// Amount as it appears on the wire (always non-negative; the
/// sign is [`Self::is_credit`]).
pub amount_value: f64,
/// ISO 4217 alphabetic currency code.
pub currency: String,
/// `true` = `CRDT` (money into the account holder), `false` =
/// `DBIT`.
pub is_credit: bool,
/// `true` when the bank flagged this entry as a reversal
/// (`RvslInd`).
pub reversal: bool,
/// Value date (`ValDt`) — `Dt` or `DtTm`, raw string.
pub value_date: Option<String>,
/// Booking date (`BookgDt`) — `Dt` or `DtTm`, raw string.
pub booking_date: Option<String>,
/// `AddtlNtryInf` free text, if present.
pub additional_info: Option<String>,
}
impl Message {
/// Flatten a [`Message::Camt053`] into one [`StatementEntry`] per
/// booked `Ntry`. Returns an empty vec for any other message kind.
#[must_use]
pub fn camt053_entries(&self) -> Vec<StatementEntry> {
let Some(stmt_doc) = self.as_camt053() else {
return Vec::new();
};
let mut out = Vec::new();
for stmt in &stmt_doc.stmt {
let Some(entries) = &stmt.ntry else { continue };
for ntry in entries {
out.push(flatten_entry(ntry));
}
}
out
}
/// Flatten a [`Message::Camt054`] into one [`StatementEntry`] per
/// `Ntry`. Same `Ntry` shape as `camt.053`; we reuse the same
/// flattening helper, so downstream consumers don't need a
/// separate code path. Returns an empty vec for any other
/// message kind.
#[must_use]
pub fn camt054_entries(&self) -> Vec<StatementEntry> {
let Some(ntfctn_doc) = self.as_camt054() else {
return Vec::new();
};
let mut out = Vec::new();
for ntfctn in &ntfctn_doc.ntfctn {
let Some(entries) = &ntfctn.ntry else {
continue;
};
for ntry in entries {
out.push(flatten_entry(ntry));
}
}
out
}
}
fn flatten_entry(ntry: &iso20022_common::common::ReportEntry14) -> StatementEntry {
use iso20022_common::common::CreditDebitCode;
// End-to-end id lives one level down, in the first transaction
// detail that carries a Refs block.
let end_to_end_id = ntry.ntry_dtls.as_ref().and_then(|dtls| {
dtls.iter().find_map(|d| {
d.tx_dtls.as_ref().and_then(|txs| {
txs.iter()
.find_map(|t| t.refs.as_ref().and_then(|r| r.end_to_end_id.clone()))
})
})
});
let reference = ntry.ntry_ref.clone().or_else(|| ntry.acct_svcr_ref.clone());
let is_credit = matches!(ntry.cdt_dbt_ind, CreditDebitCode::CodeCRDT);
StatementEntry {
reference,
end_to_end_id,
amount_value: ntry.amt.value.abs(),
currency: ntry.amt.ccy.clone(),
is_credit,
reversal: ntry.rvsl_ind.unwrap_or(false),
value_date: ntry
.val_dt
.as_ref()
.and_then(|d| d.dt.clone().or_else(|| d.dt_tm.clone())),
booking_date: ntry
.bookg_dt
.as_ref()
.and_then(|d| d.dt.clone().or_else(|| d.dt_tm.clone())),
additional_info: ntry.addtl_ntry_inf.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use iso20022_common::common::{
AccountStatement13, ActiveOrHistoricCurrencyAndAmount, CreditDebitCode,
DateAndDateTime2Choice, EntryDetails13, EntryTransaction14, ReportEntry14,
TransactionReferences6,
};
use open_payments_iso20022_camt::camt_053_001_12::BankToCustomerStatementV12;
// Build the smallest tree that exercises every field flatten_entry
// reads, via the upstream `derive_default` impls. We deliberately
// construct the struct rather than parse XML: the flattening
// traversal is the logic this crate owns and must test; full
// camt.053 serde-round-trip is a separate conformance concern.
fn one_entry_statement() -> BankToCustomerStatementV12 {
let refs = TransactionReferences6 {
end_to_end_id: Some("ORD-77".to_owned()),
..Default::default()
};
let txd = EntryTransaction14 {
refs: Some(refs),
..Default::default()
};
let dtls = EntryDetails13 {
tx_dtls: Some(vec![txd]),
..Default::default()
};
let ntry = ReportEntry14 {
ntry_ref: Some("NTRY-1".to_owned()),
amt: ActiveOrHistoricCurrencyAndAmount {
ccy: "USD".to_owned(),
value: 52.50,
},
cdt_dbt_ind: CreditDebitCode::CodeCRDT,
rvsl_ind: Some(false),
val_dt: Some(DateAndDateTime2Choice {
dt: Some("2026-05-18".to_owned()),
dt_tm: None,
}),
ntry_dtls: Some(vec![dtls]),
addtl_ntry_inf: Some("coffee".to_owned()),
..Default::default()
};
let stmt = AccountStatement13 {
ntry: Some(vec![ntry]),
..Default::default()
};
BankToCustomerStatementV12 {
stmt: vec![stmt],
..Default::default()
}
}
#[test]
fn flattens_nested_entry() {
let msg = Message::Camt053(Box::new(one_entry_statement()));
let entries = msg.camt053_entries();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.reference.as_deref(), Some("NTRY-1"));
assert_eq!(e.end_to_end_id.as_deref(), Some("ORD-77"));
assert!((e.amount_value - 52.50).abs() < 1e-9);
assert_eq!(e.currency, "USD");
assert!(e.is_credit);
assert!(!e.reversal);
assert_eq!(e.value_date.as_deref(), Some("2026-05-18"));
assert_eq!(e.additional_info.as_deref(), Some("coffee"));
}
#[test]
fn non_camt053_message_yields_no_entries() {
// Any other kind flattens to nothing rather than erroring.
let admi = Message::Admi002(Box::default());
assert!(admi.camt053_entries().is_empty());
}
#[test]
fn camt054_flattens_through_the_same_helper() {
use iso20022_common::common::AccountNotification22;
use open_payments_iso20022_camt::camt_054_001_12::BankToCustomerDebitCreditNotificationV12;
// Re-use the camt.053 fixture's single entry, wrap it as a
// notification — the Ntry shape is identical, so the flatten
// result must match.
let stmt = one_entry_statement();
let ntry = stmt.stmt.into_iter().next().unwrap().ntry.unwrap();
let ntfctn = AccountNotification22 {
ntry: Some(ntry),
..Default::default()
};
let doc = BankToCustomerDebitCreditNotificationV12 {
ntfctn: vec![ntfctn],
..Default::default()
};
let msg = Message::Camt054(Box::new(doc));
let entries = msg.camt054_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].end_to_end_id.as_deref(), Some("ORD-77"));
assert_eq!(entries[0].currency, "USD");
assert!(entries[0].is_credit);
}
}