fetch.rs (70236B)
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 let cfg = NexusCfg::parse(&cfg).unwrap(); 709 cfg 710 } 711 712 fn iban_payto(iban: impl AsRef<str>, name: impl AsRef<str>) -> PaytoURI { 713 IbanPayto::new(BankID { 714 iban: iban.as_ref().parse().expect("invalid IBAN"), 715 bic: None, 716 }) 717 .as_full_uri(name.as_ref()) 718 } 719 720 fn tx_in( 721 id: (Option<&str>, Option<&str>, Option<&str>), 722 amount: &str, 723 credit_fee: &str, 724 subject: Option<&str>, 725 execution_time: &str, 726 debtor: Option<(&str, &str)>, 727 ) -> InTx { 728 InTx { 729 id: InId::new( 730 id.0.map(|it| it.parse().unwrap()), 731 id.1.map(Into::into), 732 id.2.map(Into::into), 733 ), 734 amount: Amount::from_str(amount).unwrap(), 735 credit_fee: Amount::from_str(credit_fee).unwrap(), 736 subject: subject.map(Into::into), 737 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 738 debtor: debtor.map(|(iban, name)| iban_payto(iban, name)), 739 } 740 } 741 742 pub fn tx_out( 743 id: (Option<&str>, Option<&str>, Option<&str>), 744 amount: &str, 745 debit_fee: &str, 746 subject: Option<&str>, 747 execution_time: &str, 748 creditor: Option<(&str, &str)>, 749 ) -> OutTx { 750 OutTx { 751 id: OutId::new( 752 id.0.map(Into::into), 753 id.1.map(Into::into), 754 id.2.map(Into::into), 755 ), 756 amount: Amount::from_str(amount).unwrap(), 757 debit_fee: Amount::from_str(debit_fee).unwrap(), 758 subject: subject.map(Into::into), 759 execution_time: date_to_utc_ts(&Date::from_str(execution_time).unwrap()), 760 creditor: creditor.map(|(iban, name)| iban_payto(iban, name)), 761 } 762 } 763 764 /** Register batches of initiated payments for reconcciliation */ 765 async fn batches(pool: &PgPool, batches: &[(&str, &[Initiated])]) -> Vec<PaymentBatch> { 766 let mut tmp = Vec::<PaymentBatch>::new(); 767 for (name, txs) in batches { 768 for tx in *txs { 769 initiate( 770 pool, 771 &tx.amount, 772 &tx.subject, 773 &tx.creditor, 774 &tx.initiation_time, 775 &tx.e2e_id, 776 ) 777 .await 778 .unwrap(); 779 } 780 batch_initiated(pool, &Timestamp::now(), name, false) 781 .await 782 .unwrap(); 783 for batch in initiated_submittable(pool, &Currency::CHF).await.unwrap() { 784 batch_sub_success( 785 pool, 786 batch.id, 787 &Timestamp::now(), 788 &name.replace("BATCH", "ORDER"), 789 ) 790 .await 791 .unwrap(); 792 tmp.push(batch); 793 } 794 } 795 tmp 796 } 797 798 /** Register an XML sample into the database */ 799 async fn register(pool: &PgPool, cfg: &NexusCfg, path: &str, doc: &OrderDoc) { 800 register_file( 801 pool, 802 cfg, 803 doc, 804 &std::fs::read(format!("../libeufin-ebics/sample/platform/{path}")).unwrap(), 805 ) 806 .await 807 .unwrap() 808 } 809 810 /** Check database content */ 811 async fn check( 812 db: &PgPool, 813 status: &[( 814 &'static str, 815 SubmissionState, 816 &[(&'static str, SubmissionState)], 817 )], 818 incoming: &[InTx], 819 outgoing: &[OutTx], 820 bounced: &[OutTx], 821 ) { 822 // Check batch status 823 let batch_status = sqlx::query_as::<_, (CompactString, SubmissionState)>( 824 "SELECT message_id, status FROM initiated_outgoing_batches ORDER BY initiated_outgoing_batch_id" 825 ).fetch_all(db).await.unwrap(); 826 827 assert_eq!( 828 status 829 .iter() 830 .map(|it| (it.0.into(), it.1)) 831 .collect::<Vec<_>>(), 832 batch_status 833 ); 834 835 // Check transactions status 836 let batch_tx = sqlx::query_as::<_, (CompactString, CompactString, SubmissionState)>( 837 " 838 SELECT message_id, end_to_end_id, initiated_outgoing_transactions.status 839 FROM initiated_outgoing_transactions 840 JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) 841 ORDER BY initiated_outgoing_batch_id, initiated_outgoing_transaction_id 842 ", 843 ) 844 .fetch_all(db) 845 .await 846 .unwrap() 847 .into_iter() 848 .fold( 849 IndexMap::<_, Vec<_>>::new(), 850 |mut outer, (key, value, third)| { 851 outer.entry(key).or_default().push((value, third)); 852 outer 853 }, 854 ); 855 pretty_assertions::assert_eq!( 856 status 857 .iter() 858 .map(|it| (it.0, it.2.to_vec())) 859 .collect::<Vec<_>>(), 860 batch_tx 861 .iter() 862 .map(|it| ( 863 it.0.as_str(), 864 it.1.iter() 865 .map(|it| (it.0.as_str(), it.1)) 866 .collect::<Vec<_>>() 867 )) 868 .collect::<Vec<_>>() 869 ); 870 871 // Check incoming transactions 872 let incoming_tx = sqlx::query( 873 " 874 SELECT 875 uetr 876 ,tx_id 877 ,acct_svcr_ref 878 ,amount 879 ,credit_fee 880 ,subject 881 ,execution_time 882 ,debit_payto 883 FROM incoming_transactions 884 ORDER BY incoming_transaction_id 885 ", 886 ) 887 .try_map(|r: PgRow| { 888 Ok(InTx { 889 id: InId::new( 890 r.try_get("uetr")?, 891 r.try_get("tx_id")?, 892 r.try_get("acct_svcr_ref")?, 893 ), 894 amount: r.try_get_amount("amount", &Currency::CHF)?, 895 credit_fee: r 896 .try_get_opt_amount("credit_fee", &Currency::CHF)? 897 .unwrap_or(Amount::zero(&Currency::CHF)), 898 subject: r.try_get("subject")?, 899 execution_time: r.try_get_timestamp("execution_time")?, 900 debtor: r.try_get_opt_parse("debit_payto")?, 901 }) 902 }) 903 .fetch_all(db) 904 .await 905 .unwrap(); 906 pretty_assertions::assert_eq!(incoming, incoming_tx); 907 908 // Check outgoing transactions 909 let outgoing_tx = sqlx::query( 910 " 911 SELECT end_to_end_id 912 ,acct_svcr_ref 913 ,amount 914 ,debit_fee 915 ,subject 916 ,execution_time 917 ,credit_payto 918 FROM outgoing_transactions 919 ORDER BY outgoing_transaction_id 920 ", 921 ) 922 .try_map(|r: PgRow| { 923 Ok(OutTx { 924 id: OutId::new( 925 None, 926 r.try_get("end_to_end_id")?, 927 r.try_get("acct_svcr_ref")?, 928 ), 929 amount: r.try_get_amount("amount", &Currency::CHF)?, 930 debit_fee: r 931 .try_get_opt_amount("debit_fee", &Currency::CHF)? 932 .unwrap_or(Amount::zero(&Currency::CHF)), 933 subject: r.try_get("subject")?, 934 execution_time: r.try_get_timestamp("execution_time")?, 935 creditor: r.try_get_opt_parse("credit_payto")?, 936 }) 937 }) 938 .fetch_all(db) 939 .await 940 .unwrap(); 941 pretty_assertions::assert_eq!(outgoing, outgoing_tx); 942 943 // Check outgoing transactions 944 let bounced_tx = sqlx::query( 945 " 946 SELECT end_to_end_id 947 ,amount 948 ,subject 949 ,credit_payto 950 FROM initiated_outgoing_transactions 951 JOIN bounced_transactions USING (initiated_outgoing_transaction_id) 952 ", 953 ) 954 .try_map(|r: PgRow| { 955 Ok(OutTx { 956 id: OutId::new(None, None, None), 957 amount: r.try_get_amount("amount", &Currency::CHF)?, 958 debit_fee: Amount::zero(&Currency::CHF), 959 subject: r.try_get("subject")?, 960 execution_time: Timestamp::UNIX_EPOCH, 961 creditor: r.try_get_opt_parse("credit_payto")?, 962 }) 963 }) 964 .fetch_all(db) 965 .await 966 .unwrap(); 967 pretty_assertions::assert_eq!(bounced, bounced_tx) 968 } 969 970 #[db_test] 971 async fn pain001(db: PgPool) { 972 let cfg = setup(); 973 let mut batches = batches( 974 &db, 975 &[( 976 "MESSAGE_ID", 977 &[ 978 gen_init_pay!("TX_FIRST", "Test 42", amount("EUR:42")), 979 gen_init_pay!("TX_SECOND", "Test 5.11", amount("EUR:5.11")), 980 gen_init_pay!("TX_THIRD", "Test 0.21", amount("EUR:0.21")), 981 ], 982 )], 983 ) 984 .await; 985 let batch = &mut batches[0]; 986 let mut cfg = (*cfg.ebics().unwrap()).clone(); 987 for dialect in Dialect::entries { 988 cfg.dialect = *dialect; 989 pretty_assertions::assert_eq!( 990 std::fs::read_to_string(format!( 991 "../libeufin-ebics/sample/platform/{dialect}_pain001.xml" 992 )) 993 .unwrap(), 994 batch_pain001( 995 batch, 996 &cfg, 997 date_to_utc_ts(&Date::constant(2024, 9, 9)), 998 false 999 ) 1000 .unwrap() 1001 ); 1002 } 1003 } 1004 1005 /** HAC order id test */ 1006 #[db_test] 1007 async fn hac(db: PgPool) { 1008 let cfg = setup(); 1009 batches( 1010 &db, 1011 &[ 1012 ( 1013 "BATCH_SUCCESS", 1014 &[ 1015 gen_init_pay!("BATCH_SUCCESS_0"), 1016 gen_init_pay!("BATCH_SUCCESS_1"), 1017 ], 1018 ), 1019 ( 1020 "BATCH_FAILURE", 1021 &[ 1022 gen_init_pay!("BATCH_FAILURE_0"), 1023 gen_init_pay!("BATCH_FAILURE_1"), 1024 ], 1025 ), 1026 ], 1027 ) 1028 .await; 1029 1030 register(&db, &cfg, "hac.xml", &OrderDoc::acknowledgement).await; 1031 1032 check( 1033 &db, 1034 &[ 1035 ( 1036 "BATCH_SUCCESS", 1037 success, 1038 &[("BATCH_SUCCESS_0", pending), ("BATCH_SUCCESS_1", pending)], 1039 ), 1040 ( 1041 "BATCH_FAILURE", 1042 permanent_failure, 1043 &[ 1044 ("BATCH_FAILURE_0", permanent_failure), 1045 ("BATCH_FAILURE_1", permanent_failure), 1046 ], 1047 ), 1048 ], 1049 &[], 1050 &[], 1051 &[], 1052 ) 1053 .await; 1054 } 1055 1056 /** CreditSuisse dialect test */ 1057 #[db_test] 1058 async fn cs(db: PgPool) { 1059 let cfg = setup(); 1060 batches( 1061 &db, 1062 &[( 1063 "05BD4C5B4A2649B5B08F6EF6A31F197A", 1064 &[ 1065 gen_init_pay!("AQCXNCPWD8PHW5JTN65Y5XTF7R"), 1066 gen_init_pay!("EE9SX76FC5YSC657EK3GMVZ9TC"), 1067 gen_init_pay!("V5B3MXPEWES9VQW1JDRD6VAET4"), 1068 gen_init_pay!("M9NGRCAC1FBX3ENX3XEDEPJ2JW"), 1069 ], 1070 )], 1071 ) 1072 .await; 1073 1074 register(&db, &cfg, "pain002_part.xml", &OrderDoc::status).await; 1075 1076 check( 1077 &db, 1078 &[( 1079 "05BD4C5B4A2649B5B08F6EF6A31F197A", 1080 success, 1081 &[ 1082 ("AQCXNCPWD8PHW5JTN65Y5XTF7R", permanent_failure), 1083 ("EE9SX76FC5YSC657EK3GMVZ9TC", permanent_failure), 1084 ("V5B3MXPEWES9VQW1JDRD6VAET4", permanent_failure), 1085 ("M9NGRCAC1FBX3ENX3XEDEPJ2JW", pending), 1086 ], 1087 )], 1088 &[], 1089 &[], 1090 &[], 1091 ) 1092 .await; 1093 } 1094 1095 /** Valiant dialect test */ 1096 #[db_test] 1097 async fn valiant(db: PgPool) { 1098 let cfg = setup_cfg("valiant.conf"); 1099 batches( 1100 &db, 1101 &[ 1102 ( 1103 "MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L", 1104 &[gen_init_pay!("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE")], 1105 ), 1106 ( 1107 "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", 1108 &[ 1109 gen_init_pay!("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), 1110 gen_init_pay!("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), 1111 gen_init_pay!("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), 1112 gen_init_pay!("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), 1113 ], 1114 ), 1115 ( 1116 "X166701F6RV59LP71RVWVIW9SV2AFZYLG4", 1117 &[gen_init_pay!("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX")], 1118 ), 1119 ( 1120 "OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P", 1121 &[gen_init_pay!("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7")], 1122 ), 1123 ( 1124 "6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", 1125 &[ 1126 gen_init_pay!("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), 1127 gen_init_pay!("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), 1128 gen_init_pay!("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), 1129 ], 1130 ), 1131 ], 1132 ) 1133 .await; 1134 1135 register(&db, &cfg, "valiant_camt052.xml", &OrderDoc::report).await; 1136 1137 check( 1138 &db, 1139 &[ 1140 ( 1141 "MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L", 1142 success, 1143 &[("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE", success)], 1144 ), 1145 ( 1146 "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", 1147 success, 1148 &[ 1149 ("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8", success), 1150 ("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN", success), 1151 ("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7", success), 1152 ("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28", success), 1153 ], 1154 ), 1155 ( 1156 "X166701F6RV59LP71RVWVIW9SV2AFZYLG4", 1157 success, 1158 &[("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", late_failure)], 1159 ), 1160 ( 1161 "OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P", 1162 success, 1163 &[("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7", success)], 1164 ), 1165 ( 1166 "6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", 1167 success, 1168 &[ 1169 ("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ", success), 1170 ("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", late_failure), 1171 ("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE", success), 1172 ], 1173 ), 1174 ], 1175 &[ 1176 tx_in( 1177 (None, Some("51030655601.0001"), Some("ZV20251030/514778/1")), 1178 "CHF:0.85", 1179 "CHF:0", 1180 Some("fun stuff"), 1181 "2025-10-30", 1182 Some(("CH7389144832588726658", "Grothoff Hans")), 1183 ), 1184 tx_in( 1185 (None, Some("51030655601.0002"), Some("ZV20251030/514779/1")), 1186 "CHF:0.95", 1187 "CHF:0", 1188 Some("Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0"), 1189 "2025-10-30", 1190 Some(("CH7389144832588726658", "Grothoff Hans")), 1191 ), 1192 tx_in( 1193 ( 1194 Some("7b76d488-05d5-44ab-9d77-31d4165ec158"), 1195 Some("00204EQY370"), 1196 Some("ZV20251118/685062/1"), 1197 ), 1198 "CHF:4.55", 1199 "CHF:0", 1200 Some("TEST"), 1201 "2025-11-18", 1202 None, 1203 ), 1204 ], 1205 &[ 1206 tx_out( 1207 ( 1208 None, 1209 Some("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"), 1210 Some("ZV20251030/511372/1"), 1211 ), 1212 "CHF:0.1", 1213 "CHF:0", 1214 Some("single 2025-10-30T09:46:04.55293090 9Z"), 1215 "2025-10-30", 1216 Some(("CH7389144832588726658", "Grothoff Hans")), 1217 ), 1218 tx_out( 1219 ( 1220 None, 1221 Some("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), 1222 Some("ZV20251030/511373/1"), 1223 ), 1224 "CHF:0.1", 1225 "CHF:0", 1226 Some("multi 0 2025-10-30T09:46:10.3877961 30Z"), 1227 "2025-10-30", 1228 Some(("CH7389144832588726658", "Grothoff Hans")), 1229 ), 1230 tx_out( 1231 ( 1232 None, 1233 Some("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), 1234 Some("ZV20251030/511373/2"), 1235 ), 1236 "CHF:0.11", 1237 "CHF:0", 1238 Some("multi 1 2025-10-30T09:46:10.3877961 30Z"), 1239 "2025-10-30", 1240 Some(("CH7389144832588726658", "Grothoff Hans")), 1241 ), 1242 tx_out( 1243 ( 1244 None, 1245 Some("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), 1246 Some("ZV20251030/511373/3"), 1247 ), 1248 "CHF:0.12", 1249 "CHF:0", 1250 Some("multi 2 2025-10-30T09:46:10.3877961 30Z"), 1251 "2025-10-30", 1252 Some(("CH7389144832588726658", "Grothoff Hans")), 1253 ), 1254 tx_out( 1255 ( 1256 None, 1257 Some("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), 1258 Some("ZV20251030/511373/4"), 1259 ), 1260 "CHF:0.13", 1261 "CHF:0", 1262 Some("multi 3 2025-10-30T09:46:10.3877961 30Z"), 1263 "2025-10-30", 1264 Some(("CH7389144832588726658", "Grothoff Hans")), 1265 ), 1266 tx_out( 1267 ( 1268 None, 1269 Some("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"), 1270 Some("ZV20251030/524078/1"), 1271 ), 1272 "CHF:0.21", 1273 "CHF:0", 1274 Some("bad name 2025-10-30T12:03:24.997478 811Z"), 1275 "2025-10-30", 1276 Some(("CH6208704048981247126", "John Smith")), 1277 ), 1278 tx_out( 1279 ( 1280 None, 1281 Some("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), 1282 Some("ZV20251030/524079/1"), 1283 ), 1284 "CHF:0.1", 1285 "CHF:0", 1286 Some("single 2025-10-30T12:04:00.37042083 6Z"), 1287 "2025-10-30", 1288 Some(("CH7389144832588726658", "Grothoff Hans")), 1289 ), 1290 tx_out( 1291 ( 1292 None, 1293 Some("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), 1294 Some("ZV20251030/524079/2"), 1295 ), 1296 "CHF:0.21", 1297 "CHF:0", 1298 Some("bad name 2025-10-30T12:03:53.042190 686Z"), 1299 "2025-10-30", 1300 Some(("CH6208704048981247126", "John Smith")), 1301 ), 1302 tx_out( 1303 ( 1304 None, 1305 Some("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"), 1306 Some("ZV20251030/524077/1"), 1307 ), 1308 "CHF:0.23", 1309 "CHF:5", 1310 Some("foreign iban 2025-10-30T12:03:44.0972 63765Z"), 1311 "2025-10-30", 1312 Some(("DE48330605920000686018", "Christian Grothoff")), 1313 ), 1314 tx_out( 1315 ( 1316 None, 1317 Some("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), 1318 Some("ZV20251030/524080/1"), 1319 ), 1320 "CHF:0.23", 1321 "CHF:5", 1322 Some("foreign iban 2025-10-30T12:03:58.0046 73747Z"), 1323 "2025-10-30", 1324 Some(("DE48330605920000686018", "Christian Grothoff")), 1325 ), 1326 ], 1327 &[tx_out( 1328 (None, None, None), 1329 "CHF:0.85", 1330 "CHF:0", 1331 Some("bounce 51030655601.0001: missing reserve public key"), 1332 "1970-01-01", 1333 Some(("CH7389144832588726658", "Grothoff Hans")), 1334 )], 1335 ) 1336 .await; 1337 } 1338 1339 /** GLS dialect test */ 1340 #[db_test] 1341 async fn gls(db: PgPool) { 1342 let cfg = setup_cfg("gls.conf"); 1343 batches( 1344 &db, 1345 &[ 1346 ("COMPAT_SUCCESS", &[gen_init_pay!("COMPAT_SUCCESS")]), 1347 ("COMPAT_FAILURE", &[gen_init_pay!("COMPAT_FAILURE")]), 1348 ( 1349 "BATCH_SINGLE_SUCCESS", 1350 &[gen_init_pay!("FD622SMXKT5QWSAHDY0H8NYG3G")], 1351 ), 1352 // JEYMR3OYZTFM7505OWWENFPAH53LNOWJHS 1353 ( 1354 "BATCH_SINGLE_FAILURE", 1355 &[gen_init_pay!("DAFC3NEE4T48WVC560T76ABA2C")], 1356 ), 1357 ( 1358 "BATCH_SINGLE_RETURN", 1359 &[gen_init_pay!("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5")], 1360 ), 1361 ( 1362 "BATCH_MANY_SUCCESS", 1363 &[ 1364 gen_init_pay!("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V"), 1365 gen_init_pay!("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW"), 1366 gen_init_pay!("35M1268GW5ZFHS5JCB41UKDQNPMD40T849"), 1367 gen_init_pay!("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1"), 1368 ], 1369 ), 1370 ( 1371 "BATCH_MANY_PART", 1372 &[ 1373 gen_init_pay!("27SK3166EG36SJ7VP7VFYP0MW8"), 1374 gen_init_pay!("KGTDBASWTJ6JM89WXD3Q5KFQC4"), 1375 gen_init_pay!("8XK8Z7RAX224FGWK832FD40GYC"), 1376 ], 1377 ), 1378 ( 1379 "BATCH_MANY_FAILURE", 1380 &[ 1381 gen_init_pay!("4XTPKWE4A9V90PRQJCT8Z3MQZ8"), 1382 gen_init_pay!("3VZZHVYJ6XP2SNPKWF4D4YVHNG"), 1383 ], 1384 ), 1385 ], 1386 ) 1387 .await; 1388 1389 register(&db, &cfg, "gls_camt052.xml", &OrderDoc::report).await; 1390 register(&db, &cfg, "gls_camt053.xml", &OrderDoc::statement).await; 1391 // TODO camt054 with missing id before and after 1392 1393 check( 1394 &db, 1395 &[ 1396 ("COMPAT_SUCCESS", success, &[("COMPAT_SUCCESS", success)]), 1397 ( 1398 "COMPAT_FAILURE", 1399 pending, 1400 &[("COMPAT_FAILURE", permanent_failure)], 1401 ), 1402 ( 1403 "BATCH_SINGLE_SUCCESS", 1404 success, 1405 &[("FD622SMXKT5QWSAHDY0H8NYG3G", success)], 1406 ), 1407 ( 1408 "BATCH_SINGLE_FAILURE", // TODO success 1409 pending, 1410 &[("DAFC3NEE4T48WVC560T76ABA2C", pending)], // TODO failure 1411 ), 1412 ( 1413 "BATCH_SINGLE_RETURN", 1414 success, 1415 &[("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", late_failure)], 1416 ), 1417 ( 1418 "BATCH_MANY_SUCCESS", 1419 success, 1420 &[ 1421 ("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V", success), 1422 ("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW", success), 1423 ("35M1268GW5ZFHS5JCB41UKDQNPMD40T849", success), 1424 ("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1", success), 1425 ], 1426 ), 1427 ( 1428 "BATCH_MANY_PART", 1429 success, 1430 &[ 1431 ("27SK3166EG36SJ7VP7VFYP0MW8", success), 1432 ("KGTDBASWTJ6JM89WXD3Q5KFQC4", permanent_failure), 1433 ("8XK8Z7RAX224FGWK832FD40GYC", permanent_failure), 1434 ], 1435 ), 1436 ( 1437 "BATCH_MANY_FAILURE", // TODO success 1438 pending, 1439 &[ 1440 ("4XTPKWE4A9V90PRQJCT8Z3MQZ8", pending), // TODO failure 1441 ("3VZZHVYJ6XP2SNPKWF4D4YVHNG", pending), // TODO failure 1442 ], 1443 ), 1444 ], 1445 &[tx_in( 1446 ( 1447 None, 1448 Some("BYLADEM1WOR-G2910276709458A2"), 1449 Some("2024041210041357000"), 1450 ), 1451 "CHF:3", 1452 "CHF:0", 1453 Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), 1454 "2024-04-12", 1455 Some(("DE84500105177118117964", "John Smith")), 1456 )], 1457 &[ 1458 tx_out( 1459 (None, Some("COMPAT_SUCCESS"), Some("2024041801514102000")), 1460 "CHF:2", 1461 "CHF:0", 1462 Some("TestABC123"), 1463 "2024-04-18", 1464 Some(("DE20500105172419259181", "John Smith")), 1465 ), 1466 tx_out( 1467 ( 1468 None, 1469 Some("FD622SMXKT5QWSAHDY0H8NYG3G"), 1470 Some("2024090216552232000"), 1471 ), 1472 "CHF:1.1", 1473 "CHF:0", 1474 Some("single 2024-09-02T14:29:52.875253314Z"), 1475 "2024-09-02", 1476 Some(("DE89500105173198527518", "Grothoff Hans")), 1477 ), 1478 tx_out( 1479 ( 1480 None, 1481 Some("YF5QBARGQ0MNY0VK59S477VDG4"), 1482 Some("2024041810552821000"), 1483 ), 1484 "CHF:1.1", 1485 "CHF:0", 1486 Some("Simple tx"), 1487 "2024-04-18", 1488 Some(("DE20500105172419259181", "John Smith")), 1489 ), 1490 tx_out( 1491 (None, Some("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V"), None), 1492 "CHF:44", 1493 "CHF:0", 1494 Some("init payment"), 1495 "2024-09-20", 1496 Some(("CH4189144589712575493", "Test")), 1497 ), 1498 tx_out( 1499 (None, Some("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW"), None), 1500 "CHF:44", 1501 "CHF:0", 1502 Some("init payment"), 1503 "2024-09-20", 1504 Some(("CH4189144589712575493", "Test")), 1505 ), 1506 tx_out( 1507 (None, Some("35M1268GW5ZFHS5JCB41UKDQNPMD40T849"), None), 1508 "CHF:44", 1509 "CHF:0", 1510 Some("init payment"), 1511 "2024-09-20", 1512 Some(("CH4189144589712575493", "Test")), 1513 ), 1514 tx_out( 1515 (None, Some("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1"), None), 1516 "CHF:44", 1517 "CHF:0", 1518 Some("init payment"), 1519 "2024-09-20", 1520 Some(("CH4189144589712575493", "Test")), 1521 ), 1522 tx_out( 1523 ( 1524 None, 1525 Some("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5"), 1526 Some("2024092100252498000"), 1527 ), 1528 "CHF:0.42", 1529 "CHF:0", 1530 Some("This should fail because bad iban"), 1531 "2024-09-23", 1532 Some(("DE18500105173385245163", "John Smith")), 1533 ), 1534 tx_out( 1535 (None, Some("27SK3166EG36SJ7VP7VFYP0MW8"), None), 1536 "CHF:44", 1537 "CHF:0", 1538 Some("init payment"), 1539 "2024-09-04", 1540 Some(("CH4189144589712575493", "Test")), 1541 ), 1542 ], 1543 &[], 1544 ) 1545 .await; 1546 } 1547 1548 /** Maerki Baumann dialect test */ 1549 #[db_test] 1550 async fn maerki_baumann(db: PgPool) { 1551 let cfg = setup_cfg("maerki_baumann.conf"); 1552 batches( 1553 &db, 1554 &[( 1555 "BATCH_SINGLE_REPORTING", 1556 &[ 1557 gen_init_pay!("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), 1558 gen_init_pay!("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), 1559 gen_init_pay!("A09R35EW0359SZ51464E7TC37A0P2CBK04"), 1560 gen_init_pay!("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), 1561 ], 1562 )], 1563 ) 1564 .await; 1565 1566 register( 1567 &db, 1568 &cfg, 1569 "maerki_baumann_camt053.xml", 1570 &OrderDoc::statement, 1571 ) 1572 .await; 1573 1574 check( 1575 &db, 1576 &[( 1577 "BATCH_SINGLE_REPORTING", 1578 success, 1579 &[ 1580 ("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", success), 1581 ("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", success), 1582 ("A09R35EW0359SZ51464E7TC37A0P2CBK04", success), 1583 ("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", success), 1584 ], 1585 )], 1586 &[ 1587 tx_in( 1588 ( 1589 Some("adbe4a5a-6cea-4263-b259-8ab964561a32"), 1590 Some("41103099704.0002"), 1591 Some("ZV20241104/765446/1"), 1592 ), 1593 "CHF:1", 1594 "CHF:0.2", 1595 Some("SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G"), 1596 "2024-11-04", 1597 Some(("CH7389144832588726658", "Mr Test")), 1598 ), 1599 tx_in( 1600 ( 1601 Some("7371795e-62fa-42dd-93b7-da89cc120faa"), 1602 Some("41103099704.0003"), 1603 Some("ZV20241104/765447/1"), 1604 ), 1605 "CHF:1", 1606 "CHF:0.2", 1607 Some("Random subject"), 1608 "2024-11-04", 1609 Some(("CH7389144832588726658", "Mr Test")), 1610 ), 1611 tx_in( 1612 (None, Some("50523424675.0001"), Some("ZV20250523/851716/1")), 1613 "CHF:0.5", 1614 "CHF:0.2", 1615 None, 1616 "2025-05-23", 1617 Some(("CH7389144832588726658", "Grothoff Hans")), 1618 ), 1619 tx_in( 1620 ( 1621 Some("f203fbb4-6e13-4c78-9b2a-d852fea6374a"), 1622 Some("41202060702.0001"), 1623 Some("ZV20241202/778108/1"), 1624 ), 1625 "CHF:0.05", 1626 "CHF:0.2", 1627 Some("mini"), 1628 "2024-12-02", 1629 Some(("CH7389144832588726658", "Grothoff Hans")), 1630 ), 1631 tx_in( 1632 ( 1633 Some("81b0d8c6-a677-4577-b75e-a639dcc03681"), 1634 Some("41120636093.0001"), 1635 Some("ZV20241121/773118/1"), 1636 ), 1637 "CHF:0.1", 1638 "CHF:0.2", 1639 Some("small transfer test"), 1640 "2024-11-21", 1641 Some(("CH7389144832588726658", "Grothoff Hans")), 1642 ), 1643 tx_in( 1644 (None, None, Some("ZV20250114/796191/1")), 1645 "CHF:3003", 1646 "CHF:0", 1647 Some("Fix bad payment by MB."), 1648 "2025-01-27", 1649 None, 1650 ), 1651 tx_in( 1652 (None, Some("F000787951230001"), Some("ZV20250526/852733/1")), 1653 "CHF:1.38", 1654 "CHF:0.2", 1655 Some("Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G"), 1656 "2025-05-26", 1657 Some(("DE20500105172419259181", "Mr German")), 1658 ), 1659 ], 1660 &[ 1661 tx_out( 1662 ( 1663 None, 1664 Some("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), 1665 Some("ZV20241121/773541/1"), 1666 ), 1667 "CHF:0.1", 1668 "CHF:0", 1669 Some("multi 0 2024-11-21T15:21:59.8859234 63Z"), 1670 "2024-11-27", 1671 Some(("CH7389144832588726658", "Grothoff Hans")), 1672 ), 1673 tx_out( 1674 ( 1675 None, 1676 Some("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), 1677 Some("ZV20241121/773541/4"), 1678 ), 1679 "CHF:0.13", 1680 "CHF:0", 1681 Some("multi 3 2024-11-21T15:21:59.8859234 63Z"), 1682 "2024-11-27", 1683 Some(("CH7389144832588726658", "Grothoff Hans")), 1684 ), 1685 tx_out( 1686 ( 1687 None, 1688 Some("A09R35EW0359SZ51464E7TC37A0P2CBK04"), 1689 Some("ZV20241121/773541/3"), 1690 ), 1691 "CHF:0.12", 1692 "CHF:0", 1693 Some("multi 2 2024-11-21T15:21:59.8859234 63Z"), 1694 "2024-11-27", 1695 Some(("CH7389144832588726658", "Grothoff Hans")), 1696 ), 1697 tx_out( 1698 ( 1699 None, 1700 Some("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), 1701 Some("ZV20241121/773541/2"), 1702 ), 1703 "CHF:0.11", 1704 "CHF:0", 1705 Some("multi 1 2024-11-21T15:21:59.8859234 63Z"), 1706 "2024-11-27", 1707 Some(("CH7389144832588726658", "Grothoff Hans")), 1708 ), 1709 tx_out( 1710 (None, None, Some("GB20241220/205792/1")), 1711 "CHF:3000", 1712 "CHF:0", 1713 None, 1714 "2024-12-20", 1715 None, 1716 ), 1717 ], 1718 &[ 1719 tx_out( 1720 (None, None, None), 1721 "CHF:1", 1722 "CHF:0", 1723 Some("bounce 7371795e-62fa-42dd-93b7-da89cc120faa: missing reserve public key"), 1724 "1970-01-01", 1725 Some(("CH7389144832588726658", "Mr Test")), 1726 ), 1727 tx_out( 1728 (None, None, None), 1729 "CHF:0.5", 1730 "CHF:0", 1731 Some("bounce 50523424675.0001: missing reserve public key"), 1732 "1970-01-01", 1733 Some(("CH7389144832588726658", "Grothoff Hans")), 1734 ), 1735 tx_out( 1736 (None, None, None), 1737 "CHF:0.05", 1738 "CHF:0", 1739 Some("bounce f203fbb4-6e13-4c78-9b2a-d852fea6374a: missing reserve public key"), 1740 "1970-01-01", 1741 Some(("CH7389144832588726658", "Grothoff Hans")), 1742 ), 1743 tx_out( 1744 (None, None, None), 1745 "CHF:0.1", 1746 "CHF:0", 1747 Some("bounce 81b0d8c6-a677-4577-b75e-a639dcc03681: missing reserve public key"), 1748 "1970-01-01", 1749 Some(("CH7389144832588726658", "Grothoff Hans")), 1750 ), 1751 tx_out( 1752 (None, None, None), 1753 "CHF:1.38", 1754 "CHF:0", 1755 Some("bounce F000787951230001: restricted account"), 1756 "1970-01-01", 1757 Some(("DE20500105172419259181", "Mr German")), 1758 ), 1759 ], 1760 ) 1761 .await; 1762 } 1763 1764 /** PostFinance dialect test */ 1765 #[db_test] 1766 async fn postfinance(db: PgPool) { 1767 let cfg = setup_cfg("postfinance.conf"); 1768 batches( 1769 &db, 1770 &[ 1771 ( 1772 "ZS1PGNTSV0ZNDFAJBBWWB8015G", 1773 &[ 1774 // Legacy notifications use MsgId when EndToEndId is NOTPROVIDED. 1775 gen_init_pay!( 1776 "ZS1PGNTSV0ZNDFAJBBWWB8015G", 1777 "init payment", 1778 amount("CHF:3") 1779 ), 1780 gen_init_pay!( 1781 "POSTFINANCE_NOTIFICATION_BATCH", 1782 "init payment", 1783 amount("CHF:403") 1784 ), 1785 ], 1786 ), 1787 ( 1788 "50820f78-9024-44ff-978d-63a18c", 1789 &[gen_init_pay!( 1790 "50820f78-9024-44ff-978d-63a18c", 1791 "init payment", 1792 amount("CHF:1") 1793 )], 1794 ), 1795 ( 1796 "889d1a80-1267-49bd-8fcc-85701a", 1797 &[gen_init_pay!( 1798 "889d1a80-1267-49bd-8fcc-85701a", 1799 "init payment", 1800 amount("CHF:1") 1801 )], 1802 ), 1803 ( 1804 "4cc61cc7-6230-49c2-b5e2-b40bbb", 1805 &[gen_init_pay!( 1806 "4cc61cc7-6230-49c2-b5e2-b40bbb", 1807 "init payment", 1808 amount("CHF:1") 1809 )], 1810 ), 1811 ( 1812 "EB4D22D428214261B2B3012D2A8CEC36", 1813 &[ 1814 gen_init_pay!( 1815 "POSTFINANCE_STATEMENT_FIRST", 1816 "init payment", 1817 amount("CHF:100") 1818 ), 1819 gen_init_pay!( 1820 "POSTFINANCE_STATEMENT_SECOND", 1821 "init payment", 1822 amount("CHF:306") 1823 ), 1824 ], 1825 ), 1826 ], 1827 ) 1828 .await; 1829 1830 register( 1831 &db, 1832 &cfg, 1833 "postfinance_camt054.xml", 1834 &OrderDoc::notification, 1835 ) 1836 .await; 1837 register(&db, &cfg, "postfinance_camt053.xml", &OrderDoc::statement).await; 1838 1839 check( 1840 &db, 1841 &[ 1842 ( 1843 "ZS1PGNTSV0ZNDFAJBBWWB8015G", 1844 success, 1845 &[ 1846 ("ZS1PGNTSV0ZNDFAJBBWWB8015G", success), 1847 ("POSTFINANCE_NOTIFICATION_BATCH", success), 1848 ], 1849 ), 1850 ( 1851 "50820f78-9024-44ff-978d-63a18c", 1852 success, 1853 &[("50820f78-9024-44ff-978d-63a18c", permanent_failure)], 1854 ), 1855 ( 1856 "889d1a80-1267-49bd-8fcc-85701a", 1857 success, 1858 &[("889d1a80-1267-49bd-8fcc-85701a", permanent_failure)], 1859 ), 1860 ( 1861 "4cc61cc7-6230-49c2-b5e2-b40bbb", 1862 success, 1863 &[("4cc61cc7-6230-49c2-b5e2-b40bbb", permanent_failure)], 1864 ), 1865 ( 1866 "EB4D22D428214261B2B3012D2A8CEC36", 1867 success, 1868 &[ 1869 ("POSTFINANCE_STATEMENT_FIRST", success), 1870 ("POSTFINANCE_STATEMENT_SECOND", success), 1871 ], 1872 ), 1873 ], 1874 &[ 1875 tx_in( 1876 ( 1877 Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), 1878 None, 1879 Some("231121CH0AZWCR9T"), 1880 ), 1881 "CHF:10", 1882 "CHF:0", 1883 Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG"), 1884 "2023-12-19", 1885 Some(("CH7389144832588726658", "Mr Test")), 1886 ), 1887 tx_in( 1888 ( 1889 Some("62e2b511-7313-4ccd-8d40-c9d8e612cd72"), 1890 None, 1891 Some("231121CH0AZWCVR1"), 1892 ), 1893 "CHF:2.53", 1894 "CHF:0", 1895 Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB"), 1896 "2023-12-19", 1897 Some(("CH7389144832588726658", "Mr Test")), 1898 ), 1899 ], 1900 &[ 1901 tx_out( 1902 (None, Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), None), 1903 "CHF:3", 1904 "CHF:0", 1905 None, 1906 "2024-01-15", 1907 None, 1908 ), 1909 tx_out( 1910 (None, Some("POSTFINANCE_NOTIFICATION_BATCH"), None), 1911 "CHF:403", 1912 "CHF:0", 1913 Some("init payment"), 1914 "2024-01-15", 1915 Some(("CH4189144589712575493", "Test")), 1916 ), 1917 tx_out( 1918 (None, Some("POSTFINANCE_STATEMENT_FIRST"), None), 1919 "CHF:100", 1920 "CHF:0", 1921 Some("init payment"), 1922 "2024-08-26", 1923 Some(("CH4189144589712575493", "Test")), 1924 ), 1925 tx_out( 1926 (None, Some("POSTFINANCE_STATEMENT_SECOND"), None), 1927 "CHF:306", 1928 "CHF:0", 1929 Some("init payment"), 1930 "2024-08-26", 1931 Some(("CH4189144589712575493", "Test")), 1932 ), 1933 ], 1934 &[tx_out( 1935 (None, None, None), 1936 "CHF:2.53", 1937 "CHF:0", 1938 Some("bounce 62e2b511-7313-4ccd-8d40-c9d8e612cd72: missing reserve public key"), 1939 "1970-01-01", 1940 Some(("CH7389144832588726658", "Mr Test")), 1941 )], 1942 ) 1943 .await; 1944 } 1945 }