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    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                    directives: input.directives,
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                    directives: input.directives,
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
136        // For each zerosum account, find matching pairs
137        for (zs_account, (target_account_opt, date_range)) in &zerosum_accounts {
138            // Determine target account
139            let target_account = target_account_opt.clone().unwrap_or_else(|| {
140                if let Some((from, to)) = &account_replace {
141                    zs_account.replace(from, to)
142                } else {
143                    format!("{zs_account}-Matched")
144                }
145            });
146
147            let indices = match txn_indices.get(zs_account) {
148                Some(i) => i.clone(),
149                None => continue,
150            };
151
152            // Track which postings have been matched (by txn_idx, posting_idx)
153            let mut matched: HashSet<(usize, usize)> = HashSet::new();
154
155            // For each transaction in this zerosum account
156            for &txn_i in &indices {
157                let directive = &directives[txn_i];
158                let txn_date = &directive.date;
159
160                if let DirectiveData::Transaction(ref txn) = directive.data {
161                    // Find postings in this transaction that are in the zerosum account
162                    for (post_i, posting) in txn.postings.iter().enumerate() {
163                        if &posting.account != zs_account {
164                            continue;
165                        }
166                        if matched.contains(&(txn_i, post_i)) {
167                            continue;
168                        }
169
170                        // Get the amount
171                        let amount = match &posting.units {
172                            Some(u) => match Decimal::from_str(&u.number) {
173                                Ok(n) => n,
174                                Err(_) => continue,
175                            },
176                            None => continue,
177                        };
178                        let currency = posting.units.as_ref().map(|u| &u.currency);
179
180                        // Look for a matching posting in other transactions
181                        for &other_txn_i in &indices {
182                            if other_txn_i == txn_i {
183                                // Check within same transaction but different posting
184                                if let DirectiveData::Transaction(ref other_txn) =
185                                    directives[other_txn_i].data
186                                {
187                                    for (other_post_i, other_posting) in
188                                        other_txn.postings.iter().enumerate()
189                                    {
190                                        if other_post_i == post_i {
191                                            continue;
192                                        }
193                                        if &other_posting.account != zs_account {
194                                            continue;
195                                        }
196                                        if matched.contains(&(other_txn_i, other_post_i)) {
197                                            continue;
198                                        }
199
200                                        let other_currency =
201                                            other_posting.units.as_ref().map(|u| &u.currency);
202                                        if currency != other_currency {
203                                            continue;
204                                        }
205
206                                        let other_amount = match &other_posting.units {
207                                            Some(u) => match Decimal::from_str(&u.number) {
208                                                Ok(n) => n,
209                                                Err(_) => continue,
210                                            },
211                                            None => continue,
212                                        };
213
214                                        // Check if they sum to zero (within tolerance)
215                                        let sum = (amount + other_amount).abs();
216                                        if sum < tolerance {
217                                            // Found a match!
218                                            matched.insert((txn_i, post_i));
219                                            matched.insert((other_txn_i, other_post_i));
220                                            new_accounts.insert(target_account.clone());
221                                            break;
222                                        }
223                                    }
224                                }
225                                continue;
226                            }
227
228                            // Check date range
229                            let other_date = &directives[other_txn_i].date;
230                            if !within_date_range(txn_date, other_date, *date_range) {
231                                continue;
232                            }
233
234                            if let DirectiveData::Transaction(ref other_txn) =
235                                directives[other_txn_i].data
236                            {
237                                for (other_post_i, other_posting) in
238                                    other_txn.postings.iter().enumerate()
239                                {
240                                    if &other_posting.account != zs_account {
241                                        continue;
242                                    }
243                                    if matched.contains(&(other_txn_i, other_post_i)) {
244                                        continue;
245                                    }
246
247                                    let other_currency =
248                                        other_posting.units.as_ref().map(|u| &u.currency);
249                                    if currency != other_currency {
250                                        continue;
251                                    }
252
253                                    let other_amount = match &other_posting.units {
254                                        Some(u) => match Decimal::from_str(&u.number) {
255                                            Ok(n) => n,
256                                            Err(_) => continue,
257                                        },
258                                        None => continue,
259                                    };
260
261                                    // Check if they sum to zero (within tolerance)
262                                    let sum = (amount + other_amount).abs();
263                                    if sum < tolerance {
264                                        // Found a match!
265                                        matched.insert((txn_i, post_i));
266                                        matched.insert((other_txn_i, other_post_i));
267                                        new_accounts.insert(target_account.clone());
268                                        break;
269                                    }
270                                }
271                            }
272
273                            // If we found a match, break out
274                            if matched.contains(&(txn_i, post_i)) {
275                                break;
276                            }
277                        }
278                    }
279                }
280            }
281
282            // Now update the matched postings to use the target account
283            for (txn_i, post_i) in &matched {
284                if let DirectiveData::Transaction(ref mut txn) = directives[*txn_i].data
285                    && *post_i < txn.postings.len()
286                {
287                    txn.postings[*post_i].account.clone_from(&target_account);
288                }
289            }
290        }
291
292        // Create Open directives for new accounts (only if not already opened)
293        let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
294        if let Some(date) = earliest_date {
295            for account in &new_accounts {
296                // Skip if account already has an Open directive
297                if existing_opens.contains(account) {
298                    continue;
299                }
300                open_directives.push(DirectiveWrapper {
301                    directive_type: "open".to_string(),
302                    date: date.clone(),
303                    filename: Some("<zerosum>".to_string()),
304                    lineno: Some(0),
305                    data: DirectiveData::Open(OpenData {
306                        account: account.clone(),
307                        currencies: vec![],
308                        booking: None,
309                        metadata: vec![],
310                    }),
311                });
312            }
313        }
314
315        // Combine open directives with modified directives
316        let mut all_directives = open_directives;
317        all_directives.extend(directives);
318
319        PluginOutput {
320            directives: all_directives,
321            errors: Vec::new(),
322        }
323    }
324}
325
326/// Parse the Python-style config dict.
327fn parse_config(
328    config: &str,
329) -> Result<
330    (
331        HashMap<String, (Option<String>, i64)>,
332        Option<(String, String)>,
333        Decimal,
334    ),
335    String,
336> {
337    let mut zerosum_accounts = HashMap::new();
338    let mut account_replace: Option<(String, String)> = None;
339    let mut tolerance = Decimal::from_str(DEFAULT_TOLERANCE).unwrap();
340
341    // Simple parsing of Python dict format
342    // 'zerosum_accounts': {'Account': ('Target', 30), ...}
343    // 'account_name_replace': ('From', 'To')
344
345    // Extract zerosum_accounts
346    if let Some(start) = config.find("'zerosum_accounts'")
347        && let Some(dict_offset) = config[start..].find('{')
348    {
349        let dict_start = start + dict_offset;
350        let mut depth = 0;
351        let mut dict_end = dict_start;
352        for (i, c) in config[dict_start..].char_indices() {
353            match c {
354                '{' => depth += 1,
355                '}' => {
356                    depth -= 1;
357                    if depth == 0 {
358                        dict_end = dict_start + i + 1;
359                        break;
360                    }
361                }
362                _ => {}
363            }
364        }
365
366        let dict_str = &config[dict_start..dict_end];
367        // Parse individual account entries
368        // Format: 'AccountName': ('TargetAccount', days)
369        // or: 'AccountName': ('', days)
370        for cap in ACCOUNT_ENTRY_RE.captures_iter(dict_str) {
371            let account = cap[1].to_string();
372            let target = if cap[2].is_empty() {
373                None
374            } else {
375                Some(cap[2].to_string())
376            };
377            let days: i64 = cap[3].parse().unwrap_or(30);
378            zerosum_accounts.insert(account, (target, days));
379        }
380    }
381
382    // Extract account_name_replace
383    if let Some(start) = config.find("'account_name_replace'")
384        && let Some(cap) = ACCOUNT_REPLACE_RE.captures(&config[start..])
385    {
386        account_replace = Some((cap[1].to_string(), cap[2].to_string()));
387    }
388
389    // Extract tolerance
390    if let Some(start) = config.find("'tolerance'")
391        && let Some(cap) = TOLERANCE_RE.captures(&config[start..])
392        && let Ok(t) = Decimal::from_str(&cap[1])
393    {
394        tolerance = t;
395    }
396
397    Ok((zerosum_accounts, account_replace, tolerance))
398}
399
400/// Check if two dates are within a given range (in days).
401fn within_date_range(date1: &str, date2: &str, days: i64) -> bool {
402    use chrono::NaiveDate;
403
404    let d1 = match NaiveDate::parse_from_str(date1, "%Y-%m-%d") {
405        Ok(d) => d,
406        Err(_) => return false,
407    };
408    let d2 = match NaiveDate::parse_from_str(date2, "%Y-%m-%d") {
409        Ok(d) => d,
410        Err(_) => return false,
411    };
412
413    let diff = (d2 - d1).num_days().abs();
414    diff <= days
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::types::*;
421
422    fn create_transfer_txn(
423        date: &str,
424        from_account: &str,
425        to_account: &str,
426        amount: &str,
427        currency: &str,
428    ) -> DirectiveWrapper {
429        DirectiveWrapper {
430            directive_type: "transaction".to_string(),
431            date: date.to_string(),
432            filename: None,
433            lineno: None,
434            data: DirectiveData::Transaction(TransactionData {
435                flag: "*".to_string(),
436                payee: None,
437                narration: "Transfer".to_string(),
438                tags: vec![],
439                links: vec![],
440                metadata: vec![],
441                postings: vec![
442                    PostingData {
443                        account: from_account.to_string(),
444                        units: Some(AmountData {
445                            number: format!("-{amount}"),
446                            currency: currency.to_string(),
447                        }),
448                        cost: None,
449                        price: None,
450                        flag: None,
451                        metadata: vec![],
452                    },
453                    PostingData {
454                        account: to_account.to_string(),
455                        units: Some(AmountData {
456                            number: amount.to_string(),
457                            currency: currency.to_string(),
458                        }),
459                        cost: None,
460                        price: None,
461                        flag: None,
462                        metadata: vec![],
463                    },
464                ],
465            }),
466        }
467    }
468
469    #[test]
470    fn test_zerosum_matches_transfers() {
471        let plugin = ZerosumPlugin;
472
473        let config = r"{
474            'zerosum_accounts': {
475                'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30)
476            }
477        }";
478
479        let input = PluginInput {
480            directives: vec![
481                create_transfer_txn(
482                    "2024-01-01",
483                    "Assets:Bank",
484                    "Assets:ZeroSum:Transfers",
485                    "100.00",
486                    "USD",
487                ),
488                create_transfer_txn(
489                    "2024-01-03",
490                    "Assets:ZeroSum:Transfers",
491                    "Assets:Investment",
492                    "100.00",
493                    "USD",
494                ),
495            ],
496            options: PluginOptions {
497                operating_currencies: vec!["USD".to_string()],
498                title: None,
499            },
500            config: Some(config.to_string()),
501        };
502
503        let output = plugin.process(input);
504        assert_eq!(output.errors.len(), 0);
505
506        // Check that matched postings were moved to target account
507        let mut found_matched = false;
508        for directive in &output.directives {
509            if let DirectiveData::Transaction(ref txn) = directive.data {
510                for posting in &txn.postings {
511                    if posting.account == "Assets:ZeroSum-Matched:Transfers" {
512                        found_matched = true;
513                    }
514                }
515            }
516        }
517        assert!(found_matched, "Should have matched postings");
518    }
519
520    #[test]
521    fn test_zerosum_no_match_outside_range() {
522        let plugin = ZerosumPlugin;
523
524        let config = r"{
525            'zerosum_accounts': {
526                'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 5)
527            }
528        }";
529
530        let input = PluginInput {
531            directives: vec![
532                create_transfer_txn(
533                    "2024-01-01",
534                    "Assets:Bank",
535                    "Assets:ZeroSum:Transfers",
536                    "100.00",
537                    "USD",
538                ),
539                // 10 days later - outside the 5-day range
540                create_transfer_txn(
541                    "2024-01-11",
542                    "Assets:ZeroSum:Transfers",
543                    "Assets:Investment",
544                    "100.00",
545                    "USD",
546                ),
547            ],
548            options: PluginOptions {
549                operating_currencies: vec!["USD".to_string()],
550                title: None,
551            },
552            config: Some(config.to_string()),
553        };
554
555        let output = plugin.process(input);
556        assert_eq!(output.errors.len(), 0);
557
558        // Check that postings were NOT matched (still in original account)
559        let mut found_unmatched = false;
560        for directive in &output.directives {
561            if let DirectiveData::Transaction(ref txn) = directive.data {
562                for posting in &txn.postings {
563                    if posting.account == "Assets:ZeroSum:Transfers" {
564                        found_unmatched = true;
565                    }
566                }
567            }
568        }
569        assert!(found_unmatched, "Should have unmatched postings");
570    }
571
572    #[test]
573    fn test_zerosum_does_not_duplicate_open() {
574        // Regression test: zerosum should not create duplicate Open directives
575        // when the target account already has an Open directive.
576        let plugin = ZerosumPlugin;
577
578        let config = r"{
579            'zerosum_accounts': {
580                'Assets:Transfer': ('Assets:ZSA-Matched:Transfer', 7)
581            }
582        }";
583
584        // Create an existing Open for the target account
585        let existing_open = DirectiveWrapper {
586            directive_type: "open".to_string(),
587            date: "2020-01-01".to_string(),
588            filename: Some("accounts.beancount".to_string()),
589            lineno: Some(422),
590            data: DirectiveData::Open(OpenData {
591                account: "Assets:ZSA-Matched:Transfer".to_string(),
592                currencies: vec![],
593                booking: None,
594                metadata: vec![],
595            }),
596        };
597
598        let input = PluginInput {
599            directives: vec![
600                existing_open,
601                create_transfer_txn(
602                    "2024-01-01",
603                    "Assets:Bank",
604                    "Assets:Transfer",
605                    "100.00",
606                    "USD",
607                ),
608                create_transfer_txn(
609                    "2024-01-02",
610                    "Assets:Transfer",
611                    "Assets:Investment",
612                    "100.00",
613                    "USD",
614                ),
615            ],
616            options: PluginOptions {
617                operating_currencies: vec!["USD".to_string()],
618                title: None,
619            },
620            config: Some(config.to_string()),
621        };
622
623        let output = plugin.process(input);
624        assert_eq!(output.errors.len(), 0);
625
626        // Count Open directives for the target account
627        let open_count = output
628            .directives
629            .iter()
630            .filter(|d| {
631                if let DirectiveData::Open(ref open) = d.data {
632                    open.account == "Assets:ZSA-Matched:Transfer"
633                } else {
634                    false
635                }
636            })
637            .count();
638
639        // Should only have 1 Open (the existing one, not a duplicate from the plugin)
640        assert_eq!(
641            open_count, 1,
642            "Should not create duplicate Open directives for existing accounts"
643        );
644    }
645
646    #[test]
647    fn test_parse_config() {
648        let config = r"{
649            'zerosum_accounts': {
650                'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30),
651                'Assets:ZeroSum:CreditCard': ('', 6)
652            },
653            'account_name_replace': ('ZeroSum', 'ZeroSum-Matched'),
654            'tolerance': 0.01
655        }";
656
657        let (accounts, replace, tolerance) = parse_config(config).unwrap();
658
659        assert_eq!(accounts.len(), 2);
660        assert!(accounts.contains_key("Assets:ZeroSum:Transfers"));
661        assert!(accounts.contains_key("Assets:ZeroSum:CreditCard"));
662
663        let (target, days) = accounts.get("Assets:ZeroSum:Transfers").unwrap();
664        assert_eq!(target.as_ref().unwrap(), "Assets:ZeroSum-Matched:Transfers");
665        assert_eq!(*days, 30);
666
667        let (target2, days2) = accounts.get("Assets:ZeroSum:CreditCard").unwrap();
668        assert!(target2.is_none()); // Empty target means use account_name_replace
669        assert_eq!(*days2, 6);
670
671        assert!(replace.is_some());
672        let (from, to) = replace.unwrap();
673        assert_eq!(from, "ZeroSum");
674        assert_eq!(to, "ZeroSum-Matched");
675
676        assert_eq!(tolerance, Decimal::from_str("0.01").unwrap());
677    }
678}