feat: Introduce configuration structs
Instead of relying on the environment variables everywhere, this is now abstracted to a config struct. Furthermore, all tests now also use this struct and are made fully independent of each other. That is because now they don't rely on the set_env() / get_env() calls any more, which are not thread safe.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use crate::core::config::Config;
|
||||
use crate::core::models::{Account, BankTransaction};
|
||||
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
||||
use anyhow::Result;
|
||||
@@ -15,12 +16,14 @@ use tracing::instrument;
|
||||
|
||||
pub struct FireflyAdapter {
|
||||
client: Arc<Mutex<FireflyClient>>,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl FireflyAdapter {
|
||||
pub fn new(client: FireflyClient) -> Self {
|
||||
pub fn new(client: FireflyClient, config: Config) -> Self {
|
||||
Self {
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +176,9 @@ impl TransactionDestination for FireflyAdapter {
|
||||
let mut result = Vec::new();
|
||||
|
||||
// Cache the accounts
|
||||
let mut cache = crate::core::cache::AccountCache::load();
|
||||
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
|
||||
let mut cache =
|
||||
crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption);
|
||||
|
||||
for acc in accounts.data {
|
||||
let is_active = acc.attributes.active.unwrap_or(true);
|
||||
@@ -221,9 +226,9 @@ impl TransactionDestination for FireflyAdapter {
|
||||
zoom_level: acc.attributes.zoom_level,
|
||||
last_activity: acc.attributes.last_activity.clone(),
|
||||
};
|
||||
cache.insert(crate::core::cache::CachedAccount::Firefly(
|
||||
Box::new(ff_account),
|
||||
));
|
||||
cache.insert(crate::core::cache::CachedAccount::Firefly(Box::new(
|
||||
ff_account,
|
||||
)));
|
||||
cache.save();
|
||||
|
||||
result.push(Account {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::core::cache::{AccountCache, CachedAccount, GoCardlessAccount};
|
||||
use crate::adapters::gocardless::mapper::map_transaction;
|
||||
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
||||
use crate::core::cache::{AccountCache, CachedAccount, GoCardlessAccount};
|
||||
use crate::core::config::Config;
|
||||
use crate::core::encryption::Encryption;
|
||||
use crate::core::models::{
|
||||
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
||||
};
|
||||
@@ -19,14 +21,20 @@ pub struct GoCardlessAdapter {
|
||||
client: Arc<Mutex<GoCardlessClient>>,
|
||||
cache: Arc<Mutex<AccountCache>>,
|
||||
transaction_caches: Arc<Mutex<HashMap<String, AccountTransactionCache>>>,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl GoCardlessAdapter {
|
||||
pub fn new(client: GoCardlessClient) -> Self {
|
||||
pub fn new(client: GoCardlessClient, config: Config) -> Self {
|
||||
let encryption = Encryption::new(config.cache.key.clone());
|
||||
Self {
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
cache: Arc::new(Mutex::new(AccountCache::load())),
|
||||
cache: Arc::new(Mutex::new(AccountCache::load(
|
||||
config.cache.directory.clone(),
|
||||
encryption,
|
||||
))),
|
||||
transaction_caches: Arc::new(Mutex::new(HashMap::new())),
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,9 +197,11 @@ impl TransactionSource for GoCardlessAdapter {
|
||||
// Load or get transaction cache
|
||||
let mut caches = self.transaction_caches.lock().await;
|
||||
let cache = caches.entry(account_id.to_string()).or_insert_with(|| {
|
||||
AccountTransactionCache::load(account_id).unwrap_or_else(|_| AccountTransactionCache {
|
||||
account_id: account_id.to_string(),
|
||||
ranges: Vec::new(),
|
||||
let encryption = Encryption::new(self.config.cache.key.clone());
|
||||
let cache_dir = self.config.cache.directory.clone();
|
||||
AccountTransactionCache::load(account_id, cache_dir.clone(), encryption.clone())
|
||||
.unwrap_or_else(|_| {
|
||||
AccountTransactionCache::new(account_id.to_string(), cache_dir, encryption)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ use gocardless_client::models::Transaction;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountTransactionCache {
|
||||
pub account_id: String,
|
||||
pub ranges: Vec<CachedRange>,
|
||||
cache_dir: String,
|
||||
encryption: Encryption,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -18,45 +20,65 @@ pub struct CachedRange {
|
||||
pub transactions: Vec<Transaction>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AccountTransactionCacheData {
|
||||
pub account_id: String,
|
||||
pub ranges: Vec<CachedRange>,
|
||||
}
|
||||
|
||||
impl AccountTransactionCache {
|
||||
/// Create new cache with directory and encryption
|
||||
pub fn new(account_id: String, cache_dir: String, encryption: Encryption) -> Self {
|
||||
Self {
|
||||
account_id,
|
||||
cache_dir,
|
||||
encryption,
|
||||
ranges: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache file path for an account
|
||||
fn get_cache_path(account_id: &str) -> String {
|
||||
let cache_dir =
|
||||
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
format!("{}/transactions/{}.enc", cache_dir, account_id)
|
||||
fn get_cache_path(&self, account_id: &str) -> String {
|
||||
format!("{}/transactions/{}.enc", self.cache_dir, account_id)
|
||||
}
|
||||
|
||||
/// Load cache from disk
|
||||
pub fn load(account_id: &str) -> Result<Self> {
|
||||
let path = Self::get_cache_path(account_id);
|
||||
pub fn load(account_id: &str, cache_dir: String, encryption: Encryption) -> Result<Self> {
|
||||
let path = format!("{}/transactions/{}.enc", cache_dir, account_id);
|
||||
|
||||
if !Path::new(&path).exists() {
|
||||
// Return empty cache if file doesn't exist
|
||||
return Ok(Self {
|
||||
account_id: account_id.to_string(),
|
||||
ranges: Vec::new(),
|
||||
});
|
||||
return Ok(Self::new(account_id.to_string(), cache_dir, encryption));
|
||||
}
|
||||
|
||||
// Read encrypted data
|
||||
let encrypted_data = std::fs::read(&path)?;
|
||||
let json_data = Encryption::decrypt(&encrypted_data)?;
|
||||
let json_data = encryption.decrypt(&encrypted_data)?;
|
||||
|
||||
// Deserialize
|
||||
let cache: Self = serde_json::from_slice(&json_data)?;
|
||||
Ok(cache)
|
||||
let cache_data: AccountTransactionCacheData = serde_json::from_slice(&json_data)?;
|
||||
Ok(Self {
|
||||
account_id: cache_data.account_id,
|
||||
ranges: cache_data.ranges,
|
||||
cache_dir,
|
||||
encryption,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save cache to disk
|
||||
pub fn save(&self) -> Result<()> {
|
||||
// Serialize to JSON
|
||||
let json_data = serde_json::to_vec(self)?;
|
||||
// Serialize to JSON (only the data fields)
|
||||
let cache_data = AccountTransactionCacheData {
|
||||
account_id: self.account_id.clone(),
|
||||
ranges: self.ranges.clone(),
|
||||
};
|
||||
let json_data = serde_json::to_vec(&cache_data)?;
|
||||
|
||||
// Encrypt
|
||||
let encrypted_data = Encryption::encrypt(&json_data)?;
|
||||
let encrypted_data = self.encryption.encrypt(&json_data)?;
|
||||
|
||||
// Write to file (create directory if needed)
|
||||
let path = Self::get_cache_path(&self.account_id);
|
||||
let path = self.get_cache_path(&self.account_id);
|
||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -261,10 +283,12 @@ impl AccountTransactionCache {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use std::env;
|
||||
|
||||
fn setup_test_env(test_name: &str) -> String {
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
fn create_unique_key(prefix: &str) -> String {
|
||||
format!("{}-{}", prefix, rand::random::<u64>())
|
||||
}
|
||||
|
||||
fn setup_test_dir(test_name: &str) -> String {
|
||||
// Use a unique cache directory for each test to avoid interference
|
||||
// Include random component and timestamp for true parallelism safety
|
||||
let random_suffix = rand::random::<u64>();
|
||||
@@ -272,12 +296,10 @@ mod tests {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let cache_dir = format!(
|
||||
format!(
|
||||
"tmp/test-cache-{}-{}-{}",
|
||||
test_name, random_suffix, timestamp
|
||||
);
|
||||
env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone());
|
||||
cache_dir
|
||||
)
|
||||
}
|
||||
|
||||
fn cleanup_test_dir(cache_dir: &str) {
|
||||
@@ -299,8 +321,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_cache() {
|
||||
let cache_dir = setup_test_env("nonexistent");
|
||||
let cache = AccountTransactionCache::load("nonexistent").unwrap();
|
||||
let cache_dir = setup_test_dir("nonexistent");
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let cache =
|
||||
AccountTransactionCache::load("nonexistent", cache_dir.clone(), encryption).unwrap();
|
||||
assert_eq!(cache.account_id, "nonexistent");
|
||||
assert!(cache.ranges.is_empty());
|
||||
cleanup_test_dir(&cache_dir);
|
||||
@@ -308,25 +332,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_empty_cache() {
|
||||
let cache_dir = setup_test_env("empty");
|
||||
let cache_dir = setup_test_dir("empty");
|
||||
let encryption_key = create_unique_key("test-key");
|
||||
let encryption = Encryption::new(encryption_key.clone());
|
||||
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test_account_empty".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
let cache = AccountTransactionCache::new(
|
||||
"test_account_empty".to_string(),
|
||||
cache_dir.clone(),
|
||||
encryption,
|
||||
);
|
||||
|
||||
// Ensure env vars are set before save
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Ensure env vars are set before save
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Save
|
||||
cache.save().expect("Save should succeed");
|
||||
|
||||
// Ensure env vars are set before load
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Load
|
||||
let encryption = Encryption::new(encryption_key);
|
||||
let loaded =
|
||||
AccountTransactionCache::load("test_account_empty").expect("Load should succeed");
|
||||
AccountTransactionCache::load("test_account_empty", cache_dir.clone(), encryption)
|
||||
.expect("Load should succeed");
|
||||
|
||||
assert_eq!(loaded.account_id, "test_account_empty");
|
||||
assert!(loaded.ranges.is_empty());
|
||||
@@ -336,7 +359,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_with_data() {
|
||||
let cache_dir = setup_test_env("data");
|
||||
let cache_dir = setup_test_dir("data");
|
||||
let encryption_key = create_unique_key("test-key");
|
||||
|
||||
let transaction = Transaction {
|
||||
transaction_id: Some("test-tx-1".to_string()),
|
||||
@@ -377,21 +401,22 @@ mod tests {
|
||||
transactions: vec![transaction],
|
||||
};
|
||||
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test_account_data".to_string(),
|
||||
ranges: vec![range],
|
||||
};
|
||||
let encryption = Encryption::new(encryption_key.clone());
|
||||
let mut cache = AccountTransactionCache::new(
|
||||
"test_account_data".to_string(),
|
||||
cache_dir.clone(),
|
||||
encryption,
|
||||
);
|
||||
cache.ranges = vec![range];
|
||||
|
||||
// Ensure env vars are set before save
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Save
|
||||
cache.save().expect("Save should succeed");
|
||||
|
||||
// Ensure env vars are set before load
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Load
|
||||
let encryption = Encryption::new(encryption_key);
|
||||
let loaded =
|
||||
AccountTransactionCache::load("test_account_data").expect("Load should succeed");
|
||||
AccountTransactionCache::load("test_account_data", cache_dir.clone(), encryption)
|
||||
.expect("Load should succeed");
|
||||
|
||||
assert_eq!(loaded.account_id, "test_account_data");
|
||||
assert_eq!(loaded.ranges.len(), 1);
|
||||
@@ -406,32 +431,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_load_different_accounts() {
|
||||
let cache_dir = setup_test_env("different_accounts");
|
||||
let cache_dir = setup_test_dir("different_accounts");
|
||||
let encryption_key_a = create_unique_key("test-key-a");
|
||||
let encryption_key_b = create_unique_key("test-key-b");
|
||||
|
||||
// Save cache for account A
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let cache_a = AccountTransactionCache {
|
||||
account_id: "account_a".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
let encryption_a = Encryption::new(encryption_key_a.clone());
|
||||
let cache_a =
|
||||
AccountTransactionCache::new("account_a".to_string(), cache_dir.clone(), encryption_a);
|
||||
cache_a.save().unwrap();
|
||||
|
||||
// Save cache for account B
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let cache_b = AccountTransactionCache {
|
||||
account_id: "account_b".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
let encryption_b = Encryption::new(encryption_key_b.clone());
|
||||
let cache_b =
|
||||
AccountTransactionCache::new("account_b".to_string(), cache_dir.clone(), encryption_b);
|
||||
cache_b.save().unwrap();
|
||||
|
||||
// Load account A
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let loaded_a = AccountTransactionCache::load("account_a").unwrap();
|
||||
let encryption_a = Encryption::new(encryption_key_a);
|
||||
let loaded_a =
|
||||
AccountTransactionCache::load("account_a", cache_dir.clone(), encryption_a).unwrap();
|
||||
assert_eq!(loaded_a.account_id, "account_a");
|
||||
|
||||
// Load account B
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let loaded_b = AccountTransactionCache::load("account_b").unwrap();
|
||||
let encryption_b = Encryption::new(encryption_key_b);
|
||||
let loaded_b =
|
||||
AccountTransactionCache::load("account_b", cache_dir.clone(), encryption_b).unwrap();
|
||||
assert_eq!(loaded_b.account_id, "account_b");
|
||||
|
||||
cleanup_test_dir(&cache_dir);
|
||||
@@ -439,10 +464,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_uncovered_ranges_no_cache() {
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let cache =
|
||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
||||
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||
@@ -456,10 +480,10 @@ mod tests {
|
||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
||||
transactions: Vec::new(),
|
||||
};
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test".to_string(),
|
||||
ranges: vec![range],
|
||||
};
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let mut cache =
|
||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
||||
cache.ranges = vec![range];
|
||||
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||
@@ -473,10 +497,10 @@ mod tests {
|
||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
|
||||
transactions: Vec::new(),
|
||||
};
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test".to_string(),
|
||||
ranges: vec![range],
|
||||
};
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let mut cache =
|
||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
||||
cache.ranges = vec![range];
|
||||
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||
@@ -499,10 +523,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_store_transactions_and_merge() {
|
||||
let mut cache = AccountTransactionCache {
|
||||
account_id: "test".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let mut cache =
|
||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
||||
let start1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let tx1 = Transaction {
|
||||
@@ -590,10 +613,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_transaction_deduplication() {
|
||||
let mut cache = AccountTransactionCache {
|
||||
account_id: "test".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let mut cache =
|
||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
||||
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let tx1 = Transaction {
|
||||
@@ -673,10 +695,10 @@ mod tests {
|
||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
||||
transactions: vec![tx1],
|
||||
};
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test".to_string(),
|
||||
ranges: vec![range],
|
||||
};
|
||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
||||
let mut cache =
|
||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
||||
cache.ranges = vec![range];
|
||||
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let cached = cache.get_cached_transactions(start, end);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::adapters::firefly::client::FireflyAdapter;
|
||||
|
||||
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
||||
use crate::core::config::Config;
|
||||
use crate::debug::DebugLogger;
|
||||
use anyhow::Result;
|
||||
use firefly_client::client::FireflyClient;
|
||||
use gocardless_client::client::GoCardlessClient;
|
||||
use reqwest_middleware::ClientBuilder;
|
||||
use std::env;
|
||||
|
||||
pub struct AppContext {
|
||||
pub source: GoCardlessAdapter,
|
||||
@@ -14,38 +14,38 @@ pub struct AppContext {
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub async fn new(debug: bool) -> Result<Self> {
|
||||
// Config Load
|
||||
let gc_url = env::var("GOCARDLESS_URL")
|
||||
.unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
||||
let gc_id = env::var("GOCARDLESS_ID").expect("GOCARDLESS_ID not set");
|
||||
let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set");
|
||||
|
||||
let ff_url = env::var("FIREFLY_III_URL").expect("FIREFLY_III_URL not set");
|
||||
let ff_key = env::var("FIREFLY_III_API_KEY").expect("FIREFLY_III_API_KEY not set");
|
||||
|
||||
pub async fn new(config: Config, debug: bool) -> Result<Self> {
|
||||
// Clients
|
||||
let gc_client = if debug {
|
||||
let client = ClientBuilder::new(reqwest::Client::new())
|
||||
.with(DebugLogger::new("gocardless"))
|
||||
.build();
|
||||
GoCardlessClient::with_client(&gc_url, &gc_id, &gc_key, Some(client))?
|
||||
GoCardlessClient::with_client(
|
||||
&config.gocardless.url,
|
||||
&config.gocardless.secret_id,
|
||||
&config.gocardless.secret_key,
|
||||
Some(client),
|
||||
)?
|
||||
} else {
|
||||
GoCardlessClient::new(&gc_url, &gc_id, &gc_key)?
|
||||
GoCardlessClient::new(
|
||||
&config.gocardless.url,
|
||||
&config.gocardless.secret_id,
|
||||
&config.gocardless.secret_key,
|
||||
)?
|
||||
};
|
||||
|
||||
let ff_client = if debug {
|
||||
let client = ClientBuilder::new(reqwest::Client::new())
|
||||
.with(DebugLogger::new("firefly"))
|
||||
.build();
|
||||
FireflyClient::with_client(&ff_url, &ff_key, Some(client))?
|
||||
FireflyClient::with_client(&config.firefly.url, &config.firefly.api_key, Some(client))?
|
||||
} else {
|
||||
FireflyClient::new(&ff_url, &ff_key)?
|
||||
FireflyClient::new(&config.firefly.url, &config.firefly.api_key)?
|
||||
};
|
||||
|
||||
// Adapters
|
||||
let source = GoCardlessAdapter::new(gc_client);
|
||||
let destination = FireflyAdapter::new(ff_client);
|
||||
let source = GoCardlessAdapter::new(gc_client, config.clone());
|
||||
let destination = FireflyAdapter::new(ff_client, config);
|
||||
|
||||
Ok(Self {
|
||||
source,
|
||||
|
||||
@@ -6,13 +6,13 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum CachedAccount {
|
||||
GoCardless(Box<GoCardlessAccount>),
|
||||
Firefly(Box<FireflyAccount>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GoCardlessAccount {
|
||||
pub id: String,
|
||||
pub iban: Option<String>,
|
||||
@@ -27,7 +27,7 @@ pub struct GoCardlessAccount {
|
||||
pub cash_account_type: Option<String>, // From AccountDetail.cashAccountType
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FireflyAccount {
|
||||
pub id: String,
|
||||
pub name: String, // From Account.name
|
||||
@@ -167,26 +167,49 @@ impl AccountData for FireflyAccount {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AccountCache {
|
||||
/// Map of Account ID -> Full Account Data
|
||||
pub accounts: HashMap<String, CachedAccount>,
|
||||
/// Cache directory path
|
||||
cache_dir: String,
|
||||
/// Encryption instance
|
||||
encryption: Encryption,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct AccountCacheData {
|
||||
/// Map of Account ID -> Full Account Data
|
||||
pub accounts: HashMap<String, CachedAccount>,
|
||||
}
|
||||
|
||||
impl AccountCache {
|
||||
fn get_path() -> String {
|
||||
let cache_dir =
|
||||
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
format!("{}/accounts.enc", cache_dir)
|
||||
/// Create new AccountCache with directory and encryption
|
||||
pub fn new(cache_dir: String, encryption: Encryption) -> Self {
|
||||
Self {
|
||||
accounts: HashMap::new(),
|
||||
cache_dir,
|
||||
encryption,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let path = Self::get_path();
|
||||
fn get_path(&self) -> String {
|
||||
format!("{}/accounts.enc", self.cache_dir)
|
||||
}
|
||||
|
||||
pub fn load(cache_dir: String, encryption: Encryption) -> Self {
|
||||
let path = format!("{}/accounts.enc", cache_dir);
|
||||
if Path::new(&path).exists() {
|
||||
match fs::read(&path) {
|
||||
Ok(encrypted_data) => match Encryption::decrypt(&encrypted_data) {
|
||||
Ok(json_data) => match serde_json::from_slice(&json_data) {
|
||||
Ok(cache) => return cache,
|
||||
Ok(encrypted_data) => match encryption.decrypt(&encrypted_data) {
|
||||
Ok(json_data) => match serde_json::from_slice::<AccountCacheData>(&json_data) {
|
||||
Ok(cache_data) => {
|
||||
return Self {
|
||||
accounts: cache_data.accounts,
|
||||
cache_dir,
|
||||
encryption,
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse cache file: {}", e),
|
||||
},
|
||||
Err(e) => warn!("Failed to decrypt cache file: {}", e),
|
||||
@@ -194,11 +217,11 @@ impl AccountCache {
|
||||
Err(e) => warn!("Failed to read cache file: {}", e),
|
||||
}
|
||||
}
|
||||
Self::default()
|
||||
Self::new(cache_dir, encryption)
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let path = Self::get_path();
|
||||
let path = self.get_path();
|
||||
|
||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
@@ -210,8 +233,10 @@ impl AccountCache {
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::to_vec(self) {
|
||||
Ok(json_data) => match Encryption::encrypt(&json_data) {
|
||||
match serde_json::to_vec(&AccountCacheData {
|
||||
accounts: self.accounts.clone(),
|
||||
}) {
|
||||
Ok(json_data) => match self.encryption.encrypt(&json_data) {
|
||||
Ok(encrypted_data) => {
|
||||
if let Err(e) = fs::write(&path, encrypted_data) {
|
||||
warn!("Failed to write cache file: {}", e);
|
||||
|
||||
232
banks2ff/src/core/config.rs
Normal file
232
banks2ff/src/core/config.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! Configuration management for banks2ff
|
||||
//!
|
||||
//! Provides centralized configuration loading from environment variables
|
||||
//! with type-safe configuration structures.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::env;
|
||||
|
||||
/// Main application configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub gocardless: GoCardlessConfig,
|
||||
pub firefly: FireflyConfig,
|
||||
pub cache: CacheConfig,
|
||||
pub logging: LoggingConfig,
|
||||
}
|
||||
|
||||
/// GoCardless API configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoCardlessConfig {
|
||||
pub url: String,
|
||||
pub secret_id: String,
|
||||
pub secret_key: String,
|
||||
}
|
||||
|
||||
/// Firefly III API configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FireflyConfig {
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Cache configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheConfig {
|
||||
pub key: String,
|
||||
pub directory: String,
|
||||
}
|
||||
|
||||
/// Logging configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoggingConfig {
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from environment variables
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let gocardless = GoCardlessConfig::from_env()?;
|
||||
let firefly = FireflyConfig::from_env()?;
|
||||
let cache = CacheConfig::from_env()?;
|
||||
let logging = LoggingConfig::from_env()?;
|
||||
|
||||
Ok(Self {
|
||||
gocardless,
|
||||
firefly,
|
||||
cache,
|
||||
logging,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GoCardlessConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let url = env::var("GOCARDLESS_URL")
|
||||
.unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
||||
let secret_id = env::var("GOCARDLESS_ID")
|
||||
.map_err(|_| anyhow!("GOCARDLESS_ID environment variable not set"))?;
|
||||
let secret_key = env::var("GOCARDLESS_KEY")
|
||||
.map_err(|_| anyhow!("GOCARDLESS_KEY environment variable not set"))?;
|
||||
|
||||
Ok(Self {
|
||||
url,
|
||||
secret_id,
|
||||
secret_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FireflyConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let url = env::var("FIREFLY_III_URL")
|
||||
.map_err(|_| anyhow!("FIREFLY_III_URL environment variable not set"))?;
|
||||
let api_key = env::var("FIREFLY_III_API_KEY")
|
||||
.map_err(|_| anyhow!("FIREFLY_III_API_KEY environment variable not set"))?;
|
||||
|
||||
Ok(Self { url, api_key })
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let key = env::var("BANKS2FF_CACHE_KEY")
|
||||
.map_err(|_| anyhow!("BANKS2FF_CACHE_KEY environment variable not set"))?;
|
||||
let directory = env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
|
||||
Ok(Self { key, directory })
|
||||
}
|
||||
}
|
||||
|
||||
impl LoggingConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let level = env::var("RUST_LOG").unwrap_or_else(|_| "warn".to_string());
|
||||
Ok(Self { level })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn test_gocardless_config_from_env() {
|
||||
env::set_var("GOCARDLESS_ID", "test-id");
|
||||
env::set_var("GOCARDLESS_KEY", "test-key");
|
||||
env::set_var("GOCARDLESS_URL", "https://test.example.com");
|
||||
|
||||
let config = GoCardlessConfig::from_env().unwrap();
|
||||
assert_eq!(config.secret_id, "test-id");
|
||||
assert_eq!(config.secret_key, "test-key");
|
||||
assert_eq!(config.url, "https://test.example.com");
|
||||
|
||||
env::remove_var("GOCARDLESS_ID");
|
||||
env::remove_var("GOCARDLESS_KEY");
|
||||
env::remove_var("GOCARDLESS_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gocardless_config_default_url() {
|
||||
env::set_var("GOCARDLESS_ID", "test-id");
|
||||
env::set_var("GOCARDLESS_KEY", "test-key");
|
||||
env::remove_var("GOCARDLESS_URL");
|
||||
|
||||
let config = GoCardlessConfig::from_env().unwrap();
|
||||
assert_eq!(config.url, "https://bankaccountdata.gocardless.com");
|
||||
|
||||
env::remove_var("GOCARDLESS_ID");
|
||||
env::remove_var("GOCARDLESS_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gocardless_config_missing_id() {
|
||||
env::remove_var("GOCARDLESS_ID");
|
||||
env::set_var("GOCARDLESS_KEY", "test-key");
|
||||
|
||||
let result = GoCardlessConfig::from_env();
|
||||
assert!(result.is_err());
|
||||
|
||||
env::remove_var("GOCARDLESS_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_firefly_config_from_env() {
|
||||
env::set_var("FIREFLY_III_URL", "https://firefly.test.com");
|
||||
env::set_var("FIREFLY_III_API_KEY", "test-api-key");
|
||||
|
||||
let config = FireflyConfig::from_env().unwrap();
|
||||
assert_eq!(config.url, "https://firefly.test.com");
|
||||
assert_eq!(config.api_key, "test-api-key");
|
||||
|
||||
env::remove_var("FIREFLY_III_URL");
|
||||
env::remove_var("FIREFLY_III_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_config_from_env() {
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
env::set_var("BANKS2FF_CACHE_DIR", "/tmp/test-cache");
|
||||
|
||||
let config = CacheConfig::from_env().unwrap();
|
||||
assert_eq!(config.key, "test-cache-key");
|
||||
assert_eq!(config.directory, "/tmp/test-cache");
|
||||
|
||||
env::remove_var("BANKS2FF_CACHE_KEY");
|
||||
env::remove_var("BANKS2FF_CACHE_DIR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_config_default_directory() {
|
||||
env::remove_var("BANKS2FF_CACHE_DIR");
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
|
||||
let config = CacheConfig::from_env().unwrap();
|
||||
assert_eq!(config.directory, "data/cache");
|
||||
|
||||
env::remove_var("BANKS2FF_CACHE_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_config_from_env() {
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
|
||||
let config = LoggingConfig::from_env().unwrap();
|
||||
assert_eq!(config.level, "debug");
|
||||
|
||||
env::remove_var("RUST_LOG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logging_config_default() {
|
||||
env::remove_var("RUST_LOG");
|
||||
|
||||
let config = LoggingConfig::from_env().unwrap();
|
||||
assert_eq!(config.level, "warn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_config_from_env() {
|
||||
env::set_var("GOCARDLESS_ID", "test-id");
|
||||
env::set_var("GOCARDLESS_KEY", "test-key");
|
||||
env::set_var("FIREFLY_III_URL", "https://firefly.test.com");
|
||||
env::set_var("FIREFLY_III_API_KEY", "test-api-key");
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
env::set_var("RUST_LOG", "info");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
assert_eq!(config.gocardless.secret_id, "test-id");
|
||||
assert_eq!(config.gocardless.secret_key, "test-key");
|
||||
assert_eq!(config.firefly.url, "https://firefly.test.com");
|
||||
assert_eq!(config.firefly.api_key, "test-api-key");
|
||||
assert_eq!(config.cache.key, "test-cache-key");
|
||||
assert_eq!(config.logging.level, "info");
|
||||
|
||||
env::remove_var("GOCARDLESS_ID");
|
||||
env::remove_var("GOCARDLESS_KEY");
|
||||
env::remove_var("FIREFLY_III_URL");
|
||||
env::remove_var("FIREFLY_III_API_KEY");
|
||||
env::remove_var("BANKS2FF_CACHE_KEY");
|
||||
env::remove_var("RUST_LOG");
|
||||
}
|
||||
}
|
||||
@@ -33,31 +33,39 @@ use anyhow::{anyhow, Result};
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use rand::RngCore;
|
||||
use sha2::Sha256;
|
||||
use std::env;
|
||||
|
||||
const KEY_LEN: usize = 32; // 256-bit key
|
||||
const NONCE_LEN: usize = 12; // 96-bit nonce for AES-GCM
|
||||
const SALT_LEN: usize = 16; // 128-bit salt for PBKDF2
|
||||
|
||||
pub struct Encryption;
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Encryption {
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl Encryption {
|
||||
/// Derive encryption key from environment variable and salt
|
||||
/// Create new Encryption instance with cache key
|
||||
pub fn new(cache_key: String) -> Self {
|
||||
Self {
|
||||
password: cache_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive encryption key from password and salt
|
||||
pub fn derive_key(password: &str, salt: &[u8]) -> Key<Aes256Gcm> {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, 200_000, &mut key);
|
||||
key.into()
|
||||
}
|
||||
|
||||
/// Get password from environment variable
|
||||
fn get_password() -> Result<String> {
|
||||
env::var("BANKS2FF_CACHE_KEY")
|
||||
.map_err(|_| anyhow!("BANKS2FF_CACHE_KEY environment variable not set"))
|
||||
/// Get password from instance
|
||||
fn get_password(&self) -> Result<String> {
|
||||
Ok(self.password.clone())
|
||||
}
|
||||
|
||||
/// Encrypt data using AES-GCM
|
||||
pub fn encrypt(data: &[u8]) -> Result<Vec<u8>> {
|
||||
let password = Self::get_password()?;
|
||||
pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
let password = self.get_password()?;
|
||||
|
||||
// Generate random salt
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
@@ -84,13 +92,13 @@ impl Encryption {
|
||||
}
|
||||
|
||||
/// Decrypt data using AES-GCM
|
||||
pub fn decrypt(encrypted_data: &[u8]) -> Result<Vec<u8>> {
|
||||
pub fn decrypt(&self, encrypted_data: &[u8]) -> Result<Vec<u8>> {
|
||||
let min_len = SALT_LEN + NONCE_LEN;
|
||||
if encrypted_data.len() < min_len {
|
||||
return Err(anyhow!("Encrypted data too short"));
|
||||
}
|
||||
|
||||
let password = Self::get_password()?;
|
||||
let password = self.get_password()?;
|
||||
|
||||
// Extract salt, nonce and ciphertext: [salt(16)][nonce(12)][ciphertext]
|
||||
let salt = &encrypted_data[..SALT_LEN];
|
||||
@@ -110,23 +118,21 @@ impl Encryption {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_round_trip() {
|
||||
// Set test environment variable
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-key-for-encryption");
|
||||
|
||||
let encryption = Encryption::new("test-key-for-encryption".to_string());
|
||||
let original_data = b"Hello, World! This is test data.";
|
||||
|
||||
// Encrypt
|
||||
let encrypted = Encryption::encrypt(original_data).expect("Encryption should succeed");
|
||||
|
||||
// Ensure env var is still set for decryption
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-key-for-encryption");
|
||||
let encrypted = encryption
|
||||
.encrypt(original_data)
|
||||
.expect("Encryption should succeed");
|
||||
|
||||
// Decrypt
|
||||
let decrypted = Encryption::decrypt(&encrypted).expect("Decryption should succeed");
|
||||
let decrypted = encryption
|
||||
.decrypt(&encrypted)
|
||||
.expect("Decryption should succeed");
|
||||
|
||||
// Verify
|
||||
assert_eq!(original_data.to_vec(), decrypted);
|
||||
@@ -135,41 +141,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_different_keys() {
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "key1");
|
||||
let encryption1 = Encryption::new("key1".to_string());
|
||||
let encryption2 = Encryption::new("key2".to_string());
|
||||
let data = b"Test data";
|
||||
let encrypted = Encryption::encrypt(data).unwrap();
|
||||
let encrypted = encryption1.encrypt(data).unwrap();
|
||||
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "key2");
|
||||
let result = Encryption::decrypt(&encrypted);
|
||||
let result = encryption2.decrypt(&encrypted);
|
||||
assert!(result.is_err(), "Should fail with different key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_env_var() {
|
||||
// Save current value and restore after test
|
||||
let original_value = env::var("BANKS2FF_CACHE_KEY").ok();
|
||||
env::remove_var("BANKS2FF_CACHE_KEY");
|
||||
|
||||
let result = Encryption::get_password();
|
||||
assert!(result.is_err(), "Should fail without env var");
|
||||
|
||||
// Restore original value
|
||||
if let Some(val) = original_value {
|
||||
env::set_var("BANKS2FF_CACHE_KEY", val);
|
||||
}
|
||||
fn test_encryption_creation() {
|
||||
let encryption = Encryption::new("test-key".to_string());
|
||||
assert_eq!(encryption.password, "test-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_data() {
|
||||
// Set env var multiple times to ensure it's available
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-key");
|
||||
let encryption = Encryption::new("test-key".to_string());
|
||||
let data = b"{}"; // Minimal JSON object
|
||||
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-key");
|
||||
let encrypted = Encryption::encrypt(data).unwrap();
|
||||
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-key");
|
||||
let decrypted = Encryption::decrypt(&encrypted).unwrap();
|
||||
let encrypted = encryption.encrypt(data).unwrap();
|
||||
let decrypted = encryption.decrypt(&encrypted).unwrap();
|
||||
assert_eq!(data.to_vec(), decrypted);
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,24 @@ pub struct AccountLink {
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct LinkStore {
|
||||
pub links: Vec<AccountLink>,
|
||||
cache_dir: String,
|
||||
}
|
||||
|
||||
impl LinkStore {
|
||||
fn get_path() -> String {
|
||||
let cache_dir =
|
||||
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
format!("{}/links.json", cache_dir)
|
||||
/// Create new LinkStore with cache directory
|
||||
pub fn new(cache_dir: String) -> Self {
|
||||
Self {
|
||||
links: Vec::new(),
|
||||
cache_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let path = Self::get_path();
|
||||
fn get_path(&self) -> String {
|
||||
format!("{}/links.json", self.cache_dir)
|
||||
}
|
||||
|
||||
pub fn load(cache_dir: String) -> Self {
|
||||
let path = format!("{}/links.json", cache_dir);
|
||||
if Path::new(&path).exists() {
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_json::from_str(&content) {
|
||||
@@ -37,11 +44,11 @@ impl LinkStore {
|
||||
Err(e) => warn!("Failed to read link store: {}", e),
|
||||
}
|
||||
}
|
||||
Self::default()
|
||||
Self::new(cache_dir)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::get_path();
|
||||
let path = self.get_path();
|
||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod adapters;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod encryption;
|
||||
pub mod linking;
|
||||
pub mod models;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::core::config::Config;
|
||||
use crate::core::linking::{auto_link_accounts, LinkStore};
|
||||
use crate::core::models::{Account, SyncError};
|
||||
use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource};
|
||||
@@ -17,6 +18,7 @@ pub struct SyncResult {
|
||||
pub async fn run_sync(
|
||||
source: impl TransactionSource,
|
||||
destination: impl TransactionDestination,
|
||||
config: Config,
|
||||
cli_start_date: Option<NaiveDate>,
|
||||
cli_end_date: Option<NaiveDate>,
|
||||
dry_run: bool,
|
||||
@@ -40,7 +42,7 @@ pub async fn run_sync(
|
||||
|
||||
// Accounts are cached by their respective adapters during discover_accounts
|
||||
|
||||
let mut link_store = LinkStore::load();
|
||||
let mut link_store = LinkStore::load(config.cache.directory.clone());
|
||||
|
||||
// Auto-link accounts based on IBAN
|
||||
let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts);
|
||||
@@ -272,11 +274,39 @@ async fn process_single_account(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::config::{
|
||||
CacheConfig, Config, FireflyConfig, GoCardlessConfig, LoggingConfig,
|
||||
};
|
||||
use crate::core::models::{Account, BankTransaction};
|
||||
use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch};
|
||||
use mockall::predicate::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn create_unique_key(prefix: &str) -> String {
|
||||
format!("{}-{}", prefix, rand::random::<u64>())
|
||||
}
|
||||
|
||||
fn create_test_config(temp_dir: &str) -> Config {
|
||||
Config {
|
||||
gocardless: GoCardlessConfig {
|
||||
url: "https://bankaccountdata.gocardless.com".to_string(),
|
||||
secret_id: create_unique_key("gocardless-id"),
|
||||
secret_key: create_unique_key("gocardless-key"),
|
||||
},
|
||||
firefly: FireflyConfig {
|
||||
url: "https://firefly.test.com".to_string(),
|
||||
api_key: create_unique_key("firefly-api-key"),
|
||||
},
|
||||
cache: CacheConfig {
|
||||
key: create_unique_key("cache-key"),
|
||||
directory: temp_dir.to_string(),
|
||||
},
|
||||
logging: LoggingConfig {
|
||||
level: "warn".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sync_flow_create_new() {
|
||||
let mut source = MockTransactionSource::new();
|
||||
@@ -346,8 +376,13 @@ mod tests {
|
||||
.returning(|_, _| Ok(()));
|
||||
|
||||
// Execution
|
||||
let res = run_sync(&source, &dest, None, None, false).await;
|
||||
let temp_dir = format!("tmp/test-sync-{}", rand::random::<u64>());
|
||||
let config = create_test_config(&temp_dir);
|
||||
let res = run_sync(&source, &dest, config, None, None, false).await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -413,8 +448,13 @@ mod tests {
|
||||
.times(1)
|
||||
.returning(|_, _| Ok(()));
|
||||
|
||||
let res = run_sync(&source, &dest, None, None, false).await;
|
||||
let temp_dir = format!("tmp/test-sync-heal-{}", rand::random::<u64>());
|
||||
let config = create_test_config(&temp_dir);
|
||||
let res = run_sync(&source, &dest, config, None, None, false).await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -475,7 +515,12 @@ mod tests {
|
||||
dest.expect_create_transaction().never();
|
||||
dest.expect_update_transaction_external_id().never();
|
||||
|
||||
let res = run_sync(source, dest, None, None, true).await;
|
||||
let temp_dir = format!("tmp/test-sync-dry-run-{}", rand::random::<u64>());
|
||||
let config = create_test_config(&temp_dir);
|
||||
let res = run_sync(source, dest, config, None, None, true).await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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::encryption::Encryption;
|
||||
use crate::core::linking::LinkStore;
|
||||
use crate::core::models::AccountData;
|
||||
use crate::core::ports::TransactionSource;
|
||||
@@ -125,6 +127,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Load environment variables first
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Load configuration
|
||||
let config = Config::from_env()?;
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialize logging based on command type
|
||||
@@ -135,12 +140,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
_ => "warn",
|
||||
};
|
||||
|
||||
let log_level = std::env::var("RUST_LOG")
|
||||
.map(|s| {
|
||||
s.parse()
|
||||
.unwrap_or(tracing_subscriber::EnvFilter::new(default_level))
|
||||
})
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
|
||||
let log_level = config
|
||||
.logging
|
||||
.level
|
||||
.parse()
|
||||
.unwrap_or(tracing_subscriber::EnvFilter::new(default_level));
|
||||
|
||||
tracing_subscriber::fmt().with_env_filter(log_level).init();
|
||||
|
||||
@@ -156,7 +160,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
start,
|
||||
end,
|
||||
} => {
|
||||
handle_sync(args.debug, source, destination, start, end, args.dry_run).await?;
|
||||
handle_sync(
|
||||
config,
|
||||
args.debug,
|
||||
source,
|
||||
destination,
|
||||
start,
|
||||
end,
|
||||
args.dry_run,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Commands::Sources => {
|
||||
@@ -167,11 +180,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
Commands::Accounts { subcommand } => {
|
||||
handle_accounts(subcommand).await?;
|
||||
handle_accounts(config, subcommand).await?;
|
||||
}
|
||||
|
||||
Commands::Transactions { subcommand } => {
|
||||
handle_transactions(subcommand).await?;
|
||||
handle_transactions(config, subcommand).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +192,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn handle_sync(
|
||||
config: Config,
|
||||
debug: bool,
|
||||
source: String,
|
||||
destination: String,
|
||||
@@ -222,10 +236,19 @@ async fn handle_sync(
|
||||
anyhow::bail!("Only 'firefly' destination is currently supported (implementation pending)");
|
||||
}
|
||||
|
||||
let context = AppContext::new(debug).await?;
|
||||
let context = AppContext::new(config.clone(), debug).await?;
|
||||
|
||||
// Run sync
|
||||
match run_sync(context.source, context.destination, start, end, dry_run).await {
|
||||
match run_sync(
|
||||
context.source,
|
||||
context.destination,
|
||||
config,
|
||||
start,
|
||||
end,
|
||||
dry_run,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
info!("Sync completed successfully.");
|
||||
info!(
|
||||
@@ -264,71 +287,23 @@ async fn handle_destinations() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> {
|
||||
let context = AppContext::new(false).await?;
|
||||
async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> {
|
||||
let context = AppContext::new(config.clone(), false).await?;
|
||||
let format = OutputFormat::Table; // TODO: Add --json flag
|
||||
|
||||
match subcommand {
|
||||
AccountCommands::Link {
|
||||
subcommand: link_sub,
|
||||
} => {
|
||||
handle_link(link_sub).await?;
|
||||
}
|
||||
AccountCommands::List => {
|
||||
let accounts = context.source.list_accounts().await?;
|
||||
if accounts.is_empty() {
|
||||
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
|
||||
} else {
|
||||
print_list_output(accounts, &format);
|
||||
}
|
||||
}
|
||||
AccountCommands::Status => {
|
||||
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_list_output(status, &format);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_transactions(subcommand: TransactionCommands) -> anyhow::Result<()> {
|
||||
let context = AppContext::new(false).await?;
|
||||
let format = OutputFormat::Table; // TODO: Add --json flag
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
TransactionCommands::ClearCache => {
|
||||
// TODO: Implement cache clearing
|
||||
println!("Cache clearing not yet implemented");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
||||
let mut link_store = LinkStore::load();
|
||||
let account_cache = crate::core::cache::AccountCache::load();
|
||||
|
||||
match subcommand {
|
||||
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 {
|
||||
@@ -356,6 +331,13 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
||||
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,
|
||||
);
|
||||
|
||||
// Assume source_account is gocardless id, dest_account is firefly id
|
||||
let source_acc = account_cache.get_account(&source_account);
|
||||
let dest_acc = account_cache.get_account(&dest_account);
|
||||
@@ -375,7 +357,9 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
||||
currency: "EUR".to_string(),
|
||||
};
|
||||
|
||||
if let Some(link_id) = link_store.add_link(&src_minimal, &dst_minimal, false) {
|
||||
if let Some(link_id) =
|
||||
link_store.add_link(&src_minimal, &dst_minimal, false)
|
||||
{
|
||||
link_store.save()?;
|
||||
let src_display = account_cache
|
||||
.get_display_name(&source_account)
|
||||
@@ -400,10 +384,13 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!("Account not found. Ensure accounts are discovered via sync first.");
|
||||
println!(
|
||||
"Account not found. Ensure accounts are discovered via sync first."
|
||||
);
|
||||
}
|
||||
}
|
||||
LinkCommands::Delete { link_id } => {
|
||||
let mut link_store = LinkStore::load(config.cache.directory.clone());
|
||||
if link_store.remove_link(&link_id).is_ok() {
|
||||
link_store.save()?;
|
||||
println!("Deleted link {}", link_id);
|
||||
@@ -412,6 +399,7 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
LinkCommands::Alias { link_id, alias } => {
|
||||
let mut link_store = LinkStore::load(config.cache.directory.clone());
|
||||
if link_store.set_alias(&link_id, alias.clone()).is_ok() {
|
||||
link_store.save()?;
|
||||
println!("Set alias '{}' for link {}", alias, link_id);
|
||||
@@ -420,5 +408,55 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountCommands::List => {
|
||||
let accounts = context.source.list_accounts().await?;
|
||||
if accounts.is_empty() {
|
||||
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
|
||||
} else {
|
||||
print_list_output(accounts, &format);
|
||||
}
|
||||
}
|
||||
AccountCommands::Status => {
|
||||
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_list_output(status, &format);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
TransactionCommands::ClearCache => {
|
||||
// TODO: Implement cache clearing
|
||||
println!("Cache clearing not yet implemented");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user