Skip to main content

rustledger_plugin/native/plugins/
zerosum.rs

1//! Zero-sum account matching plugin.
2//!
3//! Matches postings in "zerosum" accounts that net to zero within a date range,
4//! and moves them to a "matched" account. Useful for tracking transfers between
5//! accounts.
6//!
7//! Configuration (as a Python-style dict string):
8//! ```text
9//! plugin "beancount_reds_plugins.zerosum.zerosum" "{
10//!   'zerosum_accounts': {
11//!     'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30),
12//!   },
13//!   'account_name_replace': ('ZeroSum', 'ZeroSum-Matched')
14//! }"
15//! ```
16
17use regex::Regex;
18use rust_decimal::Decimal;
19use std::collections::{HashMap, HashSet};
20use std::str::FromStr;
21use std::sync::LazyLock;
22
23/// Regex for parsing zerosum account entries.
24/// Format: `'AccountName': ('TargetAccount', days)` where `TargetAccount` may be empty (`''`).
25static ACCOUNT_ENTRY_RE: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(r"'([^']+)'\s*:\s*\(\s*'([^']*)'\s*,\s*(\d+)\s*\)")
27        .expect("ACCOUNT_ENTRY_RE: invalid regex pattern")
28});
29
30/// Regex for parsing `account_name_replace`.
31/// Format: `'account_name_replace': ('from', 'to')`
32static ACCOUNT_REPLACE_RE: LazyLock<Regex> = LazyLock::new(|| {
33    Regex::new(r"'account_name_replace'\s*:\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*\)")
34        .expect("ACCOUNT_REPLACE_RE: invalid regex pattern")
35});
36
37/// Regex for parsing tolerance.
38/// Format: `'tolerance': 0.01`
39static TOLERANCE_RE: LazyLock<Regex> = LazyLock::new(|| {
40    Regex::new(r"'tolerance'\s*:\s*([0-9.]+)").expect("TOLERANCE_RE: invalid regex pattern")
41});
42
43use crate::types::{
44    DirectiveData, DirectiveWrapper, OpenData, PluginError, PluginErrorSeverity, PluginInput,
45    PluginOp, PluginOutput,
46};
47
48use super::super::NativePlugin;
49
50/// Default tolerance for matching amounts.
51const DEFAULT_TOLERANCE: &str = "0.0099";
52
53/// Plugin for matching zero-sum postings.
54pub struct ZerosumPlugin;
55
56impl NativePlugin for ZerosumPlugin {
57    fn name(&self) -> &'static str {
58        "zerosum"
59    }
60
61    fn description(&self) -> &'static str {
62        "Match postings in zero-sum accounts and move to matched account"
63    }
64
65    fn process(&self, input: PluginInput) -> PluginOutput {
66        // Parse configuration
67        let config = match &input.config {
68            Some(c) => c,
69            None => {
70                return PluginOutput {
71                    ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
72                    errors: vec![PluginError {
73                        message: "zerosum plugin requires configuration".to_string(),
74                        source_file: None,
75                        line_number: None,
76                        severity: PluginErrorSeverity::Error,
77                    }],
78                };
79            }
80        };
81
82        // Parse the Python-style dict config
83        let (zerosum_accounts, account_replace, tolerance) = match parse_config(config) {
84            Ok(c) => c,
85            Err(e) => {
86                return PluginOutput {
87                    ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
88                    errors: vec![PluginError {
89                        message: format!("Failed to parse zerosum config: {e}"),
90                        source_file: None,
91                        line_number: None,
92                        severity: PluginErrorSeverity::Error,
93                    }],
94                };
95            }
96        };
97
98        let mut new_accounts: HashSet<String> = HashSet::new();
99        let mut earliest_date: Option<String> = None;
100
101        // Collect existing Open accounts to avoid creating duplicates
102        let existing_opens: HashSet<String> = input
103            .directives
104            .iter()
105            .filter_map(|d| {
106                if let DirectiveData::Open(ref open) = d.data {
107                    Some(open.account.clone())
108                } else {
109                    None
110                }
111            })
112            .collect();
113
114        // Index transactions by zerosum account
115        let mut txn_indices: HashMap<String, Vec<usize>> = HashMap::new();
116
117        for (i, directive) in input.directives.iter().enumerate() {
118            if directive.directive_type == "transaction" {
119                if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
120                    earliest_date = Some(directive.date.clone());
121                }
122
123                if let DirectiveData::Transaction(ref txn) = directive.data {
124                    for zs_account in zerosum_accounts.keys() {
125                        if txn.postings.iter().any(|p| &p.account == zs_account) {
126                            txn_indices.entry(zs_account.clone()).or_default().push(i);
127                        }
128                    }
129                }
130            }
131        }
132
133        // Convert to mutable.
134        let mut directives = input.directives;
135        // Track which input indices were mutated, so we can emit Modify
136        // ops for them and Keep ops for everything else.
137        let mut modified_indices: HashSet<usize> = HashSet::new();
138
139        // For each zerosum account, find matching pairs
140        for (zs_account, (target_account_opt, date_range)) in &zerosum_accounts {
141            // Determine target account
142            let target_account = target_account_opt.clone().unwrap_or_else(|| {
143                if let Some((from, to)) = &account_replace {
144                    zs_account.replace(from, to)
145                } else {
146                    format!("{zs_account}-Matched")
147                }
148            });
149
150            let indices = match txn_indices.get(zs_account) {
151                Some(i) => i.clone(),
152                None => continue,
153            };
154
155            // Track which postings have been matched (by txn_idx, posting_idx)
156            let mut matched: HashSet<(usize, usize)> = HashSet::new();
157
158            // For each transaction in this zerosum account
159            for &txn_i in &indices {
160                let directive = &directives[txn_i];
161                let txn_date = &directive.date;
162
163                if let DirectiveData::Transaction(ref txn) = directive.data {
164                    // Find postings in this transaction that are in the zerosum account
165                    for (post_i, posting) in txn.postings.iter().enumerate() {
166                        if &posting.account != zs_account {
167                            continue;
168                        }
169                        if matched.contains(&(txn_i, post_i)) {
170                            continue;
171                        }
172
173                        // Get the amount
174                        let amount = match &posting.units {
175                            Some(u) => match Decimal::from_str(&u.number) {
176                                Ok(n) => n,
177                                Err(_) => continue,
178                            },
179                            None => continue,
180                        };
181                        let currency = posting.units.as_ref().map(|u| &u.currency);
182
183                        // Look for a matching posting in other transactions
184                        for &other_txn_i in &indices {
185                            if other_txn_i == txn_i {
186                                // Check within same transaction but different posting
187                                if let DirectiveData::Transaction(ref other_txn) =
188                                    directives[other_txn_i].data
189                                {
190                                    for (other_post_i, other_posting) in
191                                        other_txn.postings.iter().enumerate()
192                                    {
193                                        if other_post_i == post_i {
194                                            continue;
195                                        }
196                                        if &other_posting.account != zs_account {
197                                            continue;
198                                        }
199                                        if matched.contains(&(other_txn_i, other_post_i)) {
200                                            continue;
201                                        }
202
203                                        let other_currency =
204                                            other_posting.units.as_ref().map(|u| &u.currency);
205                                        if currency != other_currency {
206                                            continue;
207                                        }
208
209                                        let other_amount = match &other_posting.units {
210                                            Some(u) => match Decimal::from_str(&u.number) {
211                                                Ok(n) => n,
212                                                Err(_) => continue,
213                                            },
214                                            None => continue,
215                                        };
216
217                                        // Check if they sum to zero (within tolerance)
218                                        let sum = (amount + other_amount).abs();
219                                        if sum < tolerance {
220                                            // Found a match!
221                                            matched.insert((txn_i, post_i));
222                                            matched.insert((other_txn_i, other_post_i));
223                                            new_accounts.insert(target_account.clone());
224                                            break;
225                                        }
226                                    }
227                                }
228                                continue;
229                            }
230
231                            // Check date range
232                            let other_date = &directives[other_txn_i].date;
233                            if !within_date_range(txn_date, other_date, *date_range) {
234                                continue;
235                            }
236
237                            if let DirectiveData::Transaction(ref other_txn) =
238                                directives[other_txn_i].data
239                            {
240                                for (other_post_i, other_posting) in
241                                    other_txn.postings.iter().enumerate()
242                                {
243                                    if &other_posting.account != zs_account {
244                                        continue;
245                                    }
246                                    if matched.contains(&(other_txn_i, other_post_i)) {
247                                        continue;
248                                    }
249
250                                    let other_currency =
251                                        other_posting.units.as_ref().map(|u| &u.currency);
252                                    if currency != other_currency {
253                                        continue;
254                                    }
255
256                                    let other_amount = match &other_posting.units {
257                                        Some(u) => match Decimal::from_str(&u.number) {
258                                            Ok(n) => n,
259                                            Err(_) => continue,
260                                        },
261                                        None => continue,
262                                    };
263
264                                    // Check if they sum to zero (within tolerance)
265                                    let sum = (amount + other_amount).abs();
266                                    if sum < tolerance {
267                                        // Found a match!
268                                        matched.insert((txn_i, post_i));
269                                        matched.insert((other_txn_i, other_post_i));
270                                        new_accounts.insert(target_account.clone());
271                                        break;
272                                    }
273                                }
274                            }
275
276                            // If we found a match, break out
277                            if matched.contains(&(txn_i, post_i)) {
278                                break;
279                            }
280                        }
281                    }
282                }
283            }
284
285            // Now update the matched postings to use the target account
286            for (txn_i, post_i) in &matched {
287                if let DirectiveData::Transaction(ref mut txn) = directives[*txn_i].data
288                    && *post_i < txn.postings.len()
289                {
290                    txn.postings[*post_i].account.clone_from(&target_account);
291                    modified_indices.insert(*txn_i);
292                }
293            }
294        }
295
296        // Emit ops: Modify for mutated indices, Keep otherwise.
297        let mut ops: Vec<PluginOp> = Vec::with_capacity(directives.len() + new_accounts.len());
298        for (i, d) in directives.into_iter().enumerate() {
299            if modified_indices.contains(&i) {
300                ops.push(PluginOp::Modify(i, d));
301            } else {
302                ops.push(PluginOp::Keep(i));
303            }
304        }
305
306        // Insert Open directives for newly synthesized matched accounts
307        // (only if not already opened).
308        if let Some(date) = earliest_date {
309            let mut accounts: Vec<&String> = new_accounts.iter().collect();
310            accounts.sort();
311            for account in accounts {
312                if existing_opens.contains(account) {
313                    continue;
314                }
315                ops.push(PluginOp::Insert(DirectiveWrapper {
316                    directive_type: "open".to_string(),
317                    date: date.clone(),
318                    filename: Some("<zerosum>".to_string()),
319                    lineno: Some(0),
320                    data: DirectiveData::Open(OpenData {
321                        account: account.clone(),
322                        currencies: vec![],
323                        booking: None,
324                        metadata: vec![],
325                    }),
326                }));
327            }
328        }
329
330        PluginOutput {
331            ops,
332            errors: Vec::new(),
333        }
334    }
335}
336
337/// Parse the Python-style config dict.
338fn parse_config(
339    config: &str,
340) -> Result<
341    (
342        HashMap<String, (Option<String>, i64)>,
343        Option<(String, String)>,
344        Decimal,
345    ),
346    String,
347> {
348    let mut zerosum_accounts = HashMap::new();
349    let mut account_replace: Option<(String, String)> = None;
350    let mut tolerance = Decimal::from_str(DEFAULT_TOLERANCE).unwrap();
351
352    // Simple parsing of Python dict format
353    // 'zerosum_accounts': {'Account': ('Target', 30), ...}
354    // 'account_name_replace': ('From', 'To')
355
356    // Extract zerosum_accounts
357    if let Some(start) = config.find("'zerosum_accounts'")
358        && let Some(dict_offset) = config[start..].find('{')
359    {
360        let dict_start = start + dict_offset;
361        let mut depth = 0;
362        let mut dict_end = dict_start;
363        for (i, c) in config[dict_start..].char_indices() {
364            match c {
365                '{' => depth += 1,
366                '}' => {
367                    depth -= 1;
368                    if depth == 0 {
369                        dict_end = dict_start + i + 1;
370                        break;
371                    }
372                }
373                _ => {}
374            }
375        }
376
377        let dict_str = &config[dict_start..dict_end];
378        // Parse individual account entries
379        // Format: 'AccountName': ('TargetAccount', days)
380        // or: 'AccountName': ('', days)
381        for cap in ACCOUNT_ENTRY_RE.captures_iter(dict_str) {
382            let account = cap[1].to_string();
383            let target = if cap[2].is_empty() {
384                None
385            } else {
386                Some(cap[2].to_string())
387            };
388            let days: i64 = cap[3].parse().unwrap_or(30);
389            zerosum_accounts.insert(account, (target, days));
390        }
391    }
392
393    // Extract account_name_replace
394    if let Some(start) = config.find("'account_name_replace'")
395        && let Some(cap) = ACCOUNT_REPLACE_RE.captures(&config[start..])
396    {
397        account_replace = Some((cap[1].to_string(), cap[2].to_string()));
398    }
399
400    // Extract tolerance
401    if let Some(start) = config.find("'tolerance'")
402        && let Some(cap) = TOLERANCE_RE.captures(&config[start..])
403        && let Ok(t) = Decimal::from_str(&cap[1])
404    {
405        tolerance = t;
406    }
407
408    Ok((zerosum_accounts, account_replace, tolerance))
409}
410
411/// Check if two dates are within a given range (in days).
412fn within_date_range(date1: &str, date2: &str, days: i64) -> bool {
413    use rustledger_core::NaiveDate;
414
415    let d1 = match date1.parse::<NaiveDate>() {
416        Ok(d) => d,
417        Err(_) => return false,
418    };
419    let d2 = match date2.parse::<NaiveDate>() {
420        Ok(d) => d,
421        Err(_) => return false,
422    };
423
424    let diff = i64::from(d2.since(d1).unwrap_or_default().get_days()).abs();
425    diff <= days
426}
427
428#[cfg(test)]
429mod tests {
430    use super::super::utils::materialize_ops;
431    use super::*;
432    use crate::types::*;
433
434    fn create_transfer_txn(
435        date: &str,
436        from_account: &str,
437        to_account: &str,
438        amount: &str,
439        currency: &str,
440    ) -> DirectiveWrapper {
441        DirectiveWrapper {
442            directive_type: "transaction".to_string(),
443            date: date.to_string(),
444            filename: None,
445            lineno: None,
446            data: DirectiveData::Transaction(TransactionData {
447                flag: "*".to_string(),
448                payee: None,
449                narration: "Transfer".to_string(),
450                tags: vec![],
451                links: vec![],
452                metadata: vec![],
453                postings: vec![
454                    PostingData {
455                        account: from_account.to_string(),
456                        units: Some(AmountData {
457                            number: format!("-{amount}"),
458                            currency: currency.to_string(),
459                        }),
460                        cost: None,
461                        price: None,
462                        flag: None,
463                        metadata: vec![],
464                    },
465                    PostingData {
466                        account: to_account.to_string(),
467                        units: Some(AmountData {
468                            number: amount.to_string(),
469                            currency: currency.to_string(),
470                        }),
471                        cost: None,
472                        price: None,
473                        flag: None,
474                        metadata: vec![],
475                    },
476                ],
477            }),
478        }
479    }
480
481    #[test]
482    fn test_zerosum_matches_transfers() {
483        let plugin = ZerosumPlugin;
484
485        let config = r"{
486            'zerosum_accounts': {
487                'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30)
488            }
489        }";
490
491        let input = PluginInput {
492            directives: vec![
493                create_transfer_txn(
494                    "2024-01-01",
495                    "Assets:Bank",
496                    "Assets:ZeroSum:Transfers",
497                    "100.00",
498                    "USD",
499                ),
500                create_transfer_txn(
501                    "2024-01-03",
502                    "Assets:ZeroSum:Transfers",
503                    "Assets:Investment",
504                    "100.00",
505                    "USD",
506                ),
507            ],
508            options: PluginOptions {
509                operating_currencies: vec!["USD".to_string()],
510                title: None,
511            },
512            config: Some(config.to_string()),
513        };
514
515        let input_dirs = input.directives.clone();
516        let output = plugin.process(input);
517        assert_eq!(output.errors.len(), 0);
518        let directives = materialize_ops(&input_dirs, &output);
519
520        // Check that matched postings were moved to target account
521        let mut found_matched = false;
522        for directive in &directives {
523            if let DirectiveData::Transaction(ref txn) = directive.data {
524                for posting in &txn.postings {
525                    if posting.account == "Assets:ZeroSum-Matched:Transfers" {
526                        found_matched = true;
527                    }
528                }
529            }
530        }
531        assert!(found_matched, "Should have matched postings");
532    }
533
534    #[test]
535    fn test_zerosum_no_match_outside_range() {
536        let plugin = ZerosumPlugin;
537
538        let config = r"{
539            'zerosum_accounts': {
540                'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 5)
541            }
542        }";
543
544        let input = PluginInput {
545            directives: vec![
546                create_transfer_txn(
547                    "2024-01-01",
548                    "Assets:Bank",
549                    "Assets:ZeroSum:Transfers",
550                    "100.00",
551                    "USD",
552                ),
553                // 10 days later - outside the 5-day range
554                create_transfer_txn(
555                    "2024-01-11",
556                    "Assets:ZeroSum:Transfers",
557                    "Assets:Investment",
558                    "100.00",
559                    "USD",
560                ),
561            ],
562            options: PluginOptions {
563                operating_currencies: vec!["USD".to_string()],
564                title: None,
565            },
566            config: Some(config.to_string()),
567        };
568
569        let input_dirs = input.directives.clone();
570        let output = plugin.process(input);
571        assert_eq!(output.errors.len(), 0);
572        let directives = materialize_ops(&input_dirs, &output);
573
574        // Check that postings were NOT matched (still in original account)
575        let mut found_unmatched = false;
576        for directive in &directives {
577            if let DirectiveData::Transaction(ref txn) = directive.data {
578                for posting in &txn.postings {
579                    if posting.account == "Assets:ZeroSum:Transfers" {
580                        found_unmatched = true;
581                    }
582                }
583            }
584        }
585        assert!(found_unmatched, "Should have unmatched postings");
586    }
587
588    #[test]
589    fn test_zerosum_does_not_duplicate_open() {
590        // Regression test: zerosum should not create duplicate Open directives
591        // when the target account already has an Open directive.
592        let plugin = ZerosumPlugin;
593
594        let config = r"{
595            'zerosum_accounts': {
596                'Assets:Transfer': ('Assets:ZSA-Matched:Transfer', 7)
597            }
598        }";
599
600        // Create an existing Open for the target account
601        let existing_open = DirectiveWrapper {
602            directive_type: "open".to_string(),
603            date: "2020-01-01".to_string(),
604            filename: Some("accounts.beancount".to_string()),
605            lineno: Some(422),
606            data: DirectiveData::Open(OpenData {
607                account: "Assets:ZSA-Matched:Transfer".to_string(),
608                currencies: vec![],
609                booking: None,
610                metadata: vec![],
611            }),
612        };
613
614        let input = PluginInput {
615            directives: vec![
616                existing_open,
617                create_transfer_txn(
618                    "2024-01-01",
619                    "Assets:Bank",
620                    "Assets:Transfer",
621                    "100.00",
622                    "USD",
623                ),
624                create_transfer_txn(
625                    "2024-01-02",
626                    "Assets:Transfer",
627                    "Assets:Investment",
628                    "100.00",
629                    "USD",
630                ),
631            ],
632            options: PluginOptions {
633                operating_currencies: vec!["USD".to_string()],
634                title: None,
635            },
636            config: Some(config.to_string()),
637        };
638
639        let input_dirs = input.directives.clone();
640        let output = plugin.process(input);
641        assert_eq!(output.errors.len(), 0);
642        let directives = materialize_ops(&input_dirs, &output);
643
644        // Count Open directives for the target account
645        let open_count = directives
646            .iter()
647            .filter(|d| {
648                if let DirectiveData::Open(ref open) = d.data {
649                    open.account == "Assets:ZSA-Matched:Transfer"
650                } else {
651                    false
652                }
653            })
654            .count();
655
656        // Should only have 1 Open (the existing one, not a duplicate from the plugin)
657        assert_eq!(
658            open_count, 1,
659            "Should not create duplicate Open directives for existing accounts"
660        );
661    }
662
663    #[test]
664    fn test_parse_config() {
665        let config = r"{
666            'zerosum_accounts': {
667                'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30),
668                'Assets:ZeroSum:CreditCard': ('', 6)
669            },
670            'account_name_replace': ('ZeroSum', 'ZeroSum-Matched'),
671            'tolerance': 0.01
672        }";
673
674        let (accounts, replace, tolerance) = parse_config(config).unwrap();
675
676        assert_eq!(accounts.len(), 2);
677        assert!(accounts.contains_key("Assets:ZeroSum:Transfers"));
678        assert!(accounts.contains_key("Assets:ZeroSum:CreditCard"));
679
680        let (target, days) = accounts.get("Assets:ZeroSum:Transfers").unwrap();
681        assert_eq!(target.as_ref().unwrap(), "Assets:ZeroSum-Matched:Transfers");
682        assert_eq!(*days, 30);
683
684        let (target2, days2) = accounts.get("Assets:ZeroSum:CreditCard").unwrap();
685        assert!(target2.is_none()); // Empty target means use account_name_replace
686        assert_eq!(*days2, 6);
687
688        assert!(replace.is_some());
689        let (from, to) = replace.unwrap();
690        assert_eq!(from, "ZeroSum");
691        assert_eq!(to, "ZeroSum-Matched");
692
693        assert_eq!(tolerance, Decimal::from_str("0.01").unwrap());
694    }
695}