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, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
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 — sell_gains operates on
42                        // post-booking transactions where the cost
43                        // carries a per-unit value (PerUnit or
44                        // PerUnitFromTotal, both expose per_unit()).
45                        let cost_per = cost
46                            .number
47                            .as_ref()
48                            .and_then(|cn| cn.per_unit())
49                            .map(|s| Decimal::from_str(s).unwrap_or_default())
50                            .unwrap_or_default();
51
52                        // Get sale price
53                        let sale_price = price
54                            .amount
55                            .as_ref()
56                            .and_then(|a| Decimal::from_str(&a.number).ok())
57                            .unwrap_or_default();
58
59                        // Calculate expected gain/loss
60                        let expected_gain = (sale_price - cost_per) * units_num.abs();
61
62                        // Look for income/expense posting that should match
63                        let has_gain_posting = txn.postings.iter().any(|p| {
64                            p.account.starts_with("Income:") || p.account.starts_with("Expenses:")
65                        });
66
67                        if expected_gain != Decimal::ZERO && !has_gain_posting {
68                            errors.push(PluginError::warning(format!(
69                                "Sale of {} {} at {} (cost {}) has expected gain/loss of {} but no Income/Expenses posting",
70                                units_num.abs(),
71                                units.currency,
72                                sale_price,
73                                cost_per,
74                                expected_gain
75                            )));
76                        }
77                    }
78                }
79            }
80        }
81
82        PluginOutput {
83            ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
84            errors,
85        }
86    }
87}
88
89impl RegularPlugin for SellGainsPlugin {}