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 {}