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::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,56 +195,57 @@ 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
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();
// Cache all accounts, regardless of active status
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();
// 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,22 +265,41 @@ 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 {
let summary = AccountSummary {
id: account_id.clone(),
name: Some(ff_account.name.clone()),
iban: ff_account.iban.clone().unwrap_or_else(|| "".to_string()),
currency: ff_account
.currency_code
.clone()
.unwrap_or_else(|| "EUR".to_string()),
};
summaries.push(summary);
if ff_account.account_type == "asset" || ff_account.account_type == "liability" {
let summary = AccountSummary {
id: account_id.clone(),
name: Some(ff_account.name.clone()),
iban: ff_account.iban.clone().unwrap_or_else(|| "".to_string()),
currency: ff_account
.currency_code
.clone()
.unwrap_or_else(|| "EUR".to_string()),
};
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
}
}

View File

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