1use rust_decimal::Decimal;
14use rustledger_plugin_types::{DirectiveData, DirectiveWrapper};
15use std::collections::HashSet;
16use std::str::FromStr;
17
18#[derive(Debug, Clone)]
20pub struct TransferConfig {
21 pub date_window_days: i64,
23 pub amount_tolerance: Decimal,
25}
26
27impl Default for TransferConfig {
28 fn default() -> Self {
29 Self {
30 date_window_days: 3,
31 amount_tolerance: Decimal::new(1, 2), }
33 }
34}
35
36#[derive(Debug, Clone)]
38pub struct TransferMatch {
39 pub from_group: usize,
41 pub from_index: usize,
43 pub to_group: usize,
45 pub to_index: usize,
47 pub amount: Decimal,
49 pub currency: String,
51 pub confidence: f64,
53}
54
55#[must_use]
60pub fn find_transfers(
61 groups: &[(String, Vec<DirectiveWrapper>)],
62 config: &TransferConfig,
63) -> Vec<TransferMatch> {
64 let mut matches = Vec::new();
65 let mut globally_matched: HashSet<(usize, usize)> = HashSet::new();
68
69 for (g1, (_, directives1)) in groups.iter().enumerate() {
71 for (g2, (_, directives2)) in groups.iter().enumerate() {
72 if g2 <= g1 {
73 continue; }
75
76 find_matches_between(
77 g1,
78 directives1,
79 g2,
80 directives2,
81 config,
82 &mut matches,
83 &mut globally_matched,
84 );
85 }
86 }
87
88 matches
89}
90
91fn find_matches_between(
93 g1: usize,
94 directives1: &[DirectiveWrapper],
95 g2: usize,
96 directives2: &[DirectiveWrapper],
97 config: &TransferConfig,
98 matches: &mut Vec<TransferMatch>,
99 globally_matched: &mut HashSet<(usize, usize)>,
100) {
101 for (i, d1) in directives1.iter().enumerate() {
102 if globally_matched.contains(&(g1, i)) {
103 continue;
104 }
105
106 let Some((amount1, currency1)) = first_posting_amount_currency(d1) else {
107 continue;
108 };
109
110 for (j, d2) in directives2.iter().enumerate() {
111 if globally_matched.contains(&(g2, j)) {
112 continue;
113 }
114
115 let Some((amount2, currency2)) = first_posting_amount_currency(d2) else {
116 continue;
117 };
118
119 if currency1 != currency2 {
121 continue;
122 }
123
124 let sum = (amount1 + amount2).abs();
126 if sum > config.amount_tolerance {
127 continue;
128 }
129
130 if !within_date_window(&d1.date, &d2.date, config.date_window_days) {
132 continue;
133 }
134
135 let mut confidence: f64 = 0.7; if has_transfer_keywords(d1) || has_transfer_keywords(d2) {
140 confidence += 0.2;
141 }
142
143 if d1.date == d2.date {
145 confidence += 0.1;
146 }
147
148 let confidence = confidence.min(1.0);
149
150 let (from_group, from_index, to_group, to_index) = if amount1.is_sign_negative() {
152 (g1, i, g2, j)
153 } else {
154 (g2, j, g1, i)
155 };
156
157 matches.push(TransferMatch {
158 from_group,
159 from_index,
160 to_group,
161 to_index,
162 amount: amount1.abs(),
163 currency: currency1.to_string(),
164 confidence,
165 });
166
167 globally_matched.insert((g1, i));
168 globally_matched.insert((g2, j));
169 break; }
171 }
172}
173
174fn first_posting_amount_currency(d: &DirectiveWrapper) -> Option<(Decimal, &str)> {
176 if let DirectiveData::Transaction(txn) = &d.data
177 && let Some(posting) = txn.postings.first()
178 && let Some(units) = &posting.units
179 {
180 let amount = Decimal::from_str(&units.number).ok()?;
181 return Some((amount, &units.currency));
182 }
183 None
184}
185
186fn within_date_window(date1: &str, date2: &str, days: i64) -> bool {
188 let d1: jiff::civil::Date = match date1.parse() {
190 Ok(d) => d,
191 Err(_) => return false,
192 };
193 let d2: jiff::civil::Date = match date2.parse() {
194 Ok(d) => d,
195 Err(_) => return false,
196 };
197 let Ok(span) = d2.since(d1) else {
198 return false;
199 };
200 let diff = span.get_days().abs();
201 i64::from(diff) <= days
202}
203
204const TRANSFER_KEYWORDS: &[&str] = &[
206 "transfer", "xfer", "ach", "wire", "payment", "internal", "move", "sweep",
207];
208
209fn has_transfer_keywords(d: &DirectiveWrapper) -> bool {
211 if let DirectiveData::Transaction(txn) = &d.data {
212 let narration_lower = txn.narration.to_lowercase();
213 if TRANSFER_KEYWORDS
214 .iter()
215 .any(|kw| narration_lower.contains(kw))
216 {
217 return true;
218 }
219 if let Some(ref payee) = txn.payee {
220 let payee_lower = payee.to_lowercase();
221 if TRANSFER_KEYWORDS.iter().any(|kw| payee_lower.contains(kw)) {
222 return true;
223 }
224 }
225 }
226 false
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use rustledger_plugin_types::{AmountData, PostingData, TransactionData};
233
234 fn make_txn(date: &str, narration: &str, amount: &str, currency: &str) -> DirectiveWrapper {
235 DirectiveWrapper {
236 directive_type: "transaction".to_string(),
237 date: date.to_string(),
238 filename: None,
239 lineno: None,
240 data: DirectiveData::Transaction(TransactionData {
241 flag: "*".to_string(),
242 payee: None,
243 narration: narration.to_string(),
244 tags: vec![],
245 links: vec![],
246 metadata: vec![],
247 postings: vec![PostingData {
248 account: "Assets:Bank".to_string(),
249 units: Some(AmountData {
250 number: amount.to_string(),
251 currency: currency.to_string(),
252 }),
253 cost: None,
254 price: None,
255 flag: None,
256 metadata: vec![],
257 }],
258 }),
259 }
260 }
261
262 #[test]
263 fn matches_opposite_amounts_same_date() {
264 let groups = vec![
265 (
266 "Assets:Checking".to_string(),
267 vec![make_txn(
268 "2024-01-15",
269 "Transfer to savings",
270 "-500.00",
271 "USD",
272 )],
273 ),
274 (
275 "Assets:Savings".to_string(),
276 vec![make_txn(
277 "2024-01-15",
278 "Transfer from checking",
279 "500.00",
280 "USD",
281 )],
282 ),
283 ];
284 let matches = find_transfers(&groups, &TransferConfig::default());
285 assert_eq!(matches.len(), 1);
286 assert_eq!(matches[0].amount, Decimal::new(50000, 2));
287 assert!(matches[0].confidence > 0.8); }
289
290 #[test]
291 fn matches_within_date_window() {
292 let groups = vec![
293 (
294 "Assets:Checking".to_string(),
295 vec![make_txn("2024-01-15", "ACH payment", "-200.00", "USD")],
296 ),
297 (
298 "Assets:CreditCard".to_string(),
299 vec![make_txn("2024-01-17", "Payment received", "200.00", "USD")],
300 ),
301 ];
302 let matches = find_transfers(&groups, &TransferConfig::default());
303 assert_eq!(matches.len(), 1);
304 }
305
306 #[test]
307 fn no_match_outside_date_window() {
308 let groups = vec![
309 (
310 "Assets:Checking".to_string(),
311 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
312 ),
313 (
314 "Assets:Savings".to_string(),
315 vec![make_txn("2024-01-25", "Transfer", "500.00", "USD")],
316 ),
317 ];
318 let matches = find_transfers(&groups, &TransferConfig::default());
319 assert!(matches.is_empty());
320 }
321
322 #[test]
323 fn no_match_different_currency() {
324 let groups = vec![
325 (
326 "Assets:Checking".to_string(),
327 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
328 ),
329 (
330 "Assets:Savings".to_string(),
331 vec![make_txn("2024-01-15", "Transfer", "500.00", "EUR")],
332 ),
333 ];
334 let matches = find_transfers(&groups, &TransferConfig::default());
335 assert!(matches.is_empty());
336 }
337
338 #[test]
339 fn no_match_same_sign() {
340 let groups = vec![
341 (
342 "Assets:Checking".to_string(),
343 vec![make_txn("2024-01-15", "Deposit", "500.00", "USD")],
344 ),
345 (
346 "Assets:Savings".to_string(),
347 vec![make_txn("2024-01-15", "Deposit", "500.00", "USD")],
348 ),
349 ];
350 let matches = find_transfers(&groups, &TransferConfig::default());
351 assert!(matches.is_empty());
352 }
353
354 #[test]
355 fn no_match_different_amounts() {
356 let groups = vec![
357 (
358 "Assets:Checking".to_string(),
359 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
360 ),
361 (
362 "Assets:Savings".to_string(),
363 vec![make_txn("2024-01-15", "Transfer", "499.00", "USD")],
364 ),
365 ];
366 let matches = find_transfers(&groups, &TransferConfig::default());
367 assert!(matches.is_empty());
368 }
369
370 #[test]
371 fn transfer_keywords_boost_confidence() {
372 let groups = vec![
373 (
374 "Assets:Checking".to_string(),
375 vec![make_txn(
376 "2024-01-15",
377 "TRANSFER TO SAVINGS",
378 "-500.00",
379 "USD",
380 )],
381 ),
382 (
383 "Assets:Savings".to_string(),
384 vec![make_txn(
385 "2024-01-15",
386 "TRANSFER FROM CHECKING",
387 "500.00",
388 "USD",
389 )],
390 ),
391 ];
392 let matches = find_transfers(&groups, &TransferConfig::default());
393 assert_eq!(matches.len(), 1);
394 assert!(matches[0].confidence >= 0.9);
396 }
397
398 #[test]
399 fn no_keywords_lower_confidence() {
400 let groups = vec![
401 (
402 "Assets:Checking".to_string(),
403 vec![make_txn("2024-01-15", "Something", "-500.00", "USD")],
404 ),
405 (
406 "Assets:Savings".to_string(),
407 vec![make_txn("2024-01-17", "Something else", "500.00", "USD")],
408 ),
409 ];
410 let matches = find_transfers(&groups, &TransferConfig::default());
411 assert_eq!(matches.len(), 1);
412 assert!(matches[0].confidence < 0.8);
414 }
415
416 #[test]
417 fn multiple_transfers() {
418 let groups = vec![
419 (
420 "Assets:Checking".to_string(),
421 vec![
422 make_txn("2024-01-15", "Transfer 1", "-500.00", "USD"),
423 make_txn("2024-01-20", "Transfer 2", "-300.00", "USD"),
424 ],
425 ),
426 (
427 "Assets:Savings".to_string(),
428 vec![
429 make_txn("2024-01-15", "Transfer 1", "500.00", "USD"),
430 make_txn("2024-01-20", "Transfer 2", "300.00", "USD"),
431 ],
432 ),
433 ];
434 let matches = find_transfers(&groups, &TransferConfig::default());
435 assert_eq!(matches.len(), 2);
436 }
437
438 #[test]
439 fn one_to_one_matching() {
440 let groups = vec![
442 (
443 "Assets:Checking".to_string(),
444 vec![
445 make_txn("2024-01-15", "Transfer", "-500.00", "USD"),
446 make_txn("2024-01-15", "Transfer", "-500.00", "USD"),
447 ],
448 ),
449 (
450 "Assets:Savings".to_string(),
451 vec![make_txn("2024-01-15", "Transfer", "500.00", "USD")],
452 ),
453 ];
454 let matches = find_transfers(&groups, &TransferConfig::default());
455 assert_eq!(matches.len(), 1);
457 }
458
459 #[test]
460 fn three_groups() {
461 let groups = vec![
462 (
463 "Assets:Checking".to_string(),
464 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
465 ),
466 (
467 "Assets:Savings".to_string(),
468 vec![make_txn("2024-01-15", "Transfer", "500.00", "USD")],
469 ),
470 (
471 "Assets:CreditCard".to_string(),
472 vec![make_txn("2024-01-15", "Payment", "200.00", "USD")],
473 ),
474 ];
475 let matches = find_transfers(&groups, &TransferConfig::default());
476 assert_eq!(matches.len(), 1);
478 }
479
480 #[test]
481 fn empty_groups() {
482 let groups: Vec<(String, Vec<DirectiveWrapper>)> = vec![];
483 let matches = find_transfers(&groups, &TransferConfig::default());
484 assert!(matches.is_empty());
485 }
486}