feat: Add the account status command

This shows the account status from gocardless (for now), based on the
sync results.
This commit is contained in:
2025-11-28 20:15:27 +01:00
parent c0453ce093
commit 0ab978fa87
4 changed files with 327 additions and 39 deletions

View File

@@ -245,10 +245,8 @@ impl TransactionDestination for FireflyAdapter {
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
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);
}

View File

@@ -304,33 +304,57 @@ impl TransactionSource for GoCardlessAdapter {
#[instrument(skip(self))]
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
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);
}
}

View File

@@ -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() {

View File

@@ -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()