diff --git a/Cargo.lock b/Cargo.lock index d176d67..755f6d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,7 @@ dependencies = [ "chrono", "clap", "comfy-table", + "dialoguer", "dotenvy", "firefly-client", "gocardless-client", @@ -435,6 +436,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -528,6 +542,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -583,6 +609,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -623,6 +655,12 @@ dependencies = [ "instant", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -737,7 +775,7 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" dependencies = [ - "fastrand", + "fastrand 1.9.0", "futures-core", "futures-io", "memchr", @@ -825,6 +863,18 @@ dependencies = [ "wasi 0.11.1+wasi-snapshot-preview1", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1527,6 +1577,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -1937,6 +1993,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -2092,6 +2154,19 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand 2.3.0", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" @@ -2449,6 +2524,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -2867,6 +2951,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "writeable" version = "0.6.2" @@ -2946,6 +3036,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 0655833..db763c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,4 @@ pbkdf2 = "0.12" rand = "0.8" sha2 = "0.10" temp-env = "0.3" +dialoguer = "0.12" diff --git a/README.md b/README.md index e9bbea0..b35bfcf 100644 --- a/README.md +++ b/README.md @@ -11,7 +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 +- **Smart Account Linking**: Automatically match bank accounts to Firefly III accounts, with interactive and intelligent manual linking options ## 🚀 Quick Start @@ -52,7 +52,9 @@ cargo run -p banks2ff -- accounts status # Manage account links cargo run -p banks2ff -- accounts link list -cargo run -p banks2ff -- accounts link create +cargo run -p banks2ff -- accounts link create # Interactive mode - guided account selection +cargo run -p banks2ff -- accounts link create "Account Name" # Smart mode - auto-detect source/destination +cargo run -p banks2ff -- accounts link create # Direct mode - for scripts # Inspect transactions and cache cargo run -p banks2ff -- transactions list @@ -68,7 +70,7 @@ Banks2FF uses a structured command-line interface with the following commands: - `destinations` - List all available destination types - `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 +- `accounts link` - Manage account links between sources and destinations (with interactive and smart modes) - `transactions list ` - Show transaction information for a specific account - `transactions cache-status` - Display cache status and statistics - `transactions clear-cache` - Clear transaction cache (implementation pending) @@ -79,11 +81,41 @@ Use `cargo run -p banks2ff -- --help` for detailed command information. Banks2FF automatically: 1. Connects to your bank accounts via GoCardless -2. Discovers and links accounts between GoCardless and Firefly III (with auto-matching and manual options) +2. Discovers accounts and provides intelligent linking between GoCardless and Firefly III 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 +The account linking system automatically matches accounts by IBAN, but also provides interactive tools for manual linking when needed. + +## 🔗 Smart Account Linking + +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 + ## 🔐 Secure Transaction Caching Banks2FF automatically caches your transaction data to make future syncs much faster: @@ -98,7 +130,9 @@ 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, or use `accounts link` to create manual links +- **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` - **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/Cargo.toml b/banks2ff/Cargo.toml index 53e7167..436e29c 100644 --- a/banks2ff/Cargo.toml +++ b/banks2ff/Cargo.toml @@ -40,6 +40,7 @@ sha2 = { workspace = true } # CLI formatting dependencies comfy-table = { workspace = true } +dialoguer = { workspace = true } [dev-dependencies] mockall = { workspace = true } diff --git a/banks2ff/src/cli/formatters.rs b/banks2ff/src/cli/formatters.rs index 8e495d3..efd9da8 100644 --- a/banks2ff/src/cli/formatters.rs +++ b/banks2ff/src/cli/formatters.rs @@ -1,3 +1,4 @@ +use crate::core::cache::AccountCache; use crate::core::models::{AccountStatus, AccountSummary, CacheInfo, TransactionInfo}; use comfy_table::{presets::UTF8_FULL, Table}; @@ -6,10 +7,10 @@ pub enum OutputFormat { } pub trait Formattable { - fn to_table(&self) -> Table; + fn to_table(&self, account_cache: Option<&AccountCache>) -> Table; } -pub fn print_list_output(data: Vec, format: &OutputFormat) { +pub fn print_list_output(data: Vec, format: &OutputFormat, account_cache: Option<&AccountCache>) { if data.is_empty() { println!("No data available"); return; @@ -18,7 +19,7 @@ pub fn print_list_output(data: Vec, format: &OutputFormat) { match format { OutputFormat::Table => { for item in data { - println!("{}", item.to_table()); + println!("{}", item.to_table(account_cache)); } } } @@ -26,13 +27,12 @@ pub fn print_list_output(data: Vec, format: &OutputFormat) { // Implement Formattable for the model structs impl Formattable for AccountSummary { - fn to_table(&self) -> Table { + fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table { let mut table = Table::new(); table.load_preset(UTF8_FULL); - table.set_header(vec!["ID", "Name", "IBAN", "Currency"]); + table.set_header(vec!["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(), @@ -42,18 +42,26 @@ impl Formattable for AccountSummary { } impl Formattable for AccountStatus { - fn to_table(&self) -> Table { + fn to_table(&self, account_cache: Option<&AccountCache>) -> Table { let mut table = Table::new(); table.load_preset(UTF8_FULL); table.set_header(vec![ - "Account ID", + "Account", "IBAN", "Last Sync", "Transaction Count", "Status", ]); + + let display_name = if let Some(cache) = account_cache { + cache.get_display_name(&self.account_id) + .unwrap_or_else(|| self.account_id.clone()) + } else { + self.account_id.clone() + }; + table.add_row(vec![ - self.account_id.clone(), + display_name, mask_iban(&self.iban), self.last_sync_date .map(|d| d.to_string()) @@ -66,7 +74,7 @@ impl Formattable for AccountStatus { } impl Formattable for TransactionInfo { - fn to_table(&self) -> Table { + fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table { let mut table = Table::new(); table.load_preset(UTF8_FULL); table.set_header(vec![ @@ -92,7 +100,7 @@ impl Formattable for TransactionInfo { } impl Formattable for CacheInfo { - fn to_table(&self) -> Table { + fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table { let mut table = Table::new(); table.load_preset(UTF8_FULL); table.set_header(vec![ @@ -119,6 +127,18 @@ fn mask_iban(iban: &str) -> String { if iban.len() <= 4 { iban.to_string() } else { - format!("{}{}", "*".repeat(iban.len() - 4), &iban[iban.len() - 4..]) + let country_code = &iban[0..2]; + let last_four = &iban[iban.len() - 4..]; + + if country_code == "NL" && iban.len() >= 12 { + // NL: show first 2 (CC) + next 6 + mask + last 4 + let next_six = &iban[2..8]; + let mask_length = iban.len() - 12; // 2 + 6 + 4 = 12, so mask_length = total - 12 + format!("{}{}{}{}", country_code, next_six, "*".repeat(mask_length), last_four) + } else { + // Other countries: show first 2 + mask + last 4 + let mask_length = iban.len() - 6; // 2 + 4 = 6 + format!("{}{}{}", country_code, "*".repeat(mask_length), last_four) + } } } diff --git a/banks2ff/src/core/cache.rs b/banks2ff/src/core/cache.rs index 6bb0aa8..820daf7 100644 --- a/banks2ff/src/core/cache.rs +++ b/banks2ff/src/core/cache.rs @@ -122,7 +122,7 @@ impl AccountData for GoCardlessAccount { fn display_name(&self) -> Option { // Priority: display_name > name > owner_name > masked IBAN - self.display_name + let base_name = self.display_name .clone() .or_else(|| self.name.clone()) .or_else(|| { @@ -138,7 +138,14 @@ impl AccountData for GoCardlessAccount { iban.to_string() } }) - }) + }); + + // For GoCardless accounts, append institution if available + if let (Some(name), Some(institution_id)) = (&base_name, &self.institution_id) { + Some(format!("{} ({})", name, institution_id)) + } else { + base_name + } } } @@ -263,6 +270,13 @@ impl AccountCache { self.get_account_data(account_id)?.display_name() } + pub fn get_adapter_type(&self, account_id: &str) -> Option<&str> { + match self.accounts.get(account_id)? { + CachedAccount::GoCardless(_) => Some("gocardless"), + CachedAccount::Firefly(_) => Some("firefly"), + } + } + pub fn insert(&mut self, account: CachedAccount) { let account_id = account.id().to_string(); self.accounts.insert(account_id, account); diff --git a/banks2ff/src/core/linking.rs b/banks2ff/src/core/linking.rs index e1fd3ec..6a8630f 100644 --- a/banks2ff/src/core/linking.rs +++ b/banks2ff/src/core/linking.rs @@ -7,16 +7,27 @@ 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, + #[serde(default = "default_source_adapter_type")] + pub source_adapter_type: String, // e.g., "gocardless", "other_source" + #[serde(default = "default_dest_adapter_type")] + pub dest_adapter_type: String, // e.g., "firefly", "other_destination" pub auto_linked: bool, } +fn default_source_adapter_type() -> String { + "gocardless".to_string() +} + +fn default_dest_adapter_type() -> String { + "firefly".to_string() +} + #[derive(Debug, Serialize, Deserialize, Default)] pub struct LinkStore { pub links: Vec, + #[serde(skip)] cache_dir: String, } @@ -37,8 +48,11 @@ impl LinkStore { let path = format!("{}/links.json", cache_dir); if Path::new(&path).exists() { match fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str(&content) { - Ok(store) => return store, + Ok(content) => match serde_json::from_str::(&content) { + Ok(mut store) => { + store.cache_dir = cache_dir; + return store; + } Err(e) => warn!("Failed to parse link store: {}", e), }, Err(e) => warn!("Failed to read link store: {}", e), @@ -61,45 +75,49 @@ impl LinkStore { &mut self, source_account: &Account, dest_account: &Account, + source_adapter_type: &str, + dest_adapter_type: &str, auto_linked: bool, - ) -> Option { - // Check if link already exists + ) -> Result { + // Check if link already exists (exact same source-dest pair) if self.links.iter().any(|l| { l.source_account_id == source_account.id && l.dest_account_id == dest_account.id }) { - return None; // Link already exists + return Ok(false); // Link already exists + } + + // Check if source account is already linked to a DIFFERENT destination of this adapter type + if let Some(existing_link) = self.links.iter().find(|l| { + l.source_account_id == source_account.id && l.dest_adapter_type == dest_adapter_type && l.dest_account_id != dest_account.id + }) { + return Err(format!( + "Source account '{}' is already linked to destination '{}' of type '{}'. Unlink first to create a new link.", + source_account.id, existing_link.dest_account_id, dest_adapter_type + )); } - let next_id = self.links.len() + 1; - let id = format!("link_{}", next_id); let link = AccountLink { - id: id.clone(), source_account_id: source_account.id.clone(), dest_account_id: dest_account.id.clone(), - alias: None, + source_adapter_type: source_adapter_type.to_string(), + dest_adapter_type: dest_adapter_type.to_string(), auto_linked, }; self.links.push(link); - Some(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(()) + Ok(true) } 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_links_by_source(&self, source_id: &str) -> Vec<&AccountLink> { + self.links.iter().filter(|l| l.source_account_id == source_id).collect() + } + + pub fn find_link_by_source_and_dest_type(&self, source_id: &str, dest_adapter_type: &str) -> Option<&AccountLink> { + self.links.iter().find(|l| l.source_account_id == source_id && l.dest_adapter_type == dest_adapter_type) + } } pub fn auto_link_accounts( @@ -142,13 +160,14 @@ mod tests { }; // First call should create a link - let first_result = store.add_link(&src, &dest, true); - assert!(first_result.is_some()); - assert_eq!(store.links.len(), 1); + let first_result = store.add_link(&src, &dest, "gocardless", "firefly", true); + assert!(first_result.is_ok()); + assert!(first_result.unwrap()); // Second call should not create a duplicate - let second_result = store.add_link(&src, &dest, true); - assert!(second_result.is_none()); + let second_result = store.add_link(&src, &dest, "gocardless", "firefly", true); + assert!(second_result.is_ok()); + assert!(!second_result.unwrap()); assert_eq!(store.links.len(), 1); // Still only one link } @@ -174,14 +193,92 @@ mod tests { currency: "EUR".to_string(), }; - // Link src1 to dest1 - let result1 = store.add_link(&src1, &dest1, true); - assert!(result1.is_some()); - assert_eq!(store.links.len(), 1); + // Link src1 to dest1 (firefly) + let result1 = store.add_link(&src1, &dest1, "gocardless", "firefly", false); + assert!(result1.is_ok()); + assert!(result1.unwrap()); - // Link src1 to dest2 (different destination) - let result2 = store.add_link(&src1, &dest2, true); - assert!(result2.is_some()); - assert_eq!(store.links.len(), 2); // Two different links + // Try to link src1 to dest2 (same adapter type) - should fail + let result2 = store.add_link(&src1, &dest2, "gocardless", "firefly", false); + assert!(result2.is_err()); + assert!(result2.unwrap_err().contains("already linked")); + assert_eq!(store.links.len(), 1); // Still only one link + } + + fn setup_test_dir(test_name: &str) -> String { + // Use a unique cache directory for each test to avoid interference + // Include random component and timestamp for true parallelism safety + let random_suffix = rand::random::(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!( + "tmp/test-links-{}-{}-{}", + test_name, random_suffix, timestamp + ) + } + + fn cleanup_test_dir(cache_dir: &str) { + // Wait a bit longer to ensure all file operations are complete + std::thread::sleep(std::time::Duration::from_millis(50)); + + // Try multiple times in case of temporary file locks + for _ in 0..5 { + if std::path::Path::new(cache_dir).exists() { + if std::fs::remove_dir_all(cache_dir).is_ok() { + break; + } + } else { + break; // Directory already gone + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + + #[test] + fn test_load_links_store() { + let cache_dir = setup_test_dir("load"); + + // Create JSON in the correct format + let json_content = r#"{ + "links": [ + { + "source_account_id": "src1", + "dest_account_id": "dest1", + "source_adapter_type": "gocardless", + "dest_adapter_type": "firefly", + "auto_linked": true + }, + { + "source_account_id": "src2", + "dest_account_id": "dest2", + "source_adapter_type": "gocardless", + "dest_adapter_type": "firefly", + "auto_linked": false + } + ] +}"#; + + // Create directory and write file + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(format!("{}/links.json", cache_dir), json_content).unwrap(); + + // Load should work and set cache_dir + let store = LinkStore::load(cache_dir.clone()); + assert_eq!(store.links.len(), 2); + assert_eq!(store.links[0].source_account_id, "src1"); + assert_eq!(store.links[0].dest_account_id, "dest1"); + assert_eq!(store.links[0].source_adapter_type, "gocardless"); + assert_eq!(store.links[0].dest_adapter_type, "firefly"); + assert!(store.links[0].auto_linked); + assert_eq!(store.links[1].source_account_id, "src2"); + assert_eq!(store.links[1].dest_account_id, "dest2"); + assert_eq!(store.links[1].source_adapter_type, "gocardless"); + assert_eq!(store.links[1].dest_adapter_type, "firefly"); + assert!(!store.links[1].auto_linked); + assert_eq!(store.cache_dir, cache_dir); + + cleanup_test_dir(&cache_dir); } } diff --git a/banks2ff/src/core/sync.rs b/banks2ff/src/core/sync.rs index 3f307dd..476f0b7 100644 --- a/banks2ff/src/core/sync.rs +++ b/banks2ff/src/core/sync.rs @@ -49,10 +49,16 @@ pub async fn run_sync( for (src_idx, dest_idx) in links { let src = &all_source_accounts[src_idx]; let dest = &all_dest_accounts[dest_idx]; - if let Some(_link_id) = link_store.add_link(src, dest, true) { - info!("Created new account link: {} -> {}", src.id, dest.id); - } else { - info!("Account link already exists: {} -> {}", src.id, dest.id); + match link_store.add_link(src, dest, "gocardless", "firefly", true) { + Ok(true) => { + info!("Created new account link: {} -> {}", src.id, dest.id); + } + Ok(false) => { + info!("Account link already exists: {} -> {}", src.id, dest.id); + } + Err(e) => { + warn!("Failed to create account link: {}", e); + } } } link_store.save().map_err(SyncError::SourceError)?; diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs index 698774f..900b2f3 100644 --- a/banks2ff/src/main.rs +++ b/banks2ff/src/main.rs @@ -8,10 +8,11 @@ use crate::cli::setup::AppContext; use crate::core::adapters::{ get_available_destinations, get_available_sources, is_valid_destination, is_valid_source, }; +use crate::core::cache::{AccountCache, CachedAccount}; use crate::core::config::Config; use crate::core::encryption::Encryption; use crate::core::linking::LinkStore; -use crate::core::models::{AccountData, AccountStatus, AccountSummary}; +use crate::core::models::{Account, AccountData, AccountStatus, AccountSummary}; use crate::core::ports::{TransactionDestination, TransactionSource}; use crate::core::sync::run_sync; use chrono::NaiveDate; @@ -78,22 +79,10 @@ enum LinkCommands { 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, + /// Source account identifier (ID, IBAN, or name). If omitted, interactive mode is used. + source_account: Option, + /// Destination account identifier (ID, IBAN, or name). Required if source is provided. + dest_account: Option, }, } @@ -310,24 +299,7 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: if link_store.links.is_empty() { println!("No account links found."); } else { - println!("Account Links:"); - for link in &link_store.links { - let source_name = account_cache - .get_display_name(&link.source_account_id) - .unwrap_or_else(|| format!("Account {}", &link.source_account_id)); - let dest_name = account_cache - .get_display_name(&link.dest_account_id) - .unwrap_or_else(|| format!("Account {}", &link.dest_account_id)); - let alias_info = link - .alias - .as_ref() - .map(|a| format!(" [alias: {}]", a)) - .unwrap_or_default(); - println!( - " {}: {} ↔ {}{}", - link.id, source_name, dest_name, alias_info - ); - } + print_links_table(&link_store.links, &account_cache); } } LinkCommands::Create { @@ -341,73 +313,22 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: encryption, ); - // Assume source_account is gocardless id, dest_account is firefly id - let source_acc = account_cache.get_account(&source_account); - let dest_acc = account_cache.get_account(&dest_account); - - if let (Some(src), Some(dst)) = (source_acc, dest_acc) { - // Create minimal Account structs for linking - let src_minimal = crate::core::models::Account { - id: src.id().to_string(), - name: Some(src.id().to_string()), // Use ID as name for linking - iban: src.iban().map(|s| s.to_string()), - currency: "EUR".to_string(), - }; - let dst_minimal = crate::core::models::Account { - id: dst.id().to_string(), - name: Some(dst.id().to_string()), // Use ID as name for linking - iban: dst.iban().map(|s| s.to_string()), - currency: "EUR".to_string(), - }; - - if let Some(link_id) = - link_store.add_link(&src_minimal, &dst_minimal, false) - { - link_store.save()?; - let src_display = account_cache - .get_display_name(&source_account) - .unwrap_or_else(|| source_account.clone()); - let dst_display = account_cache - .get_display_name(&dest_account) - .unwrap_or_else(|| dest_account.clone()); - println!( - "Created link {} between {} and {}", - link_id, src_display, dst_display - ); - } else { - let src_display = account_cache - .get_display_name(&source_account) - .unwrap_or_else(|| source_account.clone()); - let dst_display = account_cache - .get_display_name(&dest_account) - .unwrap_or_else(|| dest_account.clone()); - println!( - "Link between {} and {} already exists", - src_display, dst_display - ); + match (source_account, dest_account) { + (None, None) => { + // Interactive mode + handle_interactive_link_creation(&mut link_store, &account_cache)?; + } + (Some(source), None) => { + // Single argument - try to resolve as source or destination + handle_single_arg_link_creation(&mut link_store, &account_cache, &source)?; + } + (Some(source), Some(dest)) => { + // Two arguments - direct linking + handle_direct_link_creation(&mut link_store, &account_cache, &source, &dest)?; + } + (None, Some(_)) => { + println!("Error: Cannot specify destination without source. Use 'banks2ff accounts link create ' or interactive mode."); } - } else { - println!( - "Account not found. Ensure accounts are discovered via sync first." - ); - } - } - LinkCommands::Delete { link_id } => { - let mut link_store = LinkStore::load(config.cache.directory.clone()); - 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 } => { - let mut link_store = LinkStore::load(config.cache.directory.clone()); - 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); } } } @@ -494,26 +415,372 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: } } AccountCommands::Status => { + let encryption = Encryption::new(config.cache.key.clone()); + let account_cache = crate::core::cache::AccountCache::load( + config.cache.directory.clone(), + encryption, + ); + let status = context.source.get_account_status().await?; if status.is_empty() { println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data."); } else { - print_account_status_table(&status); + print_account_status_table(&status, &account_cache); } } } Ok(()) } +fn handle_interactive_link_creation( + link_store: &mut LinkStore, + account_cache: &AccountCache, +) -> anyhow::Result<()> { + // Get unlinked GoCardless accounts + let gocardless_accounts = get_gocardless_accounts(account_cache); + let unlinked_sources: Vec<_> = gocardless_accounts + .iter() + .filter(|acc| !link_store.find_links_by_source(&acc.id()).iter().any(|link| link.dest_adapter_type == "firefly")) + .collect(); + + if unlinked_sources.is_empty() { + println!("No unlinked source accounts found. All GoCardless accounts are already linked to Firefly III."); + return Ok(()); + } + + // Create selection items for dialoguer + let source_items: Vec = unlinked_sources + .iter() + .map(|account| { + let display_name = account.display_name().unwrap_or_else(|| account.id().to_string()); + format!("{}", display_name) + }) + .collect(); + + // Add cancel option + let mut items = source_items.clone(); + items.push("Cancel".to_string()); + + // Prompt user to select source account + let source_selection = match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt("Select a source account to link") + .items(&items) + .default(0) + .interact() + { + Ok(selection) => selection, + Err(_) => { + // Non-interactive environment (e.g., tests, scripts) + println!("Interactive mode not available in this environment."); + println!("Use: banks2ff accounts link create "); + return Ok(()); + } + }; + + if source_selection == items.len() - 1 { + // User selected "Cancel" + println!("Operation cancelled."); + return Ok(()); + } + + let selected_source = &unlinked_sources[source_selection]; + handle_source_selection(link_store, account_cache, selected_source.id().to_string())?; + + Ok(()) +} + +fn handle_single_arg_link_creation( + link_store: &mut LinkStore, + account_cache: &AccountCache, + arg: &str, +) -> anyhow::Result<()> { + // Try to find account by ID, name, or IBAN + let matched_account = find_account_by_identifier(account_cache, arg); + + match matched_account { + Some((account_id, adapter_type)) => { + if adapter_type == "gocardless" { + // It's a source account - show available destinations + handle_source_selection(link_store, account_cache, account_id) + } else { + // It's a destination account - show available sources + handle_destination_selection(link_store, account_cache, account_id) + } + } + None => { + println!("No account found matching '{}'.", arg); + println!("Try using an account ID, name, or IBAN pattern."); + println!("Run 'banks2ff accounts list' to see available accounts."); + Ok(()) + } + } +} + +fn handle_direct_link_creation( + link_store: &mut LinkStore, + account_cache: &AccountCache, + source_arg: &str, + dest_arg: &str, +) -> anyhow::Result<()> { + let source_match = find_account_by_identifier(account_cache, source_arg); + let dest_match = find_account_by_identifier(account_cache, dest_arg); + + match (source_match, dest_match) { + (Some((source_id, source_adapter)), Some((dest_id, dest_adapter))) => { + if source_adapter != "gocardless" { + println!("Error: Source must be a GoCardless account, got {} account.", source_adapter); + return Ok(()); + } + if dest_adapter != "firefly" { + println!("Error: Destination must be a Firefly III account, got {} account.", dest_adapter); + return Ok(()); + } + + create_link(link_store, account_cache, &source_id, &dest_id, &dest_adapter) + } + (None, _) => { + println!("Source account '{}' not found.", source_arg); + Ok(()) + } + (_, None) => { + println!("Destination account '{}' not found.", dest_arg); + Ok(()) + } + } +} + +fn find_account_by_identifier(account_cache: &AccountCache, identifier: &str) -> Option<(String, String)> { + // First try exact ID match + if let Some(adapter_type) = account_cache.get_adapter_type(identifier) { + return Some((identifier.to_string(), adapter_type.to_string())); + } + + // Then try name/IBAN matching + for (id, account) in &account_cache.accounts { + if let Some(display_name) = account.display_name() { + if display_name.to_lowercase().contains(&identifier.to_lowercase()) { + let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) { "gocardless" } else { "firefly" }; + return Some((id.clone(), adapter_type.to_string())); + } + } + if let Some(iban) = account.iban() { + if iban.contains(identifier) { + let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) { "gocardless" } else { "firefly" }; + return Some((id.clone(), adapter_type.to_string())); + } + } + } + + None +} + +fn handle_source_selection( + link_store: &mut LinkStore, + account_cache: &AccountCache, + source_id: String, +) -> anyhow::Result<()> { + // Check if source is already linked to firefly + if let Some(existing_link) = link_store.find_link_by_source_and_dest_type(&source_id, "firefly") { + let dest_name = account_cache + .get_display_name(&existing_link.dest_account_id) + .unwrap_or_else(|| existing_link.dest_account_id.clone()); + println!("Source account '{}' is already linked to '{}'.", + account_cache.get_display_name(&source_id).unwrap_or_else(|| source_id.clone()), + dest_name); + return Ok(()); + } + + // Get available Firefly destinations + let firefly_accounts = get_firefly_accounts(account_cache); + + if firefly_accounts.is_empty() { + println!("No Firefly III accounts found. Run sync first."); + return Ok(()); + } + + // Create selection items for dialoguer + let dest_items: Vec = firefly_accounts + .iter() + .map(|account| { + let display_name = account.display_name().unwrap_or_else(|| account.id().to_string()); + format!("{}", display_name) + }) + .collect(); + + // Add cancel option + let mut items = dest_items.clone(); + items.push("Cancel".to_string()); + + // Prompt user to select destination account + let source_name = account_cache.get_display_name(&source_id).unwrap_or_else(|| source_id.clone()); + let dest_selection = match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(format!("Select a destination account for '{}'", source_name)) + .items(&items) + .default(0) + .interact() + { + Ok(selection) => selection, + Err(_) => { + // Non-interactive environment (e.g., tests, scripts) + println!("Interactive mode not available in this environment."); + println!("Use: banks2ff accounts link create "); + return Ok(()); + } + }; + + if dest_selection == items.len() - 1 { + // User selected "Cancel" + println!("Operation cancelled."); + return Ok(()); + } + + let selected_dest = &firefly_accounts[dest_selection]; + create_link(link_store, account_cache, &source_id, &selected_dest.id(), "firefly")?; + + Ok(()) +} + +fn handle_destination_selection( + link_store: &mut LinkStore, + account_cache: &AccountCache, + dest_id: String, +) -> anyhow::Result<()> { + // Get available GoCardless sources that aren't already linked to this destination + let gocardless_accounts = get_gocardless_accounts(account_cache); + let available_sources: Vec<_> = gocardless_accounts + .iter() + .filter(|acc| !link_store.find_links_by_source(&acc.id()).iter().any(|link| link.dest_account_id == dest_id)) + .collect(); + + if available_sources.is_empty() { + println!("No available source accounts found that can link to '{}'.", + account_cache.get_display_name(&dest_id).unwrap_or_else(|| dest_id.clone())); + return Ok(()); + } + + // Create selection items for dialoguer + let source_items: Vec = available_sources + .iter() + .map(|account| { + let display_name = account.display_name().unwrap_or_else(|| account.id().to_string()); + format!("{}", display_name) + }) + .collect(); + + // Add cancel option + let mut items = source_items.clone(); + items.push("Cancel".to_string()); + + // Prompt user to select source account + let dest_name = account_cache.get_display_name(&dest_id).unwrap_or_else(|| dest_id.clone()); + let source_selection = match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(format!("Select a source account to link to '{}'", dest_name)) + .items(&items) + .default(0) + .interact() + { + Ok(selection) => selection, + Err(_) => { + // Non-interactive environment (e.g., tests, scripts) + println!("Interactive mode not available in this environment."); + println!("Use: banks2ff accounts link create "); + return Ok(()); + } + }; + + if source_selection == items.len() - 1 { + // User selected "Cancel" + println!("Operation cancelled."); + return Ok(()); + } + + let selected_source = &available_sources[source_selection]; + create_link(link_store, account_cache, &selected_source.id(), &dest_id, "firefly")?; + + Ok(()) +} + +fn create_link( + link_store: &mut LinkStore, + account_cache: &AccountCache, + source_id: &str, + dest_id: &str, + dest_adapter_type: &str, +) -> anyhow::Result<()> { + let source_acc = account_cache.get_account(source_id); + let dest_acc = account_cache.get_account(dest_id); + + if let (Some(src), Some(dst)) = (source_acc, dest_acc) { + let src_minimal = Account { + id: src.id().to_string(), + name: Some(src.id().to_string()), + iban: src.iban().map(|s| s.to_string()), + currency: "EUR".to_string(), + }; + let dst_minimal = Account { + id: dst.id().to_string(), + name: Some(dst.id().to_string()), + iban: dst.iban().map(|s| s.to_string()), + currency: "EUR".to_string(), + }; + + match link_store.add_link(&src_minimal, &dst_minimal, "gocardless", dest_adapter_type, false) { + Ok(true) => { + link_store.save()?; + let src_display = account_cache.get_display_name(source_id).unwrap_or_else(|| source_id.to_string()); + let dst_display = account_cache.get_display_name(dest_id).unwrap_or_else(|| dest_id.to_string()); + println!("Created link between {} and {}", src_display, dst_display); + } + Ok(false) => { + let src_display = account_cache.get_display_name(source_id).unwrap_or_else(|| source_id.to_string()); + let dst_display = account_cache.get_display_name(dest_id).unwrap_or_else(|| dest_id.to_string()); + println!("Link between {} and {} already exists", src_display, dst_display); + } + Err(e) => { + println!("Cannot create link: {}", e); + } + } + } else { + println!("Account not found in cache. Run sync first."); + } + + Ok(()) +} + +fn get_gocardless_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> { + account_cache + .accounts + .values() + .filter_map(|acc| { + match acc { + CachedAccount::GoCardless(gc_acc) => Some(gc_acc.as_ref() as &dyn AccountData), + _ => None, + } + }) + .collect() +} + +fn get_firefly_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> { + account_cache + .accounts + .values() + .filter_map(|acc| { + match acc { + CachedAccount::Firefly(ff_acc) => Some(ff_acc.as_ref() as &dyn AccountData), + _ => None, + } + }) + .collect() +} + fn print_accounts_table(accounts: &[AccountSummary]) { let mut table = Table::new(); table.load_preset(UTF8_FULL); - table.set_header(vec!["ID", "Name", "IBAN", "Currency"]); + table.set_header(vec!["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(), @@ -523,11 +790,34 @@ fn print_accounts_table(accounts: &[AccountSummary]) { println!("{}", table); } -fn print_account_status_table(statuses: &[AccountStatus]) { +fn print_links_table( + links: &[crate::core::linking::AccountLink], + account_cache: &crate::core::cache::AccountCache, +) { + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec!["Source Account", "Destination Account", "Auto-Linked"]); + + for link in links { + let source_name = account_cache + .get_display_name(&link.source_account_id) + .unwrap_or_else(|| format!("Account {}", &link.source_account_id)); + let dest_name = account_cache + .get_display_name(&link.dest_account_id) + .unwrap_or_else(|| format!("Account {}", &link.dest_account_id)); + let auto_linked = if link.auto_linked { "Yes" } else { "No" }; + + table.add_row(vec![source_name, dest_name, auto_linked.to_string()]); + } + + println!("{}", table); +} + +fn print_account_status_table(statuses: &[AccountStatus], account_cache: &AccountCache) { let mut table = Table::new(); table.load_preset(UTF8_FULL); table.set_header(vec![ - "Account ID", + "Account", "IBAN", "Last Sync", "Transaction Count", @@ -535,8 +825,11 @@ fn print_account_status_table(statuses: &[AccountStatus]) { ]); for status in statuses { + let display_name = account_cache + .get_display_name(&status.account_id) + .unwrap_or_else(|| status.account_id.clone()); table.add_row(vec![ - status.account_id.clone(), + display_name, mask_iban(&status.iban), status .last_sync_date @@ -554,7 +847,64 @@ fn mask_iban(iban: &str) -> String { if iban.len() <= 4 { iban.to_string() } else { - format!("{}{}", "*".repeat(iban.len() - 4), &iban[iban.len() - 4..]) + let country_code = &iban[0..2]; + let last_four = &iban[iban.len() - 4..]; + + if country_code == "NL" && iban.len() >= 12 { + // NL: show first 2 (CC) + next 6 + mask + last 4 + let next_six = &iban[2..8]; + let mask_length = iban.len() - 12; // 2 + 6 + 4 = 12, so mask_length = total - 12 + format!("{}{}{}{}", country_code, next_six, "*".repeat(mask_length), last_four) + } else { + // Other countries: show first 2 + mask + last 4 + let mask_length = iban.len() - 6; // 2 + 4 = 6 + format!("{}{}{}", country_code, "*".repeat(mask_length), last_four) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::cache::AccountCache; + use crate::core::encryption::Encryption; + + #[test] + fn test_find_account_by_identifier_exact_id() { + let cache = AccountCache::new("test".to_string(), Encryption::new("test_key".to_string())); + // Add a mock account - we'd need to create a proper test setup + // For now, just test the function signature works + let result = find_account_by_identifier(&cache, "test_id"); + assert!(result.is_none()); // No accounts in empty cache + } + + #[test] + fn test_get_gocardless_accounts_empty_cache() { + let cache = AccountCache::new("test".to_string(), Encryption::new("test_key".to_string())); + let accounts = get_gocardless_accounts(&cache); + assert!(accounts.is_empty()); + } + + #[test] + fn test_get_firefly_accounts_empty_cache() { + let cache = AccountCache::new("test".to_string(), Encryption::new("test_key".to_string())); + let accounts = get_firefly_accounts(&cache); + assert!(accounts.is_empty()); + } + + #[test] + fn test_mask_iban_short() { + assert_eq!(mask_iban("123"), "123"); + } + + #[test] + fn test_mask_iban_long() { + assert_eq!(mask_iban("NL12ABCD1234567890"), "NL12ABCD******7890"); + } + + #[test] + fn test_mask_iban_other_country() { + assert_eq!(mask_iban("DE1234567890123456"), "DE************3456"); } } @@ -565,13 +915,20 @@ async fn handle_transactions( 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, + ); + match subcommand { TransactionCommands::List { account_id } => { 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); } else { - print_list_output(vec![info], &format); + print_list_output(vec![info], &format, Some(&account_cache)); } } TransactionCommands::CacheStatus => { @@ -579,7 +936,7 @@ async fn handle_transactions( if cache_info.is_empty() { println!("No cache data available. Run 'banks2ff sync gocardless firefly' first to populate caches."); } else { - print_list_output(cache_info, &format); + print_list_output(cache_info, &format, Some(&account_cache)); } } TransactionCommands::ClearCache => {