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