Implement Phase 2: Range Management + Range Testing - added range overlap detection, transaction deduplication, range merging, cache coverage checking, and comprehensive unit tests

This commit is contained in:
2025-11-21 22:02:54 +01:00
parent a1871f64a6
commit d8bf1513de
2 changed files with 368 additions and 12 deletions

View File

@@ -1,18 +1,19 @@
use chrono::NaiveDate;
use chrono::{NaiveDate, Days};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::collections::HashSet;
use anyhow::Result;
use crate::adapters::gocardless::encryption::Encryption;
use gocardless_client::models::Transaction;
use rand;
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AccountTransactionCache {
pub account_id: String,
pub ranges: Vec<CachedRange>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CachedRange {
pub start_date: NaiveDate,
pub end_date: NaiveDate,
@@ -63,6 +64,166 @@ impl AccountTransactionCache {
std::fs::write(path, encrypted_data)?;
Ok(())
}
/// Get cached transactions within date range
pub fn get_cached_transactions(&self, start: NaiveDate, end: NaiveDate) -> Vec<Transaction> {
let mut result = Vec::new();
for range in &self.ranges {
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
for tx in &range.transactions {
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 booking_date >= start && booking_date <= end {
result.push(tx.clone());
}
}
}
}
}
}
result
}
/// Get uncovered date ranges within requested period
pub fn get_uncovered_ranges(&self, start: NaiveDate, end: NaiveDate) -> Vec<(NaiveDate, NaiveDate)> {
let mut covered_periods: Vec<(NaiveDate, NaiveDate)> = self.ranges
.iter()
.filter_map(|range| {
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
let overlap_start = range.start_date.max(start);
let overlap_end = range.end_date.min(end);
if overlap_start <= overlap_end {
Some((overlap_start, overlap_end))
} else {
None
}
} else {
None
}
})
.collect();
covered_periods.sort_by_key(|&(s, _)| s);
// Merge overlapping covered periods
let mut merged_covered: Vec<(NaiveDate, NaiveDate)> = Vec::new();
for period in covered_periods {
if let Some(last) = merged_covered.last_mut() {
if last.1 >= period.0 {
last.1 = last.1.max(period.1);
} else {
merged_covered.push(period);
}
} else {
merged_covered.push(period);
}
}
// Find gaps
let mut uncovered = Vec::new();
let mut current_start = start;
for (cov_start, cov_end) in merged_covered {
if current_start < cov_start {
uncovered.push((current_start, cov_start - Days::new(1)));
}
current_start = cov_end + Days::new(1);
}
if current_start <= end {
uncovered.push((current_start, end));
}
uncovered
}
/// Store transactions for a date range, merging with existing cache
pub fn store_transactions(&mut self, start: NaiveDate, end: NaiveDate, mut transactions: Vec<Transaction>) {
Self::deduplicate_transactions(&mut transactions);
let new_range = CachedRange {
start_date: start,
end_date: end,
transactions,
};
self.merge_ranges(new_range);
}
/// Merge a new range into existing ranges
pub fn merge_ranges(&mut self, new_range: CachedRange) {
// Find overlapping or adjacent ranges
let mut to_merge = Vec::new();
let mut remaining = Vec::new();
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) {
to_merge.push(range.clone());
} else {
remaining.push(range.clone());
}
}
// Merge all overlapping/adjacent ranges including the new one
to_merge.push(new_range);
let merged = Self::merge_range_list(to_merge);
// Update ranges
self.ranges = remaining;
self.ranges.extend(merged);
}
/// Check if two date ranges overlap
fn ranges_overlap(start1: NaiveDate, end1: NaiveDate, start2: NaiveDate, end2: NaiveDate) -> bool {
start1 <= end2 && start2 <= end1
}
/// Check if two date ranges overlap or are adjacent
fn ranges_overlap_or_adjacent(start1: NaiveDate, end1: NaiveDate, 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
fn merge_range_list(ranges: Vec<CachedRange>) -> Vec<CachedRange> {
if ranges.is_empty() {
return Vec::new();
}
// Sort by start date
let mut sorted = ranges;
sorted.sort_by_key(|r| r.start_date);
let mut merged = Vec::new();
let mut current = sorted[0].clone();
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) {
// Merge
current.start_date = current.start_date.min(range.start_date);
current.end_date = current.end_date.max(range.end_date);
// Deduplicate transactions
current.transactions.extend(range.transactions);
Self::deduplicate_transactions(&mut current.transactions);
} else {
merged.push(current);
current = range;
}
}
merged.push(current);
merged
}
/// Deduplicate transactions by transaction_id
fn deduplicate_transactions(transactions: &mut Vec<Transaction>) {
let mut seen = std::collections::HashSet::new();
transactions.retain(|tx| {
if let Some(id) = &tx.transaction_id {
seen.insert(id.clone())
} else {
true // Keep if no id
}
});
}
}
#[cfg(test)]
@@ -222,4 +383,175 @@ mod tests {
cleanup_test_dir(&cache_dir);
}
#[test]
fn test_get_uncovered_ranges_no_cache() {
let cache = AccountTransactionCache {
account_id: "test".to_string(),
ranges: Vec::new(),
};
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let uncovered = cache.get_uncovered_ranges(start, end);
assert_eq!(uncovered, vec![(start, end)]);
}
#[test]
fn test_get_uncovered_ranges_full_coverage() {
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
transactions: Vec::new(),
};
let cache = AccountTransactionCache {
account_id: "test".to_string(),
ranges: vec![range],
};
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let uncovered = cache.get_uncovered_ranges(start, end);
assert!(uncovered.is_empty());
}
#[test]
fn test_get_uncovered_ranges_partial_coverage() {
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
transactions: Vec::new(),
};
let cache = AccountTransactionCache {
account_id: "test".to_string(),
ranges: vec![range],
};
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let uncovered = cache.get_uncovered_ranges(start, end);
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!(uncovered[1], (NaiveDate::from_ymd_opt(2024, 1, 21).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()));
}
#[test]
fn test_store_transactions_and_merge() {
let mut cache = AccountTransactionCache {
account_id: "test".to_string(),
ranges: Vec::new(),
};
let start1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let tx1 = Transaction {
transaction_id: Some("tx1".to_string()),
booking_date: Some("2024-01-05".to_string()),
value_date: None,
transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(),
currency: "EUR".to_string(),
},
currency_exchange: None,
creditor_name: Some("Creditor".to_string()),
creditor_account: None,
debtor_name: None,
debtor_account: None,
remittance_information_unstructured: Some("Payment".to_string()),
proprietary_bank_transaction_code: None,
};
cache.store_transactions(start1, end1, vec![tx1]);
assert_eq!(cache.ranges.len(), 1);
assert_eq!(cache.ranges[0].start_date, start1);
assert_eq!(cache.ranges[0].end_date, end1);
assert_eq!(cache.ranges[0].transactions.len(), 1);
// Add overlapping range
let start2 = NaiveDate::from_ymd_opt(2024, 1, 5).unwrap();
let end2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let tx2 = Transaction {
transaction_id: Some("tx2".to_string()),
booking_date: Some("2024-01-12".to_string()),
value_date: None,
transaction_amount: gocardless_client::models::TransactionAmount {
amount: "200.00".to_string(),
currency: "EUR".to_string(),
},
currency_exchange: None,
creditor_name: Some("Creditor2".to_string()),
creditor_account: None,
debtor_name: None,
debtor_account: None,
remittance_information_unstructured: Some("Payment2".to_string()),
proprietary_bank_transaction_code: None,
};
cache.store_transactions(start2, end2, vec![tx2]);
// Should merge into one range
assert_eq!(cache.ranges.len(), 1);
assert_eq!(cache.ranges[0].start_date, start1);
assert_eq!(cache.ranges[0].end_date, end2);
assert_eq!(cache.ranges[0].transactions.len(), 2);
}
#[test]
fn test_transaction_deduplication() {
let mut cache = AccountTransactionCache {
account_id: "test".to_string(),
ranges: Vec::new(),
};
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let tx1 = Transaction {
transaction_id: Some("dup".to_string()),
booking_date: Some("2024-01-05".to_string()),
value_date: None,
transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(),
currency: "EUR".to_string(),
},
currency_exchange: None,
creditor_name: Some("Creditor".to_string()),
creditor_account: None,
debtor_name: None,
debtor_account: None,
remittance_information_unstructured: Some("Payment".to_string()),
proprietary_bank_transaction_code: None,
};
let tx2 = tx1.clone(); // Duplicate
cache.store_transactions(start, end, vec![tx1, tx2]);
assert_eq!(cache.ranges[0].transactions.len(), 1);
}
#[test]
fn test_get_cached_transactions() {
let tx1 = Transaction {
transaction_id: Some("tx1".to_string()),
booking_date: Some("2024-01-05".to_string()),
value_date: None,
transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(),
currency: "EUR".to_string(),
},
currency_exchange: None,
creditor_name: Some("Creditor".to_string()),
creditor_account: None,
debtor_name: None,
debtor_account: None,
remittance_information_unstructured: Some("Payment".to_string()),
proprietary_bank_transaction_code: None,
};
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
transactions: vec![tx1],
};
let cache = AccountTransactionCache {
account_id: "test".to_string(),
ranges: vec![range],
};
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let cached = cache.get_cached_transactions(start, end);
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].transaction_id, Some("tx1".to_string()));
}
}