diff --git a/banks2ff/src/adapters/firefly/client.rs b/banks2ff/src/adapters/firefly/client.rs index 026f46e..593c165 100644 --- a/banks2ff/src/adapters/firefly/client.rs +++ b/banks2ff/src/adapters/firefly/client.rs @@ -27,30 +27,6 @@ impl FireflyAdapter { #[async_trait] impl TransactionDestination for FireflyAdapter { - #[instrument(skip(self))] - async fn get_active_account_ibans(&self) -> Result> { - let client = self.client.lock().await; - // Get all asset accounts. Note: Pagination might be needed if user has > 50 accounts. - // For typical users, 50 is enough. If needed we can loop pages. - // The client `get_accounts` method hardcodes limit=default. We should probably expose a list_all method or loop here. - // For now, let's assume page 1 covers it or use search. - - let accounts = client.get_accounts("").await?; // Argument ignored in current impl - let mut ibans = 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 { - if !iban.is_empty() { - ibans.push(iban); - } - } - } - } - Ok(ibans) - } - #[instrument(skip(self))] async fn get_last_transaction_date(&self, account_id: &str) -> Result> { let client = self.client.lock().await; @@ -196,9 +172,60 @@ impl TransactionDestination for FireflyAdapter { let accounts = client.get_accounts("").await?; let mut result = Vec::new(); + // Cache the accounts + let mut cache = crate::core::cache::AccountCache::load(); + for acc in accounts.data { let is_active = acc.attributes.active.unwrap_or(true); if is_active { + // Cache the full account details + let ff_account = crate::core::cache::FireflyAccount { + id: acc.id.clone(), + name: acc.attributes.name.clone(), + account_type: acc.attributes.account_type.clone(), + iban: acc.attributes.iban.clone(), + active: acc.attributes.active, + order: acc.attributes.order, + created_at: acc.attributes.created_at.clone(), + account_role: acc.attributes.account_role.clone(), + object_group_id: acc.attributes.object_group_id.clone(), + object_group_title: acc.attributes.object_group_title.clone(), + object_group_order: acc.attributes.object_group_order, + currency_id: acc.attributes.currency_id.clone(), + currency_name: acc.attributes.currency_name.clone(), + currency_code: acc.attributes.currency_code.clone(), + currency_symbol: acc.attributes.currency_symbol.clone(), + currency_decimal_places: acc.attributes.currency_decimal_places, + primary_currency_id: acc.attributes.primary_currency_id.clone(), + primary_currency_name: acc.attributes.primary_currency_name.clone(), + primary_currency_code: acc.attributes.primary_currency_code.clone(), + primary_currency_symbol: acc.attributes.primary_currency_symbol.clone(), + primary_currency_decimal_places: acc.attributes.primary_currency_decimal_places, + opening_balance: acc.attributes.opening_balance.clone(), + pc_opening_balance: acc.attributes.pc_opening_balance.clone(), + debt_amount: acc.attributes.debt_amount.clone(), + pc_debt_amount: acc.attributes.pc_debt_amount.clone(), + notes: acc.attributes.notes.clone(), + monthly_payment_date: acc.attributes.monthly_payment_date.clone(), + credit_card_type: acc.attributes.credit_card_type.clone(), + account_number: acc.attributes.account_number.clone(), + bic: acc.attributes.bic.clone(), + opening_balance_date: acc.attributes.opening_balance_date.clone(), + liability_type: acc.attributes.liability_type.clone(), + liability_direction: acc.attributes.liability_direction.clone(), + interest: acc.attributes.interest.clone(), + interest_period: acc.attributes.interest_period.clone(), + include_net_worth: acc.attributes.include_net_worth, + longitude: acc.attributes.longitude, + latitude: acc.attributes.latitude, + zoom_level: acc.attributes.zoom_level, + last_activity: acc.attributes.last_activity.clone(), + }; + cache.insert(crate::core::cache::CachedAccount::Firefly( + Box::new(ff_account), + )); + cache.save(); + result.push(Account { id: acc.id, name: Some(acc.attributes.name), diff --git a/banks2ff/src/adapters/gocardless/cache.rs b/banks2ff/src/adapters/gocardless/cache.rs deleted file mode 100644 index f3fcbec..0000000 --- a/banks2ff/src/adapters/gocardless/cache.rs +++ /dev/null @@ -1,221 +0,0 @@ -use crate::adapters::gocardless::encryption::Encryption; -use crate::core::models::AccountData; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use tracing::warn; - -#[derive(Debug, Serialize, Deserialize)] -pub enum CachedAccount { - GoCardless(GoCardlessAccount), - Firefly(FireflyAccount), -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct GoCardlessAccount { - pub id: String, - pub iban: Option, - pub name: Option, // From AccountDetail.name - pub display_name: Option, // From AccountDetail.displayName - pub owner_name: Option, // From Account.owner_name - pub status: Option, // From Account.status - pub institution_id: Option, // From Account.institution_id - pub created: Option, // From Account.created - pub last_accessed: Option, // From Account.last_accessed - pub product: Option, // From AccountDetail.product - pub cash_account_type: Option, // From AccountDetail.cashAccountType -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct FireflyAccount { - pub id: String, - pub name: String, // From Account.name - pub account_type: String, // From Account.type - pub iban: Option, // From Account.iban - pub active: Option, // From Account.active - pub order: Option, // From Account.order - pub created_at: Option, // From Account.created_at - pub account_role: Option, // From Account.account_role - pub object_group_id: Option, // From Account.object_group_id - pub object_group_title: Option, // From Account.object_group_title - pub object_group_order: Option, // From Account.object_group_order - pub currency_id: Option, // From Account.currency_id - pub currency_name: Option, // From Account.currency_name - pub currency_code: Option, // From Account.currency_code - pub currency_symbol: Option, // From Account.currency_symbol - pub currency_decimal_places: Option, // From Account.currency_decimal_places - pub primary_currency_id: Option, // From Account.primary_currency_id - pub primary_currency_name: Option, // From Account.primary_currency_name - pub primary_currency_code: Option, // From Account.primary_currency_code - pub primary_currency_symbol: Option, // From Account.primary_currency_symbol - pub primary_currency_decimal_places: Option, // From Account.primary_currency_decimal_places - pub opening_balance: Option, // From Account.opening_balance - pub pc_opening_balance: Option, // From Account.pc_opening_balance - pub debt_amount: Option, // From Account.debt_amount - pub pc_debt_amount: Option, // From Account.pc_debt_amount - pub notes: Option, // From Account.notes - pub monthly_payment_date: Option, // From Account.monthly_payment_date - pub credit_card_type: Option, // From Account.credit_card_type - pub account_number: Option, // From Account.account_number - pub bic: Option, // From Account.bic - pub opening_balance_date: Option, // From Account.opening_balance_date - pub liability_type: Option, // From Account.liability_type - pub liability_direction: Option, // From Account.liability_direction - pub interest: Option, // From Account.interest - pub interest_period: Option, // From Account.interest_period - pub include_net_worth: Option, // From Account.include_net_worth - pub longitude: Option, // From Account.longitude - pub latitude: Option, // From Account.latitude - pub zoom_level: Option, // From Account.zoom_level - pub last_activity: Option, // From Account.last_activity -} - -impl crate::core::models::AccountData for CachedAccount { - fn id(&self) -> &str { - match self { - CachedAccount::GoCardless(acc) => &acc.id, - CachedAccount::Firefly(acc) => &acc.id, - } - } - - fn iban(&self) -> Option<&str> { - match self { - CachedAccount::GoCardless(acc) => acc.iban.as_deref(), - CachedAccount::Firefly(acc) => acc.iban.as_deref(), - } - } - - fn display_name(&self) -> Option { - match self { - CachedAccount::GoCardless(acc) => acc.display_name.clone() - .or_else(|| acc.name.clone()) - .or_else(|| acc.owner_name.as_ref().map(|owner| format!("{} Account", owner))) - .or_else(|| acc.iban.as_ref().map(|iban| { - if iban.len() > 4 { - format!("{}****{}", &iban[..4], &iban[iban.len()-4..]) - } else { - iban.to_string() - } - })), - CachedAccount::Firefly(acc) => Some(acc.name.clone()), - } - } -} - -impl AccountData for GoCardlessAccount { - fn id(&self) -> &str { - &self.id - } - - fn iban(&self) -> Option<&str> { - self.iban.as_deref() - } - - fn display_name(&self) -> Option { - // Priority: display_name > name > owner_name > masked IBAN - self.display_name.clone() - .or_else(|| self.name.clone()) - .or_else(|| self.owner_name.as_ref().map(|owner| format!("{} Account", owner))) - .or_else(|| self.iban.as_ref().map(|iban| { - if iban.len() > 4 { - format!("{}****{}", &iban[..4], &iban[iban.len()-4..]) - } else { - iban.to_string() - } - })) - } -} - -impl AccountData for FireflyAccount { - fn id(&self) -> &str { - &self.id - } - - fn iban(&self) -> Option<&str> { - self.iban.as_deref() - } - - fn display_name(&self) -> Option { - Some(self.name.clone()) - } -} - -#[derive(Debug, Serialize, Deserialize, Default)] -pub struct AccountCache { - /// Map of Account ID -> Full Account Data - pub accounts: HashMap, -} - -impl AccountCache { - fn get_path() -> String { - let cache_dir = - std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); - format!("{}/accounts.enc", cache_dir) - } - - pub fn load() -> Self { - let path = Self::get_path(); - if Path::new(&path).exists() { - match fs::read(&path) { - Ok(encrypted_data) => match Encryption::decrypt(&encrypted_data) { - Ok(json_data) => match serde_json::from_slice(&json_data) { - Ok(cache) => return cache, - Err(e) => warn!("Failed to parse cache file: {}", e), - }, - Err(e) => warn!("Failed to decrypt cache file: {}", e), - }, - Err(e) => warn!("Failed to read cache file: {}", e), - } - } - Self::default() - } - - pub fn save(&self) { - let path = Self::get_path(); - - if let Some(parent) = std::path::Path::new(&path).parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - warn!( - "Failed to create cache folder '{}': {}", - parent.display(), - e - ); - } - } - - match serde_json::to_vec(self) { - Ok(json_data) => match Encryption::encrypt(&json_data) { - Ok(encrypted_data) => { - if let Err(e) = fs::write(&path, encrypted_data) { - warn!("Failed to write cache file: {}", e); - } - } - Err(e) => warn!("Failed to encrypt cache: {}", e), - }, - Err(e) => warn!("Failed to serialize cache: {}", e), - } - } - - pub fn get_account(&self, account_id: &str) -> Option<&CachedAccount> { - self.accounts.get(account_id) - } - - pub fn get_account_data(&self, account_id: &str) -> Option<&dyn AccountData> { - match self.accounts.get(account_id)? { - CachedAccount::GoCardless(acc) => Some(acc as &dyn AccountData), - CachedAccount::Firefly(acc) => Some(acc as &dyn AccountData), - } - } - - pub fn get_display_name(&self, account_id: &str) -> Option { - self.get_account_data(account_id)?.display_name() - } - - 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/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs index 1c54516..70c9c17 100644 --- a/banks2ff/src/adapters/gocardless/client.rs +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -1,4 +1,4 @@ -use crate::adapters::gocardless::cache::{AccountCache, CachedAccount, GoCardlessAccount}; +use crate::core::cache::{AccountCache, CachedAccount, GoCardlessAccount}; use crate::adapters::gocardless::mapper::map_transaction; use crate::adapters::gocardless::transaction_cache::AccountTransactionCache; use crate::core::models::{ @@ -100,13 +100,25 @@ impl TransactionSource for GoCardlessAdapter { created: basic_account.created, last_accessed: basic_account.last_accessed, // Include details if available - name: details_result.as_ref().ok().and_then(|d| d.account.name.clone()), - display_name: details_result.as_ref().ok().and_then(|d| d.account.display_name.clone()), - product: details_result.as_ref().ok().and_then(|d| d.account.product.clone()), - cash_account_type: details_result.as_ref().ok().and_then(|d| d.account.cash_account_type.clone()), + name: details_result + .as_ref() + .ok() + .and_then(|d| d.account.name.clone()), + display_name: details_result + .as_ref() + .ok() + .and_then(|d| d.account.display_name.clone()), + product: details_result + .as_ref() + .ok() + .and_then(|d| d.account.product.clone()), + cash_account_type: details_result + .as_ref() + .ok() + .and_then(|d| d.account.cash_account_type.clone()), }; - cache.insert(CachedAccount::GoCardless(gc_account)); + cache.insert(CachedAccount::GoCardless(Box::new(gc_account))); cache.save(); } Err(e) => { @@ -118,7 +130,8 @@ impl TransactionSource for GoCardlessAdapter { } } - let iban = cache.get_account_data(&acc_id) + let iban = cache + .get_account_data(&acc_id) .and_then(|acc| acc.iban()) .unwrap_or("") .to_string(); @@ -134,11 +147,10 @@ impl TransactionSource for GoCardlessAdapter { if keep { // Try to get account name from cache if available - let name = cache.get_account(&acc_id) - .and_then(|acc| match acc { - CachedAccount::GoCardless(gc_acc) => gc_acc.name.clone(), - _ => None, - }); + let name = cache.get_account(&acc_id).and_then(|acc| match acc { + CachedAccount::GoCardless(gc_acc) => gc_acc.name.clone(), + _ => None, + }); accounts.push(Account { id: acc_id, diff --git a/banks2ff/src/adapters/gocardless/mod.rs b/banks2ff/src/adapters/gocardless/mod.rs index 8569488..6835f80 100644 --- a/banks2ff/src/adapters/gocardless/mod.rs +++ b/banks2ff/src/adapters/gocardless/mod.rs @@ -1,5 +1,3 @@ -pub mod cache; pub mod client; -pub mod encryption; pub mod mapper; pub mod transaction_cache; diff --git a/banks2ff/src/adapters/gocardless/transaction_cache.rs b/banks2ff/src/adapters/gocardless/transaction_cache.rs index 6bafd4a..59c5ac4 100644 --- a/banks2ff/src/adapters/gocardless/transaction_cache.rs +++ b/banks2ff/src/adapters/gocardless/transaction_cache.rs @@ -1,4 +1,4 @@ -use crate::adapters::gocardless::encryption::Encryption; +use crate::core::encryption::Encryption; use anyhow::Result; use chrono::{Days, NaiveDate}; use gocardless_client::models::Transaction; diff --git a/banks2ff/src/cli/setup.rs b/banks2ff/src/cli/setup.rs index f46ed36..5e79695 100644 --- a/banks2ff/src/cli/setup.rs +++ b/banks2ff/src/cli/setup.rs @@ -1,5 +1,5 @@ use crate::adapters::firefly::client::FireflyAdapter; -use crate::adapters::gocardless::cache::AccountCache; + use crate::adapters::gocardless::client::GoCardlessAdapter; use crate::debug::DebugLogger; use anyhow::Result; @@ -11,7 +11,6 @@ use std::env; pub struct AppContext { pub source: GoCardlessAdapter, pub destination: FireflyAdapter, - pub account_cache: AccountCache, } impl AppContext { @@ -51,7 +50,6 @@ impl AppContext { Ok(Self { source, destination, - account_cache: AccountCache::default(), }) } } diff --git a/banks2ff/src/core/cache.rs b/banks2ff/src/core/cache.rs new file mode 100644 index 0000000..9286f38 --- /dev/null +++ b/banks2ff/src/core/cache.rs @@ -0,0 +1,245 @@ +use crate::core::encryption::Encryption; +use crate::core::models::AccountData; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use tracing::warn; + +#[derive(Debug, Serialize, Deserialize)] +pub enum CachedAccount { + GoCardless(Box), + Firefly(Box), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GoCardlessAccount { + pub id: String, + pub iban: Option, + pub name: Option, // From AccountDetail.name + pub display_name: Option, // From AccountDetail.displayName + pub owner_name: Option, // From Account.owner_name + pub status: Option, // From Account.status + pub institution_id: Option, // From Account.institution_id + pub created: Option, // From Account.created + pub last_accessed: Option, // From Account.last_accessed + pub product: Option, // From AccountDetail.product + pub cash_account_type: Option, // From AccountDetail.cashAccountType +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FireflyAccount { + pub id: String, + pub name: String, // From Account.name + pub account_type: String, // From Account.type + pub iban: Option, // From Account.iban + pub active: Option, // From Account.active + pub order: Option, // From Account.order + pub created_at: Option, // From Account.created_at + pub account_role: Option, // From Account.account_role + pub object_group_id: Option, // From Account.object_group_id + pub object_group_title: Option, // From Account.object_group_title + pub object_group_order: Option, // From Account.object_group_order + pub currency_id: Option, // From Account.currency_id + pub currency_name: Option, // From Account.currency_name + pub currency_code: Option, // From Account.currency_code + pub currency_symbol: Option, // From Account.currency_symbol + pub currency_decimal_places: Option, // From Account.currency_decimal_places + pub primary_currency_id: Option, // From Account.primary_currency_id + pub primary_currency_name: Option, // From Account.primary_currency_name + pub primary_currency_code: Option, // From Account.primary_currency_code + pub primary_currency_symbol: Option, // From Account.primary_currency_symbol + pub primary_currency_decimal_places: Option, // From Account.primary_currency_decimal_places + pub opening_balance: Option, // From Account.opening_balance + pub pc_opening_balance: Option, // From Account.pc_opening_balance + pub debt_amount: Option, // From Account.debt_amount + pub pc_debt_amount: Option, // From Account.pc_debt_amount + pub notes: Option, // From Account.notes + pub monthly_payment_date: Option, // From Account.monthly_payment_date + pub credit_card_type: Option, // From Account.credit_card_type + pub account_number: Option, // From Account.account_number + pub bic: Option, // From Account.bic + pub opening_balance_date: Option, // From Account.opening_balance_date + pub liability_type: Option, // From Account.liability_type + pub liability_direction: Option, // From Account.liability_direction + pub interest: Option, // From Account.interest + pub interest_period: Option, // From Account.interest_period + pub include_net_worth: Option, // From Account.include_net_worth + pub longitude: Option, // From Account.longitude + pub latitude: Option, // From Account.latitude + pub zoom_level: Option, // From Account.zoom_level + pub last_activity: Option, // From Account.last_activity +} + +impl crate::core::models::AccountData for CachedAccount { + fn id(&self) -> &str { + match self { + CachedAccount::GoCardless(acc) => &acc.id, + CachedAccount::Firefly(acc) => &acc.id, + } + } + + fn iban(&self) -> Option<&str> { + match self { + CachedAccount::GoCardless(acc) => acc.iban.as_deref(), + CachedAccount::Firefly(acc) => acc.iban.as_deref(), + } + } + + fn display_name(&self) -> Option { + match self { + CachedAccount::GoCardless(acc) => acc + .display_name + .clone() + .or_else(|| acc.name.clone()) + .or_else(|| { + acc.owner_name + .as_ref() + .map(|owner| format!("{} Account", owner)) + }) + .or_else(|| { + acc.iban.as_ref().map(|iban| { + if iban.len() > 4 { + format!("{}****{}", &iban[..4], &iban[iban.len() - 4..]) + } else { + iban.to_string() + } + }) + }), + CachedAccount::Firefly(acc) => Some(acc.name.clone()), + } + } +} + +impl AccountData for GoCardlessAccount { + fn id(&self) -> &str { + &self.id + } + + fn iban(&self) -> Option<&str> { + self.iban.as_deref() + } + + fn display_name(&self) -> Option { + // Priority: display_name > name > owner_name > masked IBAN + self.display_name + .clone() + .or_else(|| self.name.clone()) + .or_else(|| { + self.owner_name + .as_ref() + .map(|owner| format!("{} Account", owner)) + }) + .or_else(|| { + self.iban.as_ref().map(|iban| { + if iban.len() > 4 { + format!("{}****{}", &iban[..4], &iban[iban.len() - 4..]) + } else { + iban.to_string() + } + }) + }) + } +} + +impl AccountData for FireflyAccount { + fn id(&self) -> &str { + &self.id + } + + fn iban(&self) -> Option<&str> { + self.iban.as_deref() + } + + fn display_name(&self) -> Option { + // Priority: name > iban > None (will fallback to "Account ") + if !self.name.is_empty() { + Some(self.name.clone()) + } else { + self.iban.as_ref().map(|iban| { + if iban.len() > 4 { + format!("{}****{}", &iban[..4], &iban[iban.len() - 4..]) + } else { + iban.to_string() + } + }) + } + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct AccountCache { + /// Map of Account ID -> Full Account Data + pub accounts: HashMap, +} + +impl AccountCache { + fn get_path() -> String { + let cache_dir = + std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); + format!("{}/accounts.enc", cache_dir) + } + + pub fn load() -> Self { + let path = Self::get_path(); + if Path::new(&path).exists() { + match fs::read(&path) { + Ok(encrypted_data) => match Encryption::decrypt(&encrypted_data) { + Ok(json_data) => match serde_json::from_slice(&json_data) { + Ok(cache) => return cache, + Err(e) => warn!("Failed to parse cache file: {}", e), + }, + Err(e) => warn!("Failed to decrypt cache file: {}", e), + }, + Err(e) => warn!("Failed to read cache file: {}", e), + } + } + Self::default() + } + + pub fn save(&self) { + let path = Self::get_path(); + + if let Some(parent) = std::path::Path::new(&path).parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + warn!( + "Failed to create cache folder '{}': {}", + parent.display(), + e + ); + } + } + + match serde_json::to_vec(self) { + Ok(json_data) => match Encryption::encrypt(&json_data) { + Ok(encrypted_data) => { + if let Err(e) = fs::write(&path, encrypted_data) { + warn!("Failed to write cache file: {}", e); + } + } + Err(e) => warn!("Failed to encrypt cache: {}", e), + }, + Err(e) => warn!("Failed to serialize cache: {}", e), + } + } + + pub fn get_account(&self, account_id: &str) -> Option<&CachedAccount> { + self.accounts.get(account_id) + } + + pub fn get_account_data(&self, account_id: &str) -> Option<&dyn AccountData> { + match self.accounts.get(account_id)? { + CachedAccount::GoCardless(acc) => Some(acc.as_ref() as &dyn AccountData), + CachedAccount::Firefly(acc) => Some(acc.as_ref() as &dyn AccountData), + } + } + + pub fn get_display_name(&self, account_id: &str) -> Option { + self.get_account_data(account_id)?.display_name() + } + + pub fn insert(&mut self, account: CachedAccount) { + let account_id = account.id().to_string(); + self.accounts.insert(account_id, account); + } +} \ No newline at end of file diff --git a/banks2ff/src/adapters/gocardless/encryption.rs b/banks2ff/src/core/encryption.rs similarity index 99% rename from banks2ff/src/adapters/gocardless/encryption.rs rename to banks2ff/src/core/encryption.rs index 8050c2a..ca795bb 100644 --- a/banks2ff/src/adapters/gocardless/encryption.rs +++ b/banks2ff/src/core/encryption.rs @@ -172,4 +172,4 @@ mod tests { let decrypted = Encryption::decrypt(&encrypted).unwrap(); assert_eq!(data.to_vec(), decrypted); } -} +} \ No newline at end of file diff --git a/banks2ff/src/core/linking.rs b/banks2ff/src/core/linking.rs index d3a847d..fd23c1d 100644 --- a/banks2ff/src/core/linking.rs +++ b/banks2ff/src/core/linking.rs @@ -93,8 +93,6 @@ impl LinkStore { 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 auto_link_accounts( @@ -104,7 +102,9 @@ pub fn auto_link_accounts( 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.as_ref().map(|s| !s.is_empty()).unwrap_or(false) { + if source.iban == dest.iban + && source.iban.as_ref().map(|s| !s.is_empty()).unwrap_or(false) + { links.push((i, j)); break; // First match } diff --git a/banks2ff/src/core/mod.rs b/banks2ff/src/core/mod.rs index dfa3277..4129e75 100644 --- a/banks2ff/src/core/mod.rs +++ b/banks2ff/src/core/mod.rs @@ -1,4 +1,6 @@ pub mod adapters; +pub mod cache; +pub mod encryption; pub mod linking; pub mod models; pub mod ports; diff --git a/banks2ff/src/core/models.rs b/banks2ff/src/core/models.rs index 62d2354..1163207 100644 --- a/banks2ff/src/core/models.rs +++ b/banks2ff/src/core/models.rs @@ -54,8 +54,8 @@ impl fmt::Debug for BankTransaction { #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Account { pub id: String, - pub name: Option, // Account display name - pub iban: Option, // IBAN may not be available for all accounts + pub name: Option, // Account display name + pub iban: Option, // IBAN may not be available for all accounts pub currency: String, } diff --git a/banks2ff/src/core/ports.rs b/banks2ff/src/core/ports.rs index f320871..6abfa74 100644 --- a/banks2ff/src/core/ports.rs +++ b/banks2ff/src/core/ports.rs @@ -83,9 +83,6 @@ pub struct TransactionMatch { #[cfg_attr(test, automock)] #[async_trait] pub trait TransactionDestination: Send + Sync { - /// Get list of all active asset account IBANs to drive the sync - async fn get_active_account_ibans(&self) -> Result>; - // New granular methods for Healer Logic async fn get_last_transaction_date(&self, account_id: &str) -> Result>; async fn find_transaction( @@ -103,10 +100,6 @@ pub trait TransactionDestination: Send + Sync { // Blanket implementation for references #[async_trait] impl TransactionDestination for &T { - async fn get_active_account_ibans(&self) -> Result> { - (**self).get_active_account_ibans().await - } - async fn get_last_transaction_date(&self, account_id: &str) -> Result> { (**self).get_last_transaction_date(account_id).await } diff --git a/banks2ff/src/core/sync.rs b/banks2ff/src/core/sync.rs index 162a798..e0a3449 100644 --- a/banks2ff/src/core/sync.rs +++ b/banks2ff/src/core/sync.rs @@ -1,4 +1,3 @@ -use crate::adapters::gocardless::cache::AccountCache; use crate::core::linking::{auto_link_accounts, LinkStore}; use crate::core::models::{Account, SyncError}; use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource}; @@ -14,34 +13,17 @@ pub struct SyncResult { pub accounts_skipped_errors: usize, } -#[instrument(skip(source, destination, _account_cache))] +#[instrument(skip(source, destination))] pub async fn run_sync( source: impl TransactionSource, destination: impl TransactionDestination, - _account_cache: &AccountCache, cli_start_date: Option, cli_end_date: Option, dry_run: bool, ) -> Result { info!("Starting synchronization..."); - // Optimization: Get active Firefly IBANs first - let wanted_ibans = destination - .get_active_account_ibans() - .await - .map_err(SyncError::DestinationError)?; - info!( - "Syncing {} active accounts from Firefly III", - wanted_ibans.len() - ); - - let accounts = source - .get_accounts(Some(wanted_ibans)) - .await - .map_err(SyncError::SourceError)?; - info!("Found {} accounts from source", accounts.len()); - - // Discover all accounts and update linking + // Discover all accounts from both source and destination let all_source_accounts = source .discover_accounts() .await @@ -50,10 +32,17 @@ pub async fn run_sync( .discover_accounts() .await .map_err(SyncError::DestinationError)?; + info!( + "Discovered {} source accounts and {} destination accounts", + all_source_accounts.len(), + all_dest_accounts.len() + ); + + // Accounts are cached by their respective adapters during discover_accounts let mut link_store = LinkStore::load(); - // Auto-link accounts + // Auto-link accounts based on IBAN 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]; @@ -66,13 +55,25 @@ pub async fn run_sync( } link_store.save().map_err(SyncError::SourceError)?; + // Get all matched accounts (those with existing links) + let mut accounts_to_sync = Vec::new(); + for source_account in &all_source_accounts { + if link_store.find_link_by_source(&source_account.id).is_some() { + accounts_to_sync.push(source_account.clone()); + } + } + info!( + "Found {} accounts with existing links to sync", + accounts_to_sync.len() + ); + // Default end date is Yesterday let end_date = cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1)); let mut result = SyncResult::default(); - for account in accounts { + for account in accounts_to_sync { let span = tracing::info_span!("sync_account", account_id = %account.id); let _enter = span.enter(); @@ -321,9 +322,6 @@ mod tests { .returning(move |_, _, _| Ok(vec![tx.clone()])); // Destination setup - 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(), @@ -348,7 +346,7 @@ mod tests { .returning(|_, _| Ok(())); // Execution - let res = run_sync(&source, &dest, &AccountCache::default(), None, None, false).await; + let res = run_sync(&source, &dest, None, None, false).await; assert!(res.is_ok()); } @@ -357,9 +355,6 @@ mod tests { let mut source = MockTransactionSource::new(); let mut dest = MockTransactionDestination::new(); - 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(), @@ -418,7 +413,7 @@ mod tests { .times(1) .returning(|_, _| Ok(())); - let res = run_sync(&source, &dest, &AccountCache::default(), None, None, false).await; + let res = run_sync(&source, &dest, None, None, false).await; assert!(res.is_ok()); } @@ -427,9 +422,6 @@ mod tests { let mut source = MockTransactionSource::new(); let mut dest = MockTransactionDestination::new(); - 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(), @@ -483,7 +475,7 @@ mod tests { dest.expect_create_transaction().never(); dest.expect_update_transaction_external_id().never(); - let res = run_sync(source, dest, &AccountCache::default(), None, None, true).await; + let res = run_sync(source, dest, None, None, true).await; assert!(res.is_ok()); } } diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs index 586f62a..697cd94 100644 --- a/banks2ff/src/main.rs +++ b/banks2ff/src/main.rs @@ -225,7 +225,7 @@ async fn handle_sync( let context = AppContext::new(debug).await?; // Run sync - match run_sync(context.source, context.destination, &context.account_cache, start, end, dry_run).await { + match run_sync(context.source, context.destination, start, end, dry_run).await { Ok(result) => { info!("Sync completed successfully."); info!( @@ -325,7 +325,7 @@ async fn handle_transactions(subcommand: TransactionCommands) -> anyhow::Result< async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> { let mut link_store = LinkStore::load(); - let account_cache = crate::adapters::gocardless::cache::AccountCache::load(); + let account_cache = crate::core::cache::AccountCache::load(); match subcommand { LinkCommands::List => { @@ -377,20 +377,27 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> { 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) + 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) + 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) + 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) + 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); + println!( + "Link between {} and {} already exists", + src_display, dst_display + ); } } else { println!("Account not found. Ensure accounts are discovered via sync first.");