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(ref number), Some(ref 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::hash_map::DefaultHasher;
580 use std::collections::HashSet;
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!(!output
1833 .directives
1834 .iter()
1835 .any(|d| d.directive_type == "balance"));
1836 }
1837
1838 #[test]
1839 fn test_check_drained_multiple_currencies() {
1840 let plugin = CheckDrainedPlugin;
1841
1842 let input = PluginInput {
1843 directives: vec![
1844 DirectiveWrapper {
1845 directive_type: "open".to_string(),
1846 date: "2024-01-01".to_string(),
1847 data: DirectiveData::Open(OpenData {
1848 account: "Assets:Bank".to_string(),
1849 currencies: vec![],
1850 booking: None,
1851 }),
1852 },
1853 DirectiveWrapper {
1854 directive_type: "transaction".to_string(),
1855 date: "2024-06-15".to_string(),
1856 data: DirectiveData::Transaction(TransactionData {
1857 flag: "*".to_string(),
1858 payee: None,
1859 narration: "USD Deposit".to_string(),
1860 tags: vec![],
1861 links: vec![],
1862 metadata: vec![],
1863 postings: vec![PostingData {
1864 account: "Assets:Bank".to_string(),
1865 units: Some(AmountData {
1866 number: "100".to_string(),
1867 currency: "USD".to_string(),
1868 }),
1869 cost: None,
1870 price: None,
1871 flag: None,
1872 metadata: vec![],
1873 }],
1874 }),
1875 },
1876 DirectiveWrapper {
1877 directive_type: "transaction".to_string(),
1878 date: "2024-07-15".to_string(),
1879 data: DirectiveData::Transaction(TransactionData {
1880 flag: "*".to_string(),
1881 payee: None,
1882 narration: "EUR Deposit".to_string(),
1883 tags: vec![],
1884 links: vec![],
1885 metadata: vec![],
1886 postings: vec![PostingData {
1887 account: "Assets:Bank".to_string(),
1888 units: Some(AmountData {
1889 number: "50".to_string(),
1890 currency: "EUR".to_string(),
1891 }),
1892 cost: None,
1893 price: None,
1894 flag: None,
1895 metadata: vec![],
1896 }],
1897 }),
1898 },
1899 DirectiveWrapper {
1900 directive_type: "close".to_string(),
1901 date: "2024-12-31".to_string(),
1902 data: DirectiveData::Close(CloseData {
1903 account: "Assets:Bank".to_string(),
1904 }),
1905 },
1906 ],
1907 options: PluginOptions {
1908 operating_currencies: vec!["USD".to_string()],
1909 title: None,
1910 },
1911 config: None,
1912 };
1913
1914 let output = plugin.process(input);
1915 assert_eq!(output.directives.len(), 6);
1917
1918 let balances: Vec<_> = output
1919 .directives
1920 .iter()
1921 .filter(|d| d.directive_type == "balance")
1922 .collect();
1923 assert_eq!(balances.len(), 2);
1924
1925 for b in &balances {
1927 assert_eq!(b.date, "2025-01-01");
1928 }
1929 }
1930}
1931
1932pub struct CommodityAttrPlugin {
1939 required_attrs: Vec<(String, Option<Vec<String>>)>,
1941}
1942
1943impl CommodityAttrPlugin {
1944 pub const fn new() -> Self {
1946 Self {
1947 required_attrs: Vec::new(),
1948 }
1949 }
1950
1951 pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
1953 Self {
1954 required_attrs: attrs,
1955 }
1956 }
1957
1958 fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
1962 let mut result = Vec::new();
1963
1964 let trimmed = config.trim();
1967 let content = if trimmed.starts_with('{') && trimmed.ends_with('}') {
1968 &trimmed[1..trimmed.len() - 1]
1969 } else {
1970 trimmed
1971 };
1972
1973 let mut depth = 0;
1975 let mut current = String::new();
1976 let mut entries = Vec::new();
1977
1978 for c in content.chars() {
1979 match c {
1980 '[' => {
1981 depth += 1;
1982 current.push(c);
1983 }
1984 ']' => {
1985 depth -= 1;
1986 current.push(c);
1987 }
1988 ',' if depth == 0 => {
1989 entries.push(current.trim().to_string());
1990 current.clear();
1991 }
1992 _ => current.push(c),
1993 }
1994 }
1995 if !current.trim().is_empty() {
1996 entries.push(current.trim().to_string());
1997 }
1998
1999 for entry in entries {
2001 if let Some((key_part, value_part)) = entry.split_once(':') {
2002 let key = key_part
2003 .trim()
2004 .trim_matches('\'')
2005 .trim_matches('"')
2006 .to_string();
2007 let value = value_part.trim();
2008
2009 if value == "null" || value == "None" {
2010 result.push((key, None));
2011 } else if value.starts_with('[') && value.ends_with(']') {
2012 let inner = &value[1..value.len() - 1];
2014 let allowed: Vec<String> = inner
2015 .split(',')
2016 .map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string())
2017 .filter(|s| !s.is_empty())
2018 .collect();
2019 result.push((key, Some(allowed)));
2020 }
2021 }
2022 }
2023
2024 result
2025 }
2026}
2027
2028impl Default for CommodityAttrPlugin {
2029 fn default() -> Self {
2030 Self::new()
2031 }
2032}
2033
2034impl NativePlugin for CommodityAttrPlugin {
2035 fn name(&self) -> &'static str {
2036 "commodity_attr"
2037 }
2038
2039 fn description(&self) -> &'static str {
2040 "Validate commodity metadata attributes"
2041 }
2042
2043 fn process(&self, input: PluginInput) -> PluginOutput {
2044 let required = if let Some(config) = &input.config {
2046 Self::parse_config(config)
2047 } else {
2048 self.required_attrs.clone()
2049 };
2050
2051 if required.is_empty() {
2053 return PluginOutput {
2054 directives: input.directives,
2055 errors: Vec::new(),
2056 };
2057 }
2058
2059 let mut errors = Vec::new();
2060
2061 for wrapper in &input.directives {
2062 if let DirectiveData::Commodity(comm) = &wrapper.data {
2063 for (attr_name, allowed_values) in &required {
2065 let found = comm.metadata.iter().find(|(k, _)| k == attr_name);
2067
2068 match found {
2069 None => {
2070 errors.push(PluginError::error(format!(
2071 "Commodity '{}' missing required attribute '{}'",
2072 comm.currency, attr_name
2073 )));
2074 }
2075 Some((_, value)) => {
2076 if let Some(allowed) = allowed_values {
2078 let value_str = match value {
2079 crate::types::MetaValueData::String(s) => s.clone(),
2080 other => format!("{other:?}"),
2081 };
2082 if !allowed.contains(&value_str) {
2083 errors.push(PluginError::error(format!(
2084 "Commodity '{}' attribute '{}' has invalid value '{}' (allowed: {:?})",
2085 comm.currency, attr_name, value_str, allowed
2086 )));
2087 }
2088 }
2089 }
2090 }
2091 }
2092 }
2093 }
2094
2095 PluginOutput {
2096 directives: input.directives,
2097 errors,
2098 }
2099 }
2100}
2101
2102#[cfg(test)]
2103mod commodity_attr_tests {
2104 use super::*;
2105 use crate::types::*;
2106
2107 #[test]
2108 fn test_commodity_attr_missing_required() {
2109 let plugin = CommodityAttrPlugin::new();
2110
2111 let input = PluginInput {
2112 directives: vec![DirectiveWrapper {
2113 directive_type: "commodity".to_string(),
2114 date: "2024-01-01".to_string(),
2115 data: DirectiveData::Commodity(CommodityData {
2116 currency: "AAPL".to_string(),
2117 metadata: vec![], }),
2119 }],
2120 options: PluginOptions {
2121 operating_currencies: vec!["USD".to_string()],
2122 title: None,
2123 },
2124 config: Some("{'name': null}".to_string()),
2125 };
2126
2127 let output = plugin.process(input);
2128 assert_eq!(output.errors.len(), 1);
2129 assert!(output.errors[0].message.contains("missing required"));
2130 assert!(output.errors[0].message.contains("name"));
2131 }
2132
2133 #[test]
2134 fn test_commodity_attr_has_required() {
2135 let plugin = CommodityAttrPlugin::new();
2136
2137 let input = PluginInput {
2138 directives: vec![DirectiveWrapper {
2139 directive_type: "commodity".to_string(),
2140 date: "2024-01-01".to_string(),
2141 data: DirectiveData::Commodity(CommodityData {
2142 currency: "AAPL".to_string(),
2143 metadata: vec![(
2144 "name".to_string(),
2145 MetaValueData::String("Apple Inc".to_string()),
2146 )],
2147 }),
2148 }],
2149 options: PluginOptions {
2150 operating_currencies: vec!["USD".to_string()],
2151 title: None,
2152 },
2153 config: Some("{'name': null}".to_string()),
2154 };
2155
2156 let output = plugin.process(input);
2157 assert_eq!(output.errors.len(), 0);
2158 }
2159
2160 #[test]
2161 fn test_commodity_attr_invalid_value() {
2162 let plugin = CommodityAttrPlugin::new();
2163
2164 let input = PluginInput {
2165 directives: vec![DirectiveWrapper {
2166 directive_type: "commodity".to_string(),
2167 date: "2024-01-01".to_string(),
2168 data: DirectiveData::Commodity(CommodityData {
2169 currency: "AAPL".to_string(),
2170 metadata: vec![(
2171 "sector".to_string(),
2172 MetaValueData::String("Healthcare".to_string()),
2173 )],
2174 }),
2175 }],
2176 options: PluginOptions {
2177 operating_currencies: vec!["USD".to_string()],
2178 title: None,
2179 },
2180 config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
2181 };
2182
2183 let output = plugin.process(input);
2184 assert_eq!(output.errors.len(), 1);
2185 assert!(output.errors[0].message.contains("invalid value"));
2186 assert!(output.errors[0].message.contains("Healthcare"));
2187 }
2188
2189 #[test]
2190 fn test_commodity_attr_valid_value() {
2191 let plugin = CommodityAttrPlugin::new();
2192
2193 let input = PluginInput {
2194 directives: vec![DirectiveWrapper {
2195 directive_type: "commodity".to_string(),
2196 date: "2024-01-01".to_string(),
2197 data: DirectiveData::Commodity(CommodityData {
2198 currency: "AAPL".to_string(),
2199 metadata: vec![(
2200 "sector".to_string(),
2201 MetaValueData::String("Tech".to_string()),
2202 )],
2203 }),
2204 }],
2205 options: PluginOptions {
2206 operating_currencies: vec!["USD".to_string()],
2207 title: None,
2208 },
2209 config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
2210 };
2211
2212 let output = plugin.process(input);
2213 assert_eq!(output.errors.len(), 0);
2214 }
2215
2216 #[test]
2217 fn test_config_parsing() {
2218 let config = "{'name': null, 'sector': ['Tech', 'Finance']}";
2219 let parsed = CommodityAttrPlugin::parse_config(config);
2220
2221 assert_eq!(parsed.len(), 2);
2222 assert_eq!(parsed[0].0, "name");
2223 assert!(parsed[0].1.is_none());
2224 assert_eq!(parsed[1].0, "sector");
2225 assert_eq!(parsed[1].1.as_ref().unwrap(), &vec!["Tech", "Finance"]);
2226 }
2227}
2228
2229pub struct CheckAverageCostPlugin {
2235 tolerance: rust_decimal::Decimal,
2237}
2238
2239impl CheckAverageCostPlugin {
2240 pub fn new() -> Self {
2242 Self {
2243 tolerance: rust_decimal::Decimal::new(1, 2), }
2245 }
2246
2247 pub const fn with_tolerance(tolerance: rust_decimal::Decimal) -> Self {
2249 Self { tolerance }
2250 }
2251}
2252
2253impl Default for CheckAverageCostPlugin {
2254 fn default() -> Self {
2255 Self::new()
2256 }
2257}
2258
2259impl NativePlugin for CheckAverageCostPlugin {
2260 fn name(&self) -> &'static str {
2261 "check_average_cost"
2262 }
2263
2264 fn description(&self) -> &'static str {
2265 "Validate reducing postings match average cost"
2266 }
2267
2268 fn process(&self, input: PluginInput) -> PluginOutput {
2269 use rust_decimal::Decimal;
2270 use std::collections::HashMap;
2271 use std::str::FromStr;
2272
2273 let tolerance = if let Some(config) = &input.config {
2275 Decimal::from_str(config.trim()).unwrap_or(self.tolerance)
2276 } else {
2277 self.tolerance
2278 };
2279
2280 let mut inventory: HashMap<(String, String), (Decimal, Decimal)> = HashMap::new();
2283
2284 let mut errors = Vec::new();
2285
2286 for wrapper in &input.directives {
2287 if let DirectiveData::Transaction(txn) = &wrapper.data {
2288 for posting in &txn.postings {
2289 let Some(units) = &posting.units else {
2291 continue;
2292 };
2293 let Some(cost) = &posting.cost else {
2294 continue;
2295 };
2296
2297 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
2298 let Some(cost_currency) = &cost.currency else {
2299 continue;
2300 };
2301
2302 let key = (posting.account.clone(), units.currency.clone());
2303
2304 if units_num > Decimal::ZERO {
2305 let cost_per = cost
2307 .number_per
2308 .as_ref()
2309 .and_then(|s| Decimal::from_str(s).ok())
2310 .unwrap_or_default();
2311
2312 let entry = inventory
2313 .entry(key)
2314 .or_insert((Decimal::ZERO, Decimal::ZERO));
2315 entry.0 += units_num; entry.1 += units_num * cost_per; } else if units_num < Decimal::ZERO {
2318 let entry = inventory.get(&key);
2320
2321 if let Some((total_units, total_cost)) = entry {
2322 if *total_units > Decimal::ZERO {
2323 let avg_cost = *total_cost / *total_units;
2324
2325 let used_cost = cost
2327 .number_per
2328 .as_ref()
2329 .and_then(|s| Decimal::from_str(s).ok())
2330 .unwrap_or_default();
2331
2332 let diff = (used_cost - avg_cost).abs();
2334 let relative_diff = if avg_cost == Decimal::ZERO {
2335 diff
2336 } else {
2337 diff / avg_cost
2338 };
2339
2340 if relative_diff > tolerance {
2341 errors.push(PluginError::warning(format!(
2342 "Sale of {} {} in {} uses cost {} {} but average cost is {} {} (difference: {:.2}%)",
2343 units_num.abs(),
2344 units.currency,
2345 posting.account,
2346 used_cost,
2347 cost_currency,
2348 avg_cost.round_dp(4),
2349 cost_currency,
2350 relative_diff * Decimal::from(100)
2351 )));
2352 }
2353
2354 let entry = inventory.get_mut(&key).unwrap();
2356 let units_sold = units_num.abs();
2357 let cost_removed = units_sold * avg_cost;
2358 entry.0 -= units_sold;
2359 entry.1 -= cost_removed;
2360 }
2361 }
2362 }
2363 }
2364 }
2365 }
2366
2367 PluginOutput {
2368 directives: input.directives,
2369 errors,
2370 }
2371 }
2372}
2373
2374#[cfg(test)]
2375mod check_average_cost_tests {
2376 use super::*;
2377 use crate::types::*;
2378
2379 #[test]
2380 fn test_check_average_cost_matching() {
2381 let plugin = CheckAverageCostPlugin::new();
2382
2383 let input = PluginInput {
2384 directives: vec![
2385 DirectiveWrapper {
2386 directive_type: "transaction".to_string(),
2387 date: "2024-01-01".to_string(),
2388 data: DirectiveData::Transaction(TransactionData {
2389 flag: "*".to_string(),
2390 payee: None,
2391 narration: "Buy".to_string(),
2392 tags: vec![],
2393 links: vec![],
2394 metadata: vec![],
2395 postings: vec![PostingData {
2396 account: "Assets:Broker".to_string(),
2397 units: Some(AmountData {
2398 number: "10".to_string(),
2399 currency: "AAPL".to_string(),
2400 }),
2401 cost: Some(CostData {
2402 number_per: Some("100.00".to_string()),
2403 number_total: None,
2404 currency: Some("USD".to_string()),
2405 date: None,
2406 label: None,
2407 merge: false,
2408 }),
2409 price: None,
2410 flag: None,
2411 metadata: vec![],
2412 }],
2413 }),
2414 },
2415 DirectiveWrapper {
2416 directive_type: "transaction".to_string(),
2417 date: "2024-02-01".to_string(),
2418 data: DirectiveData::Transaction(TransactionData {
2419 flag: "*".to_string(),
2420 payee: None,
2421 narration: "Sell at avg cost".to_string(),
2422 tags: vec![],
2423 links: vec![],
2424 metadata: vec![],
2425 postings: vec![PostingData {
2426 account: "Assets:Broker".to_string(),
2427 units: Some(AmountData {
2428 number: "-5".to_string(),
2429 currency: "AAPL".to_string(),
2430 }),
2431 cost: Some(CostData {
2432 number_per: Some("100.00".to_string()), number_total: None,
2434 currency: Some("USD".to_string()),
2435 date: None,
2436 label: None,
2437 merge: false,
2438 }),
2439 price: None,
2440 flag: None,
2441 metadata: vec![],
2442 }],
2443 }),
2444 },
2445 ],
2446 options: PluginOptions {
2447 operating_currencies: vec!["USD".to_string()],
2448 title: None,
2449 },
2450 config: None,
2451 };
2452
2453 let output = plugin.process(input);
2454 assert_eq!(output.errors.len(), 0);
2455 }
2456
2457 #[test]
2458 fn test_check_average_cost_mismatch() {
2459 let plugin = CheckAverageCostPlugin::new();
2460
2461 let input = PluginInput {
2462 directives: vec![
2463 DirectiveWrapper {
2464 directive_type: "transaction".to_string(),
2465 date: "2024-01-01".to_string(),
2466 data: DirectiveData::Transaction(TransactionData {
2467 flag: "*".to_string(),
2468 payee: None,
2469 narration: "Buy at 100".to_string(),
2470 tags: vec![],
2471 links: vec![],
2472 metadata: vec![],
2473 postings: vec![PostingData {
2474 account: "Assets:Broker".to_string(),
2475 units: Some(AmountData {
2476 number: "10".to_string(),
2477 currency: "AAPL".to_string(),
2478 }),
2479 cost: Some(CostData {
2480 number_per: Some("100.00".to_string()),
2481 number_total: None,
2482 currency: Some("USD".to_string()),
2483 date: None,
2484 label: None,
2485 merge: false,
2486 }),
2487 price: None,
2488 flag: None,
2489 metadata: vec![],
2490 }],
2491 }),
2492 },
2493 DirectiveWrapper {
2494 directive_type: "transaction".to_string(),
2495 date: "2024-02-01".to_string(),
2496 data: DirectiveData::Transaction(TransactionData {
2497 flag: "*".to_string(),
2498 payee: None,
2499 narration: "Sell at wrong cost".to_string(),
2500 tags: vec![],
2501 links: vec![],
2502 metadata: vec![],
2503 postings: vec![PostingData {
2504 account: "Assets:Broker".to_string(),
2505 units: Some(AmountData {
2506 number: "-5".to_string(),
2507 currency: "AAPL".to_string(),
2508 }),
2509 cost: Some(CostData {
2510 number_per: Some("90.00".to_string()), number_total: None,
2512 currency: Some("USD".to_string()),
2513 date: None,
2514 label: None,
2515 merge: false,
2516 }),
2517 price: None,
2518 flag: None,
2519 metadata: vec![],
2520 }],
2521 }),
2522 },
2523 ],
2524 options: PluginOptions {
2525 operating_currencies: vec!["USD".to_string()],
2526 title: None,
2527 },
2528 config: None,
2529 };
2530
2531 let output = plugin.process(input);
2532 assert_eq!(output.errors.len(), 1);
2533 assert!(output.errors[0].message.contains("average cost"));
2534 }
2535
2536 #[test]
2537 fn test_check_average_cost_multiple_buys() {
2538 let plugin = CheckAverageCostPlugin::new();
2539
2540 let input = PluginInput {
2542 directives: vec![
2543 DirectiveWrapper {
2544 directive_type: "transaction".to_string(),
2545 date: "2024-01-01".to_string(),
2546 data: DirectiveData::Transaction(TransactionData {
2547 flag: "*".to_string(),
2548 payee: None,
2549 narration: "Buy at 100".to_string(),
2550 tags: vec![],
2551 links: vec![],
2552 metadata: vec![],
2553 postings: vec![PostingData {
2554 account: "Assets:Broker".to_string(),
2555 units: Some(AmountData {
2556 number: "10".to_string(),
2557 currency: "AAPL".to_string(),
2558 }),
2559 cost: Some(CostData {
2560 number_per: Some("100.00".to_string()),
2561 number_total: None,
2562 currency: Some("USD".to_string()),
2563 date: None,
2564 label: None,
2565 merge: false,
2566 }),
2567 price: None,
2568 flag: None,
2569 metadata: vec![],
2570 }],
2571 }),
2572 },
2573 DirectiveWrapper {
2574 directive_type: "transaction".to_string(),
2575 date: "2024-01-15".to_string(),
2576 data: DirectiveData::Transaction(TransactionData {
2577 flag: "*".to_string(),
2578 payee: None,
2579 narration: "Buy at 120".to_string(),
2580 tags: vec![],
2581 links: vec![],
2582 metadata: vec![],
2583 postings: vec![PostingData {
2584 account: "Assets:Broker".to_string(),
2585 units: Some(AmountData {
2586 number: "10".to_string(),
2587 currency: "AAPL".to_string(),
2588 }),
2589 cost: Some(CostData {
2590 number_per: Some("120.00".to_string()),
2591 number_total: None,
2592 currency: Some("USD".to_string()),
2593 date: None,
2594 label: None,
2595 merge: false,
2596 }),
2597 price: None,
2598 flag: None,
2599 metadata: vec![],
2600 }],
2601 }),
2602 },
2603 DirectiveWrapper {
2604 directive_type: "transaction".to_string(),
2605 date: "2024-02-01".to_string(),
2606 data: DirectiveData::Transaction(TransactionData {
2607 flag: "*".to_string(),
2608 payee: None,
2609 narration: "Sell at avg cost".to_string(),
2610 tags: vec![],
2611 links: vec![],
2612 metadata: vec![],
2613 postings: vec![PostingData {
2614 account: "Assets:Broker".to_string(),
2615 units: Some(AmountData {
2616 number: "-5".to_string(),
2617 currency: "AAPL".to_string(),
2618 }),
2619 cost: Some(CostData {
2620 number_per: Some("110.00".to_string()), number_total: None,
2622 currency: Some("USD".to_string()),
2623 date: None,
2624 label: None,
2625 merge: false,
2626 }),
2627 price: None,
2628 flag: None,
2629 metadata: vec![],
2630 }],
2631 }),
2632 },
2633 ],
2634 options: PluginOptions {
2635 operating_currencies: vec!["USD".to_string()],
2636 title: None,
2637 },
2638 config: None,
2639 };
2640
2641 let output = plugin.process(input);
2642 assert_eq!(output.errors.len(), 0);
2643 }
2644}
2645
2646pub struct CurrencyAccountsPlugin {
2653 base_account: String,
2655}
2656
2657impl CurrencyAccountsPlugin {
2658 pub fn new() -> Self {
2660 Self {
2661 base_account: "Equity:CurrencyAccounts".to_string(),
2662 }
2663 }
2664
2665 pub const fn with_base_account(base_account: String) -> Self {
2667 Self { base_account }
2668 }
2669}
2670
2671impl Default for CurrencyAccountsPlugin {
2672 fn default() -> Self {
2673 Self::new()
2674 }
2675}
2676
2677impl NativePlugin for CurrencyAccountsPlugin {
2678 fn name(&self) -> &'static str {
2679 "currency_accounts"
2680 }
2681
2682 fn description(&self) -> &'static str {
2683 "Auto-generate currency trading postings"
2684 }
2685
2686 fn process(&self, input: PluginInput) -> PluginOutput {
2687 use crate::types::{AmountData, PostingData};
2688 use rust_decimal::Decimal;
2689 use std::collections::HashMap;
2690 use std::str::FromStr;
2691
2692 let base_account = input
2694 .config
2695 .as_ref()
2696 .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
2697
2698 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
2699
2700 for wrapper in &input.directives {
2701 if let DirectiveData::Transaction(txn) = &wrapper.data {
2702 let mut currency_totals: HashMap<String, Decimal> = HashMap::new();
2705
2706 for posting in &txn.postings {
2707 if let Some(units) = &posting.units {
2708 let amount = Decimal::from_str(&units.number).unwrap_or_default();
2709 *currency_totals.entry(units.currency.clone()).or_default() += amount;
2710 }
2711 }
2712
2713 let non_zero_currencies: Vec<_> = currency_totals
2715 .iter()
2716 .filter(|(_, &total)| total != Decimal::ZERO)
2717 .collect();
2718
2719 if non_zero_currencies.len() > 1 {
2720 let mut modified_txn = txn.clone();
2722
2723 for (currency, &total) in &non_zero_currencies {
2724 modified_txn.postings.push(PostingData {
2726 account: format!("{base_account}:{currency}"),
2727 units: Some(AmountData {
2728 number: (-total).to_string(),
2729 currency: (*currency).clone(),
2730 }),
2731 cost: None,
2732 price: None,
2733 flag: None,
2734 metadata: vec![],
2735 });
2736 }
2737
2738 new_directives.push(DirectiveWrapper {
2739 directive_type: wrapper.directive_type.clone(),
2740 date: wrapper.date.clone(),
2741 data: DirectiveData::Transaction(modified_txn),
2742 });
2743 } else {
2744 new_directives.push(wrapper.clone());
2746 }
2747 } else {
2748 new_directives.push(wrapper.clone());
2749 }
2750 }
2751
2752 PluginOutput {
2753 directives: new_directives,
2754 errors: Vec::new(),
2755 }
2756 }
2757}
2758
2759#[cfg(test)]
2760mod currency_accounts_tests {
2761 use super::*;
2762 use crate::types::*;
2763
2764 #[test]
2765 fn test_currency_accounts_adds_balancing_postings() {
2766 let plugin = CurrencyAccountsPlugin::new();
2767
2768 let input = PluginInput {
2769 directives: vec![DirectiveWrapper {
2770 directive_type: "transaction".to_string(),
2771 date: "2024-01-15".to_string(),
2772 data: DirectiveData::Transaction(TransactionData {
2773 flag: "*".to_string(),
2774 payee: None,
2775 narration: "Currency exchange".to_string(),
2776 tags: vec![],
2777 links: vec![],
2778 metadata: vec![],
2779 postings: vec![
2780 PostingData {
2781 account: "Assets:Bank:USD".to_string(),
2782 units: Some(AmountData {
2783 number: "-100".to_string(),
2784 currency: "USD".to_string(),
2785 }),
2786 cost: None,
2787 price: None,
2788 flag: None,
2789 metadata: vec![],
2790 },
2791 PostingData {
2792 account: "Assets:Bank:EUR".to_string(),
2793 units: Some(AmountData {
2794 number: "85".to_string(),
2795 currency: "EUR".to_string(),
2796 }),
2797 cost: None,
2798 price: None,
2799 flag: None,
2800 metadata: vec![],
2801 },
2802 ],
2803 }),
2804 }],
2805 options: PluginOptions {
2806 operating_currencies: vec!["USD".to_string()],
2807 title: None,
2808 },
2809 config: None,
2810 };
2811
2812 let output = plugin.process(input);
2813 assert_eq!(output.errors.len(), 0);
2814 assert_eq!(output.directives.len(), 1);
2815
2816 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2817 assert_eq!(txn.postings.len(), 4);
2819
2820 let usd_posting = txn
2822 .postings
2823 .iter()
2824 .find(|p| p.account == "Equity:CurrencyAccounts:USD");
2825 assert!(usd_posting.is_some());
2826 let usd_posting = usd_posting.unwrap();
2827 assert_eq!(usd_posting.units.as_ref().unwrap().number, "100");
2829
2830 let eur_posting = txn
2831 .postings
2832 .iter()
2833 .find(|p| p.account == "Equity:CurrencyAccounts:EUR");
2834 assert!(eur_posting.is_some());
2835 let eur_posting = eur_posting.unwrap();
2836 assert_eq!(eur_posting.units.as_ref().unwrap().number, "-85");
2838 } else {
2839 panic!("Expected Transaction directive");
2840 }
2841 }
2842
2843 #[test]
2844 fn test_currency_accounts_single_currency_unchanged() {
2845 let plugin = CurrencyAccountsPlugin::new();
2846
2847 let input = PluginInput {
2848 directives: vec![DirectiveWrapper {
2849 directive_type: "transaction".to_string(),
2850 date: "2024-01-15".to_string(),
2851 data: DirectiveData::Transaction(TransactionData {
2852 flag: "*".to_string(),
2853 payee: None,
2854 narration: "Simple transfer".to_string(),
2855 tags: vec![],
2856 links: vec![],
2857 metadata: vec![],
2858 postings: vec![
2859 PostingData {
2860 account: "Assets:Bank".to_string(),
2861 units: Some(AmountData {
2862 number: "-100".to_string(),
2863 currency: "USD".to_string(),
2864 }),
2865 cost: None,
2866 price: None,
2867 flag: None,
2868 metadata: vec![],
2869 },
2870 PostingData {
2871 account: "Expenses:Food".to_string(),
2872 units: Some(AmountData {
2873 number: "100".to_string(),
2874 currency: "USD".to_string(),
2875 }),
2876 cost: None,
2877 price: None,
2878 flag: None,
2879 metadata: vec![],
2880 },
2881 ],
2882 }),
2883 }],
2884 options: PluginOptions {
2885 operating_currencies: vec!["USD".to_string()],
2886 title: None,
2887 },
2888 config: None,
2889 };
2890
2891 let output = plugin.process(input);
2892 assert_eq!(output.errors.len(), 0);
2893
2894 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2896 assert_eq!(txn.postings.len(), 2);
2897 }
2898 }
2899
2900 #[test]
2901 fn test_currency_accounts_custom_base_account() {
2902 let plugin = CurrencyAccountsPlugin::new();
2903
2904 let input = PluginInput {
2905 directives: vec![DirectiveWrapper {
2906 directive_type: "transaction".to_string(),
2907 date: "2024-01-15".to_string(),
2908 data: DirectiveData::Transaction(TransactionData {
2909 flag: "*".to_string(),
2910 payee: None,
2911 narration: "Exchange".to_string(),
2912 tags: vec![],
2913 links: vec![],
2914 metadata: vec![],
2915 postings: vec![
2916 PostingData {
2917 account: "Assets:USD".to_string(),
2918 units: Some(AmountData {
2919 number: "-50".to_string(),
2920 currency: "USD".to_string(),
2921 }),
2922 cost: None,
2923 price: None,
2924 flag: None,
2925 metadata: vec![],
2926 },
2927 PostingData {
2928 account: "Assets:EUR".to_string(),
2929 units: Some(AmountData {
2930 number: "42".to_string(),
2931 currency: "EUR".to_string(),
2932 }),
2933 cost: None,
2934 price: None,
2935 flag: None,
2936 metadata: vec![],
2937 },
2938 ],
2939 }),
2940 }],
2941 options: PluginOptions {
2942 operating_currencies: vec!["USD".to_string()],
2943 title: None,
2944 },
2945 config: Some("Income:Trading".to_string()),
2946 };
2947
2948 let output = plugin.process(input);
2949 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
2950 assert!(txn
2952 .postings
2953 .iter()
2954 .any(|p| p.account.starts_with("Income:Trading:")));
2955 }
2956 }
2957}