From 0ab978fa87f39125c9d8f671da9a5b22ce2fd103 Mon Sep 17 00:00:00 2001 From: Jacob Kiers Date: Fri, 28 Nov 2025 20:15:27 +0100 Subject: [PATCH] feat: Add the account status command This shows the account status from gocardless (for now), based on the sync results. --- banks2ff/src/adapters/firefly/client.rs | 11 +- banks2ff/src/adapters/gocardless/client.rs | 298 +++++++++++++++++++-- banks2ff/src/core/config.rs | 8 +- banks2ff/src/main.rs | 49 +++- 4 files changed, 327 insertions(+), 39 deletions(-) diff --git a/banks2ff/src/adapters/firefly/client.rs b/banks2ff/src/adapters/firefly/client.rs index 85bad64..8f499d0 100644 --- a/banks2ff/src/adapters/firefly/client.rs +++ b/banks2ff/src/adapters/firefly/client.rs @@ -245,10 +245,8 @@ impl TransactionDestination for FireflyAdapter { async fn list_accounts(&self) -> Result> { let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone()); - let cache = crate::core::cache::AccountCache::load( - self.config.cache.directory.clone(), - encryption, - ); + let cache = + crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption); let mut summaries = Vec::new(); @@ -259,7 +257,10 @@ impl TransactionDestination for FireflyAdapter { 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()), + currency: ff_account + .currency_code + .clone() + .unwrap_or_else(|| "EUR".to_string()), }; summaries.push(summary); } diff --git a/banks2ff/src/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs index ca697df..1fbb925 100644 --- a/banks2ff/src/adapters/gocardless/client.rs +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -304,33 +304,57 @@ impl TransactionSource for GoCardlessAdapter { #[instrument(skip(self))] async fn get_account_status(&self) -> Result> { - let caches = self.transaction_caches.lock().await; + let account_cache = self.cache.lock().await; let mut statuses = Vec::new(); - for (account_id, cache) in caches.iter() { - let iban = self - .cache - .lock() - .await - .get_account_data(account_id) - .and_then(|acc| acc.iban()) - .unwrap_or("Unknown") - .to_string(); - let transaction_count = cache.ranges.iter().map(|r| r.transactions.len()).sum(); - let last_sync_date = cache.ranges.iter().map(|r| r.end_date).max(); + // Iterate through cached GoCardless accounts + for (account_id, cached_account) in &account_cache.accounts { + if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account { + // Try to load the transaction cache for this account + let encryption = Encryption::new(self.config.cache.key.clone()); + let transaction_cache = AccountTransactionCache::load( + account_id, + self.config.cache.directory.clone(), + encryption, + ); - statuses.push(AccountStatus { - account_id: account_id.clone(), - iban, - last_sync_date, - transaction_count, - status: if transaction_count > 0 { - "synced" - } else { - "pending" + let iban = account_cache + .get_account_data(account_id) + .and_then(|acc| acc.iban()) + .unwrap_or("Unknown") + .to_string(); + + match transaction_cache { + Ok(cache) => { + let transaction_count = + cache.ranges.iter().map(|r| r.transactions.len()).sum(); + let last_sync_date = cache.ranges.iter().map(|r| r.end_date).max(); + + statuses.push(AccountStatus { + account_id: account_id.clone(), + iban, + last_sync_date, + transaction_count, + status: if transaction_count > 0 { + "synced" + } else { + "pending" + } + .to_string(), + }); + } + Err(_) => { + // No transaction cache found for this account + statuses.push(AccountStatus { + account_id: account_id.clone(), + iban, + last_sync_date: None, + transaction_count: 0, + status: "pending".to_string(), + }); + } } - .to_string(), - }); + } } Ok(statuses) @@ -400,3 +424,231 @@ impl TransactionSource for GoCardlessAdapter { self.get_accounts(None).await } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::cache::{CachedAccount, GoCardlessAccount}; + use crate::core::config::Config; + use gocardless_client::models::Transaction; + + fn create_test_config() -> Config { + Config { + gocardless: crate::core::config::GoCardlessConfig { + url: "https://test.com".to_string(), + secret_id: "test".to_string(), + secret_key: "test".to_string(), + }, + firefly: crate::core::config::FireflyConfig { + url: "https://test.com".to_string(), + api_key: "test".to_string(), + }, + cache: crate::core::config::CacheConfig { + directory: "tmp/test-cache-status".to_string(), + key: "test-key-for-status".to_string(), + }, + logging: crate::core::config::LoggingConfig { + level: "info".to_string(), + }, + } + } + + fn create_test_gc_account(id: &str, iban: &str) -> GoCardlessAccount { + GoCardlessAccount { + id: id.to_string(), + iban: Some(iban.to_string()), + owner_name: Some("Test Owner".to_string()), + status: Some("READY".to_string()), + institution_id: Some("TEST_BANK".to_string()), + created: Some("2024-01-01T00:00:00Z".to_string()), + last_accessed: Some("2024-01-01T00:00:00Z".to_string()), + name: Some("Test Account".to_string()), + display_name: Some("Test Display Name".to_string()), + product: Some("Test Product".to_string()), + cash_account_type: Some("CACC".to_string()), + } + } + + fn create_test_transaction(id: &str, date: &str) -> Transaction { + Transaction { + transaction_id: Some(id.to_string()), + entry_reference: None, + end_to_end_id: None, + mandate_id: None, + check_id: None, + creditor_id: None, + booking_date: Some(date.to_string()), + value_date: None, + booking_date_time: None, + value_date_time: None, + transaction_amount: gocardless_client::models::TransactionAmount { + amount: "100.00".to_string(), + currency: "EUR".to_string(), + }, + currency_exchange: None, + creditor_name: Some("Test Creditor".to_string()), + creditor_account: None, + ultimate_creditor: None, + debtor_name: None, + debtor_account: None, + ultimate_debtor: None, + remittance_information_unstructured: Some("Test payment".to_string()), + remittance_information_unstructured_array: None, + remittance_information_structured: None, + remittance_information_structured_array: None, + additional_information: None, + purpose_code: None, + bank_transaction_code: None, + proprietary_bank_transaction_code: None, + internal_transaction_id: None, + } + } + + #[tokio::test] + async fn test_get_account_status_with_data() { + // Setup + let config = create_test_config(); + let _ = std::fs::remove_dir_all(&config.cache.directory); // Clean up any existing test data + + // Create a mock client (we won't actually use it for this test) + let client = + gocardless_client::client::GoCardlessClient::new("https://test.com", "test", "test") + .unwrap(); + + let adapter = GoCardlessAdapter::new(client, config.clone()); + + // Add test accounts to the cache + let mut account_cache = adapter.cache.lock().await; + account_cache.insert(CachedAccount::GoCardless(Box::new(create_test_gc_account( + "acc1", + "DE12345678901234567890", + )))); + account_cache.insert(CachedAccount::GoCardless(Box::new(create_test_gc_account( + "acc2", + "DE09876543210987654321", + )))); + account_cache.save(); + + // Create transaction caches with data + let encryption = Encryption::new(config.cache.key.clone()); + + let mut cache1 = AccountTransactionCache::new( + "acc1".to_string(), + config.cache.directory.clone(), + encryption.clone(), + ); + cache1.store_transactions( + chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + chrono::NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(), + vec![create_test_transaction("tx1", "2024-01-15")], + ); + cache1.save().unwrap(); + + let mut cache2 = AccountTransactionCache::new( + "acc2".to_string(), + config.cache.directory.clone(), + encryption.clone(), + ); + cache2.store_transactions( + chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), + chrono::NaiveDate::from_ymd_opt(2024, 2, 28).unwrap(), + vec![ + create_test_transaction("tx2", "2024-02-10"), + create_test_transaction("tx3", "2024-02-20"), + ], + ); + cache2.save().unwrap(); + + drop(account_cache); // Release the lock + + // Test + let statuses = adapter.get_account_status().await.unwrap(); + + // Verify + assert_eq!(statuses.len(), 2); + + // Find status for acc1 + let status1 = statuses.iter().find(|s| s.account_id == "acc1").unwrap(); + assert_eq!(status1.iban, "DE12345678901234567890"); + assert_eq!(status1.transaction_count, 1); + assert_eq!(status1.status, "synced"); + assert_eq!( + status1.last_sync_date, + Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()) + ); + + // Find status for acc2 + let status2 = statuses.iter().find(|s| s.account_id == "acc2").unwrap(); + assert_eq!(status2.iban, "DE09876543210987654321"); + assert_eq!(status2.transaction_count, 2); + assert_eq!(status2.status, "synced"); + assert_eq!( + status2.last_sync_date, + Some(chrono::NaiveDate::from_ymd_opt(2024, 2, 28).unwrap()) + ); + + // Cleanup + let _ = std::fs::remove_dir_all(&config.cache.directory); + } + + #[tokio::test] + async fn test_get_account_status_no_transaction_cache() { + // Setup + let config = create_test_config(); + let _ = std::fs::remove_dir_all(&config.cache.directory); + + let client = + gocardless_client::client::GoCardlessClient::new("https://test.com", "test", "test") + .unwrap(); + + let adapter = GoCardlessAdapter::new(client, config.clone()); + + // Add test account to the cache but don't create transaction cache + let mut account_cache = adapter.cache.lock().await; + account_cache.insert(CachedAccount::GoCardless(Box::new(create_test_gc_account( + "acc_no_cache", + "DE11111111111111111111", + )))); + account_cache.save(); + drop(account_cache); + + // Test + let statuses = adapter.get_account_status().await.unwrap(); + + // Verify + assert_eq!(statuses.len(), 1); + let status = &statuses[0]; + assert_eq!(status.account_id, "acc_no_cache"); + assert_eq!(status.iban, "DE11111111111111111111"); + assert_eq!(status.transaction_count, 0); + assert_eq!(status.status, "pending"); + assert_eq!(status.last_sync_date, None); + + // Cleanup + let _ = std::fs::remove_dir_all(&config.cache.directory); + } + + #[tokio::test] + async fn test_get_account_status_empty() { + // Setup + let config = create_test_config(); + let _ = std::fs::remove_dir_all(&config.cache.directory); + + let client = + gocardless_client::client::GoCardlessClient::new("https://test.com", "test", "test") + .unwrap(); + + let adapter = GoCardlessAdapter::new(client, config.clone()); + + // Don't add any accounts to cache + + // Test + let statuses = adapter.get_account_status().await.unwrap(); + + // Verify + assert_eq!(statuses.len(), 0); + + // Cleanup + let _ = std::fs::remove_dir_all(&config.cache.directory); + } +} diff --git a/banks2ff/src/core/config.rs b/banks2ff/src/core/config.rs index c772c93..98007b9 100644 --- a/banks2ff/src/core/config.rs +++ b/banks2ff/src/core/config.rs @@ -105,10 +105,10 @@ impl LoggingConfig { } } -#[cfg(test)] -mod tests { - use super::*; - use std::env; + #[cfg(test)] + mod tests { + use super::*; + use std::env; #[test] fn test_gocardless_config_from_env() { diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs index c77a9ee..698774f 100644 --- a/banks2ff/src/main.rs +++ b/banks2ff/src/main.rs @@ -11,7 +11,7 @@ use crate::core::adapters::{ use crate::core::config::Config; use crate::core::encryption::Encryption; use crate::core::linking::LinkStore; -use crate::core::models::{AccountData, AccountSummary}; +use crate::core::models::{AccountData, AccountStatus, AccountSummary}; use crate::core::ports::{TransactionDestination, TransactionSource}; use crate::core::sync::run_sync; use chrono::NaiveDate; @@ -293,7 +293,6 @@ async fn handle_destinations() -> anyhow::Result<()> { async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> { let context = AppContext::new(config.clone(), false).await?; - let format = OutputFormat::Table; // TODO: Add --json flag match subcommand { AccountCommands::Link { @@ -420,13 +419,16 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: Some("firefly") => false, None => true, // Show both by default Some(invalid) => { - anyhow::bail!("Invalid filter '{}'. Use 'gocardless', 'firefly', or omit for all.", invalid); + anyhow::bail!( + "Invalid filter '{}'. Use 'gocardless', 'firefly', or omit for all.", + invalid + ); } }; let show_firefly = match filter.as_deref() { Some("gocardless") => false, Some("firefly") => true, - None => true, // Show both by default + None => true, // Show both by default Some(_) => unreachable!(), // Already validated above }; @@ -435,7 +437,10 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: match context.source.list_accounts().await { Ok(mut accounts) => { accounts.sort_by(|a, b| { - a.name.as_deref().unwrap_or("").cmp(b.name.as_deref().unwrap_or("")) + a.name + .as_deref() + .unwrap_or("") + .cmp(b.name.as_deref().unwrap_or("")) }); accounts } @@ -453,7 +458,10 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: match context.destination.list_accounts().await { Ok(mut accounts) => { accounts.sort_by(|a, b| { - a.name.as_deref().unwrap_or("").cmp(b.name.as_deref().unwrap_or("")) + a.name + .as_deref() + .unwrap_or("") + .cmp(b.name.as_deref().unwrap_or("")) }); accounts } @@ -490,7 +498,7 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow: if status.is_empty() { println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data."); } else { - print_list_output(status, &format); + print_account_status_table(&status); } } } @@ -515,6 +523,33 @@ fn print_accounts_table(accounts: &[AccountSummary]) { println!("{}", table); } +fn print_account_status_table(statuses: &[AccountStatus]) { + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(vec![ + "Account ID", + "IBAN", + "Last Sync", + "Transaction Count", + "Status", + ]); + + for status in statuses { + table.add_row(vec![ + status.account_id.clone(), + mask_iban(&status.iban), + status + .last_sync_date + .map(|d| d.to_string()) + .unwrap_or_else(|| "Never".to_string()), + status.transaction_count.to_string(), + status.status.clone(), + ]); + } + + println!("{}", table); +} + fn mask_iban(iban: &str) -> String { if iban.len() <= 4 { iban.to_string()