fetch.rs (70214B)
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::{ 21 collections::BTreeMap, 22 io::{Cursor, Read as _}, 23 }; 24 25 use anyhow::{anyhow, bail}; 26 use compact_str::CompactStringExt; 27 use jiff::{SignedDuration, Timestamp, Zoned, tz::TimeZone}; 28 use libeufin_ebics::{ 29 db::{get_task_status, update_task_status}, 30 ebics::{ 31 EbicsClient, EbicsErrKind, 32 ebics_code::EbicsReturnCode, 33 order::{Order, OrderDoc}, 34 }, 35 iso20022::{ 36 HacAction, 37 camt::{AccountId, parse_camt}, 38 hac::parse_hac, 39 model::{InTx, OutBatch, OutTx, Tx}, 40 pain002::parse_pain002, 41 status_code::{PaymentGroupStatus, PaymentTransactionStatus}, 42 }, 43 keys::{BankKeys, ClientKeys}, 44 ws::listen_for_notification, 45 }; 46 use sqlx::PgPool; 47 use taler_api::subject::{ 48 IncomingSubject, parse_incoming_unstructured, parse_outgoing, subject_is_qr_bill, 49 }; 50 use taler_common::types::{amount::Currency, payto::ParsedQuery}; 51 use tokio::{time::timeout, try_join}; 52 use tracing::{debug, error, info, trace, warn}; 53 54 use crate::{ 55 config::{AccountType, NexusCfg, NexusIngestCfg}, 56 constants::{CHECKPOINT_KEY, FETCH_TASK_KEY}, 57 db::{ 58 initiated::{ 59 batch_status_update, order_failure, order_step, order_success, tx_status_update, 60 unsettled_tx_in_batch, 61 }, 62 payment::{ 63 InResult, IncomingBounceRegistrationResult, IncomingRegistrationResult, 64 OutgoingRegistrationResult, register_in, register_in_malformed, register_in_qr_bill, 65 register_in_talerable, register_out_tx, 66 }, 67 }, 68 model::SubmissionState, 69 rand_ebics_id, 70 }; 71 72 pub async fn ebics_fetch( 73 ebics: &EbicsClient<'_>, 74 cfg: &NexusCfg, 75 client: &ClientKeys, 76 bank: &BankKeys, 77 db: &PgPool, 78 documents: &[OrderDoc], 79 pinned_start: &Option<Timestamp>, 80 peek: bool, 81 transient: bool, 82 transient_checkpoint: bool, 83 ) -> anyhow::Result<()> { 84 let ebics_cfg = cfg.ebics()?; 85 let register_payload = async |doc: &OrderDoc, content: &[u8]| -> anyhow::Result<()> { 86 // Unzip payload if necessary 87 match doc { 88 OrderDoc::acknowledgement => register_file(db, cfg, doc, content).await, 89 OrderDoc::status | OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { 90 let mut z = zip::ZipArchive::new(Cursor::new(content))?; 91 for i in 0..z.len() { 92 let mut file = z.by_index(i)?; 93 trace!(target: "fetch", "parse {}", file.name()?); 94 let mut buf = Vec::new(); 95 file.read_to_end(&mut buf)?; 96 register_file(db, cfg, doc, &buf).await?; 97 } 98 Ok(()) 99 } 100 } 101 }; 102 let fetch = async |orders: &[Order], since: Option<Timestamp>| -> anyhow::Result<bool> { 103 let mut grouped_orders = BTreeMap::new(); 104 105 for order in orders { 106 grouped_orders 107 .entry(order.doc()) 108 .or_insert_with(Vec::new) 109 .push(order); 110 } 111 112 let mut success = true; 113 for (doc, orders) in grouped_orders { 114 if let Some(doc) = doc { 115 for order in orders { 116 if let Err(e) = ebics 117 .download( 118 db, 119 client, 120 bank, 121 order, 122 &since.map(|it| (it, Timestamp::now())), 123 transient && peek, 124 async |content| { 125 register_payload(&doc, &content) 126 .await 127 .map_err(|e| EbicsErrKind::Custom(e.to_string().into())) 128 }, 129 ) 130 .await 131 { 132 if let EbicsErrKind::Code { bank, .. } = e.kind { 133 match bank { 134 EbicsReturnCode::EBICS_NO_DOWNLOAD_DATA_AVAILABLE => continue, 135 EbicsReturnCode::EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED => { 136 error!(target: "fetch", "{e}"); 137 success = false; 138 continue; 139 } 140 _ => {} 141 } 142 } 143 return Err(e.into()); 144 } 145 } 146 } else { 147 debug!(target: "fetch", "Skip unsupported orders {orders:?}") 148 } 149 } 150 Ok(success) 151 }; 152 153 // EBICS order than should be fetched 154 let orders: Vec<_> = if documents.is_empty() { 155 OrderDoc::entries 156 } else { 157 documents 158 } 159 .iter() 160 .flat_map(|it| ebics_cfg.dialect.standard().downloads(it)) 161 .collect(); 162 163 let fetch_cfg = cfg.fetch()?; 164 165 let (sender, mut receiver) = tokio::sync::mpsc::channel::<Vec<Order>>(10); 166 167 let fetch = async { 168 if transient { 169 info!(target: "fetch", "Transient mode: fetching once and returning"); 170 } else { 171 info!(target: "fetch", "Running with a frequency of {}", fetch_cfg.frequency_raw); 172 } 173 174 let mut last_fetch = Timestamp::UNIX_EPOCH; 175 loop { 176 let now = Timestamp::now(); 177 let checkpoint = get_task_status(db, CHECKPOINT_KEY) 178 .await? 179 .unwrap_or_default(); 180 let next_fetch = last_fetch + fetch_cfg.frequency; 181 let next_checkpoint = { 182 if let Some(last_trial) = checkpoint.last_trial { 183 // We run today at checkpoint_time 184 let checkpoint_date = Zoned::new(now, TimeZone::UTC) 185 .with() 186 .time(fetch_cfg.checkpoint_time) 187 .build() 188 .unwrap(); 189 // If we already ran today we ran tomorrow 190 if last_trial > checkpoint_date.timestamp() { 191 checkpoint_date.tomorrow().unwrap().timestamp() 192 } else { 193 checkpoint_date.timestamp() 194 } 195 } else { 196 // We never ran, we must checkpoint now 197 Timestamp::UNIX_EPOCH 198 } 199 }; 200 201 let mut success = true; 202 if 203 // Run transient checkpoint at request 204 (transient && transient_checkpoint) 205 // Or run recurrent checkpoint 206 || (!transient && now > next_checkpoint) 207 { 208 info!(target: "fetch", "Running checkpoint"); 209 210 let since = if let Some(pinned_start) = pinned_start 211 && transient 212 && checkpoint 213 .last_successfull 214 .map(|it| *pinned_start <= it) 215 .unwrap_or(true) 216 { 217 Some(*pinned_start) 218 } else { 219 checkpoint.last_successfull 220 }; 221 let res = async { 222 // We fetch HKD to only fetch supported EBICS orders and get the document versions 223 let hkd = ebics.hkd(db, client, bank, false).await?; 224 let mut supported_orders = hkd 225 .partner 226 .orders 227 .into_iter() 228 .map(|it| it.order) 229 .collect::<Vec<_>>(); 230 debug!(target: "fetch", "HKD: {}", supported_orders.iter().map(|it| it.to_string()).join_compact(", ")); 231 supported_orders 232 .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); 233 fetch(&supported_orders, since).await 234 } 235 .await; 236 if let Err(e) = res { 237 success = false; 238 error!(target: "fetch", "{e}"); 239 } 240 try_join!( 241 update_task_status(db, CHECKPOINT_KEY, &now, success), 242 update_task_status(db, FETCH_TASK_KEY, &now, success) 243 )?; 244 last_fetch = now; 245 } else if transient || now > next_fetch { 246 if !transient { 247 info!(target: "fetch", "Running at frequency"); 248 } 249 let res = async { 250 // We fetch HAA to only fetch pending & supported EBICS orders and get the document versions 251 let mut haa = ebics.haa(db, client, bank, false).await?; 252 debug!(target: "fetch", "HAA: {}", haa.orders.iter().map(|it| it.to_string()).join_compact(", ")); 253 haa.orders 254 .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); 255 fetch(&haa.orders, *pinned_start).await 256 } 257 .await; 258 if let Err(e) = res { 259 success = false; 260 error!(target: "fetch", "{e}"); 261 } 262 update_task_status(db, FETCH_TASK_KEY, &now, success).await?; 263 last_fetch = now; 264 } 265 266 if transient { 267 if success { 268 return anyhow::Ok(()); 269 } else { 270 return Err(anyhow!("fetch failed")); 271 } 272 } 273 274 let delay = now 275 .duration_until(next_fetch.min(next_checkpoint)) 276 .max(SignedDuration::ZERO) 277 .unsigned_abs(); 278 let tx = timeout(delay, receiver.recv()).await; 279 if let Ok(Some(mut notification)) = tx { 280 notification.retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); 281 if !notification.is_empty() { 282 info!(target: "fetch", "Running at real-time notifications reception"); 283 if let Err(e) = fetch(¬ification, None).await { 284 error!(target: "fetch", "{e}"); 285 } 286 } 287 } 288 } 289 }; 290 291 if transient { 292 fetch.await?; 293 } else { 294 tokio::try_join!(fetch, async { 295 listen_for_notification(ebics, db, client, bank, sender).await; 296 Ok(()) 297 })?; 298 } 299 300 Ok(()) 301 } 302 303 async fn register_file( 304 db: &PgPool, 305 cfg: &NexusCfg, 306 doc: &OrderDoc, 307 xml: &[u8], 308 ) -> anyhow::Result<()> { 309 match doc { 310 OrderDoc::acknowledgement => { 311 for ack in parse_hac(xml)? { 312 debug!(target: "fetch", "{ack}"); 313 if let Some(order_id) = &ack.order_id { 314 match ack.action { 315 HacAction::ORDER_HAC_FINAL_POS => { 316 if let Some(msg_id) = order_success(db, order_id).await? { 317 info!(target: "fetch", "Batch {msg_id} order {order_id} accepted at {}", ack.timestamp); 318 } 319 } 320 HacAction::ORDER_HAC_FINAL_NEG => { 321 if let Some((msg_id, msg)) = order_failure(db, order_id).await? { 322 info!(target: "fetch", "Batch {msg_id} order {order_id} refused at {}{}", ack.timestamp, std::fmt::from_fn( |f| if let Some(msg) = &msg { 323 write!(f, ": {msg}") 324 } else { 325 Ok(()) 326 })); 327 } 328 } 329 _ => { 330 order_step(db, order_id, &ack.to_string()).await?; 331 } 332 } 333 } 334 } 335 } 336 OrderDoc::status => { 337 let msg_status = parse_pain002(xml)?; 338 debug!(target: "fetch", "{msg_status}"); 339 if let Some(code) = msg_status.status { 340 let msg = msg_status.msg(); 341 batch_status_update( 342 db, 343 &msg_status.id, 344 match code { 345 PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { 346 SubmissionState::success 347 } 348 PaymentGroupStatus::Rejected => { 349 error!(target: "fetch", "Batch {} failed: {msg}", msg_status.id); 350 SubmissionState::permanent_failure 351 } 352 _ => SubmissionState::pending, 353 }, 354 &msg, 355 ) 356 .await?; 357 } 358 for p_status in msg_status.payments { 359 if p_status.id != "NOTPROVIDED" { 360 warn!(target: "fetch", "Unexpected payment status for {}.{}", msg_status.id, p_status.id); 361 } else if let Some(code) = p_status.status { 362 let msg = p_status.msg(); 363 batch_status_update( 364 db, 365 &msg_status.id, 366 match code { 367 PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { 368 SubmissionState::success 369 } 370 PaymentGroupStatus::Rejected => { 371 error!(target: "fetch", "Batch {} failed: {msg}", msg_status.id); 372 SubmissionState::permanent_failure 373 } 374 _ => SubmissionState::pending, 375 }, 376 &msg, 377 ) 378 .await?; 379 } 380 for tx_status in p_status.txs { 381 let msg = tx_status.msg(); 382 tx_status_update( 383 db, 384 &tx_status.e2e_id, 385 &msg_status.id, 386 match tx_status.status { 387 PaymentTransactionStatus::Rejected | PaymentTransactionStatus::Blocked => { 388 error!(target: "fetch", "Transaction {} failed: {msg}", tx_status.e2e_id); 389 SubmissionState::permanent_failure 390 } 391 _ => SubmissionState::pending 392 }, 393 &msg, 394 ) 395 .await?; 396 } 397 } 398 } 399 OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { 400 register_camt(db, cfg, xml).await?; 401 } 402 } 403 Ok(()) 404 } 405 406 pub async fn register_camt(db: &PgPool, cfg: &NexusCfg, xml: &[u8]) -> anyhow::Result<usize> { 407 let account = &cfg.ebics()?.account; 408 let ingest_cfg = cfg.ingest()?; 409 let mut nb_tx = 0; 410 for actx in parse_camt(xml)? { 411 if let AccountId::Iban(iban) = &actx.id 412 && iban == &account.iban 413 { 414 if let Some(currency) = actx.currency 415 && currency != cfg.currency 416 { 417 bail!( 418 "Expected transactions of currency {} got {currency}", 419 cfg.currency 420 ) 421 } 422 for tx in actx.txs { 423 match tx { 424 Tx::In(InTx { amount, .. }) | Tx::Out(OutTx { amount, .. }) => { 425 if amount.currency != cfg.currency { 426 bail!( 427 "Expected transactions of currency {} got {}", 428 cfg.currency, 429 amount.currency 430 ) 431 } 432 } 433 Tx::Batch(_) | Tx::Reversal(_) => {} 434 } 435 register_tx(db, &ingest_cfg, &tx).await?; 436 nb_tx += 1; 437 } 438 } else { 439 warn!(target: "fetch", "Skip transaction for unknown account {}", actx.id); 440 } 441 } 442 Ok(nb_tx) 443 } 444 445 pub async fn register_incoming( 446 db: &PgPool, 447 cfg: &NexusIngestCfg, 448 payment: &InTx, 449 ) -> sqlx::Result<()> { 450 let log_res = |res: InResult, kind: &str, suffix: &str| { 451 let fmt = std::fmt::from_fn(|f| { 452 write!(f, "{payment}")?; 453 if !kind.is_empty() { 454 write!(f, " {kind}")?; 455 } 456 if res.new { 457 if let Some(id) = &res.bounce_id { 458 write!(f, " bounced in {id}")?; 459 } 460 } else { 461 if res.completed { 462 f.write_str(" completed")?; 463 if let Some(id) = &res.bounce_id { 464 write!(f, " bounced in {id}")?; 465 } 466 } else { 467 if let Some(id) = &res.bounce_id { 468 write!(f, " already bounced in {id}")?; 469 } 470 } 471 } 472 if !suffix.is_empty() { 473 write!(f, " {suffix}")?; 474 } 475 Ok(()) 476 }); 477 478 if res.completed || res.new { 479 info!(target: "fetch", "{fmt}") 480 } else { 481 debug!(target: "fetch", "{fmt}") 482 } 483 }; 484 let bounce = async |cause: &str| { 485 match cfg.account_type { 486 AccountType::exchange => { 487 if payment.execution_time < cfg.ignore_bounces_before { 488 let res = register_in(db, payment).await?; 489 log_res(res, "", &format!("ignored bounce: {cause}")); 490 } else { 491 let mut bounce_amount = payment.amount; 492 if !payment.credit_fee.is_zero() && cfg.bounce_deduce_fee { 493 if let Some(res) = bounce_amount.try_sub(&payment.credit_fee) { 494 bounce_amount = res 495 } else { 496 let res = register_in(db, payment).await?; 497 log_res( 498 res, 499 "", 500 &format!("skip bounce (transfer fee higher than amount): {cause}"), 501 ); 502 return Ok(()); 503 } 504 } 505 if let Some(res) = bounce_amount.try_sub(&cfg.bounce_fee) { 506 bounce_amount = res 507 } else { 508 let res = register_in(db, payment).await?; 509 log_res( 510 res, 511 "", 512 &format!("skip bounce (bounce fee higher than amount): {cause}"), 513 ); 514 return Ok(()); 515 } 516 let res = register_in_malformed( 517 db, 518 payment, 519 &bounce_amount, 520 &rand_ebics_id(), 521 &Timestamp::now(), 522 cause, 523 ) 524 .await?; 525 match res { 526 IncomingBounceRegistrationResult::Talerable => { 527 warn!(target: "fetch", "{payment} tried to bounce a talerable transaction"); 528 } 529 IncomingBounceRegistrationResult::Success(res) => { 530 log_res(res, "", &format!(": {cause}")); 531 } 532 } 533 } 534 } 535 AccountType::normal => { 536 let res = register_in(db, payment).await?; 537 log_res(res, "", ""); 538 } 539 } 540 sqlx::Result::<_, sqlx::Error>::Ok(()) 541 }; 542 543 // Check we have enough info to handle this transaction 544 if match &payment.debtor { 545 Some(payto) => match payto.query::<ParsedQuery>() { 546 Ok(q) => q.receiver_name.is_none(), 547 Err(_) => true, 548 }, 549 None => true, 550 } { 551 let res = register_in(db, payment).await?; 552 log_res(res, "incomplete", ""); 553 return Ok(()); 554 } 555 if let Some(regex) = &cfg.restriction_payto_regex 556 && let Some(debtor) = &payment.debtor 557 && !regex 558 .find(debtor.as_ref().as_str()) 559 .is_some_and(|m| m.as_str() == debtor.as_ref().as_str()) 560 { 561 bounce("restricted account").await?; 562 return Ok(()); 563 } 564 565 if let Some(subject) = &payment.subject 566 && subject_is_qr_bill(subject) 567 { 568 match register_in_qr_bill(db, payment, subject).await? { 569 IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, 570 IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, 571 IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?, 572 IncomingRegistrationResult::Success(res) => { 573 log_res(res, "", ""); 574 } 575 } 576 } else { 577 match parse_incoming_unstructured(payment.subject.as_deref().unwrap_or_default()) { 578 Ok(IncomingSubject::AdminBalanceAdjust) => { 579 let res = register_in(db, payment).await?; 580 log_res(res, "admin balance adjust", ""); 581 } 582 Ok(IncomingSubject::Key(subject)) => match register_in_talerable(db, payment, &subject) 583 .await? 584 { 585 IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, 586 IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, 587 IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?, 588 IncomingRegistrationResult::Success(res) => { 589 log_res(res, "", ""); 590 } 591 }, 592 Err(e) => { 593 bounce(&e.to_string()).await?; 594 } 595 } 596 } 597 598 Ok(()) 599 } 600 601 pub async fn register_outgoing( 602 db: &PgPool, 603 payment: &OutTx, 604 ) -> sqlx::Result<OutgoingRegistrationResult> { 605 let metadata = payment 606 .subject 607 .as_ref() 608 .and_then(|s| parse_outgoing(s).ok()); 609 let res = register_out_tx(db, payment, metadata.as_ref()).await?; 610 if res.new { 611 if res.initiated { 612 info!(target: "fetch", "{payment}"); 613 } else { 614 warn!(target: "fetch", "{payment} recovered"); 615 } 616 } else { 617 debug!(target: "fetch", "{payment} already seen"); 618 } 619 Ok(res) 620 } 621 622 pub async fn register_outgoing_batch( 623 db: &PgPool, 624 currency: &Currency, 625 batch: &OutBatch, 626 ) -> sqlx::Result<()> { 627 info!(target: "fetch", "{batch}"); 628 let txs = unsettled_tx_in_batch(db, currency, &batch.msg_id, &batch.execution_time).await?; 629 for tx in txs { 630 register_outgoing(db, &tx).await?; 631 } 632 Ok(()) 633 } 634 635 pub async fn register_tx(db: &PgPool, cfg: &NexusIngestCfg, tx: &Tx) -> sqlx::Result<()> { 636 if tx.execution_time() < &cfg.ignore_txs_before { 637 debug!(target: "fetch", "IGNORE {tx}"); 638 } else { 639 match tx { 640 Tx::In(payment) => { 641 register_incoming(db, cfg, payment).await?; 642 } 643 Tx::Out(payment) => { 644 register_outgoing(db, payment).await?; 645 } 646 Tx::Batch(batch) => { 647 register_outgoing_batch(db, &cfg.currency, batch).await?; 648 } 649 Tx::Reversal(reversal) => { 650 error!(target: "fetch", "{reversal}"); 651 tx_status_update( 652 db, 653 &reversal.e2e_id, 654 reversal.msg_id.as_deref().unwrap_or_default(), 655 SubmissionState::permanent_failure, 656 &format!("Payment bounces: {}", reversal.reason), 657 ) 658 .await?; 659 } 660 } 661 } 662 Ok(()) 663 } 664 665 #[cfg(test)] 666 pub mod test { 667 use std::str::FromStr as _; 668 669 use compact_str::CompactString; 670 use indexmap::IndexMap; 671 use jiff::{Timestamp, civil::Date}; 672 use libeufin_ebics::{ 673 dialect::Dialect, 674 ebics::order::OrderDoc, 675 iso20022::model::{InId, InTx, OutId, OutTx}, 676 }; 677 use sqlx::{PgPool, Row as _, postgres::PgRow}; 678 use taler_api::db::TypeHelper; 679 use taler_common::{ 680 config::Config, 681 types::{ 682 amount::{Amount, Currency, amount}, 683 payto::{BankID, IbanPayto, PaytoImpl, PaytoURI}, 684 utils::date_to_utc_ts, 685 }, 686 }; 687 use taler_macros::db_test; 688 689 use crate::{ 690 batch_pain001, 691 config::NexusCfg, 692 constants::CONFIG_SOURCE, 693 db::initiated::{batch_initiated, batch_sub_success, initiate, initiated_submittable}, 694 fetch::register_file, 695 gen_init_pay, 696 model::{ 697 Initiated, PaymentBatch, 698 SubmissionState::{self, *}, 699 }, 700 }; 701 702 fn setup() -> NexusCfg { 703 setup_cfg("test.conf") 704 } 705 706 fn setup_cfg(name: &str) -> NexusCfg { 707 let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{name}"))).unwrap(); 708 NexusCfg::parse(&cfg).unwrap() 709 } 710 711 fn iban_payto(iban: impl AsRef<str>, name: impl AsRef<str>) -> PaytoURI { 712 IbanPayto::new(BankID { 713 iban: iban.as_ref().parse().expect("invalid IBAN"), 714 bic: None, 715 }) 716 .as_full_uri(name.as_ref()) 717 } 718 719 fn tx_in( 720 id: (Option<&str>, Option<&str>, Option<&str>), 721 amount: &str, 722 credit_fee: &str, 723 subject: Option<&str>, 724 execution_time: &str, 725 debtor: Option<(&str, &str)>, 726 ) -> InTx { 727 InTx { 728 id: InId::new( 729 id.0.map(|it| it.parse().unwrap()), 730 id.1.map(Into::into), 731 id.2.map(Into::into), 732 ), 733 amount: Amount::from_str(amount).unwrap(), 734 credit_fee: Amount::from_str(credit_fee).unwrap(), 735 subject: subject.map(Into::into), 736 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 737 debtor: debtor.map(|(iban, name)| iban_payto(iban, name)), 738 } 739 } 740 741 pub fn tx_out( 742 id: (Option<&str>, Option<&str>, Option<&str>), 743 amount: &str, 744 debit_fee: &str, 745 subject: Option<&str>, 746 execution_time: &str, 747 creditor: Option<(&str, &str)>, 748 ) -> OutTx { 749 OutTx { 750 id: OutId::new( 751 id.0.map(Into::into), 752 id.1.map(Into::into), 753 id.2.map(Into::into), 754 ), 755 amount: Amount::from_str(amount).unwrap(), 756 debit_fee: Amount::from_str(debit_fee).unwrap(), 757 subject: subject.map(Into::into), 758 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 759 creditor: creditor.map(|(iban, name)| iban_payto(iban, name)), 760 } 761 } 762 763 /** Register batches of initiated payments for reconcciliation */ 764 async fn batches(pool: &PgPool, batches: &[(&str, &[Initiated])]) -> Vec<PaymentBatch> { 765 let mut tmp = Vec::<PaymentBatch>::new(); 766 for (name, txs) in batches { 767 for tx in *txs { 768 initiate( 769 pool, 770 &tx.amount, 771 &tx.subject, 772 &tx.creditor, 773 &tx.initiation_time, 774 &tx.e2e_id, 775 ) 776 .await 777 .unwrap(); 778 } 779 batch_initiated(pool, &Timestamp::now(), name, false) 780 .await 781 .unwrap(); 782 for batch in initiated_submittable(pool, &Currency::CHF).await.unwrap() { 783 batch_sub_success( 784 pool, 785 batch.id, 786 &Timestamp::now(), 787 &name.replace("BATCH", "ORDER"), 788 ) 789 .await 790 .unwrap(); 791 tmp.push(batch); 792 } 793 } 794 tmp 795 } 796 797 /** Register an XML sample into the database */ 798 async fn register(pool: &PgPool, cfg: &NexusCfg, path: &str, doc: &OrderDoc) { 799 register_file( 800 pool, 801 cfg, 802 doc, 803 &std::fs::read(format!("../libeufin-ebics/sample/platform/{path}")).unwrap(), 804 ) 805 .await 806 .unwrap() 807 } 808 809 /** Check database content */ 810 async fn check( 811 db: &PgPool, 812 status: &[( 813 &'static str, 814 SubmissionState, 815 &[(&'static str, SubmissionState)], 816 )], 817 incoming: &[InTx], 818 outgoing: &[OutTx], 819 bounced: &[OutTx], 820 ) { 821 // Check batch status 822 let batch_status = sqlx::query_as::<_, (CompactString, SubmissionState)>( 823 "SELECT message_id, status FROM initiated_outgoing_batches ORDER BY initiated_outgoing_batch_id" 824 ).fetch_all(db).await.unwrap(); 825 826 assert_eq!( 827 status 828 .iter() 829 .map(|it| (it.0.into(), it.1)) 830 .collect::<Vec<_>>(), 831 batch_status 832 ); 833 834 // Check transactions status 835 let batch_tx = sqlx::query_as::<_, (CompactString, CompactString, SubmissionState)>( 836 " 837 SELECT message_id, end_to_end_id, initiated_outgoing_transactions.status 838 FROM initiated_outgoing_transactions 839 JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) 840 ORDER BY initiated_outgoing_batch_id, initiated_outgoing_transaction_id 841 ", 842 ) 843 .fetch_all(db) 844 .await 845 .unwrap() 846 .into_iter() 847 .fold( 848 IndexMap::<_, Vec<_>>::new(), 849 |mut outer, (key, value, third)| { 850 outer.entry(key).or_default().push((value, third)); 851 outer 852 }, 853 ); 854 pretty_assertions::assert_eq!( 855 status 856 .iter() 857 .map(|it| (it.0, it.2.to_vec())) 858 .collect::<Vec<_>>(), 859 batch_tx 860 .iter() 861 .map(|it| ( 862 it.0.as_str(), 863 it.1.iter() 864 .map(|it| (it.0.as_str(), it.1)) 865 .collect::<Vec<_>>() 866 )) 867 .collect::<Vec<_>>() 868 ); 869 870 // Check incoming transactions 871 let incoming_tx = sqlx::query( 872 " 873 SELECT 874 uetr 875 ,tx_id 876 ,acct_svcr_ref 877 ,amount 878 ,credit_fee 879 ,subject 880 ,execution_time 881 ,debit_payto 882 FROM incoming_transactions 883 ORDER BY incoming_transaction_id 884 ", 885 ) 886 .try_map(|r: PgRow| { 887 Ok(InTx { 888 id: InId::new( 889 r.try_get("uetr")?, 890 r.try_get("tx_id")?, 891 r.try_get("acct_svcr_ref")?, 892 ), 893 amount: r.try_get_amount("amount", &Currency::CHF)?, 894 credit_fee: r 895 .try_get_opt_amount("credit_fee", &Currency::CHF)? 896 .unwrap_or(Amount::zero(&Currency::CHF)), 897 subject: r.try_get("subject")?, 898 execution_time: r.try_get_timestamp("execution_time")?, 899 debtor: r.try_get_opt_parse("debit_payto")?, 900 }) 901 }) 902 .fetch_all(db) 903 .await 904 .unwrap(); 905 pretty_assertions::assert_eq!(incoming, incoming_tx); 906 907 // Check outgoing transactions 908 let outgoing_tx = sqlx::query( 909 " 910 SELECT end_to_end_id 911 ,acct_svcr_ref 912 ,amount 913 ,debit_fee 914 ,subject 915 ,execution_time 916 ,credit_payto 917 FROM outgoing_transactions 918 ORDER BY outgoing_transaction_id 919 ", 920 ) 921 .try_map(|r: PgRow| { 922 Ok(OutTx { 923 id: OutId::new( 924 None, 925 r.try_get("end_to_end_id")?, 926 r.try_get("acct_svcr_ref")?, 927 ), 928 amount: r.try_get_amount("amount", &Currency::CHF)?, 929 debit_fee: r 930 .try_get_opt_amount("debit_fee", &Currency::CHF)? 931 .unwrap_or(Amount::zero(&Currency::CHF)), 932 subject: r.try_get("subject")?, 933 execution_time: r.try_get_timestamp("execution_time")?, 934 creditor: r.try_get_opt_parse("credit_payto")?, 935 }) 936 }) 937 .fetch_all(db) 938 .await 939 .unwrap(); 940 pretty_assertions::assert_eq!(outgoing, outgoing_tx); 941 942 // Check outgoing transactions 943 let bounced_tx = sqlx::query( 944 " 945 SELECT end_to_end_id 946 ,amount 947 ,subject 948 ,credit_payto 949 FROM initiated_outgoing_transactions 950 JOIN bounced_transactions USING (initiated_outgoing_transaction_id) 951 ", 952 ) 953 .try_map(|r: PgRow| { 954 Ok(OutTx { 955 id: OutId::new(None, None, None), 956 amount: r.try_get_amount("amount", &Currency::CHF)?, 957 debit_fee: Amount::zero(&Currency::CHF), 958 subject: r.try_get("subject")?, 959 execution_time: Timestamp::UNIX_EPOCH, 960 creditor: r.try_get_opt_parse("credit_payto")?, 961 }) 962 }) 963 .fetch_all(db) 964 .await 965 .unwrap(); 966 pretty_assertions::assert_eq!(bounced, bounced_tx) 967 } 968 969 #[db_test] 970 async fn pain001(db: PgPool) { 971 let cfg = setup(); 972 let mut batches = batches( 973 &db, 974 &[( 975 "MESSAGE_ID", 976 &[ 977 gen_init_pay!("TX_FIRST", "Test 42", amount("EUR:42")), 978 gen_init_pay!("TX_SECOND", "Test 5.11", amount("EUR:5.11")), 979 gen_init_pay!("TX_THIRD", "Test 0.21", amount("EUR:0.21")), 980 ], 981 )], 982 ) 983 .await; 984 let batch = &mut batches[0]; 985 let mut cfg = (*cfg.ebics().unwrap()).clone(); 986 for dialect in Dialect::entries { 987 cfg.dialect = *dialect; 988 pretty_assertions::assert_eq!( 989 std::fs::read_to_string(format!( 990 "../libeufin-ebics/sample/platform/{dialect}_pain001.xml" 991 )) 992 .unwrap(), 993 batch_pain001( 994 batch, 995 &cfg, 996 date_to_utc_ts(&Date::constant(2024, 9, 9)), 997 false 998 ) 999 .unwrap() 1000 ); 1001 } 1002 } 1003 1004 /** HAC order id test */ 1005 #[db_test] 1006 async fn hac(db: PgPool) { 1007 let cfg = setup(); 1008 batches( 1009 &db, 1010 &[ 1011 ( 1012 "BATCH_SUCCESS", 1013 &[ 1014 gen_init_pay!("BATCH_SUCCESS_0"), 1015 gen_init_pay!("BATCH_SUCCESS_1"), 1016 ], 1017 ), 1018 ( 1019 "BATCH_FAILURE", 1020 &[ 1021 gen_init_pay!("BATCH_FAILURE_0"), 1022 gen_init_pay!("BATCH_FAILURE_1"), 1023 ], 1024 ), 1025 ], 1026 ) 1027 .await; 1028 1029 register(&db, &cfg, "hac.xml", &OrderDoc::acknowledgement).await; 1030 1031 check( 1032 &db, 1033 &[ 1034 ( 1035 "BATCH_SUCCESS", 1036 success, 1037 &[("BATCH_SUCCESS_0", pending), ("BATCH_SUCCESS_1", pending)], 1038 ), 1039 ( 1040 "BATCH_FAILURE", 1041 permanent_failure, 1042 &[ 1043 ("BATCH_FAILURE_0", permanent_failure), 1044 ("BATCH_FAILURE_1", permanent_failure), 1045 ], 1046 ), 1047 ], 1048 &[], 1049 &[], 1050 &[], 1051 ) 1052 .await; 1053 } 1054 1055 /** CreditSuisse dialect test */ 1056 #[db_test] 1057 async fn cs(db: PgPool) { 1058 let cfg = setup(); 1059 batches( 1060 &db, 1061 &[( 1062 "05BD4C5B4A2649B5B08F6EF6A31F197A", 1063 &[ 1064 gen_init_pay!("AQCXNCPWD8PHW5JTN65Y5XTF7R"), 1065 gen_init_pay!("EE9SX76FC5YSC657EK3GMVZ9TC"), 1066 gen_init_pay!("V5B3MXPEWES9VQW1JDRD6VAET4"), 1067 gen_init_pay!("M9NGRCAC1FBX3ENX3XEDEPJ2JW"), 1068 ], 1069 )], 1070 ) 1071 .await; 1072 1073 register(&db, &cfg, "pain002_part.xml", &OrderDoc::status).await; 1074 1075 check( 1076 &db, 1077 &[( 1078 "05BD4C5B4A2649B5B08F6EF6A31F197A", 1079 success, 1080 &[ 1081 ("AQCXNCPWD8PHW5JTN65Y5XTF7R", permanent_failure), 1082 ("EE9SX76FC5YSC657EK3GMVZ9TC", permanent_failure), 1083 ("V5B3MXPEWES9VQW1JDRD6VAET4", permanent_failure), 1084 ("M9NGRCAC1FBX3ENX3XEDEPJ2JW", pending), 1085 ], 1086 )], 1087 &[], 1088 &[], 1089 &[], 1090 ) 1091 .await; 1092 } 1093 1094 /** Valiant dialect test */ 1095 #[db_test] 1096 async fn valiant(db: PgPool) { 1097 let cfg = setup_cfg("valiant.conf"); 1098 batches( 1099 &db, 1100 &[ 1101 ( 1102 "MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L", 1103 &[gen_init_pay!("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE")], 1104 ), 1105 ( 1106 "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", 1107 &[ 1108 gen_init_pay!("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), 1109 gen_init_pay!("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), 1110 gen_init_pay!("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), 1111 gen_init_pay!("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), 1112 ], 1113 ), 1114 ( 1115 "X166701F6RV59LP71RVWVIW9SV2AFZYLG4", 1116 &[gen_init_pay!("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX")], 1117 ), 1118 ( 1119 "OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P", 1120 &[gen_init_pay!("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7")], 1121 ), 1122 ( 1123 "6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", 1124 &[ 1125 gen_init_pay!("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), 1126 gen_init_pay!("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), 1127 gen_init_pay!("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), 1128 ], 1129 ), 1130 ], 1131 ) 1132 .await; 1133 1134 register(&db, &cfg, "valiant_camt052.xml", &OrderDoc::report).await; 1135 1136 check( 1137 &db, 1138 &[ 1139 ( 1140 "MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L", 1141 success, 1142 &[("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE", success)], 1143 ), 1144 ( 1145 "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", 1146 success, 1147 &[ 1148 ("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8", success), 1149 ("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN", success), 1150 ("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7", success), 1151 ("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28", success), 1152 ], 1153 ), 1154 ( 1155 "X166701F6RV59LP71RVWVIW9SV2AFZYLG4", 1156 success, 1157 &[("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", late_failure)], 1158 ), 1159 ( 1160 "OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P", 1161 success, 1162 &[("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7", success)], 1163 ), 1164 ( 1165 "6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", 1166 success, 1167 &[ 1168 ("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ", success), 1169 ("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", late_failure), 1170 ("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE", success), 1171 ], 1172 ), 1173 ], 1174 &[ 1175 tx_in( 1176 (None, Some("51030655601.0001"), Some("ZV20251030/514778/1")), 1177 "CHF:0.85", 1178 "CHF:0", 1179 Some("fun stuff"), 1180 "2025-10-30", 1181 Some(("CH7389144832588726658", "Grothoff Hans")), 1182 ), 1183 tx_in( 1184 (None, Some("51030655601.0002"), Some("ZV20251030/514779/1")), 1185 "CHF:0.95", 1186 "CHF:0", 1187 Some("Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0"), 1188 "2025-10-30", 1189 Some(("CH7389144832588726658", "Grothoff Hans")), 1190 ), 1191 tx_in( 1192 ( 1193 Some("7b76d488-05d5-44ab-9d77-31d4165ec158"), 1194 Some("00204EQY370"), 1195 Some("ZV20251118/685062/1"), 1196 ), 1197 "CHF:4.55", 1198 "CHF:0", 1199 Some("TEST"), 1200 "2025-11-18", 1201 None, 1202 ), 1203 ], 1204 &[ 1205 tx_out( 1206 ( 1207 None, 1208 Some("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"), 1209 Some("ZV20251030/511372/1"), 1210 ), 1211 "CHF:0.1", 1212 "CHF:0", 1213 Some("single 2025-10-30T09:46:04.55293090 9Z"), 1214 "2025-10-30", 1215 Some(("CH7389144832588726658", "Grothoff Hans")), 1216 ), 1217 tx_out( 1218 ( 1219 None, 1220 Some("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), 1221 Some("ZV20251030/511373/1"), 1222 ), 1223 "CHF:0.1", 1224 "CHF:0", 1225 Some("multi 0 2025-10-30T09:46:10.3877961 30Z"), 1226 "2025-10-30", 1227 Some(("CH7389144832588726658", "Grothoff Hans")), 1228 ), 1229 tx_out( 1230 ( 1231 None, 1232 Some("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), 1233 Some("ZV20251030/511373/2"), 1234 ), 1235 "CHF:0.11", 1236 "CHF:0", 1237 Some("multi 1 2025-10-30T09:46:10.3877961 30Z"), 1238 "2025-10-30", 1239 Some(("CH7389144832588726658", "Grothoff Hans")), 1240 ), 1241 tx_out( 1242 ( 1243 None, 1244 Some("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), 1245 Some("ZV20251030/511373/3"), 1246 ), 1247 "CHF:0.12", 1248 "CHF:0", 1249 Some("multi 2 2025-10-30T09:46:10.3877961 30Z"), 1250 "2025-10-30", 1251 Some(("CH7389144832588726658", "Grothoff Hans")), 1252 ), 1253 tx_out( 1254 ( 1255 None, 1256 Some("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), 1257 Some("ZV20251030/511373/4"), 1258 ), 1259 "CHF:0.13", 1260 "CHF:0", 1261 Some("multi 3 2025-10-30T09:46:10.3877961 30Z"), 1262 "2025-10-30", 1263 Some(("CH7389144832588726658", "Grothoff Hans")), 1264 ), 1265 tx_out( 1266 ( 1267 None, 1268 Some("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"), 1269 Some("ZV20251030/524078/1"), 1270 ), 1271 "CHF:0.21", 1272 "CHF:0", 1273 Some("bad name 2025-10-30T12:03:24.997478 811Z"), 1274 "2025-10-30", 1275 Some(("CH6208704048981247126", "John Smith")), 1276 ), 1277 tx_out( 1278 ( 1279 None, 1280 Some("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), 1281 Some("ZV20251030/524079/1"), 1282 ), 1283 "CHF:0.1", 1284 "CHF:0", 1285 Some("single 2025-10-30T12:04:00.37042083 6Z"), 1286 "2025-10-30", 1287 Some(("CH7389144832588726658", "Grothoff Hans")), 1288 ), 1289 tx_out( 1290 ( 1291 None, 1292 Some("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), 1293 Some("ZV20251030/524079/2"), 1294 ), 1295 "CHF:0.21", 1296 "CHF:0", 1297 Some("bad name 2025-10-30T12:03:53.042190 686Z"), 1298 "2025-10-30", 1299 Some(("CH6208704048981247126", "John Smith")), 1300 ), 1301 tx_out( 1302 ( 1303 None, 1304 Some("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"), 1305 Some("ZV20251030/524077/1"), 1306 ), 1307 "CHF:0.23", 1308 "CHF:5", 1309 Some("foreign iban 2025-10-30T12:03:44.0972 63765Z"), 1310 "2025-10-30", 1311 Some(("DE48330605920000686018", "Christian Grothoff")), 1312 ), 1313 tx_out( 1314 ( 1315 None, 1316 Some("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), 1317 Some("ZV20251030/524080/1"), 1318 ), 1319 "CHF:0.23", 1320 "CHF:5", 1321 Some("foreign iban 2025-10-30T12:03:58.0046 73747Z"), 1322 "2025-10-30", 1323 Some(("DE48330605920000686018", "Christian Grothoff")), 1324 ), 1325 ], 1326 &[tx_out( 1327 (None, None, None), 1328 "CHF:0.85", 1329 "CHF:0", 1330 Some("bounce 51030655601.0001: missing reserve public key"), 1331 "1970-01-01", 1332 Some(("CH7389144832588726658", "Grothoff Hans")), 1333 )], 1334 ) 1335 .await; 1336 } 1337 1338 /** GLS dialect test */ 1339 #[db_test] 1340 async fn gls(db: PgPool) { 1341 let cfg = setup_cfg("gls.conf"); 1342 batches( 1343 &db, 1344 &[ 1345 ("COMPAT_SUCCESS", &[gen_init_pay!("COMPAT_SUCCESS")]), 1346 ("COMPAT_FAILURE", &[gen_init_pay!("COMPAT_FAILURE")]), 1347 ( 1348 "BATCH_SINGLE_SUCCESS", 1349 &[gen_init_pay!("FD622SMXKT5QWSAHDY0H8NYG3G")], 1350 ), 1351 // JEYMR3OYZTFM7505OWWENFPAH53LNOWJHS 1352 ( 1353 "BATCH_SINGLE_FAILURE", 1354 &[gen_init_pay!("DAFC3NEE4T48WVC560T76ABA2C")], 1355 ), 1356 ( 1357 "BATCH_SINGLE_RETURN", 1358 &[gen_init_pay!("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5")], 1359 ), 1360 ( 1361 "BATCH_MANY_SUCCESS", 1362 &[ 1363 gen_init_pay!("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V"), 1364 gen_init_pay!("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW"), 1365 gen_init_pay!("35M1268GW5ZFHS5JCB41UKDQNPMD40T849"), 1366 gen_init_pay!("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1"), 1367 ], 1368 ), 1369 ( 1370 "BATCH_MANY_PART", 1371 &[ 1372 gen_init_pay!("27SK3166EG36SJ7VP7VFYP0MW8"), 1373 gen_init_pay!("KGTDBASWTJ6JM89WXD3Q5KFQC4"), 1374 gen_init_pay!("8XK8Z7RAX224FGWK832FD40GYC"), 1375 ], 1376 ), 1377 ( 1378 "BATCH_MANY_FAILURE", 1379 &[ 1380 gen_init_pay!("4XTPKWE4A9V90PRQJCT8Z3MQZ8"), 1381 gen_init_pay!("3VZZHVYJ6XP2SNPKWF4D4YVHNG"), 1382 ], 1383 ), 1384 ], 1385 ) 1386 .await; 1387 1388 register(&db, &cfg, "gls_camt052.xml", &OrderDoc::report).await; 1389 register(&db, &cfg, "gls_camt053.xml", &OrderDoc::statement).await; 1390 // TODO camt054 with missing id before and after 1391 1392 check( 1393 &db, 1394 &[ 1395 ("COMPAT_SUCCESS", success, &[("COMPAT_SUCCESS", success)]), 1396 ( 1397 "COMPAT_FAILURE", 1398 pending, 1399 &[("COMPAT_FAILURE", permanent_failure)], 1400 ), 1401 ( 1402 "BATCH_SINGLE_SUCCESS", 1403 success, 1404 &[("FD622SMXKT5QWSAHDY0H8NYG3G", success)], 1405 ), 1406 ( 1407 "BATCH_SINGLE_FAILURE", // TODO success 1408 pending, 1409 &[("DAFC3NEE4T48WVC560T76ABA2C", pending)], // TODO failure 1410 ), 1411 ( 1412 "BATCH_SINGLE_RETURN", 1413 success, 1414 &[("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", late_failure)], 1415 ), 1416 ( 1417 "BATCH_MANY_SUCCESS", 1418 success, 1419 &[ 1420 ("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V", success), 1421 ("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW", success), 1422 ("35M1268GW5ZFHS5JCB41UKDQNPMD40T849", success), 1423 ("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1", success), 1424 ], 1425 ), 1426 ( 1427 "BATCH_MANY_PART", 1428 success, 1429 &[ 1430 ("27SK3166EG36SJ7VP7VFYP0MW8", success), 1431 ("KGTDBASWTJ6JM89WXD3Q5KFQC4", permanent_failure), 1432 ("8XK8Z7RAX224FGWK832FD40GYC", permanent_failure), 1433 ], 1434 ), 1435 ( 1436 "BATCH_MANY_FAILURE", // TODO success 1437 pending, 1438 &[ 1439 ("4XTPKWE4A9V90PRQJCT8Z3MQZ8", pending), // TODO failure 1440 ("3VZZHVYJ6XP2SNPKWF4D4YVHNG", pending), // TODO failure 1441 ], 1442 ), 1443 ], 1444 &[tx_in( 1445 ( 1446 None, 1447 Some("BYLADEM1WOR-G2910276709458A2"), 1448 Some("2024041210041357000"), 1449 ), 1450 "CHF:3", 1451 "CHF:0", 1452 Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), 1453 "2024-04-12", 1454 Some(("DE84500105177118117964", "John Smith")), 1455 )], 1456 &[ 1457 tx_out( 1458 (None, Some("COMPAT_SUCCESS"), Some("2024041801514102000")), 1459 "CHF:2", 1460 "CHF:0", 1461 Some("TestABC123"), 1462 "2024-04-18", 1463 Some(("DE20500105172419259181", "John Smith")), 1464 ), 1465 tx_out( 1466 ( 1467 None, 1468 Some("FD622SMXKT5QWSAHDY0H8NYG3G"), 1469 Some("2024090216552232000"), 1470 ), 1471 "CHF:1.1", 1472 "CHF:0", 1473 Some("single 2024-09-02T14:29:52.875253314Z"), 1474 "2024-09-02", 1475 Some(("DE89500105173198527518", "Grothoff Hans")), 1476 ), 1477 tx_out( 1478 ( 1479 None, 1480 Some("YF5QBARGQ0MNY0VK59S477VDG4"), 1481 Some("2024041810552821000"), 1482 ), 1483 "CHF:1.1", 1484 "CHF:0", 1485 Some("Simple tx"), 1486 "2024-04-18", 1487 Some(("DE20500105172419259181", "John Smith")), 1488 ), 1489 tx_out( 1490 (None, Some("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V"), None), 1491 "CHF:44", 1492 "CHF:0", 1493 Some("init payment"), 1494 "2024-09-20", 1495 Some(("CH4189144589712575493", "Test")), 1496 ), 1497 tx_out( 1498 (None, Some("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW"), None), 1499 "CHF:44", 1500 "CHF:0", 1501 Some("init payment"), 1502 "2024-09-20", 1503 Some(("CH4189144589712575493", "Test")), 1504 ), 1505 tx_out( 1506 (None, Some("35M1268GW5ZFHS5JCB41UKDQNPMD40T849"), None), 1507 "CHF:44", 1508 "CHF:0", 1509 Some("init payment"), 1510 "2024-09-20", 1511 Some(("CH4189144589712575493", "Test")), 1512 ), 1513 tx_out( 1514 (None, Some("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1"), None), 1515 "CHF:44", 1516 "CHF:0", 1517 Some("init payment"), 1518 "2024-09-20", 1519 Some(("CH4189144589712575493", "Test")), 1520 ), 1521 tx_out( 1522 ( 1523 None, 1524 Some("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5"), 1525 Some("2024092100252498000"), 1526 ), 1527 "CHF:0.42", 1528 "CHF:0", 1529 Some("This should fail because bad iban"), 1530 "2024-09-23", 1531 Some(("DE18500105173385245163", "John Smith")), 1532 ), 1533 tx_out( 1534 (None, Some("27SK3166EG36SJ7VP7VFYP0MW8"), None), 1535 "CHF:44", 1536 "CHF:0", 1537 Some("init payment"), 1538 "2024-09-04", 1539 Some(("CH4189144589712575493", "Test")), 1540 ), 1541 ], 1542 &[], 1543 ) 1544 .await; 1545 } 1546 1547 /** Maerki Baumann dialect test */ 1548 #[db_test] 1549 async fn maerki_baumann(db: PgPool) { 1550 let cfg = setup_cfg("maerki_baumann.conf"); 1551 batches( 1552 &db, 1553 &[( 1554 "BATCH_SINGLE_REPORTING", 1555 &[ 1556 gen_init_pay!("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), 1557 gen_init_pay!("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), 1558 gen_init_pay!("A09R35EW0359SZ51464E7TC37A0P2CBK04"), 1559 gen_init_pay!("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), 1560 ], 1561 )], 1562 ) 1563 .await; 1564 1565 register( 1566 &db, 1567 &cfg, 1568 "maerki_baumann_camt053.xml", 1569 &OrderDoc::statement, 1570 ) 1571 .await; 1572 1573 check( 1574 &db, 1575 &[( 1576 "BATCH_SINGLE_REPORTING", 1577 success, 1578 &[ 1579 ("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", success), 1580 ("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", success), 1581 ("A09R35EW0359SZ51464E7TC37A0P2CBK04", success), 1582 ("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", success), 1583 ], 1584 )], 1585 &[ 1586 tx_in( 1587 ( 1588 Some("adbe4a5a-6cea-4263-b259-8ab964561a32"), 1589 Some("41103099704.0002"), 1590 Some("ZV20241104/765446/1"), 1591 ), 1592 "CHF:1", 1593 "CHF:0.2", 1594 Some("SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G"), 1595 "2024-11-04", 1596 Some(("CH7389144832588726658", "Mr Test")), 1597 ), 1598 tx_in( 1599 ( 1600 Some("7371795e-62fa-42dd-93b7-da89cc120faa"), 1601 Some("41103099704.0003"), 1602 Some("ZV20241104/765447/1"), 1603 ), 1604 "CHF:1", 1605 "CHF:0.2", 1606 Some("Random subject"), 1607 "2024-11-04", 1608 Some(("CH7389144832588726658", "Mr Test")), 1609 ), 1610 tx_in( 1611 (None, Some("50523424675.0001"), Some("ZV20250523/851716/1")), 1612 "CHF:0.5", 1613 "CHF:0.2", 1614 None, 1615 "2025-05-23", 1616 Some(("CH7389144832588726658", "Grothoff Hans")), 1617 ), 1618 tx_in( 1619 ( 1620 Some("f203fbb4-6e13-4c78-9b2a-d852fea6374a"), 1621 Some("41202060702.0001"), 1622 Some("ZV20241202/778108/1"), 1623 ), 1624 "CHF:0.05", 1625 "CHF:0.2", 1626 Some("mini"), 1627 "2024-12-02", 1628 Some(("CH7389144832588726658", "Grothoff Hans")), 1629 ), 1630 tx_in( 1631 ( 1632 Some("81b0d8c6-a677-4577-b75e-a639dcc03681"), 1633 Some("41120636093.0001"), 1634 Some("ZV20241121/773118/1"), 1635 ), 1636 "CHF:0.1", 1637 "CHF:0.2", 1638 Some("small transfer test"), 1639 "2024-11-21", 1640 Some(("CH7389144832588726658", "Grothoff Hans")), 1641 ), 1642 tx_in( 1643 (None, None, Some("ZV20250114/796191/1")), 1644 "CHF:3003", 1645 "CHF:0", 1646 Some("Fix bad payment by MB."), 1647 "2025-01-27", 1648 None, 1649 ), 1650 tx_in( 1651 (None, Some("F000787951230001"), Some("ZV20250526/852733/1")), 1652 "CHF:1.38", 1653 "CHF:0.2", 1654 Some("Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G"), 1655 "2025-05-26", 1656 Some(("DE20500105172419259181", "Mr German")), 1657 ), 1658 ], 1659 &[ 1660 tx_out( 1661 ( 1662 None, 1663 Some("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), 1664 Some("ZV20241121/773541/1"), 1665 ), 1666 "CHF:0.1", 1667 "CHF:0", 1668 Some("multi 0 2024-11-21T15:21:59.8859234 63Z"), 1669 "2024-11-27", 1670 Some(("CH7389144832588726658", "Grothoff Hans")), 1671 ), 1672 tx_out( 1673 ( 1674 None, 1675 Some("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), 1676 Some("ZV20241121/773541/4"), 1677 ), 1678 "CHF:0.13", 1679 "CHF:0", 1680 Some("multi 3 2024-11-21T15:21:59.8859234 63Z"), 1681 "2024-11-27", 1682 Some(("CH7389144832588726658", "Grothoff Hans")), 1683 ), 1684 tx_out( 1685 ( 1686 None, 1687 Some("A09R35EW0359SZ51464E7TC37A0P2CBK04"), 1688 Some("ZV20241121/773541/3"), 1689 ), 1690 "CHF:0.12", 1691 "CHF:0", 1692 Some("multi 2 2024-11-21T15:21:59.8859234 63Z"), 1693 "2024-11-27", 1694 Some(("CH7389144832588726658", "Grothoff Hans")), 1695 ), 1696 tx_out( 1697 ( 1698 None, 1699 Some("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), 1700 Some("ZV20241121/773541/2"), 1701 ), 1702 "CHF:0.11", 1703 "CHF:0", 1704 Some("multi 1 2024-11-21T15:21:59.8859234 63Z"), 1705 "2024-11-27", 1706 Some(("CH7389144832588726658", "Grothoff Hans")), 1707 ), 1708 tx_out( 1709 (None, None, Some("GB20241220/205792/1")), 1710 "CHF:3000", 1711 "CHF:0", 1712 None, 1713 "2024-12-20", 1714 None, 1715 ), 1716 ], 1717 &[ 1718 tx_out( 1719 (None, None, None), 1720 "CHF:1", 1721 "CHF:0", 1722 Some("bounce 7371795e-62fa-42dd-93b7-da89cc120faa: missing reserve public key"), 1723 "1970-01-01", 1724 Some(("CH7389144832588726658", "Mr Test")), 1725 ), 1726 tx_out( 1727 (None, None, None), 1728 "CHF:0.5", 1729 "CHF:0", 1730 Some("bounce 50523424675.0001: missing reserve public key"), 1731 "1970-01-01", 1732 Some(("CH7389144832588726658", "Grothoff Hans")), 1733 ), 1734 tx_out( 1735 (None, None, None), 1736 "CHF:0.05", 1737 "CHF:0", 1738 Some("bounce f203fbb4-6e13-4c78-9b2a-d852fea6374a: missing reserve public key"), 1739 "1970-01-01", 1740 Some(("CH7389144832588726658", "Grothoff Hans")), 1741 ), 1742 tx_out( 1743 (None, None, None), 1744 "CHF:0.1", 1745 "CHF:0", 1746 Some("bounce 81b0d8c6-a677-4577-b75e-a639dcc03681: missing reserve public key"), 1747 "1970-01-01", 1748 Some(("CH7389144832588726658", "Grothoff Hans")), 1749 ), 1750 tx_out( 1751 (None, None, None), 1752 "CHF:1.38", 1753 "CHF:0", 1754 Some("bounce F000787951230001: restricted account"), 1755 "1970-01-01", 1756 Some(("DE20500105172419259181", "Mr German")), 1757 ), 1758 ], 1759 ) 1760 .await; 1761 } 1762 1763 /** PostFinance dialect test */ 1764 #[db_test] 1765 async fn postfinance(db: PgPool) { 1766 let cfg = setup_cfg("postfinance.conf"); 1767 batches( 1768 &db, 1769 &[ 1770 ( 1771 "ZS1PGNTSV0ZNDFAJBBWWB8015G", 1772 &[ 1773 // Legacy notifications use MsgId when EndToEndId is NOTPROVIDED. 1774 gen_init_pay!( 1775 "ZS1PGNTSV0ZNDFAJBBWWB8015G", 1776 "init payment", 1777 amount("CHF:3") 1778 ), 1779 gen_init_pay!( 1780 "POSTFINANCE_NOTIFICATION_BATCH", 1781 "init payment", 1782 amount("CHF:403") 1783 ), 1784 ], 1785 ), 1786 ( 1787 "50820f78-9024-44ff-978d-63a18c", 1788 &[gen_init_pay!( 1789 "50820f78-9024-44ff-978d-63a18c", 1790 "init payment", 1791 amount("CHF:1") 1792 )], 1793 ), 1794 ( 1795 "889d1a80-1267-49bd-8fcc-85701a", 1796 &[gen_init_pay!( 1797 "889d1a80-1267-49bd-8fcc-85701a", 1798 "init payment", 1799 amount("CHF:1") 1800 )], 1801 ), 1802 ( 1803 "4cc61cc7-6230-49c2-b5e2-b40bbb", 1804 &[gen_init_pay!( 1805 "4cc61cc7-6230-49c2-b5e2-b40bbb", 1806 "init payment", 1807 amount("CHF:1") 1808 )], 1809 ), 1810 ( 1811 "EB4D22D428214261B2B3012D2A8CEC36", 1812 &[ 1813 gen_init_pay!( 1814 "POSTFINANCE_STATEMENT_FIRST", 1815 "init payment", 1816 amount("CHF:100") 1817 ), 1818 gen_init_pay!( 1819 "POSTFINANCE_STATEMENT_SECOND", 1820 "init payment", 1821 amount("CHF:306") 1822 ), 1823 ], 1824 ), 1825 ], 1826 ) 1827 .await; 1828 1829 register( 1830 &db, 1831 &cfg, 1832 "postfinance_camt054.xml", 1833 &OrderDoc::notification, 1834 ) 1835 .await; 1836 register(&db, &cfg, "postfinance_camt053.xml", &OrderDoc::statement).await; 1837 1838 check( 1839 &db, 1840 &[ 1841 ( 1842 "ZS1PGNTSV0ZNDFAJBBWWB8015G", 1843 success, 1844 &[ 1845 ("ZS1PGNTSV0ZNDFAJBBWWB8015G", success), 1846 ("POSTFINANCE_NOTIFICATION_BATCH", success), 1847 ], 1848 ), 1849 ( 1850 "50820f78-9024-44ff-978d-63a18c", 1851 success, 1852 &[("50820f78-9024-44ff-978d-63a18c", permanent_failure)], 1853 ), 1854 ( 1855 "889d1a80-1267-49bd-8fcc-85701a", 1856 success, 1857 &[("889d1a80-1267-49bd-8fcc-85701a", permanent_failure)], 1858 ), 1859 ( 1860 "4cc61cc7-6230-49c2-b5e2-b40bbb", 1861 success, 1862 &[("4cc61cc7-6230-49c2-b5e2-b40bbb", permanent_failure)], 1863 ), 1864 ( 1865 "EB4D22D428214261B2B3012D2A8CEC36", 1866 success, 1867 &[ 1868 ("POSTFINANCE_STATEMENT_FIRST", success), 1869 ("POSTFINANCE_STATEMENT_SECOND", success), 1870 ], 1871 ), 1872 ], 1873 &[ 1874 tx_in( 1875 ( 1876 Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), 1877 None, 1878 Some("231121CH0AZWCR9T"), 1879 ), 1880 "CHF:10", 1881 "CHF:0", 1882 Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG"), 1883 "2023-12-19", 1884 Some(("CH7389144832588726658", "Mr Test")), 1885 ), 1886 tx_in( 1887 ( 1888 Some("62e2b511-7313-4ccd-8d40-c9d8e612cd72"), 1889 None, 1890 Some("231121CH0AZWCVR1"), 1891 ), 1892 "CHF:2.53", 1893 "CHF:0", 1894 Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB"), 1895 "2023-12-19", 1896 Some(("CH7389144832588726658", "Mr Test")), 1897 ), 1898 ], 1899 &[ 1900 tx_out( 1901 (None, Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), None), 1902 "CHF:3", 1903 "CHF:0", 1904 None, 1905 "2024-01-15", 1906 None, 1907 ), 1908 tx_out( 1909 (None, Some("POSTFINANCE_NOTIFICATION_BATCH"), None), 1910 "CHF:403", 1911 "CHF:0", 1912 Some("init payment"), 1913 "2024-01-15", 1914 Some(("CH4189144589712575493", "Test")), 1915 ), 1916 tx_out( 1917 (None, Some("POSTFINANCE_STATEMENT_FIRST"), None), 1918 "CHF:100", 1919 "CHF:0", 1920 Some("init payment"), 1921 "2024-08-26", 1922 Some(("CH4189144589712575493", "Test")), 1923 ), 1924 tx_out( 1925 (None, Some("POSTFINANCE_STATEMENT_SECOND"), None), 1926 "CHF:306", 1927 "CHF:0", 1928 Some("init payment"), 1929 "2024-08-26", 1930 Some(("CH4189144589712575493", "Test")), 1931 ), 1932 ], 1933 &[tx_out( 1934 (None, None, None), 1935 "CHF:2.53", 1936 "CHF:0", 1937 Some("bounce 62e2b511-7313-4ccd-8d40-c9d8e612cd72: missing reserve public key"), 1938 "1970-01-01", 1939 Some(("CH7389144832588726658", "Mr Test")), 1940 )], 1941 ) 1942 .await; 1943 } 1944 }