1use rust_decimal::Decimal;
20use rustledger_plugin_types::{DirectiveData, DirectiveWrapper};
21use std::collections::{BTreeMap, HashSet};
22use std::str::FromStr;
23
24#[derive(Debug, Clone)]
26pub struct TransferConfig {
27 pub date_window_days: i64,
29 pub amount_tolerance: Decimal,
31}
32
33impl Default for TransferConfig {
34 fn default() -> Self {
35 Self {
36 date_window_days: 3,
37 amount_tolerance: Decimal::new(1, 2), }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct TransferMatch {
45 pub from_group: usize,
47 pub from_index: usize,
49 pub from_account: Option<String>,
51 pub from_filename: Option<String>,
53 pub from_lineno: Option<u32>,
55 pub to_group: usize,
57 pub to_index: usize,
59 pub to_account: Option<String>,
61 pub to_filename: Option<String>,
63 pub to_lineno: Option<u32>,
65 pub amount: Decimal,
67 pub currency: String,
69 pub confidence: f64,
71 pub date: String,
73}
74
75#[must_use]
84pub fn find_transfers(
85 groups: &[(String, Vec<DirectiveWrapper>)],
86 config: &TransferConfig,
87) -> Vec<TransferMatch> {
88 let mut matches = Vec::new();
89 let mut globally_matched: HashSet<(usize, usize)> = HashSet::new();
92
93 let group_accounts: Vec<&str> = groups.iter().map(|(a, _)| a.as_str()).collect();
94
95 for (g1, (_, directives1)) in groups.iter().enumerate() {
97 for (g2, (_, directives2)) in groups.iter().enumerate() {
98 if g2 <= g1 {
99 continue; }
101
102 find_matches_between(
103 g1,
104 directives1,
105 g2,
106 directives2,
107 &group_accounts,
108 config,
109 &mut matches,
110 &mut globally_matched,
111 );
112 }
113 }
114
115 matches
116}
117
118#[must_use]
134pub fn find_transfers_in_ledger(
135 directives: &[DirectiveWrapper],
136 config: &TransferConfig,
137) -> Vec<TransferMatch> {
138 let mut by_account: BTreeMap<String, Vec<DirectiveWrapper>> = BTreeMap::new();
140 for d in directives {
141 if let Some(account) = first_posting_account(d) {
142 by_account
143 .entry(account.to_string())
144 .or_default()
145 .push(d.clone());
146 }
147 }
148 let groups: Vec<(String, Vec<DirectiveWrapper>)> = by_account.into_iter().collect();
149 find_transfers(&groups, config)
150}
151
152#[allow(clippy::too_many_arguments)]
154fn find_matches_between(
155 g1: usize,
156 directives1: &[DirectiveWrapper],
157 g2: usize,
158 directives2: &[DirectiveWrapper],
159 group_accounts: &[&str],
160 config: &TransferConfig,
161 matches: &mut Vec<TransferMatch>,
162 globally_matched: &mut HashSet<(usize, usize)>,
163) {
164 for (i, d1) in directives1.iter().enumerate() {
165 if globally_matched.contains(&(g1, i)) {
166 continue;
167 }
168
169 let Some((amount1, currency1)) = first_posting_amount_currency(d1) else {
170 continue;
171 };
172
173 for (j, d2) in directives2.iter().enumerate() {
174 if globally_matched.contains(&(g2, j)) {
175 continue;
176 }
177
178 let Some((amount2, currency2)) = first_posting_amount_currency(d2) else {
179 continue;
180 };
181
182 if currency1 != currency2 {
184 continue;
185 }
186
187 let sum = (amount1 + amount2).abs();
189 if sum > config.amount_tolerance {
190 continue;
191 }
192
193 if !within_date_window(&d1.date, &d2.date, config.date_window_days) {
195 continue;
196 }
197
198 if shares_link(d1, d2) {
202 globally_matched.insert((g1, i));
203 globally_matched.insert((g2, j));
204 break;
205 }
206
207 let same_date = d1.date == d2.date;
208
209 let mut confidence: f64 = 0.7; let kw1 = classify_keywords(d1);
213 let kw2 = classify_keywords(d2);
214 let strong = kw1.strong || kw2.strong;
215 let weak = kw1.weak || kw2.weak;
216 if strong || (weak && same_date) {
217 confidence += 0.2;
218 }
219
220 if same_date {
221 confidence += 0.1;
222 }
223
224 let confidence = confidence.min(1.0);
225
226 let (from_group, from_index, to_group, to_index, from, to) =
228 if amount1.is_sign_negative() {
229 (g1, i, g2, j, d1, d2)
230 } else {
231 (g2, j, g1, i, d2, d1)
232 };
233
234 matches.push(TransferMatch {
235 from_group,
236 from_index,
237 from_account: group_accounts
238 .get(from_group)
239 .map(|s| (*s).to_string())
240 .filter(|s| !s.is_empty()),
241 from_filename: from.filename.clone(),
242 from_lineno: from.lineno,
243 to_group,
244 to_index,
245 to_account: group_accounts
246 .get(to_group)
247 .map(|s| (*s).to_string())
248 .filter(|s| !s.is_empty()),
249 to_filename: to.filename.clone(),
250 to_lineno: to.lineno,
251 amount: amount1.abs(),
252 currency: currency1.to_string(),
253 confidence,
254 date: from.date.clone(),
255 });
256
257 globally_matched.insert((g1, i));
258 globally_matched.insert((g2, j));
259 break; }
261 }
262}
263
264fn first_posting_amount_currency(d: &DirectiveWrapper) -> Option<(Decimal, &str)> {
266 if let DirectiveData::Transaction(txn) = &d.data
267 && let Some(posting) = txn.postings.first()
268 && let Some(units) = &posting.units
269 {
270 let amount = Decimal::from_str(&units.number).ok()?;
271 return Some((amount, &units.currency));
272 }
273 None
274}
275
276fn first_posting_account(d: &DirectiveWrapper) -> Option<&str> {
278 if let DirectiveData::Transaction(txn) = &d.data
279 && let Some(posting) = txn.postings.first()
280 {
281 return Some(posting.account.as_str());
282 }
283 None
284}
285
286fn shares_link(a: &DirectiveWrapper, b: &DirectiveWrapper) -> bool {
291 let (DirectiveData::Transaction(txn_a), DirectiveData::Transaction(txn_b)) = (&a.data, &b.data)
292 else {
293 return false;
294 };
295 if txn_a.links.is_empty() || txn_b.links.is_empty() {
296 return false;
297 }
298 let set: HashSet<&str> = txn_a.links.iter().map(String::as_str).collect();
299 txn_b.links.iter().any(|l| set.contains(l.as_str()))
300}
301
302fn within_date_window(date1: &str, date2: &str, days: i64) -> bool {
304 let d1: jiff::civil::Date = match date1.parse() {
306 Ok(d) => d,
307 Err(_) => return false,
308 };
309 let d2: jiff::civil::Date = match date2.parse() {
310 Ok(d) => d,
311 Err(_) => return false,
312 };
313 let Ok(span) = d2.since(d1) else {
314 return false;
315 };
316 let diff = span.get_days().abs();
317 i64::from(diff) <= days
318}
319
320const STRONG_KEYWORDS: &[&str] = &["transfer", "xfer", "internal", "sweep", "move"];
322
323const WEAK_KEYWORDS: &[&str] = &["payment", "ach", "wire"];
327
328#[derive(Default, Clone, Copy)]
329struct KeywordHit {
330 strong: bool,
331 weak: bool,
332}
333
334fn classify_keywords(d: &DirectiveWrapper) -> KeywordHit {
335 let DirectiveData::Transaction(txn) = &d.data else {
336 return KeywordHit::default();
337 };
338 let mut hit = KeywordHit::default();
339 let narration_lower = txn.narration.to_lowercase();
340 let payee_lower = txn.payee.as_deref().unwrap_or("").to_lowercase();
341 let scan = |needles: &[&str]| -> bool {
342 needles
343 .iter()
344 .any(|kw| narration_lower.contains(kw) || payee_lower.contains(kw))
345 };
346 hit.strong = scan(STRONG_KEYWORDS);
347 hit.weak = scan(WEAK_KEYWORDS);
348 hit
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use rustledger_plugin_types::{AmountData, PostingData, TransactionData};
355
356 fn make_txn(date: &str, narration: &str, amount: &str, currency: &str) -> DirectiveWrapper {
357 make_txn_with(date, narration, amount, currency, "Assets:Bank", vec![])
358 }
359
360 fn make_txn_with(
361 date: &str,
362 narration: &str,
363 amount: &str,
364 currency: &str,
365 account: &str,
366 links: Vec<String>,
367 ) -> DirectiveWrapper {
368 DirectiveWrapper {
369 directive_type: "transaction".to_string(),
370 date: date.to_string(),
371 filename: None,
372 lineno: None,
373 data: DirectiveData::Transaction(TransactionData {
374 flag: "*".to_string(),
375 payee: None,
376 narration: narration.to_string(),
377 tags: vec![],
378 links,
379 metadata: vec![],
380 postings: vec![PostingData {
381 account: account.to_string(),
382 units: Some(AmountData {
383 number: amount.to_string(),
384 currency: currency.to_string(),
385 }),
386 cost: None,
387 price: None,
388 flag: None,
389 metadata: vec![],
390 }],
391 }),
392 }
393 }
394
395 fn make_txn_loc(
396 date: &str,
397 narration: &str,
398 amount: &str,
399 currency: &str,
400 account: &str,
401 filename: &str,
402 lineno: u32,
403 ) -> DirectiveWrapper {
404 let mut d = make_txn_with(date, narration, amount, currency, account, vec![]);
405 d.filename = Some(filename.to_string());
406 d.lineno = Some(lineno);
407 d
408 }
409
410 #[test]
411 fn matches_opposite_amounts_same_date() {
412 let groups = vec![
413 (
414 "Assets:Checking".to_string(),
415 vec![make_txn(
416 "2024-01-15",
417 "Transfer to savings",
418 "-500.00",
419 "USD",
420 )],
421 ),
422 (
423 "Assets:Savings".to_string(),
424 vec![make_txn(
425 "2024-01-15",
426 "Transfer from checking",
427 "500.00",
428 "USD",
429 )],
430 ),
431 ];
432 let matches = find_transfers(&groups, &TransferConfig::default());
433 assert_eq!(matches.len(), 1);
434 assert_eq!(matches[0].amount, Decimal::new(50000, 2));
435 assert!(matches[0].confidence > 0.8); }
437
438 #[test]
439 fn matches_within_date_window() {
440 let groups = vec![
441 (
442 "Assets:Checking".to_string(),
443 vec![make_txn("2024-01-15", "ACH payment", "-200.00", "USD")],
444 ),
445 (
446 "Assets:CreditCard".to_string(),
447 vec![make_txn("2024-01-17", "Payment received", "200.00", "USD")],
448 ),
449 ];
450 let matches = find_transfers(&groups, &TransferConfig::default());
451 assert_eq!(matches.len(), 1);
452 }
453
454 #[test]
455 fn no_match_outside_date_window() {
456 let groups = vec![
457 (
458 "Assets:Checking".to_string(),
459 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
460 ),
461 (
462 "Assets:Savings".to_string(),
463 vec![make_txn("2024-01-25", "Transfer", "500.00", "USD")],
464 ),
465 ];
466 let matches = find_transfers(&groups, &TransferConfig::default());
467 assert!(matches.is_empty());
468 }
469
470 #[test]
471 fn no_match_different_currency() {
472 let groups = vec![
473 (
474 "Assets:Checking".to_string(),
475 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
476 ),
477 (
478 "Assets:Savings".to_string(),
479 vec![make_txn("2024-01-15", "Transfer", "500.00", "EUR")],
480 ),
481 ];
482 let matches = find_transfers(&groups, &TransferConfig::default());
483 assert!(matches.is_empty());
484 }
485
486 #[test]
487 fn no_match_same_sign() {
488 let groups = vec![
489 (
490 "Assets:Checking".to_string(),
491 vec![make_txn("2024-01-15", "Deposit", "500.00", "USD")],
492 ),
493 (
494 "Assets:Savings".to_string(),
495 vec![make_txn("2024-01-15", "Deposit", "500.00", "USD")],
496 ),
497 ];
498 let matches = find_transfers(&groups, &TransferConfig::default());
499 assert!(matches.is_empty());
500 }
501
502 #[test]
503 fn no_match_different_amounts() {
504 let groups = vec![
505 (
506 "Assets:Checking".to_string(),
507 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
508 ),
509 (
510 "Assets:Savings".to_string(),
511 vec![make_txn("2024-01-15", "Transfer", "499.00", "USD")],
512 ),
513 ];
514 let matches = find_transfers(&groups, &TransferConfig::default());
515 assert!(matches.is_empty());
516 }
517
518 #[test]
519 fn transfer_keywords_boost_confidence() {
520 let groups = vec![
521 (
522 "Assets:Checking".to_string(),
523 vec![make_txn(
524 "2024-01-15",
525 "TRANSFER TO SAVINGS",
526 "-500.00",
527 "USD",
528 )],
529 ),
530 (
531 "Assets:Savings".to_string(),
532 vec![make_txn(
533 "2024-01-15",
534 "TRANSFER FROM CHECKING",
535 "500.00",
536 "USD",
537 )],
538 ),
539 ];
540 let matches = find_transfers(&groups, &TransferConfig::default());
541 assert_eq!(matches.len(), 1);
542 assert!(matches[0].confidence >= 0.9);
544 }
545
546 #[test]
547 fn no_keywords_lower_confidence() {
548 let groups = vec![
549 (
550 "Assets:Checking".to_string(),
551 vec![make_txn("2024-01-15", "Something", "-500.00", "USD")],
552 ),
553 (
554 "Assets:Savings".to_string(),
555 vec![make_txn("2024-01-17", "Something else", "500.00", "USD")],
556 ),
557 ];
558 let matches = find_transfers(&groups, &TransferConfig::default());
559 assert_eq!(matches.len(), 1);
560 assert!(matches[0].confidence < 0.8);
562 }
563
564 #[test]
565 fn multiple_transfers() {
566 let groups = vec![
567 (
568 "Assets:Checking".to_string(),
569 vec![
570 make_txn("2024-01-15", "Transfer 1", "-500.00", "USD"),
571 make_txn("2024-01-20", "Transfer 2", "-300.00", "USD"),
572 ],
573 ),
574 (
575 "Assets:Savings".to_string(),
576 vec![
577 make_txn("2024-01-15", "Transfer 1", "500.00", "USD"),
578 make_txn("2024-01-20", "Transfer 2", "300.00", "USD"),
579 ],
580 ),
581 ];
582 let matches = find_transfers(&groups, &TransferConfig::default());
583 assert_eq!(matches.len(), 2);
584 }
585
586 #[test]
587 fn one_to_one_matching() {
588 let groups = vec![
590 (
591 "Assets:Checking".to_string(),
592 vec![
593 make_txn("2024-01-15", "Transfer", "-500.00", "USD"),
594 make_txn("2024-01-15", "Transfer", "-500.00", "USD"),
595 ],
596 ),
597 (
598 "Assets:Savings".to_string(),
599 vec![make_txn("2024-01-15", "Transfer", "500.00", "USD")],
600 ),
601 ];
602 let matches = find_transfers(&groups, &TransferConfig::default());
603 assert_eq!(matches.len(), 1);
604 }
605
606 #[test]
607 fn three_groups() {
608 let groups = vec![
609 (
610 "Assets:Checking".to_string(),
611 vec![make_txn("2024-01-15", "Transfer", "-500.00", "USD")],
612 ),
613 (
614 "Assets:Savings".to_string(),
615 vec![make_txn("2024-01-15", "Transfer", "500.00", "USD")],
616 ),
617 (
618 "Assets:CreditCard".to_string(),
619 vec![make_txn("2024-01-15", "Payment", "200.00", "USD")],
620 ),
621 ];
622 let matches = find_transfers(&groups, &TransferConfig::default());
623 assert_eq!(matches.len(), 1);
625 }
626
627 #[test]
628 fn empty_groups() {
629 let groups: Vec<(String, Vec<DirectiveWrapper>)> = vec![];
630 let matches = find_transfers(&groups, &TransferConfig::default());
631 assert!(matches.is_empty());
632 }
633
634 #[test]
637 fn in_ledger_groups_by_first_posting_account() {
638 let directives = vec![
640 make_txn_with(
641 "2024-01-15",
642 "Transfer to savings",
643 "-500.00",
644 "USD",
645 "Assets:Checking",
646 vec![],
647 ),
648 make_txn_with(
649 "2024-01-15",
650 "Transfer from checking",
651 "500.00",
652 "USD",
653 "Assets:Savings",
654 vec![],
655 ),
656 ];
657 let matches = find_transfers_in_ledger(&directives, &TransferConfig::default());
658 assert_eq!(matches.len(), 1);
659 assert_eq!(matches[0].from_account.as_deref(), Some("Assets:Checking"));
660 assert_eq!(matches[0].to_account.as_deref(), Some("Assets:Savings"));
661 }
662
663 #[test]
664 fn in_ledger_does_not_match_within_same_account() {
665 let directives = vec![
667 make_txn_with(
668 "2024-01-15",
669 "Out",
670 "-500.00",
671 "USD",
672 "Assets:Checking",
673 vec![],
674 ),
675 make_txn_with(
676 "2024-01-15",
677 "In",
678 "500.00",
679 "USD",
680 "Assets:Checking",
681 vec![],
682 ),
683 ];
684 let matches = find_transfers_in_ledger(&directives, &TransferConfig::default());
685 assert!(matches.is_empty());
686 }
687
688 #[test]
689 fn transfer_match_carries_filename_and_lineno() {
690 let groups = vec![
691 (
692 "Assets:Checking".to_string(),
693 vec![make_txn_loc(
694 "2024-01-15",
695 "Transfer",
696 "-500.00",
697 "USD",
698 "Assets:Checking",
699 "checking.bean",
700 42,
701 )],
702 ),
703 (
704 "Assets:Savings".to_string(),
705 vec![make_txn_loc(
706 "2024-01-15",
707 "Transfer",
708 "500.00",
709 "USD",
710 "Assets:Savings",
711 "savings.bean",
712 18,
713 )],
714 ),
715 ];
716 let matches = find_transfers(&groups, &TransferConfig::default());
717 assert_eq!(matches.len(), 1);
718 let m = &matches[0];
719 assert_eq!(m.from_filename.as_deref(), Some("checking.bean"));
720 assert_eq!(m.from_lineno, Some(42));
721 assert_eq!(m.to_filename.as_deref(), Some("savings.bean"));
722 assert_eq!(m.to_lineno, Some(18));
723 }
724
725 #[test]
726 fn already_linked_pair_is_skipped() {
727 let groups = vec![
728 (
729 "Assets:Checking".to_string(),
730 vec![make_txn_with(
731 "2024-01-15",
732 "Transfer",
733 "-500.00",
734 "USD",
735 "Assets:Checking",
736 vec!["xfer-001".to_string()],
737 )],
738 ),
739 (
740 "Assets:Savings".to_string(),
741 vec![make_txn_with(
742 "2024-01-15",
743 "Transfer",
744 "500.00",
745 "USD",
746 "Assets:Savings",
747 vec!["xfer-001".to_string()],
748 )],
749 ),
750 ];
751 let matches = find_transfers(&groups, &TransferConfig::default());
752 assert!(
753 matches.is_empty(),
754 "already-linked pair must not be re-detected; got {matches:?}"
755 );
756 }
757
758 #[test]
759 fn unrelated_links_do_not_block_match() {
760 let groups = vec![
761 (
762 "Assets:Checking".to_string(),
763 vec![make_txn_with(
764 "2024-01-15",
765 "Transfer",
766 "-500.00",
767 "USD",
768 "Assets:Checking",
769 vec!["batch-import-A".to_string()],
770 )],
771 ),
772 (
773 "Assets:Savings".to_string(),
774 vec![make_txn_with(
775 "2024-01-15",
776 "Transfer",
777 "500.00",
778 "USD",
779 "Assets:Savings",
780 vec!["batch-import-B".to_string()],
781 )],
782 ),
783 ];
784 let matches = find_transfers(&groups, &TransferConfig::default());
785 assert_eq!(matches.len(), 1);
786 }
787
788 #[test]
789 fn weak_keyword_does_not_boost_when_dates_differ() {
790 let groups = vec![
791 (
792 "Assets:Checking".to_string(),
793 vec![make_txn("2024-01-15", "PAYMENT", "-200.00", "USD")],
794 ),
795 (
796 "Liabilities:Card".to_string(),
797 vec![make_txn("2024-01-17", "PAYMENT", "200.00", "USD")],
798 ),
799 ];
800 let matches = find_transfers(&groups, &TransferConfig::default());
801 assert_eq!(matches.len(), 1);
802 assert!(
803 (matches[0].confidence - 0.7).abs() < 1e-9,
804 "weak keyword + different dates must stay at base 0.7; got {}",
805 matches[0].confidence
806 );
807 }
808
809 #[test]
810 fn weak_keyword_boosts_on_same_date() {
811 let groups = vec![
812 (
813 "Assets:Checking".to_string(),
814 vec![make_txn("2024-01-15", "PAYMENT", "-200.00", "USD")],
815 ),
816 (
817 "Liabilities:Card".to_string(),
818 vec![make_txn("2024-01-15", "PAYMENT", "200.00", "USD")],
819 ),
820 ];
821 let matches = find_transfers(&groups, &TransferConfig::default());
822 assert_eq!(matches.len(), 1);
823 assert!(matches[0].confidence > 0.95);
825 }
826
827 #[test]
828 fn strong_keyword_boosts_even_on_different_dates() {
829 let groups = vec![
830 (
831 "Assets:Checking".to_string(),
832 vec![make_txn("2024-01-15", "TRANSFER", "-500.00", "USD")],
833 ),
834 (
835 "Assets:Savings".to_string(),
836 vec![make_txn("2024-01-17", "TRANSFER", "500.00", "USD")],
837 ),
838 ];
839 let matches = find_transfers(&groups, &TransferConfig::default());
840 assert_eq!(matches.len(), 1);
841 assert!(
843 (matches[0].confidence - 0.9).abs() < 1e-9,
844 "strong keyword + different dates: expect 0.9, got {}",
845 matches[0].confidence
846 );
847 }
848}