From a384a9cfcd51be1762a7514dc765861e3b5de777 Mon Sep 17 00:00:00 2001 From: Jacob Kiers Date: Fri, 28 Nov 2025 00:03:14 +0100 Subject: [PATCH] feat: Improve sync algorithm Now the synchronisation works differently: 1. Discover and cache all accounts in source and destination 2. Auto-link unlinked accounts 3. Sync all linked accounts, including previously linked ones In order to cache all accounts, the accounts cache (and encryptor) are therefore moved to the core types, instead of being part of the Gocardless adapter. --- banks2ff/src/adapters/firefly/client.rs | 75 ++++-- banks2ff/src/adapters/gocardless/cache.rs | 221 ---------------- banks2ff/src/adapters/gocardless/client.rs | 36 ++- banks2ff/src/adapters/gocardless/mod.rs | 2 - .../adapters/gocardless/transaction_cache.rs | 2 +- banks2ff/src/cli/setup.rs | 4 +- banks2ff/src/core/cache.rs | 245 ++++++++++++++++++ .../gocardless => core}/encryption.rs | 2 +- banks2ff/src/core/linking.rs | 6 +- banks2ff/src/core/mod.rs | 2 + banks2ff/src/core/models.rs | 4 +- banks2ff/src/core/ports.rs | 7 - banks2ff/src/core/sync.rs | 60 ++--- banks2ff/src/main.rs | 21 +- 14 files changed, 370 insertions(+), 317 deletions(-) delete mode 100644 banks2ff/src/adapters/gocardless/cache.rs create mode 100644 banks2ff/src/core/cache.rs rename banks2ff/src/{adapters/gocardless => core}/encryption.rs (99%) 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.");