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.
This commit is contained in:
@@ -27,30 +27,6 @@ impl FireflyAdapter {
|
||||
|
||||
#[async_trait]
|
||||
impl TransactionDestination for FireflyAdapter {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
||||
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<Option<NaiveDate>> {
|
||||
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),
|
||||
|
||||
@@ -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<String>,
|
||||
pub name: Option<String>, // From AccountDetail.name
|
||||
pub display_name: Option<String>, // From AccountDetail.displayName
|
||||
pub owner_name: Option<String>, // From Account.owner_name
|
||||
pub status: Option<String>, // From Account.status
|
||||
pub institution_id: Option<String>, // From Account.institution_id
|
||||
pub created: Option<String>, // From Account.created
|
||||
pub last_accessed: Option<String>, // From Account.last_accessed
|
||||
pub product: Option<String>, // From AccountDetail.product
|
||||
pub cash_account_type: Option<String>, // 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<String>, // From Account.iban
|
||||
pub active: Option<bool>, // From Account.active
|
||||
pub order: Option<i32>, // From Account.order
|
||||
pub created_at: Option<String>, // From Account.created_at
|
||||
pub account_role: Option<String>, // From Account.account_role
|
||||
pub object_group_id: Option<String>, // From Account.object_group_id
|
||||
pub object_group_title: Option<String>, // From Account.object_group_title
|
||||
pub object_group_order: Option<i32>, // From Account.object_group_order
|
||||
pub currency_id: Option<String>, // From Account.currency_id
|
||||
pub currency_name: Option<String>, // From Account.currency_name
|
||||
pub currency_code: Option<String>, // From Account.currency_code
|
||||
pub currency_symbol: Option<String>, // From Account.currency_symbol
|
||||
pub currency_decimal_places: Option<i32>, // From Account.currency_decimal_places
|
||||
pub primary_currency_id: Option<String>, // From Account.primary_currency_id
|
||||
pub primary_currency_name: Option<String>, // From Account.primary_currency_name
|
||||
pub primary_currency_code: Option<String>, // From Account.primary_currency_code
|
||||
pub primary_currency_symbol: Option<String>, // From Account.primary_currency_symbol
|
||||
pub primary_currency_decimal_places: Option<i32>, // From Account.primary_currency_decimal_places
|
||||
pub opening_balance: Option<String>, // From Account.opening_balance
|
||||
pub pc_opening_balance: Option<String>, // From Account.pc_opening_balance
|
||||
pub debt_amount: Option<String>, // From Account.debt_amount
|
||||
pub pc_debt_amount: Option<String>, // From Account.pc_debt_amount
|
||||
pub notes: Option<String>, // From Account.notes
|
||||
pub monthly_payment_date: Option<String>, // From Account.monthly_payment_date
|
||||
pub credit_card_type: Option<String>, // From Account.credit_card_type
|
||||
pub account_number: Option<String>, // From Account.account_number
|
||||
pub bic: Option<String>, // From Account.bic
|
||||
pub opening_balance_date: Option<String>, // From Account.opening_balance_date
|
||||
pub liability_type: Option<String>, // From Account.liability_type
|
||||
pub liability_direction: Option<String>, // From Account.liability_direction
|
||||
pub interest: Option<String>, // From Account.interest
|
||||
pub interest_period: Option<String>, // From Account.interest_period
|
||||
pub include_net_worth: Option<bool>, // From Account.include_net_worth
|
||||
pub longitude: Option<f64>, // From Account.longitude
|
||||
pub latitude: Option<f64>, // From Account.latitude
|
||||
pub zoom_level: Option<i32>, // From Account.zoom_level
|
||||
pub last_activity: Option<String>, // 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<String> {
|
||||
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<String> {
|
||||
// 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<String> {
|
||||
Some(self.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct AccountCache {
|
||||
/// Map of Account ID -> Full Account Data
|
||||
pub accounts: HashMap<String, CachedAccount>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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,8 +147,7 @@ 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 {
|
||||
let name = cache.get_account(&acc_id).and_then(|acc| match acc {
|
||||
CachedAccount::GoCardless(gc_acc) => gc_acc.name.clone(),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod encryption;
|
||||
pub mod mapper;
|
||||
pub mod transaction_cache;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
245
banks2ff/src/core/cache.rs
Normal file
245
banks2ff/src/core/cache.rs
Normal file
@@ -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<GoCardlessAccount>),
|
||||
Firefly(Box<FireflyAccount>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GoCardlessAccount {
|
||||
pub id: String,
|
||||
pub iban: Option<String>,
|
||||
pub name: Option<String>, // From AccountDetail.name
|
||||
pub display_name: Option<String>, // From AccountDetail.displayName
|
||||
pub owner_name: Option<String>, // From Account.owner_name
|
||||
pub status: Option<String>, // From Account.status
|
||||
pub institution_id: Option<String>, // From Account.institution_id
|
||||
pub created: Option<String>, // From Account.created
|
||||
pub last_accessed: Option<String>, // From Account.last_accessed
|
||||
pub product: Option<String>, // From AccountDetail.product
|
||||
pub cash_account_type: Option<String>, // 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<String>, // From Account.iban
|
||||
pub active: Option<bool>, // From Account.active
|
||||
pub order: Option<i32>, // From Account.order
|
||||
pub created_at: Option<String>, // From Account.created_at
|
||||
pub account_role: Option<String>, // From Account.account_role
|
||||
pub object_group_id: Option<String>, // From Account.object_group_id
|
||||
pub object_group_title: Option<String>, // From Account.object_group_title
|
||||
pub object_group_order: Option<i32>, // From Account.object_group_order
|
||||
pub currency_id: Option<String>, // From Account.currency_id
|
||||
pub currency_name: Option<String>, // From Account.currency_name
|
||||
pub currency_code: Option<String>, // From Account.currency_code
|
||||
pub currency_symbol: Option<String>, // From Account.currency_symbol
|
||||
pub currency_decimal_places: Option<i32>, // From Account.currency_decimal_places
|
||||
pub primary_currency_id: Option<String>, // From Account.primary_currency_id
|
||||
pub primary_currency_name: Option<String>, // From Account.primary_currency_name
|
||||
pub primary_currency_code: Option<String>, // From Account.primary_currency_code
|
||||
pub primary_currency_symbol: Option<String>, // From Account.primary_currency_symbol
|
||||
pub primary_currency_decimal_places: Option<i32>, // From Account.primary_currency_decimal_places
|
||||
pub opening_balance: Option<String>, // From Account.opening_balance
|
||||
pub pc_opening_balance: Option<String>, // From Account.pc_opening_balance
|
||||
pub debt_amount: Option<String>, // From Account.debt_amount
|
||||
pub pc_debt_amount: Option<String>, // From Account.pc_debt_amount
|
||||
pub notes: Option<String>, // From Account.notes
|
||||
pub monthly_payment_date: Option<String>, // From Account.monthly_payment_date
|
||||
pub credit_card_type: Option<String>, // From Account.credit_card_type
|
||||
pub account_number: Option<String>, // From Account.account_number
|
||||
pub bic: Option<String>, // From Account.bic
|
||||
pub opening_balance_date: Option<String>, // From Account.opening_balance_date
|
||||
pub liability_type: Option<String>, // From Account.liability_type
|
||||
pub liability_direction: Option<String>, // From Account.liability_direction
|
||||
pub interest: Option<String>, // From Account.interest
|
||||
pub interest_period: Option<String>, // From Account.interest_period
|
||||
pub include_net_worth: Option<bool>, // From Account.include_net_worth
|
||||
pub longitude: Option<f64>, // From Account.longitude
|
||||
pub latitude: Option<f64>, // From Account.latitude
|
||||
pub zoom_level: Option<i32>, // From Account.zoom_level
|
||||
pub last_activity: Option<String>, // 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<String> {
|
||||
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<String> {
|
||||
// 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<String> {
|
||||
// Priority: name > iban > None (will fallback to "Account <id>")
|
||||
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<String, CachedAccount>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod adapters;
|
||||
pub mod cache;
|
||||
pub mod encryption;
|
||||
pub mod linking;
|
||||
pub mod models;
|
||||
pub mod ports;
|
||||
|
||||
@@ -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<Vec<String>>;
|
||||
|
||||
// New granular methods for Healer Logic
|
||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
|
||||
async fn find_transaction(
|
||||
@@ -103,10 +100,6 @@ pub trait TransactionDestination: Send + Sync {
|
||||
// Blanket implementation for references
|
||||
#[async_trait]
|
||||
impl<T: TransactionDestination> TransactionDestination for &T {
|
||||
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
||||
(**self).get_active_account_ibans().await
|
||||
}
|
||||
|
||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
||||
(**self).get_last_transaction_date(account_id).await
|
||||
}
|
||||
|
||||
@@ -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<NaiveDate>,
|
||||
cli_end_date: Option<NaiveDate>,
|
||||
dry_run: bool,
|
||||
) -> Result<SyncResult> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user