commit 9f6974a6bdec8324079045b9d3cee71833bf1c3e
parent f1b08eed8215d1f866ef35fe86f728a2aabd5e3c
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:38:00 +0200
nexus: add list cmd
Diffstat:
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(' ')?;
}