Files
banks2ff/banks2ff/src/adapters/firefly/client.rs
Jacob Kiers 1c566071ba 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.
2025-11-22 18:36:05 +00:00

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