feat: implement account linking and management system

Add comprehensive account linking functionality to automatically match bank accounts to Firefly III accounts, with manual override options. This includes:

- New LinkStore module for persistent storage of account links with auto-linking based on IBAN matching
- Extended adapter traits with inspection methods (list_accounts, get_account_status, etc.) and discover_accounts for account discovery
- Integration of linking into sync logic to automatically discover and link accounts before syncing transactions
- CLI commands for managing account links (list, create, etc.)
- Updated README with new features and usage examples

This enables users to easily manage account mappings between sources and destinations, reducing manual configuration and improving sync reliability.
This commit is contained in:
2025-11-22 18:29:18 +00:00
parent 2824c7448c
commit 1c566071ba
10 changed files with 819 additions and 88 deletions

View File

@@ -1,4 +1,6 @@
use crate::core::models::BankTransaction;
use crate::core::models::{
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
};
use crate::core::ports::{TransactionDestination, TransactionMatch};
use anyhow::Result;
use async_trait::async_trait;
@@ -218,4 +220,127 @@ impl TransactionDestination for FireflyAdapter {
.await
.map_err(|e| e.into())
}
#[instrument(skip(self))]
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
let client = self.client.lock().await;
let accounts = client.get_accounts("").await?;
let mut summaries = 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 {
summaries.push(AccountSummary {
id: acc.id,
iban,
currency: "EUR".to_string(), // Default to EUR
status: "active".to_string(),
});
}
}
}
Ok(summaries)
}
#[instrument(skip(self))]
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
let client = self.client.lock().await;
let accounts = client.get_accounts("").await?;
let mut statuses = 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 {
let last_sync_date = self.get_last_transaction_date(&acc.id).await?;
let transaction_count = client
.list_account_transactions(&acc.id, None, None)
.await?
.data
.len();
statuses.push(AccountStatus {
account_id: acc.id,
iban,
last_sync_date,
transaction_count,
status: "active".to_string(),
});
}
}
}
Ok(statuses)
}
#[instrument(skip(self))]
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
let client = self.client.lock().await;
let tx_list = client
.list_account_transactions(account_id, None, None)
.await?;
let total_count = tx_list.data.len();
let date_range = if tx_list.data.is_empty() {
None
} else {
let dates: Vec<NaiveDate> = tx_list
.data
.iter()
.filter_map(|tx| {
tx.attributes.transactions.first().and_then(|split| {
split
.date
.split('T')
.next()
.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
})
})
.collect();
if dates.is_empty() {
None
} else {
let min_date = dates.iter().min().cloned();
let max_date = dates.iter().max().cloned();
min_date.and_then(|min| max_date.map(|max| (min, max)))
}
};
let last_updated = date_range.map(|(_, max)| max);
Ok(TransactionInfo {
account_id: account_id.to_string(),
total_count,
date_range,
last_updated,
})
}
#[instrument(skip(self))]
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
// Firefly doesn't have local cache, so return empty
Ok(Vec::new())
}
#[instrument(skip(self))]
async fn discover_accounts(&self) -> Result<Vec<Account>> {
let client = self.client.lock().await;
let accounts = client.get_accounts("").await?;
let mut result = Vec::new();
for acc in accounts.data {
let is_active = acc.attributes.active.unwrap_or(true);
if is_active {
result.push(Account {
id: acc.id,
iban: acc.attributes.iban.unwrap_or_default(),
currency: "EUR".to_string(),
});
}
}
Ok(result)
}
}