Skip to main content

rustledger_plugin/native/plugins/
sell_gains.rs

1//! Cross-check capital gains against sales.
2
3use crate::types::{DirectiveData, PluginError, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7/// Plugin that cross-checks declared gains against sale prices.
8///
9/// When selling a position at a price, this plugin verifies that any
10/// income/expense postings match the expected gain/loss from the sale.
11pub struct SellGainsPlugin;
12
13impl NativePlugin for SellGainsPlugin {
14    fn name(&self) -> &'static str {
15        "sellgains"
16    }
17
18    fn description(&self) -> &'static str {
19        "Cross-check capital gains against sales"
20    }
21
22    fn process(&self, input: PluginInput) -> PluginOutput {
23        use rust_decimal::Decimal;
24        use std::str::FromStr;
25
26        let mut errors = Vec::new();
27
28        for wrapper in &input.directives {
29            if let DirectiveData::Transaction(txn) = &wrapper.data {
30                // Find postings that are sales (negative units with cost and price)
31                for posting in &txn.postings {
32                    if let (Some(units), Some(cost), Some(price)) =
33                        (&posting.units, &posting.cost, &posting.price)
34                    {
35                        // Check if this is a sale (negative units)
36                        let units_num = Decimal::from_str(&units.number).unwrap_or_default();
37                        if units_num >= Decimal::ZERO {
38                            continue;
39                        }
40
41                        // Get cost basis
42                        let cost_per = cost
43                            .number_per
44                            .as_ref()
45                            .and_then(|s| Decimal::from_str(s).ok())
46                            .unwrap_or_default();
47
48                        // Get sale price
49                        let sale_price = price
50                            .amount
51                            .as_ref()
52                            .and_then(|a| Decimal::from_str(&a.number).ok())
53                            .unwrap_or_default();
54
55                        // Calculate expected gain/loss
56                        let expected_gain = (sale_price - cost_per) * units_num.abs();
57
58                        // Look for income/expense posting that should match
59                        let has_gain_posting = txn.postings.iter().any(|p| {
60                            p.account.starts_with("Income:") || p.account.starts_with("Expenses:")
61                        });
62
63                        if expected_gain != Decimal::ZERO && !has_gain_posting {
64                            errors.push(PluginError::warning(format!(
65                                "Sale of {} {} at {} (cost {}) has expected gain/loss of {} but no Income/Expenses posting",
66                                units_num.abs(),
67                                units.currency,
68                                sale_price,
69                                cost_per,
70                                expected_gain
71                            )));
72                        }
73                    }
74                }
75            }
76        }
77
78        PluginOutput {
79            directives: input.directives,
80            errors,
81        }
82    }
83}