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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user