1use crate::types::{
7 DirectiveData, DirectiveWrapper, DocumentData, OpenData, PluginError, PluginInput,
8 PluginOutput, TransactionData,
9};
10
11pub trait NativePlugin: Send + Sync {
13 fn name(&self) -> &'static str;
15
16 fn description(&self) -> &'static str;
18
19 fn process(&self, input: PluginInput) -> PluginOutput;
21}
22
23pub struct NativePluginRegistry {
25 plugins: Vec<Box<dyn NativePlugin>>,
26}
27
28impl NativePluginRegistry {
29 pub fn new() -> Self {
31 Self {
32 plugins: vec![
33 Box::new(ImplicitPricesPlugin),
34 Box::new(CheckCommodityPlugin),
35 Box::new(AutoTagPlugin::new()),
36 Box::new(AutoAccountsPlugin),
37 Box::new(LeafOnlyPlugin),
38 Box::new(NoDuplicatesPlugin),
39 Box::new(OneCommodityPlugin),
40 Box::new(UniquePricesPlugin),
41 Box::new(CheckClosingPlugin),
42 Box::new(CloseTreePlugin),
43 Box::new(CoherentCostPlugin),
44 Box::new(SellGainsPlugin),
45 Box::new(PedanticPlugin),
46 Box::new(UnrealizedPlugin::new()),
47 Box::new(NoUnusedPlugin),
48 Box::new(CheckDrainedPlugin),
49 Box::new(CommodityAttrPlugin::new()),
50 Box::new(CheckAverageCostPlugin::new()),
51 Box::new(CurrencyAccountsPlugin::new()),
52 ],
53 }
54 }
55
56 pub fn find(&self, name: &str) -> Option<&dyn NativePlugin> {
58 let name = name.strip_prefix("beancount.plugins.").unwrap_or(name);
60
61 self.plugins
62 .iter()
63 .find(|p| p.name() == name)
64 .map(std::convert::AsRef::as_ref)
65 }
66
67 pub fn list(&self) -> Vec<&dyn NativePlugin> {
69 self.plugins.iter().map(AsRef::as_ref).collect()
70 }
71
72 pub fn is_builtin(name: &str) -> bool {
74 let name = name.strip_prefix("beancount.plugins.").unwrap_or(name);
75
76 matches!(
77 name,
78 "implicit_prices"
79 | "check_commodity"
80 | "auto_tag"
81 | "auto_accounts"
82 | "leafonly"
83 | "noduplicates"
84 | "onecommodity"
85 | "unique_prices"
86 | "check_closing"
87 | "close_tree"
88 | "coherent_cost"
89 | "sellgains"
90 | "pedantic"
91 | "unrealized"
92 | "nounused"
93 | "check_drained"
94 | "commodity_attr"
95 | "check_average_cost"
96 | "currency_accounts"
97 )
98 }
99}
100
101impl Default for NativePluginRegistry {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107pub struct ImplicitPricesPlugin;
112
113impl NativePlugin for ImplicitPricesPlugin {
114 fn name(&self) -> &'static str {
115 "implicit_prices"
116 }
117
118 fn description(&self) -> &'static str {
119 "Generate price entries from transaction costs/prices"
120 }
121
122 fn process(&self, input: PluginInput) -> PluginOutput {
123 let mut new_directives = Vec::new();
124 let mut generated_prices = Vec::new();
125
126 for wrapper in &input.directives {
127 new_directives.push(wrapper.clone());
128
129 if wrapper.directive_type != "transaction" {
131 continue;
132 }
133
134 if let crate::types::DirectiveData::Transaction(ref txn) = wrapper.data {
136 for posting in &txn.postings {
137 if let Some(ref units) = posting.units {
139 if let Some(ref price) = posting.price {
140 if let Some(ref price_amount) = price.amount {
142 let price_wrapper = DirectiveWrapper {
143 directive_type: "price".to_string(),
144 date: wrapper.date.clone(),
145 data: crate::types::DirectiveData::Price(
146 crate::types::PriceData {
147 currency: units.currency.clone(),
148 amount: price_amount.clone(),
149 },
150 ),
151 };
152 generated_prices.push(price_wrapper);
153 }
154 }
155
156 if let Some(ref cost) = posting.cost {
158 if let (Some(number), Some(currency)) =
159 (&cost.number_per, &cost.currency)
160 {
161 let price_wrapper = DirectiveWrapper {
162 directive_type: "price".to_string(),
163 date: wrapper.date.clone(),
164 data: crate::types::DirectiveData::Price(
165 crate::types::PriceData {
166 currency: units.currency.clone(),
167 amount: crate::types::AmountData {
168 number: number.clone(),
169 currency: currency.clone(),
170 },
171 },
172 ),
173 };
174 generated_prices.push(price_wrapper);
175 }
176 }
177 }
178 }
179 }
180 }
181
182 new_directives.extend(generated_prices);
184
185 PluginOutput {
186 directives: new_directives,
187 errors: Vec::new(),
188 }
189 }
190}
191
192pub struct CheckCommodityPlugin;
194
195impl NativePlugin for CheckCommodityPlugin {
196 fn name(&self) -> &'static str {
197 "check_commodity"
198 }
199
200 fn description(&self) -> &'static str {
201 "Verify all commodities are declared"
202 }
203
204 fn process(&self, input: PluginInput) -> PluginOutput {
205 use std::collections::HashSet;
206
207 let mut declared_commodities: HashSet<String> = HashSet::new();
208 let mut used_commodities: HashSet<String> = HashSet::new();
209 let mut errors = Vec::new();
210
211 for wrapper in &input.directives {
213 if wrapper.directive_type == "commodity" {
214 if let crate::types::DirectiveData::Commodity(ref comm) = wrapper.data {
215 declared_commodities.insert(comm.currency.clone());
216 }
217 }
218 }
219
220 for wrapper in &input.directives {
222 match &wrapper.data {
223 crate::types::DirectiveData::Transaction(txn) => {
224 for posting in &txn.postings {
225 if let Some(ref units) = posting.units {
226 used_commodities.insert(units.currency.clone());
227 }
228 if let Some(ref cost) = posting.cost {
229 if let Some(ref currency) = cost.currency {
230 used_commodities.insert(currency.clone());
231 }
232 }
233 }
234 }
235 crate::types::DirectiveData::Balance(bal) => {
236 used_commodities.insert(bal.amount.currency.clone());
237 }
238 crate::types::DirectiveData::Price(price) => {
239 used_commodities.insert(price.currency.clone());
240 used_commodities.insert(price.amount.currency.clone());
241 }
242 _ => {}
243 }
244 }
245
246 for currency in &used_commodities {
248 if !declared_commodities.contains(currency) {
249 errors.push(PluginError::warning(format!(
250 "commodity '{currency}' used but not declared"
251 )));
252 }
253 }
254
255 PluginOutput {
256 directives: input.directives,
257 errors,
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_native_plugin_registry() {
268 let registry = NativePluginRegistry::new();
269
270 assert!(registry.find("implicit_prices").is_some());
271 assert!(registry.find("beancount.plugins.implicit_prices").is_some());
272 assert!(registry.find("check_commodity").is_some());
273 assert!(registry.find("nonexistent").is_none());
274 }
275
276 #[test]
277 fn test_is_builtin() {
278 assert!(NativePluginRegistry::is_builtin("implicit_prices"));
279 assert!(NativePluginRegistry::is_builtin(
280 "beancount.plugins.implicit_prices"
281 ));
282 assert!(!NativePluginRegistry::is_builtin("my_custom_plugin"));
283 }
284}
285
286pub struct AutoTagPlugin {
294 rules: Vec<(String, String)>,
296}
297
298impl AutoTagPlugin {
299 pub fn new() -> Self {
301 Self {
302 rules: vec![
303 ("Expenses:Food".to_string(), "food".to_string()),
304 ("Expenses:Travel".to_string(), "travel".to_string()),
305 ("Expenses:Transport".to_string(), "transport".to_string()),
306 ("Income:Salary".to_string(), "income".to_string()),
307 ],
308 }
309 }
310
311 pub const fn with_rules(rules: Vec<(String, String)>) -> Self {
313 Self { rules }
314 }
315}
316
317impl Default for AutoTagPlugin {
318 fn default() -> Self {
319 Self::new()
320 }
321}
322
323impl NativePlugin for AutoTagPlugin {
324 fn name(&self) -> &'static str {
325 "auto_tag"
326 }
327
328 fn description(&self) -> &'static str {
329 "Auto-tag transactions by account patterns"
330 }
331
332 fn process(&self, input: PluginInput) -> PluginOutput {
333 let directives: Vec<_> = input
334 .directives
335 .into_iter()
336 .map(|mut wrapper| {
337 if wrapper.directive_type == "transaction" {
338 if let crate::types::DirectiveData::Transaction(ref mut txn) = wrapper.data {
339 for posting in &txn.postings {
341 for (prefix, tag) in &self.rules {
342 if posting.account.starts_with(prefix) {
343 if !txn.tags.contains(tag) {
345 txn.tags.push(tag.clone());
346 }
347 }
348 }
349 }
350 }
351 }
352 wrapper
353 })
354 .collect();
355
356 PluginOutput {
357 directives,
358 errors: Vec::new(),
359 }
360 }
361}
362
363#[cfg(test)]
364mod auto_tag_tests {
365 use super::*;
366 use crate::types::*;
367
368 #[test]
369 fn test_auto_tag_adds_tag() {
370 let plugin = AutoTagPlugin::new();
371
372 let input = PluginInput {
373 directives: vec![DirectiveWrapper {
374 directive_type: "transaction".to_string(),
375 date: "2024-01-15".to_string(),
376 data: DirectiveData::Transaction(TransactionData {
377 flag: "*".to_string(),
378 payee: None,
379 narration: "Lunch".to_string(),
380 tags: vec![],
381 links: vec![],
382 metadata: vec![],
383 postings: vec![
384 PostingData {
385 account: "Expenses:Food:Restaurants".to_string(),
386 units: Some(AmountData {
387 number: "25.00".to_string(),
388 currency: "USD".to_string(),
389 }),
390 cost: None,
391 price: None,
392 flag: None,
393 metadata: vec![],
394 },
395 PostingData {
396 account: "Assets:Cash".to_string(),
397 units: Some(AmountData {
398 number: "-25.00".to_string(),
399 currency: "USD".to_string(),
400 }),
401 cost: None,
402 price: None,
403 flag: None,
404 metadata: vec![],
405 },
406 ],
407 }),
408 }],
409 options: PluginOptions {
410 operating_currencies: vec!["USD".to_string()],
411 title: None,
412 },
413 config: None,
414 };
415
416 let output = plugin.process(input);
417 assert_eq!(output.errors.len(), 0);
418 assert_eq!(output.directives.len(), 1);
419
420 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
421 assert!(txn.tags.contains(&"food".to_string()));
422 } else {
423 panic!("Expected transaction");
424 }
425 }
426}
427
428pub struct AutoAccountsPlugin;
434
435impl NativePlugin for AutoAccountsPlugin {
436 fn name(&self) -> &'static str {
437 "auto_accounts"
438 }
439
440 fn description(&self) -> &'static str {
441 "Auto-generate Open directives for used accounts"
442 }
443
444 fn process(&self, input: PluginInput) -> PluginOutput {
445 use std::collections::{HashMap, HashSet};
446
447 let mut opened_accounts: HashSet<String> = HashSet::new();
448 let mut account_first_use: HashMap<String, String> = HashMap::new(); for wrapper in &input.directives {
453 match &wrapper.data {
454 DirectiveData::Open(data) => {
455 opened_accounts.insert(data.account.clone());
456 }
457 DirectiveData::Transaction(txn) => {
458 for posting in &txn.postings {
459 account_first_use
460 .entry(posting.account.clone())
461 .and_modify(|existing| {
462 if wrapper.date < *existing {
463 existing.clone_from(&wrapper.date);
464 }
465 })
466 .or_insert_with(|| wrapper.date.clone());
467 }
468 }
469 DirectiveData::Balance(data) => {
470 account_first_use
471 .entry(data.account.clone())
472 .and_modify(|existing| {
473 if wrapper.date < *existing {
474 existing.clone_from(&wrapper.date);
475 }
476 })
477 .or_insert_with(|| wrapper.date.clone());
478 }
479 DirectiveData::Pad(data) => {
480 account_first_use
481 .entry(data.account.clone())
482 .and_modify(|existing| {
483 if wrapper.date < *existing {
484 existing.clone_from(&wrapper.date);
485 }
486 })
487 .or_insert_with(|| wrapper.date.clone());
488 account_first_use
489 .entry(data.source_account.clone())
490 .and_modify(|existing| {
491 if wrapper.date < *existing {
492 existing.clone_from(&wrapper.date);
493 }
494 })
495 .or_insert_with(|| wrapper.date.clone());
496 }
497 _ => {}
498 }
499 }
500
501 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
503 for (account, date) in &account_first_use {
504 if !opened_accounts.contains(account) {
505 new_directives.push(DirectiveWrapper {
506 directive_type: "open".to_string(),
507 date: date.clone(),
508 data: DirectiveData::Open(OpenData {
509 account: account.clone(),
510 currencies: vec![],
511 booking: None,
512 }),
513 });
514 }
515 }
516
517 new_directives.extend(input.directives);
519
520 new_directives.sort_by(|a, b| {
523 match a.date.cmp(&b.date) {
524 std::cmp::Ordering::Equal => {
525 let a_is_open = a.directive_type == "open";
527 let b_is_open = b.directive_type == "open";
528 b_is_open.cmp(&a_is_open) }
530 other => other,
531 }
532 });
533
534 PluginOutput {
535 directives: new_directives,
536 errors: Vec::new(),
537 }
538 }
539}
540
541pub struct LeafOnlyPlugin;
543
544impl NativePlugin for LeafOnlyPlugin {
545 fn name(&self) -> &'static str {
546 "leafonly"
547 }
548
549 fn description(&self) -> &'static str {
550 "Error on postings to non-leaf accounts"
551 }
552
553 fn process(&self, input: PluginInput) -> PluginOutput {
554 use std::collections::HashSet;
555
556 let mut all_accounts: HashSet<String> = HashSet::new();
558 for wrapper in &input.directives {
559 if let DirectiveData::Transaction(txn) = &wrapper.data {
560 for posting in &txn.postings {
561 all_accounts.insert(posting.account.clone());
562 }
563 }
564 }
565
566 let parent_accounts: HashSet<&String> = all_accounts
568 .iter()
569 .filter(|acc| {
570 all_accounts
571 .iter()
572 .any(|other| other != *acc && other.starts_with(&format!("{acc}:")))
573 })
574 .collect();
575
576 let mut errors = Vec::new();
578 for wrapper in &input.directives {
579 if let DirectiveData::Transaction(txn) = &wrapper.data {
580 for posting in &txn.postings {
581 if parent_accounts.contains(&posting.account) {
582 errors.push(PluginError::error(format!(
583 "Posting to non-leaf account '{}' - has child accounts",
584 posting.account
585 )));
586 }
587 }
588 }
589 }
590
591 PluginOutput {
592 directives: input.directives,
593 errors,
594 }
595 }
596}
597
598pub struct NoDuplicatesPlugin;
600
601impl NativePlugin for NoDuplicatesPlugin {
602 fn name(&self) -> &'static str {
603 "noduplicates"
604 }
605
606 fn description(&self) -> &'static str {
607 "Hash-based duplicate transaction detection"
608 }
609
610 fn process(&self, input: PluginInput) -> PluginOutput {
611 use std::collections::HashSet;
612 use std::collections::hash_map::DefaultHasher;
613 use std::hash::{Hash, Hasher};
614
615 fn hash_transaction(date: &str, txn: &TransactionData) -> u64 {
616 let mut hasher = DefaultHasher::new();
617 date.hash(&mut hasher);
618 txn.narration.hash(&mut hasher);
619 txn.payee.hash(&mut hasher);
620 for posting in &txn.postings {
621 posting.account.hash(&mut hasher);
622 if let Some(units) = &posting.units {
623 units.number.hash(&mut hasher);
624 units.currency.hash(&mut hasher);
625 }
626 }
627 hasher.finish()
628 }
629
630 let mut seen: HashSet<u64> = HashSet::new();
631 let mut errors = Vec::new();
632
633 for wrapper in &input.directives {
634 if let DirectiveData::Transaction(txn) = &wrapper.data {
635 let hash = hash_transaction(&wrapper.date, txn);
636 if !seen.insert(hash) {
637 errors.push(PluginError::error(format!(
638 "Duplicate transaction: {} \"{}\"",
639 wrapper.date, txn.narration
640 )));
641 }
642 }
643 }
644
645 PluginOutput {
646 directives: input.directives,
647 errors,
648 }
649 }
650}
651
652pub struct OneCommodityPlugin;
654
655impl NativePlugin for OneCommodityPlugin {
656 fn name(&self) -> &'static str {
657 "onecommodity"
658 }
659
660 fn description(&self) -> &'static str {
661 "Enforce single commodity per account"
662 }
663
664 fn process(&self, input: PluginInput) -> PluginOutput {
665 use std::collections::HashMap;
666
667 let mut account_currencies: HashMap<String, String> = HashMap::new();
669 let mut errors = Vec::new();
670
671 for wrapper in &input.directives {
672 if let DirectiveData::Transaction(txn) = &wrapper.data {
673 for posting in &txn.postings {
674 if let Some(units) = &posting.units {
675 if let Some(existing) = account_currencies.get(&posting.account) {
676 if existing != &units.currency {
677 errors.push(PluginError::error(format!(
678 "Account '{}' uses multiple currencies: {} and {}",
679 posting.account, existing, units.currency
680 )));
681 }
682 } else {
683 account_currencies
684 .insert(posting.account.clone(), units.currency.clone());
685 }
686 }
687 }
688 }
689 }
690
691 PluginOutput {
692 directives: input.directives,
693 errors,
694 }
695 }
696}
697
698pub struct UniquePricesPlugin;
700
701impl NativePlugin for UniquePricesPlugin {
702 fn name(&self) -> &'static str {
703 "unique_prices"
704 }
705
706 fn description(&self) -> &'static str {
707 "One price per day per currency pair"
708 }
709
710 fn process(&self, input: PluginInput) -> PluginOutput {
711 use std::collections::HashSet;
712
713 let mut seen: HashSet<(String, String, String)> = HashSet::new();
715 let mut errors = Vec::new();
716
717 for wrapper in &input.directives {
718 if let DirectiveData::Price(price) = &wrapper.data {
719 let key = (
720 wrapper.date.clone(),
721 price.currency.clone(),
722 price.amount.currency.clone(),
723 );
724 if !seen.insert(key.clone()) {
725 errors.push(PluginError::error(format!(
726 "Duplicate price for {}/{} on {}",
727 price.currency, price.amount.currency, wrapper.date
728 )));
729 }
730 }
731 }
732
733 PluginOutput {
734 directives: input.directives,
735 errors,
736 }
737 }
738}
739
740pub struct DocumentDiscoveryPlugin {
748 pub directories: Vec<String>,
750}
751
752impl DocumentDiscoveryPlugin {
753 pub const fn new(directories: Vec<String>) -> Self {
755 Self { directories }
756 }
757}
758
759impl NativePlugin for DocumentDiscoveryPlugin {
760 fn name(&self) -> &'static str {
761 "document_discovery"
762 }
763
764 fn description(&self) -> &'static str {
765 "Auto-discover documents from directories"
766 }
767
768 fn process(&self, input: PluginInput) -> PluginOutput {
769 use std::path::Path;
770
771 let mut new_directives = Vec::new();
772 let mut errors = Vec::new();
773
774 let mut existing_docs: std::collections::HashSet<String> = std::collections::HashSet::new();
776 for wrapper in &input.directives {
777 if let DirectiveData::Document(doc) = &wrapper.data {
778 existing_docs.insert(doc.path.clone());
779 }
780 }
781
782 for dir in &self.directories {
784 let dir_path = Path::new(dir);
785 if !dir_path.exists() {
786 continue;
787 }
788
789 if let Err(e) = scan_documents(
790 dir_path,
791 dir,
792 &existing_docs,
793 &mut new_directives,
794 &mut errors,
795 ) {
796 errors.push(PluginError::error(format!(
797 "Error scanning documents in {dir}: {e}"
798 )));
799 }
800 }
801
802 let mut all_directives = input.directives;
804 all_directives.extend(new_directives);
805
806 all_directives.sort_by(|a, b| a.date.cmp(&b.date));
808
809 PluginOutput {
810 directives: all_directives,
811 errors,
812 }
813 }
814}
815
816#[allow(clippy::only_used_in_recursion)]
818fn scan_documents(
819 path: &std::path::Path,
820 base_dir: &str,
821 existing: &std::collections::HashSet<String>,
822 directives: &mut Vec<DirectiveWrapper>,
823 errors: &mut Vec<PluginError>,
824) -> std::io::Result<()> {
825 use std::fs;
826
827 for entry in fs::read_dir(path)? {
828 let entry = entry?;
829 let entry_path = entry.path();
830
831 if entry_path.is_dir() {
832 scan_documents(&entry_path, base_dir, existing, directives, errors)?;
833 } else if entry_path.is_file() {
834 if let Some(file_name) = entry_path.file_name().and_then(|n| n.to_str()) {
836 if file_name.len() >= 10
837 && file_name.chars().nth(4) == Some('-')
838 && file_name.chars().nth(7) == Some('-')
839 {
840 let date_str = &file_name[0..10];
841 if date_str.chars().take(4).all(|c| c.is_ascii_digit())
843 && date_str.chars().skip(5).take(2).all(|c| c.is_ascii_digit())
844 && date_str.chars().skip(8).take(2).all(|c| c.is_ascii_digit())
845 {
846 if let Ok(rel_path) = entry_path.strip_prefix(base_dir) {
848 if let Some(parent) = rel_path.parent() {
849 let account = parent
850 .components()
851 .map(|c| c.as_os_str().to_string_lossy().to_string())
852 .collect::<Vec<_>>()
853 .join(":");
854
855 if !account.is_empty() {
856 let full_path = entry_path.to_string_lossy().to_string();
857
858 if existing.contains(&full_path) {
860 continue;
861 }
862
863 directives.push(DirectiveWrapper {
864 directive_type: "document".to_string(),
865 date: date_str.to_string(),
866 data: DirectiveData::Document(DocumentData {
867 account,
868 path: full_path,
869 }),
870 });
871 }
872 }
873 }
874 }
875 }
876 }
877 }
878 }
879
880 Ok(())
881}
882
883pub struct CheckClosingPlugin;
888
889impl NativePlugin for CheckClosingPlugin {
890 fn name(&self) -> &'static str {
891 "check_closing"
892 }
893
894 fn description(&self) -> &'static str {
895 "Zero balance assertion on account closing"
896 }
897
898 fn process(&self, input: PluginInput) -> PluginOutput {
899 use crate::types::{AmountData, BalanceData, MetaValueData};
900
901 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
902
903 for wrapper in &input.directives {
904 new_directives.push(wrapper.clone());
905
906 if let DirectiveData::Transaction(txn) = &wrapper.data {
907 for posting in &txn.postings {
908 let has_closing = posting.metadata.iter().any(|(key, val)| {
910 key == "closing" && matches!(val, MetaValueData::Bool(true))
911 });
912
913 if has_closing {
914 if let Some(next_date) = increment_date(&wrapper.date) {
916 let currency = posting
918 .units
919 .as_ref()
920 .map_or_else(|| "USD".to_string(), |u| u.currency.clone());
921
922 new_directives.push(DirectiveWrapper {
924 directive_type: "balance".to_string(),
925 date: next_date,
926 data: DirectiveData::Balance(BalanceData {
927 account: posting.account.clone(),
928 amount: AmountData {
929 number: "0".to_string(),
930 currency,
931 },
932 tolerance: None,
933 }),
934 });
935 }
936 }
937 }
938 }
939 }
940
941 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
943
944 PluginOutput {
945 directives: new_directives,
946 errors: Vec::new(),
947 }
948 }
949}
950
951fn increment_date(date: &str) -> Option<String> {
953 let parts: Vec<&str> = date.split('-').collect();
954 if parts.len() != 3 {
955 return None;
956 }
957
958 let year: i32 = parts[0].parse().ok()?;
959 let month: u32 = parts[1].parse().ok()?;
960 let day: u32 = parts[2].parse().ok()?;
961
962 let days_in_month = match month {
964 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
965 4 | 6 | 9 | 11 => 30,
966 2 => {
967 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
968 29
969 } else {
970 28
971 }
972 }
973 _ => return None,
974 };
975
976 let (new_year, new_month, new_day) = if day < days_in_month {
977 (year, month, day + 1)
978 } else if month < 12 {
979 (year, month + 1, 1)
980 } else {
981 (year + 1, 1, 1)
982 };
983
984 Some(format!("{new_year:04}-{new_month:02}-{new_day:02}"))
985}
986
987pub struct CloseTreePlugin;
992
993impl NativePlugin for CloseTreePlugin {
994 fn name(&self) -> &'static str {
995 "close_tree"
996 }
997
998 fn description(&self) -> &'static str {
999 "Close descendant accounts automatically"
1000 }
1001
1002 fn process(&self, input: PluginInput) -> PluginOutput {
1003 use crate::types::CloseData;
1004 use std::collections::HashSet;
1005
1006 let mut all_accounts: HashSet<String> = HashSet::new();
1008 for wrapper in &input.directives {
1009 if let DirectiveData::Open(data) = &wrapper.data {
1010 all_accounts.insert(data.account.clone());
1011 }
1012 if let DirectiveData::Transaction(txn) = &wrapper.data {
1013 for posting in &txn.postings {
1014 all_accounts.insert(posting.account.clone());
1015 }
1016 }
1017 }
1018
1019 let mut closed_parents: Vec<(String, String)> = Vec::new(); for wrapper in &input.directives {
1022 if let DirectiveData::Close(data) = &wrapper.data {
1023 closed_parents.push((data.account.clone(), wrapper.date.clone()));
1024 }
1025 }
1026
1027 let mut new_directives = input.directives;
1029
1030 for (parent, close_date) in &closed_parents {
1031 let prefix = format!("{parent}:");
1032 for account in &all_accounts {
1033 if account.starts_with(&prefix) {
1034 let already_closed = new_directives.iter().any(|w| {
1036 if let DirectiveData::Close(data) = &w.data {
1037 &data.account == account
1038 } else {
1039 false
1040 }
1041 });
1042
1043 if !already_closed {
1044 new_directives.push(DirectiveWrapper {
1045 directive_type: "close".to_string(),
1046 date: close_date.clone(),
1047 data: DirectiveData::Close(CloseData {
1048 account: account.clone(),
1049 }),
1050 });
1051 }
1052 }
1053 }
1054 }
1055
1056 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
1058
1059 PluginOutput {
1060 directives: new_directives,
1061 errors: Vec::new(),
1062 }
1063 }
1064}
1065
1066pub struct CoherentCostPlugin;
1071
1072impl NativePlugin for CoherentCostPlugin {
1073 fn name(&self) -> &'static str {
1074 "coherent_cost"
1075 }
1076
1077 fn description(&self) -> &'static str {
1078 "Enforce cost OR price (not both) consistency"
1079 }
1080
1081 fn process(&self, input: PluginInput) -> PluginOutput {
1082 use std::collections::{HashMap, HashSet};
1083
1084 let mut currencies_with_cost: HashSet<String> = HashSet::new();
1086 let mut currencies_with_price: HashSet<String> = HashSet::new();
1087 let mut first_use: HashMap<String, (String, String)> = HashMap::new(); for wrapper in &input.directives {
1090 if let DirectiveData::Transaction(txn) = &wrapper.data {
1091 for posting in &txn.postings {
1092 if let Some(units) = &posting.units {
1093 let currency = &units.currency;
1094
1095 if posting.cost.is_some() && !currencies_with_cost.contains(currency) {
1096 currencies_with_cost.insert(currency.clone());
1097 first_use
1098 .entry(currency.clone())
1099 .or_insert(("cost".to_string(), wrapper.date.clone()));
1100 }
1101
1102 if posting.price.is_some() && !currencies_with_price.contains(currency) {
1103 currencies_with_price.insert(currency.clone());
1104 first_use
1105 .entry(currency.clone())
1106 .or_insert(("price".to_string(), wrapper.date.clone()));
1107 }
1108 }
1109 }
1110 }
1111 }
1112
1113 let mut errors = Vec::new();
1115 for currency in currencies_with_cost.intersection(¤cies_with_price) {
1116 errors.push(PluginError::error(format!(
1117 "Currency '{currency}' is used with both cost and price notation - this may cause inconsistencies"
1118 )));
1119 }
1120
1121 PluginOutput {
1122 directives: input.directives,
1123 errors,
1124 }
1125 }
1126}
1127
1128pub struct SellGainsPlugin;
1133
1134impl NativePlugin for SellGainsPlugin {
1135 fn name(&self) -> &'static str {
1136 "sellgains"
1137 }
1138
1139 fn description(&self) -> &'static str {
1140 "Cross-check capital gains against sales"
1141 }
1142
1143 fn process(&self, input: PluginInput) -> PluginOutput {
1144 use rust_decimal::Decimal;
1145 use std::str::FromStr;
1146
1147 let mut errors = Vec::new();
1148
1149 for wrapper in &input.directives {
1150 if let DirectiveData::Transaction(txn) = &wrapper.data {
1151 for posting in &txn.postings {
1153 if let (Some(units), Some(cost), Some(price)) =
1154 (&posting.units, &posting.cost, &posting.price)
1155 {
1156 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
1158 if units_num >= Decimal::ZERO {
1159 continue;
1160 }
1161
1162 let cost_per = cost
1164 .number_per
1165 .as_ref()
1166 .and_then(|s| Decimal::from_str(s).ok())
1167 .unwrap_or_default();
1168
1169 let sale_price = price
1171 .amount
1172 .as_ref()
1173 .and_then(|a| Decimal::from_str(&a.number).ok())
1174 .unwrap_or_default();
1175
1176 let expected_gain = (sale_price - cost_per) * units_num.abs();
1178
1179 let has_gain_posting = txn.postings.iter().any(|p| {
1181 p.account.starts_with("Income:") || p.account.starts_with("Expenses:")
1182 });
1183
1184 if expected_gain != Decimal::ZERO && !has_gain_posting {
1185 errors.push(PluginError::warning(format!(
1186 "Sale of {} {} at {} (cost {}) has expected gain/loss of {} but no Income/Expenses posting",
1187 units_num.abs(),
1188 units.currency,
1189 sale_price,
1190 cost_per,
1191 expected_gain
1192 )));
1193 }
1194 }
1195 }
1196 }
1197 }
1198
1199 PluginOutput {
1200 directives: input.directives,
1201 errors,
1202 }
1203 }
1204}
1205
1206pub struct PedanticPlugin;
1214
1215impl NativePlugin for PedanticPlugin {
1216 fn name(&self) -> &'static str {
1217 "pedantic"
1218 }
1219
1220 fn description(&self) -> &'static str {
1221 "Enable all strict validation rules"
1222 }
1223
1224 fn process(&self, input: PluginInput) -> PluginOutput {
1225 let mut all_errors = Vec::new();
1226
1227 let leafonly = LeafOnlyPlugin;
1229 let result = leafonly.process(PluginInput {
1230 directives: input.directives.clone(),
1231 options: input.options.clone(),
1232 config: None,
1233 });
1234 all_errors.extend(result.errors);
1235
1236 let onecommodity = OneCommodityPlugin;
1238 let result = onecommodity.process(PluginInput {
1239 directives: input.directives.clone(),
1240 options: input.options.clone(),
1241 config: None,
1242 });
1243 all_errors.extend(result.errors);
1244
1245 let noduplicates = NoDuplicatesPlugin;
1247 let result = noduplicates.process(PluginInput {
1248 directives: input.directives.clone(),
1249 options: input.options.clone(),
1250 config: None,
1251 });
1252 all_errors.extend(result.errors);
1253
1254 let check_commodity = CheckCommodityPlugin;
1256 let result = check_commodity.process(PluginInput {
1257 directives: input.directives.clone(),
1258 options: input.options.clone(),
1259 config: None,
1260 });
1261 all_errors.extend(result.errors);
1262
1263 PluginOutput {
1264 directives: input.directives,
1265 errors: all_errors,
1266 }
1267 }
1268}
1269
1270pub struct UnrealizedPlugin {
1275 pub gains_account: String,
1277}
1278
1279impl UnrealizedPlugin {
1280 pub fn new() -> Self {
1282 Self {
1283 gains_account: "Income:Unrealized".to_string(),
1284 }
1285 }
1286
1287 pub const fn with_account(account: String) -> Self {
1289 Self {
1290 gains_account: account,
1291 }
1292 }
1293}
1294
1295impl Default for UnrealizedPlugin {
1296 fn default() -> Self {
1297 Self::new()
1298 }
1299}
1300
1301impl NativePlugin for UnrealizedPlugin {
1302 fn name(&self) -> &'static str {
1303 "unrealized"
1304 }
1305
1306 fn description(&self) -> &'static str {
1307 "Calculate unrealized gains/losses"
1308 }
1309
1310 fn process(&self, input: PluginInput) -> PluginOutput {
1311 use rust_decimal::Decimal;
1312 use std::collections::HashMap;
1313 use std::str::FromStr;
1314
1315 let mut prices: HashMap<(String, String), (String, Decimal)> = HashMap::new(); for wrapper in &input.directives {
1319 if let DirectiveData::Price(price) = &wrapper.data {
1320 let key = (price.currency.clone(), price.amount.currency.clone());
1321 let price_val = Decimal::from_str(&price.amount.number).unwrap_or_default();
1322
1323 if let Some((existing_date, _)) = prices.get(&key) {
1325 if &wrapper.date > existing_date {
1326 prices.insert(key, (wrapper.date.clone(), price_val));
1327 }
1328 } else {
1329 prices.insert(key, (wrapper.date.clone(), price_val));
1330 }
1331 }
1332 }
1333
1334 let mut positions: HashMap<String, HashMap<String, (Decimal, Decimal)>> = HashMap::new(); let mut errors = Vec::new();
1338
1339 for wrapper in &input.directives {
1340 if let DirectiveData::Transaction(txn) = &wrapper.data {
1341 for posting in &txn.postings {
1342 if let Some(units) = &posting.units {
1343 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
1344
1345 let cost_basis = if let Some(cost) = &posting.cost {
1346 cost.number_per
1347 .as_ref()
1348 .and_then(|s| Decimal::from_str(s).ok())
1349 .unwrap_or_default()
1350 * units_num.abs()
1351 } else {
1352 Decimal::ZERO
1353 };
1354
1355 let account_positions =
1356 positions.entry(posting.account.clone()).or_default();
1357
1358 let (existing_units, existing_cost) = account_positions
1359 .entry(units.currency.clone())
1360 .or_insert((Decimal::ZERO, Decimal::ZERO));
1361
1362 *existing_units += units_num;
1363 *existing_cost += cost_basis;
1364 }
1365 }
1366 }
1367 }
1368
1369 for (account, currencies) in &positions {
1371 for (currency, (units, cost_basis)) in currencies {
1372 if *units == Decimal::ZERO {
1373 continue;
1374 }
1375
1376 if let Some((_, market_price)) = prices.get(&(currency.clone(), "USD".to_string()))
1378 {
1379 let market_value = *units * market_price;
1380 let unrealized_gain = market_value - cost_basis;
1381
1382 if unrealized_gain.abs() > Decimal::new(1, 2) {
1383 errors.push(PluginError::warning(format!(
1385 "Unrealized gain on {units} {currency} in {account}: {unrealized_gain} USD"
1386 )));
1387 }
1388 }
1389 }
1390 }
1391
1392 PluginOutput {
1393 directives: input.directives,
1394 errors,
1395 }
1396 }
1397}
1398
1399pub struct NoUnusedPlugin;
1404
1405impl NativePlugin for NoUnusedPlugin {
1406 fn name(&self) -> &'static str {
1407 "nounused"
1408 }
1409
1410 fn description(&self) -> &'static str {
1411 "Warn about unused accounts"
1412 }
1413
1414 fn process(&self, input: PluginInput) -> PluginOutput {
1415 use std::collections::HashSet;
1416
1417 let mut opened_accounts: HashSet<String> = HashSet::new();
1418 let mut used_accounts: HashSet<String> = HashSet::new();
1419
1420 for wrapper in &input.directives {
1422 match &wrapper.data {
1423 DirectiveData::Open(data) => {
1424 opened_accounts.insert(data.account.clone());
1425 }
1426 DirectiveData::Close(data) => {
1427 used_accounts.insert(data.account.clone());
1429 }
1430 DirectiveData::Transaction(txn) => {
1431 for posting in &txn.postings {
1432 used_accounts.insert(posting.account.clone());
1433 }
1434 }
1435 DirectiveData::Balance(data) => {
1436 used_accounts.insert(data.account.clone());
1437 }
1438 DirectiveData::Pad(data) => {
1439 used_accounts.insert(data.account.clone());
1440 used_accounts.insert(data.source_account.clone());
1441 }
1442 DirectiveData::Note(data) => {
1443 used_accounts.insert(data.account.clone());
1444 }
1445 DirectiveData::Document(data) => {
1446 used_accounts.insert(data.account.clone());
1447 }
1448 DirectiveData::Custom(data) => {
1449 for value in &data.values {
1452 if value.starts_with("Assets:")
1453 || value.starts_with("Liabilities:")
1454 || value.starts_with("Equity:")
1455 || value.starts_with("Income:")
1456 || value.starts_with("Expenses:")
1457 {
1458 used_accounts.insert(value.clone());
1459 }
1460 }
1461 }
1462 _ => {}
1463 }
1464 }
1465
1466 let mut errors = Vec::new();
1468 let mut unused: Vec<_> = opened_accounts
1469 .difference(&used_accounts)
1470 .cloned()
1471 .collect();
1472 unused.sort(); for account in unused {
1475 errors.push(PluginError::warning(format!(
1476 "Account '{account}' is opened but never used"
1477 )));
1478 }
1479
1480 PluginOutput {
1481 directives: input.directives,
1482 errors,
1483 }
1484 }
1485}
1486
1487#[cfg(test)]
1488mod nounused_tests {
1489 use super::*;
1490 use crate::types::*;
1491
1492 #[test]
1493 fn test_nounused_reports_unused_account() {
1494 let plugin = NoUnusedPlugin;
1495
1496 let input = PluginInput {
1497 directives: vec![
1498 DirectiveWrapper {
1499 directive_type: "open".to_string(),
1500 date: "2024-01-01".to_string(),
1501 data: DirectiveData::Open(OpenData {
1502 account: "Assets:Bank".to_string(),
1503 currencies: vec![],
1504 booking: None,
1505 }),
1506 },
1507 DirectiveWrapper {
1508 directive_type: "open".to_string(),
1509 date: "2024-01-01".to_string(),
1510 data: DirectiveData::Open(OpenData {
1511 account: "Assets:Unused".to_string(),
1512 currencies: vec![],
1513 booking: None,
1514 }),
1515 },
1516 DirectiveWrapper {
1517 directive_type: "transaction".to_string(),
1518 date: "2024-01-15".to_string(),
1519 data: DirectiveData::Transaction(TransactionData {
1520 flag: "*".to_string(),
1521 payee: None,
1522 narration: "Test".to_string(),
1523 tags: vec![],
1524 links: vec![],
1525 metadata: vec![],
1526 postings: vec![PostingData {
1527 account: "Assets:Bank".to_string(),
1528 units: Some(AmountData {
1529 number: "100".to_string(),
1530 currency: "USD".to_string(),
1531 }),
1532 cost: None,
1533 price: None,
1534 flag: None,
1535 metadata: vec![],
1536 }],
1537 }),
1538 },
1539 ],
1540 options: PluginOptions {
1541 operating_currencies: vec!["USD".to_string()],
1542 title: None,
1543 },
1544 config: None,
1545 };
1546
1547 let output = plugin.process(input);
1548 assert_eq!(output.errors.len(), 1);
1549 assert!(output.errors[0].message.contains("Assets:Unused"));
1550 assert!(output.errors[0].message.contains("never used"));
1551 }
1552
1553 #[test]
1554 fn test_nounused_no_warning_for_used_accounts() {
1555 let plugin = NoUnusedPlugin;
1556
1557 let input = PluginInput {
1558 directives: vec![
1559 DirectiveWrapper {
1560 directive_type: "open".to_string(),
1561 date: "2024-01-01".to_string(),
1562 data: DirectiveData::Open(OpenData {
1563 account: "Assets:Bank".to_string(),
1564 currencies: vec![],
1565 booking: None,
1566 }),
1567 },
1568 DirectiveWrapper {
1569 directive_type: "transaction".to_string(),
1570 date: "2024-01-15".to_string(),
1571 data: DirectiveData::Transaction(TransactionData {
1572 flag: "*".to_string(),
1573 payee: None,
1574 narration: "Test".to_string(),
1575 tags: vec![],
1576 links: vec![],
1577 metadata: vec![],
1578 postings: vec![PostingData {
1579 account: "Assets:Bank".to_string(),
1580 units: Some(AmountData {
1581 number: "100".to_string(),
1582 currency: "USD".to_string(),
1583 }),
1584 cost: None,
1585 price: None,
1586 flag: None,
1587 metadata: vec![],
1588 }],
1589 }),
1590 },
1591 ],
1592 options: PluginOptions {
1593 operating_currencies: vec!["USD".to_string()],
1594 title: None,
1595 },
1596 config: None,
1597 };
1598
1599 let output = plugin.process(input);
1600 assert_eq!(output.errors.len(), 0);
1601 }
1602
1603 #[test]
1604 fn test_nounused_close_counts_as_used() {
1605 let plugin = NoUnusedPlugin;
1606
1607 let input = PluginInput {
1608 directives: vec![
1609 DirectiveWrapper {
1610 directive_type: "open".to_string(),
1611 date: "2024-01-01".to_string(),
1612 data: DirectiveData::Open(OpenData {
1613 account: "Assets:OldAccount".to_string(),
1614 currencies: vec![],
1615 booking: None,
1616 }),
1617 },
1618 DirectiveWrapper {
1619 directive_type: "close".to_string(),
1620 date: "2024-12-31".to_string(),
1621 data: DirectiveData::Close(CloseData {
1622 account: "Assets:OldAccount".to_string(),
1623 }),
1624 },
1625 ],
1626 options: PluginOptions {
1627 operating_currencies: vec!["USD".to_string()],
1628 title: None,
1629 },
1630 config: None,
1631 };
1632
1633 let output = plugin.process(input);
1634 assert_eq!(output.errors.len(), 0);
1636 }
1637}
1638
1639pub struct CheckDrainedPlugin;
1645
1646impl NativePlugin for CheckDrainedPlugin {
1647 fn name(&self) -> &'static str {
1648 "check_drained"
1649 }
1650
1651 fn description(&self) -> &'static str {
1652 "Zero balance assertion on balance sheet account close"
1653 }
1654
1655 fn process(&self, input: PluginInput) -> PluginOutput {
1656 use crate::types::{AmountData, BalanceData};
1657 use std::collections::{HashMap, HashSet};
1658
1659 let mut account_currencies: HashMap<String, HashSet<String>> = HashMap::new();
1661
1662 for wrapper in &input.directives {
1664 match &wrapper.data {
1665 DirectiveData::Transaction(txn) => {
1666 for posting in &txn.postings {
1667 if let Some(units) = &posting.units {
1668 account_currencies
1669 .entry(posting.account.clone())
1670 .or_default()
1671 .insert(units.currency.clone());
1672 }
1673 }
1674 }
1675 DirectiveData::Balance(data) => {
1676 account_currencies
1677 .entry(data.account.clone())
1678 .or_default()
1679 .insert(data.amount.currency.clone());
1680 }
1681 DirectiveData::Open(data) => {
1682 for currency in &data.currencies {
1684 account_currencies
1685 .entry(data.account.clone())
1686 .or_default()
1687 .insert(currency.clone());
1688 }
1689 }
1690 _ => {}
1691 }
1692 }
1693
1694 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
1696
1697 for wrapper in &input.directives {
1698 new_directives.push(wrapper.clone());
1699
1700 if let DirectiveData::Close(data) = &wrapper.data {
1701 let is_balance_sheet = data.account.starts_with("Assets:")
1703 || data.account.starts_with("Liabilities:")
1704 || data.account.starts_with("Equity:")
1705 || data.account == "Assets"
1706 || data.account == "Liabilities"
1707 || data.account == "Equity";
1708
1709 if !is_balance_sheet {
1710 continue;
1711 }
1712
1713 if let Some(currencies) = account_currencies.get(&data.account) {
1715 if let Some(next_date) = increment_date(&wrapper.date) {
1717 let mut sorted_currencies: Vec<_> = currencies.iter().collect();
1719 sorted_currencies.sort(); for currency in sorted_currencies {
1722 new_directives.push(DirectiveWrapper {
1723 directive_type: "balance".to_string(),
1724 date: next_date.clone(),
1725 data: DirectiveData::Balance(BalanceData {
1726 account: data.account.clone(),
1727 amount: AmountData {
1728 number: "0".to_string(),
1729 currency: currency.clone(),
1730 },
1731 tolerance: None,
1732 }),
1733 });
1734 }
1735 }
1736 }
1737 }
1738 }
1739
1740 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
1742
1743 PluginOutput {
1744 directives: new_directives,
1745 errors: Vec::new(),
1746 }
1747 }
1748}
1749
1750#[cfg(test)]
1751mod check_drained_tests {
1752 use super::*;
1753 use crate::types::*;
1754
1755 #[test]
1756 fn test_check_drained_adds_balance_assertion() {
1757 let plugin = CheckDrainedPlugin;
1758
1759 let input = PluginInput {
1760 directives: vec![
1761 DirectiveWrapper {
1762 directive_type: "open".to_string(),
1763 date: "2024-01-01".to_string(),
1764 data: DirectiveData::Open(OpenData {
1765 account: "Assets:Bank".to_string(),
1766 currencies: vec!["USD".to_string()],
1767 booking: None,
1768 }),
1769 },
1770 DirectiveWrapper {
1771 directive_type: "transaction".to_string(),
1772 date: "2024-06-15".to_string(),
1773 data: DirectiveData::Transaction(TransactionData {
1774 flag: "*".to_string(),
1775 payee: None,
1776 narration: "Deposit".to_string(),
1777 tags: vec![],
1778 links: vec![],
1779 metadata: vec![],
1780 postings: vec![PostingData {
1781 account: "Assets:Bank".to_string(),
1782 units: Some(AmountData {
1783 number: "100".to_string(),
1784 currency: "USD".to_string(),
1785 }),
1786 cost: None,
1787 price: None,
1788 flag: None,
1789 metadata: vec![],
1790 }],
1791 }),
1792 },
1793 DirectiveWrapper {
1794 directive_type: "close".to_string(),
1795 date: "2024-12-31".to_string(),
1796 data: DirectiveData::Close(CloseData {
1797 account: "Assets:Bank".to_string(),
1798 }),
1799 },
1800 ],
1801 options: PluginOptions {
1802 operating_currencies: vec!["USD".to_string()],
1803 title: None,
1804 },
1805 config: None,
1806 };
1807
1808 let output = plugin.process(input);
1809 assert_eq!(output.errors.len(), 0);
1810
1811 assert_eq!(output.directives.len(), 4);
1813
1814 let balance = output
1816 .directives
1817 .iter()
1818 .find(|d| d.directive_type == "balance")
1819 .expect("Should have balance directive");
1820
1821 assert_eq!(balance.date, "2025-01-01"); if let DirectiveData::Balance(b) = &balance.data {
1823 assert_eq!(b.account, "Assets:Bank");
1824 assert_eq!(b.amount.number, "0");
1825 assert_eq!(b.amount.currency, "USD");
1826 } else {
1827 panic!("Expected Balance directive");
1828 }
1829 }
1830
1831 #[test]
1832 fn test_check_drained_ignores_income_expense() {
1833 let plugin = CheckDrainedPlugin;
1834
1835 let input = PluginInput {
1836 directives: vec![
1837 DirectiveWrapper {
1838 directive_type: "open".to_string(),
1839 date: "2024-01-01".to_string(),
1840 data: DirectiveData::Open(OpenData {
1841 account: "Income:Salary".to_string(),
1842 currencies: vec!["USD".to_string()],
1843 booking: None,
1844 }),
1845 },
1846 DirectiveWrapper {
1847 directive_type: "close".to_string(),
1848 date: "2024-12-31".to_string(),
1849 data: DirectiveData::Close(CloseData {
1850 account: "Income:Salary".to_string(),
1851 }),
1852 },
1853 ],
1854 options: PluginOptions {
1855 operating_currencies: vec!["USD".to_string()],
1856 title: None,
1857 },
1858 config: None,
1859 };
1860
1861 let output = plugin.process(input);
1862 assert_eq!(output.directives.len(), 2);
1864 assert!(
1865 !output
1866 .directives
1867 .iter()
1868 .any(|d| d.directive_type == "balance")
1869 );
1870 }
1871
1872 #[test]
1873 fn test_check_drained_multiple_currencies() {
1874 let plugin = CheckDrainedPlugin;
1875
1876 let input = PluginInput {
1877 directives: vec![
1878 DirectiveWrapper {
1879 directive_type: "open".to_string(),
1880 date: "2024-01-01".to_string(),
1881 data: DirectiveData::Open(OpenData {
1882 account: "Assets:Bank".to_string(),
1883 currencies: vec![],
1884 booking: None,
1885 }),
1886 },
1887 DirectiveWrapper {
1888 directive_type: "transaction".to_string(),
1889 date: "2024-06-15".to_string(),
1890 data: DirectiveData::Transaction(TransactionData {
1891 flag: "*".to_string(),
1892 payee: None,
1893 narration: "USD Deposit".to_string(),
1894 tags: vec![],
1895 links: vec![],
1896 metadata: vec![],
1897 postings: vec![PostingData {
1898 account: "Assets:Bank".to_string(),
1899 units: Some(AmountData {
1900 number: "100".to_string(),
1901 currency: "USD".to_string(),
1902 }),
1903 cost: None,
1904 price: None,
1905 flag: None,
1906 metadata: vec![],
1907 }],
1908 }),
1909 },
1910 DirectiveWrapper {
1911 directive_type: "transaction".to_string(),
1912 date: "2024-07-15".to_string(),
1913 data: DirectiveData::Transaction(TransactionData {
1914 flag: "*".to_string(),
1915 payee: None,
1916 narration: "EUR Deposit".to_string(),
1917 tags: vec![],
1918 links: vec![],
1919 metadata: vec![],
1920 postings: vec![PostingData {
1921 account: "Assets:Bank".to_string(),
1922 units: Some(AmountData {
1923 number: "50".to_string(),
1924 currency: "EUR".to_string(),
1925 }),
1926 cost: None,
1927 price: None,
1928 flag: None,
1929 metadata: vec![],
1930 }],
1931 }),
1932 },
1933 DirectiveWrapper {
1934 directive_type: "close".to_string(),
1935 date: "2024-12-31".to_string(),
1936 data: DirectiveData::Close(CloseData {
1937 account: "Assets:Bank".to_string(),
1938 }),
1939 },
1940 ],
1941 options: PluginOptions {
1942 operating_currencies: vec!["USD".to_string()],
1943 title: None,
1944 },
1945 config: None,
1946 };
1947
1948 let output = plugin.process(input);
1949 assert_eq!(output.directives.len(), 6);
1951
1952 let balances: Vec<_> = output
1953 .directives
1954 .iter()
1955 .filter(|d| d.directive_type == "balance")
1956 .collect();
1957 assert_eq!(balances.len(), 2);
1958
1959 for b in &balances {
1961 assert_eq!(b.date, "2025-01-01");
1962 }
1963 }
1964}
1965
1966pub struct CommodityAttrPlugin {
1973 required_attrs: Vec<(String, Option<Vec<String>>)>,
1975}
1976
1977impl CommodityAttrPlugin {
1978 pub const fn new() -> Self {
1980 Self {
1981 required_attrs: Vec::new(),
1982 }
1983 }
1984
1985 pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
1987 Self {
1988 required_attrs: attrs,
1989 }
1990 }
1991
1992 fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
1996 let mut result = Vec::new();
1997
1998 let trimmed = config.trim();
2001 let content = if trimmed.starts_with('{') && trimmed.ends_with('}') {
2002 &trimmed[1..trimmed.len() - 1]
2003 } else {
2004 trimmed
2005 };
2006
2007 let mut depth = 0;
2009 let mut current = String::new();
2010 let mut entries = Vec::new();
2011
2012 for c in content.chars() {
2013 match c {
2014 '[' => {
2015 depth += 1;
2016 current.push(c);
2017 }
2018 ']' => {
2019 depth -= 1;
2020 current.push(c);
2021 }
2022 ',' if depth == 0 => {
2023 entries.push(current.trim().to_string());
2024 current.clear();
2025 }
2026 _ => current.push(c),
2027 }
2028 }
2029 if !current.trim().is_empty() {
2030 entries.push(current.trim().to_string());
2031 }
2032
2033 for entry in entries {
2035 if let Some((key_part, value_part)) = entry.split_once(':') {
2036 let key = key_part
2037 .trim()
2038 .trim_matches('\'')
2039 .trim_matches('"')
2040 .to_string();
2041 let value = value_part.trim();
2042
2043 if value == "null" || value == "None" {
2044 result.push((key, None));
2045 } else if value.starts_with('[') && value.ends_with(']') {
2046 let inner = &value[1..value.len() - 1];
2048 let allowed: Vec<String> = inner
2049 .split(',')
2050 .map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string())
2051 .filter(|s| !s.is_empty())
2052 .collect();
2053 result.push((key, Some(allowed)));
2054 }
2055 }
2056 }
2057
2058 result
2059 }
2060}
2061
2062impl Default for CommodityAttrPlugin {
2063 fn default() -> Self {
2064 Self::new()
2065 }
2066}
2067
2068impl NativePlugin for CommodityAttrPlugin {
2069 fn name(&self) -> &'static str {
2070 "commodity_attr"
2071 }
2072
2073 fn description(&self) -> &'static str {
2074 "Validate commodity metadata attributes"
2075 }
2076
2077 fn process(&self, input: PluginInput) -> PluginOutput {
2078 let required = if let Some(config) = &input.config {
2080 Self::parse_config(config)
2081 } else {
2082 self.required_attrs.clone()
2083 };
2084
2085 if required.is_empty() {
2087 return PluginOutput {
2088 directives: input.directives,
2089 errors: Vec::new(),
2090 };
2091 }
2092
2093 let mut errors = Vec::new();
2094
2095 for wrapper in &input.directives {
2096 if let DirectiveData::Commodity(comm) = &wrapper.data {
2097 for (attr_name, allowed_values) in &required {
2099 let found = comm.metadata.iter().find(|(k, _)| k == attr_name);
2101
2102 match found {
2103 None => {
2104 errors.push(PluginError::error(format!(
2105 "Commodity '{}' missing required attribute '{}'",
2106 comm.currency, attr_name
2107 )));
2108 }
2109 Some((_, value)) => {
2110 if let Some(allowed) = allowed_values {
2112 let value_str = match value {
2113 crate::types::MetaValueData::String(s) => s.clone(),
2114 other => format!("{other:?}"),
2115 };
2116 if !allowed.contains(&value_str) {
2117 errors.push(PluginError::error(format!(
2118 "Commodity '{}' attribute '{}' has invalid value '{}' (allowed: {:?})",
2119 comm.currency, attr_name, value_str, allowed
2120 )));
2121 }
2122 }
2123 }
2124 }
2125 }
2126 }
2127 }
2128
2129 PluginOutput {
2130 directives: input.directives,
2131 errors,
2132 }
2133 }
2134}
2135
2136#[cfg(test)]
2137mod commodity_attr_tests {
2138 use super::*;
2139 use crate::types::*;
2140
2141 #[test]
2142 fn test_commodity_attr_missing_required() {
2143 let plugin = CommodityAttrPlugin::new();
2144
2145 let input = PluginInput {
2146 directives: vec![DirectiveWrapper {
2147 directive_type: "commodity".to_string(),
2148 date: "2024-01-01".to_string(),
2149 data: DirectiveData::Commodity(CommodityData {
2150 currency: "AAPL".to_string(),
2151 metadata: vec![], }),
2153 }],
2154 options: PluginOptions {
2155 operating_currencies: vec!["USD".to_string()],
2156 title: None,
2157 },
2158 config: Some("{'name': null}".to_string()),
2159 };
2160
2161 let output = plugin.process(input);
2162 assert_eq!(output.errors.len(), 1);
2163 assert!(output.errors[0].message.contains("missing required"));
2164 assert!(output.errors[0].message.contains("name"));
2165 }
2166
2167 #[test]
2168 fn test_commodity_attr_has_required() {
2169 let plugin = CommodityAttrPlugin::new();
2170
2171 let input = PluginInput {
2172 directives: vec![DirectiveWrapper {
2173 directive_type: "commodity".to_string(),
2174 date: "2024-01-01".to_string(),
2175 data: DirectiveData::Commodity(CommodityData {
2176 currency: "AAPL".to_string(),
2177 metadata: vec![(
2178 "name".to_string(),
2179 MetaValueData::String("Apple Inc".to_string()),
2180 )],
2181 }),
2182 }],
2183 options: PluginOptions {
2184 operating_currencies: vec!["USD".to_string()],
2185 title: None,
2186 },
2187 config: Some("{'name': null}".to_string()),
2188 };
2189
2190 let output = plugin.process(input);
2191 assert_eq!(output.errors.len(), 0);
2192 }
2193
2194 #[test]
2195 fn test_commodity_attr_invalid_value() {
2196 let plugin = CommodityAttrPlugin::new();
2197
2198 let input = PluginInput {
2199 directives: vec![DirectiveWrapper {
2200 directive_type: "commodity".to_string(),
2201 date: "2024-01-01".to_string(),
2202 data: DirectiveData::Commodity(CommodityData {
2203 currency: "AAPL".to_string(),
2204 metadata: vec![(
2205 "sector".to_string(),
2206 MetaValueData::String("Healthcare".to_string()),
2207 )],
2208 }),
2209 }],
2210 options: PluginOptions {
2211 operating_currencies: vec!["USD".to_string()],
2212 title: None,
2213 },
2214 config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
2215 };
2216
2217 let output = plugin.process(input);
2218 assert_eq!(output.errors.len(), 1);
2219 assert!(output.errors[0].message.contains("invalid value"));
2220 assert!(output.errors[0].message.contains("Healthcare"));
2221 }
2222
2223 #[test]
2224 fn test_commodity_attr_valid_value() {
2225 let plugin = CommodityAttrPlugin::new();
2226
2227 let input = PluginInput {
2228 directives: vec![DirectiveWrapper {
2229 directive_type: "commodity".to_string(),
2230 date: "2024-01-01".to_string(),
2231 data: DirectiveData::Commodity(CommodityData {
2232 currency: "AAPL".to_string(),
2233 metadata: vec![(
2234 "sector".to_string(),
2235 MetaValueData::String("Tech".to_string()),
2236 )],
2237 }),
2238 }],
2239 options: PluginOptions {
2240 operating_currencies: vec!["USD".to_string()],
2241 title: None,
2242 },
2243 config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
2244 };
2245
2246 let output = plugin.process(input);
2247 assert_eq!(output.errors.len(), 0);
2248 }
2249
2250 #[test]
2251 fn test_config_parsing() {
2252 let config = "{'name': null, 'sector': ['Tech', 'Finance']}";
2253 let parsed = CommodityAttrPlugin::parse_config(config);
2254
2255 assert_eq!(parsed.len(), 2);
2256 assert_eq!(parsed[0].0, "name");
2257 assert!(parsed[0].1.is_none());
2258 assert_eq!(parsed[1].0, "sector");
2259 assert_eq!(parsed[1].1.as_ref().unwrap(), &vec!["Tech", "Finance"]);
2260 }
2261}
2262
2263pub struct CheckAverageCostPlugin {
2269 tolerance: rust_decimal::Decimal,
2271}
2272
2273impl CheckAverageCostPlugin {
2274 pub fn new() -> Self {
2276 Self {
2277 tolerance: rust_decimal::Decimal::new(1, 2), }
2279 }
2280
2281 pub const fn with_tolerance(tolerance: rust_decimal::Decimal) -> Self {
2283 Self { tolerance }
2284 }
2285}
2286
2287impl Default for CheckAverageCostPlugin {
2288 fn default() -> Self {
2289 Self::new()
2290 }
2291}
2292
2293impl NativePlugin for CheckAverageCostPlugin {
2294 fn name(&self) -> &'static str {
2295 "check_average_cost"
2296 }
2297
2298 fn description(&self) -> &'static str {
2299 "Validate reducing postings match average cost"
2300 }
2301
2302 fn process(&self, input: PluginInput) -> PluginOutput {
2303 use rust_decimal::Decimal;
2304 use std::collections::HashMap;
2305 use std::str::FromStr;
2306
2307 let tolerance = if let Some(config) = &input.config {
2309 Decimal::from_str(config.trim()).unwrap_or(self.tolerance)
2310 } else {
2311 self.tolerance
2312 };
2313
2314 let mut inventory: HashMap<(String, String), (Decimal, Decimal)> = HashMap::new();
2317
2318 let mut errors = Vec::new();
2319
2320 for wrapper in &input.directives {
2321 if let DirectiveData::Transaction(txn) = &wrapper.data {
2322 for posting in &txn.postings {
2323 let Some(units) = &posting.units else {
2325 continue;
2326 };
2327 let Some(cost) = &posting.cost else {
2328 continue;
2329 };
2330
2331 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
2332 let Some(cost_currency) = &cost.currency else {
2333 continue;
2334 };
2335
2336 let key = (posting.account.clone(), units.currency.clone());
2337
2338 if units_num > Decimal::ZERO {
2339 let cost_per = cost
2341 .number_per
2342 .as_ref()
2343 .and_then(|s| Decimal::from_str(s).ok())
2344 .unwrap_or_default();
2345
2346 let entry = inventory
2347 .entry(key)
2348 .or_insert((Decimal::ZERO, Decimal::ZERO));
2349 entry.0 += units_num; entry.1 += units_num * cost_per; } else if units_num < Decimal::ZERO {
2352 let entry = inventory.get(&key);
2354
2355 if let Some((total_units, total_cost)) = entry {
2356 if *total_units > Decimal::ZERO {
2357 let avg_cost = *total_cost / *total_units;
2358
2359 let used_cost = cost
2361 .number_per
2362 .as_ref()
2363 .and_then(|s| Decimal::from_str(s).ok())
2364 .unwrap_or_default();
2365
2366 let diff = (used_cost - avg_cost).abs();
2368 let relative_diff = if avg_cost == Decimal::ZERO {
2369 diff
2370 } else {
2371 diff / avg_cost
2372 };
2373
2374 if relative_diff > tolerance {
2375 errors.push(PluginError::warning(format!(
2376 "Sale of {} {} in {} uses cost {} {} but average cost is {} {} (difference: {:.2}%)",
2377 units_num.abs(),
2378 units.currency,
2379 posting.account,
2380 used_cost,
2381 cost_currency,
2382 avg_cost.round_dp(4),
2383 cost_currency,
2384 relative_diff * Decimal::from(100)
2385 )));
2386 }
2387
2388 let entry = inventory.get_mut(&key).unwrap();
2390 let units_sold = units_num.abs();
2391 let cost_removed = units_sold * avg_cost;
2392 entry.0 -= units_sold;
2393 entry.1 -= cost_removed;
2394 }
2395 }
2396 }
2397 }
2398 }
2399 }
2400
2401 PluginOutput {
2402 directives: input.directives,
2403 errors,
2404 }
2405 }
2406}
2407
2408#[cfg(test)]
2409mod check_average_cost_tests {
2410 use super::*;
2411 use crate::types::*;
2412
2413 #[test]
2414 fn test_check_average_cost_matching() {
2415 let plugin = CheckAverageCostPlugin::new();
2416
2417 let input = PluginInput {
2418 directives: vec![
2419 DirectiveWrapper {
2420 directive_type: "transaction".to_string(),
2421 date: "2024-01-01".to_string(),
2422 data: DirectiveData::Transaction(TransactionData {
2423 flag: "*".to_string(),
2424 payee: None,
2425 narration: "Buy".to_string(),
2426 tags: vec![],
2427 links: vec![],
2428 metadata: vec![],
2429 postings: vec![PostingData {
2430 account: "Assets:Broker".to_string(),
2431 units: Some(AmountData {
2432 number: "10".to_string(),
2433 currency: "AAPL".to_string(),
2434 }),
2435 cost: Some(CostData {
2436 number_per: Some("100.00".to_string()),
2437 number_total: None,
2438 currency: Some("USD".to_string()),
2439 date: None,
2440 label: None,
2441 merge: false,
2442 }),
2443 price: None,
2444 flag: None,
2445 metadata: vec![],
2446 }],
2447 }),
2448 },
2449 DirectiveWrapper {
2450 directive_type: "transaction".to_string(),
2451 date: "2024-02-01".to_string(),
2452 data: DirectiveData::Transaction(TransactionData {
2453 flag: "*".to_string(),
2454 payee: None,
2455 narration: "Sell at avg cost".to_string(),
2456 tags: vec![],
2457 links: vec![],
2458 metadata: vec![],
2459 postings: vec![PostingData {
2460 account: "Assets:Broker".to_string(),
2461 units: Some(AmountData {
2462 number: "-5".to_string(),
2463 currency: "AAPL".to_string(),
2464 }),
2465 cost: Some(CostData {
2466 number_per: Some("100.00".to_string()), number_total: None,
2468 currency: Some("USD".to_string()),
2469 date: None,
2470 label: None,
2471 merge: false,
2472 }),
2473 price: None,
2474 flag: None,
2475 metadata: vec![],
2476 }],
2477 }),
2478 },
2479 ],
2480 options: PluginOptions {
2481 operating_currencies: vec!["USD".to_string()],
2482 title: None,
2483 },
2484 config: None,
2485 };
2486
2487 let output = plugin.process(input);
2488 assert_eq!(output.errors.len(), 0);
2489 }
2490
2491 #[test]
2492 fn test_check_average_cost_mismatch() {
2493 let plugin = CheckAverageCostPlugin::new();
2494
2495 let input = PluginInput {
2496 directives: vec![
2497 DirectiveWrapper {
2498 directive_type: "transaction".to_string(),
2499 date: "2024-01-01".to_string(),
2500 data: DirectiveData::Transaction(TransactionData {
2501 flag: "*".to_string(),
2502 payee: None,
2503 narration: "Buy at 100".to_string(),
2504 tags: vec![],
2505 links: vec![],
2506 metadata: vec![],
2507 postings: vec![PostingData {
2508 account: "Assets:Broker".to_string(),
2509 units: Some(AmountData {
2510 number: "10".to_string(),
2511 currency: "AAPL".to_string(),
2512 }),
2513 cost: Some(CostData {
2514 number_per: Some("100.00".to_string()),
2515 number_total: None,
2516 currency: Some("USD".to_string()),
2517 date: None,
2518 label: None,
2519 merge: false,
2520 }),
2521 price: None,
2522 flag: None,
2523 metadata: vec![],
2524 }],
2525 }),
2526 },
2527 DirectiveWrapper {
2528 directive_type: "transaction".to_string(),
2529 date: "2024-02-01".to_string(),
2530 data: DirectiveData::Transaction(TransactionData {
2531 flag: "*".to_string(),
2532 payee: None,
2533 narration: "Sell at wrong cost".to_string(),
2534 tags: vec![],
2535 links: vec![],
2536 metadata: vec![],
2537 postings: vec![PostingData {
2538 account: "Assets:Broker".to_string(),
2539 units: Some(AmountData {
2540 number: "-5".to_string(),
2541 currency: "AAPL".to_string(),
2542 }),
2543 cost: Some(CostData {
2544 number_per: Some("90.00".to_string()), number_total: None,
2546 currency: Some("USD".to_string()),
2547 date: None,
2548 label: None,
2549 merge: false,
2550 }),
2551 price: None,
2552 flag: None,
2553 metadata: vec![],
2554 }],
2555 }),
2556 },
2557 ],
2558 options: PluginOptions {
2559 operating_currencies: vec!["USD".to_string()],
2560 title: None,
2561 },
2562 config: None,
2563 };
2564
2565 let output = plugin.process(input);
2566 assert_eq!(output.errors.len(), 1);
2567 assert!(output.errors[0].message.contains("average cost"));
2568 }
2569
2570 #[test]
2571 fn test_check_average_cost_multiple_buys() {
2572 let plugin = CheckAverageCostPlugin::new();
2573
2574 let input = PluginInput {
2576 directives: vec![
2577 DirectiveWrapper {
2578 directive_type: "transaction".to_string(),
2579 date: "2024-01-01".to_string(),
2580 data: DirectiveData::Transaction(TransactionData {
2581 flag: "*".to_string(),
2582 payee: None,
2583 narration: "Buy at 100".to_string(),
2584 tags: vec![],
2585 links: vec![],
2586 metadata: vec![],
2587 postings: vec![PostingData {
2588 account: "Assets:Broker".to_string(),
2589 units: Some(AmountData {
2590 number: "10".to_string(),
2591 currency: "AAPL".to_string(),
2592 }),
2593 cost: Some(CostData {
2594 number_per: Some("100.00".to_string()),
2595 number_total: None,
2596 currency: Some("USD".to_string()),
2597 date: None,
2598 label: None,
2599 merge: false,
2600 }),
2601 price: None,
2602 flag: None,
2603 metadata: vec![],
2604 }],
2605 }),
2606 },
2607 DirectiveWrapper {
2608 directive_type: "transaction".to_string(),
2609 date: "2024-01-15".to_string(),
2610 data: DirectiveData::Transaction(TransactionData {
2611 flag: "*".to_string(),
2612 payee: None,
2613 narration: "Buy at 120".to_string(),
2614 tags: vec![],
2615 links: vec![],
2616 metadata: vec![],
2617 postings: vec![PostingData {
2618 account: "Assets:Broker".to_string(),
2619 units: Some(AmountData {
2620 number: "10".to_string(),
2621 currency: "AAPL".to_string(),
2622 }),
2623 cost: Some(CostData {
2624 number_per: Some("120.00".to_string()),
2625 number_total: None,
2626 currency: Some("USD".to_string()),
2627 date: None,
2628 label: None,
2629 merge: false,
2630 }),
2631 price: None,
2632 flag: None,
2633 metadata: vec![],
2634 }],
2635 }),
2636 },
2637 DirectiveWrapper {
2638 directive_type: "transaction".to_string(),
2639 date: "2024-02-01".to_string(),
2640 data: DirectiveData::Transaction(TransactionData {
2641 flag: "*".to_string(),
2642 payee: None,
2643 narration: "Sell at avg cost".to_string(),
2644 tags: vec![],
2645 links: vec![],
2646 metadata: vec![],
2647 postings: vec![PostingData {
2648 account: "Assets:Broker".to_string(),
2649 units: Some(AmountData {
2650 number: "-5".to_string(),
2651 currency: "AAPL".to_string(),
2652 }),
2653 cost: Some(CostData {
2654 number_per: Some("110.00".to_string()), number_total: None,
2656 currency: Some("USD".to_string()),
2657 date: None,
2658 label: None,
2659 merge: false,
2660 }),
2661 price: None,
2662 flag: None,
2663 metadata: vec![],
2664 }],
2665 }),
2666 },
2667 ],
2668 options: PluginOptions {
2669 operating_currencies: vec!["USD".to_string()],
2670 title: None,
2671 },
2672 config: None,
2673 };
2674
2675 let output = plugin.process(input);
2676 assert_eq!(output.errors.len(), 0);
2677 }
2678}
2679
2680pub struct CurrencyAccountsPlugin {
2687 base_account: String,
2689}
2690
2691impl CurrencyAccountsPlugin {
2692 pub fn new() -> Self {
2694 Self {
2695 base_account: "Equity:CurrencyAccounts".to_string(),
2696 }
2697 }
2698
2699 pub const fn with_base_account(base_account: String) -> Self {
2701 Self { base_account }
2702 }
2703}
2704
2705impl Default for CurrencyAccountsPlugin {
2706 fn default() -> Self {
2707 Self::new()
2708 }
2709}
2710
2711impl NativePlugin for CurrencyAccountsPlugin {
2712 fn name(&self) -> &'static str {
2713 "currency_accounts"
2714 }
2715
2716 fn description(&self) -> &'static str {
2717 "Auto-generate currency trading postings"
2718 }
2719
2720 fn process(&self, input: PluginInput) -> PluginOutput {
2721 use crate::types::{AmountData, PostingData};
2722 use rust_decimal::Decimal;
2723 use std::collections::HashMap;
2724 use std::str::FromStr;
2725
2726 let base_account = input
2728 .config
2729 .as_ref()
2730 .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
2731
2732 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
2733
2734 for wrapper in &input.directives {
2735 if let DirectiveData::Transaction(txn) = &wrapper.data {
2736 let mut currency_totals: HashMap<String, Decimal> = HashMap::new();
2739
2740 for posting in &txn.postings {
2741 if let Some(units) = &posting.units {
2742 let amount = Decimal::from_str(&units.number).unwrap_or_default();
2743 *currency_totals.entry(units.currency.clone()).or_default() += amount;
2744 }
2745 }
2746
2747 let non_zero_currencies: Vec<_> = currency_totals
2749 .iter()
2750 .filter(|&(_, total)| *total != Decimal::ZERO)
2751 .collect();
2752
2753 if non_zero_currencies.len() > 1 {
2754 let mut modified_txn = txn.clone();
2756
2757 for &(currency, total) in &non_zero_currencies {
2758 modified_txn.postings.push(PostingData {
2760 account: format!("{base_account}:{currency}"),
2761 units: Some(AmountData {
2762 number: (-*total).to_string(),
2763 currency: (*currency).clone(),
2764 }),
2765 cost: None,
2766 price: None,
2767 flag: None,
2768 metadata: vec![],
2769 });
2770 }
2771
2772 new_directives.push(DirectiveWrapper {
2773 directive_type: wrapper.directive_type.clone(),
2774 date: wrapper.date.clone(),
2775 data: DirectiveData::Transaction(modified_txn),
2776 });
2777 } else {
2778 new_directives.push(wrapper.clone());
2780 }
2781 } else {
2782 new_directives.push(wrapper.clone());
2783 }
2784 }
2785
2786 PluginOutput {
2787 directives: new_directives,
2788 errors: Vec::new(),
2789 }
2790 }
2791}
2792
2793#[cfg(test)]
2794mod currency_accounts_tests {
2795 use super::*;
2796 use crate::types::*;
2797
2798 #[test]
2799 fn test_currency_accounts_adds_balancing_postings() {
2800 let plugin = CurrencyAccountsPlugin::new();
2801
2802 let input = PluginInput {
2803 directives: vec![DirectiveWrapper {
2804 directive_type: "transaction".to_string(),
2805 date: "2024-01-15".to_string(),
2806 data: DirectiveData::Transaction(TransactionData {
2807 flag: "*".to_string(),
2808 payee: None,
2809 narration: "Currency exchange".to_string(),
2810 tags: vec![],
2811 links: vec![],
2812 metadata: vec![],
2813 postings: vec![
2814 PostingData {
2815 account: "Assets:Bank:USD".to_string(),
2816 units: Some(AmountData {
2817 number: "-100".to_string(),
2818 currency: "USD".to_string(),
2819 }),
2820 cost: None,
2821 price: None,
2822 flag: None,
2823 metadata: vec![],
2824 },
2825 PostingData {
2826 account: "Assets:Bank:EUR".to_string(),
2827 units: Some(AmountData {
2828 number: "85".to_string(),
2829 currency: "EUR".to_string(),
2830 }),
2831 cost: None,
2832 price: None,
2833 flag: None,
2834 metadata: vec![],
2835 },
2836 ],
2837 }),
2838 }],
2839 options: PluginOptions {
2840 operating_currencies: vec!["USD".to_string()],
2841 title: None,
2842 },
2843 config: None,
2844 };
2845
2846 let output = plugin.process(input);
2847 assert_eq!(output.errors.len(), 0);
2848 assert_eq!(output.directives.len(), 1);
2849
2850 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2851 assert_eq!(txn.postings.len(), 4);
2853
2854 let usd_posting = txn
2856 .postings
2857 .iter()
2858 .find(|p| p.account == "Equity:CurrencyAccounts:USD");
2859 assert!(usd_posting.is_some());
2860 let usd_posting = usd_posting.unwrap();
2861 assert_eq!(usd_posting.units.as_ref().unwrap().number, "100");
2863
2864 let eur_posting = txn
2865 .postings
2866 .iter()
2867 .find(|p| p.account == "Equity:CurrencyAccounts:EUR");
2868 assert!(eur_posting.is_some());
2869 let eur_posting = eur_posting.unwrap();
2870 assert_eq!(eur_posting.units.as_ref().unwrap().number, "-85");
2872 } else {
2873 panic!("Expected Transaction directive");
2874 }
2875 }
2876
2877 #[test]
2878 fn test_currency_accounts_single_currency_unchanged() {
2879 let plugin = CurrencyAccountsPlugin::new();
2880
2881 let input = PluginInput {
2882 directives: vec![DirectiveWrapper {
2883 directive_type: "transaction".to_string(),
2884 date: "2024-01-15".to_string(),
2885 data: DirectiveData::Transaction(TransactionData {
2886 flag: "*".to_string(),
2887 payee: None,
2888 narration: "Simple transfer".to_string(),
2889 tags: vec![],
2890 links: vec![],
2891 metadata: vec![],
2892 postings: vec![
2893 PostingData {
2894 account: "Assets:Bank".to_string(),
2895 units: Some(AmountData {
2896 number: "-100".to_string(),
2897 currency: "USD".to_string(),
2898 }),
2899 cost: None,
2900 price: None,
2901 flag: None,
2902 metadata: vec![],
2903 },
2904 PostingData {
2905 account: "Expenses:Food".to_string(),
2906 units: Some(AmountData {
2907 number: "100".to_string(),
2908 currency: "USD".to_string(),
2909 }),
2910 cost: None,
2911 price: None,
2912 flag: None,
2913 metadata: vec![],
2914 },
2915 ],
2916 }),
2917 }],
2918 options: PluginOptions {
2919 operating_currencies: vec!["USD".to_string()],
2920 title: None,
2921 },
2922 config: None,
2923 };
2924
2925 let output = plugin.process(input);
2926 assert_eq!(output.errors.len(), 0);
2927
2928 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2930 assert_eq!(txn.postings.len(), 2);
2931 }
2932 }
2933
2934 #[test]
2935 fn test_currency_accounts_custom_base_account() {
2936 let plugin = CurrencyAccountsPlugin::new();
2937
2938 let input = PluginInput {
2939 directives: vec![DirectiveWrapper {
2940 directive_type: "transaction".to_string(),
2941 date: "2024-01-15".to_string(),
2942 data: DirectiveData::Transaction(TransactionData {
2943 flag: "*".to_string(),
2944 payee: None,
2945 narration: "Exchange".to_string(),
2946 tags: vec![],
2947 links: vec![],
2948 metadata: vec![],
2949 postings: vec![
2950 PostingData {
2951 account: "Assets:USD".to_string(),
2952 units: Some(AmountData {
2953 number: "-50".to_string(),
2954 currency: "USD".to_string(),
2955 }),
2956 cost: None,
2957 price: None,
2958 flag: None,
2959 metadata: vec![],
2960 },
2961 PostingData {
2962 account: "Assets:EUR".to_string(),
2963 units: Some(AmountData {
2964 number: "42".to_string(),
2965 currency: "EUR".to_string(),
2966 }),
2967 cost: None,
2968 price: None,
2969 flag: None,
2970 metadata: vec![],
2971 },
2972 ],
2973 }),
2974 }],
2975 options: PluginOptions {
2976 operating_currencies: vec!["USD".to_string()],
2977 title: None,
2978 },
2979 config: Some("Income:Trading".to_string()),
2980 };
2981
2982 let output = plugin.process(input);
2983 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2984 assert!(
2986 txn.postings
2987 .iter()
2988 .any(|p| p.account.starts_with("Income:Trading:"))
2989 );
2990 }
2991 }
2992}