diff --git a/README.md b/README.md index 1d1f15a..931aa2c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A robust command-line tool to synchronize bank transactions between various sour - **Reliable Operation**: Continues working even when some accounts need attention - **Safe Preview Mode**: Test changes before applying them to your finances - **Rate Limit Aware**: Works within API limits to ensure consistent access +- **Flexible Account Linking**: Automatically match bank accounts to Firefly III accounts, with manual override options ## 🚀 Quick Start @@ -43,6 +44,10 @@ cargo run -p banks2ff -- sync gocardless firefly --start 2023-01-01 --end 2023-0 cargo run -p banks2ff -- sources cargo run -p banks2ff -- destinations +# Manage account links +cargo run -p banks2ff -- accounts link list +cargo run -p banks2ff -- accounts link create + # Additional inspection commands available in future releases ``` @@ -53,8 +58,9 @@ Banks2FF uses a structured command-line interface with the following commands: - `sync ` - Synchronize transactions between source and destination - `sources` - List all available source types - `destinations` - List all available destination types +- `accounts link` - Manage account links between sources and destinations -Additional inspection commands (accounts, transactions, status) will be available in future releases. +Additional inspection commands (accounts list/status, transactions, status) will be available in future releases. Use `cargo run -p banks2ff -- --help` for detailed command information. @@ -62,7 +68,7 @@ Use `cargo run -p banks2ff -- --help` for detailed command information. Banks2FF automatically: 1. Connects to your bank accounts via GoCardless -2. Finds matching accounts in your Firefly III instance +2. Discovers and links accounts between GoCardless and Firefly III (with auto-matching and manual options) 3. Downloads new transactions since your last sync 4. Adds them to Firefly III (avoiding duplicates) 5. Handles errors gracefully - keeps working even if some accounts have issues @@ -81,7 +87,7 @@ The cache requires `BANKS2FF_CACHE_KEY` to be set in your `.env` file for secure ## 🔧 Troubleshooting - **Unknown source/destination?** Use `sources` and `destinations` commands to see what's available -- **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III +- **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III, or use `accounts link` to create manual links - **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/firefly/client.rs b/banks2ff/src/adapters/firefly/client.rs index a193d3f..e1f2041 100644 --- a/banks2ff/src/adapters/firefly/client.rs +++ b/banks2ff/src/adapters/firefly/client.rs @@ -1,4 +1,6 @@ -use crate::core::models::BankTransaction; +use crate::core::models::{ + Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo, +}; use crate::core::ports::{TransactionDestination, TransactionMatch}; use anyhow::Result; use async_trait::async_trait; @@ -218,4 +220,127 @@ impl TransactionDestination for FireflyAdapter { .await .map_err(|e| e.into()) } + + #[instrument(skip(self))] + async fn list_accounts(&self) -> Result> { + let client = self.client.lock().await; + let accounts = client.get_accounts("").await?; + let mut summaries = Vec::new(); + + for acc in accounts.data { + let is_active = acc.attributes.active.unwrap_or(true); + if is_active { + if let Some(iban) = acc.attributes.iban { + summaries.push(AccountSummary { + id: acc.id, + iban, + currency: "EUR".to_string(), // Default to EUR + status: "active".to_string(), + }); + } + } + } + + Ok(summaries) + } + + #[instrument(skip(self))] + async fn get_account_status(&self) -> Result> { + let client = self.client.lock().await; + let accounts = client.get_accounts("").await?; + let mut statuses = Vec::new(); + + for acc in accounts.data { + let is_active = acc.attributes.active.unwrap_or(true); + if is_active { + if let Some(iban) = acc.attributes.iban { + let last_sync_date = self.get_last_transaction_date(&acc.id).await?; + let transaction_count = client + .list_account_transactions(&acc.id, None, None) + .await? + .data + .len(); + + statuses.push(AccountStatus { + account_id: acc.id, + iban, + last_sync_date, + transaction_count, + status: "active".to_string(), + }); + } + } + } + + Ok(statuses) + } + + #[instrument(skip(self))] + async fn get_transaction_info(&self, account_id: &str) -> Result { + let client = self.client.lock().await; + let tx_list = client + .list_account_transactions(account_id, None, None) + .await?; + let total_count = tx_list.data.len(); + + let date_range = if tx_list.data.is_empty() { + None + } else { + let dates: Vec = tx_list + .data + .iter() + .filter_map(|tx| { + tx.attributes.transactions.first().and_then(|split| { + split + .date + .split('T') + .next() + .and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()) + }) + }) + .collect(); + if dates.is_empty() { + None + } else { + let min_date = dates.iter().min().cloned(); + let max_date = dates.iter().max().cloned(); + min_date.and_then(|min| max_date.map(|max| (min, max))) + } + }; + + let last_updated = date_range.map(|(_, max)| max); + + Ok(TransactionInfo { + account_id: account_id.to_string(), + total_count, + date_range, + last_updated, + }) + } + + #[instrument(skip(self))] + async fn get_cache_info(&self) -> Result> { + // Firefly doesn't have local cache, so return empty + Ok(Vec::new()) + } + + #[instrument(skip(self))] + async fn discover_accounts(&self) -> Result> { + let client = self.client.lock().await; + let accounts = client.get_accounts("").await?; + let mut result = Vec::new(); + + for acc in accounts.data { + let is_active = acc.attributes.active.unwrap_or(true); + if is_active { + result.push(Account { + id: acc.id, + iban: acc.attributes.iban.unwrap_or_default(), + currency: "EUR".to_string(), + }); + } + } + + Ok(result) + } } diff --git a/banks2ff/src/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs index beb7aa0..a3591a4 100644 --- a/banks2ff/src/adapters/gocardless/client.rs +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -1,7 +1,9 @@ use crate::adapters::gocardless::cache::AccountCache; use crate::adapters::gocardless::mapper::map_transaction; use crate::adapters::gocardless::transaction_cache::AccountTransactionCache; -use crate::core::models::{Account, BankTransaction}; +use crate::core::models::{ + Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo, +}; use crate::core::ports::TransactionSource; use anyhow::Result; use async_trait::async_trait; @@ -232,4 +234,151 @@ impl TransactionSource for GoCardlessAdapter { ); Ok(transactions) } + + #[instrument(skip(self))] + async fn list_accounts(&self) -> Result> { + let mut client = self.client.lock().await; + let mut cache = self.cache.lock().await; + + client.obtain_access_token().await?; + + let requisitions = client.get_requisitions().await?; + let mut summaries = Vec::new(); + + for req in requisitions.results { + if req.status != "LN" { + continue; + } + + if let Some(agreement_id) = &req.agreement { + if client.is_agreement_expired(agreement_id).await? { + continue; + } + } + + if let Some(req_accounts) = req.accounts { + for acc_id in req_accounts { + let iban = if let Some(iban) = cache.get_iban(&acc_id) { + iban + } else { + // Fetch if not cached + match client.get_account(&acc_id).await { + Ok(details) => { + let iban = details.iban.unwrap_or_default(); + cache.insert(acc_id.clone(), iban.clone()); + cache.save(); + iban + } + Err(_) => "Unknown".to_string(), + } + }; + + summaries.push(AccountSummary { + id: acc_id, + iban, + currency: "EUR".to_string(), // Assuming EUR for now + status: "linked".to_string(), + }); + } + } + } + + Ok(summaries) + } + + #[instrument(skip(self))] + async fn get_account_status(&self) -> Result> { + let caches = self.transaction_caches.lock().await; + let mut statuses = Vec::new(); + + for (account_id, cache) in caches.iter() { + let iban = self + .cache + .lock() + .await + .get_iban(account_id) + .unwrap_or_else(|| "Unknown".to_string()); + let transaction_count = cache.ranges.iter().map(|r| r.transactions.len()).sum(); + let last_sync_date = cache.ranges.iter().map(|r| r.end_date).max(); + + statuses.push(AccountStatus { + account_id: account_id.clone(), + iban, + last_sync_date, + transaction_count, + status: if transaction_count > 0 { + "synced" + } else { + "pending" + } + .to_string(), + }); + } + + Ok(statuses) + } + + #[instrument(skip(self))] + async fn get_transaction_info(&self, account_id: &str) -> Result { + 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(); + 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, + }) + } else { + Ok(TransactionInfo { + account_id: account_id.to_string(), + total_count: 0, + date_range: None, + last_updated: None, + }) + } + } + + #[instrument(skip(self))] + async fn get_cache_info(&self) -> Result> { + let mut infos = Vec::new(); + + // Account cache + let account_cache = self.cache.lock().await; + infos.push(CacheInfo { + account_id: None, + cache_type: "account".to_string(), + entry_count: account_cache.accounts.len(), + total_size_bytes: 0, // Not tracking size + last_updated: None, // Not tracking + }); + + // Transaction caches + let transaction_caches = self.transaction_caches.lock().await; + for (account_id, cache) in transaction_caches.iter() { + infos.push(CacheInfo { + account_id: Some(account_id.clone()), + cache_type: "transaction".to_string(), + entry_count: cache.ranges.len(), + total_size_bytes: 0, // Not tracking + last_updated: cache.ranges.iter().map(|r| r.end_date).max(), + }); + } + + Ok(infos) + } + + #[instrument(skip(self))] + async fn discover_accounts(&self) -> Result> { + self.get_accounts(None).await + } } diff --git a/banks2ff/src/core/linking.rs b/banks2ff/src/core/linking.rs new file mode 100644 index 0000000..aa355bb --- /dev/null +++ b/banks2ff/src/core/linking.rs @@ -0,0 +1,123 @@ +use crate::core::models::Account; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use tracing::warn; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountLink { + pub id: String, + pub source_account_id: String, + pub dest_account_id: String, + pub alias: Option, + pub auto_linked: bool, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct LinkStore { + pub links: Vec, + pub source_accounts: HashMap>, // outer key: source type, inner: account id + pub dest_accounts: HashMap>, // outer key: dest type, inner: account id + next_id: usize, +} + +impl LinkStore { + fn get_path() -> String { + let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); + format!("{}/links.json", cache_dir) + } + + pub fn load() -> Self { + let path = Self::get_path(); + if Path::new(&path).exists() { + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(store) => return store, + Err(e) => warn!("Failed to parse link store: {}", e), + }, + Err(e) => warn!("Failed to read link store: {}", e), + } + } + Self::default() + } + + pub fn save(&self) -> Result<()> { + let path = Self::get_path(); + if let Some(parent) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(self)?; + fs::write(path, content)?; + Ok(()) + } + + pub fn add_link(&mut self, source_account: &Account, dest_account: &Account, auto_linked: bool) -> String { + let id = format!("link_{}", self.next_id); + self.next_id += 1; + let link = AccountLink { + id: id.clone(), + source_account_id: source_account.id.clone(), + dest_account_id: dest_account.id.clone(), + alias: None, + auto_linked, + }; + self.links.push(link); + id + } + + pub fn set_alias(&mut self, link_id: &str, alias: String) -> Result<()> { + if let Some(link) = self.links.iter_mut().find(|l| l.id == link_id) { + link.alias = Some(alias); + Ok(()) + } else { + Err(anyhow::anyhow!("Link not found")) + } + } + + pub fn remove_link(&mut self, link_id: &str) -> Result<()> { + self.links.retain(|l| l.id != link_id); + Ok(()) + } + + pub fn find_link_by_source(&self, source_id: &str) -> Option<&AccountLink> { + self.links.iter().find(|l| l.source_account_id == source_id) + } + + pub fn find_link_by_dest(&self, dest_id: &str) -> Option<&AccountLink> { + self.links.iter().find(|l| l.dest_account_id == dest_id) + } + + pub fn find_link_by_alias(&self, alias: &str) -> Option<&AccountLink> { + self.links.iter().find(|l| l.alias.as_ref() == Some(&alias.to_string())) + } + + pub fn update_source_accounts(&mut self, source_type: &str, accounts: Vec) { + let type_map = self.source_accounts.entry(source_type.to_string()).or_insert_with(HashMap::new); + for account in accounts { + type_map.insert(account.id.clone(), account); + } + } + + pub fn update_dest_accounts(&mut self, dest_type: &str, accounts: Vec) { + let type_map = self.dest_accounts.entry(dest_type.to_string()).or_insert_with(HashMap::new); + for account in accounts { + type_map.insert(account.id.clone(), account); + } + } +} + +pub fn auto_link_accounts(source_accounts: &[Account], dest_accounts: &[Account]) -> Vec<(usize, usize)> { + let mut links = Vec::new(); + for (i, source) in source_accounts.iter().enumerate() { + for (j, dest) in dest_accounts.iter().enumerate() { + if source.iban == dest.iban && !source.iban.is_empty() { + links.push((i, j)); + break; // First match + } + } + } + // Could add name similarity matching here + links +} \ No newline at end of file diff --git a/banks2ff/src/core/mod.rs b/banks2ff/src/core/mod.rs index b0b39b5..dfa3277 100644 --- a/banks2ff/src/core/mod.rs +++ b/banks2ff/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod adapters; +pub mod linking; pub mod models; pub mod ports; pub mod sync; diff --git a/banks2ff/src/core/models.rs b/banks2ff/src/core/models.rs index 787a52e..6beac6a 100644 --- a/banks2ff/src/core/models.rs +++ b/banks2ff/src/core/models.rs @@ -1,5 +1,6 @@ use chrono::NaiveDate; use rust_decimal::Decimal; +use serde::Serialize; use std::fmt; use thiserror::Error; @@ -50,7 +51,7 @@ impl fmt::Debug for BankTransaction { } } -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Account { pub id: String, pub iban: String, @@ -115,6 +116,40 @@ mod tests { } } +#[derive(Clone, Debug, Serialize)] +pub struct AccountSummary { + pub id: String, + pub iban: String, + pub currency: String, + pub status: String, // e.g., "active", "expired", "linked" +} + +#[derive(Clone, Debug, Serialize)] +pub struct AccountStatus { + pub account_id: String, + pub iban: String, + pub last_sync_date: Option, + pub transaction_count: usize, + pub status: String, // e.g., "synced", "pending", "error" +} + +#[derive(Clone, Debug, Serialize)] +pub struct TransactionInfo { + pub account_id: String, + pub total_count: usize, + pub date_range: Option<(NaiveDate, NaiveDate)>, + pub last_updated: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CacheInfo { + pub account_id: Option, // None for global, Some for per-account + pub cache_type: String, // e.g., "account", "transaction" + pub entry_count: usize, + pub total_size_bytes: usize, + pub last_updated: Option, +} + #[derive(Error, Debug)] pub enum SyncError { #[error("End User Agreement {agreement_id} has expired")] diff --git a/banks2ff/src/core/ports.rs b/banks2ff/src/core/ports.rs index cda6dcc..87cc7cc 100644 --- a/banks2ff/src/core/ports.rs +++ b/banks2ff/src/core/ports.rs @@ -1,4 +1,6 @@ -use crate::core::models::{Account, BankTransaction}; +use crate::core::models::{ + Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo, +}; use anyhow::Result; use async_trait::async_trait; use chrono::NaiveDate; @@ -24,6 +26,15 @@ pub trait TransactionSource: Send + Sync { start: NaiveDate, end: NaiveDate, ) -> Result>; + + /// Inspection methods for CLI + async fn list_accounts(&self) -> Result>; + async fn get_account_status(&self) -> Result>; + async fn get_transaction_info(&self, account_id: &str) -> Result; + async fn get_cache_info(&self) -> Result>; + + /// Account discovery for linking + async fn discover_accounts(&self) -> Result>; } // Blanket implementation for references @@ -41,6 +52,26 @@ impl TransactionSource for &T { ) -> Result> { (**self).get_transactions(account_id, start, end).await } + + async fn list_accounts(&self) -> Result> { + (**self).list_accounts().await + } + + async fn get_account_status(&self) -> Result> { + (**self).get_account_status().await + } + + async fn get_transaction_info(&self, account_id: &str) -> Result { + (**self).get_transaction_info(account_id).await + } + + async fn get_cache_info(&self) -> Result> { + (**self).get_cache_info().await + } + + async fn discover_accounts(&self) -> Result> { + (**self).discover_accounts().await + } } #[derive(Debug, Clone)] @@ -65,6 +96,15 @@ pub trait TransactionDestination: Send + Sync { ) -> Result>; async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>; async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>; + + /// Inspection methods for CLI + async fn list_accounts(&self) -> Result>; + async fn get_account_status(&self) -> Result>; + async fn get_transaction_info(&self, account_id: &str) -> Result; + async fn get_cache_info(&self) -> Result>; + + /// Account discovery for linking + async fn discover_accounts(&self) -> Result>; } // Blanket implementation for references @@ -99,4 +139,24 @@ impl TransactionDestination for &T { .update_transaction_external_id(id, external_id) .await } + + async fn list_accounts(&self) -> Result> { + (**self).list_accounts().await + } + + async fn get_account_status(&self) -> Result> { + (**self).get_account_status().await + } + + async fn get_transaction_info(&self, account_id: &str) -> Result { + (**self).get_transaction_info(account_id).await + } + + async fn get_cache_info(&self) -> Result> { + (**self).get_cache_info().await + } + + async fn discover_accounts(&self) -> Result> { + (**self).discover_accounts().await + } } diff --git a/banks2ff/src/core/sync.rs b/banks2ff/src/core/sync.rs index 95c47b0..0e6f10d 100644 --- a/banks2ff/src/core/sync.rs +++ b/banks2ff/src/core/sync.rs @@ -1,3 +1,4 @@ +use crate::core::linking::{auto_link_accounts, LinkStore}; use crate::core::models::{Account, SyncError}; use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource}; use anyhow::Result; @@ -38,6 +39,29 @@ pub async fn run_sync( .map_err(SyncError::SourceError)?; info!("Found {} accounts from source", accounts.len()); + // Discover all accounts and update linking + let all_source_accounts = source + .discover_accounts() + .await + .map_err(SyncError::SourceError)?; + let all_dest_accounts = destination + .discover_accounts() + .await + .map_err(SyncError::DestinationError)?; + + let mut link_store = LinkStore::load(); + link_store.update_source_accounts("gocardless", all_source_accounts.clone()); + link_store.update_dest_accounts("firefly", all_dest_accounts.clone()); + + // Auto-link accounts + let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts); + for (src_idx, dest_idx) in links { + let src = &all_source_accounts[src_idx]; + let dest = &all_dest_accounts[dest_idx]; + link_store.add_link(src, dest, true); + } + link_store.save().map_err(SyncError::SourceError)?; + // Default end date is Yesterday let end_date = cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1)); @@ -55,6 +79,7 @@ pub async fn run_sync( &source, &destination, &account, + &link_store, cli_start_date, end_date, dry_run, @@ -106,20 +131,19 @@ async fn process_single_account( source: &impl TransactionSource, destination: &impl TransactionDestination, account: &Account, + link_store: &LinkStore, cli_start_date: Option, end_date: NaiveDate, dry_run: bool, ) -> Result { - let dest_id_opt = destination - .resolve_account_id(&account.iban) - .await - .map_err(SyncError::DestinationError)?; - let Some(dest_id) = dest_id_opt else { + let link_opt = link_store.find_link_by_source(&account.id); + let Some(link) = link_opt else { return Err(SyncError::AccountSkipped { account_id: account.id.clone(), - reason: "Not found in destination".to_string(), + reason: "No link found to destination account".to_string(), }); }; + let dest_id = link.dest_account_id.clone(); info!("Resolved destination ID: {}", dest_id); @@ -265,6 +289,16 @@ mod tests { }]) }); + source + .expect_discover_accounts() + .returning(|| { + Ok(vec![Account { + id: "src_1".to_string(), + iban: "NL01".to_string(), + currency: "EUR".to_string(), + }]) + }); + let tx = BankTransaction { internal_id: "tx1".into(), date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(), @@ -286,6 +320,15 @@ mod tests { dest.expect_get_active_account_ibans() .returning(|| Ok(vec!["NL01".to_string()])); + dest.expect_discover_accounts() + .returning(|| { + Ok(vec![Account { + id: "dest_1".to_string(), + iban: "NL01".to_string(), + currency: "EUR".to_string(), + }]) + }); + dest.expect_resolve_account_id() .returning(|_| Ok(Some("dest_1".into()))); @@ -316,6 +359,15 @@ mod tests { dest.expect_get_active_account_ibans() .returning(|| Ok(vec!["NL01".to_string()])); + dest.expect_discover_accounts() + .returning(|| { + Ok(vec![Account { + id: "dest_1".to_string(), + iban: "NL01".to_string(), + currency: "EUR".to_string(), + }]) + }); + source.expect_get_accounts().with(always()).returning(|_| { Ok(vec![Account { id: "src_1".to_string(), @@ -324,6 +376,14 @@ mod tests { }]) }); + source.expect_discover_accounts().returning(|| { + Ok(vec![Account { + id: "src_1".to_string(), + iban: "NL01".to_string(), + currency: "EUR".to_string(), + }]) + }); + source.expect_get_transactions().returning(|_, _, _| { Ok(vec![BankTransaction { internal_id: "tx1".into(), @@ -369,6 +429,15 @@ mod tests { dest.expect_get_active_account_ibans() .returning(|| Ok(vec!["NL01".to_string()])); + dest.expect_discover_accounts() + .returning(|| { + Ok(vec![Account { + id: "dest_1".to_string(), + iban: "NL01".to_string(), + currency: "EUR".to_string(), + }]) + }); + source.expect_get_accounts().with(always()).returning(|_| { Ok(vec![Account { id: "src_1".to_string(), @@ -377,6 +446,14 @@ mod tests { }]) }); + source.expect_discover_accounts().returning(|| { + Ok(vec![Account { + id: "src_1".to_string(), + iban: "NL01".to_string(), + currency: "EUR".to_string(), + }]) + }); + let tx = BankTransaction { internal_id: "tx1".into(), date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(), diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs index 963b894..7d09e92 100644 --- a/banks2ff/src/main.rs +++ b/banks2ff/src/main.rs @@ -7,6 +7,7 @@ use crate::cli::setup::AppContext; use crate::core::adapters::{ get_available_destinations, get_available_sources, is_valid_destination, is_valid_source, }; +use crate::core::linking::LinkStore; use crate::core::sync::run_sync; use chrono::NaiveDate; use clap::{Parser, Subcommand}; @@ -47,12 +48,52 @@ enum Commands { end: Option, }, + /// Manage accounts and linking + Accounts { + #[command(subcommand)] + subcommand: AccountCommands, + }, + /// List all available source types Sources, /// List all available destination types Destinations, } +#[derive(Subcommand, Debug)] +enum AccountCommands { + /// Manage account links between sources and destinations + Link { + #[command(subcommand)] + subcommand: LinkCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum LinkCommands { + /// List all account links + List, + /// Create a new account link + Create { + /// Source account identifier (ID, IBAN, or name) + source_account: String, + /// Destination account identifier (ID, IBAN, or name) + dest_account: String, + }, + /// Delete an account link + Delete { + /// Link ID + link_id: String, + }, + /// Set or update alias for a link + Alias { + /// Link ID + link_id: String, + /// Alias name + alias: String, + }, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { // Load environment variables first @@ -98,6 +139,10 @@ async fn main() -> anyhow::Result<()> { Commands::Destinations => { handle_destinations().await?; } + + Commands::Accounts { subcommand } => { + handle_accounts(subcommand).await?; + } } Ok(()) @@ -188,3 +233,64 @@ async fn handle_destinations() -> anyhow::Result<()> { } Ok(()) } + +async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> { + match subcommand { + AccountCommands::Link { subcommand: link_sub } => { + handle_link(link_sub).await?; + } + } + Ok(()) +} + +async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> { + let mut link_store = LinkStore::load(); + + match subcommand { + LinkCommands::List => { + if link_store.links.is_empty() { + println!("No account links found."); + } else { + println!("Account Links:"); + for link in &link_store.links { + let source_acc = link_store.source_accounts.get("gocardless").and_then(|m| m.get(&link.source_account_id)); + let dest_acc = link_store.dest_accounts.get("firefly").and_then(|m| m.get(&link.dest_account_id)); + let source_name = source_acc.map(|a| format!("{} ({})", a.iban, a.id)).unwrap_or_else(|| link.source_account_id.clone()); + let dest_name = dest_acc.map(|a| format!("{} ({})", a.iban, a.id)).unwrap_or_else(|| link.dest_account_id.clone()); + let alias_info = link.alias.as_ref().map(|a| format!(" [alias: {}]", a)).unwrap_or_default(); + println!(" {}: {} ↔ {}{}", link.id, source_name, dest_name, alias_info); + } + } + } + LinkCommands::Create { source_account, dest_account } => { + // Assume source_account is gocardless id, dest_account is firefly id + let source_acc = link_store.source_accounts.get("gocardless").and_then(|m| m.get(&source_account)).cloned(); + let dest_acc = link_store.dest_accounts.get("firefly").and_then(|m| m.get(&dest_account)).cloned(); + + if let (Some(src), Some(dst)) = (source_acc, dest_acc) { + let link_id = link_store.add_link(&src, &dst, false); + link_store.save()?; + println!("Created link {} between {} and {}", link_id, src.iban, dst.iban); + } else { + println!("Account not found. Ensure accounts are discovered via sync first."); + } + } + LinkCommands::Delete { link_id } => { + if link_store.remove_link(&link_id).is_ok() { + link_store.save()?; + println!("Deleted link {}", link_id); + } else { + println!("Link {} not found", link_id); + } + } + LinkCommands::Alias { link_id, alias } => { + if link_store.set_alias(&link_id, alias.clone()).is_ok() { + link_store.save()?; + println!("Set alias '{}' for link {}", alias, link_id); + } else { + println!("Link {} not found", link_id); + } + } + } + Ok(()) +} diff --git a/specs/cli-refactor-plan.md b/specs/cli-refactor-plan.md index 1d364fe..555d98b 100644 --- a/specs/cli-refactor-plan.md +++ b/specs/cli-refactor-plan.md @@ -63,26 +63,126 @@ COMMANDS: - All placeholder commands log appropriate messages for future implementation - Maintained all existing sync functionality and flags -### Phase 2: Core Port Extensions +### Phase 2: Core Port Extensions ✅ COMPLETED **Objective**: Extend ports and adapters to support inspection capabilities. **Steps:** -1. Add inspection methods to `TransactionSource` and `TransactionDestination` traits: +1. ✅ Add inspection methods to `TransactionSource` and `TransactionDestination` traits: - `list_accounts()`: Return account summaries - `get_account_status()`: Return sync status for accounts - `get_transaction_info()`: Return transaction metadata - `get_cache_info()`: Return caching status -2. Update existing adapters (GoCardless, Firefly) to implement new methods -3. Define serializable response structs in `core::models` for inspection data -4. Ensure all new methods handle errors gracefully with `anyhow` +2. ✅ Update existing adapters (GoCardless, Firefly) to implement new methods +3. ✅ Define serializable response structs in `core::models` for inspection data +4. ✅ Ensure all new methods handle errors gracefully with `anyhow` **Testing:** - Unit tests for trait implementations on existing adapters - Mock tests for new inspection methods - Integration tests verifying data serialization -### Phase 3: Adapter Factory Implementation +**Implementation Details:** +- Added `AccountSummary`, `AccountStatus`, `TransactionInfo`, and `CacheInfo` structs with `Serialize` and `Debug` traits +- Extended both `TransactionSource` and `TransactionDestination` traits with inspection methods +- Implemented methods in `GoCardlessAdapter` using existing client calls and cache data +- Implemented methods in `FireflyAdapter` using existing client calls +- All code formatted with `cargo fmt` and linted with `cargo clippy` +- Existing tests pass; new methods compile but not yet tested due to CLI not implemented + +### Phase 3: Account Linking and Management ✅ COMPLETED + +**Objective**: Implement comprehensive account linking between sources and destinations to enable reliable sync, with auto-linking where possible and manual overrides. + +**Steps:** +1. ✅ Create `core::linking` module with data structures: + - `AccountLink`: Links source account ID to destination account ID with metadata + - `LinkStore`: Persistent storage for links, aliases, and account registries + - Auto-linking logic (IBAN/name similarity scoring) + +2. ✅ Extend adapters with account discovery: + - `TransactionSource::discover_accounts()`: Full account list without filtering + - `TransactionDestination::discover_accounts()`: Full account list + +3. ✅ Implement linking management: + - Auto-link on sync/account discovery (IBAN/name matches) + - CLI commands: `banks2ff accounts link list`, `banks2ff accounts link create `, `banks2ff accounts link delete ` + - Alias support: `banks2ff accounts alias set `, `banks2ff accounts alias update ` + +4. ✅ Integrate with sync: + - Always discover accounts during sync and update stores + - Use links in `run_sync()` instead of IBAN-only matching + - Handle unlinked accounts (skip with warning or prompt for manual linking) + +5. ✅ Update CLI help text: + - Explain linking process in `banks2ff accounts --help` + - Note that sync auto-discovers and attempts linking + +**Testing:** +- Unit tests for auto-linking algorithms +- Integration tests for various account scenarios (IBAN matches, name matches, no matches) +- Persistence tests for link store +- CLI tests for link management commands + +**Implementation Details:** +- Created `core::linking` with `LinkStore` using nested `HashMap`s for organized storage by adapter type +- Extended traits with `discover_accounts()` and implemented in GoCardless/Firefly adapters +- Integrated account discovery and auto-linking into `run_sync()` with persistent storage +- Added CLI commands under `banks2ff accounts link` with full CRUD operations and alias support +- Updated README with new account linking feature, examples, and troubleshooting + +### Phase 4: CLI Output and Formatting + +**Objective**: Implement user-friendly output for inspection commands. + +**Steps:** +1. Create `cli::formatters` module for consistent output formatting +2. Implement table-based display for accounts and transactions +3. Add JSON output option for programmatic use +4. Ensure sensitive data masking in all outputs +5. Add progress indicators for long-running operations +6. Implement `accounts` command with `list` and `status` subcommands +7. Implement `transactions` command with `list`, `cache-status`, and `clear-cache` subcommands +8. Add account and transaction inspection methods to adapter traits + +**Testing:** +- Unit tests for formatter functions +- Integration tests for CLI output with sample data +- Accessibility tests for output readability +- Unit tests for new command implementations +- Integration tests for account/transaction inspection + +### Phase 5: Status and Cache Management + +**Objective**: Implement status overview and cache management commands. + +**Steps:** +1. Implement `status` command aggregating data from all adapters +2. Add cache inspection and clearing functionality to `transactions cache-status` and `transactions clear-cache` +3. Create status models for sync health metrics +4. Integrate with existing debug logging infrastructure + +**Testing:** +- Unit tests for status aggregation logic +- Integration tests for cache operations +- Mock tests for status data collection + +### Phase 6: Sync Logic Updates + +**Objective**: Make sync logic adapter-agnostic and reusable. + +**Steps:** +1. Modify `core::sync::run_sync()` to accept source/destination traits instead of concrete types +2. Update sync result structures to include inspection data +3. Refactor account processing to work with any `TransactionSource` +4. Ensure dry-run mode works with all adapter types + +**Testing:** +- Unit tests for sync logic with mock adapters +- Integration tests with different source/destination combinations +- Regression tests ensuring existing functionality unchanged + +### Phase 7: Adapter Factory Implementation **Objective**: Enable dynamic adapter instantiation for multiple sources/destinations. @@ -98,7 +198,24 @@ COMMANDS: - Mock tests for adapter creation - Integration tests with real configurations -### Phase 4: File-Based Source Adapters +### Phase 8: Integration and Validation + +**Objective**: Ensure all components work together and prepare for web API. + +**Steps:** +1. Full integration testing across all source/destination combinations +2. Performance testing with realistic data volumes +3. Documentation updates in `docs/architecture.md` +4. Code review against project guidelines +5. Update `AGENTS.md` with new development patterns + +**Testing:** +- End-to-end tests for complete workflows +- Load tests for sync operations +- Security audits for data handling +- Compatibility tests with existing configurations + +### Phase 9: File-Based Source Adapters **Objective**: Implement adapters for file-based transaction sources. @@ -119,74 +236,6 @@ COMMANDS: - Integration tests with fixture files from `tests/fixtures/` - Performance tests for large file handling -### Phase 5: Sync Logic Updates - -**Objective**: Make sync logic adapter-agnostic and reusable. - -**Steps:** -1. Modify `core::sync::run_sync()` to accept source/destination traits instead of concrete types -2. Update sync result structures to include inspection data -3. Refactor account processing to work with any `TransactionSource` -4. Ensure dry-run mode works with all adapter types - -**Testing:** -- Unit tests for sync logic with mock adapters -- Integration tests with different source/destination combinations -- Regression tests ensuring existing functionality unchanged - -### Phase 6: CLI Output and Formatting - -**Objective**: Implement user-friendly output for inspection commands. - -**Steps:** -1. Create `cli::formatters` module for consistent output formatting -2. Implement table-based display for accounts and transactions -3. Add JSON output option for programmatic use -4. Ensure sensitive data masking in all outputs -5. Add progress indicators for long-running operations -6. Implement `accounts` command with `list` and `status` subcommands -7. Implement `transactions` command with `list`, `cached`, and `clear-cache` subcommands -8. Add account and transaction inspection methods to adapter traits - -**Testing:** -- Unit tests for formatter functions -- Integration tests for CLI output with sample data -- Accessibility tests for output readability -- Unit tests for new command implementations -- Integration tests for account/transaction inspection - -### Phase 7: Status and Cache Management - -**Objective**: Implement status overview and cache management commands. - -**Steps:** -1. Implement `status` command aggregating data from all adapters -2. Add cache inspection and clearing functionality to `transactions cached` and `transactions clear-cache` -3. Create status models for sync health metrics -4. Integrate with existing debug logging infrastructure - -**Testing:** -- Unit tests for status aggregation logic -- Integration tests for cache operations -- Mock tests for status data collection - -### Phase 8: Integration and Validation - -**Objective**: Ensure all components work together and prepare for web API. - -**Steps:** -1. Full integration testing across all source/destination combinations -2. Performance testing with realistic data volumes -3. Documentation updates in `docs/architecture.md` -4. Code review against project guidelines -5. Update `AGENTS.md` with new development patterns - -**Testing:** -- End-to-end tests for complete workflows -- Load tests for sync operations -- Security audits for data handling -- Compatibility tests with existing configurations - ## Architecture Considerations - **Hexagonal Architecture**: Maintain separation between core business logic, ports, and adapters