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 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 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 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 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
136 for (zs_account, (target_account_opt, date_range)) in &zerosum_accounts {
138 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 let mut matched: HashSet<(usize, usize)> = HashSet::new();
154
155 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 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 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 for &other_txn_i in &indices {
182 if other_txn_i == txn_i {
183 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 let sum = (amount + other_amount).abs();
216 if sum < tolerance {
217 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 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 let sum = (amount + other_amount).abs();
263 if sum < tolerance {
264 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 matched.contains(&(txn_i, post_i)) {
275 break;
276 }
277 }
278 }
279 }
280 }
281
282 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 let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
294 if let Some(date) = earliest_date {
295 for account in &new_accounts {
296 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 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
326fn 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 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 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 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 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
400fn 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 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 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 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 let plugin = ZerosumPlugin;
577
578 let config = r"{
579 'zerosum_accounts': {
580 'Assets:Transfer': ('Assets:ZSA-Matched:Transfer', 7)
581 }
582 }";
583
584 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 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 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()); 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}