Skip to main content

rustledger_plugin/native/plugins/
coherent_cost.rs

1//! Enforce consistent cost tracking per currency.
2
3use crate::types::{DirectiveData, PluginError, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7/// Plugin that ensures currencies are tracked consistently with cost or price-only.
8///
9/// If a currency is used with cost notation `{...}` in some postings, it should
10/// not be used with price-only notation `@` (without cost) in other postings,
11/// as this indicates inconsistent tracking.
12///
13/// Note: Having BOTH cost AND price on the same posting is valid and common
14/// when selling positions (cost = acquisition price, price = sale price).
15pub struct CoherentCostPlugin;
16
17impl NativePlugin for CoherentCostPlugin {
18    fn name(&self) -> &'static str {
19        "coherent_cost"
20    }
21
22    fn description(&self) -> &'static str {
23        "Enforce consistent cost tracking per currency"
24    }
25
26    fn process(&self, input: PluginInput) -> PluginOutput {
27        use std::collections::HashSet;
28
29        // Track currencies used with cost (with or without price)
30        // Use references to avoid cloning currency strings
31        let mut currencies_with_cost: HashSet<&str> = HashSet::new();
32        // Track currencies used with price-only (no cost)
33        let mut currencies_with_price_only: HashSet<&str> = HashSet::new();
34
35        for wrapper in &input.directives {
36            if let DirectiveData::Transaction(txn) = &wrapper.data {
37                for posting in &txn.postings {
38                    if let Some(units) = &posting.units {
39                        let currency = units.currency.as_str();
40
41                        // Check if this posting has cost
42                        if posting.cost.is_some() {
43                            currencies_with_cost.insert(currency);
44                        } else if posting.price.is_some() {
45                            // Price-only (no cost) - this is the problematic case
46                            currencies_with_price_only.insert(currency);
47                        }
48                    }
49                }
50            }
51        }
52
53        // Find currencies used with cost in some places and price-only in others
54        // Collect and sort for deterministic error ordering
55        let mut inconsistent: Vec<_> = currencies_with_cost
56            .intersection(&currencies_with_price_only)
57            .copied()
58            .collect();
59        inconsistent.sort_unstable();
60
61        let errors: Vec<_> = inconsistent
62            .into_iter()
63            .map(|currency| {
64                PluginError::error(format!(
65                    "Currency '{currency}' is used with both cost and price-only notation - this may cause inconsistencies"
66                ))
67            })
68            .collect();
69
70        PluginOutput {
71            directives: input.directives,
72            errors,
73        }
74    }
75}