chore: Move all command handlers to their own files

This makes the code much easier to follow and shortens main.rs from
>1000 lines to around 150.
This commit is contained in:
2025-11-29 00:54:46 +01:00
parent 095e15cd5f
commit 5f54124015
19 changed files with 1057 additions and 918 deletions

View File

@@ -186,6 +186,20 @@ After making ANY code change, you MUST run these commands and fix any issues:
- **firefly/**: Firefly III API integration - **firefly/**: Firefly III API integration
- Each adapter implements the appropriate port trait - Each adapter implements the appropriate port trait
### Commands Module (`banks2ff/src/commands/`)
- **sync.rs**: Sync command handler
- **accounts/**: Account management commands
- **mod.rs**: Account command dispatch
- **link.rs**: Account linking logic and LinkCommands dispatch
- **list.rs**: Account listing handler
- **status.rs**: Account status handler
- **transactions/**: Transaction management commands
- **mod.rs**: Transaction command dispatch
- **list.rs**: Transaction listing handler
- **cache.rs**: Cache status handler
- **clear.rs**: Cache clearing handler
- **list.rs**: Source/destination listing handler
### Client Libraries ### Client Libraries
- **gocardless-client/**: Standalone GoCardless API wrapper - **gocardless-client/**: Standalone GoCardless API wrapper
- **firefly-client/**: Standalone Firefly III API wrapper - **firefly-client/**: Standalone Firefly III API wrapper

View File

@@ -1,2 +1,3 @@
pub mod formatters; pub mod formatters;
pub mod setup; pub mod setup;
pub mod tables;

View File

@@ -0,0 +1,99 @@
use crate::core::cache::AccountCache;
use crate::core::models::{AccountStatus, AccountSummary};
use comfy_table::{presets::UTF8_FULL, Table};
pub fn print_accounts_table(accounts: &[AccountSummary]) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "IBAN", "Currency"]);
for account in accounts {
let name = account.name.as_deref().unwrap_or("");
table.add_row(vec![
name.to_string(),
mask_iban(&account.iban),
account.currency.clone(),
]);
}
println!("{}", table);
}
pub fn print_links_table(
links: &[crate::core::linking::AccountLink],
account_cache: &crate::core::cache::AccountCache,
) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Source Account", "Destination Account", "Auto-Linked"]);
for link in links {
let source_name = account_cache
.get_display_name(&link.source_account_id)
.unwrap_or_else(|| format!("Account {}", &link.source_account_id));
let dest_name = account_cache
.get_display_name(&link.dest_account_id)
.unwrap_or_else(|| format!("Account {}", &link.dest_account_id));
let auto_linked = if link.auto_linked { "Yes" } else { "No" };
table.add_row(vec![source_name, dest_name, auto_linked.to_string()]);
}
println!("{}", table);
}
pub fn print_account_status_table(statuses: &[AccountStatus], account_cache: &AccountCache) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account",
"IBAN",
"Last Sync",
"Transaction Count",
"Status",
]);
for status in statuses {
let display_name = account_cache
.get_display_name(&status.account_id)
.unwrap_or_else(|| status.account_id.clone());
table.add_row(vec![
display_name,
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);
}
pub fn mask_iban(iban: &str) -> String {
if iban.len() <= 4 {
iban.to_string()
} else {
let country_code = &iban[0..2];
let last_four = &iban[iban.len() - 4..];
if country_code == "NL" && iban.len() >= 12 {
// NL: show first 2 (CC) + next 6 + mask + last 4
let next_six = &iban[2..8];
let mask_length = iban.len() - 12; // 2 + 6 + 4 = 12, so mask_length = total - 12
format!(
"{}{}{}{}",
country_code,
next_six,
"*".repeat(mask_length),
last_four
)
} else {
// Other countries: show first 2 + mask + last 4
let mask_length = iban.len() - 6; // 2 + 4 = 6
format!("{}{}{}", country_code, "*".repeat(mask_length), last_four)
}
}
}

View File

@@ -0,0 +1,513 @@
use crate::core::cache::{AccountCache, CachedAccount};
use crate::core::linking::LinkStore;
use crate::core::models::{Account, AccountData};
use clap::Subcommand;
pub fn handle_interactive_link_creation(
link_store: &mut LinkStore,
account_cache: &AccountCache,
) -> anyhow::Result<()> {
// Get unlinked GoCardless accounts
let gocardless_accounts = get_gocardless_accounts(account_cache);
let unlinked_sources: Vec<_> = gocardless_accounts
.iter()
.filter(|acc| {
!link_store
.find_links_by_source(acc.id())
.iter()
.any(|link| link.dest_adapter_type == "firefly")
})
.collect();
if unlinked_sources.is_empty() {
println!("No unlinked source accounts found. All GoCardless accounts are already linked to Firefly III.");
return Ok(());
}
// Create selection items for dialoguer
let source_items: Vec<String> = unlinked_sources
.iter()
.map(|account| {
let display_name = account
.display_name()
.unwrap_or_else(|| account.id().to_string());
display_name.to_string()
})
.collect();
// Add cancel option
let mut items = source_items.clone();
items.push("Cancel".to_string());
// Prompt user to select source account
let source_selection =
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt("Select a source account to link")
.items(&items)
.default(0)
.interact()
{
Ok(selection) => selection,
Err(_) => {
// Non-interactive environment (e.g., tests, scripts)
println!("Interactive mode not available in this environment.");
println!("Use: banks2ff accounts link create <source> <destination>");
return Ok(());
}
};
if source_selection == items.len() - 1 {
// User selected "Cancel"
println!("Operation cancelled.");
return Ok(());
}
let selected_source = &unlinked_sources[source_selection];
handle_source_selection(link_store, account_cache, selected_source.id().to_string())?;
Ok(())
}
pub fn handle_single_arg_link_creation(
link_store: &mut LinkStore,
account_cache: &AccountCache,
arg: &str,
) -> anyhow::Result<()> {
// Try to find account by ID, name, or IBAN
let matched_account = find_account_by_identifier(account_cache, arg);
match matched_account {
Some((account_id, adapter_type)) => {
if adapter_type == "gocardless" {
// It's a source account - show available destinations
handle_source_selection(link_store, account_cache, account_id)
} else {
// It's a destination account - show available sources
handle_destination_selection(link_store, account_cache, account_id)
}
}
None => {
println!("No account found matching '{}'.", arg);
println!("Try using an account ID, name, or IBAN pattern.");
println!("Run 'banks2ff accounts list' to see available accounts.");
Ok(())
}
}
}
pub fn handle_direct_link_creation(
link_store: &mut LinkStore,
account_cache: &AccountCache,
source_arg: &str,
dest_arg: &str,
) -> anyhow::Result<()> {
let source_match = find_account_by_identifier(account_cache, source_arg);
let dest_match = find_account_by_identifier(account_cache, dest_arg);
match (source_match, dest_match) {
(Some((source_id, source_adapter)), Some((dest_id, dest_adapter))) => {
if source_adapter != "gocardless" {
println!(
"Error: Source must be a GoCardless account, got {} account.",
source_adapter
);
return Ok(());
}
if dest_adapter != "firefly" {
println!(
"Error: Destination must be a Firefly III account, got {} account.",
dest_adapter
);
return Ok(());
}
create_link(
link_store,
account_cache,
&source_id,
&dest_id,
&dest_adapter,
)
}
(None, _) => {
println!("Source account '{}' not found.", source_arg);
Ok(())
}
(_, None) => {
println!("Destination account '{}' not found.", dest_arg);
Ok(())
}
}
}
pub fn find_account_by_identifier(
account_cache: &AccountCache,
identifier: &str,
) -> Option<(String, String)> {
// First try exact ID match
if let Some(adapter_type) = account_cache.get_adapter_type(identifier) {
return Some((identifier.to_string(), adapter_type.to_string()));
}
// Then try name/IBAN matching
for (id, account) in &account_cache.accounts {
if let Some(display_name) = account.display_name() {
if display_name
.to_lowercase()
.contains(&identifier.to_lowercase())
{
let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) {
"gocardless"
} else {
"firefly"
};
return Some((id.clone(), adapter_type.to_string()));
}
}
if let Some(iban) = account.iban() {
if iban.contains(identifier) {
let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) {
"gocardless"
} else {
"firefly"
};
return Some((id.clone(), adapter_type.to_string()));
}
}
}
None
}
pub fn handle_source_selection(
link_store: &mut LinkStore,
account_cache: &AccountCache,
source_id: String,
) -> anyhow::Result<()> {
// Check if source is already linked to firefly
if let Some(existing_link) = link_store.find_link_by_source_and_dest_type(&source_id, "firefly")
{
let dest_name = account_cache
.get_display_name(&existing_link.dest_account_id)
.unwrap_or_else(|| existing_link.dest_account_id.clone());
println!(
"Source account '{}' is already linked to '{}'.",
account_cache
.get_display_name(&source_id)
.unwrap_or_else(|| source_id.clone()),
dest_name
);
return Ok(());
}
// Get available Firefly destinations
let firefly_accounts = get_firefly_accounts(account_cache);
if firefly_accounts.is_empty() {
println!("No Firefly III accounts found. Run sync first.");
return Ok(());
}
// Create selection items for dialoguer
let dest_items: Vec<String> = firefly_accounts
.iter()
.map(|account| {
let display_name = account
.display_name()
.unwrap_or_else(|| account.id().to_string());
display_name.to_string()
})
.collect();
// Add cancel option
let mut items = dest_items.clone();
items.push("Cancel".to_string());
// Prompt user to select destination account
let source_name = account_cache
.get_display_name(&source_id)
.unwrap_or_else(|| source_id.clone());
let dest_selection =
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!(
"Select a destination account for '{}'",
source_name
))
.items(&items)
.default(0)
.interact()
{
Ok(selection) => selection,
Err(_) => {
// Non-interactive environment (e.g., tests, scripts)
println!("Interactive mode not available in this environment.");
println!("Use: banks2ff accounts link create <source> <destination>");
return Ok(());
}
};
if dest_selection == items.len() - 1 {
// User selected "Cancel"
println!("Operation cancelled.");
return Ok(());
}
let selected_dest = &firefly_accounts[dest_selection];
create_link(
link_store,
account_cache,
&source_id,
selected_dest.id(),
"firefly",
)?;
Ok(())
}
pub fn handle_destination_selection(
link_store: &mut LinkStore,
account_cache: &AccountCache,
dest_id: String,
) -> anyhow::Result<()> {
// Get available GoCardless sources that aren't already linked to this destination
let gocardless_accounts = get_gocardless_accounts(account_cache);
let available_sources: Vec<_> = gocardless_accounts
.iter()
.filter(|acc| {
!link_store
.find_links_by_source(acc.id())
.iter()
.any(|link| link.dest_account_id == dest_id)
})
.collect();
if available_sources.is_empty() {
println!(
"No available source accounts found that can link to '{}'.",
account_cache
.get_display_name(&dest_id)
.unwrap_or_else(|| dest_id.clone())
);
return Ok(());
}
// Create selection items for dialoguer
let source_items: Vec<String> = available_sources
.iter()
.map(|account| {
let display_name = account
.display_name()
.unwrap_or_else(|| account.id().to_string());
display_name.to_string()
})
.collect();
// Add cancel option
let mut items = source_items.clone();
items.push("Cancel".to_string());
// Prompt user to select source account
let dest_name = account_cache
.get_display_name(&dest_id)
.unwrap_or_else(|| dest_id.clone());
let source_selection =
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!(
"Select a source account to link to '{}'",
dest_name
))
.items(&items)
.default(0)
.interact()
{
Ok(selection) => selection,
Err(_) => {
// Non-interactive environment (e.g., tests, scripts)
println!("Interactive mode not available in this environment.");
println!("Use: banks2ff accounts link create <source> <destination>");
return Ok(());
}
};
if source_selection == items.len() - 1 {
// User selected "Cancel"
println!("Operation cancelled.");
return Ok(());
}
let selected_source = &available_sources[source_selection];
create_link(
link_store,
account_cache,
selected_source.id(),
&dest_id,
"firefly",
)?;
Ok(())
}
pub fn create_link(
link_store: &mut LinkStore,
account_cache: &AccountCache,
source_id: &str,
dest_id: &str,
dest_adapter_type: &str,
) -> anyhow::Result<()> {
let source_acc = account_cache.get_account(source_id);
let dest_acc = account_cache.get_account(dest_id);
if let (Some(src), Some(dst)) = (source_acc, dest_acc) {
let src_minimal = Account {
id: src.id().to_string(),
name: Some(src.id().to_string()),
iban: src.iban().map(|s| s.to_string()),
currency: "EUR".to_string(),
};
let dst_minimal = Account {
id: dst.id().to_string(),
name: Some(dst.id().to_string()),
iban: dst.iban().map(|s| s.to_string()),
currency: "EUR".to_string(),
};
match link_store.add_link(
&src_minimal,
&dst_minimal,
"gocardless",
dest_adapter_type,
false,
) {
Ok(true) => {
link_store.save()?;
let src_display = account_cache
.get_display_name(source_id)
.unwrap_or_else(|| source_id.to_string());
let dst_display = account_cache
.get_display_name(dest_id)
.unwrap_or_else(|| dest_id.to_string());
println!("Created link between {} and {}", src_display, dst_display);
}
Ok(false) => {
let src_display = account_cache
.get_display_name(source_id)
.unwrap_or_else(|| source_id.to_string());
let dst_display = account_cache
.get_display_name(dest_id)
.unwrap_or_else(|| dest_id.to_string());
println!(
"Link between {} and {} already exists",
src_display, dst_display
);
}
Err(e) => {
println!("Cannot create link: {}", e);
}
}
} else {
println!("Account not found in cache. Run sync first.");
}
Ok(())
}
pub fn get_gocardless_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> {
account_cache
.accounts
.values()
.filter_map(|acc| match acc {
CachedAccount::GoCardless(gc_acc) => Some(gc_acc.as_ref() as &dyn AccountData),
_ => None,
})
.collect()
}
pub fn get_firefly_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> {
account_cache
.accounts
.values()
.filter_map(|acc| match acc {
CachedAccount::Firefly(ff_acc) => Some(ff_acc.as_ref() as &dyn AccountData),
_ => None,
})
.collect()
}
// Command handlers
use crate::cli::tables::print_links_table;
use crate::core::config::Config;
use crate::core::encryption::Encryption;
#[derive(Subcommand, Debug)]
pub enum LinkCommands {
/// List all account links
List,
/// Create a new account link
Create {
/// Source account identifier (ID, IBAN, or name). If omitted, interactive mode is used.
source_account: Option<String>,
/// Destination account identifier (ID, IBAN, or name). Required if source is provided.
dest_account: Option<String>,
},
}
pub async fn handle_link_commands(config: Config, subcommand: LinkCommands) -> anyhow::Result<()> {
match subcommand {
LinkCommands::List => {
handle_link_list(config).await?;
}
LinkCommands::Create {
source_account,
dest_account,
} => {
handle_link_create(config, source_account, dest_account).await?;
}
}
Ok(())
}
pub async fn handle_link_list(config: Config) -> anyhow::Result<()> {
let encryption = Encryption::new(config.cache.key.clone());
let link_store = LinkStore::load(config.cache.directory.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
if link_store.links.is_empty() {
println!("No account links found.");
} else {
print_links_table(&link_store.links, &account_cache);
}
Ok(())
}
pub async fn handle_link_create(
config: Config,
source_account: Option<String>,
dest_account: Option<String>,
) -> anyhow::Result<()> {
let encryption = Encryption::new(config.cache.key.clone());
let mut link_store = LinkStore::load(config.cache.directory.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
match (source_account, dest_account) {
(None, None) => {
// Interactive mode
handle_interactive_link_creation(&mut link_store, &account_cache)?;
}
(Some(source), None) => {
// Single argument - try to resolve as source or destination
handle_single_arg_link_creation(&mut link_store, &account_cache, &source)?;
}
(Some(source), Some(dest)) => {
// Two arguments - direct linking
handle_direct_link_creation(&mut link_store, &account_cache, &source, &dest)?;
}
(None, Some(_)) => {
println!("Error: Cannot specify destination without source. Use 'banks2ff accounts link create <source> <destination>' or interactive mode.");
}
}
Ok(())
}

View File

@@ -0,0 +1,91 @@
use crate::cli::setup::AppContext;
use crate::cli::tables::print_accounts_table;
use crate::core::config::Config;
use crate::core::ports::{TransactionDestination, TransactionSource};
use tracing::warn;
pub async fn handle_list(config: Config, filter: Option<String>) -> anyhow::Result<()> {
let context = AppContext::new(config, false).await?;
// Validate filter parameter
let show_gocardless = match filter.as_deref() {
Some("gocardless") => true,
Some("firefly") => false,
None => true, // Show both by default
Some(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
Some(_) => unreachable!(), // Already validated above
};
// Get GoCardless accounts if needed
let gocardless_accounts = if show_gocardless {
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(""))
});
accounts
}
Err(e) => {
warn!("Failed to list GoCardless accounts: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
// Get Firefly III accounts if needed
let firefly_accounts = if show_firefly {
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(""))
});
accounts
}
Err(e) => {
warn!("Failed to list Firefly III accounts: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
if gocardless_accounts.is_empty() && firefly_accounts.is_empty() {
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
} else {
// Print GoCardless accounts
if !gocardless_accounts.is_empty() {
println!("GoCardless Accounts ({}):", gocardless_accounts.len());
print_accounts_table(&gocardless_accounts);
}
// Print Firefly III accounts
if !firefly_accounts.is_empty() {
if !gocardless_accounts.is_empty() {
println!(); // Add spacing between tables
}
println!("Firefly III Accounts ({}):", firefly_accounts.len());
print_accounts_table(&firefly_accounts);
}
}
Ok(())
}

View File

@@ -0,0 +1,43 @@
pub mod link;
pub mod list;
pub mod status;
use crate::core::config::Config;
use clap::Subcommand;
use link::handle_link_commands;
use link::LinkCommands;
#[derive(Subcommand, Debug)]
pub enum AccountCommands {
/// Manage account links between sources and destinations
Link {
#[command(subcommand)]
subcommand: LinkCommands,
},
/// List all accounts
List {
/// Filter by adapter type: 'gocardless' or 'firefly', or omit for all
filter: Option<String>,
},
/// Show account status
Status,
}
pub async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> {
match subcommand {
AccountCommands::Link {
subcommand: link_sub,
} => {
handle_link_commands(config.clone(), link_sub).await?;
}
AccountCommands::List { filter } => {
list::handle_list(config, filter).await?;
}
AccountCommands::Status => {
status::handle_status(config).await?;
}
}
Ok(())
}

View File

@@ -0,0 +1,21 @@
use crate::cli::setup::AppContext;
use crate::cli::tables::print_account_status_table;
use crate::core::config::Config;
use crate::core::encryption::Encryption;
use crate::core::ports::TransactionSource;
pub async fn handle_status(config: Config) -> anyhow::Result<()> {
let context = AppContext::new(config.clone(), false).await?;
let encryption = Encryption::new(config.cache.key.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
let status = context.source.get_account_status().await?;
if status.is_empty() {
println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data.");
} else {
print_account_status_table(&status, &account_cache);
}
Ok(())
}

View File

@@ -0,0 +1,17 @@
use crate::core::adapters::{get_available_destinations, get_available_sources};
pub async fn handle_sources() -> anyhow::Result<()> {
println!("Available sources:");
for source in get_available_sources() {
println!(" {} - {}", source.id, source.description);
}
Ok(())
}
pub async fn handle_destinations() -> anyhow::Result<()> {
println!("Available destinations:");
for destination in get_available_destinations() {
println!(" {} - {}", destination.id, destination.description);
}
Ok(())
}

View File

@@ -0,0 +1,4 @@
pub mod accounts;
pub mod list;
pub mod sync;
pub mod transactions;

View File

@@ -0,0 +1,88 @@
use crate::cli::setup::AppContext;
use crate::core::adapters::{
get_available_destinations, get_available_sources, is_valid_destination, is_valid_source,
};
use crate::core::config::Config;
use crate::core::sync::run_sync;
use chrono::NaiveDate;
use tracing::{error, info};
pub async fn handle_sync(
config: Config,
debug: bool,
source: String,
destination: String,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
dry_run: bool,
) -> anyhow::Result<()> {
// Validate source
if !is_valid_source(&source) {
let available = get_available_sources()
.iter()
.map(|s| s.id)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"Unknown source '{}'. Available sources: {}",
source,
available
);
}
// Validate destination
if !is_valid_destination(&destination) {
let available = get_available_destinations()
.iter()
.map(|d| d.id)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"Unknown destination '{}'. Available destinations: {}",
destination,
available
);
}
// For now, only support gocardless -> firefly
if source != "gocardless" {
anyhow::bail!("Only 'gocardless' source is currently supported (implementation pending)");
}
if destination != "firefly" {
anyhow::bail!("Only 'firefly' destination is currently supported (implementation pending)");
}
let context = AppContext::new(config.clone(), debug).await?;
// Run sync
match run_sync(
context.source,
context.destination,
config,
start,
end,
dry_run,
)
.await
{
Ok(result) => {
info!("Sync completed successfully.");
info!(
"Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
result.accounts_processed,
result.accounts_skipped_expired,
result.accounts_skipped_errors
);
info!(
"Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
result.ingest.created,
result.ingest.healed,
result.ingest.duplicates,
result.ingest.errors
);
}
Err(e) => error!("Sync failed: {}", e),
}
Ok(())
}

View File

@@ -0,0 +1,24 @@
use crate::cli::formatters::{print_list_output, OutputFormat};
use crate::cli::setup::AppContext;
use crate::core::config::Config;
use crate::core::encryption::Encryption;
use crate::core::ports::TransactionSource;
pub async fn handle_cache_status(config: Config) -> anyhow::Result<()> {
let context = AppContext::new(config.clone(), false).await?;
let format = OutputFormat::Table; // TODO: Add --json flag
// Load account cache for display name resolution
let encryption = Encryption::new(config.cache.key.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
let cache_info = context.source.get_cache_info().await?;
if cache_info.is_empty() {
println!("No cache data available. Run 'banks2ff sync gocardless firefly' first to populate caches.");
} else {
print_list_output(cache_info, &format, Some(&account_cache));
}
Ok(())
}

View File

@@ -0,0 +1,7 @@
use crate::core::config::Config;
pub async fn handle_clear_cache(_config: Config) -> anyhow::Result<()> {
// TODO: Implement cache clearing
println!("Cache clearing not yet implemented");
Ok(())
}

View File

@@ -0,0 +1,24 @@
use crate::cli::formatters::{print_list_output, OutputFormat};
use crate::cli::setup::AppContext;
use crate::core::config::Config;
use crate::core::encryption::Encryption;
use crate::core::ports::TransactionSource;
pub async fn handle_list(config: Config, account_id: String) -> anyhow::Result<()> {
let context = AppContext::new(config.clone(), false).await?;
let format = OutputFormat::Table; // TODO: Add --json flag
// Load account cache for display name resolution
let encryption = Encryption::new(config.cache.key.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
let info = context.source.get_transaction_info(&account_id).await?;
if info.total_count == 0 {
println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", account_id);
} else {
print_list_output(vec![info], &format, Some(&account_cache));
}
Ok(())
}

View File

@@ -0,0 +1,41 @@
pub mod cache;
pub mod clear;
pub mod list;
use crate::core::config::Config;
use clap::Subcommand;
use self::cache::handle_cache_status;
use self::clear::handle_clear_cache;
use self::list::handle_list as handle_transaction_list;
#[derive(Subcommand, Debug)]
pub enum TransactionCommands {
/// List transactions for an account
List {
/// Account ID to list transactions for
account_id: String,
},
/// Show cache status
CacheStatus,
/// Clear transaction cache
ClearCache,
}
pub async fn handle_transactions(
config: Config,
subcommand: TransactionCommands,
) -> anyhow::Result<()> {
match subcommand {
TransactionCommands::List { account_id } => {
handle_transaction_list(config, account_id).await?;
}
TransactionCommands::CacheStatus => {
handle_cache_status(config).await?;
}
TransactionCommands::ClearCache => {
handle_clear_cache(config).await?;
}
}
Ok(())
}

View File

@@ -7,7 +7,7 @@ use anyhow::{anyhow, Result};
use std::env; use std::env;
/// Main application configuration /// Main application configuration
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
pub gocardless: GoCardlessConfig, pub gocardless: GoCardlessConfig,
pub firefly: FireflyConfig, pub firefly: FireflyConfig,
@@ -16,7 +16,7 @@ pub struct Config {
} }
/// GoCardless API configuration /// GoCardless API configuration
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct GoCardlessConfig { pub struct GoCardlessConfig {
pub url: String, pub url: String,
pub secret_id: String, pub secret_id: String,
@@ -24,21 +24,21 @@ pub struct GoCardlessConfig {
} }
/// Firefly III API configuration /// Firefly III API configuration
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct FireflyConfig { pub struct FireflyConfig {
pub url: String, pub url: String,
pub api_key: String, pub api_key: String,
} }
/// Cache configuration /// Cache configuration
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct CacheConfig { pub struct CacheConfig {
pub key: String, pub key: String,
pub directory: String, pub directory: String,
} }
/// Logging configuration /// Logging configuration
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct LoggingConfig { pub struct LoggingConfig {
pub level: String, pub level: String,
} }

View File

@@ -14,7 +14,7 @@ pub struct SyncResult {
pub accounts_skipped_errors: usize, pub accounts_skipped_errors: usize,
} }
#[instrument(skip(source, destination))] #[instrument(skip(source, destination, config))]
pub async fn run_sync( pub async fn run_sync(
source: impl TransactionSource, source: impl TransactionSource,
destination: impl TransactionDestination, destination: impl TransactionDestination,

View File

@@ -1,24 +1,16 @@
mod adapters; mod adapters;
mod cli; mod cli;
mod commands;
mod core; mod core;
mod debug; mod debug;
use crate::cli::formatters::{print_list_output, OutputFormat}; use crate::commands::accounts::AccountCommands;
use crate::cli::setup::AppContext; use crate::commands::sync::handle_sync;
use crate::core::adapters::{ use crate::commands::transactions::TransactionCommands;
get_available_destinations, get_available_sources, is_valid_destination, is_valid_source,
};
use crate::core::cache::{AccountCache, CachedAccount};
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::encryption::Encryption;
use crate::core::linking::LinkStore;
use crate::core::models::{Account, AccountData, AccountStatus, AccountSummary};
use crate::core::ports::{TransactionDestination, TransactionSource};
use crate::core::sync::run_sync;
use chrono::NaiveDate; use chrono::NaiveDate;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use comfy_table::{presets::UTF8_FULL, Table}; use tracing::info;
use tracing::{error, info, warn};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@@ -73,48 +65,6 @@ enum Commands {
Destinations, Destinations,
} }
#[derive(Subcommand, Debug)]
enum LinkCommands {
/// List all account links
List,
/// Create a new account link
Create {
/// Source account identifier (ID, IBAN, or name). If omitted, interactive mode is used.
source_account: Option<String>,
/// Destination account identifier (ID, IBAN, or name). Required if source is provided.
dest_account: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum AccountCommands {
/// Manage account links between sources and destinations
Link {
#[command(subcommand)]
subcommand: LinkCommands,
},
/// List all accounts
List {
/// Filter by adapter type: 'gocardless' or 'firefly', or omit for all
filter: Option<String>,
},
/// Show account status
Status,
}
#[derive(Subcommand, Debug)]
enum TransactionCommands {
/// List transactions for an account
List {
/// Account ID to list transactions for
account_id: String,
},
/// Show cache status
CacheStatus,
/// Clear transaction cache
ClearCache,
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Load environment variables first // Load environment variables first
@@ -166,870 +116,27 @@ async fn main() -> anyhow::Result<()> {
} }
Commands::Sources => { Commands::Sources => {
handle_sources().await?; commands::list::handle_sources().await?;
} }
Commands::Destinations => { Commands::Destinations => {
handle_destinations().await?; commands::list::handle_destinations().await?;
} }
Commands::Accounts { subcommand } => { Commands::Accounts { subcommand } => {
handle_accounts(config, subcommand).await?; commands::accounts::handle_accounts(config, subcommand).await?;
} }
Commands::Transactions { subcommand } => { Commands::Transactions { subcommand } => {
handle_transactions(config, subcommand).await?; commands::transactions::handle_transactions(config, subcommand).await?;
} }
} }
Ok(()) Ok(())
} }
async fn handle_sync(
config: Config,
debug: bool,
source: String,
destination: String,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
dry_run: bool,
) -> anyhow::Result<()> {
// Validate source
if !is_valid_source(&source) {
let available = get_available_sources()
.iter()
.map(|s| s.id)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"Unknown source '{}'. Available sources: {}",
source,
available
);
}
// Validate destination
if !is_valid_destination(&destination) {
let available = get_available_destinations()
.iter()
.map(|d| d.id)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"Unknown destination '{}'. Available destinations: {}",
destination,
available
);
}
// For now, only support gocardless -> firefly
if source != "gocardless" {
anyhow::bail!("Only 'gocardless' source is currently supported (implementation pending)");
}
if destination != "firefly" {
anyhow::bail!("Only 'firefly' destination is currently supported (implementation pending)");
}
let context = AppContext::new(config.clone(), debug).await?;
// Run sync
match run_sync(
context.source,
context.destination,
config,
start,
end,
dry_run,
)
.await
{
Ok(result) => {
info!("Sync completed successfully.");
info!(
"Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
result.accounts_processed,
result.accounts_skipped_expired,
result.accounts_skipped_errors
);
info!(
"Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
result.ingest.created,
result.ingest.healed,
result.ingest.duplicates,
result.ingest.errors
);
}
Err(e) => error!("Sync failed: {}", e),
}
Ok(())
}
async fn handle_sources() -> anyhow::Result<()> {
println!("Available sources:");
for source in get_available_sources() {
println!(" {} - {}", source.id, source.description);
}
Ok(())
}
async fn handle_destinations() -> anyhow::Result<()> {
println!("Available destinations:");
for destination in get_available_destinations() {
println!(" {} - {}", destination.id, destination.description);
}
Ok(())
}
async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> {
let context = AppContext::new(config.clone(), false).await?;
match subcommand {
AccountCommands::Link {
subcommand: link_sub,
} => {
match link_sub {
LinkCommands::List => {
let encryption = Encryption::new(config.cache.key.clone());
let link_store = LinkStore::load(config.cache.directory.clone());
let account_cache = crate::core::cache::AccountCache::load(
config.cache.directory.clone(),
encryption,
);
if link_store.links.is_empty() {
println!("No account links found.");
} else {
print_links_table(&link_store.links, &account_cache);
}
}
LinkCommands::Create {
source_account,
dest_account,
} => {
let encryption = Encryption::new(config.cache.key.clone());
let mut link_store = LinkStore::load(config.cache.directory.clone());
let account_cache = crate::core::cache::AccountCache::load(
config.cache.directory.clone(),
encryption,
);
match (source_account, dest_account) {
(None, None) => {
// Interactive mode
handle_interactive_link_creation(&mut link_store, &account_cache)?;
}
(Some(source), None) => {
// Single argument - try to resolve as source or destination
handle_single_arg_link_creation(
&mut link_store,
&account_cache,
&source,
)?;
}
(Some(source), Some(dest)) => {
// Two arguments - direct linking
handle_direct_link_creation(
&mut link_store,
&account_cache,
&source,
&dest,
)?;
}
(None, Some(_)) => {
println!("Error: Cannot specify destination without source. Use 'banks2ff accounts link create <source> <destination>' or interactive mode.");
}
}
}
}
}
AccountCommands::List { filter } => {
// Validate filter parameter
let show_gocardless = match filter.as_deref() {
Some("gocardless") => true,
Some("firefly") => false,
None => true, // Show both by default
Some(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
Some(_) => unreachable!(), // Already validated above
};
// Get GoCardless accounts if needed
let gocardless_accounts = if show_gocardless {
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(""))
});
accounts
}
Err(e) => {
warn!("Failed to list GoCardless accounts: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
// Get Firefly III accounts if needed
let firefly_accounts = if show_firefly {
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(""))
});
accounts
}
Err(e) => {
warn!("Failed to list Firefly III accounts: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
if gocardless_accounts.is_empty() && firefly_accounts.is_empty() {
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
} else {
// Print GoCardless accounts
if !gocardless_accounts.is_empty() {
println!("GoCardless Accounts ({}):", gocardless_accounts.len());
print_accounts_table(&gocardless_accounts);
}
// Print Firefly III accounts
if !firefly_accounts.is_empty() {
if !gocardless_accounts.is_empty() {
println!(); // Add spacing between tables
}
println!("Firefly III Accounts ({}):", firefly_accounts.len());
print_accounts_table(&firefly_accounts);
}
}
}
AccountCommands::Status => {
let encryption = Encryption::new(config.cache.key.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
let status = context.source.get_account_status().await?;
if status.is_empty() {
println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data.");
} else {
print_account_status_table(&status, &account_cache);
}
}
}
Ok(())
}
fn handle_interactive_link_creation(
link_store: &mut LinkStore,
account_cache: &AccountCache,
) -> anyhow::Result<()> {
// Get unlinked GoCardless accounts
let gocardless_accounts = get_gocardless_accounts(account_cache);
let unlinked_sources: Vec<_> = gocardless_accounts
.iter()
.filter(|acc| {
!link_store
.find_links_by_source(acc.id())
.iter()
.any(|link| link.dest_adapter_type == "firefly")
})
.collect();
if unlinked_sources.is_empty() {
println!("No unlinked source accounts found. All GoCardless accounts are already linked to Firefly III.");
return Ok(());
}
// Create selection items for dialoguer
let source_items: Vec<String> = unlinked_sources
.iter()
.map(|account| {
let display_name = account
.display_name()
.unwrap_or_else(|| account.id().to_string());
display_name.to_string()
})
.collect();
// Add cancel option
let mut items = source_items.clone();
items.push("Cancel".to_string());
// Prompt user to select source account
let source_selection =
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt("Select a source account to link")
.items(&items)
.default(0)
.interact()
{
Ok(selection) => selection,
Err(_) => {
// Non-interactive environment (e.g., tests, scripts)
println!("Interactive mode not available in this environment.");
println!("Use: banks2ff accounts link create <source> <destination>");
return Ok(());
}
};
if source_selection == items.len() - 1 {
// User selected "Cancel"
println!("Operation cancelled.");
return Ok(());
}
let selected_source = &unlinked_sources[source_selection];
handle_source_selection(link_store, account_cache, selected_source.id().to_string())?;
Ok(())
}
fn handle_single_arg_link_creation(
link_store: &mut LinkStore,
account_cache: &AccountCache,
arg: &str,
) -> anyhow::Result<()> {
// Try to find account by ID, name, or IBAN
let matched_account = find_account_by_identifier(account_cache, arg);
match matched_account {
Some((account_id, adapter_type)) => {
if adapter_type == "gocardless" {
// It's a source account - show available destinations
handle_source_selection(link_store, account_cache, account_id)
} else {
// It's a destination account - show available sources
handle_destination_selection(link_store, account_cache, account_id)
}
}
None => {
println!("No account found matching '{}'.", arg);
println!("Try using an account ID, name, or IBAN pattern.");
println!("Run 'banks2ff accounts list' to see available accounts.");
Ok(())
}
}
}
fn handle_direct_link_creation(
link_store: &mut LinkStore,
account_cache: &AccountCache,
source_arg: &str,
dest_arg: &str,
) -> anyhow::Result<()> {
let source_match = find_account_by_identifier(account_cache, source_arg);
let dest_match = find_account_by_identifier(account_cache, dest_arg);
match (source_match, dest_match) {
(Some((source_id, source_adapter)), Some((dest_id, dest_adapter))) => {
if source_adapter != "gocardless" {
println!(
"Error: Source must be a GoCardless account, got {} account.",
source_adapter
);
return Ok(());
}
if dest_adapter != "firefly" {
println!(
"Error: Destination must be a Firefly III account, got {} account.",
dest_adapter
);
return Ok(());
}
create_link(
link_store,
account_cache,
&source_id,
&dest_id,
&dest_adapter,
)
}
(None, _) => {
println!("Source account '{}' not found.", source_arg);
Ok(())
}
(_, None) => {
println!("Destination account '{}' not found.", dest_arg);
Ok(())
}
}
}
fn find_account_by_identifier(
account_cache: &AccountCache,
identifier: &str,
) -> Option<(String, String)> {
// First try exact ID match
if let Some(adapter_type) = account_cache.get_adapter_type(identifier) {
return Some((identifier.to_string(), adapter_type.to_string()));
}
// Then try name/IBAN matching
for (id, account) in &account_cache.accounts {
if let Some(display_name) = account.display_name() {
if display_name
.to_lowercase()
.contains(&identifier.to_lowercase())
{
let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) {
"gocardless"
} else {
"firefly"
};
return Some((id.clone(), adapter_type.to_string()));
}
}
if let Some(iban) = account.iban() {
if iban.contains(identifier) {
let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) {
"gocardless"
} else {
"firefly"
};
return Some((id.clone(), adapter_type.to_string()));
}
}
}
None
}
fn handle_source_selection(
link_store: &mut LinkStore,
account_cache: &AccountCache,
source_id: String,
) -> anyhow::Result<()> {
// Check if source is already linked to firefly
if let Some(existing_link) = link_store.find_link_by_source_and_dest_type(&source_id, "firefly")
{
let dest_name = account_cache
.get_display_name(&existing_link.dest_account_id)
.unwrap_or_else(|| existing_link.dest_account_id.clone());
println!(
"Source account '{}' is already linked to '{}'.",
account_cache
.get_display_name(&source_id)
.unwrap_or_else(|| source_id.clone()),
dest_name
);
return Ok(());
}
// Get available Firefly destinations
let firefly_accounts = get_firefly_accounts(account_cache);
if firefly_accounts.is_empty() {
println!("No Firefly III accounts found. Run sync first.");
return Ok(());
}
// Create selection items for dialoguer
let dest_items: Vec<String> = firefly_accounts
.iter()
.map(|account| {
let display_name = account
.display_name()
.unwrap_or_else(|| account.id().to_string());
display_name.to_string()
})
.collect();
// Add cancel option
let mut items = dest_items.clone();
items.push("Cancel".to_string());
// Prompt user to select destination account
let source_name = account_cache
.get_display_name(&source_id)
.unwrap_or_else(|| source_id.clone());
let dest_selection =
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!(
"Select a destination account for '{}'",
source_name
))
.items(&items)
.default(0)
.interact()
{
Ok(selection) => selection,
Err(_) => {
// Non-interactive environment (e.g., tests, scripts)
println!("Interactive mode not available in this environment.");
println!("Use: banks2ff accounts link create <source> <destination>");
return Ok(());
}
};
if dest_selection == items.len() - 1 {
// User selected "Cancel"
println!("Operation cancelled.");
return Ok(());
}
let selected_dest = &firefly_accounts[dest_selection];
create_link(
link_store,
account_cache,
&source_id,
selected_dest.id(),
"firefly",
)?;
Ok(())
}
fn handle_destination_selection(
link_store: &mut LinkStore,
account_cache: &AccountCache,
dest_id: String,
) -> anyhow::Result<()> {
// Get available GoCardless sources that aren't already linked to this destination
let gocardless_accounts = get_gocardless_accounts(account_cache);
let available_sources: Vec<_> = gocardless_accounts
.iter()
.filter(|acc| {
!link_store
.find_links_by_source(acc.id())
.iter()
.any(|link| link.dest_account_id == dest_id)
})
.collect();
if available_sources.is_empty() {
println!(
"No available source accounts found that can link to '{}'.",
account_cache
.get_display_name(&dest_id)
.unwrap_or_else(|| dest_id.clone())
);
return Ok(());
}
// Create selection items for dialoguer
let source_items: Vec<String> = available_sources
.iter()
.map(|account| {
let display_name = account
.display_name()
.unwrap_or_else(|| account.id().to_string());
display_name.to_string()
})
.collect();
// Add cancel option
let mut items = source_items.clone();
items.push("Cancel".to_string());
// Prompt user to select source account
let dest_name = account_cache
.get_display_name(&dest_id)
.unwrap_or_else(|| dest_id.clone());
let source_selection =
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!(
"Select a source account to link to '{}'",
dest_name
))
.items(&items)
.default(0)
.interact()
{
Ok(selection) => selection,
Err(_) => {
// Non-interactive environment (e.g., tests, scripts)
println!("Interactive mode not available in this environment.");
println!("Use: banks2ff accounts link create <source> <destination>");
return Ok(());
}
};
if source_selection == items.len() - 1 {
// User selected "Cancel"
println!("Operation cancelled.");
return Ok(());
}
let selected_source = &available_sources[source_selection];
create_link(
link_store,
account_cache,
selected_source.id(),
&dest_id,
"firefly",
)?;
Ok(())
}
fn create_link(
link_store: &mut LinkStore,
account_cache: &AccountCache,
source_id: &str,
dest_id: &str,
dest_adapter_type: &str,
) -> anyhow::Result<()> {
let source_acc = account_cache.get_account(source_id);
let dest_acc = account_cache.get_account(dest_id);
if let (Some(src), Some(dst)) = (source_acc, dest_acc) {
let src_minimal = Account {
id: src.id().to_string(),
name: Some(src.id().to_string()),
iban: src.iban().map(|s| s.to_string()),
currency: "EUR".to_string(),
};
let dst_minimal = Account {
id: dst.id().to_string(),
name: Some(dst.id().to_string()),
iban: dst.iban().map(|s| s.to_string()),
currency: "EUR".to_string(),
};
match link_store.add_link(
&src_minimal,
&dst_minimal,
"gocardless",
dest_adapter_type,
false,
) {
Ok(true) => {
link_store.save()?;
let src_display = account_cache
.get_display_name(source_id)
.unwrap_or_else(|| source_id.to_string());
let dst_display = account_cache
.get_display_name(dest_id)
.unwrap_or_else(|| dest_id.to_string());
println!("Created link between {} and {}", src_display, dst_display);
}
Ok(false) => {
let src_display = account_cache
.get_display_name(source_id)
.unwrap_or_else(|| source_id.to_string());
let dst_display = account_cache
.get_display_name(dest_id)
.unwrap_or_else(|| dest_id.to_string());
println!(
"Link between {} and {} already exists",
src_display, dst_display
);
}
Err(e) => {
println!("Cannot create link: {}", e);
}
}
} else {
println!("Account not found in cache. Run sync first.");
}
Ok(())
}
fn get_gocardless_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> {
account_cache
.accounts
.values()
.filter_map(|acc| match acc {
CachedAccount::GoCardless(gc_acc) => Some(gc_acc.as_ref() as &dyn AccountData),
_ => None,
})
.collect()
}
fn get_firefly_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> {
account_cache
.accounts
.values()
.filter_map(|acc| match acc {
CachedAccount::Firefly(ff_acc) => Some(ff_acc.as_ref() as &dyn AccountData),
_ => None,
})
.collect()
}
fn print_accounts_table(accounts: &[AccountSummary]) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "IBAN", "Currency"]);
for account in accounts {
let name = account.name.as_deref().unwrap_or("");
table.add_row(vec![
name.to_string(),
mask_iban(&account.iban),
account.currency.clone(),
]);
}
println!("{}", table);
}
fn print_links_table(
links: &[crate::core::linking::AccountLink],
account_cache: &crate::core::cache::AccountCache,
) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Source Account", "Destination Account", "Auto-Linked"]);
for link in links {
let source_name = account_cache
.get_display_name(&link.source_account_id)
.unwrap_or_else(|| format!("Account {}", &link.source_account_id));
let dest_name = account_cache
.get_display_name(&link.dest_account_id)
.unwrap_or_else(|| format!("Account {}", &link.dest_account_id));
let auto_linked = if link.auto_linked { "Yes" } else { "No" };
table.add_row(vec![source_name, dest_name, auto_linked.to_string()]);
}
println!("{}", table);
}
fn print_account_status_table(statuses: &[AccountStatus], account_cache: &AccountCache) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account",
"IBAN",
"Last Sync",
"Transaction Count",
"Status",
]);
for status in statuses {
let display_name = account_cache
.get_display_name(&status.account_id)
.unwrap_or_else(|| status.account_id.clone());
table.add_row(vec![
display_name,
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()
} else {
let country_code = &iban[0..2];
let last_four = &iban[iban.len() - 4..];
if country_code == "NL" && iban.len() >= 12 {
// NL: show first 2 (CC) + next 6 + mask + last 4
let next_six = &iban[2..8];
let mask_length = iban.len() - 12; // 2 + 6 + 4 = 12, so mask_length = total - 12
format!(
"{}{}{}{}",
country_code,
next_six,
"*".repeat(mask_length),
last_four
)
} else {
// Other countries: show first 2 + mask + last 4
let mask_length = iban.len() - 6; // 2 + 4 = 6
format!("{}{}{}", country_code, "*".repeat(mask_length), last_four)
}
}
}
async fn handle_transactions(
config: Config,
subcommand: TransactionCommands,
) -> anyhow::Result<()> {
let context = AppContext::new(config.clone(), false).await?;
let format = OutputFormat::Table; // TODO: Add --json flag
// Load account cache for display name resolution
let encryption = Encryption::new(config.cache.key.clone());
let account_cache =
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
match subcommand {
TransactionCommands::List { account_id } => {
let info = context.source.get_transaction_info(&account_id).await?;
if info.total_count == 0 {
println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", account_id);
} else {
print_list_output(vec![info], &format, Some(&account_cache));
}
}
TransactionCommands::CacheStatus => {
let cache_info = context.source.get_cache_info().await?;
if cache_info.is_empty() {
println!("No cache data available. Run 'banks2ff sync gocardless firefly' first to populate caches.");
} else {
print_list_output(cache_info, &format, Some(&account_cache));
}
}
TransactionCommands::ClearCache => {
// TODO: Implement cache clearing
println!("Cache clearing not yet implemented");
}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use crate::cli::tables::mask_iban;
use crate::core::cache::AccountCache;
use crate::core::encryption::Encryption;
#[test]
fn test_find_account_by_identifier_exact_id() {
let cache = AccountCache::new("test".to_string(), Encryption::new("test_key".to_string()));
// Add a mock account - we'd need to create a proper test setup
// For now, just test the function signature works
let result = find_account_by_identifier(&cache, "test_id");
assert!(result.is_none()); // No accounts in empty cache
}
#[test]
fn test_get_gocardless_accounts_empty_cache() {
let cache = AccountCache::new("test".to_string(), Encryption::new("test_key".to_string()));
let accounts = get_gocardless_accounts(&cache);
assert!(accounts.is_empty());
}
#[test]
fn test_get_firefly_accounts_empty_cache() {
let cache = AccountCache::new("test".to_string(), Encryption::new("test_key".to_string()));
let accounts = get_firefly_accounts(&cache);
assert!(accounts.is_empty());
}
#[test] #[test]
fn test_mask_iban_short() { fn test_mask_iban_short() {

View File

@@ -10,9 +10,19 @@ Banks2FF implements a **Hexagonal (Ports & Adapters) Architecture** to synchroni
banks2ff/ banks2ff/
├── banks2ff/ # Main CLI application ├── banks2ff/ # Main CLI application
│ └── src/ │ └── src/
│ ├── commands/ # Command handlers
│ │ ├── accounts/ # Account management commands
│ │ │ ├── mod.rs # Account commands dispatch
│ │ │ ├── link.rs # Account linking logic
│ │ │ ├── list.rs # Account listing functionality
│ │ │ └── status.rs # Account status functionality
│ │ ├── transactions/ # Transaction management commands
│ │ ├── list.rs # Source/destination listing
│ │ └── sync.rs # Sync command handler
│ ├── cli/ # CLI utilities and formatting
│ ├── core/ # Domain logic and models │ ├── core/ # Domain logic and models
│ ├── adapters/ # External service integrations │ ├── adapters/ # External service integrations
│ └── main.rs # CLI entry point │ └── main.rs # CLI entry point and command dispatch
├── firefly-client/ # Firefly III API client library ├── firefly-client/ # Firefly III API client library
├── gocardless-client/ # GoCardless API client library ├── gocardless-client/ # GoCardless API client library
└── docs/ # Architecture documentation └── docs/ # Architecture documentation
@@ -49,7 +59,15 @@ banks2ff/
- `client.rs`: Wrapper for Firefly client for transaction storage - `client.rs`: Wrapper for Firefly client for transaction storage
- Maps domain models to Firefly API format - Maps domain models to Firefly API format
### 3. API Clients ### 3. Command Handlers (`banks2ff/src/commands/`)
The CLI commands are organized into focused modules:
- **sync.rs**: Handles transaction synchronization between sources and destinations
- **accounts/**: Account management including linking, listing, and status
- **transactions/**: Transaction inspection, caching, and cache management
- **list.rs**: Simple listing of available sources and destinations
### 4. API Clients
Both clients are hand-crafted using `reqwest`: Both clients are hand-crafted using `reqwest`:
- Strongly-typed DTOs for compile-time safety - Strongly-typed DTOs for compile-time safety

View File

@@ -223,19 +223,46 @@ COMMANDS:
- Security audits for data handling - Security audits for data handling
- Compatibility tests with existing configurations - Compatibility tests with existing configurations
### Phase 9: File-Based Source Adapters ### Phase 9.5: Command Handler Extraction ✅ COMPLETED
**Objective**: Extract command handling logic from main.rs into dedicated modules for better maintainability and separation of concerns.
**Steps:**
1. ✅ Create `commands/` module structure with submodules for each command group
2. ✅ Extract table printing utilities to `cli/tables.rs`
3. ✅ Move command handlers to appropriate modules:
- `commands/sync.rs`: Sync command logic
- `commands/accounts/`: Account management (link, list, status)
- `commands/transactions/`: Transaction operations (list, cache, clear)
- `commands/list.rs`: Source/destination listing
4. ✅ Update main.rs to dispatch to new command modules
5. ✅ Remove extracted functions from main.rs, reducing it from 1049 to ~150 lines
6. ✅ Update documentation to reflect new structure
**Implementation Details:**
- Created hierarchical module structure with focused responsibilities
- Maintained all existing functionality and CLI interface
- Improved code organization and testability
- Updated architecture documentation with new module structure
**Testing:**
- All existing tests pass
- CLI functionality preserved
- Code formatting and linting applied
### Phase 10: File-Based Source Adapters
**Objective**: Implement adapters for file-based transaction sources. **Objective**: Implement adapters for file-based transaction sources.
**Steps:** **Steps:**
1. Create `adapters::csv` module implementing `TransactionSource` 1. Create `adapters::csv` module implementing `TransactionSource`
- Parse CSV files with configurable column mappings - Parse CSV files with configurable column mappings
- Implement caching similar to GoCardless adapter - Implement caching similar to GoCardless adapter
- Add inspection methods for file status and transaction counts - Add inspection methods for file status and transaction counts
2. Create `adapters::camt053` and `adapters::mt940` modules 2. Create `adapters::camt053` and `adapters::mt940` modules
- Parse respective financial file formats - Parse respective financial file formats
- Implement transaction mapping and validation - Implement transaction mapping and validation
- Add format-specific caching and inspection - Add format-specific caching and inspection
3. Update `adapter_factory` to instantiate file adapters with file paths 3. Update `adapter_factory` to instantiate file adapters with file paths
**Testing:** **Testing:**