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>> {
|
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
||||||
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
|
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
|
||||||
let cache = crate::core::cache::AccountCache::load(
|
let cache =
|
||||||
self.config.cache.directory.clone(),
|
crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption);
|
||||||
encryption,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut summaries = Vec::new();
|
let mut summaries = Vec::new();
|
||||||
|
|
||||||
@@ -259,7 +257,10 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
id: account_id.clone(),
|
id: account_id.clone(),
|
||||||
name: Some(ff_account.name.clone()),
|
name: Some(ff_account.name.clone()),
|
||||||
iban: ff_account.iban.clone().unwrap_or_else(|| "".to_string()),
|
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);
|
summaries.push(summary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,19 +304,30 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
|
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();
|
let mut statuses = Vec::new();
|
||||||
|
|
||||||
for (account_id, cache) in caches.iter() {
|
// Iterate through cached GoCardless accounts
|
||||||
let iban = self
|
for (account_id, cached_account) in &account_cache.accounts {
|
||||||
.cache
|
if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account {
|
||||||
.lock()
|
// Try to load the transaction cache for this account
|
||||||
.await
|
let encryption = Encryption::new(self.config.cache.key.clone());
|
||||||
|
let transaction_cache = AccountTransactionCache::load(
|
||||||
|
account_id,
|
||||||
|
self.config.cache.directory.clone(),
|
||||||
|
encryption,
|
||||||
|
);
|
||||||
|
|
||||||
|
let iban = account_cache
|
||||||
.get_account_data(account_id)
|
.get_account_data(account_id)
|
||||||
.and_then(|acc| acc.iban())
|
.and_then(|acc| acc.iban())
|
||||||
.unwrap_or("Unknown")
|
.unwrap_or("Unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let transaction_count = cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
|
||||||
|
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();
|
let last_sync_date = cache.ranges.iter().map(|r| r.end_date).max();
|
||||||
|
|
||||||
statuses.push(AccountStatus {
|
statuses.push(AccountStatus {
|
||||||
@@ -332,6 +343,19 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
.to_string(),
|
.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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(statuses)
|
Ok(statuses)
|
||||||
}
|
}
|
||||||
@@ -400,3 +424,231 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
self.get_accounts(None).await
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::core::adapters::{
|
|||||||
use crate::core::config::Config;
|
use crate::core::config::Config;
|
||||||
use crate::core::encryption::Encryption;
|
use crate::core::encryption::Encryption;
|
||||||
use crate::core::linking::LinkStore;
|
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::ports::{TransactionDestination, TransactionSource};
|
||||||
use crate::core::sync::run_sync;
|
use crate::core::sync::run_sync;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
@@ -293,7 +293,6 @@ async fn handle_destinations() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> {
|
async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> {
|
||||||
let context = AppContext::new(config.clone(), false).await?;
|
let context = AppContext::new(config.clone(), false).await?;
|
||||||
let format = OutputFormat::Table; // TODO: Add --json flag
|
|
||||||
|
|
||||||
match subcommand {
|
match subcommand {
|
||||||
AccountCommands::Link {
|
AccountCommands::Link {
|
||||||
@@ -420,7 +419,10 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow:
|
|||||||
Some("firefly") => false,
|
Some("firefly") => false,
|
||||||
None => true, // Show both by default
|
None => true, // Show both by default
|
||||||
Some(invalid) => {
|
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() {
|
let show_firefly = match filter.as_deref() {
|
||||||
@@ -435,7 +437,10 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow:
|
|||||||
match context.source.list_accounts().await {
|
match context.source.list_accounts().await {
|
||||||
Ok(mut accounts) => {
|
Ok(mut accounts) => {
|
||||||
accounts.sort_by(|a, b| {
|
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
|
accounts
|
||||||
}
|
}
|
||||||
@@ -453,7 +458,10 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow:
|
|||||||
match context.destination.list_accounts().await {
|
match context.destination.list_accounts().await {
|
||||||
Ok(mut accounts) => {
|
Ok(mut accounts) => {
|
||||||
accounts.sort_by(|a, b| {
|
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
|
accounts
|
||||||
}
|
}
|
||||||
@@ -490,7 +498,7 @@ async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow:
|
|||||||
if status.is_empty() {
|
if status.is_empty() {
|
||||||
println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data.");
|
println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data.");
|
||||||
} else {
|
} else {
|
||||||
print_list_output(status, &format);
|
print_account_status_table(&status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,6 +523,33 @@ fn print_accounts_table(accounts: &[AccountSummary]) {
|
|||||||
println!("{}", table);
|
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 {
|
fn mask_iban(iban: &str) -> String {
|
||||||
if iban.len() <= 4 {
|
if iban.len() <= 4 {
|
||||||
iban.to_string()
|
iban.to_string()
|
||||||
|
|||||||
Reference in New Issue
Block a user