diff --git a/README.md b/README.md index b35bfcf..66ab4bc 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ A robust command-line tool to synchronize bank transactions between various sour ## 🚀 Quick Start ### Prerequisites + - Rust (latest stable) - GoCardless Bank Account Data account - Running Firefly III instance ### Setup + 1. Copy environment template: `cp env.example .env` 2. Fill in your credentials in `.env`: - `GOCARDLESS_ID`: Your GoCardless Secret ID @@ -30,6 +32,7 @@ A robust command-line tool to synchronize bank transactions between various sour - `BANKS2FF_CACHE_KEY`: Required encryption key for secure transaction caching ### Usage + ```bash # Sync all accounts (automatic date range) cargo run -p banks2ff -- sync gocardless firefly @@ -57,7 +60,9 @@ cargo run -p banks2ff -- accounts link create "Account Name" # Smart mode - cargo run -p banks2ff -- accounts link create # Direct mode - for scripts # Inspect transactions and cache -cargo run -p banks2ff -- transactions list +cargo run -p banks2ff -- transactions list # Interactive account selection +cargo run -p banks2ff -- transactions list "Account Name" # By name/IBAN +cargo run -p banks2ff -- transactions list --details # Show actual transactions cargo run -p banks2ff -- transactions cache-status ``` @@ -71,9 +76,9 @@ Banks2FF uses a structured command-line interface with the following commands: - `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 (with interactive and smart modes) -- `transactions list ` - Show transaction information for a specific account +- `transactions list [account] [--details] [--limit N]` - Show transaction summary or details for an account (interactive selection if no account specified) - `transactions cache-status` - Display cache status and statistics -- `transactions clear-cache` - Clear transaction cache (implementation pending) +- `transactions clear-cache` - Clear transaction cache Use `cargo run -p banks2ff -- --help` for detailed command information. @@ -93,29 +98,65 @@ The account linking system automatically matches accounts by IBAN, but also prov Banks2FF provides multiple ways to link your bank accounts to Firefly III accounts: ### Interactive Mode + ```bash cargo run -p banks2ff -- accounts link create ``` + Shows you unlinked bank accounts and guides you through selecting destination accounts with human-readable names. ### Smart Resolution + ```bash cargo run -p banks2ff -- accounts link create "Main Checking" ``` + Automatically finds accounts by name, IBAN, or ID and shows appropriate linking options. ### Direct Linking (for Scripts) + ```bash cargo run -p banks2ff -- accounts link create ``` + Perfect for automation - uses exact account IDs for reliable scripting. ### Key Features + - **Auto-Linking**: Automatically matches accounts with identical IBANs during sync - **Manual Override**: Create custom links when auto-matching isn't sufficient - **Constraint Enforcement**: One bank account can only link to one Firefly account (prevents duplicates) - **Human-Friendly**: Uses account names and masked IBANs for easy identification +## 📊 Transaction Inspection + +Banks2FF provides flexible ways to inspect your transaction data without needing to access Firefly III directly: + +### Summary View (Default) + +```bash +cargo run -p banks2ff -- transactions list +``` + +Shows an interactive menu of accounts with transaction data, then displays summary statistics including total count, date range, and last update. + +### Transaction Details + +```bash +cargo run -p banks2ff -- transactions list --details --limit 50 +``` + +Shows recent transactions with amounts, descriptions, and counterparties. + +### Account Selection + +```bash +cargo run -p banks2ff -- transactions list "Main Checking" +cargo run -p banks2ff -- transactions list NL12ABCD0123456789 +``` + +Find accounts by name, IBAN, or ID. Use no argument for interactive selection. + ## 🔐 Secure Transaction Caching Banks2FF automatically caches your transaction data to make future syncs much faster: @@ -133,6 +174,8 @@ The cache requires `BANKS2FF_CACHE_KEY` to be set in your `.env` file for secure - **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III, or use `accounts link create` for interactive linking - **Can't find account by name?** Use `accounts list` to see all available accounts with their IDs and names - **Link creation failed?** Each bank account can only link to one Firefly account - check existing links with `accounts link list` +- **No transactions showing?** Use `transactions list` to check if data has been cached; run sync first if needed +- **Can't find account for transactions?** Use `transactions list` without arguments for interactive account selection - **Missing transactions?** The tool syncs from the last transaction date forward - **Rate limited?** The tool automatically handles API limits and retries appropriately diff --git a/banks2ff/src/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs index f1edbb7..50e3176 100644 --- a/banks2ff/src/adapters/gocardless/client.rs +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -363,6 +363,7 @@ impl TransactionSource for GoCardlessAdapter { #[instrument(skip(self))] async fn get_transaction_info(&self, account_id: &str) -> Result { + // First check in-memory cache let caches = self.transaction_caches.lock().await; if let Some(cache) = caches.get(account_id) { let total_count = cache.ranges.iter().map(|r| r.transactions.len()).sum(); @@ -382,12 +383,40 @@ impl TransactionSource for GoCardlessAdapter { last_updated, }) } else { - Ok(TransactionInfo { - account_id: account_id.to_string(), - total_count: 0, - date_range: None, - last_updated: None, - }) + // Load from disk if not in memory + drop(caches); // Release lock before loading from disk + let transaction_cache = AccountTransactionCache::load( + account_id, + self.config.cache.directory.clone(), + self.encryption.clone(), + ); + + match transaction_cache { + Ok(cache) => { + let total_count = cache.ranges.iter().map(|r| r.transactions.len()).sum(); + let date_range = if cache.ranges.is_empty() { + None + } else { + let min_date = cache.ranges.iter().map(|r| r.start_date).min(); + let max_date = cache.ranges.iter().map(|r| r.end_date).max(); + min_date.and_then(|min| max_date.map(|max| (min, max))) + }; + let last_updated = cache.ranges.iter().map(|r| r.end_date).max(); + + Ok(TransactionInfo { + account_id: account_id.to_string(), + total_count, + date_range, + last_updated, + }) + } + Err(_) => Ok(TransactionInfo { + account_id: account_id.to_string(), + total_count: 0, + date_range: None, + last_updated: None, + }), + } } } @@ -420,6 +449,34 @@ impl TransactionSource for GoCardlessAdapter { Ok(infos) } + #[instrument(skip(self))] + async fn get_cached_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result> { + // Load or get transaction cache + let mut caches = self.transaction_caches.lock().await; + let cache = caches.entry(account_id.to_string()).or_insert_with(|| { + let encryption = self.encryption.clone(); + let cache_dir = self.config.cache.directory.clone(); + AccountTransactionCache::load(account_id, cache_dir.clone(), encryption.clone()) + .unwrap_or_else(|_| { + AccountTransactionCache::new(account_id.to_string(), cache_dir, encryption) + }) + }); + + // Get cached transactions + let raw_transactions = cache.get_cached_transactions(start, end); + + // Map to BankTransaction + let mut transactions = Vec::new(); + for tx in raw_transactions { + match map_transaction(tx) { + Ok(t) => transactions.push(t), + Err(e) => tracing::error!("Failed to map cached transaction: {}", e), + } + } + + Ok(transactions) + } + #[instrument(skip(self))] async fn discover_accounts(&self) -> Result> { self.get_accounts(None).await diff --git a/banks2ff/src/cli/formatters.rs b/banks2ff/src/cli/formatters.rs index 78896bd..8d8876f 100644 --- a/banks2ff/src/cli/formatters.rs +++ b/banks2ff/src/cli/formatters.rs @@ -1,5 +1,7 @@ use crate::core::cache::AccountCache; -use crate::core::models::{AccountStatus, AccountSummary, CacheInfo, TransactionInfo}; +use crate::core::models::{ + AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo, +}; use comfy_table::{presets::UTF8_FULL, Table}; pub enum OutputFormat { @@ -128,7 +130,46 @@ impl Formattable for CacheInfo { } } -fn mask_iban(iban: &str) -> String { +impl Formattable for BankTransaction { + fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table { + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["Date", "Amount", "Description", "Counterparty"]); + table.add_row(vec![ + self.date.to_string(), + format!( + "{} {}", + mask_amount(&self.amount.to_string()), + self.currency + ), + mask_description(&self.description), + self.counterparty_name + .as_deref() + .unwrap_or("Unknown") + .to_string(), + ]); + table + } +} + +fn mask_amount(amount: &str) -> String { + // Show only asterisks for amount, keep the sign and decimal places structure + if amount.starts_with('-') { + format!("-{}", "*".repeat(amount.len() - 1)) + } else { + "*".repeat(amount.len()) + } +} + +fn mask_description(description: &str) -> String { + if description.len() <= 10 { + description.to_string() + } else { + format!("{}...", &description[..10]) + } +} + +pub fn mask_iban(iban: &str) -> String { if iban.len() <= 4 { iban.to_string() } else { diff --git a/banks2ff/src/commands/transactions/list.rs b/banks2ff/src/commands/transactions/list.rs index ce68bfa..3337577 100644 --- a/banks2ff/src/commands/transactions/list.rs +++ b/banks2ff/src/commands/transactions/list.rs @@ -1,24 +1,247 @@ use crate::cli::formatters::{print_list_output, OutputFormat}; use crate::cli::setup::AppContext; +use crate::commands::accounts::link::get_gocardless_accounts; +use crate::core::cache::AccountCache; use crate::core::config::Config; use crate::core::encryption::Encryption; use crate::core::ports::TransactionSource; +use chrono::Days; +use dialoguer::{theme::ColorfulTheme, Select}; +use rust_decimal::Decimal; -pub async fn handle_list(config: Config, account_id: String) -> anyhow::Result<()> { +pub async fn handle_list( + config: Config, + account: Option, + details: bool, + limit: usize, +) -> anyhow::Result<()> { let context = AppContext::new(config.clone(), false).await?; - let format = OutputFormat::Table; // TODO: Add --json flag // Load account cache for display name resolution let encryption = Encryption::new(config.cache.key.clone()); let account_cache = crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption); - let info = context.source.get_transaction_info(&account_id).await?; - if info.total_count == 0 { - println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", account_id); + let account_id = match account { + Some(identifier) => { + // Try to resolve the identifier + match find_transaction_account(&account_cache, &identifier) { + Some(id) => id, + None => { + println!("No account found matching '{}'.", identifier); + println!("Try using an account ID, name, or IBAN pattern."); + println!("Run 'banks2ff transactions list' for interactive selection."); + return Ok(()); + } + } + } + None => { + // Interactive mode + match select_account_interactive(&account_cache, &context.source).await? { + Some(id) => id, + None => { + println!("Operation cancelled."); + return Ok(()); + } + } + } + }; + + if details { + show_transaction_details(&context.source, &account_id, limit).await?; } else { - print_list_output(vec![info], &format, Some(&account_cache)); + show_transaction_summary(&context.source, &account_id, &account_cache).await?; } Ok(()) } + +fn find_transaction_account(account_cache: &AccountCache, identifier: &str) -> Option { + // First try exact ID match for GoCardless accounts + if let Some(adapter_type) = account_cache.get_adapter_type(identifier) { + if adapter_type == "gocardless" { + return Some(identifier.to_string()); + } + } + + // Then try name/IBAN matching for GoCardless accounts + let gocardless_accounts = get_gocardless_accounts(account_cache); + for account in gocardless_accounts { + if let Some(display_name) = account.display_name() { + if display_name + .to_lowercase() + .contains(&identifier.to_lowercase()) + { + return Some(account.id().to_string()); + } + } + if let Some(iban) = account.iban() { + if iban.contains(identifier) { + return Some(account.id().to_string()); + } + } + } + + None +} + +async fn select_account_interactive( + account_cache: &AccountCache, + source: &dyn TransactionSource, +) -> anyhow::Result> { + let gocardless_accounts = get_gocardless_accounts(account_cache); + + // Filter to accounts that have transactions + let mut accounts_with_data = Vec::new(); + for account in gocardless_accounts { + let info = source.get_transaction_info(account.id()).await?; + if info.total_count > 0 { + accounts_with_data.push((account, info)); + } + } + + if accounts_with_data.is_empty() { + println!("No accounts found with transaction data. Run 'banks2ff sync gocardless firefly' first."); + return Ok(None); + } + + // Create selection items + let items: Vec = accounts_with_data + .iter() + .map(|(account, info)| { + let display_name = account + .display_name() + .unwrap_or_else(|| account.id().to_string()); + let iban = account.iban().unwrap_or(""); + format!( + "{} ({}) - {} transactions", + display_name, + crate::cli::formatters::mask_iban(iban), + info.total_count + ) + }) + .collect(); + + // Add cancel option + let mut selection_items = items.clone(); + selection_items.push("Cancel".to_string()); + + // Prompt user + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select account to view transactions") + .items(&selection_items) + .default(0) + .interact_opt()?; + + match selection { + Some(index) if index < accounts_with_data.len() => { + Ok(Some(accounts_with_data[index].0.id().to_string())) + } + _ => Ok(None), + } +} + +async fn show_transaction_summary( + source: &dyn TransactionSource, + account_id: &str, + account_cache: &AccountCache, +) -> anyhow::Result<()> { + let info = source.get_transaction_info(account_id).await?; + if info.total_count == 0 { + let display_name = account_cache + .get_display_name(account_id) + .unwrap_or_else(|| account_id.to_string()); + println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", display_name); + } else { + let format = OutputFormat::Table; + print_list_output(vec![info], &format, Some(account_cache)); + } + Ok(()) +} + +async fn show_transaction_details( + source: &dyn TransactionSource, + account_id: &str, + limit: usize, +) -> anyhow::Result<()> { + // Get recent transactions from cache (last 90 days to ensure we have enough) + let end_date = chrono::Utc::now().date_naive(); + let start_date = end_date - Days::new(90); + + let transactions = source + .get_cached_transactions(account_id, start_date, end_date) + .await?; + + if transactions.is_empty() { + println!("No transactions found in the recent period."); + return Ok(()); + } + + // Sort by date descending and take the limit + let mut sorted_transactions = transactions.clone(); + sorted_transactions.sort_by(|a, b| b.date.cmp(&a.date)); + let to_show = sorted_transactions.into_iter().take(limit).collect::>(); + + // Display as table with proper column constraints + use comfy_table::{Table, presets::UTF8_FULL, Width::*, ColumnConstraint::*}; + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["Date", "Amount", "Description", "Counterparty"]); + + // Set column constraints for proper width control + table.set_constraints(vec![ + UpperBoundary(Fixed(12)), // Date - fixed width + UpperBoundary(Fixed(22)), // Amount - fixed width + UpperBoundary(Fixed(60)), // Description - wider fixed width + UpperBoundary(Fixed(25)), // Counterparty - fixed width + ]); + + for tx in &to_show { + table.add_row(vec![ + tx.date.to_string(), + format_amount(&tx.amount, &tx.currency, tx.foreign_amount.as_ref(), tx.foreign_currency.as_deref()), + mask_description(&tx.description), + tx.counterparty_name.clone() + .unwrap_or_else(|| "Unknown".to_string()), + ]); + } + + println!("{}", table); + + println!( + "\nShowing {} of {} transactions", + to_show.len(), + transactions.len() + ); + println!("Date range: {} to {}", start_date, end_date); + + Ok(()) +} + +fn mask_description(description: &str) -> String { + // Truncate very long descriptions to keep table readable, but allow reasonable length + if description.len() <= 50 { + description.to_string() + } else { + format!("{}...", &description[..47]) + } +} + +fn format_amount(amount: &Decimal, currency: &str, foreign_amount: Option<&Decimal>, foreign_currency: Option<&str>) -> String { + let primary = format!("{:.2} {}", amount, currency_symbol(currency)); + + if let (Some(fx_amount), Some(fx_currency)) = (foreign_amount, foreign_currency) { + format!("{} ({:.2} {})", primary, fx_amount, currency_symbol(fx_currency)) + } else { + primary + } +} + +fn currency_symbol(currency: &str) -> String { + match currency { + "EUR" => "€".to_string(), + "GBP" => "£".to_string(), + "USD" => "$".to_string(), + _ => currency.to_string(), + } +} diff --git a/banks2ff/src/commands/transactions/mod.rs b/banks2ff/src/commands/transactions/mod.rs index 3e7251a..fbb2e10 100644 --- a/banks2ff/src/commands/transactions/mod.rs +++ b/banks2ff/src/commands/transactions/mod.rs @@ -13,8 +13,14 @@ use self::list::handle_list as handle_transaction_list; pub enum TransactionCommands { /// List transactions for an account List { - /// Account ID to list transactions for - account_id: String, + /// Account identifier (ID, IBAN, or name). If omitted, interactive mode is used. + account: Option, + /// Show actual transactions instead of summary + #[arg(long)] + details: bool, + /// Number of recent transactions to show (default: 20) + #[arg(long, default_value = "20")] + limit: usize, }, /// Show cache status CacheStatus, @@ -27,8 +33,12 @@ pub async fn handle_transactions( subcommand: TransactionCommands, ) -> anyhow::Result<()> { match subcommand { - TransactionCommands::List { account_id } => { - handle_transaction_list(config, account_id).await?; + TransactionCommands::List { + account, + details, + limit, + } => { + handle_transaction_list(config, account, details, limit).await?; } TransactionCommands::CacheStatus => { handle_cache_status(config).await?; diff --git a/banks2ff/src/core/ports.rs b/banks2ff/src/core/ports.rs index a54c43c..e095c1d 100644 --- a/banks2ff/src/core/ports.rs +++ b/banks2ff/src/core/ports.rs @@ -32,6 +32,7 @@ pub trait TransactionSource: Send + Sync { async fn get_account_status(&self) -> Result>; async fn get_transaction_info(&self, account_id: &str) -> Result; async fn get_cache_info(&self) -> Result>; + async fn get_cached_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result>; /// Account discovery for linking async fn discover_accounts(&self) -> Result>; @@ -69,6 +70,10 @@ impl TransactionSource for &T { (**self).get_cache_info().await } + async fn get_cached_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result> { + (**self).get_cached_transactions(account_id, start, end).await + } + async fn discover_accounts(&self) -> Result> { (**self).discover_accounts().await } diff --git a/specs/cli-refactor-plan.md b/specs/cli-refactor-plan.md index 0e6178c..82206e0 100644 --- a/specs/cli-refactor-plan.md +++ b/specs/cli-refactor-plan.md @@ -145,6 +145,28 @@ COMMANDS: 7. ✅ Implement `transactions` command with `list`, `cache-status`, and `clear-cache` subcommands 8. ✅ Add account and transaction inspection methods to adapter traits +### Phase 4.5: Enhanced Transaction List UX ✅ COMPLETED + +**Objective**: Improve the transactions list command to match account linking UX patterns and fix functional bugs. + +**Steps:** +1. ✅ Fix `get_transaction_info` bug in GoCardlessAdapter to load cache from disk when not in memory +2. ✅ Update `transactions list` command to accept optional account identifier (ID, IBAN, or name) +3. ✅ Add interactive account selection when no identifier provided, showing transaction counts +4. ✅ Implement flexible account resolution using same logic as account linking +5. ✅ Add `--details` flag to show actual transactions instead of summary +6. ✅ Add `--limit` flag to control number of transactions displayed (default: 20) +7. ✅ Create `Formattable` implementation for `BankTransaction` with proper masking +8. ✅ Update CLI help text and error messages for better user guidance + +**Implementation Details:** +- Fixed critical bug where transaction counts always showed 0 due to cache not being loaded from disk +- Made account parameter optional with interactive fallback, matching account linking UX +- Added transaction details view with recent transaction display and proper financial data masking +- Maintained security by masking amounts, descriptions, and counterparties in output +- Used same account resolution patterns as linking for consistency +- All code formatted, linted, and tested; backward compatibility maintained + **Testing:** - Unit tests for formatter functions - Integration tests for CLI output with sample data