camt.rs (46762B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2026 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 use std::{fmt::Write as _, str::FromStr}; 21 22 use compact_str::CompactString; 23 use jiff::{ 24 Timestamp, 25 civil::{self}, 26 tz::TimeZone, 27 }; 28 use taler_common::types::{ 29 amount::{Amount, Currency}, 30 iban::IBAN, 31 payto::{BankID, IbanPayto, PaytoImpl, PaytoURI}, 32 }; 33 use taler_macros::EnumMeta; 34 use tracing::{trace, warn}; 35 use uuid::Uuid; 36 37 use crate::{ 38 iso20022::{ 39 ChargeBearer, 40 bank_tx_code::{BankTxDomainCode, BankTxFamilyCode, BankTxSubFamilyCode}, 41 model::{BatchId, InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, 42 status_code::ReturnReason, 43 }, 44 xml::{self, Xml, XmlAccess as _}, 45 }; 46 47 #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] 48 #[enum_meta(Str)] 49 #[allow(clippy::upper_case_acronyms)] 50 enum Kind { 51 CRDT, 52 DBIT, 53 } 54 55 #[derive(Debug, Clone, PartialEq, Eq)] 56 pub enum AccountId { 57 Iban(IBAN), 58 Other(CompactString), 59 } 60 61 impl std::fmt::Display for AccountId { 62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 match self { 64 AccountId::Iban(iban) => iban.fmt(f), 65 AccountId::Other(id) => id.fmt(f), 66 } 67 } 68 } 69 70 #[derive(Debug, Clone, PartialEq, Eq)] 71 pub struct AccountTransactions { 72 pub id: AccountId, 73 pub currency: Option<Currency>, 74 pub txs: Vec<Tx>, 75 } 76 77 #[derive(Debug, Clone, PartialEq, Eq)] 78 enum OutIds { 79 Tx(OutId), 80 Batch(BatchId), 81 } 82 83 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 84 struct BankTxCode { 85 domain: BankTxDomainCode, 86 family: BankTxFamilyCode, 87 subfamily: BankTxSubFamilyCode, 88 } 89 90 impl BankTxCode { 91 fn is_reversal(&self) -> bool { 92 matches!( 93 self.subfamily, 94 BankTxSubFamilyCode::RRTN 95 | BankTxSubFamilyCode::RPCR 96 | BankTxSubFamilyCode::PSTE 97 | BankTxSubFamilyCode::XRTN 98 | BankTxSubFamilyCode::RCDD 99 | BankTxSubFamilyCode::UPDD 100 | BankTxSubFamilyCode::PRDD 101 | BankTxSubFamilyCode::RCOV 102 ) 103 } 104 } 105 106 impl std::fmt::Display for BankTxCode { 107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 108 let Self { 109 domain, 110 family, 111 subfamily, 112 } = self; 113 write!( 114 f, 115 "{domain} {family} {subfamily} - '{}' '{}' '{}'", 116 domain.description(), 117 family.description(), 118 subfamily.description() 119 ) 120 } 121 } 122 123 /** Parse the instruction execution date */ 124 fn execution_date(n: Xml) -> xml::Result<Timestamp> { 125 // Value date if present else booking date 126 let date = n 127 .opt("ValDt") 128 .transpose() 129 .unwrap_or_else(|| n.one("BookgDt"))?; 130 let date = if let Some(date) = date.opt("Dt")? { 131 date.parse::<civil::Date>()?.into() 132 } else { 133 date.one("DtTm").parse::<civil::DateTime>()? 134 }; 135 Ok(date.to_zoned(TimeZone::UTC).unwrap().timestamp()) 136 } 137 138 /** Parse a payto */ 139 fn payto(n: Xml, prefix: &str) -> xml::Result<Option<PaytoURI>> { 140 let Some(parties) = n.opt("RltdPties")? else { 141 return Ok(None); 142 }; 143 144 let Some(iban) = parties 145 .opt(&format!("{prefix}Acct")) 146 .one("Id") 147 .opt("IBAN") 148 .parse()? 149 else { 150 return Ok(None); 151 }; 152 // TODO parse BIC 153 let bank_id = BankID { iban, bic: None }; 154 Ok(Some(if let Some(p) = parties.opt(prefix)? { 155 let name = p 156 .opt("Nm") 157 .transpose() 158 .unwrap_or_else(|| p.one("Pty").one("Nm"))? 159 .text(); 160 IbanPayto::new(bank_id).as_full_uri(name) 161 } else { 162 IbanPayto::new(bank_id).as_uri() 163 })) 164 } 165 166 /** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */ 167 fn outgoing_id(n: Xml, sref: Option<&str>) -> xml::Result<OutIds> { 168 Ok(if let Some(refs) = n.opt("Refs")? { 169 let e2e_id: Option<CompactString> = refs.opt("EndToEndId").parse()?; 170 let msg_id: Option<CompactString> = refs.opt("MsgId").parse()?; 171 let sref: Option<CompactString> = 172 sref.filter(|it| *it != "NOTPROVIDED").map(|it| it.into()); 173 match (e2e_id, msg_id) { 174 // This is a batch representation 175 (None, Some(msg_id)) => OutIds::Batch(BatchId { msg_id, sref }), 176 // If not set use MsgId as end-to-end ID for retrocompatibility 177 (Some(e2e_id), msg_id) if &e2e_id == "NOTPROVIDED" => OutIds::Tx(OutId { 178 e2e_id: msg_id.clone(), 179 msg_id, 180 sref, 181 }), 182 (e2e_id, msg_id) => OutIds::Tx(OutId { 183 msg_id, 184 e2e_id, 185 sref, 186 }), 187 } 188 } else { 189 OutIds::Tx(OutId { 190 msg_id: None, 191 e2e_id: None, 192 sref: sref.map(|it| it.into()), 193 }) 194 }) 195 } 196 197 /** Parse transaction ids as provided by bank */ 198 fn incoming_id(n: Xml, sref: Option<&str>) -> xml::Result<InId> { 199 if let Some(refs) = n.opt("Refs")? { 200 let uetr: Option<Uuid> = refs.opt("UETR").parse()?; 201 let tx_id: Option<CompactString> = refs.opt("TxId").parse()?; 202 Ok(InId { 203 uetr, 204 tx_id, 205 sref: sref.map(|it| it.into()), 206 }) 207 } else { 208 Ok(InId { 209 uetr: None, 210 tx_id: None, 211 sref: sref.map(|it| it.into()), 212 }) 213 } 214 } 215 216 /** Parse transaction wire transfer subject */ 217 fn wire_transfer_subject(n: Xml) -> xml::Result<Option<String>> { 218 Ok(n.opt("RmtInf")?.map(|n| { 219 // Concat all lines 220 let mut s = n.many("Ustrd").map(|n| n.text()).collect::<String>(); 221 // Merge whitespace 222 let mut pending_space = false; 223 s.retain(|c| { 224 if c.is_ascii_whitespace() { 225 if pending_space { 226 false 227 } else { 228 pending_space = true; 229 true 230 } 231 } else if pending_space { 232 pending_space = false; 233 true 234 } else { 235 true 236 } 237 }); 238 s 239 })) 240 } 241 242 /** Parse and format transaction return reasons */ 243 fn return_reason(n: Xml) -> xml::Result<String> { 244 let mut buf = String::new(); 245 if let Some(n) = n.opt("RtrInf")? { 246 let code: ReturnReason = n.one("Rsn").one("Cd").parse()?; 247 248 write!(&mut buf, "{code} '{}'", code.description()).unwrap(); 249 let mut infos = n.many("AddtlInf"); 250 if let Some(first) = infos.next() { 251 buf.push_str(" - '"); 252 buf.push_str(first.text()); 253 for info in infos { 254 buf.push_str(info.text()); 255 } 256 buf.push('\''); 257 } 258 } else if let Some(n) = wire_transfer_subject(n)? { 259 return Ok(n); 260 } 261 Ok(buf) 262 } 263 /** Parse amount */ 264 fn amount(n: Xml) -> xml::Result<Amount> { 265 let amt = n.one("Amt")?; 266 let currency = amt.attr("Ccy")?; 267 let amount = amt.text(); 268 let concat = format!("{currency}:0{amount}"); 269 Amount::from_str(&concat).map_err(|e| amt.parse_err(e)) 270 } 271 272 #[derive(Debug, Clone, Copy)] 273 struct ComplexAmount { 274 /// Transaction amount 275 amount: Amount, 276 /// The applied fee 277 fee: Amount, 278 } 279 280 impl ComplexAmount { 281 /// Check that entry and tx amount are compatible and return the result 282 fn resolve(&self, tx: &ComplexAmount) -> xml::Result<ComplexAmount> { 283 // Most time transaction will match 284 if self.amount == tx.amount && self.fee == tx.fee { 285 return Ok(*self); 286 } 287 288 // Or one of the level is missing the fee 289 if (tx.amount.decimal() > tx.fee.decimal() 290 && tx.amount.try_sub(&tx.fee).unwrap() == self.amount) 291 || self.amount.try_sub(&self.fee).unwrap() == tx.amount 292 { 293 return if tx.fee.is_zero() { Ok(*self) } else { Ok(*tx) }; 294 } 295 296 // Or the conversion information are only present at the entry layer 297 if tx.amount.currency != self.amount.currency { 298 return Ok(*self); 299 } 300 301 panic!("Amount mismatch, got {self:?} in the entry and {tx:?} in the tx") 302 } 303 } 304 305 struct ChargeRecord { 306 amount: Amount, 307 kind: Kind, 308 included: bool, 309 bearer: ChargeBearer, 310 } 311 312 fn charges(n: Xml) -> xml::Result<Vec<ChargeRecord>> { 313 if let Some(n) = n.opt("Chrgs")? { 314 n.many("Rcrd") 315 .map(|n| { 316 Ok(ChargeRecord { 317 amount: amount(n)?, 318 kind: n.opt("CdtDbtInd").parse()?.unwrap_or(Kind::CRDT), 319 included: n.opt("ChrgInclInd").parse()? == Some(true), // TODO not clear in spec 320 bearer: n.opt("Br").parse()?.unwrap_or(ChargeBearer::SHAR), 321 }) 322 }) 323 .collect() 324 } else { 325 Ok(Vec::new()) 326 } 327 } 328 329 fn complex_amount(amt: Xml, charges: &[ChargeRecord]) -> xml::Result<ComplexAmount> { 330 // Amount before charges 331 let currency = amt.attr("Ccy")?; 332 // In case of fee overflow it's possible to have a negative amount here 333 // We ignore this as it will be handled elsewhere correctly 334 let amount = amt.text().trim_start_matches('-'); 335 let concat = format!("{currency}:0{amount}"); 336 337 let mut amount = Amount::from_str(&concat).map_err(|e| amt.parse_err(e))?; 338 let mut fee = Amount::zero(&amount.currency); 339 340 for chr in charges { 341 if chr.included && !chr.amount.is_zero() { 342 fee = fee.try_add(&chr.amount).expect("Should never overflow"); 343 if chr.kind == Kind::DBIT { 344 if chr.bearer == ChargeBearer::DEBT { 345 if chr.amount.decimal() > amount.decimal() { 346 // This can happen when an incoming transaction fail because of debit fee 347 amount = chr.amount.try_sub(&amount).expect("Should never overflow"); 348 } else { 349 amount = amount.try_sub(&chr.amount).expect("Should never overflow"); 350 } 351 } else if chr.bearer == ChargeBearer::CRED { 352 amount = amount.try_add(&chr.amount).expect("Should never overflow"); 353 } else { 354 return Err(amt.parse_err(format_args!( 355 "Included charge {} with bearer {}", 356 chr.kind, chr.bearer 357 ))); 358 } 359 } 360 } 361 } 362 363 Ok(ComplexAmount { amount, fee }) 364 } 365 366 /** Parse bank transaction code */ 367 fn bank_tx_code(n: Xml) -> xml::Result<BankTxCode> { 368 let domnd = n.one("Domn")?; 369 let fmly = domnd.one("Fmly")?; 370 Ok(BankTxCode { 371 domain: domnd.one("Cd").parse()?, 372 family: fmly.one("Cd").parse()?, 373 subfamily: fmly.one("SubFmlyCd").parse()?, 374 }) 375 } 376 377 /** Parse camt files */ 378 pub fn parse_camt(xml: &[u8]) -> xml::Result<Vec<AccountTransactions>> { 379 /* 380 In ISO 20022 specifications, most fields are optional and the same information 381 can be written several times in different places. For libeufin, we're only 382 interested in a subset of the available values that can be found in both camt.052, 383 camt.053 and camt.054. This function should not fail on legitimate files and should 384 simply warn when available information are insufficient. 385 386 EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the 387 UETR (unique end-to-end transaction reference), which is a universally unique 388 identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification) 389 is a unique identification as assigned by the first instructing agent. As its format 390 is ambiguous, its uniqueness is not guaranteed by the standard, and it is only 391 supposed to be unique for a “pre-agreed period”, whatever that means. These two 392 identifiers are optional in the standard, but have the advantage of being unique 393 and can be used to track a transaction between banks so we use them when available. 394 395 It is also possible to use AccountServicerReference, which is a unique reference 396 assigned by the account servicing institution. They can be present at several levels 397 (batch level, transaction level, etc.) and are often optional. They also have the 398 disadvantage of being known only by the account servicing institution. They should 399 therefore only be used as a last resort. 400 */ 401 trace!("Parse transactions camt file"); 402 403 fn parse_inner(root: Xml) -> xml::Result<AccountTransactions> { 404 let (id, currency) = { 405 let account = root.one("Acct")?; 406 let id = account.one("Id")?; 407 let account_id = if let Some(iban) = id.opt("IBAN")? { 408 AccountId::Iban(iban.parse()?) 409 } else { 410 AccountId::Other(id.one("Othr").one("Id").parse()?) 411 }; 412 let currency: Option<Currency> = account.opt("Ccy").parse()?; 413 (account_id, currency) 414 }; 415 let txs = root.many("Ntry").try_fold(Vec::new(), |mut txs, entry| { 416 // Skip if not booked 417 if !{ 418 let status = entry.one("Sts")?; 419 let status = status 420 .opt("Cd")? 421 .map(|n| n.text()) 422 .unwrap_or_else(|| status.text()); 423 status == "BOOK" 424 } { 425 return Ok(txs); 426 } 427 428 let reversal = entry.opt("RvslInd").parse()? == Some(true); 429 let entry_code = bank_tx_code(entry.one("BkTxCd")?)?; 430 let entry_kind = entry.opt("CdtDbtInd").parse::<Kind>()?; 431 let entry_ref = entry.opt("AcctSvcrRef").parse::<CompactString>()?; 432 let date = execution_date(entry)?; 433 let entry_charges = charges(entry)?; 434 let entry_amount = complex_amount(entry.one("Amt")?, &entry_charges)?; 435 436 let Some(details) = entry.opt("NtryDtls")? else { 437 return Ok(txs); 438 }; 439 // When an entry only contain a single transactions information will sometimes only be stored at the entry level 440 let unique = details.many("TxDtls").count() == 1; 441 for tx in details.many("TxDtls") { 442 // Check information are present and coherent 443 let kind = tx.opt("CdtDbtInd").parse()?.or(entry_kind).unwrap(); 444 445 // Sometimes the transaction level have a more precise bank transaction code 446 let code = tx 447 .opt("BkTxCd")? 448 .map(bank_tx_code) 449 .transpose()? 450 .unwrap_or(entry_code); 451 452 let tx_charges = charges(tx)?; 453 // Amount 454 let amount = if unique { 455 // When unique the charges can be only at the entry level 456 if let Some(amt) = tx.opt("Amt")? { 457 let tx_amount = complex_amount( 458 amt, 459 if tx_charges.is_empty() { 460 &entry_charges 461 } else { 462 &tx_charges 463 }, 464 )?; 465 // Check coherence 466 entry_amount.resolve(&tx_amount)? 467 } else { 468 entry_amount 469 } 470 } else { 471 // When many inner transaction the entry level is an aggregate of them 472 // We only use the transaction level information 473 complex_amount(tx.one("Amt")?, &tx_charges)? 474 }; 475 476 // We can only use the entry ref as the transaction ref if there is a single transaction in the batch 477 let sref: Option<CompactString> = tx 478 .opt("Refs") 479 .opt("AcctSvcrRef") 480 .parse::<CompactString>()? 481 .or_else(|| unique.then(|| entry_ref.clone()).flatten()); 482 483 match (kind, code.is_reversal() || reversal) { 484 (Kind::CRDT, true) => { 485 let out_id = outgoing_id(tx, sref.as_deref())?; 486 if let OutIds::Tx(OutId { 487 msg_id, 488 e2e_id: Some(e2e_id), 489 .. 490 }) = out_id 491 { 492 txs.push(Tx::Reversal(OutReversal { 493 e2e_id, 494 msg_id, 495 reason: return_reason(tx)?, 496 execution_time: date, 497 })) 498 } else { 499 warn!("missing unique ID for Credit reversal {out_id:?}"); 500 } 501 } 502 (Kind::DBIT, true) | (Kind::CRDT, false) => { 503 let id = incoming_id(tx, sref.as_deref())?; 504 if id.uetr.is_none() && id.tx_id.is_none() && id.sref.is_none() { 505 warn!("missing unique ID for Credit") 506 } else { 507 txs.push(Tx::In(InTx { 508 id, 509 amount: amount.amount, 510 credit_fee: amount.fee, 511 subject: wire_transfer_subject(tx)?, 512 execution_time: date, 513 debtor: payto(tx, "Dbtr")?, 514 })); 515 } 516 } 517 (Kind::DBIT, false) => { 518 let id = outgoing_id(tx, sref.as_deref())?; 519 match id { 520 OutIds::Tx(id) => { 521 if id.e2e_id.is_none() && id.msg_id.is_none() && id.sref.is_none() { 522 warn!("missing unique ID for Debit") 523 } else { 524 txs.push(Tx::Out(OutTx { 525 id, 526 amount: amount.amount, 527 debit_fee: amount.fee, 528 subject: wire_transfer_subject(tx)?, 529 execution_time: date, 530 creditor: payto(tx, "Cdtr")?, 531 })); 532 } 533 } 534 OutIds::Batch(BatchId { msg_id, .. }) => { 535 txs.push(Tx::Batch(OutBatch { 536 msg_id, 537 execution_time: date, 538 })); 539 } 540 } 541 } 542 } 543 } 544 Ok(txs) 545 })?; 546 Ok(AccountTransactions { id, currency, txs }) 547 } 548 549 Xml::parse(xml, "Document", |root| { 550 if let Some(camt053) = root.opt("BkToCstmrStmt")? { 551 camt053.many("Stmt").map(parse_inner).collect() 552 } else if let Some(camt052) = root.opt("BkToCstmrAcctRpt")? { 553 camt052.many("Rpt").map(parse_inner).collect() 554 } else if let Some(camt054) = root.opt("BkToCstmrDbtCdtNtfctn")? { 555 camt054.many("Ntfctn").map(parse_inner).collect() 556 } else { 557 Err(root.parse_err("Malformed camt file")) 558 } 559 }) 560 } 561 562 #[cfg(test)] 563 pub mod test { 564 use std::str::FromStr; 565 566 use jiff::civil::Date; 567 use taler_common::types::{ 568 amount::{Amount, Currency}, 569 iban::IBAN, 570 payto::{BankID, IbanPayto, PaytoImpl as _, PaytoURI}, 571 utils::date_to_utc_ts, 572 }; 573 574 use crate::iso20022::{ 575 camt::{AccountId, parse_camt}, 576 model::{InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, 577 }; 578 579 fn iban_payto(iban: impl AsRef<str>, name: impl AsRef<str>) -> PaytoURI { 580 IbanPayto::new(BankID { 581 iban: iban.as_ref().parse().expect("invalid IBAN"), 582 bic: None, 583 }) 584 .as_full_uri(name.as_ref()) 585 } 586 587 #[track_caller] 588 pub fn check_tx(path: &str, iban: &str, currency: Option<&str>, txs: &[Tx]) { 589 let content = std::fs::read(path).unwrap(); 590 let res = parse_camt(&content).unwrap(); 591 assert_eq!(res.len(), 1); 592 593 let first = &res[0]; 594 assert_eq!(first.id, AccountId::Iban(IBAN::from_str(iban).unwrap())); 595 assert_eq!( 596 first.currency, 597 currency.map(|it| Currency::from_str(it).unwrap()) 598 ); 599 pretty_assertions::assert_eq!(first.txs, txs); 600 } 601 602 pub fn tx_out( 603 id: (Option<&str>, Option<&str>, Option<&str>), 604 amount: &str, 605 debit_fee: &str, 606 subject: Option<&str>, 607 execution_time: &str, 608 creditor: Option<(&str, &str)>, 609 ) -> Tx { 610 Tx::Out(OutTx { 611 id: OutId::new( 612 id.0.map(Into::into), 613 id.1.map(Into::into), 614 id.2.map(Into::into), 615 ), 616 amount: Amount::from_str(amount).unwrap(), 617 debit_fee: Amount::from_str(debit_fee).unwrap(), 618 subject: subject.map(Into::into), 619 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 620 creditor: creditor.map(|(iban, name)| iban_payto(iban, name)), 621 }) 622 } 623 624 pub fn tx_in( 625 id: (Option<&str>, Option<&str>, Option<&str>), 626 amount: &str, 627 credit_fee: &str, 628 subject: Option<&str>, 629 execution_time: &str, 630 debtor: Option<(&str, &str)>, 631 ) -> Tx { 632 Tx::In(InTx { 633 id: InId::new( 634 id.0.map(|it| it.parse().unwrap()), 635 id.1.map(Into::into), 636 id.2.map(Into::into), 637 ), 638 amount: Amount::from_str(amount).unwrap(), 639 credit_fee: Amount::from_str(credit_fee).unwrap(), 640 subject: subject.map(Into::into), 641 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 642 debtor: debtor.map(|(iban, name)| iban_payto(iban, name)), 643 }) 644 } 645 646 pub fn tx_reversal( 647 e2e_id: &str, 648 msg_id: Option<&str>, 649 reason: &str, 650 execution_time: &str, 651 ) -> Tx { 652 Tx::Reversal(OutReversal { 653 e2e_id: e2e_id.parse().unwrap(), 654 msg_id: msg_id.map(Into::into), 655 reason: reason.into(), 656 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 657 }) 658 } 659 660 pub fn tx_batch(msg_id: &str, execution_time: &str) -> Tx { 661 Tx::Batch(OutBatch { 662 msg_id: msg_id.into(), 663 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 664 }) 665 } 666 667 #[test] 668 fn postfinance_camt054() { 669 check_tx( 670 "sample/platform/postfinance_camt054.xml", 671 "CH9289144596463965762", 672 Some("CHF"), 673 &[ 674 tx_out( 675 ( 676 Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), 677 Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), 678 None, 679 ), 680 "CHF:3.00", 681 "CHF:0", 682 None, 683 "2024-01-15", 684 None, 685 ), 686 tx_in( 687 ( 688 Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), 689 None, 690 Some("231121CH0AZWCR9T"), 691 ), 692 "CHF:10", 693 "CHF:0", 694 Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG"), 695 "2023-12-19", 696 Some(("CH7389144832588726658", "Mr Test")), 697 ), 698 tx_in( 699 ( 700 Some("62e2b511-7313-4ccd-8d40-c9d8e612cd72"), 701 None, 702 Some("231121CH0AZWCVR1"), 703 ), 704 "CHF:2.53", 705 "CHF:0", 706 Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB"), 707 "2023-12-19", 708 Some(("CH7389144832588726658", "Mr Test")), 709 ), 710 tx_reversal( 711 "50820f78-9024-44ff-978d-63a18c", 712 Some("50820f78-9024-44ff-978d-63a18c"), 713 "", 714 "2024-01-15", 715 ), 716 tx_batch("ZS1PGNTSV0ZNDFAJBBWWB8015G", "2024-01-15"), 717 ], 718 ); 719 } 720 721 #[test] 722 fn postfinance_camt053() { 723 check_tx( 724 "sample/platform/postfinance_camt053.xml", 725 "CH9289144596463965762", 726 Some("CHF"), 727 &[ 728 tx_reversal( 729 "889d1a80-1267-49bd-8fcc-85701a", 730 Some("889d1a80-1267-49bd-8fcc-85701a"), 731 "InconsistenWithEndCustomer 'Identification of end customer is not consistent with associated account number, organisation ID or private ID' - 'more info here ...'", 732 "2023-11-22", 733 ), 734 tx_reversal( 735 "4cc61cc7-6230-49c2-b5e2-b40bbb", 736 Some("4cc61cc7-6230-49c2-b5e2-b40bbb"), 737 "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing' - 'more info here ...'", 738 "2023-11-22", 739 ), 740 tx_batch("EB4D22D428214261B2B3012D2A8CEC36", "2024-08-26"), 741 ], 742 ); 743 } 744 745 #[test] 746 fn raiffeisen_camt053() { 747 check_tx( 748 "sample/platform/raiffeisen_camt053.xml", 749 "CH7389144832588726658", 750 None, 751 &[ 752 tx_in( 753 (None, None, Some("A200020494367552")), 754 "CHF:20000", 755 "CHF:0", 756 Some("1. TZ 2025"), 757 "2025-12-23", 758 Some(("CH7389144832588726658", "KANTON BERN")), 759 ), 760 tx_out( 761 (None, None, Some("19868398389")), 762 "CHF:15", 763 "CHF:0", 764 None, 765 "2025-12-31", 766 None, 767 ), 768 tx_out( 769 (None, None, Some("19890406743")), 770 "CHF:2", 771 "CHF:0", 772 None, 773 "2025-12-31", 774 None, 775 ), 776 tx_out( 777 (None, None, Some("19885172770")), 778 "CHF:3", 779 "CHF:0", 780 None, 781 "2025-12-31", 782 None, 783 ), 784 ], 785 ); 786 } 787 788 #[test] 789 fn valiant_camt052() { 790 check_tx( 791 "sample/platform/valiant_camt052.xml", 792 "CH7389144832588726658", 793 Some("CHF"), 794 &[ 795 tx_out( 796 ( 797 Some("MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L"), 798 Some("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"), 799 Some("ZV20251030/511372/1"), 800 ), 801 "CHF:0.1", 802 "CHF:0", 803 Some("single 2025-10-30T09:46:04.55293090 9Z"), 804 "2025-10-30", 805 Some(("CH7389144832588726658", "Grothoff Hans")), 806 ), 807 tx_out( 808 ( 809 Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), 810 Some("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), 811 Some("ZV20251030/511373/1"), 812 ), 813 "CHF:0.1", 814 "CHF:0", 815 Some("multi 0 2025-10-30T09:46:10.3877961 30Z"), 816 "2025-10-30", 817 Some(("CH7389144832588726658", "Grothoff Hans")), 818 ), 819 tx_out( 820 ( 821 Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), 822 Some("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), 823 Some("ZV20251030/511373/2"), 824 ), 825 "CHF:0.11", 826 "CHF:0", 827 Some("multi 1 2025-10-30T09:46:10.3877961 30Z"), 828 "2025-10-30", 829 Some(("CH7389144832588726658", "Grothoff Hans")), 830 ), 831 tx_out( 832 ( 833 Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), 834 Some("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), 835 Some("ZV20251030/511373/3"), 836 ), 837 "CHF:0.12", 838 "CHF:0", 839 Some("multi 2 2025-10-30T09:46:10.3877961 30Z"), 840 "2025-10-30", 841 Some(("CH7389144832588726658", "Grothoff Hans")), 842 ), 843 tx_out( 844 ( 845 Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), 846 Some("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), 847 Some("ZV20251030/511373/4"), 848 ), 849 "CHF:0.13", 850 "CHF:0", 851 Some("multi 3 2025-10-30T09:46:10.3877961 30Z"), 852 "2025-10-30", 853 Some(("CH7389144832588726658", "Grothoff Hans")), 854 ), 855 tx_in( 856 (None, Some("51030655601.0001"), Some("ZV20251030/514778/1")), 857 "CHF:0.85", 858 "CHF:0", 859 Some("fun stuff"), 860 "2025-10-30", 861 Some(("CH7389144832588726658", "Grothoff Hans")), 862 ), 863 tx_in( 864 (None, Some("51030655601.0002"), Some("ZV20251030/514779/1")), 865 "CHF:0.95", 866 "CHF:0", 867 Some("Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0"), 868 "2025-10-30", 869 Some(("CH7389144832588726658", "Grothoff Hans")), 870 ), 871 tx_out( 872 ( 873 Some("X166701F6RV59LP71RVWVIW9SV2AFZYLG4"), 874 Some("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"), 875 Some("ZV20251030/524078/1"), 876 ), 877 "CHF:0.21", 878 "CHF:0", 879 Some("bad name 2025-10-30T12:03:24.997478 811Z"), 880 "2025-10-30", 881 Some(("CH6208704048981247126", "John Smith")), 882 ), 883 tx_out( 884 ( 885 Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), 886 Some("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), 887 Some("ZV20251030/524079/1"), 888 ), 889 "CHF:0.1", 890 "CHF:0", 891 Some("single 2025-10-30T12:04:00.37042083 6Z"), 892 "2025-10-30", 893 Some(("CH7389144832588726658", "Grothoff Hans")), 894 ), 895 tx_out( 896 ( 897 Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), 898 Some("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), 899 Some("ZV20251030/524079/2"), 900 ), 901 "CHF:0.21", 902 "CHF:0", 903 Some("bad name 2025-10-30T12:03:53.042190 686Z"), 904 "2025-10-30", 905 Some(("CH6208704048981247126", "John Smith")), 906 ), 907 tx_reversal( 908 "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", 909 None, 910 "Error msg in german", 911 "2025-10-30", 912 ), 913 tx_reversal( 914 "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", 915 None, 916 "Error msg in german", 917 "2025-10-30", 918 ), 919 tx_out( 920 ( 921 Some("OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P"), 922 Some("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"), 923 Some("ZV20251030/524077/1"), 924 ), 925 "CHF:0.23", 926 "CHF:5", 927 Some("foreign iban 2025-10-30T12:03:44.0972 63765Z"), 928 "2025-10-30", 929 Some(("DE48330605920000686018", "Christian Grothoff")), 930 ), 931 tx_out( 932 ( 933 Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), 934 Some("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), 935 Some("ZV20251030/524080/1"), 936 ), 937 "CHF:0.23", 938 "CHF:5", 939 Some("foreign iban 2025-10-30T12:03:58.0046 73747Z"), 940 "2025-10-30", 941 Some(("DE48330605920000686018", "Christian Grothoff")), 942 ), 943 tx_in( 944 ( 945 Some("7b76d488-05d5-44ab-9d77-31d4165ec158"), 946 Some("00204EQY370"), 947 Some("ZV20251118/685062/1"), 948 ), 949 "CHF:4.55", 950 "CHF:0", 951 Some("TEST"), 952 "2025-11-18", 953 None, 954 ), 955 ], 956 ) 957 } 958 959 #[test] 960 fn gls_camt052() { 961 check_tx( 962 "sample/platform/gls_camt052.xml", 963 "DE84500105177118117964", 964 Some("EUR"), 965 &[ 966 tx_out( 967 ( 968 Some("COMPAT_SUCCESS"), 969 Some("COMPAT_SUCCESS"), 970 Some("2024041801514102000"), 971 ), 972 "EUR:2", 973 "EUR:0", 974 Some("TestABC123"), 975 "2024-04-18", 976 Some(("DE20500105172419259181", "John Smith")), 977 ), 978 tx_reversal( 979 "8XK8Z7RAX224FGWK832FD40GYC", 980 None, 981 "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'", 982 "2024-09-05", 983 ), 984 tx_in( 985 ( 986 None, 987 Some("BYLADEM1WOR-G2910276709458A2"), 988 Some("2024041210041357000"), 989 ), 990 "EUR:3", 991 "EUR:0", 992 Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), 993 "2024-04-12", 994 Some(("DE84500105177118117964", "John Smith")), 995 ), 996 tx_reversal( 997 "COMPAT_FAILURE", 998 None, 999 "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", 1000 "2024-04-12", 1001 ), 1002 tx_out( 1003 ( 1004 Some("BATCH_SINGLE_SUCCESS"), 1005 Some("FD622SMXKT5QWSAHDY0H8NYG3G"), 1006 Some("2024090216552232000"), 1007 ), 1008 "EUR:1.1", 1009 "EUR:0", 1010 Some("single 2024-09-02T14:29:52.875253314Z"), 1011 "2024-09-02", 1012 Some(("DE89500105173198527518", "Grothoff Hans")), 1013 ), 1014 tx_out( 1015 ( 1016 Some("YF5QBARGQ0MNY0VK59S477VDG4"), 1017 Some("YF5QBARGQ0MNY0VK59S477VDG4"), 1018 Some("2024041810552821000"), 1019 ), 1020 "EUR:1.1", 1021 "EUR:0", 1022 Some("Simple tx"), 1023 "2024-04-18", 1024 Some(("DE20500105172419259181", "John Smith")), 1025 ), 1026 tx_batch("BATCH_MANY_SUCCESS", "2024-09-20"), 1027 tx_out( 1028 ( 1029 Some("BATCH_SINGLE_RETURN"), 1030 Some("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5"), 1031 Some("2024092100252498000"), 1032 ), 1033 "EUR:0.42", 1034 "EUR:0", 1035 Some("This should fail because bad iban"), 1036 "2024-09-23", 1037 Some(("DE18500105173385245163", "John Smith")), 1038 ), 1039 tx_reversal( 1040 "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", 1041 None, 1042 "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'", 1043 "2024-09-24", 1044 ), 1045 ], 1046 ) 1047 } 1048 1049 #[test] 1050 fn gls_camt053() { 1051 check_tx( 1052 "sample/platform/gls_camt053.xml", 1053 "DE84500105177118117964", 1054 Some("EUR"), 1055 &[ 1056 tx_out( 1057 ( 1058 Some("COMPAT_SUCCESS"), 1059 Some("COMPAT_SUCCESS"), 1060 Some("2024041801514102000"), 1061 ), 1062 "EUR:2", 1063 "EUR:0", 1064 Some("TestABC123"), 1065 "2024-04-18", 1066 Some(("DE20500105172419259181", "John Smith")), 1067 ), 1068 tx_reversal( 1069 "KGTDBASWTJ6JM89WXD3Q5KFQC4", 1070 None, 1071 "Retoure aus SEPA Überweisung multi line", 1072 "2024-09-04", 1073 ), 1074 tx_batch("BATCH_MANY_PART", "2024-09-04"), 1075 tx_in( 1076 ( 1077 None, 1078 Some("BYLADEM1WOR-G2910276709458A2"), 1079 Some("2024041210041357000"), 1080 ), 1081 "EUR:3", 1082 "EUR:0", 1083 Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), 1084 "2024-04-12", 1085 Some(("DE84500105177118117964", "John Smith")), 1086 ), 1087 tx_reversal( 1088 "COMPAT_FAILURE", 1089 None, 1090 "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", 1091 "2024-04-12", 1092 ), 1093 tx_out( 1094 ( 1095 Some("BATCH_SINGLE_SUCCESS"), 1096 Some("FD622SMXKT5QWSAHDY0H8NYG3G"), 1097 Some("2024090216552232000"), 1098 ), 1099 "EUR:1.1", 1100 "EUR:0", 1101 Some("single 2024-09-02T14:29:52.875253314Z"), 1102 "2024-09-02", 1103 Some(("DE89500105173198527518", "Grothoff Hans")), 1104 ), 1105 tx_out( 1106 ( 1107 Some("YF5QBARGQ0MNY0VK59S477VDG4"), 1108 Some("YF5QBARGQ0MNY0VK59S477VDG4"), 1109 Some("2024041810552821000"), 1110 ), 1111 "EUR:1.1", 1112 "EUR:0", 1113 Some("Simple tx"), 1114 "2024-04-18", 1115 Some(("DE20500105172419259181", "John Smith")), 1116 ), 1117 ], 1118 ) 1119 } 1120 1121 #[test] 1122 fn gls_camt054() { 1123 check_tx( 1124 "sample/platform/gls_camt054.xml", 1125 "DE84500105177118117964", 1126 Some("EUR"), 1127 &[tx_in( 1128 (None, Some("IS11PGENODEFF2DA8899900378806"), None), 1129 "EUR:2.5", 1130 "EUR:0", 1131 Some("Test ICT"), 1132 "2024-05-05", 1133 Some(("DE84500105177118117964", "Mr Test")), 1134 )], 1135 ); 1136 } 1137 1138 #[test] 1139 fn maerki_baumann_camt053() { 1140 check_tx( 1141 "sample/platform/maerki_baumann_camt053.xml", 1142 "CH7389144832588726658", 1143 Some("CHF"), 1144 &[ 1145 tx_in( 1146 ( 1147 Some("adbe4a5a-6cea-4263-b259-8ab964561a32"), 1148 Some("41103099704.0002"), 1149 Some("ZV20241104/765446/1"), 1150 ), 1151 "CHF:1", 1152 "CHF:0.2", 1153 Some("SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G"), 1154 "2024-11-04", 1155 Some(("CH7389144832588726658", "Mr Test")), 1156 ), 1157 tx_in( 1158 ( 1159 Some("7371795e-62fa-42dd-93b7-da89cc120faa"), 1160 Some("41103099704.0003"), 1161 Some("ZV20241104/765447/1"), 1162 ), 1163 "CHF:1", 1164 "CHF:0.2", 1165 Some("Random subject"), 1166 "2024-11-04", 1167 Some(("CH7389144832588726658", "Mr Test")), 1168 ), 1169 tx_in( 1170 (None, Some("50523424675.0001"), Some("ZV20250523/851716/1")), 1171 "CHF:0.5", 1172 "CHF:0.2", 1173 None, 1174 "2025-05-23", 1175 Some(("CH7389144832588726658", "Grothoff Hans")), 1176 ), 1177 tx_out( 1178 ( 1179 Some("BATCH_SINGLE_REPORTING"), 1180 Some("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), 1181 Some("ZV20241121/773541/1"), 1182 ), 1183 "CHF:0.1", 1184 "CHF:0", 1185 Some("multi 0 2024-11-21T15:21:59.8859234 63Z"), 1186 "2024-11-27", 1187 Some(("CH7389144832588726658", "Grothoff Hans")), 1188 ), 1189 tx_out( 1190 ( 1191 Some("BATCH_SINGLE_REPORTING"), 1192 Some("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), 1193 Some("ZV20241121/773541/4"), 1194 ), 1195 "CHF:0.13", 1196 "CHF:0", 1197 Some("multi 3 2024-11-21T15:21:59.8859234 63Z"), 1198 "2024-11-27", 1199 Some(("CH7389144832588726658", "Grothoff Hans")), 1200 ), 1201 tx_out( 1202 ( 1203 Some("BATCH_SINGLE_REPORTING"), 1204 Some("A09R35EW0359SZ51464E7TC37A0P2CBK04"), 1205 Some("ZV20241121/773541/3"), 1206 ), 1207 "CHF:0.12", 1208 "CHF:0", 1209 Some("multi 2 2024-11-21T15:21:59.8859234 63Z"), 1210 "2024-11-27", 1211 Some(("CH7389144832588726658", "Grothoff Hans")), 1212 ), 1213 tx_out( 1214 ( 1215 Some("BATCH_SINGLE_REPORTING"), 1216 Some("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), 1217 Some("ZV20241121/773541/2"), 1218 ), 1219 "CHF:0.11", 1220 "CHF:0", 1221 Some("multi 1 2024-11-21T15:21:59.8859234 63Z"), 1222 "2024-11-27", 1223 Some(("CH7389144832588726658", "Grothoff Hans")), 1224 ), 1225 tx_in( 1226 ( 1227 Some("f203fbb4-6e13-4c78-9b2a-d852fea6374a"), 1228 Some("41202060702.0001"), 1229 Some("ZV20241202/778108/1"), 1230 ), 1231 "CHF:0.05", 1232 "CHF:0.2", 1233 Some("mini"), 1234 "2024-12-02", 1235 Some(("CH7389144832588726658", "Grothoff Hans")), 1236 ), 1237 tx_in( 1238 ( 1239 Some("81b0d8c6-a677-4577-b75e-a639dcc03681"), 1240 Some("41120636093.0001"), 1241 Some("ZV20241121/773118/1"), 1242 ), 1243 "CHF:0.1", 1244 "CHF:0.2", 1245 Some("small transfer test"), 1246 "2024-11-21", 1247 Some(("CH7389144832588726658", "Grothoff Hans")), 1248 ), 1249 tx_out( 1250 (None, None, Some("GB20241220/205792/1")), 1251 "CHF:3000", 1252 "CHF:0", 1253 None, 1254 "2024-12-20", 1255 None, 1256 ), 1257 tx_in( 1258 (None, None, Some("ZV20250114/796191/1")), 1259 "CHF:3003", 1260 "CHF:0", 1261 Some("Fix bad payment by MB."), 1262 "2025-01-27", 1263 None, 1264 ), 1265 tx_in( 1266 (None, Some("F000787951230001"), Some("ZV20250526/852733/1")), 1267 "CHF:1.38", 1268 "CHF:0.2", 1269 Some("Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G"), 1270 "2025-05-26", 1271 Some(("DE20500105172419259181", "Mr German")), 1272 ), 1273 ], 1274 ) 1275 } 1276 }