Completely replace implementation #1
@@ -1,15 +1,17 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use anyhow::Result;
|
|
||||||
use tracing::instrument;
|
|
||||||
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
|
||||||
use crate::core::models::BankTransaction;
|
use crate::core::models::BankTransaction;
|
||||||
|
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::NaiveDate;
|
||||||
use firefly_client::client::FireflyClient;
|
use firefly_client::client::FireflyClient;
|
||||||
use firefly_client::models::{TransactionStore, TransactionSplitStore, TransactionUpdate, TransactionSplitUpdate};
|
use firefly_client::models::{
|
||||||
use std::sync::Arc;
|
TransactionSplitStore, TransactionSplitUpdate, TransactionStore, TransactionUpdate,
|
||||||
use tokio::sync::Mutex;
|
};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use chrono::NaiveDate;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
pub struct FireflyAdapter {
|
pub struct FireflyAdapter {
|
||||||
client: Arc<Mutex<FireflyClient>>,
|
client: Arc<Mutex<FireflyClient>>,
|
||||||
@@ -29,7 +31,7 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
|
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
|
||||||
let client = self.client.lock().await;
|
let client = self.client.lock().await;
|
||||||
let accounts = client.search_accounts(iban).await?;
|
let accounts = client.search_accounts(iban).await?;
|
||||||
|
|
||||||
// Look for exact match on IBAN, ensuring account is active
|
// Look for exact match on IBAN, ensuring account is active
|
||||||
for acc in accounts.data {
|
for acc in accounts.data {
|
||||||
// Filter for active accounts only (default is usually active, but let's check if attribute exists)
|
// Filter for active accounts only (default is usually active, but let's check if attribute exists)
|
||||||
@@ -42,11 +44,11 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
|
|
||||||
if let Some(acc_iban) = acc.attributes.iban {
|
if let Some(acc_iban) = acc.attributes.iban {
|
||||||
if acc_iban.replace(" ", "") == iban.replace(" ", "") {
|
if acc_iban.replace(" ", "") == iban.replace(" ", "") {
|
||||||
return Ok(Some(acc.id));
|
return Ok(Some(acc.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,19 +59,19 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
// For typical users, 50 is enough. If needed we can loop pages.
|
// For typical users, 50 is enough. If needed we can loop pages.
|
||||||
// The client `get_accounts` method hardcodes limit=default. We should probably expose a list_all method or loop here.
|
// The client `get_accounts` method hardcodes limit=default. We should probably expose a list_all method or loop here.
|
||||||
// For now, let's assume page 1 covers it or use search.
|
// For now, let's assume page 1 covers it or use search.
|
||||||
|
|
||||||
let accounts = client.get_accounts("").await?; // Argument ignored in current impl
|
let accounts = client.get_accounts("").await?; // Argument ignored in current impl
|
||||||
let mut ibans = Vec::new();
|
let mut ibans = Vec::new();
|
||||||
|
|
||||||
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);
|
||||||
if is_active {
|
if is_active {
|
||||||
if let Some(iban) = acc.attributes.iban {
|
if let Some(iban) = acc.attributes.iban {
|
||||||
if !iban.is_empty() {
|
if !iban.is_empty() {
|
||||||
ibans.push(iban);
|
ibans.push(iban);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ibans)
|
Ok(ibans)
|
||||||
}
|
}
|
||||||
@@ -78,63 +80,71 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
||||||
let client = self.client.lock().await;
|
let client = self.client.lock().await;
|
||||||
// Fetch latest 1 transaction
|
// Fetch latest 1 transaction
|
||||||
let tx_list = client.list_account_transactions(account_id, None, None).await?;
|
let tx_list = client
|
||||||
|
.list_account_transactions(account_id, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if let Some(first) = tx_list.data.first() {
|
if let Some(first) = tx_list.data.first() {
|
||||||
if let Some(split) = first.attributes.transactions.first() {
|
if let Some(split) = first.attributes.transactions.first() {
|
||||||
// Format is usually YYYY-MM-DDT... or YYYY-MM-DD
|
// Format is usually YYYY-MM-DDT... or YYYY-MM-DD
|
||||||
let date_str = split.date.split('T').next().unwrap_or(&split.date);
|
let date_str = split.date.split('T').next().unwrap_or(&split.date);
|
||||||
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||||
return Ok(Some(date));
|
return Ok(Some(date));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn find_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<Option<TransactionMatch>> {
|
async fn find_transaction(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
tx: &BankTransaction,
|
||||||
|
) -> Result<Option<TransactionMatch>> {
|
||||||
let client = self.client.lock().await;
|
let client = self.client.lock().await;
|
||||||
|
|
||||||
// Search window: +/- 3 days
|
// Search window: +/- 3 days
|
||||||
let start_date = tx.date - chrono::Duration::days(3);
|
let start_date = tx.date - chrono::Duration::days(3);
|
||||||
let end_date = tx.date + chrono::Duration::days(3);
|
let end_date = tx.date + chrono::Duration::days(3);
|
||||||
|
|
||||||
let tx_list = client.list_account_transactions(
|
let tx_list = client
|
||||||
account_id,
|
.list_account_transactions(
|
||||||
Some(&start_date.format("%Y-%m-%d").to_string()),
|
account_id,
|
||||||
Some(&end_date.format("%Y-%m-%d").to_string())
|
Some(&start_date.format("%Y-%m-%d").to_string()),
|
||||||
).await?;
|
Some(&end_date.format("%Y-%m-%d").to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Filter logic
|
// Filter logic
|
||||||
for existing_tx in tx_list.data {
|
for existing_tx in tx_list.data {
|
||||||
for split in existing_tx.attributes.transactions {
|
for split in existing_tx.attributes.transactions {
|
||||||
// 1. Check Amount (exact match absolute value)
|
// 1. Check Amount (exact match absolute value)
|
||||||
if let Ok(amount) = Decimal::from_str(&split.amount) {
|
if let Ok(amount) = Decimal::from_str(&split.amount) {
|
||||||
if amount.abs() == tx.amount.abs() {
|
if amount.abs() == tx.amount.abs() {
|
||||||
// 2. Check External ID
|
// 2. Check External ID
|
||||||
if let Some(ref ext_id) = split.external_id {
|
if let Some(ref ext_id) = split.external_id {
|
||||||
if ext_id == &tx.internal_id {
|
if ext_id == &tx.internal_id {
|
||||||
return Ok(Some(TransactionMatch {
|
return Ok(Some(TransactionMatch {
|
||||||
id: existing_tx.id.clone(),
|
id: existing_tx.id.clone(),
|
||||||
has_external_id: true,
|
has_external_id: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 3. "Naked" transaction match (Heuristic)
|
// 3. "Naked" transaction match (Heuristic)
|
||||||
// If currency matches
|
// If currency matches
|
||||||
if let Some(ref code) = split.currency_code {
|
if let Some(ref code) = split.currency_code {
|
||||||
if code != &tx.currency {
|
if code != &tx.currency {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(Some(TransactionMatch {
|
return Ok(Some(TransactionMatch {
|
||||||
id: existing_tx.id.clone(),
|
id: existing_tx.id.clone(),
|
||||||
has_external_id: false,
|
has_external_id: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,22 +159,42 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
// Map to Firefly Transaction
|
// Map to Firefly Transaction
|
||||||
let is_credit = tx.amount.is_sign_positive();
|
let is_credit = tx.amount.is_sign_positive();
|
||||||
let transaction_type = if is_credit { "deposit" } else { "withdrawal" };
|
let transaction_type = if is_credit { "deposit" } else { "withdrawal" };
|
||||||
|
|
||||||
let split = TransactionSplitStore {
|
let split = TransactionSplitStore {
|
||||||
transaction_type: transaction_type.to_string(),
|
transaction_type: transaction_type.to_string(),
|
||||||
date: tx.date.format("%Y-%m-%d").to_string(),
|
date: tx.date.format("%Y-%m-%d").to_string(),
|
||||||
amount: tx.amount.abs().to_string(),
|
amount: tx.amount.abs().to_string(),
|
||||||
description: tx.description.clone(),
|
description: tx.description.clone(),
|
||||||
source_id: if !is_credit { Some(account_id.to_string()) } else { None },
|
source_id: if !is_credit {
|
||||||
source_name: if is_credit { tx.counterparty_name.clone().or(Some("Unknown Sender".to_string())) } else { None },
|
Some(account_id.to_string())
|
||||||
destination_id: if is_credit { Some(account_id.to_string()) } else { None },
|
} else {
|
||||||
destination_name: if !is_credit { tx.counterparty_name.clone().or(Some("Unknown Recipient".to_string())) } else { None },
|
None
|
||||||
|
},
|
||||||
|
source_name: if is_credit {
|
||||||
|
tx.counterparty_name
|
||||||
|
.clone()
|
||||||
|
.or(Some("Unknown Sender".to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
destination_id: if is_credit {
|
||||||
|
Some(account_id.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
destination_name: if !is_credit {
|
||||||
|
tx.counterparty_name
|
||||||
|
.clone()
|
||||||
|
.or(Some("Unknown Recipient".to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
currency_code: Some(tx.currency.clone()),
|
currency_code: Some(tx.currency.clone()),
|
||||||
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
||||||
foreign_currency_code: tx.foreign_currency.clone(),
|
foreign_currency_code: tx.foreign_currency.clone(),
|
||||||
external_id: Some(tx.internal_id.clone()),
|
external_id: Some(tx.internal_id.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = TransactionStore {
|
let store = TransactionStore {
|
||||||
transactions: vec![split],
|
transactions: vec![split],
|
||||||
apply_rules: Some(true),
|
apply_rules: Some(true),
|
||||||
@@ -183,6 +213,9 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
external_id: Some(external_id.to_string()),
|
external_id: Some(external_id.to_string()),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
client.update_transaction(id, update).await.map_err(|e| e.into())
|
client
|
||||||
|
.update_transaction(id, update)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
use crate::adapters::gocardless::encryption::Encryption;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use crate::adapters::gocardless::encryption::Encryption;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
pub struct AccountCache {
|
pub struct AccountCache {
|
||||||
@@ -13,7 +13,8 @@ pub struct AccountCache {
|
|||||||
|
|
||||||
impl AccountCache {
|
impl AccountCache {
|
||||||
fn get_path() -> String {
|
fn get_path() -> String {
|
||||||
let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
let cache_dir =
|
||||||
|
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||||
format!("{}/accounts.enc", cache_dir)
|
format!("{}/accounts.enc", cache_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +40,11 @@ impl AccountCache {
|
|||||||
|
|
||||||
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) {
|
||||||
warn!("Failed to create cache folder '{}': {}", parent.display(), e);
|
warn!(
|
||||||
|
"Failed to create cache folder '{}': {}",
|
||||||
|
parent.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +54,7 @@ impl AccountCache {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => warn!("Failed to encrypt cache: {}", e),
|
Err(e) => warn!("Failed to encrypt cache: {}", e),
|
||||||
},
|
},
|
||||||
Err(e) => warn!("Failed to serialize cache: {}", e),
|
Err(e) => warn!("Failed to serialize cache: {}", e),
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
|
use crate::adapters::gocardless::cache::AccountCache;
|
||||||
|
use crate::adapters::gocardless::mapper::map_transaction;
|
||||||
|
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
||||||
|
use crate::core::models::{Account, BankTransaction};
|
||||||
|
use crate::core::ports::TransactionSource;
|
||||||
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use anyhow::Result;
|
|
||||||
use tracing::{debug, info, instrument, warn};
|
|
||||||
use crate::core::ports::TransactionSource;
|
|
||||||
use crate::core::models::{Account, BankTransaction};
|
|
||||||
use crate::adapters::gocardless::mapper::map_transaction;
|
|
||||||
use crate::adapters::gocardless::cache::AccountCache;
|
|
||||||
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
|
||||||
use gocardless_client::client::GoCardlessClient;
|
use gocardless_client::client::GoCardlessClient;
|
||||||
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub struct GoCardlessAdapter {
|
pub struct GoCardlessAdapter {
|
||||||
@@ -62,14 +62,20 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
if let Some(agreement_id) = &req.agreement {
|
if let Some(agreement_id) = &req.agreement {
|
||||||
match client.is_agreement_expired(agreement_id).await {
|
match client.is_agreement_expired(agreement_id).await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
debug!("Skipping requisition {} - agreement {} has expired", req.id, agreement_id);
|
debug!(
|
||||||
|
"Skipping requisition {} - agreement {} has expired",
|
||||||
|
req.id, agreement_id
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// Agreement is valid, proceed
|
// Agreement is valid, proceed
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to check agreement {} expiry: {}. Skipping requisition.", agreement_id, e);
|
warn!(
|
||||||
|
"Failed to check agreement {} expiry: {}. Skipping requisition.",
|
||||||
|
agreement_id, e
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,32 +88,32 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
// 2. Fetch if missing
|
// 2. Fetch if missing
|
||||||
if iban_opt.is_none() {
|
if iban_opt.is_none() {
|
||||||
match client.get_account(&acc_id).await {
|
match client.get_account(&acc_id).await {
|
||||||
Ok(details) => {
|
Ok(details) => {
|
||||||
let new_iban = details.iban.unwrap_or_default();
|
let new_iban = details.iban.unwrap_or_default();
|
||||||
cache.insert(acc_id.clone(), new_iban.clone());
|
cache.insert(acc_id.clone(), new_iban.clone());
|
||||||
cache.save();
|
cache.save();
|
||||||
iban_opt = Some(new_iban);
|
iban_opt = Some(new_iban);
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// If rate limit hit here, we might want to skip this account and continue?
|
// If rate limit hit here, we might want to skip this account and continue?
|
||||||
// But get_account is critical to identify the account.
|
// But get_account is critical to identify the account.
|
||||||
// If we fail here, we can't match.
|
// If we fail here, we can't match.
|
||||||
warn!("Failed to fetch details for account {}: {}", acc_id, e);
|
warn!("Failed to fetch details for account {}: {}", acc_id, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let iban = iban_opt.unwrap_or_default();
|
let iban = iban_opt.unwrap_or_default();
|
||||||
|
|
||||||
let mut keep = true;
|
let mut keep = true;
|
||||||
if let Some(ref wanted) = wanted_set {
|
if let Some(ref wanted) = wanted_set {
|
||||||
if !wanted.contains(&iban.replace(" ", "")) {
|
if !wanted.contains(&iban.replace(" ", "")) {
|
||||||
keep = false;
|
keep = false;
|
||||||
} else {
|
} else {
|
||||||
found_count += 1;
|
found_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if keep {
|
if keep {
|
||||||
@@ -119,11 +125,13 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optimization: Stop if we found all wanted accounts
|
// Optimization: Stop if we found all wanted accounts
|
||||||
if wanted_set.is_some()
|
if wanted_set.is_some() && found_count >= target_count && target_count > 0 {
|
||||||
&& found_count >= target_count && target_count > 0 {
|
info!(
|
||||||
info!("Found all {} wanted accounts. Stopping search.", target_count);
|
"Found all {} wanted accounts. Stopping search.",
|
||||||
return Ok(accounts);
|
target_count
|
||||||
}
|
);
|
||||||
|
return Ok(accounts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +141,12 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>> {
|
async fn get_transactions(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Result<Vec<BankTransaction>> {
|
||||||
let mut client = self.client.lock().await;
|
let mut client = self.client.lock().await;
|
||||||
client.obtain_access_token().await?;
|
client.obtain_access_token().await?;
|
||||||
|
|
||||||
@@ -154,27 +167,43 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
// Fetch missing ranges
|
// Fetch missing ranges
|
||||||
for (range_start, range_end) in uncovered_ranges {
|
for (range_start, range_end) in uncovered_ranges {
|
||||||
let response_result = client.get_transactions(
|
let response_result = client
|
||||||
account_id,
|
.get_transactions(
|
||||||
Some(&range_start.to_string()),
|
account_id,
|
||||||
Some(&range_end.to_string())
|
Some(&range_start.to_string()),
|
||||||
).await;
|
Some(&range_end.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
match response_result {
|
match response_result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let raw_txs = response.transactions.booked.clone();
|
let raw_txs = response.transactions.booked.clone();
|
||||||
raw_transactions.extend(raw_txs.clone());
|
raw_transactions.extend(raw_txs.clone());
|
||||||
cache.store_transactions(range_start, range_end, raw_txs);
|
cache.store_transactions(range_start, range_end, raw_txs);
|
||||||
info!("Fetched {} transactions for account {} in range {}-{}", response.transactions.booked.len(), account_id, range_start, range_end);
|
info!(
|
||||||
},
|
"Fetched {} transactions for account {} in range {}-{}",
|
||||||
|
response.transactions.booked.len(),
|
||||||
|
account_id,
|
||||||
|
range_start,
|
||||||
|
range_end
|
||||||
|
);
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_str = e.to_string();
|
let err_str = e.to_string();
|
||||||
if err_str.contains("429") {
|
if err_str.contains("429") {
|
||||||
warn!("Rate limit reached for account {} in range {}-{}. Skipping.", account_id, range_start, range_end);
|
warn!(
|
||||||
|
"Rate limit reached for account {} in range {}-{}. Skipping.",
|
||||||
|
account_id, range_start, range_end
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if err_str.contains("401") && (err_str.contains("expired") || err_str.contains("EUA")) {
|
if err_str.contains("401")
|
||||||
debug!("EUA expired for account {} in range {}-{}. Skipping.", account_id, range_start, range_end);
|
&& (err_str.contains("expired") || err_str.contains("EUA"))
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
"EUA expired for account {} in range {}-{}. Skipping.",
|
||||||
|
account_id, range_start, range_end
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
@@ -194,7 +223,13 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Total {} transactions for account {} in range {}-{}", transactions.len(), account_id, start, end);
|
info!(
|
||||||
|
"Total {} transactions for account {} in range {}-{}",
|
||||||
|
transactions.len(),
|
||||||
|
account_id,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
);
|
||||||
Ok(transactions)
|
Ok(transactions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,13 @@
|
|||||||
//! - Key derivation: ~50-100ms (computed once per operation)
|
//! - Key derivation: ~50-100ms (computed once per operation)
|
||||||
//! - Memory: Minimal additional overhead
|
//! - Memory: Minimal additional overhead
|
||||||
|
|
||||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
|
||||||
use aes_gcm::aead::{Aead, KeyInit};
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||||
|
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;
|
use std::env;
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
|
|
||||||
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
|
||||||
@@ -72,7 +72,8 @@ impl Encryption {
|
|||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
// Encrypt
|
// Encrypt
|
||||||
let ciphertext = cipher.encrypt(nonce, data)
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, data)
|
||||||
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
// Prepend salt and nonce to ciphertext: [salt(16)][nonce(12)][ciphertext]
|
// Prepend salt and nonce to ciphertext: [salt(16)][nonce(12)][ciphertext]
|
||||||
@@ -100,7 +101,8 @@ impl Encryption {
|
|||||||
let cipher = Aes256Gcm::new(&key);
|
let cipher = Aes256Gcm::new(&key);
|
||||||
|
|
||||||
// Decrypt
|
// Decrypt
|
||||||
cipher.decrypt(nonce, ciphertext)
|
cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
.map_err(|e| anyhow!("Decryption failed: {}", e))
|
.map_err(|e| anyhow!("Decryption failed: {}", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,4 +172,4 @@ mod tests {
|
|||||||
let decrypted = Encryption::decrypt(&encrypted).unwrap();
|
let decrypted = Encryption::decrypt(&encrypted).unwrap();
|
||||||
assert_eq!(data.to_vec(), decrypted);
|
assert_eq!(data.to_vec(), decrypted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
use rust_decimal::Decimal;
|
|
||||||
use rust_decimal::prelude::Signed;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use anyhow::Result;
|
|
||||||
use crate::core::models::BankTransaction;
|
use crate::core::models::BankTransaction;
|
||||||
|
use anyhow::Result;
|
||||||
use gocardless_client::models::Transaction;
|
use gocardless_client::models::Transaction;
|
||||||
|
use rust_decimal::prelude::Signed;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
||||||
let internal_id = tx.transaction_id
|
let internal_id = tx
|
||||||
|
.transaction_id
|
||||||
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
|
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
|
||||||
|
|
||||||
let date_str = tx.booking_date.or(tx.value_date)
|
let date_str = tx
|
||||||
|
.booking_date
|
||||||
|
.or(tx.value_date)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Transaction date missing"))?;
|
.ok_or_else(|| anyhow::anyhow!("Transaction date missing"))?;
|
||||||
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
|
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
|
||||||
|
|
||||||
@@ -23,7 +26,9 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|||||||
|
|
||||||
if let Some(exchanges) = tx.currency_exchange {
|
if let Some(exchanges) = tx.currency_exchange {
|
||||||
if let Some(exchange) = exchanges.first() {
|
if let Some(exchange) = exchanges.first() {
|
||||||
if let (Some(source_curr), Some(rate_str)) = (&exchange.source_currency, &exchange.exchange_rate) {
|
if let (Some(source_curr), Some(rate_str)) =
|
||||||
|
(&exchange.source_currency, &exchange.exchange_rate)
|
||||||
|
{
|
||||||
foreign_currency = Some(source_curr.clone());
|
foreign_currency = Some(source_curr.clone());
|
||||||
if let Ok(rate) = Decimal::from_str(rate_str) {
|
if let Ok(rate) = Decimal::from_str(rate_str) {
|
||||||
let calc = amount.abs() * rate;
|
let calc = amount.abs() * rate;
|
||||||
@@ -42,7 +47,8 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for description: Remittance Unstructured -> Debtor/Creditor Name -> "Unknown"
|
// Fallback for description: Remittance Unstructured -> Debtor/Creditor Name -> "Unknown"
|
||||||
let description = tx.remittance_information_unstructured
|
let description = tx
|
||||||
|
.remittance_information_unstructured
|
||||||
.or(tx.creditor_name.clone())
|
.or(tx.creditor_name.clone())
|
||||||
.or(tx.debtor_name.clone())
|
.or(tx.debtor_name.clone())
|
||||||
.unwrap_or_else(|| "Unknown Transaction".to_string());
|
.unwrap_or_else(|| "Unknown Transaction".to_string());
|
||||||
@@ -56,14 +62,20 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|||||||
foreign_currency,
|
foreign_currency,
|
||||||
description,
|
description,
|
||||||
counterparty_name: tx.creditor_name.or(tx.debtor_name),
|
counterparty_name: tx.creditor_name.or(tx.debtor_name),
|
||||||
counterparty_iban: tx.creditor_account.and_then(|a| a.iban).or(tx.debtor_account.and_then(|a| a.iban)),
|
counterparty_iban: tx
|
||||||
|
.creditor_account
|
||||||
|
.and_then(|a| a.iban)
|
||||||
|
.or(tx.debtor_account.and_then(|a| a.iban)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_amount(amount: &Decimal) -> Result<()> {
|
fn validate_amount(amount: &Decimal) -> Result<()> {
|
||||||
let abs = amount.abs();
|
let abs = amount.abs();
|
||||||
if abs > Decimal::new(1_000_000_000, 0) {
|
if abs > Decimal::new(1_000_000_000, 0) {
|
||||||
return Err(anyhow::anyhow!("Amount exceeds reasonable bounds: {}", amount));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Amount exceeds reasonable bounds: {}",
|
||||||
|
amount
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if abs == Decimal::ZERO {
|
if abs == Decimal::ZERO {
|
||||||
return Err(anyhow::anyhow!("Amount cannot be zero"));
|
return Err(anyhow::anyhow!("Amount cannot be zero"));
|
||||||
@@ -73,10 +85,16 @@ fn validate_amount(amount: &Decimal) -> Result<()> {
|
|||||||
|
|
||||||
fn validate_currency(currency: &str) -> Result<()> {
|
fn validate_currency(currency: &str) -> Result<()> {
|
||||||
if currency.len() != 3 {
|
if currency.len() != 3 {
|
||||||
return Err(anyhow::anyhow!("Invalid currency code length: {}", currency));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid currency code length: {}",
|
||||||
|
currency
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !currency.chars().all(|c| c.is_ascii_uppercase()) {
|
if !currency.chars().all(|c| c.is_ascii_uppercase()) {
|
||||||
return Err(anyhow::anyhow!("Invalid currency code format: {}", currency));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid currency code format: {}",
|
||||||
|
currency
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -84,7 +102,7 @@ fn validate_currency(currency: &str) -> Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gocardless_client::models::{TransactionAmount, CurrencyExchange};
|
use gocardless_client::models::{CurrencyExchange, TransactionAmount};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_normal_transaction() {
|
fn test_map_normal_transaction() {
|
||||||
@@ -193,8 +211,6 @@ mod tests {
|
|||||||
assert!(validate_amount(&amount).is_err());
|
assert!(validate_amount(&amount).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_currency_invalid_length() {
|
fn test_validate_currency_invalid_length() {
|
||||||
assert!(validate_currency("EU").is_err());
|
assert!(validate_currency("EU").is_err());
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pub mod client;
|
|
||||||
pub mod mapper;
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
pub mod client;
|
||||||
pub mod encryption;
|
pub mod encryption;
|
||||||
|
pub mod mapper;
|
||||||
pub mod transaction_cache;
|
pub mod transaction_cache;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use chrono::{NaiveDate, Days};
|
use crate::adapters::gocardless::encryption::Encryption;
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{Days, NaiveDate};
|
||||||
|
use gocardless_client::models::Transaction;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use anyhow::Result;
|
|
||||||
use crate::adapters::gocardless::encryption::Encryption;
|
|
||||||
use gocardless_client::models::Transaction;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct AccountTransactionCache {
|
pub struct AccountTransactionCache {
|
||||||
@@ -21,7 +21,8 @@ pub struct CachedRange {
|
|||||||
impl AccountTransactionCache {
|
impl AccountTransactionCache {
|
||||||
/// 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(account_id: &str) -> String {
|
||||||
let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
let cache_dir =
|
||||||
|
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||||
format!("{}/transactions/{}.enc", cache_dir, account_id)
|
format!("{}/transactions/{}.enc", cache_dir, account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,9 @@ impl AccountTransactionCache {
|
|||||||
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
|
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
|
||||||
for tx in &range.transactions {
|
for tx in &range.transactions {
|
||||||
if let Some(booking_date_str) = &tx.booking_date {
|
if let Some(booking_date_str) = &tx.booking_date {
|
||||||
if let Ok(booking_date) = NaiveDate::parse_from_str(booking_date_str, "%Y-%m-%d") {
|
if let Ok(booking_date) =
|
||||||
|
NaiveDate::parse_from_str(booking_date_str, "%Y-%m-%d")
|
||||||
|
{
|
||||||
if booking_date >= start && booking_date <= end {
|
if booking_date >= start && booking_date <= end {
|
||||||
result.push(tx.clone());
|
result.push(tx.clone());
|
||||||
}
|
}
|
||||||
@@ -83,8 +86,13 @@ impl AccountTransactionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get uncovered date ranges within requested period
|
/// Get uncovered date ranges within requested period
|
||||||
pub fn get_uncovered_ranges(&self, start: NaiveDate, end: NaiveDate) -> Vec<(NaiveDate, NaiveDate)> {
|
pub fn get_uncovered_ranges(
|
||||||
let mut covered_periods: Vec<(NaiveDate, NaiveDate)> = self.ranges
|
&self,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Vec<(NaiveDate, NaiveDate)> {
|
||||||
|
let mut covered_periods: Vec<(NaiveDate, NaiveDate)> = self
|
||||||
|
.ranges
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|range| {
|
.filter_map(|range| {
|
||||||
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
|
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
|
||||||
@@ -134,7 +142,12 @@ impl AccountTransactionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Store transactions for a date range, merging with existing cache
|
/// Store transactions for a date range, merging with existing cache
|
||||||
pub fn store_transactions(&mut self, start: NaiveDate, end: NaiveDate, mut transactions: Vec<Transaction>) {
|
pub fn store_transactions(
|
||||||
|
&mut self,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
mut transactions: Vec<Transaction>,
|
||||||
|
) {
|
||||||
Self::deduplicate_transactions(&mut transactions);
|
Self::deduplicate_transactions(&mut transactions);
|
||||||
let new_range = CachedRange {
|
let new_range = CachedRange {
|
||||||
start_date: start,
|
start_date: start,
|
||||||
@@ -151,7 +164,12 @@ impl AccountTransactionCache {
|
|||||||
let mut remaining = Vec::new();
|
let mut remaining = Vec::new();
|
||||||
|
|
||||||
for range in &self.ranges {
|
for range in &self.ranges {
|
||||||
if Self::ranges_overlap_or_adjacent(range.start_date, range.end_date, new_range.start_date, new_range.end_date) {
|
if Self::ranges_overlap_or_adjacent(
|
||||||
|
range.start_date,
|
||||||
|
range.end_date,
|
||||||
|
new_range.start_date,
|
||||||
|
new_range.end_date,
|
||||||
|
) {
|
||||||
to_merge.push(range.clone());
|
to_merge.push(range.clone());
|
||||||
} else {
|
} else {
|
||||||
remaining.push(range.clone());
|
remaining.push(range.clone());
|
||||||
@@ -169,15 +187,25 @@ impl AccountTransactionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if two date ranges overlap
|
/// Check if two date ranges overlap
|
||||||
fn ranges_overlap(start1: NaiveDate, end1: NaiveDate, start2: NaiveDate, end2: NaiveDate) -> bool {
|
fn ranges_overlap(
|
||||||
|
start1: NaiveDate,
|
||||||
|
end1: NaiveDate,
|
||||||
|
start2: NaiveDate,
|
||||||
|
end2: NaiveDate,
|
||||||
|
) -> bool {
|
||||||
start1 <= end2 && start2 <= end1
|
start1 <= end2 && start2 <= end1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if two date ranges overlap or are adjacent
|
/// Check if two date ranges overlap or are adjacent
|
||||||
fn ranges_overlap_or_adjacent(start1: NaiveDate, end1: NaiveDate, start2: NaiveDate, end2: NaiveDate) -> bool {
|
fn ranges_overlap_or_adjacent(
|
||||||
Self::ranges_overlap(start1, end1, start2, end2) ||
|
start1: NaiveDate,
|
||||||
(end1 + Days::new(1)) == start2 ||
|
end1: NaiveDate,
|
||||||
(end2 + Days::new(1)) == start1
|
start2: NaiveDate,
|
||||||
|
end2: NaiveDate,
|
||||||
|
) -> bool {
|
||||||
|
Self::ranges_overlap(start1, end1, start2, end2)
|
||||||
|
|| (end1 + Days::new(1)) == start2
|
||||||
|
|| (end2 + Days::new(1)) == start1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge a list of ranges into minimal set
|
/// Merge a list of ranges into minimal set
|
||||||
@@ -194,7 +222,12 @@ impl AccountTransactionCache {
|
|||||||
let mut current = sorted[0].clone();
|
let mut current = sorted[0].clone();
|
||||||
|
|
||||||
for range in sorted.into_iter().skip(1) {
|
for range in sorted.into_iter().skip(1) {
|
||||||
if Self::ranges_overlap_or_adjacent(current.start_date, current.end_date, range.start_date, range.end_date) {
|
if Self::ranges_overlap_or_adjacent(
|
||||||
|
current.start_date,
|
||||||
|
current.end_date,
|
||||||
|
range.start_date,
|
||||||
|
range.end_date,
|
||||||
|
) {
|
||||||
// Merge
|
// Merge
|
||||||
current.start_date = current.start_date.min(range.start_date);
|
current.start_date = current.start_date.min(range.start_date);
|
||||||
current.end_date = current.end_date.max(range.end_date);
|
current.end_date = current.end_date.max(range.end_date);
|
||||||
@@ -227,8 +260,8 @@ impl AccountTransactionCache {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::env;
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn setup_test_env(test_name: &str) -> String {
|
fn setup_test_env(test_name: &str) -> String {
|
||||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
@@ -239,7 +272,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!("tmp/test-cache-{}-{}-{}", test_name, random_suffix, timestamp);
|
let cache_dir = format!(
|
||||||
|
"tmp/test-cache-{}-{}-{}",
|
||||||
|
test_name, random_suffix, timestamp
|
||||||
|
);
|
||||||
env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone());
|
env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone());
|
||||||
cache_dir
|
cache_dir
|
||||||
}
|
}
|
||||||
@@ -261,8 +297,6 @@ 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_env("nonexistent");
|
||||||
@@ -291,7 +325,8 @@ mod tests {
|
|||||||
// Ensure env vars are set before load
|
// Ensure env vars are set before load
|
||||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
// Load
|
// Load
|
||||||
let loaded = AccountTransactionCache::load("test_account_empty").expect("Load should succeed");
|
let loaded =
|
||||||
|
AccountTransactionCache::load("test_account_empty").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());
|
||||||
@@ -355,12 +390,16 @@ mod tests {
|
|||||||
// Ensure env vars are set before load
|
// Ensure env vars are set before load
|
||||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
// Load
|
// Load
|
||||||
let loaded = AccountTransactionCache::load("test_account_data").expect("Load should succeed");
|
let loaded =
|
||||||
|
AccountTransactionCache::load("test_account_data").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);
|
||||||
assert_eq!(loaded.ranges[0].transactions.len(), 1);
|
assert_eq!(loaded.ranges[0].transactions.len(), 1);
|
||||||
assert_eq!(loaded.ranges[0].transactions[0].transaction_id, Some("test-tx-1".to_string()));
|
assert_eq!(
|
||||||
|
loaded.ranges[0].transactions[0].transaction_id,
|
||||||
|
Some("test-tx-1".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
cleanup_test_dir(&cache_dir);
|
cleanup_test_dir(&cache_dir);
|
||||||
}
|
}
|
||||||
@@ -442,8 +481,20 @@ mod tests {
|
|||||||
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);
|
||||||
assert_eq!(uncovered.len(), 2);
|
assert_eq!(uncovered.len(), 2);
|
||||||
assert_eq!(uncovered[0], (NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()));
|
assert_eq!(
|
||||||
assert_eq!(uncovered[1], (NaiveDate::from_ymd_opt(2024, 1, 21).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()));
|
uncovered[0],
|
||||||
|
(
|
||||||
|
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
uncovered[1],
|
||||||
|
(
|
||||||
|
NaiveDate::from_ymd_opt(2024, 1, 21).unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -632,4 +683,4 @@ mod tests {
|
|||||||
assert_eq!(cached.len(), 1);
|
assert_eq!(cached.len(), 1);
|
||||||
assert_eq!(cached[0].transaction_id, Some("tx1".to_string()));
|
assert_eq!(cached[0].transaction_id, Some("tx1".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use rust_decimal::Decimal;
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -32,11 +32,20 @@ impl fmt::Debug for BankTransaction {
|
|||||||
.field("date", &self.date)
|
.field("date", &self.date)
|
||||||
.field("amount", &"[REDACTED]")
|
.field("amount", &"[REDACTED]")
|
||||||
.field("currency", &self.currency)
|
.field("currency", &self.currency)
|
||||||
.field("foreign_amount", &self.foreign_amount.as_ref().map(|_| "[REDACTED]"))
|
.field(
|
||||||
|
"foreign_amount",
|
||||||
|
&self.foreign_amount.as_ref().map(|_| "[REDACTED]"),
|
||||||
|
)
|
||||||
.field("foreign_currency", &self.foreign_currency)
|
.field("foreign_currency", &self.foreign_currency)
|
||||||
.field("description", &"[REDACTED]")
|
.field("description", &"[REDACTED]")
|
||||||
.field("counterparty_name", &self.counterparty_name.as_ref().map(|_| "[REDACTED]"))
|
.field(
|
||||||
.field("counterparty_iban", &self.counterparty_iban.as_ref().map(|_| "[REDACTED]"))
|
"counterparty_name",
|
||||||
|
&self.counterparty_name.as_ref().map(|_| "[REDACTED]"),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"counterparty_iban",
|
||||||
|
&self.counterparty_iban.as_ref().map(|_| "[REDACTED]"),
|
||||||
|
)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
use crate::core::models::{Account, BankTransaction};
|
||||||
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use anyhow::Result;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
use crate::core::models::{BankTransaction, Account};
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct IngestResult {
|
pub struct IngestResult {
|
||||||
@@ -18,7 +18,12 @@ pub struct IngestResult {
|
|||||||
pub trait TransactionSource: Send + Sync {
|
pub trait TransactionSource: Send + Sync {
|
||||||
/// Fetch accounts. Optionally filter by a list of wanted IBANs to save requests.
|
/// Fetch accounts. Optionally filter by a list of wanted IBANs to save requests.
|
||||||
async fn get_accounts(&self, wanted_ibans: Option<Vec<String>>) -> Result<Vec<Account>>;
|
async fn get_accounts(&self, wanted_ibans: Option<Vec<String>>) -> Result<Vec<Account>>;
|
||||||
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>>;
|
async fn get_transactions(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Result<Vec<BankTransaction>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blanket implementation for references
|
// Blanket implementation for references
|
||||||
@@ -28,7 +33,12 @@ impl<T: TransactionSource> TransactionSource for &T {
|
|||||||
(**self).get_accounts(wanted_ibans).await
|
(**self).get_accounts(wanted_ibans).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>> {
|
async fn get_transactions(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> Result<Vec<BankTransaction>> {
|
||||||
(**self).get_transactions(account_id, start, end).await
|
(**self).get_transactions(account_id, start, end).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +58,11 @@ pub trait TransactionDestination: Send + Sync {
|
|||||||
|
|
||||||
// New granular methods for Healer Logic
|
// New granular methods for Healer Logic
|
||||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
|
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
|
||||||
async fn find_transaction(&self, account_id: &str, transaction: &BankTransaction) -> Result<Option<TransactionMatch>>;
|
async fn find_transaction(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
transaction: &BankTransaction,
|
||||||
|
) -> Result<Option<TransactionMatch>>;
|
||||||
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
|
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
|
||||||
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
|
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
@@ -68,7 +82,11 @@ impl<T: TransactionDestination> TransactionDestination for &T {
|
|||||||
(**self).get_last_transaction_date(account_id).await
|
(**self).get_last_transaction_date(account_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_transaction(&self, account_id: &str, transaction: &BankTransaction) -> Result<Option<TransactionMatch>> {
|
async fn find_transaction(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
transaction: &BankTransaction,
|
||||||
|
) -> Result<Option<TransactionMatch>> {
|
||||||
(**self).find_transaction(account_id, transaction).await
|
(**self).find_transaction(account_id, transaction).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +95,8 @@ impl<T: TransactionDestination> TransactionDestination for &T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()> {
|
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()> {
|
||||||
(**self).update_transaction_external_id(id, external_id).await
|
(**self)
|
||||||
|
.update_transaction_external_id(id, external_id)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
use crate::core::models::{Account, SyncError};
|
||||||
|
use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{info, warn, instrument};
|
use chrono::{Local, NaiveDate};
|
||||||
use crate::core::ports::{IngestResult, TransactionSource, TransactionDestination};
|
use tracing::{info, instrument, warn};
|
||||||
use crate::core::models::{SyncError, Account};
|
|
||||||
use chrono::{NaiveDate, Local};
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SyncResult {
|
pub struct SyncResult {
|
||||||
@@ -24,14 +23,24 @@ pub async fn run_sync(
|
|||||||
info!("Starting synchronization...");
|
info!("Starting synchronization...");
|
||||||
|
|
||||||
// Optimization: Get active Firefly IBANs first
|
// Optimization: Get active Firefly IBANs first
|
||||||
let wanted_ibans = destination.get_active_account_ibans().await.map_err(SyncError::DestinationError)?;
|
let wanted_ibans = destination
|
||||||
info!("Syncing {} active accounts from Firefly III", wanted_ibans.len());
|
.get_active_account_ibans()
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::DestinationError)?;
|
||||||
|
info!(
|
||||||
|
"Syncing {} active accounts from Firefly III",
|
||||||
|
wanted_ibans.len()
|
||||||
|
);
|
||||||
|
|
||||||
let accounts = source.get_accounts(Some(wanted_ibans)).await.map_err(SyncError::SourceError)?;
|
let accounts = source
|
||||||
|
.get_accounts(Some(wanted_ibans))
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::SourceError)?;
|
||||||
info!("Found {} accounts from source", accounts.len());
|
info!("Found {} accounts from source", accounts.len());
|
||||||
|
|
||||||
// Default end date is Yesterday
|
// Default end date is Yesterday
|
||||||
let end_date = cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
|
let end_date =
|
||||||
|
cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
|
||||||
|
|
||||||
let mut result = SyncResult::default();
|
let mut result = SyncResult::default();
|
||||||
|
|
||||||
@@ -42,19 +51,33 @@ pub async fn run_sync(
|
|||||||
info!("Processing account...");
|
info!("Processing account...");
|
||||||
|
|
||||||
// Process account with error handling
|
// Process account with error handling
|
||||||
match process_single_account(&source, &destination, &account, cli_start_date, end_date, dry_run).await {
|
match process_single_account(
|
||||||
|
&source,
|
||||||
|
&destination,
|
||||||
|
&account,
|
||||||
|
cli_start_date,
|
||||||
|
end_date,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
result.accounts_processed += 1;
|
result.accounts_processed += 1;
|
||||||
result.ingest.created += stats.created;
|
result.ingest.created += stats.created;
|
||||||
result.ingest.healed += stats.healed;
|
result.ingest.healed += stats.healed;
|
||||||
result.ingest.duplicates += stats.duplicates;
|
result.ingest.duplicates += stats.duplicates;
|
||||||
result.ingest.errors += stats.errors;
|
result.ingest.errors += stats.errors;
|
||||||
info!("Account {} sync complete. Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
info!(
|
||||||
account.id, stats.created, stats.healed, stats.duplicates, stats.errors);
|
"Account {} sync complete. Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||||
|
account.id, stats.created, stats.healed, stats.duplicates, stats.errors
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(SyncError::AgreementExpired { agreement_id }) => {
|
Err(SyncError::AgreementExpired { agreement_id }) => {
|
||||||
result.accounts_skipped_expired += 1;
|
result.accounts_skipped_expired += 1;
|
||||||
warn!("Account {} skipped - associated agreement {} has expired", account.id, agreement_id);
|
warn!(
|
||||||
|
"Account {} skipped - associated agreement {} has expired",
|
||||||
|
account.id, agreement_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(SyncError::AccountSkipped { account_id, reason }) => {
|
Err(SyncError::AccountSkipped { account_id, reason }) => {
|
||||||
result.accounts_skipped_errors += 1;
|
result.accounts_skipped_errors += 1;
|
||||||
@@ -67,10 +90,14 @@ pub async fn run_sync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Synchronization finished. Processed: {}, Skipped (expired): {}, Skipped (errors): {}",
|
info!(
|
||||||
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors);
|
"Synchronization finished. Processed: {}, Skipped (expired): {}, Skipped (errors): {}",
|
||||||
info!("Total transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors
|
||||||
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors);
|
);
|
||||||
|
info!(
|
||||||
|
"Total transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||||
|
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors
|
||||||
|
);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -83,7 +110,10 @@ async fn process_single_account(
|
|||||||
end_date: NaiveDate,
|
end_date: NaiveDate,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<IngestResult, SyncError> {
|
) -> Result<IngestResult, SyncError> {
|
||||||
let dest_id_opt = destination.resolve_account_id(&account.iban).await.map_err(SyncError::DestinationError)?;
|
let dest_id_opt = destination
|
||||||
|
.resolve_account_id(&account.iban)
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::DestinationError)?;
|
||||||
let Some(dest_id) = dest_id_opt else {
|
let Some(dest_id) = dest_id_opt else {
|
||||||
return Err(SyncError::AccountSkipped {
|
return Err(SyncError::AccountSkipped {
|
||||||
account_id: account.id.clone(),
|
account_id: account.id.clone(),
|
||||||
@@ -98,24 +128,34 @@ async fn process_single_account(
|
|||||||
d
|
d
|
||||||
} else {
|
} else {
|
||||||
// Default: Latest transaction date + 1 day
|
// Default: Latest transaction date + 1 day
|
||||||
match destination.get_last_transaction_date(&dest_id).await.map_err(SyncError::DestinationError)? {
|
match destination
|
||||||
|
.get_last_transaction_date(&dest_id)
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::DestinationError)?
|
||||||
|
{
|
||||||
Some(last_date) => last_date + chrono::Duration::days(1),
|
Some(last_date) => last_date + chrono::Duration::days(1),
|
||||||
None => {
|
None => {
|
||||||
// If no transaction exists in Firefly, we assume this is a fresh sync.
|
// If no transaction exists in Firefly, we assume this is a fresh sync.
|
||||||
// Default to syncing last 30 days.
|
// Default to syncing last 30 days.
|
||||||
end_date - chrono::Duration::days(30)
|
end_date - chrono::Duration::days(30)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if start_date > end_date {
|
if start_date > end_date {
|
||||||
info!("Start date {} is after end date {}. Nothing to sync.", start_date, end_date);
|
info!(
|
||||||
return Ok(IngestResult::default());
|
"Start date {} is after end date {}. Nothing to sync.",
|
||||||
|
start_date, end_date
|
||||||
|
);
|
||||||
|
return Ok(IngestResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Syncing interval: {} to {}", start_date, end_date);
|
info!("Syncing interval: {} to {}", start_date, end_date);
|
||||||
|
|
||||||
let transactions = match source.get_transactions(&account.id, start_date, end_date).await {
|
let transactions = match source
|
||||||
|
.get_transactions(&account.id, start_date, end_date)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(txns) => txns,
|
Ok(txns) => txns,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_str = e.to_string();
|
let err_str = e.to_string();
|
||||||
@@ -139,49 +179,62 @@ async fn process_single_account(
|
|||||||
|
|
||||||
// Healer Logic Loop
|
// Healer Logic Loop
|
||||||
for tx in transactions {
|
for tx in transactions {
|
||||||
// 1. Check if it exists
|
// 1. Check if it exists
|
||||||
match destination.find_transaction(&dest_id, &tx).await.map_err(SyncError::DestinationError)? {
|
match destination
|
||||||
Some(existing) => {
|
.find_transaction(&dest_id, &tx)
|
||||||
if existing.has_external_id {
|
.await
|
||||||
// Already synced properly
|
.map_err(SyncError::DestinationError)?
|
||||||
stats.duplicates += 1;
|
{
|
||||||
} else {
|
Some(existing) => {
|
||||||
// Found "naked" transaction -> Heal it
|
if existing.has_external_id {
|
||||||
if dry_run {
|
// Already synced properly
|
||||||
info!("[DRY RUN] Would heal transaction {} (Firefly ID: {})", tx.internal_id, existing.id);
|
stats.duplicates += 1;
|
||||||
stats.healed += 1;
|
} else {
|
||||||
} else {
|
// Found "naked" transaction -> Heal it
|
||||||
info!("Healing transaction {} (Firefly ID: {})", tx.internal_id, existing.id);
|
if dry_run {
|
||||||
if let Err(e) = destination.update_transaction_external_id(&existing.id, &tx.internal_id).await {
|
info!(
|
||||||
tracing::error!("Failed to heal transaction: {}", e);
|
"[DRY RUN] Would heal transaction {} (Firefly ID: {})",
|
||||||
stats.errors += 1;
|
tx.internal_id, existing.id
|
||||||
} else {
|
);
|
||||||
stats.healed += 1;
|
stats.healed += 1;
|
||||||
}
|
} else {
|
||||||
}
|
info!(
|
||||||
}
|
"Healing transaction {} (Firefly ID: {})",
|
||||||
},
|
tx.internal_id, existing.id
|
||||||
None => {
|
);
|
||||||
// New transaction
|
if let Err(e) = destination
|
||||||
if dry_run {
|
.update_transaction_external_id(&existing.id, &tx.internal_id)
|
||||||
info!("[DRY RUN] Would create transaction {}", tx.internal_id);
|
.await
|
||||||
stats.created += 1;
|
{
|
||||||
} else if let Err(e) = destination.create_transaction(&dest_id, &tx).await {
|
tracing::error!("Failed to heal transaction: {}", e);
|
||||||
// Firefly might still reject it as duplicate if hash matches, even if we didn't find it via heuristic
|
stats.errors += 1;
|
||||||
// (unlikely if heuristic is good, but possible)
|
} else {
|
||||||
let err_str = e.to_string();
|
stats.healed += 1;
|
||||||
if err_str.contains("422") || err_str.contains("Duplicate") {
|
}
|
||||||
warn!("Duplicate rejected by Firefly: {}", tx.internal_id);
|
}
|
||||||
stats.duplicates += 1;
|
}
|
||||||
} else {
|
}
|
||||||
tracing::error!("Failed to create transaction: {}", e);
|
None => {
|
||||||
stats.errors += 1;
|
// New transaction
|
||||||
}
|
if dry_run {
|
||||||
} else {
|
info!("[DRY RUN] Would create transaction {}", tx.internal_id);
|
||||||
stats.created += 1;
|
stats.created += 1;
|
||||||
}
|
} else if let Err(e) = destination.create_transaction(&dest_id, &tx).await {
|
||||||
}
|
// Firefly might still reject it as duplicate if hash matches, even if we didn't find it via heuristic
|
||||||
}
|
// (unlikely if heuristic is good, but possible)
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("422") || err_str.contains("Duplicate") {
|
||||||
|
warn!("Duplicate rejected by Firefly: {}", tx.internal_id);
|
||||||
|
stats.duplicates += 1;
|
||||||
|
} else {
|
||||||
|
tracing::error!("Failed to create transaction: {}", e);
|
||||||
|
stats.errors += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.created += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
@@ -190,10 +243,10 @@ async fn process_single_account(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::ports::{MockTransactionSource, MockTransactionDestination, TransactionMatch};
|
|
||||||
use crate::core::models::{Account, BankTransaction};
|
use crate::core::models::{Account, BankTransaction};
|
||||||
use rust_decimal::Decimal;
|
use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch};
|
||||||
use mockall::predicate::*;
|
use mockall::predicate::*;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sync_flow_create_new() {
|
async fn test_sync_flow_create_new() {
|
||||||
@@ -201,13 +254,16 @@ mod tests {
|
|||||||
let mut dest = MockTransactionDestination::new();
|
let mut dest = MockTransactionDestination::new();
|
||||||
|
|
||||||
// Source setup
|
// Source setup
|
||||||
source.expect_get_accounts()
|
source
|
||||||
|
.expect_get_accounts()
|
||||||
.with(always()) // Match any argument
|
.with(always()) // Match any argument
|
||||||
.returning(|_| Ok(vec![Account {
|
.returning(|_| {
|
||||||
id: "src_1".to_string(),
|
Ok(vec![Account {
|
||||||
iban: "NL01".to_string(),
|
id: "src_1".to_string(),
|
||||||
currency: "EUR".to_string(),
|
iban: "NL01".to_string(),
|
||||||
}]));
|
currency: "EUR".to_string(),
|
||||||
|
}])
|
||||||
|
});
|
||||||
|
|
||||||
let tx = BankTransaction {
|
let tx = BankTransaction {
|
||||||
internal_id: "tx1".into(),
|
internal_id: "tx1".into(),
|
||||||
@@ -222,7 +278,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx.clone();
|
||||||
|
|
||||||
source.expect_get_transactions()
|
source
|
||||||
|
.expect_get_transactions()
|
||||||
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
||||||
|
|
||||||
// Destination setup
|
// Destination setup
|
||||||
@@ -231,7 +288,7 @@ mod tests {
|
|||||||
|
|
||||||
dest.expect_resolve_account_id()
|
dest.expect_resolve_account_id()
|
||||||
.returning(|_| Ok(Some("dest_1".into())));
|
.returning(|_| Ok(Some("dest_1".into())));
|
||||||
|
|
||||||
dest.expect_get_last_transaction_date()
|
dest.expect_get_last_transaction_date()
|
||||||
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||||
|
|
||||||
@@ -239,7 +296,7 @@ mod tests {
|
|||||||
dest.expect_find_transaction()
|
dest.expect_find_transaction()
|
||||||
.times(1)
|
.times(1)
|
||||||
.returning(|_, _| Ok(None));
|
.returning(|_, _| Ok(None));
|
||||||
|
|
||||||
// 2. Create -> Ok
|
// 2. Create -> Ok
|
||||||
dest.expect_create_transaction()
|
dest.expect_create_transaction()
|
||||||
.with(eq("dest_1"), eq(tx_clone))
|
.with(eq("dest_1"), eq(tx_clone))
|
||||||
@@ -250,49 +307,50 @@ mod tests {
|
|||||||
let res = run_sync(&source, &dest, None, None, false).await;
|
let res = run_sync(&source, &dest, None, None, false).await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sync_flow_heal_existing() {
|
async fn test_sync_flow_heal_existing() {
|
||||||
let mut source = MockTransactionSource::new();
|
let mut source = MockTransactionSource::new();
|
||||||
let mut dest = MockTransactionDestination::new();
|
let mut dest = MockTransactionDestination::new();
|
||||||
|
|
||||||
dest.expect_get_active_account_ibans()
|
dest.expect_get_active_account_ibans()
|
||||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
source.expect_get_accounts()
|
source.expect_get_accounts().with(always()).returning(|_| {
|
||||||
.with(always())
|
Ok(vec![Account {
|
||||||
.returning(|_| Ok(vec![Account {
|
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
iban: "NL01".to_string(),
|
iban: "NL01".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}]));
|
}])
|
||||||
|
});
|
||||||
|
|
||||||
source.expect_get_transactions()
|
source.expect_get_transactions().returning(|_, _, _| {
|
||||||
.returning(|_, _, _| Ok(vec![
|
Ok(vec![BankTransaction {
|
||||||
BankTransaction {
|
internal_id: "tx1".into(),
|
||||||
internal_id: "tx1".into(),
|
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
||||||
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
amount: Decimal::new(100, 0),
|
||||||
amount: Decimal::new(100, 0),
|
currency: "EUR".into(),
|
||||||
currency: "EUR".into(),
|
foreign_amount: None,
|
||||||
foreign_amount: None,
|
foreign_currency: None,
|
||||||
foreign_currency: None,
|
description: "Test".into(),
|
||||||
description: "Test".into(),
|
counterparty_name: None,
|
||||||
counterparty_name: None,
|
counterparty_iban: None,
|
||||||
counterparty_iban: None,
|
}])
|
||||||
}
|
});
|
||||||
]));
|
|
||||||
|
|
||||||
dest.expect_resolve_account_id().returning(|_| Ok(Some("dest_1".into())));
|
dest.expect_resolve_account_id()
|
||||||
dest.expect_get_last_transaction_date().returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
.returning(|_| Ok(Some("dest_1".into())));
|
||||||
|
dest.expect_get_last_transaction_date()
|
||||||
|
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||||
|
|
||||||
// 1. Find -> Some(No External ID)
|
// 1. Find -> Some(No External ID)
|
||||||
dest.expect_find_transaction()
|
dest.expect_find_transaction().times(1).returning(|_, _| {
|
||||||
.times(1)
|
Ok(Some(TransactionMatch {
|
||||||
.returning(|_, _| Ok(Some(TransactionMatch {
|
|
||||||
id: "ff_tx_1".to_string(),
|
id: "ff_tx_1".to_string(),
|
||||||
has_external_id: false,
|
has_external_id: false,
|
||||||
})));
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Update -> Ok
|
// 2. Update -> Ok
|
||||||
dest.expect_update_transaction_external_id()
|
dest.expect_update_transaction_external_id()
|
||||||
.with(eq("ff_tx_1"), eq("tx1"))
|
.with(eq("ff_tx_1"), eq("tx1"))
|
||||||
@@ -302,22 +360,22 @@ mod tests {
|
|||||||
let res = run_sync(&source, &dest, None, None, false).await;
|
let res = run_sync(&source, &dest, None, None, false).await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sync_flow_dry_run() {
|
async fn test_sync_flow_dry_run() {
|
||||||
let mut source = MockTransactionSource::new();
|
let mut source = MockTransactionSource::new();
|
||||||
let mut dest = MockTransactionDestination::new();
|
let mut dest = MockTransactionDestination::new();
|
||||||
|
|
||||||
dest.expect_get_active_account_ibans()
|
dest.expect_get_active_account_ibans()
|
||||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
source.expect_get_accounts()
|
source.expect_get_accounts().with(always()).returning(|_| {
|
||||||
.with(always())
|
Ok(vec![Account {
|
||||||
.returning(|_| Ok(vec![Account {
|
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
iban: "NL01".to_string(),
|
iban: "NL01".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}]));
|
}])
|
||||||
|
});
|
||||||
|
|
||||||
let tx = BankTransaction {
|
let tx = BankTransaction {
|
||||||
internal_id: "tx1".into(),
|
internal_id: "tx1".into(),
|
||||||
@@ -331,16 +389,18 @@ mod tests {
|
|||||||
counterparty_iban: None,
|
counterparty_iban: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
source.expect_get_transactions()
|
source
|
||||||
|
.expect_get_transactions()
|
||||||
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
||||||
|
|
||||||
dest.expect_resolve_account_id().returning(|_| Ok(Some("dest_1".into())));
|
dest.expect_resolve_account_id()
|
||||||
dest.expect_get_last_transaction_date().returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
.returning(|_| Ok(Some("dest_1".into())));
|
||||||
|
dest.expect_get_last_transaction_date()
|
||||||
|
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||||
|
|
||||||
// 1. Find -> None (New transaction)
|
// 1. Find -> None (New transaction)
|
||||||
dest.expect_find_transaction()
|
dest.expect_find_transaction().returning(|_, _| Ok(None));
|
||||||
.returning(|_, _| Ok(None));
|
|
||||||
|
|
||||||
// 2. Create -> NEVER Called (Dry Run)
|
// 2. Create -> NEVER Called (Dry Run)
|
||||||
dest.expect_create_transaction().never();
|
dest.expect_create_transaction().never();
|
||||||
dest.expect_update_transaction_external_id().never();
|
dest.expect_update_transaction_external_id().never();
|
||||||
@@ -348,4 +408,4 @@ mod tests {
|
|||||||
let res = run_sync(source, dest, None, None, true).await;
|
let res = run_sync(source, dest, None, None, true).await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use reqwest_middleware::{Middleware, Next};
|
|
||||||
use task_local_extensions::Extensions;
|
|
||||||
use reqwest::{Request, Response};
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use hyper::Body;
|
use hyper::Body;
|
||||||
|
use reqwest::{Request, Response};
|
||||||
|
use reqwest_middleware::{Middleware, Next};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use task_local_extensions::Extensions;
|
||||||
|
|
||||||
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
@@ -51,7 +51,11 @@ impl Middleware for DebugLogger {
|
|||||||
log_content.push_str("# Request:\n");
|
log_content.push_str("# Request:\n");
|
||||||
log_content.push_str(&format!("{} {} HTTP/1.1\n", req.method(), req.url()));
|
log_content.push_str(&format!("{} {} HTTP/1.1\n", req.method(), req.url()));
|
||||||
for (key, value) in req.headers() {
|
for (key, value) in req.headers() {
|
||||||
log_content.push_str(&format!("{}: {}\n", key, value.to_str().unwrap_or("[INVALID]")));
|
log_content.push_str(&format!(
|
||||||
|
"{}: {}\n",
|
||||||
|
key,
|
||||||
|
value.to_str().unwrap_or("[INVALID]")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if let Some(body) = req.body() {
|
if let Some(body) = req.body() {
|
||||||
if let Some(bytes) = body.as_bytes() {
|
if let Some(bytes) = body.as_bytes() {
|
||||||
@@ -70,13 +74,26 @@ impl Middleware for DebugLogger {
|
|||||||
|
|
||||||
// Response
|
// Response
|
||||||
log_content.push_str("# Response:\n");
|
log_content.push_str("# Response:\n");
|
||||||
log_content.push_str(&format!("HTTP/1.1 {} {}\n", status.as_u16(), status.canonical_reason().unwrap_or("Unknown")));
|
log_content.push_str(&format!(
|
||||||
|
"HTTP/1.1 {} {}\n",
|
||||||
|
status.as_u16(),
|
||||||
|
status.canonical_reason().unwrap_or("Unknown")
|
||||||
|
));
|
||||||
for (key, value) in &headers {
|
for (key, value) in &headers {
|
||||||
log_content.push_str(&format!("{}: {}\n", key, value.to_str().unwrap_or("[INVALID]")));
|
log_content.push_str(&format!(
|
||||||
|
"{}: {}\n",
|
||||||
|
key,
|
||||||
|
value.to_str().unwrap_or("[INVALID]")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read body
|
// Read body
|
||||||
let body_bytes = response.bytes().await.map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!("Failed to read response body: {}", e)))?;
|
let body_bytes = response.bytes().await.map_err(|e| {
|
||||||
|
reqwest_middleware::Error::Middleware(anyhow::anyhow!(
|
||||||
|
"Failed to read response body: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||||
log_content.push_str(&format!("\n{}", body_str));
|
log_content.push_str(&format!("\n{}", body_str));
|
||||||
|
|
||||||
@@ -86,9 +103,7 @@ impl Middleware for DebugLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct response
|
// Reconstruct response
|
||||||
let mut builder = http::Response::builder()
|
let mut builder = http::Response::builder().status(status).version(version);
|
||||||
.status(status)
|
|
||||||
.version(version);
|
|
||||||
for (key, value) in &headers {
|
for (key, value) in &headers {
|
||||||
builder = builder.header(key, value);
|
builder = builder.header(key, value);
|
||||||
}
|
}
|
||||||
@@ -113,4 +128,4 @@ fn build_curl_command(req: &Request) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
curl
|
curl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ mod adapters;
|
|||||||
mod core;
|
mod core;
|
||||||
mod debug;
|
mod debug;
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
use tracing::{info, error};
|
|
||||||
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
|
||||||
use crate::adapters::firefly::client::FireflyAdapter;
|
use crate::adapters::firefly::client::FireflyAdapter;
|
||||||
|
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
||||||
use crate::core::sync::run_sync;
|
use crate::core::sync::run_sync;
|
||||||
use crate::debug::DebugLogger;
|
use crate::debug::DebugLogger;
|
||||||
use gocardless_client::client::GoCardlessClient;
|
use chrono::NaiveDate;
|
||||||
|
use clap::Parser;
|
||||||
use firefly_client::client::FireflyClient;
|
use firefly_client::client::FireflyClient;
|
||||||
|
use gocardless_client::client::GoCardlessClient;
|
||||||
use reqwest_middleware::ClientBuilder;
|
use reqwest_middleware::ClientBuilder;
|
||||||
use std::env;
|
use std::env;
|
||||||
use chrono::NaiveDate;
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
@@ -56,7 +56,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config Load
|
// Config Load
|
||||||
let gc_url = env::var("GOCARDLESS_URL").unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
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_id = env::var("GOCARDLESS_ID").expect("GOCARDLESS_ID not set");
|
||||||
let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set");
|
let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set");
|
||||||
|
|
||||||
@@ -90,10 +91,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
match run_sync(source, destination, args.start, args.end, args.dry_run).await {
|
match run_sync(source, destination, args.start, args.end, args.dry_run).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
info!("Sync completed successfully.");
|
info!("Sync completed successfully.");
|
||||||
info!("Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
|
info!(
|
||||||
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors);
|
"Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
|
||||||
info!("Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
result.accounts_processed,
|
||||||
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors);
|
result.accounts_skipped_expired,
|
||||||
|
result.accounts_skipped_errors
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||||
|
result.ingest.created,
|
||||||
|
result.ingest.healed,
|
||||||
|
result.ingest.duplicates,
|
||||||
|
result.ingest.errors
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => error!("Sync failed: {}", e),
|
Err(e) => error!("Sync failed: {}", e),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
use crate::models::{AccountArray, TransactionArray, TransactionStore, TransactionUpdate};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use reqwest_middleware::ClientWithMiddleware;
|
use reqwest_middleware::ClientWithMiddleware;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
use crate::models::{AccountArray, TransactionStore, TransactionArray, TransactionUpdate};
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum FireflyError {
|
pub enum FireflyError {
|
||||||
@@ -28,10 +28,16 @@ impl FireflyClient {
|
|||||||
Self::with_client(base_url, access_token, None)
|
Self::with_client(base_url, access_token, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_client(base_url: &str, access_token: &str, client: Option<ClientWithMiddleware>) -> Result<Self, FireflyError> {
|
pub fn with_client(
|
||||||
|
base_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
client: Option<ClientWithMiddleware>,
|
||||||
|
) -> Result<Self, FireflyError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
base_url: Url::parse(base_url)?,
|
base_url: Url::parse(base_url)?,
|
||||||
client: client.unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()),
|
client: client.unwrap_or_else(|| {
|
||||||
|
reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()
|
||||||
|
}),
|
||||||
access_token: access_token.to_string(),
|
access_token: access_token.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -39,12 +45,11 @@ impl FireflyClient {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> {
|
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> {
|
||||||
let mut url = self.base_url.join("/api/v1/accounts")?;
|
let mut url = self.base_url.join("/api/v1/accounts")?;
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut().append_pair("type", "asset");
|
||||||
.append_pair("type", "asset");
|
|
||||||
|
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn search_accounts(&self, query: &str) -> Result<AccountArray, FireflyError> {
|
pub async fn search_accounts(&self, query: &str) -> Result<AccountArray, FireflyError> {
|
||||||
let mut url = self.base_url.join("/api/v1/search/accounts")?;
|
let mut url = self.base_url.join("/api/v1/search/accounts")?;
|
||||||
@@ -52,15 +57,20 @@ impl FireflyClient {
|
|||||||
.append_pair("query", query)
|
.append_pair("query", query)
|
||||||
.append_pair("type", "asset")
|
.append_pair("type", "asset")
|
||||||
.append_pair("field", "all");
|
.append_pair("field", "all");
|
||||||
|
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, transaction))]
|
#[instrument(skip(self, transaction))]
|
||||||
pub async fn store_transaction(&self, transaction: TransactionStore) -> Result<(), FireflyError> {
|
pub async fn store_transaction(
|
||||||
|
&self,
|
||||||
|
transaction: TransactionStore,
|
||||||
|
) -> Result<(), FireflyError> {
|
||||||
let url = self.base_url.join("/api/v1/transactions")?;
|
let url = self.base_url.join("/api/v1/transactions")?;
|
||||||
|
|
||||||
let response = self.client.post(url)
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(url)
|
||||||
.bearer_auth(&self.access_token)
|
.bearer_auth(&self.access_token)
|
||||||
.header("accept", "application/json")
|
.header("accept", "application/json")
|
||||||
.json(&transaction)
|
.json(&transaction)
|
||||||
@@ -70,15 +80,25 @@ impl FireflyClient {
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
return Err(FireflyError::ApiError(format!("Store Transaction Failed {}: {}", status, text)));
|
return Err(FireflyError::ApiError(format!(
|
||||||
|
"Store Transaction Failed {}: {}",
|
||||||
|
status, text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_account_transactions(&self, account_id: &str, start: Option<&str>, end: Option<&str>) -> Result<TransactionArray, FireflyError> {
|
pub async fn list_account_transactions(
|
||||||
let mut url = self.base_url.join(&format!("/api/v1/accounts/{}/transactions", account_id))?;
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
start: Option<&str>,
|
||||||
|
end: Option<&str>,
|
||||||
|
) -> Result<TransactionArray, FireflyError> {
|
||||||
|
let mut url = self
|
||||||
|
.base_url
|
||||||
|
.join(&format!("/api/v1/accounts/{}/transactions", account_id))?;
|
||||||
{
|
{
|
||||||
let mut pairs = url.query_pairs_mut();
|
let mut pairs = url.query_pairs_mut();
|
||||||
if let Some(s) = start {
|
if let Some(s) = start {
|
||||||
@@ -88,17 +108,25 @@ impl FireflyClient {
|
|||||||
pairs.append_pair("end", e);
|
pairs.append_pair("end", e);
|
||||||
}
|
}
|
||||||
// Limit to 50, could be higher but safer to page if needed. For heuristic checks 50 is usually plenty per day range.
|
// Limit to 50, could be higher but safer to page if needed. For heuristic checks 50 is usually plenty per day range.
|
||||||
pairs.append_pair("limit", "50");
|
pairs.append_pair("limit", "50");
|
||||||
}
|
}
|
||||||
|
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, update))]
|
#[instrument(skip(self, update))]
|
||||||
pub async fn update_transaction(&self, id: &str, update: TransactionUpdate) -> Result<(), FireflyError> {
|
pub async fn update_transaction(
|
||||||
let url = self.base_url.join(&format!("/api/v1/transactions/{}", id))?;
|
&self,
|
||||||
|
id: &str,
|
||||||
|
update: TransactionUpdate,
|
||||||
|
) -> Result<(), FireflyError> {
|
||||||
|
let url = self
|
||||||
|
.base_url
|
||||||
|
.join(&format!("/api/v1/transactions/{}", id))?;
|
||||||
|
|
||||||
let response = self.client.put(url)
|
let response = self
|
||||||
|
.client
|
||||||
|
.put(url)
|
||||||
.bearer_auth(&self.access_token)
|
.bearer_auth(&self.access_token)
|
||||||
.header("accept", "application/json")
|
.header("accept", "application/json")
|
||||||
.json(&update)
|
.json(&update)
|
||||||
@@ -106,25 +134,33 @@ impl FireflyClient {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
return Err(FireflyError::ApiError(format!("Update Transaction Failed {}: {}", status, text)));
|
return Err(FireflyError::ApiError(format!(
|
||||||
|
"Update Transaction Failed {}: {}",
|
||||||
|
status, text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_authenticated<T: DeserializeOwned>(&self, url: Url) -> Result<T, FireflyError> {
|
async fn get_authenticated<T: DeserializeOwned>(&self, url: Url) -> Result<T, FireflyError> {
|
||||||
let response = self.client.get(url)
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
.bearer_auth(&self.access_token)
|
.bearer_auth(&self.access_token)
|
||||||
.header("accept", "application/json")
|
.header("accept", "application/json")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
return Err(FireflyError::ApiError(format!("API request failed {}: {}", status, text)));
|
return Err(FireflyError::ApiError(format!(
|
||||||
|
"API request failed {}: {}",
|
||||||
|
status, text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = response.json().await?;
|
let data = response.json().await?;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use firefly_client::client::FireflyClient;
|
use firefly_client::client::FireflyClient;
|
||||||
use firefly_client::models::{TransactionStore, TransactionSplitStore};
|
use firefly_client::models::{TransactionSplitStore, TransactionStore};
|
||||||
use wiremock::matchers::{method, path, header};
|
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use wiremock::matchers::{header, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_search_accounts() {
|
async fn test_search_accounts() {
|
||||||
@@ -21,7 +21,10 @@ async fn test_search_accounts() {
|
|||||||
|
|
||||||
assert_eq!(accounts.data.len(), 1);
|
assert_eq!(accounts.data.len(), 1);
|
||||||
assert_eq!(accounts.data[0].attributes.name, "Checking Account");
|
assert_eq!(accounts.data[0].attributes.name, "Checking Account");
|
||||||
assert_eq!(accounts.data[0].attributes.iban.as_deref(), Some("NL01BANK0123456789"));
|
assert_eq!(
|
||||||
|
accounts.data[0].attributes.iban.as_deref(),
|
||||||
|
Some("NL01BANK0123456789")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -36,7 +39,7 @@ async fn test_store_transaction() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let client = FireflyClient::new(&mock_server.uri(), "my-token").unwrap();
|
let client = FireflyClient::new(&mock_server.uri(), "my-token").unwrap();
|
||||||
|
|
||||||
let tx = TransactionStore {
|
let tx = TransactionStore {
|
||||||
transactions: vec![TransactionSplitStore {
|
transactions: vec![TransactionSplitStore {
|
||||||
transaction_type: "withdrawal".to_string(),
|
transaction_type: "withdrawal".to_string(),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
use crate::models::{
|
||||||
|
Account, EndUserAgreement, PaginatedResponse, Requisition, TokenResponse, TransactionsResponse,
|
||||||
|
};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use reqwest_middleware::ClientWithMiddleware;
|
use reqwest_middleware::ClientWithMiddleware;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
use crate::models::{TokenResponse, PaginatedResponse, Requisition, Account, TransactionsResponse, EndUserAgreement};
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum GoCardlessError {
|
pub enum GoCardlessError {
|
||||||
@@ -39,10 +41,17 @@ impl GoCardlessClient {
|
|||||||
Self::with_client(base_url, secret_id, secret_key, None)
|
Self::with_client(base_url, secret_id, secret_key, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_client(base_url: &str, secret_id: &str, secret_key: &str, client: Option<ClientWithMiddleware>) -> Result<Self, GoCardlessError> {
|
pub fn with_client(
|
||||||
|
base_url: &str,
|
||||||
|
secret_id: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
client: Option<ClientWithMiddleware>,
|
||||||
|
) -> Result<Self, GoCardlessError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
base_url: Url::parse(base_url)?,
|
base_url: Url::parse(base_url)?,
|
||||||
client: client.unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()),
|
client: client.unwrap_or_else(|| {
|
||||||
|
reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()
|
||||||
|
}),
|
||||||
secret_id: secret_id.to_string(),
|
secret_id: secret_id.to_string(),
|
||||||
secret_key: secret_key.to_string(),
|
secret_key: secret_key.to_string(),
|
||||||
access_token: None,
|
access_token: None,
|
||||||
@@ -67,40 +76,47 @@ impl GoCardlessClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
debug!("Requesting new access token");
|
debug!("Requesting new access token");
|
||||||
let response = self.client.post(url)
|
let response = self.client.post(url).json(&body).send().await?;
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
return Err(GoCardlessError::ApiError(format!("Token request failed {}: {}", status, text)));
|
return Err(GoCardlessError::ApiError(format!(
|
||||||
|
"Token request failed {}: {}",
|
||||||
|
status, text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let token_resp: TokenResponse = response.json().await?;
|
let token_resp: TokenResponse = response.json().await?;
|
||||||
self.access_token = Some(token_resp.access);
|
self.access_token = Some(token_resp.access);
|
||||||
self.access_expires_at = Some(chrono::Utc::now() + chrono::Duration::seconds(token_resp.access_expires as i64));
|
self.access_expires_at =
|
||||||
|
Some(chrono::Utc::now() + chrono::Duration::seconds(token_resp.access_expires as i64));
|
||||||
debug!("Access token obtained");
|
debug!("Access token obtained");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_requisitions(&self) -> Result<PaginatedResponse<Requisition>, GoCardlessError> {
|
pub async fn get_requisitions(
|
||||||
|
&self,
|
||||||
|
) -> Result<PaginatedResponse<Requisition>, GoCardlessError> {
|
||||||
let url = self.base_url.join("/api/v2/requisitions/")?;
|
let url = self.base_url.join("/api/v2/requisitions/")?;
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_agreements(&self) -> Result<PaginatedResponse<EndUserAgreement>, GoCardlessError> {
|
pub async fn get_agreements(
|
||||||
|
&self,
|
||||||
|
) -> Result<PaginatedResponse<EndUserAgreement>, GoCardlessError> {
|
||||||
let url = self.base_url.join("/api/v2/agreements/enduser/")?;
|
let url = self.base_url.join("/api/v2/agreements/enduser/")?;
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_agreement(&self, id: &str) -> Result<EndUserAgreement, GoCardlessError> {
|
pub async fn get_agreement(&self, id: &str) -> Result<EndUserAgreement, GoCardlessError> {
|
||||||
let url = self.base_url.join(&format!("/api/v2/agreements/enduser/{}/", id))?;
|
let url = self
|
||||||
|
.base_url
|
||||||
|
.join(&format!("/api/v2/agreements/enduser/{}/", id))?;
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,9 +148,16 @@ impl GoCardlessClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_transactions(&self, account_id: &str, date_from: Option<&str>, date_to: Option<&str>) -> Result<TransactionsResponse, GoCardlessError> {
|
pub async fn get_transactions(
|
||||||
let mut url = self.base_url.join(&format!("/api/v2/accounts/{}/transactions/", account_id))?;
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
date_from: Option<&str>,
|
||||||
|
date_to: Option<&str>,
|
||||||
|
) -> Result<TransactionsResponse, GoCardlessError> {
|
||||||
|
let mut url = self
|
||||||
|
.base_url
|
||||||
|
.join(&format!("/api/v2/accounts/{}/transactions/", account_id))?;
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut pairs = url.query_pairs_mut();
|
let mut pairs = url.query_pairs_mut();
|
||||||
if let Some(from) = date_from {
|
if let Some(from) = date_from {
|
||||||
@@ -148,19 +171,29 @@ impl GoCardlessClient {
|
|||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_authenticated<T: for<'de> Deserialize<'de>>(&self, url: Url) -> Result<T, GoCardlessError> {
|
async fn get_authenticated<T: for<'de> Deserialize<'de>>(
|
||||||
let token = self.access_token.as_ref().ok_or(GoCardlessError::ApiError("No access token available. Call obtain_access_token() first.".into()))?;
|
&self,
|
||||||
|
url: Url,
|
||||||
|
) -> Result<T, GoCardlessError> {
|
||||||
|
let token = self.access_token.as_ref().ok_or(GoCardlessError::ApiError(
|
||||||
|
"No access token available. Call obtain_access_token() first.".into(),
|
||||||
|
))?;
|
||||||
|
|
||||||
let response = self.client.get(url)
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
.bearer_auth(token)
|
.bearer_auth(token)
|
||||||
.header("accept", "application/json")
|
.header("accept", "application/json")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
return Err(GoCardlessError::ApiError(format!("API request failed {}: {}", status, text)));
|
return Err(GoCardlessError::ApiError(format!(
|
||||||
|
"API request failed {}: {}",
|
||||||
|
status, text
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = response.json().await?;
|
let data = response.json().await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user