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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user