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:
14
AGENTS.md
14
AGENTS.md
@@ -186,6 +186,20 @@ After making ANY code change, you MUST run these commands and fix any issues:
|
||||
- **firefly/**: Firefly III API integration
|
||||
- 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
|
||||
- **gocardless-client/**: Standalone GoCardless API wrapper
|
||||
- **firefly-client/**: Standalone Firefly III API wrapper
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod formatters;
|
||||
pub mod setup;
|
||||
pub mod tables;
|
||||
|
||||
99
banks2ff/src/cli/tables.rs
Normal file
99
banks2ff/src/cli/tables.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
513
banks2ff/src/commands/accounts/link.rs
Normal file
513
banks2ff/src/commands/accounts/link.rs
Normal 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(())
|
||||
}
|
||||
91
banks2ff/src/commands/accounts/list.rs
Normal file
91
banks2ff/src/commands/accounts/list.rs
Normal 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(())
|
||||
}
|
||||
43
banks2ff/src/commands/accounts/mod.rs
Normal file
43
banks2ff/src/commands/accounts/mod.rs
Normal 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(())
|
||||
}
|
||||
21
banks2ff/src/commands/accounts/status.rs
Normal file
21
banks2ff/src/commands/accounts/status.rs
Normal 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(())
|
||||
}
|
||||
17
banks2ff/src/commands/list.rs
Normal file
17
banks2ff/src/commands/list.rs
Normal 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(())
|
||||
}
|
||||
4
banks2ff/src/commands/mod.rs
Normal file
4
banks2ff/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod accounts;
|
||||
pub mod list;
|
||||
pub mod sync;
|
||||
pub mod transactions;
|
||||
88
banks2ff/src/commands/sync.rs
Normal file
88
banks2ff/src/commands/sync.rs
Normal 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(())
|
||||
}
|
||||
24
banks2ff/src/commands/transactions/cache.rs
Normal file
24
banks2ff/src/commands/transactions/cache.rs
Normal 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(())
|
||||
}
|
||||
7
banks2ff/src/commands/transactions/clear.rs
Normal file
7
banks2ff/src/commands/transactions/clear.rs
Normal 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(())
|
||||
}
|
||||
24
banks2ff/src/commands/transactions/list.rs
Normal file
24
banks2ff/src/commands/transactions/list.rs
Normal 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(())
|
||||
}
|
||||
41
banks2ff/src/commands/transactions/mod.rs
Normal file
41
banks2ff/src/commands/transactions/mod.rs
Normal 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(())
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use anyhow::{anyhow, Result};
|
||||
use std::env;
|
||||
|
||||
/// Main application configuration
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub gocardless: GoCardlessConfig,
|
||||
pub firefly: FireflyConfig,
|
||||
@@ -16,7 +16,7 @@ pub struct Config {
|
||||
}
|
||||
|
||||
/// GoCardless API configuration
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct GoCardlessConfig {
|
||||
pub url: String,
|
||||
pub secret_id: String,
|
||||
@@ -24,21 +24,21 @@ pub struct GoCardlessConfig {
|
||||
}
|
||||
|
||||
/// Firefly III API configuration
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct FireflyConfig {
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Cache configuration
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct CacheConfig {
|
||||
pub key: String,
|
||||
pub directory: String,
|
||||
}
|
||||
|
||||
/// Logging configuration
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct LoggingConfig {
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct SyncResult {
|
||||
pub accounts_skipped_errors: usize,
|
||||
}
|
||||
|
||||
#[instrument(skip(source, destination))]
|
||||
#[instrument(skip(source, destination, config))]
|
||||
pub async fn run_sync(
|
||||
source: impl TransactionSource,
|
||||
destination: impl TransactionDestination,
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
mod adapters;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod core;
|
||||
mod debug;
|
||||
|
||||
use crate::cli::formatters::{print_list_output, OutputFormat};
|
||||
use crate::cli::setup::AppContext;
|
||||
use crate::core::adapters::{
|
||||
get_available_destinations, get_available_sources, is_valid_destination, is_valid_source,
|
||||
};
|
||||
use crate::core::cache::{AccountCache, CachedAccount};
|
||||
use crate::commands::accounts::AccountCommands;
|
||||
use crate::commands::sync::handle_sync;
|
||||
use crate::commands::transactions::TransactionCommands;
|
||||
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 clap::{Parser, Subcommand};
|
||||
use comfy_table::{presets::UTF8_FULL, Table};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
@@ -73,48 +65,6 @@ enum Commands {
|
||||
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]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load environment variables first
|
||||
@@ -166,870 +116,27 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
Commands::Sources => {
|
||||
handle_sources().await?;
|
||||
commands::list::handle_sources().await?;
|
||||
}
|
||||
Commands::Destinations => {
|
||||
handle_destinations().await?;
|
||||
commands::list::handle_destinations().await?;
|
||||
}
|
||||
|
||||
Commands::Accounts { subcommand } => {
|
||||
handle_accounts(config, subcommand).await?;
|
||||
commands::accounts::handle_accounts(config, subcommand).await?;
|
||||
}
|
||||
|
||||
Commands::Transactions { subcommand } => {
|
||||
handle_transactions(config, subcommand).await?;
|
||||
commands::transactions::handle_transactions(config, subcommand).await?;
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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());
|
||||
}
|
||||
use crate::cli::tables::mask_iban;
|
||||
|
||||
#[test]
|
||||
fn test_mask_iban_short() {
|
||||
|
||||
@@ -10,9 +10,19 @@ Banks2FF implements a **Hexagonal (Ports & Adapters) Architecture** to synchroni
|
||||
banks2ff/
|
||||
├── banks2ff/ # Main CLI application
|
||||
│ └── 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
|
||||
│ ├── adapters/ # External service integrations
|
||||
│ └── main.rs # CLI entry point
|
||||
│ └── main.rs # CLI entry point and command dispatch
|
||||
├── firefly-client/ # Firefly III API client library
|
||||
├── gocardless-client/ # GoCardless API client library
|
||||
└── docs/ # Architecture documentation
|
||||
@@ -49,7 +59,15 @@ banks2ff/
|
||||
- `client.rs`: Wrapper for Firefly client for transaction storage
|
||||
- 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`:
|
||||
- Strongly-typed DTOs for compile-time safety
|
||||
|
||||
@@ -223,19 +223,46 @@ COMMANDS:
|
||||
- Security audits for data handling
|
||||
- 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.
|
||||
|
||||
**Steps:**
|
||||
1. Create `adapters::csv` module implementing `TransactionSource`
|
||||
- Parse CSV files with configurable column mappings
|
||||
- Implement caching similar to GoCardless adapter
|
||||
- Add inspection methods for file status and transaction counts
|
||||
- Parse CSV files with configurable column mappings
|
||||
- Implement caching similar to GoCardless adapter
|
||||
- Add inspection methods for file status and transaction counts
|
||||
2. Create `adapters::camt053` and `adapters::mt940` modules
|
||||
- Parse respective financial file formats
|
||||
- Implement transaction mapping and validation
|
||||
- Add format-specific caching and inspection
|
||||
- Parse respective financial file formats
|
||||
- Implement transaction mapping and validation
|
||||
- Add format-specific caching and inspection
|
||||
3. Update `adapter_factory` to instantiate file adapters with file paths
|
||||
|
||||
**Testing:**
|
||||
|
||||
Reference in New Issue
Block a user