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, RegularPlugin};
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
337impl RegularPlugin for ZerosumPlugin {}
338
339fn parse_config(
341 config: &str,
342) -> Result<
343 (
344 HashMap<String, (Option<String>, i64)>,
345 Option<(String, String)>,
346 Decimal,
347 ),
348 String,
349> {
350 let mut zerosum_accounts = HashMap::new();
351 let mut account_replace: Option<(String, String)> = None;
352 let mut tolerance = Decimal::from_str(DEFAULT_TOLERANCE).unwrap();
353
354 if let Some(start) = config.find("'zerosum_accounts'")
360 && let Some(dict_offset) = config[start..].find('{')
361 {
362 let dict_start = start + dict_offset;
363 let mut depth = 0;
364 let mut dict_end = dict_start;
365 for (i, c) in config[dict_start..].char_indices() {
366 match c {
367 '{' => depth += 1,
368 '}' => {
369 depth -= 1;
370 if depth == 0 {
371 dict_end = dict_start + i + 1;
372 break;
373 }
374 }
375 _ => {}
376 }
377 }
378
379 let dict_str = &config[dict_start..dict_end];
380 for cap in ACCOUNT_ENTRY_RE.captures_iter(dict_str) {
384 let account = cap[1].to_string();
385 let target = if cap[2].is_empty() {
386 None
387 } else {
388 Some(cap[2].to_string())
389 };
390 let days: i64 = cap[3].parse().unwrap_or(30);
391 zerosum_accounts.insert(account, (target, days));
392 }
393 }
394
395 if let Some(start) = config.find("'account_name_replace'")
397 && let Some(cap) = ACCOUNT_REPLACE_RE.captures(&config[start..])
398 {
399 account_replace = Some((cap[1].to_string(), cap[2].to_string()));
400 }
401
402 if let Some(start) = config.find("'tolerance'")
404 && let Some(cap) = TOLERANCE_RE.captures(&config[start..])
405 && let Ok(t) = Decimal::from_str(&cap[1])
406 {
407 tolerance = t;
408 }
409
410 Ok((zerosum_accounts, account_replace, tolerance))
411}
412
413fn within_date_range(date1: &str, date2: &str, days: i64) -> bool {
415 use rustledger_core::NaiveDate;
416
417 let d1 = match date1.parse::<NaiveDate>() {
418 Ok(d) => d,
419 Err(_) => return false,
420 };
421 let d2 = match date2.parse::<NaiveDate>() {
422 Ok(d) => d,
423 Err(_) => return false,
424 };
425
426 let diff = i64::from(d2.since(d1).unwrap_or_default().get_days()).abs();
427 diff <= days
428}
429
430#[cfg(test)]
431mod tests {
432 use super::super::utils::materialize_ops;
433 use super::*;
434 use crate::types::*;
435
436 fn create_transfer_txn(
437 date: &str,
438 from_account: &str,
439 to_account: &str,
440 amount: &str,
441 currency: &str,
442 ) -> DirectiveWrapper {
443 DirectiveWrapper {
444 directive_type: "transaction".to_string(),
445 date: date.to_string(),
446 filename: None,
447 lineno: None,
448 data: DirectiveData::Transaction(TransactionData {
449 flag: "*".to_string(),
450 payee: None,
451 narration: "Transfer".to_string(),
452 tags: vec![],
453 links: vec![],
454 metadata: vec![],
455 postings: vec![
456 PostingData {
457 account: from_account.to_string(),
458 units: Some(AmountData {
459 number: format!("-{amount}"),
460 currency: currency.to_string(),
461 }),
462 cost: None,
463 price: None,
464 flag: None,
465 metadata: vec![],
466 span: None,
467 },
468 PostingData {
469 account: to_account.to_string(),
470 units: Some(AmountData {
471 number: amount.to_string(),
472 currency: currency.to_string(),
473 }),
474 cost: None,
475 price: None,
476 flag: None,
477 metadata: vec![],
478 span: None,
479 },
480 ],
481 }),
482 }
483 }
484
485 #[test]
486 fn test_zerosum_matches_transfers() {
487 let plugin = ZerosumPlugin;
488
489 let config = r"{
490 'zerosum_accounts': {
491 'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30)
492 }
493 }";
494
495 let input = PluginInput {
496 directives: vec![
497 create_transfer_txn(
498 "2024-01-01",
499 "Assets:Bank",
500 "Assets:ZeroSum:Transfers",
501 "100.00",
502 "USD",
503 ),
504 create_transfer_txn(
505 "2024-01-03",
506 "Assets:ZeroSum:Transfers",
507 "Assets:Investment",
508 "100.00",
509 "USD",
510 ),
511 ],
512 options: PluginOptions {
513 operating_currencies: vec!["USD".to_string()],
514 title: None,
515 },
516 config: Some(config.to_string()),
517 };
518
519 let input_dirs = input.directives.clone();
520 let output = plugin.process(input);
521 assert_eq!(output.errors.len(), 0);
522 let directives = materialize_ops(&input_dirs, &output);
523
524 let mut found_matched = false;
526 for directive in &directives {
527 if let DirectiveData::Transaction(ref txn) = directive.data {
528 for posting in &txn.postings {
529 if posting.account == "Assets:ZeroSum-Matched:Transfers" {
530 found_matched = true;
531 }
532 }
533 }
534 }
535 assert!(found_matched, "Should have matched postings");
536 }
537
538 #[test]
539 fn test_zerosum_no_match_outside_range() {
540 let plugin = ZerosumPlugin;
541
542 let config = r"{
543 'zerosum_accounts': {
544 'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 5)
545 }
546 }";
547
548 let input = PluginInput {
549 directives: vec![
550 create_transfer_txn(
551 "2024-01-01",
552 "Assets:Bank",
553 "Assets:ZeroSum:Transfers",
554 "100.00",
555 "USD",
556 ),
557 create_transfer_txn(
559 "2024-01-11",
560 "Assets:ZeroSum:Transfers",
561 "Assets:Investment",
562 "100.00",
563 "USD",
564 ),
565 ],
566 options: PluginOptions {
567 operating_currencies: vec!["USD".to_string()],
568 title: None,
569 },
570 config: Some(config.to_string()),
571 };
572
573 let input_dirs = input.directives.clone();
574 let output = plugin.process(input);
575 assert_eq!(output.errors.len(), 0);
576 let directives = materialize_ops(&input_dirs, &output);
577
578 let mut found_unmatched = false;
580 for directive in &directives {
581 if let DirectiveData::Transaction(ref txn) = directive.data {
582 for posting in &txn.postings {
583 if posting.account == "Assets:ZeroSum:Transfers" {
584 found_unmatched = true;
585 }
586 }
587 }
588 }
589 assert!(found_unmatched, "Should have unmatched postings");
590 }
591
592 #[test]
593 fn test_zerosum_does_not_duplicate_open() {
594 let plugin = ZerosumPlugin;
597
598 let config = r"{
599 'zerosum_accounts': {
600 'Assets:Transfer': ('Assets:ZSA-Matched:Transfer', 7)
601 }
602 }";
603
604 let existing_open = DirectiveWrapper {
606 directive_type: "open".to_string(),
607 date: "2020-01-01".to_string(),
608 filename: Some("accounts.beancount".to_string()),
609 lineno: Some(422),
610 data: DirectiveData::Open(OpenData {
611 account: "Assets:ZSA-Matched:Transfer".to_string(),
612 currencies: vec![],
613 booking: None,
614 metadata: vec![],
615 }),
616 };
617
618 let input = PluginInput {
619 directives: vec![
620 existing_open,
621 create_transfer_txn(
622 "2024-01-01",
623 "Assets:Bank",
624 "Assets:Transfer",
625 "100.00",
626 "USD",
627 ),
628 create_transfer_txn(
629 "2024-01-02",
630 "Assets:Transfer",
631 "Assets:Investment",
632 "100.00",
633 "USD",
634 ),
635 ],
636 options: PluginOptions {
637 operating_currencies: vec!["USD".to_string()],
638 title: None,
639 },
640 config: Some(config.to_string()),
641 };
642
643 let input_dirs = input.directives.clone();
644 let output = plugin.process(input);
645 assert_eq!(output.errors.len(), 0);
646 let directives = materialize_ops(&input_dirs, &output);
647
648 let open_count = directives
650 .iter()
651 .filter(|d| {
652 if let DirectiveData::Open(ref open) = d.data {
653 open.account == "Assets:ZSA-Matched:Transfer"
654 } else {
655 false
656 }
657 })
658 .count();
659
660 assert_eq!(
662 open_count, 1,
663 "Should not create duplicate Open directives for existing accounts"
664 );
665 }
666
667 #[test]
668 fn test_parse_config() {
669 let config = r"{
670 'zerosum_accounts': {
671 'Assets:ZeroSum:Transfers': ('Assets:ZeroSum-Matched:Transfers', 30),
672 'Assets:ZeroSum:CreditCard': ('', 6)
673 },
674 'account_name_replace': ('ZeroSum', 'ZeroSum-Matched'),
675 'tolerance': 0.01
676 }";
677
678 let (accounts, replace, tolerance) = parse_config(config).unwrap();
679
680 assert_eq!(accounts.len(), 2);
681 assert!(accounts.contains_key("Assets:ZeroSum:Transfers"));
682 assert!(accounts.contains_key("Assets:ZeroSum:CreditCard"));
683
684 let (target, days) = accounts.get("Assets:ZeroSum:Transfers").unwrap();
685 assert_eq!(target.as_ref().unwrap(), "Assets:ZeroSum-Matched:Transfers");
686 assert_eq!(*days, 30);
687
688 let (target2, days2) = accounts.get("Assets:ZeroSum:CreditCard").unwrap();
689 assert!(target2.is_none()); assert_eq!(*days2, 6);
691
692 assert!(replace.is_some());
693 let (from, to) = replace.unwrap();
694 assert_eq!(from, "ZeroSum");
695 assert_eq!(to, "ZeroSum-Matched");
696
697 assert_eq!(tolerance, Decimal::from_str("0.01").unwrap());
698 }
699}