diff --git a/README.md b/README.md index a6a2e4b..e9bbea0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ cargo run -p banks2ff -- destinations # Inspect accounts cargo run -p banks2ff -- accounts list +cargo run -p banks2ff -- accounts list gocardless # Only GoCardless accounts +cargo run -p banks2ff -- accounts list firefly # Only Firefly III accounts cargo run -p banks2ff -- accounts status # Manage account links @@ -64,7 +66,7 @@ Banks2FF uses a structured command-line interface with the following commands: - `sync ` - Synchronize transactions between source and destination - `sources` - List all available source types - `destinations` - List all available destination types -- `accounts list` - List all discovered accounts +- `accounts list [gocardless|firefly]` - List all discovered accounts (optionally filter by adapter type) - `accounts status` - Show sync status for all accounts - `accounts link` - Manage account links between sources and destinations - `transactions list ` - Show transaction information for a specific account diff --git a/banks2ff/src/adapters/firefly/client.rs b/banks2ff/src/adapters/firefly/client.rs index c847681..85bad64 100644 --- a/banks2ff/src/adapters/firefly/client.rs +++ b/banks2ff/src/adapters/firefly/client.rs @@ -1,5 +1,5 @@ use crate::core::config::Config; -use crate::core::models::{Account, BankTransaction}; +use crate::core::models::{Account, AccountSummary, BankTransaction}; use crate::core::ports::{TransactionDestination, TransactionMatch}; use anyhow::Result; use async_trait::async_trait; @@ -242,4 +242,29 @@ impl TransactionDestination for FireflyAdapter { Ok(result) } + + async fn list_accounts(&self) -> Result> { + let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone()); + let cache = crate::core::cache::AccountCache::load( + self.config.cache.directory.clone(), + encryption, + ); + + let mut summaries = Vec::new(); + + // Use cached account data for display + for (account_id, cached_account) in &cache.accounts { + if let crate::core::cache::CachedAccount::Firefly(ff_account) = cached_account { + let summary = AccountSummary { + id: account_id.clone(), + name: Some(ff_account.name.clone()), + iban: ff_account.iban.clone().unwrap_or_else(|| "".to_string()), + currency: ff_account.currency_code.clone().unwrap_or_else(|| "EUR".to_string()), + }; + summaries.push(summary); + } + } + + Ok(summaries) + } } diff --git a/banks2ff/src/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs index fd571c8..ca697df 100644 --- a/banks2ff/src/adapters/gocardless/client.rs +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -284,15 +284,18 @@ impl TransactionSource for GoCardlessAdapter { let cache = self.cache.lock().await; let mut summaries = Vec::new(); - // Use cached account data for display - for account_id in cache.accounts.keys() { - if let Some(account_data) = cache.get_account_data(account_id) { - let summary = AccountSummary { - id: account_id.clone(), - iban: account_data.iban().unwrap_or("").to_string(), - currency: "EUR".to_string(), // GoCardless primarily uses EUR - }; - summaries.push(summary); + // Use cached account data for display - only GoCardless accounts + for (account_id, cached_account) in &cache.accounts { + if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account { + if let Some(account_data) = cache.get_account_data(account_id) { + let summary = AccountSummary { + id: account_id.clone(), + name: account_data.display_name(), + iban: account_data.iban().unwrap_or("").to_string(), + currency: "EUR".to_string(), // GoCardless primarily uses EUR + }; + summaries.push(summary); + } } } diff --git a/banks2ff/src/cli/formatters.rs b/banks2ff/src/cli/formatters.rs index bea7535..8e495d3 100644 --- a/banks2ff/src/cli/formatters.rs +++ b/banks2ff/src/cli/formatters.rs @@ -29,9 +29,11 @@ impl Formattable for AccountSummary { fn to_table(&self) -> Table { let mut table = Table::new(); table.load_preset(UTF8_FULL); - table.set_header(vec!["ID", "IBAN", "Currency"]); + table.set_header(vec!["ID", "Name", "IBAN", "Currency"]); + let name = self.name.as_deref().unwrap_or(""); table.add_row(vec![ self.id.clone(), + name.to_string(), mask_iban(&self.iban), self.currency.clone(), ]); diff --git a/banks2ff/src/core/models.rs b/banks2ff/src/core/models.rs index 1163207..517b502 100644 --- a/banks2ff/src/core/models.rs +++ b/banks2ff/src/core/models.rs @@ -128,6 +128,7 @@ mod tests { #[derive(Clone, Debug, Serialize)] pub struct AccountSummary { pub id: String, + pub name: Option, pub iban: String, pub currency: String, } diff --git a/banks2ff/src/core/ports.rs b/banks2ff/src/core/ports.rs index 6abfa74..a54c43c 100644 --- a/banks2ff/src/core/ports.rs +++ b/banks2ff/src/core/ports.rs @@ -95,6 +95,9 @@ pub trait TransactionDestination: Send + Sync { /// Account discovery for linking async fn discover_accounts(&self) -> Result>; + + /// Inspection methods for CLI + async fn list_accounts(&self) -> Result>; } // Blanket implementation for references @@ -125,4 +128,8 @@ impl TransactionDestination for &T { async fn discover_accounts(&self) -> Result> { (**self).discover_accounts().await } + + async fn list_accounts(&self) -> Result> { + (**self).list_accounts().await + } } diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs index d4f3c77..c77a9ee 100644 --- a/banks2ff/src/main.rs +++ b/banks2ff/src/main.rs @@ -11,12 +11,13 @@ use crate::core::adapters::{ use crate::core::config::Config; use crate::core::encryption::Encryption; use crate::core::linking::LinkStore; -use crate::core::models::AccountData; -use crate::core::ports::TransactionSource; +use crate::core::models::{AccountData, AccountSummary}; +use crate::core::ports::{TransactionDestination, TransactionSource}; use crate::core::sync::run_sync; use chrono::NaiveDate; use clap::{Parser, Subcommand}; -use tracing::{error, info}; +use comfy_table::{presets::UTF8_FULL, Table}; +use tracing::{error, info, warn}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -104,7 +105,10 @@ enum AccountCommands { subcommand: LinkCommands, }, /// List all accounts - List, + List { + /// Filter by adapter type: 'gocardless' or 'firefly', or omit for all + filter: Option, + }, /// Show account status Status, } @@ -409,12 +413,76 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: } } } - AccountCommands::List => { - let accounts = context.source.list_accounts().await?; - if accounts.is_empty() { + AccountCommands::List { filter } => { + // Validate filter parameter + let show_gocardless = match filter.as_deref() { + Some("gocardless") => true, + Some("firefly") => false, + None => true, // Show both by default + Some(invalid) => { + anyhow::bail!("Invalid filter '{}'. Use 'gocardless', 'firefly', or omit for all.", invalid); + } + }; + let show_firefly = match filter.as_deref() { + Some("gocardless") => false, + Some("firefly") => true, + None => true, // Show both by default + Some(_) => unreachable!(), // Already validated above + }; + + // Get GoCardless accounts if needed + let gocardless_accounts = if show_gocardless { + match context.source.list_accounts().await { + Ok(mut accounts) => { + accounts.sort_by(|a, b| { + a.name.as_deref().unwrap_or("").cmp(b.name.as_deref().unwrap_or("")) + }); + accounts + } + Err(e) => { + warn!("Failed to list GoCardless accounts: {}", e); + Vec::new() + } + } + } else { + Vec::new() + }; + + // Get Firefly III accounts if needed + let firefly_accounts = if show_firefly { + match context.destination.list_accounts().await { + Ok(mut accounts) => { + accounts.sort_by(|a, b| { + a.name.as_deref().unwrap_or("").cmp(b.name.as_deref().unwrap_or("")) + }); + accounts + } + Err(e) => { + warn!("Failed to list Firefly III accounts: {}", e); + Vec::new() + } + } + } else { + Vec::new() + }; + + if gocardless_accounts.is_empty() && firefly_accounts.is_empty() { println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data."); } else { - print_list_output(accounts, &format); + // Print GoCardless accounts + if !gocardless_accounts.is_empty() { + println!("GoCardless Accounts ({}):", gocardless_accounts.len()); + print_accounts_table(&gocardless_accounts); + } + + // Print Firefly III accounts + if !firefly_accounts.is_empty() { + if !gocardless_accounts.is_empty() { + println!(); // Add spacing between tables + } + println!("Firefly III Accounts ({}):", firefly_accounts.len()); + print_accounts_table(&firefly_accounts); + } } } AccountCommands::Status => { @@ -429,6 +497,32 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: Ok(()) } +fn print_accounts_table(accounts: &[AccountSummary]) { + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["ID", "Name", "IBAN", "Currency"]); + + for account in accounts { + let name = account.name.as_deref().unwrap_or(""); + table.add_row(vec![ + account.id.clone(), + name.to_string(), + mask_iban(&account.iban), + account.currency.clone(), + ]); + } + + println!("{}", table); +} + +fn mask_iban(iban: &str) -> String { + if iban.len() <= 4 { + iban.to_string() + } else { + format!("{}{}", "*".repeat(iban.len() - 4), &iban[iban.len() - 4..]) + } +} + async fn handle_transactions( config: Config, subcommand: TransactionCommands,