libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 9f6974a6bdec8324079045b9d3cee71833bf1c3e
parent f1b08eed8215d1f866ef35fe86f728a2aabd5e3c
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 10:38:00 +0200

nexus: add list cmd

Diffstat:
MCargo.lock | 2++
MCargo.toml | 5+++--
Msrc/api.rs | 2+-
Msrc/bench.rs | 17++++++++++-------
Msrc/bin/testbench.rs | 92++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/db.rs | 1+
Asrc/db/list.rs | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/db/payment.rs | 14+++++++-------
Msrc/iso20022/camt.rs | 6+++---
Msrc/iso20022/pain002.rs | 10+++-------
Msrc/lib.rs | 171++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Asrc/list.rs | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 6++----
Msrc/model.rs | 8++++----
14 files changed, 770 insertions(+), 86 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1628,6 +1628,7 @@ dependencies = [ "roxmltree", "serde", "serde_json", + "shlex", "sqlx", "taler-api", "taler-build", @@ -3130,6 +3131,7 @@ dependencies = [ "serde_urlencoded", "serde_with", "sqlx", + "taler-enum-meta", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml @@ -54,4 +54,5 @@ zip = { version = "*", default-features = false, features = [ calamine = "*" indicatif = "0.18.0" tracing-subscriber = "*" -owo-colors = "*" -\ No newline at end of file +owo-colors = "*" +shlex = "*" +\ No newline at end of file diff --git a/src/api.rs b/src/api.rs @@ -116,7 +116,7 @@ async fn add_incoming( id: InId { uetr: None, tx_id: Some(rand_ebics_id()), - acct_svcr_ref: None, + sref: None, }, amount, credit_fee: Amount::zero(&amount.currency), diff --git a/src/bench.rs b/src/bench.rs @@ -36,17 +36,18 @@ mod test { use crate::{ api::test::api_setup, test::{ - ACCOUNT, malformed_completeted_in, malformed_in, malformed_incomplete_in, - prepared_completeted_in, prepared_in, prepared_incomplete_in, register_incomplete_out, - register_out, talerable_completeted_in, talerable_in, talerable_incomplete_in, + ACCOUNT, incomplete_out, malformed_completeted_in, malformed_in, + malformed_incomplete_in, malformed_out, prepared_completeted_in, prepared_in, + prepared_incomplete_in, talerable_completeted_in, talerable_in, + talerable_incomplete_in, talerable_out, }, }; #[tokio::test] pub async fn bench_db() { let (server, db) = api_setup().await; - let amount = 100; - let iter = 100; + let amount = 10; + let iter = 10; let amount = amount.max(10); let accounts_pubs: Vec<_> = (0..amount * 2) .map(|_| { @@ -169,8 +170,10 @@ mod test { prepared_completeted_in(&db) }) .await; - b.measure("register_out", |_| register_out(&db)).await; - b.measure("register_incomplete_out", |_| register_incomplete_out(&db)) + b.measure("register_out", |_| malformed_out(&db)).await; + b.measure("register_talerable_out", |_| talerable_out(&db)) + .await; + b.measure("register_incomplete_out", |_| incomplete_out(&db)) .await; // Revenue api diff --git a/src/bin/testbench.rs b/src/bin/testbench.rs @@ -21,25 +21,16 @@ use std::{borrow::Cow, fmt::Display, str::FromStr}; use anyhow::bail; use clap::{Parser, ValueEnum}; -use compact_str::format_compact; use jiff::Timestamp; use libeufin::{ CONFIG_SOURCE, - Cmd::{self}, config::NexusCfg, - ebics::logger::EbicsLogger, - ebics_setup, keys::{load_bank_keys, load_client_keys}, run, }; use owo_colors::OwoColorize as _; use reedline::{Prompt, Reedline, Signal}; -use reqwest::Client; -use taler_common::{ - config::Config, - log::taler_logger, - types::{amount::amount, payto::TransferIbanPayto}, -}; +use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto}; use tracing::Level; use tracing_subscriber::util::SubscriberInitExt as _; @@ -70,6 +61,9 @@ pub enum NexusCmd { /// Fetch all documents Fetch, Submit, + List { + raw_args: Vec<String>, + }, Exit, } @@ -96,6 +90,19 @@ fn check<R, E: Display>(res: Result<R, E>) -> bool { res.is_ok() } +pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> bool { + let parts = shlex::split(cmd).unwrap(); + let args = std::iter::once("dummy_bin").chain(parts.iter().map(|it| it.as_str())); + + match libeufin::Args::try_parse_from(args) { + Ok(cmd) => check(run(cfg.clone(), cmd.cmd).await), + Err(e) => { + println!("Error: {}", e); + false + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { taler_logger(Some(Level::DEBUG)).init(); @@ -130,8 +137,8 @@ async fn main() -> anyhow::Result<()> { // Augment config let simple_cfg = std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap(); - let conf = format!("testbench/test/{}/ebics.conf", cmd.platform); - std::fs::write(&conf, format!(r#" + let cfg = format!( + r#" {simple_cfg} {} [paths] @@ -160,13 +167,19 @@ async fn main() -> anyhow::Result<()> { [ebisyncdb-postgres] CONFIG = postgres:///libeufintestbench - "#, simple_cfg.replace("[nexus-ebics]", "[ebisync]").replace("[nexus-setup]", "[ebisync-setup]"), cmd.platform, cmd.platform)).unwrap(); + "#, + simple_cfg + .replace("[nexus-ebics]", "[ebisync]") + .replace("[nexus-setup]", "[ebisync-setup]"), + cmd.platform, + cmd.platform + ); let mut line_editor = Reedline::create(); let prompt = BenchPrompt { prompt: format!("{:?} {}", cmd.component, cmd.platform), }; - let cfg = Config::from_file(CONFIG_SOURCE, Some(&conf)).unwrap(); + let cfg = Config::from_mem_with_env(CONFIG_SOURCE, &cfg).unwrap(); let cfg = NexusCfg::parse(cfg).unwrap(); let ebics = cfg.keys().unwrap(); let (name, settings) = match cfg.host().unwrap().base_url.as_str() { @@ -181,10 +194,7 @@ async fn main() -> anyhow::Result<()> { "https://ebics.postfinance.ch/ebics/ebics.aspx" => ("PostFinance", None), _ => ("Unknown", None), }; - let http = &Client::new(); let test = settings.is_some(); - let ebics_log = - EbicsLogger::new(Some(format!("testbench/test/{}", cmd.platform).into())).unwrap(); let payto = match cfg.currency.as_ref() { "CHF" => { "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" @@ -195,6 +205,8 @@ async fn main() -> anyhow::Result<()> { _ => todo!("{}", cfg.currency), }; let payto = TransferIbanPayto::from_str(payto).unwrap(); + let log_flags = format!("--debug-ebics testbench/test/{}", cmd.platform); + let transient_flag = format!("{log_flags} --transient"); loop { // Automatic setup { @@ -208,7 +220,7 @@ async fn main() -> anyhow::Result<()> { || bank.map(|it| !it.accepted).unwrap_or(true) { step("Run EBICS setup"); - if !check(ebics_setup(http, &cfg, &ebics_log, false, true).await) { + if !nexus_cmd(&cfg.cfg, &format!("ebics-setup {log_flags}")).await { if let Some(settings) = settings { let client = load_client_keys(ebics.client_priv_keys_path.as_ref()).unwrap(); @@ -236,33 +248,41 @@ async fn main() -> anyhow::Result<()> { }; match NexusCmd::try_parse_from(buf.split_whitespace()) { Ok(cmd) => match cmd { + NexusCmd::ResetDb => { + nexus_cmd(&cfg.cfg, "dbinit -r").await; + } NexusCmd::Fetch => { - check(run(cfg.cfg.clone(), &Cmd::EbicsFetch {}, &ebics_log).await); + nexus_cmd(&cfg.cfg, &format!("ebics-fetch {transient_flag}")).await; } NexusCmd::Submit => { - check(run(cfg.cfg.clone(), &Cmd::EbicsSubmit {}, &ebics_log).await); + nexus_cmd(&cfg.cfg, &format!("ebics-submit {transient_flag}")).await; } NexusCmd::Tx => { - check( - run( - cfg.cfg.clone(), - &Cmd::InitiatePayment { - amount: Some(amount(format!("{}:0.1", cfg.currency))), - subject: Some(format_compact!("single {}", Timestamp::now())), - end_to_end_id: None, - payto: payto.clone(), - }, - &ebics_log, - ) - .await, - ); + nexus_cmd( + &cfg.cfg, + &format!( + "initiate-payment --amount={}:0.1 --subject=\"single {}\" {payto}", + cfg.currency, + Timestamp::now() + ), + ) + .await; + } + NexusCmd::List { raw_args } => { + nexus_cmd( + &cfg.cfg, + &format!( + "list {}", + shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() + ), + ) + .await; } - NexusCmd::ResetDb => {} NexusCmd::ResetKeys => { if test { - std::fs::remove_file(format!("testbench/{}", ebics.client_priv_keys_path))?; + std::fs::remove_file(&ebics.client_priv_keys_path)?; } - std::fs::remove_file(format!("testbench/{}", ebics.bank_pub_keys_path))?; + std::fs::remove_file(&ebics.bank_pub_keys_path)?; } NexusCmd::Exit => return Ok(()), }, diff --git a/src/db.rs b/src/db.rs @@ -23,6 +23,7 @@ use crate::config::parse_db_cfg; pub mod exchange; pub mod initiated; +pub mod list; pub mod payment; pub mod transfer; diff --git a/src/db/list.rs b/src/db/list.rs @@ -0,0 +1,275 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use compact_str::CompactString; +use jiff::Timestamp; +use sqlx::{PgPool, Row as _, postgres::PgRow}; +use taler_api::db::TypeHelper as _; +use taler_common::{ + api_common::{EddsaPublicKey, ShortHashCode}, + types::amount::{Amount, Currency, Decimal}, +}; + +use crate::model::{InId, OutId}; + +/** Incoming transaction metadata for debugging */ +pub struct InMetadata { + pub id: InId, + pub date: Timestamp, + pub amount: Amount, + pub credit_fee: Option<Decimal>, + pub subject: Option<String>, + pub debtor: Option<String>, + pub talerable: Option<String>, + pub bounced: Option<String>, +} + +/** Outgoing transaction metadata for debugging */ +pub struct OutMetadata { + pub id: OutId, + pub date: Timestamp, + pub amount: Amount, + pub subject: Option<String>, + pub creditor: Option<String>, + pub wtid: Option<ShortHashCode>, + pub exchange_base_url: Option<String>, +} + +/** Initiated metadata for debugging */ +pub struct InitMetadata { + pub date: Timestamp, + pub amount: Amount, + pub subject: String, + pub creditor: String, + pub id: String, + pub batch: Option<String>, + pub batch_order: Option<String>, + pub status: String, + pub msg: Option<String>, + pub submission_time: Option<Timestamp>, + pub submission_counter: u32, +} + +/** Initiated metadata for debugging */ +pub struct InitMetadataAck { + pub date: Timestamp, + pub amount: Amount, + pub subject: String, + pub creditor: String, + pub db_id: u64, + pub id: String, +} + +/** List incoming transaction metadata for debugging */ +pub async fn incoming( + db: &PgPool, + incomplete: bool, + currency: &Currency, +) -> sqlx::Result<Vec<InMetadata>> { + let query = if incomplete { + " + SELECT + incoming.amount AS amount + ,credit_fee + ,incoming.subject + ,end_to_end_id AS bounced + ,execution_time + ,debit_payto + ,type::text + ,metadata + ,uetr + ,tx_id + ,acct_svcr_ref + ,talerable_incoming_transactions.authorization_pub as auth_pub + ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub + FROM incoming_transactions AS incoming + LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) + LEFT JOIN bounced_transactions USING (incoming_transaction_id) + LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) + WHERE debit_payto IS NULL OR incoming.subject IS NULL + ORDER BY execution_time + " + } else { + " + SELECT + incoming.amount AS amount + ,credit_fee + ,incoming.subject + ,end_to_end_id AS bounced + ,execution_time + ,debit_payto + ,type::text + ,metadata + ,uetr + ,tx_id + ,acct_svcr_ref + ,talerable_incoming_transactions.authorization_pub as auth_pub + ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub + FROM incoming_transactions AS incoming + LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) + LEFT JOIN bounced_transactions USING (incoming_transaction_id) + LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) + ORDER BY execution_time + " + }; + sqlx::query(query) + .try_map(|r: PgRow| { + let auth_pub: Option<EddsaPublicKey> = r.try_get("auth_pub")?; + let pending_pub: Option<EddsaPublicKey> = r.try_get("pending_pub")?; + let map = if let Some(auth_pub) = auth_pub { + format!(" mapped by {auth_pub}") + } else { + String::new() + }; + Ok(InMetadata { + id: InId { + uetr: r.try_get("uetr")?, + tx_id: r.try_get("tx_id")?, + sref: r.try_get("acct_svcr_ref")?, + }, + date: r.try_get_timestamp("execution_time")?, + amount: r.try_get_amount("amount", currency)?, + credit_fee: r.try_get("credit_fee")?, + subject: r.try_get("subject")?, + debtor: r.try_get("debit_payto")?, + bounced: r.try_get("bounced")?, + talerable: match r.try_get::<Option<CompactString>, _>("type")? { + None => { + if let Some(pending) = pending_pub { + Some(format!("pending mapped by {pending}")) + } else { + None + } + } + Some(ty) => Some(format!( + "{ty} {}{map}", + r.try_get::<EddsaPublicKey, _>("metadata")? + )), + }, + }) + }) + .fetch_all(db) + .await +} + +/** List outgoing transaction metadata for debugging */ +pub async fn outgoing(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<OutMetadata>> { + sqlx::query( + " + SELECT + amount + ,subject + ,execution_time + ,credit_payto + ,end_to_end_id + ,acct_svcr_ref + ,wtid + ,exchange_base_url + FROM outgoing_transactions + LEFT JOIN talerable_outgoing_transactions using (outgoing_transaction_id) + ORDER BY execution_time + ", + ) + .try_map(|r: PgRow| { + Ok(OutMetadata { + id: OutId { + msg_id: None, + e2e_id: r.try_get("end_to_end_id")?, + sref: r.try_get("acct_svcr_ref")?, + }, + date: r.try_get_timestamp("execution_time")?, + amount: r.try_get_amount("amount", currency)?, + subject: r.try_get("subject")?, + creditor: r.try_get("credit_payto")?, + wtid: r.try_get("wtid")?, + exchange_base_url: r.try_get("exchange_base_url")?, + }) + }) + .fetch_all(db) + .await +} + +/** List initiated transaction metadata for debugging */ +pub async fn initiated(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<InitMetadata>> { + sqlx::query( + " + SELECT + amount + ,subject + ,initiation_time + ,submission_date + ,submission_counter + ,credit_payto + ,end_to_end_id + ,message_id + ,order_id + ,initiated_outgoing_transactions.status::text + ,initiated_outgoing_transactions.status_msg + FROM initiated_outgoing_transactions + LEFT JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) + ORDER BY initiation_time + ", + ) + .try_map(|r: PgRow| { + Ok(InitMetadata { + date: r.try_get_timestamp("initiation_time")?, + amount: r.try_get_amount("amount", currency)?, + subject: r.try_get("subject")?, + creditor: r.try_get("credit_payto")?, + id: r.try_get("end_to_end_id")?, + batch: r.try_get("message_id")?, + batch_order: r.try_get("order_id")?, + status: r.try_get("status")?, + msg: r.try_get("status_msg")?, + submission_time: r.try_get_opt_timestamp("submission_date")?, + submission_counter: r.try_get_opt_u32("submission_counter")?.unwrap_or_default(), + }) + }) + .fetch_all(db) + .await +} + +/** List initiated transaction metadata pending acknowledgment for debugging */ +pub async fn initiated_ack(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<InitMetadataAck>> { + sqlx::query( + " + SELECT + amount + ,subject + ,initiation_time + ,credit_payto + ,end_to_end_id + ,initiated_outgoing_transaction_id + FROM initiated_outgoing_transactions + WHERE initiated_outgoing_batch_id IS NULL AND NOT awaiting_ack + ORDER BY initiation_time + ", + ) + .try_map(|r: PgRow| { + Ok(InitMetadataAck { + date: r.try_get_timestamp("initiation_time")?, + amount: r.try_get_amount("amount", currency)?, + subject: r.try_get("subject")?, + creditor: r.try_get("credit_payto")?, + id: r.try_get("end_to_end_id")?, + db_id: r.try_get_u64("initiated_outgoing_transaction_id")?, + }) + }) + .fetch_all(db) + .await +} diff --git a/src/db/payment.rs b/src/db/payment.rs @@ -133,7 +133,7 @@ pub async fn register_in(pool: &PgPool, payment: &InTx) -> sqlx::Result<InResult .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) .bind(payment.id.uetr) .bind(&payment.id.tx_id) - .bind(&payment.id.acct_svcr_ref) + .bind(&payment.id.sref) .try_map(|r: PgRow| { Ok(InResult { id: r.try_get_u64("out_tx_id")?, @@ -174,7 +174,7 @@ pub async fn register_in_talerable( .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) .bind(payment.id.uetr) .bind(&payment.id.tx_id) - .bind(&payment.id.acct_svcr_ref) + .bind(&payment.id.sref) .bind(subject.ty()) .bind(subject.key()) .try_map(|r: PgRow| { @@ -225,7 +225,7 @@ pub async fn register_in_qr_bill( .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) .bind(payment.id.uetr) .bind(&payment.id.tx_id) - .bind(&payment.id.acct_svcr_ref) + .bind(&payment.id.sref) .bind(reference) .try_map(|r: PgRow| { Ok(if r.try_get_flag("out_reserve_pub_reuse")? { @@ -277,7 +277,7 @@ pub async fn register_in_malformed( .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) .bind(payment.id.uetr) .bind(&payment.id.tx_id) - .bind(&payment.id.acct_svcr_ref) + .bind(&payment.id.sref) .bind(bounce_amount) .bind_timestamp(timestamp) .bind(bounce_end_to_end_id) @@ -977,7 +977,7 @@ mod test { ) .bind(p.id.uetr) .bind(&p.id.tx_id) - .bind(&p.id.acct_svcr_ref) + .bind(&p.id.sref) .bind(&p.subject) .bind(p.debtor.as_ref().map(|it| it.as_ref().as_str())) .try_map(|r: PgRow| { @@ -1015,7 +1015,7 @@ mod test { let full_id = InId::new( Some(id.uetr.unwrap_or_else(Uuid::new_v4)), Some(id.tx_id.clone().unwrap_or_else(rand_ebics_id)), - Some(id.acct_svcr_ref.clone().unwrap_or_else(rand_ebics_id)), + Some(id.sref.clone().unwrap_or_else(rand_ebics_id)), ); let full = InTx { id: full_id.clone(), @@ -1062,7 +1062,7 @@ mod test { let full_id = InId::new( Some(id.uetr.unwrap_or_else(Uuid::new_v4)), Some(id.tx_id.clone().unwrap_or_else(rand_ebics_id)), - Some(id.acct_svcr_ref.clone().unwrap_or_else(rand_ebics_id)), + Some(id.sref.clone().unwrap_or_else(rand_ebics_id)), ); let full = InTx { id: full_id.clone(), diff --git a/src/iso20022/camt.rs b/src/iso20022/camt.rs @@ -191,13 +191,13 @@ fn incoming_id(n: Xml, sref: Option<&str>) -> xml::Result<InId> { Ok(InId { uetr, tx_id, - acct_svcr_ref: sref.map(|it| it.into()), + sref: sref.map(|it| it.into()), }) } else { Ok(InId { uetr: None, tx_id: None, - acct_svcr_ref: sref.map(|it| it.into()), + sref: sref.map(|it| it.into()), }) } } @@ -474,7 +474,7 @@ pub fn parse_camt(xml: &[u8]) -> xml::Result<Vec<AccountTransactions>> { } (Kind::DBIT, true) | (Kind::CRDT, false) => { let id = incoming_id(tx, sref.as_deref())?; - if id.uetr.is_none() && id.tx_id.is_none() && id.acct_svcr_ref.is_none() { + if id.uetr.is_none() && id.tx_id.is_none() && id.sref.is_none() { warn!("missing unique ID for Credit") } else { txs.push(Tx::In(InTx { diff --git a/src/iso20022/pain002.rs b/src/iso20022/pain002.rs @@ -35,7 +35,7 @@ fn fmt_msg( if let Some(code) = code { write!(f, "{code}")?; if let Some(description) = description { - write!(f, " '{description}' ")?; + write!(f, " '{description}'")?; } if !reasons.is_empty() { f.write_char(':')?; @@ -46,15 +46,11 @@ fn fmt_msg( info: information, } in reasons { - f.write_char(' ')?; if let Some(code) = code { - write!(f, "{} '{}' ", code.code(), code.description())?; + write!(f, " {} '{}'", code.code(), code.description())?; } if !information.is_empty() { - if code.is_some() { - f.write_char(' ')?; - } - write!(f, "'{information}' ")?; + write!(f, " '{information}'")?; } } Ok(()) diff --git a/src/lib.rs b/src/lib.rs @@ -20,19 +20,20 @@ use std::{ collections::BTreeMap, io::{Cursor, Read}, - path::Path, + path::{Path, PathBuf}, str::FromStr, }; use anyhow::{anyhow, bail}; use compact_str::{CompactString, CompactStringExt}; -use jiff::Timestamp; +use jiff::{Timestamp, civil::Date}; use rand::prelude::IndexedRandom; use reqwest::Client; use sqlx::PgPool; use taler_build::long_version; use taler_common::{ CommonArgs, + cli::ConfigCmd, config::{Config, parser::ConfigSource}, types::{ amount::Amount, @@ -45,6 +46,7 @@ use crate::{ config::NexusCfg, crypto::ebics_pub_key_hash, db::{ + dbinit, initiated::{ batch_initiated, batch_status_update, batch_sub_failure, batch_sub_success, initiate, initiated_submittable, order_failure, order_step, order_success, tx_status_update, @@ -74,6 +76,7 @@ use crate::{ BankPubKeysFile, ClientPriKeysFile, expect_full_keys, load_bank_keys, load_client_keys, persist_bank_keys, persist_client_keys, }, + list::ListCmd, model::{InTx, OutTx, PaymentBatch, SubmissionState, Tx}, utils::hex_chunk_by_two, worker::register_tx, @@ -88,6 +91,7 @@ pub mod dialect; pub mod ebics; pub mod iso20022; pub mod keys; +pub mod list; pub mod model; pub mod utils; pub mod worker; @@ -106,22 +110,96 @@ pub fn rand_ebics_id() -> CompactString { .collect() } +#[derive(clap::Parser, Debug, Clone)] +pub struct EbicsArgs { + #[command(flatten)] + logs: EbicsLogs, + + /// Execute once and return, ignoring the 'FREQUENCY' configuration value + #[clap(long)] + transient: bool, +} + +#[derive(clap::Parser, Debug, Clone)] +pub struct EbicsLogs { + /// Log EBICS transactions steps and payload at log_dir + #[clap(long = "debug-ebics", value_name = "log_dir")] + #[arg(global = true)] + dir: Option<PathBuf>, +} + #[derive(clap::Subcommand, Debug)] pub enum Cmd { - Setup {}, - EbicsFetch {}, - EbicsSubmit {}, + /// Initialize libeufin-nexus database + Dbinit { + /// Reset database (DANGEROUS: All existing data is lost) + #[clap(long, short)] + reset: bool, + }, + /// Set up the EBICS subscriber + EbicsSetup { + #[command(flatten)] + ebics_logs: EbicsLogs, + + /// Resubmits all the keys to the bank + #[clap(long)] + force_keys_resubmission: bool, + + /// Accepts the bank keys without interactively asking the user + #[clap(long)] + auto_accept_keys: bool, + + /// Generates the PDF with the client public keys to send to the bank + #[clap(long)] + generate_registration_pdf: bool, + }, + /// Submits pending initiated payments found in the database + EbicsSubmit { + #[clap(flatten)] + ebics: EbicsArgs, + }, + /// Downloads and parse EBICS files from the bank and register them into the database + EbicsFetch { + #[clap(flatten)] + ebics: EbicsArgs, + + /// Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents + #[clap(long, value_name = "YYYY-MM-DD")] + pinned_start: Option<Date>, + + /// Only supported in --transient mode, do not consume fetched documents + #[clap(long, requires = "transient")] + peek: bool, + + /// Only supported in --transient mode, run a checkpoint + #[clap(long, requires = "transient")] + checkpoint: bool, + }, + Serve {}, /// Initiate an outgoing payment InitiatePayment { /// The amount to transfer, payto 'amount' parameter takes the precedence + #[clap(long)] amount: Option<Amount>, + /// The payment subject, payto 'message' parameter takes the precedence + #[clap(long)] subject: Option<CompactString>, + /// The payment end-to-end UID + #[clap(long, alias = "request-uid")] end_to_end_id: Option<CompactString>, + /// The credited account IBAN payto UR payto: TransferIbanPayto, }, + Manual {}, + #[command(subcommand)] + List(ListCmd), + #[command(subcommand)] + Config(ConfigCmd), + + Testing {}, } #[derive(clap::Parser, Debug)] @@ -592,27 +670,65 @@ pub async fn ebics_fetch( Ok(()) } -pub async fn run(cfg: Config, cmd: &Cmd, ebics_log: &EbicsLogger) -> anyhow::Result<()> { +pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> { match cmd { - Cmd::Setup {} => { + Cmd::Dbinit { reset } => { + dbinit(&cfg, reset).await?; + } + Cmd::EbicsSetup { + ebics_logs, + force_keys_resubmission, + auto_accept_keys, + generate_registration_pdf, + } => { let cfg = NexusCfg::parse(cfg)?; - ebics_setup(&Client::new(), &cfg, &EbicsLogger::new(None)?, false, false).await?; + ebics_setup( + &Client::new(), + &cfg, + &EbicsLogger::new(ebics_logs.dir)?, + force_keys_resubmission, + auto_accept_keys, + ) + .await?; } - Cmd::EbicsFetch {} => { + Cmd::EbicsFetch { + pinned_start, + peek, + checkpoint, + ebics, + } => { let pool = pool(&cfg).await?; let http = Client::new(); let cfg = NexusCfg::parse(cfg)?; let key_cfg = cfg.keys()?; let (client, bank) = expect_full_keys(key_cfg)?; - ebics_fetch(&cfg, &http, &client, &bank, ebics_log, &pool, None).await? + ebics_fetch( + &cfg, + &http, + &client, + &bank, + &EbicsLogger::new(ebics.logs.dir)?, + &pool, + None, + ) + .await? } - Cmd::EbicsSubmit {} => { + Cmd::EbicsSubmit { ebics } => { let pool = pool(&cfg).await?; let http = Client::new(); let cfg = NexusCfg::parse(cfg)?; let key_cfg = cfg.keys()?; let (client, bank) = expect_full_keys(key_cfg)?; - ebics_submit(&cfg, &http, &client, &bank, ebics_log, &pool, true).await? + ebics_submit( + &cfg, + &http, + &client, + &bank, + &EbicsLogger::new(ebics.logs.dir)?, + &pool, + true, + ) + .await? } Cmd::InitiatePayment { amount, @@ -654,6 +770,15 @@ pub async fn run(cfg: Config, cmd: &Cmd, ebics_log: &EbicsLogger) -> anyhow::Res ) .await?; } + Cmd::Serve {} => todo!(), + Cmd::Manual {} => todo!(), + Cmd::List(cmd) => { + let pool = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + cmd.run(&pool, &cfg.currency).await?; + } + Cmd::Config(config_cmd) => todo!(), + Cmd::Testing {} => todo!(), } Ok(()) } @@ -665,15 +790,17 @@ pub mod test { use compact_str::CompactString; use jiff::Timestamp; use sqlx::PgPool; - use taler_api::subject::{fmt_in_subject, subject_fmt_qr_bill}; + use taler_api::subject::{fmt_in_subject, fmt_out_subject, subject_fmt_qr_bill}; use taler_common::{ api_common::{EddsaPublicKey, EddsaSignature}, db::IncomingType, types::{ amount::{Amount, Currency}, + base32::Base32, payto::{IbanPayto, PaytoURI, payto}, }, }; + use url::Url; use crate::{ config::{AccountType, NexusIngestCfg}, @@ -900,14 +1027,14 @@ pub mod test { } /** Register an outgoing transaction */ - pub async fn register_out(db: &PgPool) { + pub async fn malformed_out(db: &PgPool) { register_outgoing(db, &gen_out_pay("ignored")) .await .unwrap(); } /** Register an incomplete outgoing transaction */ - pub async fn register_incomplete_out(db: &PgPool) { + pub async fn incomplete_out(db: &PgPool) { let incomplete = OutTx { subject: None, creditor: None, @@ -915,4 +1042,18 @@ pub mod test { }; register_outgoing(db, &incomplete).await.unwrap(); } + + /// Register outgoing talerable transaction + pub async fn talerable_out(db: &PgPool) { + register_outgoing( + db, + &gen_out_pay(fmt_out_subject( + &Base32::rand(), + &Url::from_str("https://exchange.test.com").unwrap(), + None, + )), + ) + .await + .unwrap(); + } } diff --git a/src/list.rs b/src/list.rs @@ -0,0 +1,247 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin is free software; you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation; either version 3, or +* (at your option) any later version. + +* LibEuFin is distributed in the hope that it will be useful, but +* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +* Public License for more details. + +* You should have received a copy of the GNU Affero General Public +* License along with LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::{fmt::from_fn, io::Write as _, str::FromStr}; + +use sqlx::PgPool; +use taler_common::types::{ + amount::{Currency, Decimal}, + payto::FullIbanPayto, +}; + +use crate::db::list::{self, InMetadata, InitMetadata, InitMetadataAck, OutMetadata}; + +fn fmt_payto(f: &mut std::fmt::Formatter, payto: &str) -> std::fmt::Result { + match FullIbanPayto::from_str(payto) { + Ok(p) => { + write!(f, "{}", p.iban)?; + if let Some(bic) = &p.bic { + write!(f, " {bic}")?; + } + // TODO handle missing name better + write!(f, " {}", p.name) + } + Err(_) => write!(f, "{}", payto.trim_start_matches("payto//")), + } +} + +/// List nexus transactions +#[derive(clap::Subcommand, Debug)] +pub enum ListCmd { + /// List incoming transactions + Incoming { + /// Only list transactions that are incomplete + #[clap(long)] + incomplete: bool, + }, + /// List outgoing transactions + Outgoing, + /// List initiated transactions + Initiated { + /// Only list transactions awaiting manual acknowledgment + #[clap(long, alias = "awaiting-ack")] + ack: bool, + }, +} + +impl ListCmd { + pub async fn run(&self, db: &PgPool, currency: &Currency) -> anyhow::Result<()> { + match self { + ListCmd::Incoming { incomplete } => { + let txs = list::incoming(db, *incomplete, currency).await?; + let out = &mut std::io::stdout().lock(); + for InMetadata { + id, + date, + amount, + credit_fee, + subject, + debtor, + talerable, + bounced, + } in txs + { + write!(out, "{date} {id} {amount}")?; + if let Some(credit_fee) = credit_fee + && credit_fee != Decimal::zero() + { + write!(out, "-{credit_fee}")?; + } + writeln!(out)?; + if let Some(debtor) = debtor { + writeln!(out, " debtor: {}", from_fn(|f| fmt_payto(f, &debtor)))?; + } + if let Some(subject) = subject { + writeln!(out, " subject: {subject}")?; + } + if let Some(talerable) = talerable { + writeln!(out, " subject: {talerable}")?; + } + if let Some(bounced) = bounced { + writeln!(out, " bounced: {bounced}")?; + } + writeln!(out)?; + } + } + ListCmd::Outgoing => { + let txs = list::outgoing(db, currency).await?; + let out = &mut std::io::stdout().lock(); + for OutMetadata { + id, + date, + amount, + subject, + creditor, + wtid, + exchange_base_url, + } in txs + { + writeln!(out, "{date} {id} {amount}")?; + if let Some(creditor) = creditor { + writeln!(out, " creditor: {}", from_fn(|f| fmt_payto(f, &creditor)))?; + } + if let Some(subject) = subject { + writeln!(out, " subject: {subject}")?; + } + if let Some(wtid) = wtid { + writeln!( + out, + " talerable: {wtid} {}", + exchange_base_url.unwrap_or_default() + )?; + } + writeln!(out)?; + } + } + ListCmd::Initiated { ack } => { + if *ack { + let txs = list::initiated_ack(db, currency).await?; + let out = &mut std::io::stdout().lock(); + for InitMetadataAck { + date, + amount, + subject, + creditor, + db_id, + id, + } in txs + { + writeln!(out, "{date} {id} {amount}")?; + writeln!(out, " creditor: {}", from_fn(|f| fmt_payto(f, &creditor)))?; + writeln!(out, " subject: {subject}")?; + writeln!(out, " ack: {db_id}")?; + writeln!(out)?; + } + } else { + let txs = list::initiated(db, currency).await?; + let out = &mut std::io::stdout().lock(); + for InitMetadata { + date, + amount, + subject, + creditor, + id, + batch, + batch_order, + status, + msg, + submission_time, + submission_counter, + } in txs + { + writeln!(out, "{date} {id} {amount}")?; + writeln!(out, " creditor: {}", from_fn(|f| fmt_payto(f, &creditor)))?; + writeln!(out, " subject: {subject}")?; + if let Some(batch) = batch { + if let Some(order) = batch_order { + writeln!(out, " batch: {batch} {order}")?; + } else { + writeln!(out, " batch: {batch}")?; + } + } + if let Some(submission_time) = submission_time { + writeln!(out, " submission: {submission_time} {submission_counter}")?; + } + write!(out, " status: {status}")?; + if let Some(msg) = msg { + write!(out, "{msg}")?; + } + writeln!(out, "\n")?; + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use crate::{ + db::test::db_setup, + list::ListCmd, + test::{ + CURR, incomplete_out, malformed_completeted_in, malformed_in, malformed_incomplete_in, + malformed_out, prepared_completeted_in, prepared_incomplete_in, + talerable_completeted_in, talerable_in, talerable_incomplete_in, talerable_kyc_in, + talerable_out, + }, + }; + + #[tokio::test] + async fn check() { + let (_, db) = db_setup().await; + + let check = async || { + for cmd in [ + ListCmd::Outgoing, + ListCmd::Incoming { incomplete: true }, + ListCmd::Incoming { incomplete: false }, + ListCmd::Initiated { ack: true }, + ListCmd::Initiated { ack: false }, + ] { + cmd.run(&db, &CURR).await.unwrap(); + } + }; + + // Check empty + check().await; + + // Check with transactions + malformed_in(&db).await; + malformed_out(&db).await; + check().await; + + // Check with taler transactions + talerable_out(&db).await; + talerable_in(&db).await; + talerable_kyc_in(&db).await; + malformed_completeted_in(&db).await; + talerable_completeted_in(&db).await; + prepared_completeted_in(&db).await; + check().await; + + // Check with incomplete + incomplete_out(&db).await; + prepared_incomplete_in(&db).await; + malformed_incomplete_in(&db).await; + talerable_incomplete_in(&db).await; + check().await; + } +} diff --git a/src/main.rs b/src/main.rs @@ -18,12 +18,10 @@ */ use clap::Parser as _; -use libeufin::{Args, CONFIG_SOURCE, ebics::logger::EbicsLogger, run}; +use libeufin::{Args, CONFIG_SOURCE, run}; use taler_common::taler_main; fn main() { let args = Args::parse(); - taler_main(CONFIG_SOURCE, args.common, async |cfg| { - run(cfg, &args.cmd, &EbicsLogger::new(None)?).await - }) + taler_main(CONFIG_SOURCE, args.common, |cfg| run(cfg, args.cmd)) } diff --git a/src/model.rs b/src/model.rs @@ -77,7 +77,7 @@ pub struct InId { /// ISO20022 TxID pub tx_id: Option<CompactString>, /// ISO20022 AcctSvcrRef - pub acct_svcr_ref: Option<CompactString>, + pub sref: Option<CompactString>, } impl InId { @@ -90,7 +90,7 @@ impl InId { Self { uetr, tx_id, - acct_svcr_ref, + sref: acct_svcr_ref, } } @@ -98,7 +98,7 @@ impl InId { self.uetr .map(|e| e.to_compact_string()) .or(self.tx_id.clone()) - .or(self.acct_svcr_ref.clone()) + .or(self.sref.clone()) .expect("must be at least one ref") } } @@ -119,7 +119,7 @@ impl std::fmt::Display for InId { f.write_str(tx_id)?; prepend = true; } - if let Some(acct_svcr_ref) = &self.acct_svcr_ref { + if let Some(acct_svcr_ref) = &self.sref { if prepend { f.write_char(' ')?; }