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