use rust_decimal::Decimal;
use rustledger_plugin_types::{DirectiveData, DirectiveWrapper};
use std::collections::HashSet;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct TransferConfig {
pub date_window_days: i64,
pub amount_tolerance: Decimal,
}
impl Default for TransferConfig {
fn default() -> Self {
Self {
date_window_days: 3,
amount_tolerance: Decimal::new(1, 2), }
}
}
#[derive(Debug, Clone)]
pub struct TransferMatch {
pub from_group: usize,
pub from_index: usize,
pub to_group: usize,
pub to_index: usize,
pub amount: Decimal,
pub currency: String,
pub confidence: f64,
}
#[must_use]
pub fn find_transfers(
groups: &[(String, Vec<DirectiveWrapper>)],
config: &TransferConfig,
) -> Vec<TransferMatch> {
let mut matches = Vec::new();
let mut globally_matched: HashSet<(usize, usize)> = HashSet::new();
for (g1, (_, directives1)) in groups.iter().enumerate() {
for (g2, (_, directives2)) in groups.iter().enumerate() {
if g2 <= g1 {
continue; }
find_matches_between(
g1,
directives1,
g2,
directives2,
config,
&mut matches,
&mut globally_matched,
);
}
}
matches
}
fn find_matches_between(
g1: usize,
directives1: &[DirectiveWrapper],
g2: usize,
directives2: &[DirectiveWrapper],
config: &TransferConfig,
matches: &mut Vec<TransferMatch>,
globally_matched: &mut HashSet<(usize, usize)>,
) {
for (i, d1) in directives1.iter().enumerate() {
if globally_matched.contains(&(g1, i)) {
continue;
}
let Some((amount1, currency1)) = first_posting_amount_currency(d1) else {
continue;
};
for (j, d2) in directives2.iter().enumerate() {
if globally_matched.contains(&(g2, j)) {
continue;
}
let Some((amount2, currency2)) = first_posting_amount_currency(d2) else {
continue;
};
if currency1 != currency2 {
continue;
}
let sum = (amount1 + amount2).abs();
if sum > config.amount_tolerance {
continue;
}
if !within_date_window(&d1.date, &d2.date, config.date_window_days) {
continue;
}
let mut confidence: f64 = 0.7;
if has_transfer_keywords(d1) || has_transfer_keywords(d2) {
confidence += 0.2;
}
if d1.date == d2.date {
confidence += 0.1;
}
let confidence = confidence.min(1.0);
let (from_group, from_index, to_group, to_index) = if amount1.is_sign_negative() {
(g1, i, g2, j)
} else {
(g2, j, g1, i)
};
matches.push(TransferMatch {
from_group,
from_index,
to_group,
to_index,
amount: amount1.abs(),
currency: currency1.to_string(),
confidence,
});
globally_matched.insert((g1, i));
globally_matched.insert((g2, j));
break; }
}
}
fn first_posting_amount_currency(d: &DirectiveWrapper) -> Option<(Decimal, &str)> {
if let DirectiveData::Transaction(txn) = &d.data
&& let Some(posting) = txn.postings.first()
&& let Some(units) = &posting.units
{
let amount = Decimal::from_str(&units.number).ok()?;
return Some((amount, &units.currency));
}
None
}
fn within_date_window(date1: &str, date2: &str, days: i64) -> bool {
let d1: jiff::civil::Date = match date1.parse() {
Ok(d) => d,
Err(_) => return false,
};
let d2: jiff::civil::Date = match date2.parse() {
Ok(d) => d,
Err(_) => return false,
};
let Ok(span) = d2.since(d1) else {
return false;
};
let diff = span.get_days().abs();
i64::from(diff) <= days
}
const TRANSFER_KEYWORDS: &[&str] = &[
"transfer", "xfer", "ach", "wire", "payment", "internal", "move", "sweep",
];
fn has_transfer_keywords(d: &DirectiveWrapper) -> bool {
if let DirectiveData::Transaction(txn) = &d.data {
let narration_lower = txn.narration.to_lowercase();
if TRANSFER_KEYWORDS
.iter()
.any(|kw| narration_lower.contains(kw))
{
return true;
}
if let Some(ref payee) = txn.payee {
let payee_lower = payee.to_lowercase();
if TRANSFER_KEYWORDS.iter().any(|kw| payee_lower.contains(kw)) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_plugin_types::{AmountData, PostingData, TransactionData};
fn make_txn(date: &str, narration: &str, amount: &str, currency: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![PostingData {
account: "Assets:Bank".to_string(),
units: Some(AmountData {
number: amount.to_string(),
currency: currency.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
}],
}),
}
}
#[test]
fn matches_opposite_amounts_same_date() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn(
"2024-01-15",
"Transfer to savings",
"-500.00",
"USD",
)],
),
(
"Assets:Savings".to_string(),
vec![make_txn(
"2024-01-15",
"Transfer from checking",
"500.00",
"USD",
)],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].amount, Decimal::new(50000, 2));
assert!(matches[0].confidence > 0.8); }
#[test]
fn matches_within_date_window() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "ACH payment", "-200.00", "USD")],
),
(
"Assets:CreditCard".to_string(),
vec![make_txn("2024-01-17", "Payment received", "200.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 1);
}
#[test]
fn no_match_outside_date_window() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-25", "Transfer", "500.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert!(matches.is_empty());
}
#[test]
fn no_match_different_currency() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-15", "Transfer", "500.00", "EUR")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert!(matches.is_empty());
}
#[test]
fn no_match_same_sign() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "Deposit", "500.00", "USD")],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-15", "Deposit", "500.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert!(matches.is_empty());
}
#[test]
fn no_match_different_amounts() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-15", "Transfer", "499.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert!(matches.is_empty());
}
#[test]
fn transfer_keywords_boost_confidence() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn(
"2024-01-15",
"TRANSFER TO SAVINGS",
"-500.00",
"USD",
)],
),
(
"Assets:Savings".to_string(),
vec![make_txn(
"2024-01-15",
"TRANSFER FROM CHECKING",
"500.00",
"USD",
)],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 1);
assert!(matches[0].confidence >= 0.9);
}
#[test]
fn no_keywords_lower_confidence() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "Something", "-500.00", "USD")],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-17", "Something else", "500.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 1);
assert!(matches[0].confidence < 0.8);
}
#[test]
fn multiple_transfers() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![
make_txn("2024-01-15", "Transfer 1", "-500.00", "USD"),
make_txn("2024-01-20", "Transfer 2", "-300.00", "USD"),
],
),
(
"Assets:Savings".to_string(),
vec![
make_txn("2024-01-15", "Transfer 1", "500.00", "USD"),
make_txn("2024-01-20", "Transfer 2", "300.00", "USD"),
],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 2);
}
#[test]
fn one_to_one_matching() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![
make_txn("2024-01-15", "Transfer", "-500.00", "USD"),
make_txn("2024-01-15", "Transfer", "-500.00", "USD"),
],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-15", "Transfer", "500.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 1);
}
#[test]
fn three_groups() {
let groups = vec![
(
"Assets:Checking".to_string(),
vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
),
(
"Assets:Savings".to_string(),
vec![make_txn("2024-01-15", "Transfer", "500.00", "USD")],
),
(
"Assets:CreditCard".to_string(),
vec![make_txn("2024-01-15", "Payment", "200.00", "USD")],
),
];
let matches = find_transfers(&groups, &TransferConfig::default());
assert_eq!(matches.len(), 1);
}
#[test]
fn empty_groups() {
let groups: Vec<(String, Vec<DirectiveWrapper>)> = vec![];
let matches = find_transfers(&groups, &TransferConfig::default());
assert!(matches.is_empty());
}
}