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:
@@ -1,3 +1,4 @@
|
||||
use crate::core::cache::{AccountCache, CachedAccount};
|
||||
use crate::core::config::Config;
|
||||
use crate::core::models::{Account, AccountSummary, BankTransaction};
|
||||
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
||||
@@ -114,31 +115,44 @@ impl TransactionDestination for FireflyAdapter {
|
||||
let is_credit = tx.amount.is_sign_positive();
|
||||
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 {
|
||||
transaction_type: transaction_type.to_string(),
|
||||
date: tx.date.format("%Y-%m-%d").to_string(),
|
||||
amount: tx.amount.abs().to_string(),
|
||||
description: tx.description.clone(),
|
||||
source_id: if !is_credit {
|
||||
Some(account_id.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
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
|
||||
},
|
||||
source_id,
|
||||
source_name,
|
||||
destination_id,
|
||||
destination_name,
|
||||
currency_code: Some(tx.currency.clone()),
|
||||
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
||||
foreign_currency_code: tx.foreign_currency.clone(),
|
||||
@@ -172,7 +186,7 @@ impl TransactionDestination for FireflyAdapter {
|
||||
#[instrument(skip(self))]
|
||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
||||
let client = self.client.lock().await;
|
||||
let accounts = client.get_accounts("").await?;
|
||||
let accounts = client.get_accounts().await?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
// Cache the accounts
|
||||
@@ -181,9 +195,7 @@ impl TransactionDestination for FireflyAdapter {
|
||||
crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption);
|
||||
|
||||
for acc in accounts.data {
|
||||
let is_active = acc.attributes.active.unwrap_or(true);
|
||||
if is_active {
|
||||
// Cache the full account details
|
||||
// Cache all accounts, regardless of active status
|
||||
let ff_account = crate::core::cache::FireflyAccount {
|
||||
id: acc.id.clone(),
|
||||
name: acc.attributes.name.clone(),
|
||||
@@ -231,6 +243,9 @@ impl TransactionDestination for FireflyAdapter {
|
||||
)));
|
||||
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 {
|
||||
id: acc.id,
|
||||
name: Some(acc.attributes.name),
|
||||
@@ -250,9 +265,10 @@ impl TransactionDestination for FireflyAdapter {
|
||||
|
||||
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 {
|
||||
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 {
|
||||
id: account_id.clone(),
|
||||
name: Some(ff_account.name.clone()),
|
||||
@@ -265,7 +281,25 @@ impl TransactionDestination for FireflyAdapter {
|
||||
summaries.push(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,8 @@ impl FireflyClient {
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> {
|
||||
let mut url = self.base_url.join("/api/v1/accounts")?;
|
||||
url.query_pairs_mut().append_pair("type", "asset");
|
||||
pub async fn get_accounts(&self) -> Result<AccountArray, FireflyError> {
|
||||
let url = self.base_url.join("/api/v1/accounts")?;
|
||||
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user