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:
2025-11-28 19:01:49 +01:00
parent a384a9cfcd
commit 8518bb33f5
11 changed files with 685 additions and 307 deletions

View File

@@ -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 {

View File

@@ -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)
})
});

View File

@@ -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);

View File

@@ -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,

View File

@@ -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
View 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");
}
}

View File

@@ -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);
}
}

View File

@@ -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)?;
}

View File

@@ -1,5 +1,6 @@
pub mod adapters;
pub mod cache;
pub mod config;
pub mod encryption;
pub mod linking;
pub mod models;

View File

@@ -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);
}
}

View File

@@ -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(())
}