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 {
452 match &wrapper.data {
453 DirectiveData::Open(data) => {
454 opened_accounts.insert(data.account.clone());
455 }
456 DirectiveData::Transaction(txn) => {
457 for posting in &txn.postings {
458 account_first_use
459 .entry(posting.account.clone())
460 .or_insert_with(|| wrapper.date.clone());
461 }
462 }
463 DirectiveData::Balance(data) => {
464 account_first_use
465 .entry(data.account.clone())
466 .or_insert_with(|| wrapper.date.clone());
467 }
468 DirectiveData::Pad(data) => {
469 account_first_use
470 .entry(data.account.clone())
471 .or_insert_with(|| wrapper.date.clone());
472 account_first_use
473 .entry(data.source_account.clone())
474 .or_insert_with(|| wrapper.date.clone());
475 }
476 _ => {}
477 }
478 }
479
480 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
482 for (account, date) in &account_first_use {
483 if !opened_accounts.contains(account) {
484 new_directives.push(DirectiveWrapper {
485 directive_type: "open".to_string(),
486 date: date.clone(),
487 data: DirectiveData::Open(OpenData {
488 account: account.clone(),
489 currencies: vec![],
490 booking: None,
491 }),
492 });
493 }
494 }
495
496 new_directives.extend(input.directives);
498
499 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
501
502 PluginOutput {
503 directives: new_directives,
504 errors: Vec::new(),
505 }
506 }
507}
508
509pub struct LeafOnlyPlugin;
511
512impl NativePlugin for LeafOnlyPlugin {
513 fn name(&self) -> &'static str {
514 "leafonly"
515 }
516
517 fn description(&self) -> &'static str {
518 "Error on postings to non-leaf accounts"
519 }
520
521 fn process(&self, input: PluginInput) -> PluginOutput {
522 use std::collections::HashSet;
523
524 let mut all_accounts: HashSet<String> = HashSet::new();
526 for wrapper in &input.directives {
527 if let DirectiveData::Transaction(txn) = &wrapper.data {
528 for posting in &txn.postings {
529 all_accounts.insert(posting.account.clone());
530 }
531 }
532 }
533
534 let parent_accounts: HashSet<&String> = all_accounts
536 .iter()
537 .filter(|acc| {
538 all_accounts
539 .iter()
540 .any(|other| other != *acc && other.starts_with(&format!("{acc}:")))
541 })
542 .collect();
543
544 let mut errors = Vec::new();
546 for wrapper in &input.directives {
547 if let DirectiveData::Transaction(txn) = &wrapper.data {
548 for posting in &txn.postings {
549 if parent_accounts.contains(&posting.account) {
550 errors.push(PluginError::error(format!(
551 "Posting to non-leaf account '{}' - has child accounts",
552 posting.account
553 )));
554 }
555 }
556 }
557 }
558
559 PluginOutput {
560 directives: input.directives,
561 errors,
562 }
563 }
564}
565
566pub struct NoDuplicatesPlugin;
568
569impl NativePlugin for NoDuplicatesPlugin {
570 fn name(&self) -> &'static str {
571 "noduplicates"
572 }
573
574 fn description(&self) -> &'static str {
575 "Hash-based duplicate transaction detection"
576 }
577
578 fn process(&self, input: PluginInput) -> PluginOutput {
579 use std::collections::HashSet;
580 use std::collections::hash_map::DefaultHasher;
581 use std::hash::{Hash, Hasher};
582
583 fn hash_transaction(date: &str, txn: &TransactionData) -> u64 {
584 let mut hasher = DefaultHasher::new();
585 date.hash(&mut hasher);
586 txn.narration.hash(&mut hasher);
587 txn.payee.hash(&mut hasher);
588 for posting in &txn.postings {
589 posting.account.hash(&mut hasher);
590 if let Some(units) = &posting.units {
591 units.number.hash(&mut hasher);
592 units.currency.hash(&mut hasher);
593 }
594 }
595 hasher.finish()
596 }
597
598 let mut seen: HashSet<u64> = HashSet::new();
599 let mut errors = Vec::new();
600
601 for wrapper in &input.directives {
602 if let DirectiveData::Transaction(txn) = &wrapper.data {
603 let hash = hash_transaction(&wrapper.date, txn);
604 if !seen.insert(hash) {
605 errors.push(PluginError::error(format!(
606 "Duplicate transaction: {} \"{}\"",
607 wrapper.date, txn.narration
608 )));
609 }
610 }
611 }
612
613 PluginOutput {
614 directives: input.directives,
615 errors,
616 }
617 }
618}
619
620pub struct OneCommodityPlugin;
622
623impl NativePlugin for OneCommodityPlugin {
624 fn name(&self) -> &'static str {
625 "onecommodity"
626 }
627
628 fn description(&self) -> &'static str {
629 "Enforce single commodity per account"
630 }
631
632 fn process(&self, input: PluginInput) -> PluginOutput {
633 use std::collections::HashMap;
634
635 let mut account_currencies: HashMap<String, String> = HashMap::new();
637 let mut errors = Vec::new();
638
639 for wrapper in &input.directives {
640 if let DirectiveData::Transaction(txn) = &wrapper.data {
641 for posting in &txn.postings {
642 if let Some(units) = &posting.units {
643 if let Some(existing) = account_currencies.get(&posting.account) {
644 if existing != &units.currency {
645 errors.push(PluginError::error(format!(
646 "Account '{}' uses multiple currencies: {} and {}",
647 posting.account, existing, units.currency
648 )));
649 }
650 } else {
651 account_currencies
652 .insert(posting.account.clone(), units.currency.clone());
653 }
654 }
655 }
656 }
657 }
658
659 PluginOutput {
660 directives: input.directives,
661 errors,
662 }
663 }
664}
665
666pub struct UniquePricesPlugin;
668
669impl NativePlugin for UniquePricesPlugin {
670 fn name(&self) -> &'static str {
671 "unique_prices"
672 }
673
674 fn description(&self) -> &'static str {
675 "One price per day per currency pair"
676 }
677
678 fn process(&self, input: PluginInput) -> PluginOutput {
679 use std::collections::HashSet;
680
681 let mut seen: HashSet<(String, String, String)> = HashSet::new();
683 let mut errors = Vec::new();
684
685 for wrapper in &input.directives {
686 if let DirectiveData::Price(price) = &wrapper.data {
687 let key = (
688 wrapper.date.clone(),
689 price.currency.clone(),
690 price.amount.currency.clone(),
691 );
692 if !seen.insert(key.clone()) {
693 errors.push(PluginError::error(format!(
694 "Duplicate price for {}/{} on {}",
695 price.currency, price.amount.currency, wrapper.date
696 )));
697 }
698 }
699 }
700
701 PluginOutput {
702 directives: input.directives,
703 errors,
704 }
705 }
706}
707
708pub struct DocumentDiscoveryPlugin {
716 pub directories: Vec<String>,
718}
719
720impl DocumentDiscoveryPlugin {
721 pub const fn new(directories: Vec<String>) -> Self {
723 Self { directories }
724 }
725}
726
727impl NativePlugin for DocumentDiscoveryPlugin {
728 fn name(&self) -> &'static str {
729 "document_discovery"
730 }
731
732 fn description(&self) -> &'static str {
733 "Auto-discover documents from directories"
734 }
735
736 fn process(&self, input: PluginInput) -> PluginOutput {
737 use std::path::Path;
738
739 let mut new_directives = Vec::new();
740 let mut errors = Vec::new();
741
742 let mut existing_docs: std::collections::HashSet<String> = std::collections::HashSet::new();
744 for wrapper in &input.directives {
745 if let DirectiveData::Document(doc) = &wrapper.data {
746 existing_docs.insert(doc.path.clone());
747 }
748 }
749
750 for dir in &self.directories {
752 let dir_path = Path::new(dir);
753 if !dir_path.exists() {
754 continue;
755 }
756
757 if let Err(e) = scan_documents(
758 dir_path,
759 dir,
760 &existing_docs,
761 &mut new_directives,
762 &mut errors,
763 ) {
764 errors.push(PluginError::error(format!(
765 "Error scanning documents in {dir}: {e}"
766 )));
767 }
768 }
769
770 let mut all_directives = input.directives;
772 all_directives.extend(new_directives);
773
774 all_directives.sort_by(|a, b| a.date.cmp(&b.date));
776
777 PluginOutput {
778 directives: all_directives,
779 errors,
780 }
781 }
782}
783
784#[allow(clippy::only_used_in_recursion)]
786fn scan_documents(
787 path: &std::path::Path,
788 base_dir: &str,
789 existing: &std::collections::HashSet<String>,
790 directives: &mut Vec<DirectiveWrapper>,
791 errors: &mut Vec<PluginError>,
792) -> std::io::Result<()> {
793 use std::fs;
794
795 for entry in fs::read_dir(path)? {
796 let entry = entry?;
797 let entry_path = entry.path();
798
799 if entry_path.is_dir() {
800 scan_documents(&entry_path, base_dir, existing, directives, errors)?;
801 } else if entry_path.is_file() {
802 if let Some(file_name) = entry_path.file_name().and_then(|n| n.to_str()) {
804 if file_name.len() >= 10
805 && file_name.chars().nth(4) == Some('-')
806 && file_name.chars().nth(7) == Some('-')
807 {
808 let date_str = &file_name[0..10];
809 if date_str.chars().take(4).all(|c| c.is_ascii_digit())
811 && date_str.chars().skip(5).take(2).all(|c| c.is_ascii_digit())
812 && date_str.chars().skip(8).take(2).all(|c| c.is_ascii_digit())
813 {
814 if let Ok(rel_path) = entry_path.strip_prefix(base_dir) {
816 if let Some(parent) = rel_path.parent() {
817 let account = parent
818 .components()
819 .map(|c| c.as_os_str().to_string_lossy().to_string())
820 .collect::<Vec<_>>()
821 .join(":");
822
823 if !account.is_empty() {
824 let full_path = entry_path.to_string_lossy().to_string();
825
826 if existing.contains(&full_path) {
828 continue;
829 }
830
831 directives.push(DirectiveWrapper {
832 directive_type: "document".to_string(),
833 date: date_str.to_string(),
834 data: DirectiveData::Document(DocumentData {
835 account,
836 path: full_path,
837 }),
838 });
839 }
840 }
841 }
842 }
843 }
844 }
845 }
846 }
847
848 Ok(())
849}
850
851pub struct CheckClosingPlugin;
856
857impl NativePlugin for CheckClosingPlugin {
858 fn name(&self) -> &'static str {
859 "check_closing"
860 }
861
862 fn description(&self) -> &'static str {
863 "Zero balance assertion on account closing"
864 }
865
866 fn process(&self, input: PluginInput) -> PluginOutput {
867 use crate::types::{AmountData, BalanceData, MetaValueData};
868
869 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
870
871 for wrapper in &input.directives {
872 new_directives.push(wrapper.clone());
873
874 if let DirectiveData::Transaction(txn) = &wrapper.data {
875 for posting in &txn.postings {
876 let has_closing = posting.metadata.iter().any(|(key, val)| {
878 key == "closing" && matches!(val, MetaValueData::Bool(true))
879 });
880
881 if has_closing {
882 if let Some(next_date) = increment_date(&wrapper.date) {
884 let currency = posting
886 .units
887 .as_ref()
888 .map_or_else(|| "USD".to_string(), |u| u.currency.clone());
889
890 new_directives.push(DirectiveWrapper {
892 directive_type: "balance".to_string(),
893 date: next_date,
894 data: DirectiveData::Balance(BalanceData {
895 account: posting.account.clone(),
896 amount: AmountData {
897 number: "0".to_string(),
898 currency,
899 },
900 tolerance: None,
901 }),
902 });
903 }
904 }
905 }
906 }
907 }
908
909 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
911
912 PluginOutput {
913 directives: new_directives,
914 errors: Vec::new(),
915 }
916 }
917}
918
919fn increment_date(date: &str) -> Option<String> {
921 let parts: Vec<&str> = date.split('-').collect();
922 if parts.len() != 3 {
923 return None;
924 }
925
926 let year: i32 = parts[0].parse().ok()?;
927 let month: u32 = parts[1].parse().ok()?;
928 let day: u32 = parts[2].parse().ok()?;
929
930 let days_in_month = match month {
932 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
933 4 | 6 | 9 | 11 => 30,
934 2 => {
935 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
936 29
937 } else {
938 28
939 }
940 }
941 _ => return None,
942 };
943
944 let (new_year, new_month, new_day) = if day < days_in_month {
945 (year, month, day + 1)
946 } else if month < 12 {
947 (year, month + 1, 1)
948 } else {
949 (year + 1, 1, 1)
950 };
951
952 Some(format!("{new_year:04}-{new_month:02}-{new_day:02}"))
953}
954
955pub struct CloseTreePlugin;
960
961impl NativePlugin for CloseTreePlugin {
962 fn name(&self) -> &'static str {
963 "close_tree"
964 }
965
966 fn description(&self) -> &'static str {
967 "Close descendant accounts automatically"
968 }
969
970 fn process(&self, input: PluginInput) -> PluginOutput {
971 use crate::types::CloseData;
972 use std::collections::HashSet;
973
974 let mut all_accounts: HashSet<String> = HashSet::new();
976 for wrapper in &input.directives {
977 if let DirectiveData::Open(data) = &wrapper.data {
978 all_accounts.insert(data.account.clone());
979 }
980 if let DirectiveData::Transaction(txn) = &wrapper.data {
981 for posting in &txn.postings {
982 all_accounts.insert(posting.account.clone());
983 }
984 }
985 }
986
987 let mut closed_parents: Vec<(String, String)> = Vec::new(); for wrapper in &input.directives {
990 if let DirectiveData::Close(data) = &wrapper.data {
991 closed_parents.push((data.account.clone(), wrapper.date.clone()));
992 }
993 }
994
995 let mut new_directives = input.directives;
997
998 for (parent, close_date) in &closed_parents {
999 let prefix = format!("{parent}:");
1000 for account in &all_accounts {
1001 if account.starts_with(&prefix) {
1002 let already_closed = new_directives.iter().any(|w| {
1004 if let DirectiveData::Close(data) = &w.data {
1005 &data.account == account
1006 } else {
1007 false
1008 }
1009 });
1010
1011 if !already_closed {
1012 new_directives.push(DirectiveWrapper {
1013 directive_type: "close".to_string(),
1014 date: close_date.clone(),
1015 data: DirectiveData::Close(CloseData {
1016 account: account.clone(),
1017 }),
1018 });
1019 }
1020 }
1021 }
1022 }
1023
1024 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
1026
1027 PluginOutput {
1028 directives: new_directives,
1029 errors: Vec::new(),
1030 }
1031 }
1032}
1033
1034pub struct CoherentCostPlugin;
1039
1040impl NativePlugin for CoherentCostPlugin {
1041 fn name(&self) -> &'static str {
1042 "coherent_cost"
1043 }
1044
1045 fn description(&self) -> &'static str {
1046 "Enforce cost OR price (not both) consistency"
1047 }
1048
1049 fn process(&self, input: PluginInput) -> PluginOutput {
1050 use std::collections::{HashMap, HashSet};
1051
1052 let mut currencies_with_cost: HashSet<String> = HashSet::new();
1054 let mut currencies_with_price: HashSet<String> = HashSet::new();
1055 let mut first_use: HashMap<String, (String, String)> = HashMap::new(); for wrapper in &input.directives {
1058 if let DirectiveData::Transaction(txn) = &wrapper.data {
1059 for posting in &txn.postings {
1060 if let Some(units) = &posting.units {
1061 let currency = &units.currency;
1062
1063 if posting.cost.is_some() && !currencies_with_cost.contains(currency) {
1064 currencies_with_cost.insert(currency.clone());
1065 first_use
1066 .entry(currency.clone())
1067 .or_insert(("cost".to_string(), wrapper.date.clone()));
1068 }
1069
1070 if posting.price.is_some() && !currencies_with_price.contains(currency) {
1071 currencies_with_price.insert(currency.clone());
1072 first_use
1073 .entry(currency.clone())
1074 .or_insert(("price".to_string(), wrapper.date.clone()));
1075 }
1076 }
1077 }
1078 }
1079 }
1080
1081 let mut errors = Vec::new();
1083 for currency in currencies_with_cost.intersection(¤cies_with_price) {
1084 errors.push(PluginError::error(format!(
1085 "Currency '{currency}' is used with both cost and price notation - this may cause inconsistencies"
1086 )));
1087 }
1088
1089 PluginOutput {
1090 directives: input.directives,
1091 errors,
1092 }
1093 }
1094}
1095
1096pub struct SellGainsPlugin;
1101
1102impl NativePlugin for SellGainsPlugin {
1103 fn name(&self) -> &'static str {
1104 "sellgains"
1105 }
1106
1107 fn description(&self) -> &'static str {
1108 "Cross-check capital gains against sales"
1109 }
1110
1111 fn process(&self, input: PluginInput) -> PluginOutput {
1112 use rust_decimal::Decimal;
1113 use std::str::FromStr;
1114
1115 let mut errors = Vec::new();
1116
1117 for wrapper in &input.directives {
1118 if let DirectiveData::Transaction(txn) = &wrapper.data {
1119 for posting in &txn.postings {
1121 if let (Some(units), Some(cost), Some(price)) =
1122 (&posting.units, &posting.cost, &posting.price)
1123 {
1124 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
1126 if units_num >= Decimal::ZERO {
1127 continue;
1128 }
1129
1130 let cost_per = cost
1132 .number_per
1133 .as_ref()
1134 .and_then(|s| Decimal::from_str(s).ok())
1135 .unwrap_or_default();
1136
1137 let sale_price = price
1139 .amount
1140 .as_ref()
1141 .and_then(|a| Decimal::from_str(&a.number).ok())
1142 .unwrap_or_default();
1143
1144 let expected_gain = (sale_price - cost_per) * units_num.abs();
1146
1147 let has_gain_posting = txn.postings.iter().any(|p| {
1149 p.account.starts_with("Income:") || p.account.starts_with("Expenses:")
1150 });
1151
1152 if expected_gain != Decimal::ZERO && !has_gain_posting {
1153 errors.push(PluginError::warning(format!(
1154 "Sale of {} {} at {} (cost {}) has expected gain/loss of {} but no Income/Expenses posting",
1155 units_num.abs(),
1156 units.currency,
1157 sale_price,
1158 cost_per,
1159 expected_gain
1160 )));
1161 }
1162 }
1163 }
1164 }
1165 }
1166
1167 PluginOutput {
1168 directives: input.directives,
1169 errors,
1170 }
1171 }
1172}
1173
1174pub struct PedanticPlugin;
1182
1183impl NativePlugin for PedanticPlugin {
1184 fn name(&self) -> &'static str {
1185 "pedantic"
1186 }
1187
1188 fn description(&self) -> &'static str {
1189 "Enable all strict validation rules"
1190 }
1191
1192 fn process(&self, input: PluginInput) -> PluginOutput {
1193 let mut all_errors = Vec::new();
1194
1195 let leafonly = LeafOnlyPlugin;
1197 let result = leafonly.process(PluginInput {
1198 directives: input.directives.clone(),
1199 options: input.options.clone(),
1200 config: None,
1201 });
1202 all_errors.extend(result.errors);
1203
1204 let onecommodity = OneCommodityPlugin;
1206 let result = onecommodity.process(PluginInput {
1207 directives: input.directives.clone(),
1208 options: input.options.clone(),
1209 config: None,
1210 });
1211 all_errors.extend(result.errors);
1212
1213 let noduplicates = NoDuplicatesPlugin;
1215 let result = noduplicates.process(PluginInput {
1216 directives: input.directives.clone(),
1217 options: input.options.clone(),
1218 config: None,
1219 });
1220 all_errors.extend(result.errors);
1221
1222 let check_commodity = CheckCommodityPlugin;
1224 let result = check_commodity.process(PluginInput {
1225 directives: input.directives.clone(),
1226 options: input.options.clone(),
1227 config: None,
1228 });
1229 all_errors.extend(result.errors);
1230
1231 PluginOutput {
1232 directives: input.directives,
1233 errors: all_errors,
1234 }
1235 }
1236}
1237
1238pub struct UnrealizedPlugin {
1243 pub gains_account: String,
1245}
1246
1247impl UnrealizedPlugin {
1248 pub fn new() -> Self {
1250 Self {
1251 gains_account: "Income:Unrealized".to_string(),
1252 }
1253 }
1254
1255 pub const fn with_account(account: String) -> Self {
1257 Self {
1258 gains_account: account,
1259 }
1260 }
1261}
1262
1263impl Default for UnrealizedPlugin {
1264 fn default() -> Self {
1265 Self::new()
1266 }
1267}
1268
1269impl NativePlugin for UnrealizedPlugin {
1270 fn name(&self) -> &'static str {
1271 "unrealized"
1272 }
1273
1274 fn description(&self) -> &'static str {
1275 "Calculate unrealized gains/losses"
1276 }
1277
1278 fn process(&self, input: PluginInput) -> PluginOutput {
1279 use rust_decimal::Decimal;
1280 use std::collections::HashMap;
1281 use std::str::FromStr;
1282
1283 let mut prices: HashMap<(String, String), (String, Decimal)> = HashMap::new(); for wrapper in &input.directives {
1287 if let DirectiveData::Price(price) = &wrapper.data {
1288 let key = (price.currency.clone(), price.amount.currency.clone());
1289 let price_val = Decimal::from_str(&price.amount.number).unwrap_or_default();
1290
1291 if let Some((existing_date, _)) = prices.get(&key) {
1293 if &wrapper.date > existing_date {
1294 prices.insert(key, (wrapper.date.clone(), price_val));
1295 }
1296 } else {
1297 prices.insert(key, (wrapper.date.clone(), price_val));
1298 }
1299 }
1300 }
1301
1302 let mut positions: HashMap<String, HashMap<String, (Decimal, Decimal)>> = HashMap::new(); let mut errors = Vec::new();
1306
1307 for wrapper in &input.directives {
1308 if let DirectiveData::Transaction(txn) = &wrapper.data {
1309 for posting in &txn.postings {
1310 if let Some(units) = &posting.units {
1311 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
1312
1313 let cost_basis = if let Some(cost) = &posting.cost {
1314 cost.number_per
1315 .as_ref()
1316 .and_then(|s| Decimal::from_str(s).ok())
1317 .unwrap_or_default()
1318 * units_num.abs()
1319 } else {
1320 Decimal::ZERO
1321 };
1322
1323 let account_positions =
1324 positions.entry(posting.account.clone()).or_default();
1325
1326 let (existing_units, existing_cost) = account_positions
1327 .entry(units.currency.clone())
1328 .or_insert((Decimal::ZERO, Decimal::ZERO));
1329
1330 *existing_units += units_num;
1331 *existing_cost += cost_basis;
1332 }
1333 }
1334 }
1335 }
1336
1337 for (account, currencies) in &positions {
1339 for (currency, (units, cost_basis)) in currencies {
1340 if *units == Decimal::ZERO {
1341 continue;
1342 }
1343
1344 if let Some((_, market_price)) = prices.get(&(currency.clone(), "USD".to_string()))
1346 {
1347 let market_value = *units * market_price;
1348 let unrealized_gain = market_value - cost_basis;
1349
1350 if unrealized_gain.abs() > Decimal::new(1, 2) {
1351 errors.push(PluginError::warning(format!(
1353 "Unrealized gain on {units} {currency} in {account}: {unrealized_gain} USD"
1354 )));
1355 }
1356 }
1357 }
1358 }
1359
1360 PluginOutput {
1361 directives: input.directives,
1362 errors,
1363 }
1364 }
1365}
1366
1367pub struct NoUnusedPlugin;
1372
1373impl NativePlugin for NoUnusedPlugin {
1374 fn name(&self) -> &'static str {
1375 "nounused"
1376 }
1377
1378 fn description(&self) -> &'static str {
1379 "Warn about unused accounts"
1380 }
1381
1382 fn process(&self, input: PluginInput) -> PluginOutput {
1383 use std::collections::HashSet;
1384
1385 let mut opened_accounts: HashSet<String> = HashSet::new();
1386 let mut used_accounts: HashSet<String> = HashSet::new();
1387
1388 for wrapper in &input.directives {
1390 match &wrapper.data {
1391 DirectiveData::Open(data) => {
1392 opened_accounts.insert(data.account.clone());
1393 }
1394 DirectiveData::Close(data) => {
1395 used_accounts.insert(data.account.clone());
1397 }
1398 DirectiveData::Transaction(txn) => {
1399 for posting in &txn.postings {
1400 used_accounts.insert(posting.account.clone());
1401 }
1402 }
1403 DirectiveData::Balance(data) => {
1404 used_accounts.insert(data.account.clone());
1405 }
1406 DirectiveData::Pad(data) => {
1407 used_accounts.insert(data.account.clone());
1408 used_accounts.insert(data.source_account.clone());
1409 }
1410 DirectiveData::Note(data) => {
1411 used_accounts.insert(data.account.clone());
1412 }
1413 DirectiveData::Document(data) => {
1414 used_accounts.insert(data.account.clone());
1415 }
1416 DirectiveData::Custom(data) => {
1417 for value in &data.values {
1420 if value.starts_with("Assets:")
1421 || value.starts_with("Liabilities:")
1422 || value.starts_with("Equity:")
1423 || value.starts_with("Income:")
1424 || value.starts_with("Expenses:")
1425 {
1426 used_accounts.insert(value.clone());
1427 }
1428 }
1429 }
1430 _ => {}
1431 }
1432 }
1433
1434 let mut errors = Vec::new();
1436 let mut unused: Vec<_> = opened_accounts
1437 .difference(&used_accounts)
1438 .cloned()
1439 .collect();
1440 unused.sort(); for account in unused {
1443 errors.push(PluginError::warning(format!(
1444 "Account '{account}' is opened but never used"
1445 )));
1446 }
1447
1448 PluginOutput {
1449 directives: input.directives,
1450 errors,
1451 }
1452 }
1453}
1454
1455#[cfg(test)]
1456mod nounused_tests {
1457 use super::*;
1458 use crate::types::*;
1459
1460 #[test]
1461 fn test_nounused_reports_unused_account() {
1462 let plugin = NoUnusedPlugin;
1463
1464 let input = PluginInput {
1465 directives: vec![
1466 DirectiveWrapper {
1467 directive_type: "open".to_string(),
1468 date: "2024-01-01".to_string(),
1469 data: DirectiveData::Open(OpenData {
1470 account: "Assets:Bank".to_string(),
1471 currencies: vec![],
1472 booking: None,
1473 }),
1474 },
1475 DirectiveWrapper {
1476 directive_type: "open".to_string(),
1477 date: "2024-01-01".to_string(),
1478 data: DirectiveData::Open(OpenData {
1479 account: "Assets:Unused".to_string(),
1480 currencies: vec![],
1481 booking: None,
1482 }),
1483 },
1484 DirectiveWrapper {
1485 directive_type: "transaction".to_string(),
1486 date: "2024-01-15".to_string(),
1487 data: DirectiveData::Transaction(TransactionData {
1488 flag: "*".to_string(),
1489 payee: None,
1490 narration: "Test".to_string(),
1491 tags: vec![],
1492 links: vec![],
1493 metadata: vec![],
1494 postings: vec![PostingData {
1495 account: "Assets:Bank".to_string(),
1496 units: Some(AmountData {
1497 number: "100".to_string(),
1498 currency: "USD".to_string(),
1499 }),
1500 cost: None,
1501 price: None,
1502 flag: None,
1503 metadata: vec![],
1504 }],
1505 }),
1506 },
1507 ],
1508 options: PluginOptions {
1509 operating_currencies: vec!["USD".to_string()],
1510 title: None,
1511 },
1512 config: None,
1513 };
1514
1515 let output = plugin.process(input);
1516 assert_eq!(output.errors.len(), 1);
1517 assert!(output.errors[0].message.contains("Assets:Unused"));
1518 assert!(output.errors[0].message.contains("never used"));
1519 }
1520
1521 #[test]
1522 fn test_nounused_no_warning_for_used_accounts() {
1523 let plugin = NoUnusedPlugin;
1524
1525 let input = PluginInput {
1526 directives: vec![
1527 DirectiveWrapper {
1528 directive_type: "open".to_string(),
1529 date: "2024-01-01".to_string(),
1530 data: DirectiveData::Open(OpenData {
1531 account: "Assets:Bank".to_string(),
1532 currencies: vec![],
1533 booking: None,
1534 }),
1535 },
1536 DirectiveWrapper {
1537 directive_type: "transaction".to_string(),
1538 date: "2024-01-15".to_string(),
1539 data: DirectiveData::Transaction(TransactionData {
1540 flag: "*".to_string(),
1541 payee: None,
1542 narration: "Test".to_string(),
1543 tags: vec![],
1544 links: vec![],
1545 metadata: vec![],
1546 postings: vec![PostingData {
1547 account: "Assets:Bank".to_string(),
1548 units: Some(AmountData {
1549 number: "100".to_string(),
1550 currency: "USD".to_string(),
1551 }),
1552 cost: None,
1553 price: None,
1554 flag: None,
1555 metadata: vec![],
1556 }],
1557 }),
1558 },
1559 ],
1560 options: PluginOptions {
1561 operating_currencies: vec!["USD".to_string()],
1562 title: None,
1563 },
1564 config: None,
1565 };
1566
1567 let output = plugin.process(input);
1568 assert_eq!(output.errors.len(), 0);
1569 }
1570
1571 #[test]
1572 fn test_nounused_close_counts_as_used() {
1573 let plugin = NoUnusedPlugin;
1574
1575 let input = PluginInput {
1576 directives: vec![
1577 DirectiveWrapper {
1578 directive_type: "open".to_string(),
1579 date: "2024-01-01".to_string(),
1580 data: DirectiveData::Open(OpenData {
1581 account: "Assets:OldAccount".to_string(),
1582 currencies: vec![],
1583 booking: None,
1584 }),
1585 },
1586 DirectiveWrapper {
1587 directive_type: "close".to_string(),
1588 date: "2024-12-31".to_string(),
1589 data: DirectiveData::Close(CloseData {
1590 account: "Assets:OldAccount".to_string(),
1591 }),
1592 },
1593 ],
1594 options: PluginOptions {
1595 operating_currencies: vec!["USD".to_string()],
1596 title: None,
1597 },
1598 config: None,
1599 };
1600
1601 let output = plugin.process(input);
1602 assert_eq!(output.errors.len(), 0);
1604 }
1605}
1606
1607pub struct CheckDrainedPlugin;
1613
1614impl NativePlugin for CheckDrainedPlugin {
1615 fn name(&self) -> &'static str {
1616 "check_drained"
1617 }
1618
1619 fn description(&self) -> &'static str {
1620 "Zero balance assertion on balance sheet account close"
1621 }
1622
1623 fn process(&self, input: PluginInput) -> PluginOutput {
1624 use crate::types::{AmountData, BalanceData};
1625 use std::collections::{HashMap, HashSet};
1626
1627 let mut account_currencies: HashMap<String, HashSet<String>> = HashMap::new();
1629
1630 for wrapper in &input.directives {
1632 match &wrapper.data {
1633 DirectiveData::Transaction(txn) => {
1634 for posting in &txn.postings {
1635 if let Some(units) = &posting.units {
1636 account_currencies
1637 .entry(posting.account.clone())
1638 .or_default()
1639 .insert(units.currency.clone());
1640 }
1641 }
1642 }
1643 DirectiveData::Balance(data) => {
1644 account_currencies
1645 .entry(data.account.clone())
1646 .or_default()
1647 .insert(data.amount.currency.clone());
1648 }
1649 DirectiveData::Open(data) => {
1650 for currency in &data.currencies {
1652 account_currencies
1653 .entry(data.account.clone())
1654 .or_default()
1655 .insert(currency.clone());
1656 }
1657 }
1658 _ => {}
1659 }
1660 }
1661
1662 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
1664
1665 for wrapper in &input.directives {
1666 new_directives.push(wrapper.clone());
1667
1668 if let DirectiveData::Close(data) = &wrapper.data {
1669 let is_balance_sheet = data.account.starts_with("Assets:")
1671 || data.account.starts_with("Liabilities:")
1672 || data.account.starts_with("Equity:")
1673 || data.account == "Assets"
1674 || data.account == "Liabilities"
1675 || data.account == "Equity";
1676
1677 if !is_balance_sheet {
1678 continue;
1679 }
1680
1681 if let Some(currencies) = account_currencies.get(&data.account) {
1683 if let Some(next_date) = increment_date(&wrapper.date) {
1685 let mut sorted_currencies: Vec<_> = currencies.iter().collect();
1687 sorted_currencies.sort(); for currency in sorted_currencies {
1690 new_directives.push(DirectiveWrapper {
1691 directive_type: "balance".to_string(),
1692 date: next_date.clone(),
1693 data: DirectiveData::Balance(BalanceData {
1694 account: data.account.clone(),
1695 amount: AmountData {
1696 number: "0".to_string(),
1697 currency: currency.clone(),
1698 },
1699 tolerance: None,
1700 }),
1701 });
1702 }
1703 }
1704 }
1705 }
1706 }
1707
1708 new_directives.sort_by(|a, b| a.date.cmp(&b.date));
1710
1711 PluginOutput {
1712 directives: new_directives,
1713 errors: Vec::new(),
1714 }
1715 }
1716}
1717
1718#[cfg(test)]
1719mod check_drained_tests {
1720 use super::*;
1721 use crate::types::*;
1722
1723 #[test]
1724 fn test_check_drained_adds_balance_assertion() {
1725 let plugin = CheckDrainedPlugin;
1726
1727 let input = PluginInput {
1728 directives: vec![
1729 DirectiveWrapper {
1730 directive_type: "open".to_string(),
1731 date: "2024-01-01".to_string(),
1732 data: DirectiveData::Open(OpenData {
1733 account: "Assets:Bank".to_string(),
1734 currencies: vec!["USD".to_string()],
1735 booking: None,
1736 }),
1737 },
1738 DirectiveWrapper {
1739 directive_type: "transaction".to_string(),
1740 date: "2024-06-15".to_string(),
1741 data: DirectiveData::Transaction(TransactionData {
1742 flag: "*".to_string(),
1743 payee: None,
1744 narration: "Deposit".to_string(),
1745 tags: vec![],
1746 links: vec![],
1747 metadata: vec![],
1748 postings: vec![PostingData {
1749 account: "Assets:Bank".to_string(),
1750 units: Some(AmountData {
1751 number: "100".to_string(),
1752 currency: "USD".to_string(),
1753 }),
1754 cost: None,
1755 price: None,
1756 flag: None,
1757 metadata: vec![],
1758 }],
1759 }),
1760 },
1761 DirectiveWrapper {
1762 directive_type: "close".to_string(),
1763 date: "2024-12-31".to_string(),
1764 data: DirectiveData::Close(CloseData {
1765 account: "Assets:Bank".to_string(),
1766 }),
1767 },
1768 ],
1769 options: PluginOptions {
1770 operating_currencies: vec!["USD".to_string()],
1771 title: None,
1772 },
1773 config: None,
1774 };
1775
1776 let output = plugin.process(input);
1777 assert_eq!(output.errors.len(), 0);
1778
1779 assert_eq!(output.directives.len(), 4);
1781
1782 let balance = output
1784 .directives
1785 .iter()
1786 .find(|d| d.directive_type == "balance")
1787 .expect("Should have balance directive");
1788
1789 assert_eq!(balance.date, "2025-01-01"); if let DirectiveData::Balance(b) = &balance.data {
1791 assert_eq!(b.account, "Assets:Bank");
1792 assert_eq!(b.amount.number, "0");
1793 assert_eq!(b.amount.currency, "USD");
1794 } else {
1795 panic!("Expected Balance directive");
1796 }
1797 }
1798
1799 #[test]
1800 fn test_check_drained_ignores_income_expense() {
1801 let plugin = CheckDrainedPlugin;
1802
1803 let input = PluginInput {
1804 directives: vec![
1805 DirectiveWrapper {
1806 directive_type: "open".to_string(),
1807 date: "2024-01-01".to_string(),
1808 data: DirectiveData::Open(OpenData {
1809 account: "Income:Salary".to_string(),
1810 currencies: vec!["USD".to_string()],
1811 booking: None,
1812 }),
1813 },
1814 DirectiveWrapper {
1815 directive_type: "close".to_string(),
1816 date: "2024-12-31".to_string(),
1817 data: DirectiveData::Close(CloseData {
1818 account: "Income:Salary".to_string(),
1819 }),
1820 },
1821 ],
1822 options: PluginOptions {
1823 operating_currencies: vec!["USD".to_string()],
1824 title: None,
1825 },
1826 config: None,
1827 };
1828
1829 let output = plugin.process(input);
1830 assert_eq!(output.directives.len(), 2);
1832 assert!(
1833 !output
1834 .directives
1835 .iter()
1836 .any(|d| d.directive_type == "balance")
1837 );
1838 }
1839
1840 #[test]
1841 fn test_check_drained_multiple_currencies() {
1842 let plugin = CheckDrainedPlugin;
1843
1844 let input = PluginInput {
1845 directives: vec![
1846 DirectiveWrapper {
1847 directive_type: "open".to_string(),
1848 date: "2024-01-01".to_string(),
1849 data: DirectiveData::Open(OpenData {
1850 account: "Assets:Bank".to_string(),
1851 currencies: vec![],
1852 booking: None,
1853 }),
1854 },
1855 DirectiveWrapper {
1856 directive_type: "transaction".to_string(),
1857 date: "2024-06-15".to_string(),
1858 data: DirectiveData::Transaction(TransactionData {
1859 flag: "*".to_string(),
1860 payee: None,
1861 narration: "USD Deposit".to_string(),
1862 tags: vec![],
1863 links: vec![],
1864 metadata: vec![],
1865 postings: vec![PostingData {
1866 account: "Assets:Bank".to_string(),
1867 units: Some(AmountData {
1868 number: "100".to_string(),
1869 currency: "USD".to_string(),
1870 }),
1871 cost: None,
1872 price: None,
1873 flag: None,
1874 metadata: vec![],
1875 }],
1876 }),
1877 },
1878 DirectiveWrapper {
1879 directive_type: "transaction".to_string(),
1880 date: "2024-07-15".to_string(),
1881 data: DirectiveData::Transaction(TransactionData {
1882 flag: "*".to_string(),
1883 payee: None,
1884 narration: "EUR Deposit".to_string(),
1885 tags: vec![],
1886 links: vec![],
1887 metadata: vec![],
1888 postings: vec![PostingData {
1889 account: "Assets:Bank".to_string(),
1890 units: Some(AmountData {
1891 number: "50".to_string(),
1892 currency: "EUR".to_string(),
1893 }),
1894 cost: None,
1895 price: None,
1896 flag: None,
1897 metadata: vec![],
1898 }],
1899 }),
1900 },
1901 DirectiveWrapper {
1902 directive_type: "close".to_string(),
1903 date: "2024-12-31".to_string(),
1904 data: DirectiveData::Close(CloseData {
1905 account: "Assets:Bank".to_string(),
1906 }),
1907 },
1908 ],
1909 options: PluginOptions {
1910 operating_currencies: vec!["USD".to_string()],
1911 title: None,
1912 },
1913 config: None,
1914 };
1915
1916 let output = plugin.process(input);
1917 assert_eq!(output.directives.len(), 6);
1919
1920 let balances: Vec<_> = output
1921 .directives
1922 .iter()
1923 .filter(|d| d.directive_type == "balance")
1924 .collect();
1925 assert_eq!(balances.len(), 2);
1926
1927 for b in &balances {
1929 assert_eq!(b.date, "2025-01-01");
1930 }
1931 }
1932}
1933
1934pub struct CommodityAttrPlugin {
1941 required_attrs: Vec<(String, Option<Vec<String>>)>,
1943}
1944
1945impl CommodityAttrPlugin {
1946 pub const fn new() -> Self {
1948 Self {
1949 required_attrs: Vec::new(),
1950 }
1951 }
1952
1953 pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
1955 Self {
1956 required_attrs: attrs,
1957 }
1958 }
1959
1960 fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
1964 let mut result = Vec::new();
1965
1966 let trimmed = config.trim();
1969 let content = if trimmed.starts_with('{') && trimmed.ends_with('}') {
1970 &trimmed[1..trimmed.len() - 1]
1971 } else {
1972 trimmed
1973 };
1974
1975 let mut depth = 0;
1977 let mut current = String::new();
1978 let mut entries = Vec::new();
1979
1980 for c in content.chars() {
1981 match c {
1982 '[' => {
1983 depth += 1;
1984 current.push(c);
1985 }
1986 ']' => {
1987 depth -= 1;
1988 current.push(c);
1989 }
1990 ',' if depth == 0 => {
1991 entries.push(current.trim().to_string());
1992 current.clear();
1993 }
1994 _ => current.push(c),
1995 }
1996 }
1997 if !current.trim().is_empty() {
1998 entries.push(current.trim().to_string());
1999 }
2000
2001 for entry in entries {
2003 if let Some((key_part, value_part)) = entry.split_once(':') {
2004 let key = key_part
2005 .trim()
2006 .trim_matches('\'')
2007 .trim_matches('"')
2008 .to_string();
2009 let value = value_part.trim();
2010
2011 if value == "null" || value == "None" {
2012 result.push((key, None));
2013 } else if value.starts_with('[') && value.ends_with(']') {
2014 let inner = &value[1..value.len() - 1];
2016 let allowed: Vec<String> = inner
2017 .split(',')
2018 .map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string())
2019 .filter(|s| !s.is_empty())
2020 .collect();
2021 result.push((key, Some(allowed)));
2022 }
2023 }
2024 }
2025
2026 result
2027 }
2028}
2029
2030impl Default for CommodityAttrPlugin {
2031 fn default() -> Self {
2032 Self::new()
2033 }
2034}
2035
2036impl NativePlugin for CommodityAttrPlugin {
2037 fn name(&self) -> &'static str {
2038 "commodity_attr"
2039 }
2040
2041 fn description(&self) -> &'static str {
2042 "Validate commodity metadata attributes"
2043 }
2044
2045 fn process(&self, input: PluginInput) -> PluginOutput {
2046 let required = if let Some(config) = &input.config {
2048 Self::parse_config(config)
2049 } else {
2050 self.required_attrs.clone()
2051 };
2052
2053 if required.is_empty() {
2055 return PluginOutput {
2056 directives: input.directives,
2057 errors: Vec::new(),
2058 };
2059 }
2060
2061 let mut errors = Vec::new();
2062
2063 for wrapper in &input.directives {
2064 if let DirectiveData::Commodity(comm) = &wrapper.data {
2065 for (attr_name, allowed_values) in &required {
2067 let found = comm.metadata.iter().find(|(k, _)| k == attr_name);
2069
2070 match found {
2071 None => {
2072 errors.push(PluginError::error(format!(
2073 "Commodity '{}' missing required attribute '{}'",
2074 comm.currency, attr_name
2075 )));
2076 }
2077 Some((_, value)) => {
2078 if let Some(allowed) = allowed_values {
2080 let value_str = match value {
2081 crate::types::MetaValueData::String(s) => s.clone(),
2082 other => format!("{other:?}"),
2083 };
2084 if !allowed.contains(&value_str) {
2085 errors.push(PluginError::error(format!(
2086 "Commodity '{}' attribute '{}' has invalid value '{}' (allowed: {:?})",
2087 comm.currency, attr_name, value_str, allowed
2088 )));
2089 }
2090 }
2091 }
2092 }
2093 }
2094 }
2095 }
2096
2097 PluginOutput {
2098 directives: input.directives,
2099 errors,
2100 }
2101 }
2102}
2103
2104#[cfg(test)]
2105mod commodity_attr_tests {
2106 use super::*;
2107 use crate::types::*;
2108
2109 #[test]
2110 fn test_commodity_attr_missing_required() {
2111 let plugin = CommodityAttrPlugin::new();
2112
2113 let input = PluginInput {
2114 directives: vec![DirectiveWrapper {
2115 directive_type: "commodity".to_string(),
2116 date: "2024-01-01".to_string(),
2117 data: DirectiveData::Commodity(CommodityData {
2118 currency: "AAPL".to_string(),
2119 metadata: vec![], }),
2121 }],
2122 options: PluginOptions {
2123 operating_currencies: vec!["USD".to_string()],
2124 title: None,
2125 },
2126 config: Some("{'name': null}".to_string()),
2127 };
2128
2129 let output = plugin.process(input);
2130 assert_eq!(output.errors.len(), 1);
2131 assert!(output.errors[0].message.contains("missing required"));
2132 assert!(output.errors[0].message.contains("name"));
2133 }
2134
2135 #[test]
2136 fn test_commodity_attr_has_required() {
2137 let plugin = CommodityAttrPlugin::new();
2138
2139 let input = PluginInput {
2140 directives: vec![DirectiveWrapper {
2141 directive_type: "commodity".to_string(),
2142 date: "2024-01-01".to_string(),
2143 data: DirectiveData::Commodity(CommodityData {
2144 currency: "AAPL".to_string(),
2145 metadata: vec![(
2146 "name".to_string(),
2147 MetaValueData::String("Apple Inc".to_string()),
2148 )],
2149 }),
2150 }],
2151 options: PluginOptions {
2152 operating_currencies: vec!["USD".to_string()],
2153 title: None,
2154 },
2155 config: Some("{'name': null}".to_string()),
2156 };
2157
2158 let output = plugin.process(input);
2159 assert_eq!(output.errors.len(), 0);
2160 }
2161
2162 #[test]
2163 fn test_commodity_attr_invalid_value() {
2164 let plugin = CommodityAttrPlugin::new();
2165
2166 let input = PluginInput {
2167 directives: vec![DirectiveWrapper {
2168 directive_type: "commodity".to_string(),
2169 date: "2024-01-01".to_string(),
2170 data: DirectiveData::Commodity(CommodityData {
2171 currency: "AAPL".to_string(),
2172 metadata: vec![(
2173 "sector".to_string(),
2174 MetaValueData::String("Healthcare".to_string()),
2175 )],
2176 }),
2177 }],
2178 options: PluginOptions {
2179 operating_currencies: vec!["USD".to_string()],
2180 title: None,
2181 },
2182 config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
2183 };
2184
2185 let output = plugin.process(input);
2186 assert_eq!(output.errors.len(), 1);
2187 assert!(output.errors[0].message.contains("invalid value"));
2188 assert!(output.errors[0].message.contains("Healthcare"));
2189 }
2190
2191 #[test]
2192 fn test_commodity_attr_valid_value() {
2193 let plugin = CommodityAttrPlugin::new();
2194
2195 let input = PluginInput {
2196 directives: vec![DirectiveWrapper {
2197 directive_type: "commodity".to_string(),
2198 date: "2024-01-01".to_string(),
2199 data: DirectiveData::Commodity(CommodityData {
2200 currency: "AAPL".to_string(),
2201 metadata: vec![(
2202 "sector".to_string(),
2203 MetaValueData::String("Tech".to_string()),
2204 )],
2205 }),
2206 }],
2207 options: PluginOptions {
2208 operating_currencies: vec!["USD".to_string()],
2209 title: None,
2210 },
2211 config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
2212 };
2213
2214 let output = plugin.process(input);
2215 assert_eq!(output.errors.len(), 0);
2216 }
2217
2218 #[test]
2219 fn test_config_parsing() {
2220 let config = "{'name': null, 'sector': ['Tech', 'Finance']}";
2221 let parsed = CommodityAttrPlugin::parse_config(config);
2222
2223 assert_eq!(parsed.len(), 2);
2224 assert_eq!(parsed[0].0, "name");
2225 assert!(parsed[0].1.is_none());
2226 assert_eq!(parsed[1].0, "sector");
2227 assert_eq!(parsed[1].1.as_ref().unwrap(), &vec!["Tech", "Finance"]);
2228 }
2229}
2230
2231pub struct CheckAverageCostPlugin {
2237 tolerance: rust_decimal::Decimal,
2239}
2240
2241impl CheckAverageCostPlugin {
2242 pub fn new() -> Self {
2244 Self {
2245 tolerance: rust_decimal::Decimal::new(1, 2), }
2247 }
2248
2249 pub const fn with_tolerance(tolerance: rust_decimal::Decimal) -> Self {
2251 Self { tolerance }
2252 }
2253}
2254
2255impl Default for CheckAverageCostPlugin {
2256 fn default() -> Self {
2257 Self::new()
2258 }
2259}
2260
2261impl NativePlugin for CheckAverageCostPlugin {
2262 fn name(&self) -> &'static str {
2263 "check_average_cost"
2264 }
2265
2266 fn description(&self) -> &'static str {
2267 "Validate reducing postings match average cost"
2268 }
2269
2270 fn process(&self, input: PluginInput) -> PluginOutput {
2271 use rust_decimal::Decimal;
2272 use std::collections::HashMap;
2273 use std::str::FromStr;
2274
2275 let tolerance = if let Some(config) = &input.config {
2277 Decimal::from_str(config.trim()).unwrap_or(self.tolerance)
2278 } else {
2279 self.tolerance
2280 };
2281
2282 let mut inventory: HashMap<(String, String), (Decimal, Decimal)> = HashMap::new();
2285
2286 let mut errors = Vec::new();
2287
2288 for wrapper in &input.directives {
2289 if let DirectiveData::Transaction(txn) = &wrapper.data {
2290 for posting in &txn.postings {
2291 let Some(units) = &posting.units else {
2293 continue;
2294 };
2295 let Some(cost) = &posting.cost else {
2296 continue;
2297 };
2298
2299 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
2300 let Some(cost_currency) = &cost.currency else {
2301 continue;
2302 };
2303
2304 let key = (posting.account.clone(), units.currency.clone());
2305
2306 if units_num > Decimal::ZERO {
2307 let cost_per = cost
2309 .number_per
2310 .as_ref()
2311 .and_then(|s| Decimal::from_str(s).ok())
2312 .unwrap_or_default();
2313
2314 let entry = inventory
2315 .entry(key)
2316 .or_insert((Decimal::ZERO, Decimal::ZERO));
2317 entry.0 += units_num; entry.1 += units_num * cost_per; } else if units_num < Decimal::ZERO {
2320 let entry = inventory.get(&key);
2322
2323 if let Some((total_units, total_cost)) = entry {
2324 if *total_units > Decimal::ZERO {
2325 let avg_cost = *total_cost / *total_units;
2326
2327 let used_cost = cost
2329 .number_per
2330 .as_ref()
2331 .and_then(|s| Decimal::from_str(s).ok())
2332 .unwrap_or_default();
2333
2334 let diff = (used_cost - avg_cost).abs();
2336 let relative_diff = if avg_cost == Decimal::ZERO {
2337 diff
2338 } else {
2339 diff / avg_cost
2340 };
2341
2342 if relative_diff > tolerance {
2343 errors.push(PluginError::warning(format!(
2344 "Sale of {} {} in {} uses cost {} {} but average cost is {} {} (difference: {:.2}%)",
2345 units_num.abs(),
2346 units.currency,
2347 posting.account,
2348 used_cost,
2349 cost_currency,
2350 avg_cost.round_dp(4),
2351 cost_currency,
2352 relative_diff * Decimal::from(100)
2353 )));
2354 }
2355
2356 let entry = inventory.get_mut(&key).unwrap();
2358 let units_sold = units_num.abs();
2359 let cost_removed = units_sold * avg_cost;
2360 entry.0 -= units_sold;
2361 entry.1 -= cost_removed;
2362 }
2363 }
2364 }
2365 }
2366 }
2367 }
2368
2369 PluginOutput {
2370 directives: input.directives,
2371 errors,
2372 }
2373 }
2374}
2375
2376#[cfg(test)]
2377mod check_average_cost_tests {
2378 use super::*;
2379 use crate::types::*;
2380
2381 #[test]
2382 fn test_check_average_cost_matching() {
2383 let plugin = CheckAverageCostPlugin::new();
2384
2385 let input = PluginInput {
2386 directives: vec![
2387 DirectiveWrapper {
2388 directive_type: "transaction".to_string(),
2389 date: "2024-01-01".to_string(),
2390 data: DirectiveData::Transaction(TransactionData {
2391 flag: "*".to_string(),
2392 payee: None,
2393 narration: "Buy".to_string(),
2394 tags: vec![],
2395 links: vec![],
2396 metadata: vec![],
2397 postings: vec![PostingData {
2398 account: "Assets:Broker".to_string(),
2399 units: Some(AmountData {
2400 number: "10".to_string(),
2401 currency: "AAPL".to_string(),
2402 }),
2403 cost: Some(CostData {
2404 number_per: Some("100.00".to_string()),
2405 number_total: None,
2406 currency: Some("USD".to_string()),
2407 date: None,
2408 label: None,
2409 merge: false,
2410 }),
2411 price: None,
2412 flag: None,
2413 metadata: vec![],
2414 }],
2415 }),
2416 },
2417 DirectiveWrapper {
2418 directive_type: "transaction".to_string(),
2419 date: "2024-02-01".to_string(),
2420 data: DirectiveData::Transaction(TransactionData {
2421 flag: "*".to_string(),
2422 payee: None,
2423 narration: "Sell at avg cost".to_string(),
2424 tags: vec![],
2425 links: vec![],
2426 metadata: vec![],
2427 postings: vec![PostingData {
2428 account: "Assets:Broker".to_string(),
2429 units: Some(AmountData {
2430 number: "-5".to_string(),
2431 currency: "AAPL".to_string(),
2432 }),
2433 cost: Some(CostData {
2434 number_per: Some("100.00".to_string()), number_total: None,
2436 currency: Some("USD".to_string()),
2437 date: None,
2438 label: None,
2439 merge: false,
2440 }),
2441 price: None,
2442 flag: None,
2443 metadata: vec![],
2444 }],
2445 }),
2446 },
2447 ],
2448 options: PluginOptions {
2449 operating_currencies: vec!["USD".to_string()],
2450 title: None,
2451 },
2452 config: None,
2453 };
2454
2455 let output = plugin.process(input);
2456 assert_eq!(output.errors.len(), 0);
2457 }
2458
2459 #[test]
2460 fn test_check_average_cost_mismatch() {
2461 let plugin = CheckAverageCostPlugin::new();
2462
2463 let input = PluginInput {
2464 directives: vec![
2465 DirectiveWrapper {
2466 directive_type: "transaction".to_string(),
2467 date: "2024-01-01".to_string(),
2468 data: DirectiveData::Transaction(TransactionData {
2469 flag: "*".to_string(),
2470 payee: None,
2471 narration: "Buy at 100".to_string(),
2472 tags: vec![],
2473 links: vec![],
2474 metadata: vec![],
2475 postings: vec![PostingData {
2476 account: "Assets:Broker".to_string(),
2477 units: Some(AmountData {
2478 number: "10".to_string(),
2479 currency: "AAPL".to_string(),
2480 }),
2481 cost: Some(CostData {
2482 number_per: Some("100.00".to_string()),
2483 number_total: None,
2484 currency: Some("USD".to_string()),
2485 date: None,
2486 label: None,
2487 merge: false,
2488 }),
2489 price: None,
2490 flag: None,
2491 metadata: vec![],
2492 }],
2493 }),
2494 },
2495 DirectiveWrapper {
2496 directive_type: "transaction".to_string(),
2497 date: "2024-02-01".to_string(),
2498 data: DirectiveData::Transaction(TransactionData {
2499 flag: "*".to_string(),
2500 payee: None,
2501 narration: "Sell at wrong cost".to_string(),
2502 tags: vec![],
2503 links: vec![],
2504 metadata: vec![],
2505 postings: vec![PostingData {
2506 account: "Assets:Broker".to_string(),
2507 units: Some(AmountData {
2508 number: "-5".to_string(),
2509 currency: "AAPL".to_string(),
2510 }),
2511 cost: Some(CostData {
2512 number_per: Some("90.00".to_string()), number_total: None,
2514 currency: Some("USD".to_string()),
2515 date: None,
2516 label: None,
2517 merge: false,
2518 }),
2519 price: None,
2520 flag: None,
2521 metadata: vec![],
2522 }],
2523 }),
2524 },
2525 ],
2526 options: PluginOptions {
2527 operating_currencies: vec!["USD".to_string()],
2528 title: None,
2529 },
2530 config: None,
2531 };
2532
2533 let output = plugin.process(input);
2534 assert_eq!(output.errors.len(), 1);
2535 assert!(output.errors[0].message.contains("average cost"));
2536 }
2537
2538 #[test]
2539 fn test_check_average_cost_multiple_buys() {
2540 let plugin = CheckAverageCostPlugin::new();
2541
2542 let input = PluginInput {
2544 directives: vec![
2545 DirectiveWrapper {
2546 directive_type: "transaction".to_string(),
2547 date: "2024-01-01".to_string(),
2548 data: DirectiveData::Transaction(TransactionData {
2549 flag: "*".to_string(),
2550 payee: None,
2551 narration: "Buy at 100".to_string(),
2552 tags: vec![],
2553 links: vec![],
2554 metadata: vec![],
2555 postings: vec![PostingData {
2556 account: "Assets:Broker".to_string(),
2557 units: Some(AmountData {
2558 number: "10".to_string(),
2559 currency: "AAPL".to_string(),
2560 }),
2561 cost: Some(CostData {
2562 number_per: Some("100.00".to_string()),
2563 number_total: None,
2564 currency: Some("USD".to_string()),
2565 date: None,
2566 label: None,
2567 merge: false,
2568 }),
2569 price: None,
2570 flag: None,
2571 metadata: vec![],
2572 }],
2573 }),
2574 },
2575 DirectiveWrapper {
2576 directive_type: "transaction".to_string(),
2577 date: "2024-01-15".to_string(),
2578 data: DirectiveData::Transaction(TransactionData {
2579 flag: "*".to_string(),
2580 payee: None,
2581 narration: "Buy at 120".to_string(),
2582 tags: vec![],
2583 links: vec![],
2584 metadata: vec![],
2585 postings: vec![PostingData {
2586 account: "Assets:Broker".to_string(),
2587 units: Some(AmountData {
2588 number: "10".to_string(),
2589 currency: "AAPL".to_string(),
2590 }),
2591 cost: Some(CostData {
2592 number_per: Some("120.00".to_string()),
2593 number_total: None,
2594 currency: Some("USD".to_string()),
2595 date: None,
2596 label: None,
2597 merge: false,
2598 }),
2599 price: None,
2600 flag: None,
2601 metadata: vec![],
2602 }],
2603 }),
2604 },
2605 DirectiveWrapper {
2606 directive_type: "transaction".to_string(),
2607 date: "2024-02-01".to_string(),
2608 data: DirectiveData::Transaction(TransactionData {
2609 flag: "*".to_string(),
2610 payee: None,
2611 narration: "Sell at avg cost".to_string(),
2612 tags: vec![],
2613 links: vec![],
2614 metadata: vec![],
2615 postings: vec![PostingData {
2616 account: "Assets:Broker".to_string(),
2617 units: Some(AmountData {
2618 number: "-5".to_string(),
2619 currency: "AAPL".to_string(),
2620 }),
2621 cost: Some(CostData {
2622 number_per: Some("110.00".to_string()), number_total: None,
2624 currency: Some("USD".to_string()),
2625 date: None,
2626 label: None,
2627 merge: false,
2628 }),
2629 price: None,
2630 flag: None,
2631 metadata: vec![],
2632 }],
2633 }),
2634 },
2635 ],
2636 options: PluginOptions {
2637 operating_currencies: vec!["USD".to_string()],
2638 title: None,
2639 },
2640 config: None,
2641 };
2642
2643 let output = plugin.process(input);
2644 assert_eq!(output.errors.len(), 0);
2645 }
2646}
2647
2648pub struct CurrencyAccountsPlugin {
2655 base_account: String,
2657}
2658
2659impl CurrencyAccountsPlugin {
2660 pub fn new() -> Self {
2662 Self {
2663 base_account: "Equity:CurrencyAccounts".to_string(),
2664 }
2665 }
2666
2667 pub const fn with_base_account(base_account: String) -> Self {
2669 Self { base_account }
2670 }
2671}
2672
2673impl Default for CurrencyAccountsPlugin {
2674 fn default() -> Self {
2675 Self::new()
2676 }
2677}
2678
2679impl NativePlugin for CurrencyAccountsPlugin {
2680 fn name(&self) -> &'static str {
2681 "currency_accounts"
2682 }
2683
2684 fn description(&self) -> &'static str {
2685 "Auto-generate currency trading postings"
2686 }
2687
2688 fn process(&self, input: PluginInput) -> PluginOutput {
2689 use crate::types::{AmountData, PostingData};
2690 use rust_decimal::Decimal;
2691 use std::collections::HashMap;
2692 use std::str::FromStr;
2693
2694 let base_account = input
2696 .config
2697 .as_ref()
2698 .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
2699
2700 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
2701
2702 for wrapper in &input.directives {
2703 if let DirectiveData::Transaction(txn) = &wrapper.data {
2704 let mut currency_totals: HashMap<String, Decimal> = HashMap::new();
2707
2708 for posting in &txn.postings {
2709 if let Some(units) = &posting.units {
2710 let amount = Decimal::from_str(&units.number).unwrap_or_default();
2711 *currency_totals.entry(units.currency.clone()).or_default() += amount;
2712 }
2713 }
2714
2715 let non_zero_currencies: Vec<_> = currency_totals
2717 .iter()
2718 .filter(|&(_, total)| *total != Decimal::ZERO)
2719 .collect();
2720
2721 if non_zero_currencies.len() > 1 {
2722 let mut modified_txn = txn.clone();
2724
2725 for &(currency, total) in &non_zero_currencies {
2726 modified_txn.postings.push(PostingData {
2728 account: format!("{base_account}:{currency}"),
2729 units: Some(AmountData {
2730 number: (-*total).to_string(),
2731 currency: (*currency).clone(),
2732 }),
2733 cost: None,
2734 price: None,
2735 flag: None,
2736 metadata: vec![],
2737 });
2738 }
2739
2740 new_directives.push(DirectiveWrapper {
2741 directive_type: wrapper.directive_type.clone(),
2742 date: wrapper.date.clone(),
2743 data: DirectiveData::Transaction(modified_txn),
2744 });
2745 } else {
2746 new_directives.push(wrapper.clone());
2748 }
2749 } else {
2750 new_directives.push(wrapper.clone());
2751 }
2752 }
2753
2754 PluginOutput {
2755 directives: new_directives,
2756 errors: Vec::new(),
2757 }
2758 }
2759}
2760
2761#[cfg(test)]
2762mod currency_accounts_tests {
2763 use super::*;
2764 use crate::types::*;
2765
2766 #[test]
2767 fn test_currency_accounts_adds_balancing_postings() {
2768 let plugin = CurrencyAccountsPlugin::new();
2769
2770 let input = PluginInput {
2771 directives: vec![DirectiveWrapper {
2772 directive_type: "transaction".to_string(),
2773 date: "2024-01-15".to_string(),
2774 data: DirectiveData::Transaction(TransactionData {
2775 flag: "*".to_string(),
2776 payee: None,
2777 narration: "Currency exchange".to_string(),
2778 tags: vec![],
2779 links: vec![],
2780 metadata: vec![],
2781 postings: vec![
2782 PostingData {
2783 account: "Assets:Bank:USD".to_string(),
2784 units: Some(AmountData {
2785 number: "-100".to_string(),
2786 currency: "USD".to_string(),
2787 }),
2788 cost: None,
2789 price: None,
2790 flag: None,
2791 metadata: vec![],
2792 },
2793 PostingData {
2794 account: "Assets:Bank:EUR".to_string(),
2795 units: Some(AmountData {
2796 number: "85".to_string(),
2797 currency: "EUR".to_string(),
2798 }),
2799 cost: None,
2800 price: None,
2801 flag: None,
2802 metadata: vec![],
2803 },
2804 ],
2805 }),
2806 }],
2807 options: PluginOptions {
2808 operating_currencies: vec!["USD".to_string()],
2809 title: None,
2810 },
2811 config: None,
2812 };
2813
2814 let output = plugin.process(input);
2815 assert_eq!(output.errors.len(), 0);
2816 assert_eq!(output.directives.len(), 1);
2817
2818 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2819 assert_eq!(txn.postings.len(), 4);
2821
2822 let usd_posting = txn
2824 .postings
2825 .iter()
2826 .find(|p| p.account == "Equity:CurrencyAccounts:USD");
2827 assert!(usd_posting.is_some());
2828 let usd_posting = usd_posting.unwrap();
2829 assert_eq!(usd_posting.units.as_ref().unwrap().number, "100");
2831
2832 let eur_posting = txn
2833 .postings
2834 .iter()
2835 .find(|p| p.account == "Equity:CurrencyAccounts:EUR");
2836 assert!(eur_posting.is_some());
2837 let eur_posting = eur_posting.unwrap();
2838 assert_eq!(eur_posting.units.as_ref().unwrap().number, "-85");
2840 } else {
2841 panic!("Expected Transaction directive");
2842 }
2843 }
2844
2845 #[test]
2846 fn test_currency_accounts_single_currency_unchanged() {
2847 let plugin = CurrencyAccountsPlugin::new();
2848
2849 let input = PluginInput {
2850 directives: vec![DirectiveWrapper {
2851 directive_type: "transaction".to_string(),
2852 date: "2024-01-15".to_string(),
2853 data: DirectiveData::Transaction(TransactionData {
2854 flag: "*".to_string(),
2855 payee: None,
2856 narration: "Simple transfer".to_string(),
2857 tags: vec![],
2858 links: vec![],
2859 metadata: vec![],
2860 postings: vec![
2861 PostingData {
2862 account: "Assets:Bank".to_string(),
2863 units: Some(AmountData {
2864 number: "-100".to_string(),
2865 currency: "USD".to_string(),
2866 }),
2867 cost: None,
2868 price: None,
2869 flag: None,
2870 metadata: vec![],
2871 },
2872 PostingData {
2873 account: "Expenses:Food".to_string(),
2874 units: Some(AmountData {
2875 number: "100".to_string(),
2876 currency: "USD".to_string(),
2877 }),
2878 cost: None,
2879 price: None,
2880 flag: None,
2881 metadata: vec![],
2882 },
2883 ],
2884 }),
2885 }],
2886 options: PluginOptions {
2887 operating_currencies: vec!["USD".to_string()],
2888 title: None,
2889 },
2890 config: None,
2891 };
2892
2893 let output = plugin.process(input);
2894 assert_eq!(output.errors.len(), 0);
2895
2896 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2898 assert_eq!(txn.postings.len(), 2);
2899 }
2900 }
2901
2902 #[test]
2903 fn test_currency_accounts_custom_base_account() {
2904 let plugin = CurrencyAccountsPlugin::new();
2905
2906 let input = PluginInput {
2907 directives: vec![DirectiveWrapper {
2908 directive_type: "transaction".to_string(),
2909 date: "2024-01-15".to_string(),
2910 data: DirectiveData::Transaction(TransactionData {
2911 flag: "*".to_string(),
2912 payee: None,
2913 narration: "Exchange".to_string(),
2914 tags: vec![],
2915 links: vec![],
2916 metadata: vec![],
2917 postings: vec![
2918 PostingData {
2919 account: "Assets:USD".to_string(),
2920 units: Some(AmountData {
2921 number: "-50".to_string(),
2922 currency: "USD".to_string(),
2923 }),
2924 cost: None,
2925 price: None,
2926 flag: None,
2927 metadata: vec![],
2928 },
2929 PostingData {
2930 account: "Assets:EUR".to_string(),
2931 units: Some(AmountData {
2932 number: "42".to_string(),
2933 currency: "EUR".to_string(),
2934 }),
2935 cost: None,
2936 price: None,
2937 flag: None,
2938 metadata: vec![],
2939 },
2940 ],
2941 }),
2942 }],
2943 options: PluginOptions {
2944 operating_currencies: vec!["USD".to_string()],
2945 title: None,
2946 },
2947 config: Some("Income:Trading".to_string()),
2948 };
2949
2950 let output = plugin.process(input);
2951 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2952 assert!(
2954 txn.postings
2955 .iter()
2956 .any(|p| p.account.starts_with("Income:Trading:"))
2957 );
2958 }
2959 }
2960}