feat: Improve accounts list

The accounts lists are now shown per source and/or destination, and
include the name. Furthermore they are sorted alphabetically by name,
because that is how humans think.
This commit is contained in:
2025-11-28 19:17:20 +01:00
parent 8518bb33f5
commit c3d74fa6ae
7 changed files with 154 additions and 20 deletions

View File

@@ -46,6 +46,8 @@ cargo run -p banks2ff -- destinations
# Inspect accounts # Inspect accounts
cargo run -p banks2ff -- accounts list 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 cargo run -p banks2ff -- accounts status
# Manage account links # Manage account links
@@ -64,7 +66,7 @@ Banks2FF uses a structured command-line interface with the following commands:
- `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination - `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination
- `sources` - List all available source types - `sources` - List all available source types
- `destinations` - List all available destination 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 status` - Show sync status for all accounts
- `accounts link` - Manage account links between sources and destinations - `accounts link` - Manage account links between sources and destinations
- `transactions list <account_id>` - Show transaction information for a specific account - `transactions list <account_id>` - Show transaction information for a specific account

View File

@@ -1,5 +1,5 @@
use crate::core::config::Config; 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 crate::core::ports::{TransactionDestination, TransactionMatch};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
@@ -242,4 +242,29 @@ impl TransactionDestination for FireflyAdapter {
Ok(result) Ok(result)
} }
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
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)
}
} }

View File

@@ -284,17 +284,20 @@ impl TransactionSource for GoCardlessAdapter {
let cache = self.cache.lock().await; let cache = self.cache.lock().await;
let mut summaries = Vec::new(); let mut summaries = Vec::new();
// Use cached account data for display // Use cached account data for display - only GoCardless accounts
for account_id in cache.accounts.keys() { 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) { if let Some(account_data) = cache.get_account_data(account_id) {
let summary = AccountSummary { let summary = AccountSummary {
id: account_id.clone(), id: account_id.clone(),
name: account_data.display_name(),
iban: account_data.iban().unwrap_or("").to_string(), iban: account_data.iban().unwrap_or("").to_string(),
currency: "EUR".to_string(), // GoCardless primarily uses EUR currency: "EUR".to_string(), // GoCardless primarily uses EUR
}; };
summaries.push(summary); summaries.push(summary);
} }
} }
}
Ok(summaries) Ok(summaries)
} }

View File

@@ -29,9 +29,11 @@ impl Formattable for AccountSummary {
fn to_table(&self) -> Table { fn to_table(&self) -> Table {
let mut table = Table::new(); let mut table = Table::new();
table.load_preset(UTF8_FULL); 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![ table.add_row(vec![
self.id.clone(), self.id.clone(),
name.to_string(),
mask_iban(&self.iban), mask_iban(&self.iban),
self.currency.clone(), self.currency.clone(),
]); ]);

View File

@@ -128,6 +128,7 @@ mod tests {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct AccountSummary { pub struct AccountSummary {
pub id: String, pub id: String,
pub name: Option<String>,
pub iban: String, pub iban: String,
pub currency: String, pub currency: String,
} }

View File

@@ -95,6 +95,9 @@ pub trait TransactionDestination: Send + Sync {
/// Account discovery for linking /// Account discovery for linking
async fn discover_accounts(&self) -> Result<Vec<Account>>; async fn discover_accounts(&self) -> Result<Vec<Account>>;
/// Inspection methods for CLI
async fn list_accounts(&self) -> Result<Vec<AccountSummary>>;
} }
// Blanket implementation for references // Blanket implementation for references
@@ -125,4 +128,8 @@ impl<T: TransactionDestination> TransactionDestination for &T {
async fn discover_accounts(&self) -> Result<Vec<Account>> { async fn discover_accounts(&self) -> Result<Vec<Account>> {
(**self).discover_accounts().await (**self).discover_accounts().await
} }
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
(**self).list_accounts().await
}
} }

View File

@@ -11,12 +11,13 @@ use crate::core::adapters::{
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::encryption::Encryption; use crate::core::encryption::Encryption;
use crate::core::linking::LinkStore; use crate::core::linking::LinkStore;
use crate::core::models::AccountData; use crate::core::models::{AccountData, AccountSummary};
use crate::core::ports::TransactionSource; use crate::core::ports::{TransactionDestination, TransactionSource};
use crate::core::sync::run_sync; use crate::core::sync::run_sync;
use chrono::NaiveDate; use chrono::NaiveDate;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use tracing::{error, info}; use comfy_table::{presets::UTF8_FULL, Table};
use tracing::{error, info, warn};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@@ -104,7 +105,10 @@ enum AccountCommands {
subcommand: LinkCommands, subcommand: LinkCommands,
}, },
/// List all accounts /// List all accounts
List, List {
/// Filter by adapter type: 'gocardless' or 'firefly', or omit for all
filter: Option<String>,
},
/// Show account status /// Show account status
Status, Status,
} }
@@ -409,12 +413,76 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow:
} }
} }
} }
AccountCommands::List => { AccountCommands::List { filter } => {
let accounts = context.source.list_accounts().await?; // Validate filter parameter
if accounts.is_empty() { 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."); println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
} else { } 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 => { AccountCommands::Status => {
@@ -429,6 +497,32 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow:
Ok(()) 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( async fn handle_transactions(
config: Config, config: Config,
subcommand: TransactionCommands, subcommand: TransactionCommands,