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
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 <SOURCE> <DESTINATION>` - 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 <account_id>` - Show transaction information for a specific account

View File

@@ -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<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,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);
}
}
}

View File

@@ -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(),
]);

View File

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

View File

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