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:
2025-11-28 00:03:14 +01:00
parent 53be083dc0
commit a384a9cfcd
14 changed files with 370 additions and 317 deletions

View File

@@ -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),

View File

@@ -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);
}
}

View File

@@ -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,
});

View File

@@ -1,5 +1,3 @@
pub mod cache;
pub mod client;
pub mod encryption;
pub mod mapper;
pub mod transaction_cache;

View File

@@ -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;

View File

@@ -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
View 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);
}
}

View File

@@ -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
}

View File

@@ -1,4 +1,6 @@
pub mod adapters;
pub mod cache;
pub mod encryption;
pub mod linking;
pub mod models;
pub mod ports;

View File

@@ -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
}

View File

@@ -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());
}
}

View File

@@ -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.");