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
|
- **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
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod formatters;
|
pub mod formatters;
|
||||||
pub mod setup;
|
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;
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|||||||
Reference in New Issue
Block a user