rustledger_plugin/
native.rs

1//! Native (non-WASM) plugin support.
2//!
3//! These plugins run as native Rust code for maximum performance.
4//! They implement the same interface as WASM plugins.
5
6use crate::types::{
7    DirectiveData, DirectiveWrapper, DocumentData, OpenData, PluginError, PluginInput,
8    PluginOutput, TransactionData,
9};
10
11/// Trait for native plugins.
12pub trait NativePlugin: Send + Sync {
13    /// Plugin name.
14    fn name(&self) -> &'static str;
15
16    /// Plugin description.
17    fn description(&self) -> &'static str;
18
19    /// Process directives and return modified directives + errors.
20    fn process(&self, input: PluginInput) -> PluginOutput;
21}
22
23/// Registry of built-in native plugins.
24pub struct NativePluginRegistry {
25    plugins: Vec<Box<dyn NativePlugin>>,
26}
27
28impl NativePluginRegistry {
29    /// Create a new registry with all built-in plugins.
30    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    /// Find a plugin by name.
57    pub fn find(&self, name: &str) -> Option<&dyn NativePlugin> {
58        // Check for beancount.plugins.* prefix
59        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    /// List all available plugins.
68    pub fn list(&self) -> Vec<&dyn NativePlugin> {
69        self.plugins.iter().map(AsRef::as_ref).collect()
70    }
71
72    /// Check if a name refers to a built-in plugin.
73    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
107/// Plugin that generates price entries from transaction costs and prices.
108///
109/// When a transaction has a posting with a cost or price annotation,
110/// this plugin generates a corresponding Price directive.
111pub 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            // Only process transactions
130            if wrapper.directive_type != "transaction" {
131                continue;
132            }
133
134            // Extract prices from transaction data
135            if let crate::types::DirectiveData::Transaction(ref txn) = wrapper.data {
136                for posting in &txn.postings {
137                    // Check for price annotation
138                    if let Some(ref units) = posting.units {
139                        if let Some(ref price) = posting.price {
140                            // Generate a price directive only if we have a complete amount
141                            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                        // Check for cost with price info
157                        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        // Add generated prices
183        new_directives.extend(generated_prices);
184
185        PluginOutput {
186            directives: new_directives,
187            errors: Vec::new(),
188        }
189    }
190}
191
192/// Plugin that checks all used commodities are declared.
193pub 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        // First pass: collect declared commodities
212        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        // Second pass: collect used commodities and check
221        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        // Report undeclared commodities
247        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
286/// Plugin that automatically adds tags based on account patterns.
287///
288/// This is an example plugin showing how to implement custom tagging logic.
289/// It can be configured with rules like:
290/// - "Expenses:Food" -> #food
291/// - "Expenses:Travel" -> #travel
292/// - "Assets:Bank" -> #banking
293pub struct AutoTagPlugin {
294    /// Rules mapping account prefixes to tags.
295    rules: Vec<(String, String)>,
296}
297
298impl AutoTagPlugin {
299    /// Create with default rules.
300    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    /// Create with custom rules.
312    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                        // Check each posting against rules
340                        for posting in &txn.postings {
341                            for (prefix, tag) in &self.rules {
342                                if posting.account.starts_with(prefix) {
343                                    // Add tag if not already present
344                                    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
428// ============================================================================
429// Additional Built-in Plugins
430// ============================================================================
431
432/// Plugin that auto-generates Open directives for accounts used without explicit open.
433pub 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(); // account -> date
449
450        // First pass: find all open directives and first use of each account
451        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        // Generate open directives for accounts without explicit open
481        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        // Add existing directives
497        new_directives.extend(input.directives);
498
499        // Sort by date
500        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
509/// Plugin that errors when posting to non-leaf (parent) accounts.
510pub 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        // Collect all accounts used
525        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        // Find parent accounts (accounts that are prefixes of others)
535        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        // Check for postings to parent accounts
545        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
566/// Plugin that detects duplicate transactions based on hash.
567pub 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
620/// Plugin that enforces single commodity per account.
621pub 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        // Track currencies used per account
636        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
666/// Plugin that enforces unique prices (one per commodity pair per day).
667pub 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        // Track (date, base_currency, quote_currency) tuples
682        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
708/// Plugin that auto-discovers document files from configured directories.
709///
710/// Scans directories specified in `option "documents"` for files matching
711/// the pattern: `{Account}/YYYY-MM-DD.description.*`
712///
713/// For example: `documents/Assets/Bank/Checking/2024-01-15.statement.pdf`
714/// generates: `2024-01-15 document Assets:Bank:Checking "documents/Assets/Bank/Checking/2024-01-15.statement.pdf"`
715pub struct DocumentDiscoveryPlugin {
716    /// Directories to scan for documents.
717    pub directories: Vec<String>,
718}
719
720impl DocumentDiscoveryPlugin {
721    /// Create a new plugin with the given directories.
722    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        // Collect existing document paths to avoid duplicates
743        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        // Scan each directory
751        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        // Add discovered documents to directives
771        let mut all_directives = input.directives;
772        all_directives.extend(new_directives);
773
774        // Sort by date
775        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/// Recursively scan a directory for document files.
785#[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            // Try to parse filename as YYYY-MM-DD.description.ext
803            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                    // Validate date format
810                    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                        // Extract account from path relative to base_dir
815                        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                                    // Skip if already exists
827                                    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
851/// Plugin that inserts zero balance assertion when posting has `closing: TRUE` metadata.
852///
853/// When a posting has metadata `closing: TRUE`, this plugin adds a balance assertion
854/// for that account with zero balance on the next day.
855pub 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                    // Check for closing: TRUE metadata
877                    let has_closing = posting.metadata.iter().any(|(key, val)| {
878                        key == "closing" && matches!(val, MetaValueData::Bool(true))
879                    });
880
881                    if has_closing {
882                        // Parse the date and add one day
883                        if let Some(next_date) = increment_date(&wrapper.date) {
884                            // Get the currency from the posting
885                            let currency = posting
886                                .units
887                                .as_ref()
888                                .map_or_else(|| "USD".to_string(), |u| u.currency.clone());
889
890                            // Add zero balance assertion
891                            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        // Sort by date
910        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
919/// Increment a date string by one day (YYYY-MM-DD format).
920fn 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    // Simple date increment (handles month/year rollovers)
931    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
955/// Plugin that closes all descendant accounts when a parent account closes.
956///
957/// When an account like `Assets:Bank` is closed, this plugin also generates
958/// close directives for all sub-accounts like `Assets:Bank:Checking`.
959pub 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        // Collect all accounts that are used
975        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        // Collect accounts that are explicitly closed
988        let mut closed_parents: Vec<(String, String)> = Vec::new(); // (account, date)
989        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        // Find child accounts for each closed parent
996        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                    // Check if already closed
1003                    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        // Sort by date
1025        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
1034/// Plugin that ensures currencies use cost OR price consistently, never both.
1035///
1036/// If a currency is used with cost notation `{...}`, it should not also be used
1037/// with price notation `@` in the same ledger, as this can lead to inconsistencies.
1038pub 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        // Track which currencies are used with cost vs price
1053        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(); // currency -> (type, date)
1056
1057        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        // Find currencies used with both
1082        let mut errors = Vec::new();
1083        for currency in currencies_with_cost.intersection(&currencies_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
1096/// Plugin that cross-checks declared gains against sale prices.
1097///
1098/// When selling a position at a price, this plugin verifies that any
1099/// income/expense postings match the expected gain/loss from the sale.
1100pub 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                // Find postings that are sales (negative units with cost and price)
1120                for posting in &txn.postings {
1121                    if let (Some(units), Some(cost), Some(price)) =
1122                        (&posting.units, &posting.cost, &posting.price)
1123                    {
1124                        // Check if this is a sale (negative units)
1125                        let units_num = Decimal::from_str(&units.number).unwrap_or_default();
1126                        if units_num >= Decimal::ZERO {
1127                            continue;
1128                        }
1129
1130                        // Get cost basis
1131                        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                        // Get sale price
1138                        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                        // Calculate expected gain/loss
1145                        let expected_gain = (sale_price - cost_per) * units_num.abs();
1146
1147                        // Look for income/expense posting that should match
1148                        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
1174/// Meta-plugin that enables all strict validation plugins.
1175///
1176/// This plugin runs multiple validation checks:
1177/// - leafonly: No postings to parent accounts
1178/// - onecommodity: Single currency per account
1179/// - `check_commodity`: All currencies must be declared
1180/// - noduplicates: No duplicate transactions
1181pub 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        // Run leafonly checks
1196        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        // Run onecommodity checks
1205        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        // Run noduplicates checks
1214        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        // Run check_commodity checks
1223        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
1238/// Plugin that calculates unrealized gains on positions.
1239///
1240/// For each position held at cost, this plugin can generate unrealized
1241/// gain/loss entries based on current market prices from the price database.
1242pub struct UnrealizedPlugin {
1243    /// Account to book unrealized gains to.
1244    pub gains_account: String,
1245}
1246
1247impl UnrealizedPlugin {
1248    /// Create a new plugin with the default gains account.
1249    pub fn new() -> Self {
1250        Self {
1251            gains_account: "Income:Unrealized".to_string(),
1252        }
1253    }
1254
1255    /// Create with a custom gains account.
1256    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        // Build price database from Price directives
1284        let mut prices: HashMap<(String, String), (String, Decimal)> = HashMap::new(); // (base, quote) -> (date, price)
1285
1286        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                // Keep the most recent price
1292                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        // Track positions by account
1303        let mut positions: HashMap<String, HashMap<String, (Decimal, Decimal)>> = HashMap::new(); // account -> currency -> (units, cost_basis)
1304
1305        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        // Calculate unrealized gains for positions with known prices
1338        for (account, currencies) in &positions {
1339            for (currency, (units, cost_basis)) in currencies {
1340                if *units == Decimal::ZERO {
1341                    continue;
1342                }
1343
1344                // Look for a price to the operating currency (assume USD for now)
1345                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                        // More than $0.01
1352                        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
1367/// Plugin that identifies accounts that are opened but never used.
1368///
1369/// Reports a warning for each account that has an Open directive but is never
1370/// referenced in any transaction, balance, pad, or other directive.
1371pub 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        // Collect all opened accounts and used accounts in one pass
1389        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                    // Closing an account counts as using it
1396                    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                    // Check custom directive values for account references
1418                    // Account names start with standard prefixes
1419                    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        // Find unused accounts (opened but never used)
1435        let mut errors = Vec::new();
1436        let mut unused: Vec<_> = opened_accounts
1437            .difference(&used_accounts)
1438            .cloned()
1439            .collect();
1440        unused.sort(); // Consistent ordering for output
1441
1442        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        // Close counts as usage, so no warning
1603        assert_eq!(output.errors.len(), 0);
1604    }
1605}
1606
1607/// Plugin that inserts zero balance assertions when balance sheet accounts are closed.
1608///
1609/// When a Close directive is encountered for an account (Assets, Liabilities, or Equity),
1610/// this plugin generates Balance directives with zero amounts for all currencies that
1611/// were used in that account. The assertions are dated one day after the close date.
1612pub 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        // Track currencies used per account
1628        let mut account_currencies: HashMap<String, HashSet<String>> = HashMap::new();
1629
1630        // First pass: collect all currencies used per account
1631        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                    // If Open has currencies, track them
1651                    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        // Second pass: generate balance assertions for closed balance sheet accounts
1663        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                // Only generate for balance sheet accounts (Assets, Liabilities, Equity)
1670                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                // Get currencies for this account
1682                if let Some(currencies) = account_currencies.get(&data.account) {
1683                    // Calculate the day after close
1684                    if let Some(next_date) = increment_date(&wrapper.date) {
1685                        // Generate zero balance assertion for each currency
1686                        let mut sorted_currencies: Vec<_> = currencies.iter().collect();
1687                        sorted_currencies.sort(); // Consistent ordering
1688
1689                        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        // Sort by date
1709        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        // Should have 4 directives: open, transaction, close, balance
1780        assert_eq!(output.directives.len(), 4);
1781
1782        // Find the balance directive
1783        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"); // Day after close
1790        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        // Should not add balance assertions for income/expense accounts
1831        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        // Should have 6 directives: open, 2 transactions, close, 2 balance assertions
1918        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        // Both should be dated 2025-01-01
1928        for b in &balances {
1929            assert_eq!(b.date, "2025-01-01");
1930        }
1931    }
1932}
1933
1934/// Plugin that validates Commodity directives have required metadata attributes.
1935///
1936/// Can be configured with a string specifying required attributes and their allowed values:
1937/// - `"{'name': null, 'sector': ['Tech', 'Finance']}"` means:
1938///   - `name` is required but any value is allowed
1939///   - `sector` is required and must be one of the allowed values
1940pub struct CommodityAttrPlugin {
1941    /// Required attributes and their allowed values (None means any value is allowed).
1942    required_attrs: Vec<(String, Option<Vec<String>>)>,
1943}
1944
1945impl CommodityAttrPlugin {
1946    /// Create with default configuration (no required attributes).
1947    pub const fn new() -> Self {
1948        Self {
1949            required_attrs: Vec::new(),
1950        }
1951    }
1952
1953    /// Create with required attributes.
1954    pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
1955        Self {
1956            required_attrs: attrs,
1957        }
1958    }
1959
1960    /// Parse configuration string in Python dict-like format.
1961    ///
1962    /// Example: `"{'name': null, 'sector': ['Tech', 'Finance']}"`
1963    fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
1964        let mut result = Vec::new();
1965
1966        // Simple parser for the config format
1967        // Strip outer braces and split by commas
1968        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        // Split by comma (careful with nested arrays)
1976        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        // Parse each entry: "'key': value"
2002        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                    // Parse array of allowed values
2015                    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        // Parse config if provided
2047        let required = if let Some(config) = &input.config {
2048            Self::parse_config(config)
2049        } else {
2050            self.required_attrs.clone()
2051        };
2052
2053        // If no required attributes configured, pass through
2054        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                // Check each required attribute
2066                for (attr_name, allowed_values) in &required {
2067                    // Find the attribute in metadata
2068                    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                            // Check if value is in allowed list (if specified)
2079                            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![], // Missing 'name'
2120                }),
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
2231/// Plugin that validates reducing postings use average cost for accounts with NONE booking.
2232///
2233/// For accounts with booking method NONE (average cost), when selling/reducing positions,
2234/// this plugin verifies that the cost basis used matches the calculated average cost
2235/// within a specified tolerance.
2236pub struct CheckAverageCostPlugin {
2237    /// Tolerance for cost comparison (default: 0.01 = 1%).
2238    tolerance: rust_decimal::Decimal,
2239}
2240
2241impl CheckAverageCostPlugin {
2242    /// Create with default tolerance (1%).
2243    pub fn new() -> Self {
2244        Self {
2245            tolerance: rust_decimal::Decimal::new(1, 2), // 0.01 = 1%
2246        }
2247    }
2248
2249    /// Create with custom tolerance.
2250    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        // Parse optional tolerance from config
2276        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        // Track average cost per account per commodity
2283        // Key: (account, commodity) -> (total_units, total_cost)
2284        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                    // Only process postings with units and cost
2292                    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                        // Acquisition: add to inventory
2308                        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; // total units
2318                        entry.1 += units_num * cost_per; // total cost
2319                    } else if units_num < Decimal::ZERO {
2320                        // Reduction: check against average cost
2321                        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                                // Get the cost used in this posting
2328                                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                                // Calculate relative difference
2335                                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                                // Update inventory
2357                                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()), // Matches average
2435                                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()), // 10% different from avg
2513                                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        // Buy 10 at $100, then 10 at $120 -> avg = $110
2543        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()), // Matches average
2623                                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
2648/// Plugin that auto-generates currency trading account postings.
2649///
2650/// For multi-currency transactions, this plugin adds neutralizing postings
2651/// to equity accounts like `Equity:CurrencyAccounts:USD` to track currency
2652/// conversion gains/losses. This enables proper reporting of currency
2653/// trading activity.
2654pub struct CurrencyAccountsPlugin {
2655    /// Base account for currency tracking (default: "Equity:CurrencyAccounts").
2656    base_account: String,
2657}
2658
2659impl CurrencyAccountsPlugin {
2660    /// Create with default base account.
2661    pub fn new() -> Self {
2662        Self {
2663            base_account: "Equity:CurrencyAccounts".to_string(),
2664        }
2665    }
2666
2667    /// Create with custom base account.
2668    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        // Get base account from config if provided
2695        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                // Calculate currency totals for this transaction
2705                // Map from currency -> total amount in that currency
2706                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                // If we have multiple currencies with non-zero totals, add balancing postings
2716                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                    // Clone the transaction and add currency account postings
2723                    let mut modified_txn = txn.clone();
2724
2725                    for &(currency, total) in &non_zero_currencies {
2726                        // Add posting to currency account to neutralize
2727                        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                    // Single currency or balanced - pass through
2747                    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            // Should have original 2 postings + 2 currency account postings
2820            assert_eq!(txn.postings.len(), 4);
2821
2822            // Check for currency account postings
2823            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            // Should neutralize the -100 USD
2830            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            // Should neutralize the 85 EUR
2839            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        // Single currency balanced - should not add any postings
2897        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            // Check for custom base account
2953            assert!(
2954                txn.postings
2955                    .iter()
2956                    .any(|p| p.account.starts_with("Income:Trading:"))
2957            );
2958        }
2959    }
2960}