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.
347 lines
12 KiB
Rust
347 lines
12 KiB
Rust
use crate::core::models::{
|
|
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
|
};
|
|
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
|
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use chrono::NaiveDate;
|
|
use firefly_client::client::FireflyClient;
|
|
use firefly_client::models::{
|
|
TransactionSplitStore, TransactionSplitUpdate, TransactionStore, TransactionUpdate,
|
|
};
|
|
use rust_decimal::Decimal;
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
use tracing::instrument;
|
|
|
|
pub struct FireflyAdapter {
|
|
client: Arc<Mutex<FireflyClient>>,
|
|
}
|
|
|
|
impl FireflyAdapter {
|
|
pub fn new(client: FireflyClient) -> Self {
|
|
Self {
|
|
client: Arc::new(Mutex::new(client)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TransactionDestination for FireflyAdapter {
|
|
#[instrument(skip(self))]
|
|
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
|
|
let client = self.client.lock().await;
|
|
let accounts = client.search_accounts(iban).await?;
|
|
|
|
// Look for exact match on IBAN, ensuring account is active
|
|
for acc in accounts.data {
|
|
// Filter for active accounts only (default is usually active, but let's check if attribute exists)
|
|
// Note: The Firefly API spec v6.4.4 Account object has 'active' attribute as boolean.
|
|
let is_active = acc.attributes.active.unwrap_or(true);
|
|
|
|
if !is_active {
|
|
continue;
|
|
}
|
|
|
|
if let Some(acc_iban) = acc.attributes.iban {
|
|
if acc_iban.replace(" ", "") == iban.replace(" ", "") {
|
|
return Ok(Some(acc.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
|
let client = self.client.lock().await;
|
|
// Get all asset accounts. Note: Pagination might be needed if user has > 50 accounts.
|
|
// For typical users, 50 is enough. If needed we can loop pages.
|
|
// The client `get_accounts` method hardcodes limit=default. We should probably expose a list_all method or loop here.
|
|
// For now, let's assume page 1 covers it or use search.
|
|
|
|
let accounts = client.get_accounts("").await?; // Argument ignored in current impl
|
|
let mut ibans = 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 {
|
|
if !iban.is_empty() {
|
|
ibans.push(iban);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(ibans)
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
|
let client = self.client.lock().await;
|
|
// Fetch latest 1 transaction
|
|
let tx_list = client
|
|
.list_account_transactions(account_id, None, None)
|
|
.await?;
|
|
|
|
if let Some(first) = tx_list.data.first() {
|
|
if let Some(split) = first.attributes.transactions.first() {
|
|
// Format is usually YYYY-MM-DDT... or YYYY-MM-DD
|
|
let date_str = split.date.split('T').next().unwrap_or(&split.date);
|
|
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
|
return Ok(Some(date));
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
async fn find_transaction(
|
|
&self,
|
|
account_id: &str,
|
|
tx: &BankTransaction,
|
|
) -> Result<Option<TransactionMatch>> {
|
|
let client = self.client.lock().await;
|
|
|
|
// Search window: +/- 3 days
|
|
let start_date = tx.date - chrono::Duration::days(3);
|
|
let end_date = tx.date + chrono::Duration::days(3);
|
|
|
|
let tx_list = client
|
|
.list_account_transactions(
|
|
account_id,
|
|
Some(&start_date.format("%Y-%m-%d").to_string()),
|
|
Some(&end_date.format("%Y-%m-%d").to_string()),
|
|
)
|
|
.await?;
|
|
|
|
// Filter logic
|
|
for existing_tx in tx_list.data {
|
|
for split in existing_tx.attributes.transactions {
|
|
// 1. Check Amount (exact match absolute value)
|
|
if let Ok(amount) = Decimal::from_str(&split.amount) {
|
|
if amount.abs() == tx.amount.abs() {
|
|
// 2. Check External ID
|
|
if let Some(ref ext_id) = split.external_id {
|
|
if ext_id == &tx.internal_id {
|
|
return Ok(Some(TransactionMatch {
|
|
id: existing_tx.id.clone(),
|
|
has_external_id: true,
|
|
}));
|
|
}
|
|
} else {
|
|
// 3. "Naked" transaction match (Heuristic)
|
|
// If currency matches
|
|
if let Some(ref code) = split.currency_code {
|
|
if code != &tx.currency {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return Ok(Some(TransactionMatch {
|
|
id: existing_tx.id.clone(),
|
|
has_external_id: false,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()> {
|
|
let client = self.client.lock().await;
|
|
|
|
// Map to Firefly Transaction
|
|
let is_credit = tx.amount.is_sign_positive();
|
|
let transaction_type = if is_credit { "deposit" } else { "withdrawal" };
|
|
|
|
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()
|
|
.or(Some("Unknown Sender".to_string()))
|
|
} else {
|
|
None
|
|
},
|
|
destination_id: if is_credit {
|
|
Some(account_id.to_string())
|
|
} else {
|
|
None
|
|
},
|
|
destination_name: if !is_credit {
|
|
tx.counterparty_name
|
|
.clone()
|
|
.or(Some("Unknown Recipient".to_string()))
|
|
} else {
|
|
None
|
|
},
|
|
currency_code: Some(tx.currency.clone()),
|
|
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
|
foreign_currency_code: tx.foreign_currency.clone(),
|
|
external_id: Some(tx.internal_id.clone()),
|
|
};
|
|
|
|
let store = TransactionStore {
|
|
transactions: vec![split],
|
|
apply_rules: Some(true),
|
|
fire_webhooks: Some(true),
|
|
error_if_duplicate_hash: Some(true),
|
|
};
|
|
|
|
client.store_transaction(store).await.map_err(|e| e.into())
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()> {
|
|
let client = self.client.lock().await;
|
|
let update = TransactionUpdate {
|
|
transactions: vec![TransactionSplitUpdate {
|
|
external_id: Some(external_id.to_string()),
|
|
}],
|
|
};
|
|
client
|
|
.update_transaction(id, update)
|
|
.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)
|
|
}
|
|
}
|