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
|
# 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,15 +284,18 @@ 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 Some(account_data) = cache.get_account_data(account_id) {
|
if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account {
|
||||||
let summary = AccountSummary {
|
if let Some(account_data) = cache.get_account_data(account_id) {
|
||||||
id: account_id.clone(),
|
let summary = AccountSummary {
|
||||||
iban: account_data.iban().unwrap_or("").to_string(),
|
id: account_id.clone(),
|
||||||
currency: "EUR".to_string(), // GoCardless primarily uses EUR
|
name: account_data.display_name(),
|
||||||
};
|
iban: account_data.iban().unwrap_or("").to_string(),
|
||||||
summaries.push(summary);
|
currency: "EUR".to_string(), // GoCardless primarily uses EUR
|
||||||
|
};
|
||||||
|
summaries.push(summary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user