Skip to main content

rustledger_ops/
transfer.rs

1//! Transfer matching across accounts.
2//!
3//! Detects transfer pairs — transactions that represent the same real-world
4//! money movement appearing in two different account imports (e.g., a $500
5//! debit in checking and a $500 credit in savings on the same day).
6//!
7//! The matcher finds pairs based on:
8//! - Opposite-sign amounts (within tolerance)
9//! - Same currency
10//! - Dates within a configurable window
11//! - Narration keyword boosting (strong: TRANSFER/XFER/INTERNAL/SWEEP/MOVE;
12//!   weak: PAYMENT/ACH/WIRE — these only boost on same-date matches because
13//!   they alone are too eager: every credit-card payment, every direct
14//!   deposit, etc.)
15//!
16//! Pairs that already share a `^link:` tag are skipped — re-running the
17//! detector against an already-linked ledger is a no-op (idempotent).
18
19use rust_decimal::Decimal;
20use rustledger_plugin_types::{DirectiveData, DirectiveWrapper};
21use std::collections::{BTreeMap, HashSet};
22use std::str::FromStr;
23
24/// Configuration for transfer matching.
25#[derive(Debug, Clone)]
26pub struct TransferConfig {
27    /// Maximum number of days between matched transactions (default: 3).
28    pub date_window_days: i64,
29    /// Amount tolerance for matching (default: 0.01).
30    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), // 0.01
38        }
39    }
40}
41
42/// A detected transfer pair.
43#[derive(Debug, Clone)]
44pub struct TransferMatch {
45    /// Index of the source transaction (debit side) in the first group.
46    pub from_group: usize,
47    /// Index within that group's directives.
48    pub from_index: usize,
49    /// Account name of the debit side (if available).
50    pub from_account: Option<String>,
51    /// Source file of the debit side (if available).
52    pub from_filename: Option<String>,
53    /// Source line number of the debit side (if available).
54    pub from_lineno: Option<u32>,
55    /// Index of the destination transaction (credit side) in the second group.
56    pub to_group: usize,
57    /// Index within that group's directives.
58    pub to_index: usize,
59    /// Account name of the credit side (if available).
60    pub to_account: Option<String>,
61    /// Source file of the credit side (if available).
62    pub to_filename: Option<String>,
63    /// Source line number of the credit side (if available).
64    pub to_lineno: Option<u32>,
65    /// The matched amount (absolute value).
66    pub amount: Decimal,
67    /// The matched currency.
68    pub currency: String,
69    /// Confidence score (0.0 to 1.0).
70    pub confidence: f64,
71    /// Date of the debit (from) side, in YYYY-MM-DD form.
72    pub date: String,
73}
74
75/// Find transfer pairs across multiple account import groups.
76///
77/// Each group is a `(account_name, directives)` pair. Returns matches between
78/// groups (never within a single group). For "match all transfers across this
79/// ledger regardless of file boundaries," use `find_transfers_in_ledger`.
80///
81/// Idempotent: pairs whose transactions already share at least one `^link:`
82/// tag are skipped.
83#[must_use]
84pub fn find_transfers(
85    groups: &[(String, Vec<DirectiveWrapper>)],
86    config: &TransferConfig,
87) -> Vec<TransferMatch> {
88    let mut matches = Vec::new();
89    // Track all matched directives globally so a directive in one group
90    // cannot be matched by multiple other groups.
91    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    // Compare each pair of groups
96    for (g1, (_, directives1)) in groups.iter().enumerate() {
97        for (g2, (_, directives2)) in groups.iter().enumerate() {
98            if g2 <= g1 {
99                continue; // Avoid duplicate comparisons
100            }
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/// Find transfer pairs across all accounts in a flat directive list.
119///
120/// Groups directives by the **first posting's account** (the "owning"
121/// account of an imported transaction is conventionally the first posting)
122/// and runs the same cross-group matching as `find_transfers`. Use this
123/// when you have one combined ledger and want all internal transfers
124/// detected without manually splitting by file.
125///
126/// Non-transaction directives (Open, Balance, Pad, etc.) are skipped at
127/// grouping time. Transactions whose first posting has no units are still
128/// grouped (by that posting's account), but they can never match — the
129/// per-pair predicate requires units on both sides.
130///
131/// Idempotent: pairs whose transactions already share at least one `^link:`
132/// tag are skipped.
133#[must_use]
134pub fn find_transfers_in_ledger(
135    directives: &[DirectiveWrapper],
136    config: &TransferConfig,
137) -> Vec<TransferMatch> {
138    // BTreeMap for deterministic group ordering by account name.
139    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/// Find matching transactions between two directive lists.
153#[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            // Must be same currency
183            if currency1 != currency2 {
184                continue;
185            }
186
187            // Must be opposite signs and similar absolute amounts
188            let sum = (amount1 + amount2).abs();
189            if sum > config.amount_tolerance {
190                continue;
191            }
192
193            // Must be within date window
194            if !within_date_window(&d1.date, &d2.date, config.date_window_days) {
195                continue;
196            }
197
198            // Idempotency: skip if both txns already share a link. Mark both
199            // as "used" so they can't pair with a third party and produce a
200            // redundant match.
201            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            // Compute confidence.
210            let mut confidence: f64 = 0.7; // Base for amount + date match
211
212            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            // Determine from/to based on sign
227            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; // One match per source transaction
260        }
261    }
262}
263
264/// Extract the first posting's amount and currency from a directive.
265fn 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
276/// Extract the first posting's account name from a directive.
277fn 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
286/// True if both transactions share at least one `^link:` tag.
287///
288/// `link` strings in `TransactionData::links` are stored without the `^`
289/// sigil, so we compare them directly.
290fn 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
302/// Check if two date strings are within a given window (in days).
303fn within_date_window(date1: &str, date2: &str, days: i64) -> bool {
304    // Simple date comparison for YYYY-MM-DD format
305    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
320/// Strong transfer keywords: explicit transfer language. Boost unconditionally.
321const STRONG_KEYWORDS: &[&str] = &["transfer", "xfer", "internal", "sweep", "move"];
322
323/// Weak keywords: appear on transfers but also on many non-transfers (every
324/// credit-card payment has "payment"; every direct-deposit paycheck is an
325/// ACH credit). Boost only when the two sides also match on date.
326const 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); // Strong keyword + exact date
436    }
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        // Strong keyword + exact date = max
543        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        // No keywords, different dates = base only
561        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        // Same amount twice — single savings entry only matches one of them.
589        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        // Checking↔Savings matches; CreditCard has no opposite-sign match
624        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    // ─── Phase 0 — new behavior ────────────────────────────────────────────
635
636    #[test]
637    fn in_ledger_groups_by_first_posting_account() {
638        // Single flat list, transfers between accounts inside it.
639        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        // Two txns on the same account can't be a transfer between accounts.
666        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        // 0.7 base + 0.2 weak + 0.1 same-date = 1.0
824        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        // 0.7 base + 0.2 strong = 0.9 (no same-date bonus)
842        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}