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