1use regex::Regex;
18use rust_decimal::Decimal;
19use std::collections::{HashMap, HashSet};
20use std::str::FromStr;
21use std::sync::LazyLock;
22
23static 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
30static 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
37static 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
50const DEFAULT_TOLERANCE: &str = "0.0099";
52
53pub 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 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 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 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 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 let mut directives = input.directives;
135 let mut modified_indices: HashSet<usize> = HashSet::new();
138
139 for (zs_account, (target_account_opt, date_range)) in &zerosum_accounts {
141 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 let mut matched: HashSet<(usize, usize)> = HashSet::new();
157
158 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 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 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 for &other_txn_i in &indices {
185 if other_txn_i == txn_i {
186 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 let sum = (amount + other_amount).abs();
219 if sum < tolerance {
220 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 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 let sum = (amount + other_amount).abs();
266 if sum < tolerance {
267 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 matched.contains(&(txn_i, post_i)) {
278 break;
279 }
280 }
281 }
282 }
283 }
284
285 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 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 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
337fn 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 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 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 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 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
411fn 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 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 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 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 let plugin = ZerosumPlugin;
593
594 let config = r"{
595 'zerosum_accounts': {
596 'Assets:Transfer': ('Assets:ZSA-Matched:Transfer', 7)
597 }
598 }";
599
600 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 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 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()); 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}