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//! - Optional narration keyword boosting (TRANSFER, XFER, ACH, etc.)
12
13use rust_decimal::Decimal;
14use rustledger_plugin_types::{DirectiveData, DirectiveWrapper};
15use std::collections::HashSet;
16use std::str::FromStr;
17
18/// Configuration for transfer matching.
19#[derive(Debug, Clone)]
20pub struct TransferConfig {
21    /// Maximum number of days between matched transactions (default: 3).
22    pub date_window_days: i64,
23    /// Amount tolerance for matching (default: 0.01).
24    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), // 0.01
32        }
33    }
34}
35
36/// A detected transfer pair.
37#[derive(Debug, Clone)]
38pub struct TransferMatch {
39    /// Index of the source transaction (debit side) in the first group.
40    pub from_group: usize,
41    /// Index within that group's directives.
42    pub from_index: usize,
43    /// Index of the destination transaction (credit side) in the second group.
44    pub to_group: usize,
45    /// Index within that group's directives.
46    pub to_index: usize,
47    /// The matched amount (absolute value).
48    pub amount: Decimal,
49    /// The matched currency.
50    pub currency: String,
51    /// Confidence score (0.0 to 1.0).
52    pub confidence: f64,
53}
54
55/// Find transfer pairs across multiple account import groups.
56///
57/// Each group is a `(account_name, directives)` pair from a separate import.
58/// Returns matches between groups (never within a single group).
59#[must_use]
60pub fn find_transfers(
61    groups: &[(String, Vec<DirectiveWrapper>)],
62    config: &TransferConfig,
63) -> Vec<TransferMatch> {
64    let mut matches = Vec::new();
65    // Track all matched directives globally so a directive in one group
66    // cannot be matched by multiple other groups.
67    let mut globally_matched: HashSet<(usize, usize)> = HashSet::new();
68
69    // Compare each pair of groups
70    for (g1, (_, directives1)) in groups.iter().enumerate() {
71        for (g2, (_, directives2)) in groups.iter().enumerate() {
72            if g2 <= g1 {
73                continue; // Avoid duplicate comparisons
74            }
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
91/// Find matching transactions between two directive lists.
92fn 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            // Must be same currency
120            if currency1 != currency2 {
121                continue;
122            }
123
124            // Must be opposite signs and similar absolute amounts
125            let sum = (amount1 + amount2).abs();
126            if sum > config.amount_tolerance {
127                continue;
128            }
129
130            // Must be within date window
131            if !within_date_window(&d1.date, &d2.date, config.date_window_days) {
132                continue;
133            }
134
135            // Compute confidence
136            let mut confidence: f64 = 0.7; // Base confidence for amount + date match
137
138            // Boost for transfer-related keywords in narration
139            if has_transfer_keywords(d1) || has_transfer_keywords(d2) {
140                confidence += 0.2;
141            }
142
143            // Boost for exact date match
144            if d1.date == d2.date {
145                confidence += 0.1;
146            }
147
148            let confidence = confidence.min(1.0);
149
150            // Determine from/to based on sign
151            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; // One match per source transaction
170        }
171    }
172}
173
174/// Extract the first posting's amount and currency from a directive.
175fn 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
186/// Check if two date strings are within a given window (in days).
187fn within_date_window(date1: &str, date2: &str, days: i64) -> bool {
188    // Simple date comparison for YYYY-MM-DD format
189    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
204/// Transfer-related keywords that boost matching confidence.
205const TRANSFER_KEYWORDS: &[&str] = &[
206    "transfer", "xfer", "ach", "wire", "payment", "internal", "move", "sweep",
207];
208
209/// Check if a directive's narration contains transfer-related keywords.
210fn 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); // Transfer keywords + exact date
288    }
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        // Both sides have keywords + exact date = max confidence
395        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        // No keywords, different dates = base confidence only
413        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        // Same amount appears twice — should not double-match
441        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        // Only one match — the single savings entry can only match one checking entry
456        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        // Checking→Savings matches; CreditCard has no opposite-sign match
477        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}