use std::str::FromStr;
use crate::ct_to_eur_str;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown credit/debit indicator {0:?}: expected CRDT or DBIT")]
pub struct UnknownIndicator(
pub String,
);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CreditDebitIndicator {
#[default]
#[cfg_attr(feature = "serde", serde(rename = "CRDT"))]
Credit,
#[cfg_attr(feature = "serde", serde(rename = "DBIT"))]
Debit,
}
impl CreditDebitIndicator {
#[inline]
#[must_use]
pub const fn as_code(self) -> &'static str {
match self {
Self::Credit => "CRDT",
Self::Debit => "DBIT",
}
}
}
impl std::fmt::Display for CreditDebitIndicator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
impl FromStr for CreditDebitIndicator {
type Err = UnknownIndicator;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"CRDT" => Ok(Self::Credit),
"DBIT" => Ok(Self::Debit),
_ => Err(UnknownIndicator(s.to_owned())),
}
}
}
impl TryFrom<&str> for CreditDebitIndicator {
type Error = UnknownIndicator;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ReturnInfo {
pub reason_code: String,
pub additional_info: Option<String>,
}
impl ReturnInfo {
pub fn new(reason_code: impl Into<String>) -> Self {
Self {
reason_code: reason_code.into(),
additional_info: None,
}
}
#[must_use]
pub fn with_additional_info(mut self, info: impl Into<String>) -> Self {
self.additional_info = Some(info.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camt054Entry {
pub iban: String,
pub amount_ct: i64,
pub indicator: CreditDebitIndicator,
pub value_date_raw: String,
pub booking_date_raw: Option<String>,
pub reference: String,
pub end_to_end_id: Option<String>,
pub return_reason: Option<ReturnInfo>,
pub counterparty_name: Option<String>,
pub counterparty_iban: Option<String>,
}
impl Camt054Entry {
#[inline]
#[must_use]
pub const fn to_ledger_ct(&self) -> i64 {
match self.indicator {
CreditDebitIndicator::Credit => -self.amount_ct,
CreditDebitIndicator::Debit => self.amount_ct,
}
}
#[inline]
#[must_use]
pub const fn is_return(&self) -> bool {
self.return_reason.is_some()
}
#[must_use]
pub fn value_date(&self) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(&self.value_date_raw).ok()
}
#[must_use]
pub fn booking_date(&self) -> Option<crate::IsoDate> {
crate::IsoDate::parse_date_part(self.booking_date_raw.as_deref()?).ok()
}
#[must_use]
pub fn description(&self) -> String {
self.return_reason.as_ref().map_or_else(
|| match self.indicator {
CreditDebitIndicator::Credit => format!("CAMT.054 Zahlung: {}", self.reference),
CreditDebitIndicator::Debit => format!("CAMT.054 Abbuchung: {}", self.reference),
},
|r| {
format!(
"SEPA-Rückläufer {} ({})",
r.reason_code,
r.additional_info.as_deref().unwrap_or("Rückgabe"),
)
},
)
}
#[inline]
#[must_use]
pub fn amount_eur_str(&self) -> String {
ct_to_eur_str(self.amount_ct)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camt054Notification {
pub notification_id: String,
pub account_iban: String,
pub account_servicer_bic: Option<String>,
pub from_date: Option<String>,
pub to_date: Option<String>,
pub entries: Vec<crate::camt::CashEntry>,
}
impl Camt054Notification {
#[must_use]
pub fn net_movement_ct(&self) -> i64 {
self.entries
.iter()
.fold(0i64, |acc, e| acc.saturating_add(e.signed_ct()))
}
pub fn returns(&self) -> impl Iterator<Item = &crate::camt::CashEntry> {
self.entries.iter().filter(|e| e.is_return())
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camt054Document {
pub msg_id: String,
pub created_at: String,
pub namespace: Option<String>,
pub notifications: Vec<Camt054Notification>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Camt054ParseError {
#[error(transparent)]
Xml(#[from] crate::xml::XmlError),
#[error("not a camt.054 document: root element <BkToCstmrDbtCdtNtfctn> not found")]
NotCamt054,
}
pub fn parse_camt054(xml: &str) -> Result<Camt054Document, Camt054ParseError> {
use crate::camt;
use crate::xml::{Document, Node};
fn parse_notification(n: &Node) -> Camt054Notification {
let (from_date, to_date) = camt::period(n);
Camt054Notification {
notification_id: n.text_of("Id").unwrap_or_default().to_owned(),
account_iban: camt::account_iban(n),
account_servicer_bic: n
.path(&["Acct", "Svcr"])
.and_then(camt::agent_bic)
.map(str::to_owned),
from_date,
to_date,
entries: camt::entries_of(n),
}
}
let doc = Document::parse(xml)?;
let root = doc
.root
.child("BkToCstmrDbtCdtNtfctn")
.ok_or(Camt054ParseError::NotCamt054)?;
let grp_hdr = root.child("GrpHdr");
let text = |tag: &str| {
grp_hdr
.and_then(|h| h.text_of(tag))
.unwrap_or_default()
.to_owned()
};
Ok(Camt054Document {
msg_id: text("MsgId"),
created_at: text("CreDtTm"),
namespace: doc.namespace,
notifications: root
.children_named("Ntfctn")
.map(parse_notification)
.collect(),
})
}
#[cfg(feature = "json")]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SimpleJsonError {
#[error("expected a JSON object, found {found}")]
NotAnObject {
found: &'static str,
},
#[error("required field {field:?} is missing")]
MissingField {
field: &'static str,
},
#[error("field {field:?} must be {expected}")]
WrongType {
field: &'static str,
expected: &'static str,
},
#[error("field {field:?} must not be empty")]
Empty {
field: &'static str,
},
#[error("field {field:?}: {source}")]
InvalidAmount {
field: &'static str,
#[source]
source: crate::AmountError,
},
#[error("field {field:?}: {source}")]
InvalidDate {
field: &'static str,
#[source]
source: crate::DateError,
},
}
#[cfg(feature = "json")]
pub fn parse_simple_json(value: &serde_json::Value) -> Result<Camt054Entry, SimpleJsonError> {
use serde_json::Value;
if !value.is_object() {
return Err(SimpleJsonError::NotAnObject {
found: json_type_name(value),
});
}
let required_str = |field: &'static str| -> Result<String, SimpleJsonError> {
let raw = value
.get(field)
.ok_or(SimpleJsonError::MissingField { field })?
.as_str()
.ok_or(SimpleJsonError::WrongType {
field,
expected: "a string",
})?
.trim();
if raw.is_empty() {
return Err(SimpleJsonError::Empty { field });
}
Ok(raw.to_owned())
};
let optional_str = |field: &'static str| -> Result<Option<String>, SimpleJsonError> {
match value.get(field) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(SimpleJsonError::WrongType {
field,
expected: "a string",
}),
}
};
let iban = required_str("iban")?;
let date_raw = required_str("date")?;
crate::IsoDate::parse(&date_raw).map_err(|source| SimpleJsonError::InvalidDate {
field: "date",
source,
})?;
let amount_ct = parse_amount_ct(value.get("amount_eur").ok_or(
SimpleJsonError::MissingField {
field: "amount_eur",
},
)?)?;
let (amount_ct, indicator) = if amount_ct >= 0 {
(amount_ct, CreditDebitIndicator::Credit)
} else {
let magnitude = amount_ct
.checked_neg()
.ok_or_else(|| SimpleJsonError::InvalidAmount {
field: "amount_eur",
source: crate::AmountError::Overflow {
value: amount_ct.to_string(),
},
})?;
(magnitude, CreditDebitIndicator::Debit)
};
Ok(Camt054Entry {
iban,
amount_ct,
indicator,
value_date_raw: date_raw.clone(),
booking_date_raw: Some(date_raw),
reference: optional_str("reference")?.unwrap_or_default(),
end_to_end_id: optional_str("end_to_end_id")?,
return_reason: None,
counterparty_name: optional_str("counterparty_name")?,
counterparty_iban: optional_str("counterparty_iban")?,
})
}
#[cfg(feature = "json")]
const fn json_type_name(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
}
}
#[cfg(feature = "json")]
fn parse_amount_ct(val: &serde_json::Value) -> Result<i64, SimpleJsonError> {
const FIELD: &str = "amount_eur";
let raw = match val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
other => {
return Err(SimpleJsonError::WrongType {
field: FIELD,
expected: match other {
serde_json::Value::Null => "a decimal string or number, not null",
_ => "a decimal string or number",
},
});
}
};
crate::ct_from_eur_str(&raw).map_err(|source| SimpleJsonError::InvalidAmount {
field: FIELD,
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn credit_entry() -> Camt054Entry {
Camt054Entry {
iban: "DE89370400440532013000".to_owned(),
amount_ct: 15_500,
indicator: CreditDebitIndicator::Credit,
value_date_raw: "2026-07-10".to_owned(),
booking_date_raw: Some("2026-07-10".to_owned()),
reference: "Invoice 2026-06-001".to_owned(),
end_to_end_id: None,
return_reason: None,
counterparty_name: Some("Max Mustermann".to_owned()),
counterparty_iban: None,
}
}
#[test]
fn credit_to_ledger_ct_is_negative() {
assert_eq!(credit_entry().to_ledger_ct(), -15_500);
}
#[test]
fn debit_to_ledger_ct_is_positive() {
let mut entry = credit_entry();
entry.indicator = CreditDebitIndicator::Debit;
assert_eq!(entry.to_ledger_ct(), 15_500);
}
#[test]
fn is_return_false_by_default() {
assert!(!credit_entry().is_return());
}
#[test]
fn return_info_constructor() {
let r = ReturnInfo::new("MD01").with_additional_info("No mandate");
assert_eq!(r.reason_code, "MD01");
assert_eq!(r.additional_info.as_deref(), Some("No mandate"));
}
#[test]
fn is_return_true_with_reason() {
let mut entry = credit_entry();
entry.indicator = CreditDebitIndicator::Debit;
entry.return_reason = Some(ReturnInfo::new("MD01"));
assert!(entry.is_return());
assert!(entry.description().contains("MD01"));
}
#[test]
fn amount_eur_str_no_f64() {
assert_eq!(credit_entry().amount_eur_str(), "155.00");
}
#[test]
fn indicator_display_and_parse() {
assert_eq!(CreditDebitIndicator::Credit.to_string(), "CRDT");
assert_eq!(CreditDebitIndicator::Debit.to_string(), "DBIT");
assert_eq!(
"CRDT".parse::<CreditDebitIndicator>().unwrap(),
CreditDebitIndicator::Credit
);
assert_eq!(
"dbit".parse::<CreditDebitIndicator>().unwrap(),
CreditDebitIndicator::Debit
);
assert!("INVALID".parse::<CreditDebitIndicator>().is_err());
}
#[test]
fn credit_debit_indicator_codes() {
assert_eq!(CreditDebitIndicator::Credit.as_code(), "CRDT");
assert_eq!(CreditDebitIndicator::Debit.as_code(), "DBIT");
}
#[cfg(feature = "json")]
#[test]
fn parse_json_credit_string_amount() {
let json = serde_json::json!({
"iban": "DE89370400440532013000",
"amount_eur": "155.42",
"reference": "Ref001",
"date": "2026-07-10"
});
let entry = parse_simple_json(&json).unwrap();
assert_eq!(entry.amount_ct, 15_542);
assert_eq!(entry.indicator, CreditDebitIndicator::Credit);
assert_eq!(entry.to_ledger_ct(), -15_542);
}
#[cfg(feature = "json")]
#[test]
fn parse_json_debit_negative_amount() {
let json = serde_json::json!({
"iban": "DE89370400440532013000",
"amount_eur": "-75.00",
"reference": "Ref002",
"date": "2026-07-10"
});
let entry = parse_simple_json(&json).unwrap();
assert_eq!(entry.amount_ct, 7_500);
assert_eq!(entry.indicator, CreditDebitIndicator::Debit);
assert_eq!(entry.to_ledger_ct(), 7_500);
}
#[cfg(feature = "json")]
#[test]
fn parse_json_amount_no_decimal() {
let json = serde_json::json!({
"iban": "DE89370400440532013000",
"amount_eur": "100",
"reference": "Ref003",
"date": "2026-07-10"
});
let entry = parse_simple_json(&json).unwrap();
assert_eq!(entry.amount_ct, 10_000);
}
#[cfg(feature = "json")]
#[test]
fn parse_json_amount_fractional_only() {
let json = serde_json::json!({
"iban": "DE89370400440532013000",
"amount_eur": ".42",
"reference": "R",
"date": "2026-07-10"
});
let entry = parse_simple_json(&json).unwrap();
assert_eq!(entry.amount_ct, 42);
}
#[cfg(feature = "json")]
#[test]
fn every_rejection_says_which_field_and_why() {
let base = serde_json::json!({
"iban": "DE89370400440532013000",
"amount_eur": "100.00",
"date": "2026-07-10",
});
let with = |field: &str, v: serde_json::Value| {
let mut j = base.clone();
j[field] = v;
j
};
let without = |field: &str| {
let mut j = base.clone();
j.as_object_mut().unwrap().remove(field);
j
};
assert_eq!(
parse_simple_json(&without("iban")),
Err(SimpleJsonError::MissingField { field: "iban" })
);
assert_eq!(
parse_simple_json(&without("amount_eur")),
Err(SimpleJsonError::MissingField {
field: "amount_eur"
})
);
assert_eq!(
parse_simple_json(&without("date")),
Err(SimpleJsonError::MissingField { field: "date" })
);
assert_eq!(
parse_simple_json(&with("iban", serde_json::json!(""))),
Err(SimpleJsonError::Empty { field: "iban" })
);
assert_eq!(
parse_simple_json(&with("iban", serde_json::json!(42))),
Err(SimpleJsonError::WrongType {
field: "iban",
expected: "a string"
})
);
assert!(matches!(
parse_simple_json(&with("amount_eur", serde_json::json!("155,00"))),
Err(SimpleJsonError::InvalidAmount {
field: "amount_eur",
..
})
));
assert!(matches!(
parse_simple_json(&with("amount_eur", serde_json::json!(true))),
Err(SimpleJsonError::WrongType {
field: "amount_eur",
..
})
));
assert!(matches!(
parse_simple_json(&with("date", serde_json::json!("10.07.2026"))),
Err(SimpleJsonError::InvalidDate { field: "date", .. })
));
assert!(matches!(
parse_simple_json(&serde_json::json!([1, 2, 3])),
Err(SimpleJsonError::NotAnObject { found: "an array" })
));
}
#[cfg(feature = "json")]
#[test]
fn optional_fields_are_optional_but_still_type_checked() {
let json = serde_json::json!({
"iban": "DE89370400440532013000",
"amount_eur": 42,
"date": "2026-07-10",
"counterparty_name": "Max Mustermann",
});
let entry = parse_simple_json(&json).unwrap();
assert_eq!(entry.amount_ct, 4_200);
assert_eq!(entry.reference, "");
assert_eq!(entry.counterparty_name.as_deref(), Some("Max Mustermann"));
let mut bad = json;
bad["reference"] = serde_json::json!(7);
assert_eq!(
parse_simple_json(&bad),
Err(SimpleJsonError::WrongType {
field: "reference",
expected: "a string"
})
);
}
}