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