feat: Implement IBAN-based account linking for Firefly III transactions

This enhancement improves transaction synchronization by
automatically linking counterparty IBANs from GoCardless to
existing Firefly III accounts, ensuring more accurate
reconciliation and better data integrity. Users benefit from
reduced manual effort in matching transactions, fewer duplicate
payees, and cleaner financial records that reflect real-world
banking relationships. The implementation caches all account
types during discovery, adds IBAN lookup logic with fallback to
payee creation, and filters CLI account lists to focus on
user-managed asset and liability accounts while maintaining
full backward compatibility.
This commit is contained in:
2025-12-07 23:42:56 +01:00
parent 82197d414d
commit d9a3ea4e94
2 changed files with 117 additions and 84 deletions

View File

@@ -1,3 +1,4 @@
use crate::core::cache::{AccountCache, CachedAccount};
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::models::{Account, AccountSummary, BankTransaction}; use crate::core::models::{Account, AccountSummary, BankTransaction};
use crate::core::ports::{TransactionDestination, TransactionMatch}; use crate::core::ports::{TransactionDestination, TransactionMatch};
@@ -114,31 +115,44 @@ impl TransactionDestination for FireflyAdapter {
let is_credit = tx.amount.is_sign_positive(); let is_credit = tx.amount.is_sign_positive();
let transaction_type = if is_credit { "deposit" } else { "withdrawal" }; let transaction_type = if is_credit { "deposit" } else { "withdrawal" };
// Determine source and destination based on IBAN linking
let (source_id, source_name, destination_id, destination_name) = if is_credit {
// Deposit: money coming in, source is counterparty, destination is user's account
let destination_id = Some(account_id.to_string());
let (source_id, source_name) = if let Some(iban) = &tx.counterparty_iban {
if let Some(acc_id) = self.find_account_by_iban(iban) {
(Some(acc_id), None)
} else {
(None, tx.counterparty_name.clone())
}
} else {
(None, tx.counterparty_name.clone())
};
(source_id, source_name, destination_id, None)
} else {
// Withdrawal: money going out, source is user's account, destination is counterparty
let source_id = Some(account_id.to_string());
let (destination_id, destination_name) = if let Some(iban) = &tx.counterparty_iban {
if let Some(acc_id) = self.find_account_by_iban(iban) {
(Some(acc_id), None)
} else {
(None, tx.counterparty_name.clone())
}
} else {
(None, tx.counterparty_name.clone())
};
(source_id, None, destination_id, destination_name)
};
let split = TransactionSplitStore { let split = TransactionSplitStore {
transaction_type: transaction_type.to_string(), transaction_type: transaction_type.to_string(),
date: tx.date.format("%Y-%m-%d").to_string(), date: tx.date.format("%Y-%m-%d").to_string(),
amount: tx.amount.abs().to_string(), amount: tx.amount.abs().to_string(),
description: tx.description.clone(), description: tx.description.clone(),
source_id: if !is_credit { source_id,
Some(account_id.to_string()) source_name,
} else { destination_id,
None destination_name,
},
source_name: if is_credit {
tx.counterparty_name.clone()
} else {
None
},
destination_id: if is_credit {
Some(account_id.to_string())
} else {
None
},
destination_name: if !is_credit {
tx.counterparty_name.clone()
} else {
None
},
currency_code: Some(tx.currency.clone()), currency_code: Some(tx.currency.clone()),
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()), foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
foreign_currency_code: tx.foreign_currency.clone(), foreign_currency_code: tx.foreign_currency.clone(),
@@ -172,7 +186,7 @@ impl TransactionDestination for FireflyAdapter {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn discover_accounts(&self) -> Result<Vec<Account>> { async fn discover_accounts(&self) -> Result<Vec<Account>> {
let client = self.client.lock().await; let client = self.client.lock().await;
let accounts = client.get_accounts("").await?; let accounts = client.get_accounts().await?;
let mut result = Vec::new(); let mut result = Vec::new();
// Cache the accounts // Cache the accounts
@@ -181,9 +195,7 @@ impl TransactionDestination for FireflyAdapter {
crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption); crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption);
for acc in accounts.data { for acc in accounts.data {
let is_active = acc.attributes.active.unwrap_or(true); // Cache all accounts, regardless of active status
if is_active {
// Cache the full account details
let ff_account = crate::core::cache::FireflyAccount { let ff_account = crate::core::cache::FireflyAccount {
id: acc.id.clone(), id: acc.id.clone(),
name: acc.attributes.name.clone(), name: acc.attributes.name.clone(),
@@ -231,6 +243,9 @@ impl TransactionDestination for FireflyAdapter {
))); )));
cache.save(); cache.save();
// Only return active asset accounts for linking (existing behavior)
let is_active = acc.attributes.active.unwrap_or(true);
if is_active && acc.attributes.account_type == "asset" {
result.push(Account { result.push(Account {
id: acc.id, id: acc.id,
name: Some(acc.attributes.name), name: Some(acc.attributes.name),
@@ -250,9 +265,10 @@ impl TransactionDestination for FireflyAdapter {
let mut summaries = Vec::new(); let mut summaries = Vec::new();
// Use cached account data for display // Use cached account data for display, filter to show only asset and liability accounts
for (account_id, cached_account) in &cache.accounts { for (account_id, cached_account) in &cache.accounts {
if let crate::core::cache::CachedAccount::Firefly(ff_account) = cached_account { if let crate::core::cache::CachedAccount::Firefly(ff_account) = cached_account {
if ff_account.account_type == "asset" || ff_account.account_type == "liability" {
let summary = AccountSummary { let summary = AccountSummary {
id: account_id.clone(), id: account_id.clone(),
name: Some(ff_account.name.clone()), name: Some(ff_account.name.clone()),
@@ -265,7 +281,25 @@ impl TransactionDestination for FireflyAdapter {
summaries.push(summary); summaries.push(summary);
} }
} }
}
Ok(summaries) Ok(summaries)
} }
} }
impl FireflyAdapter {
fn find_account_by_iban(&self, iban: &str) -> Option<String> {
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
let cache = AccountCache::load(self.config.cache.directory.clone(), encryption);
for cached_account in cache.accounts.values() {
if let CachedAccount::Firefly(ff_account) = cached_account {
if ff_account.iban.as_ref() == Some(&iban.to_string())
&& ff_account.active.unwrap_or(true)
{
return Some(ff_account.id.clone());
}
}
}
None
}
}

View File

@@ -43,9 +43,8 @@ impl FireflyClient {
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> { pub async fn get_accounts(&self) -> Result<AccountArray, FireflyError> {
let mut url = self.base_url.join("/api/v1/accounts")?; let url = self.base_url.join("/api/v1/accounts")?;
url.query_pairs_mut().append_pair("type", "asset");
self.get_authenticated(url).await self.get_authenticated(url).await
} }