Skip to main content

rustledger_plugin/native/plugins/
implicit_prices.rs

1//! Plugin that generates price entries from transaction costs and prices.
2
3use crate::types::{
4    AmountData, CostData, DirectiveData, DirectiveWrapper, PluginInput, PluginOp, PluginOutput,
5    PriceAnnotationData, PriceAnnotationView, PriceData,
6};
7use rust_decimal::Decimal;
8use rustledger_core::extract_per_unit_price;
9use std::collections::{HashMap, HashSet};
10use std::str::FromStr;
11
12use super::super::NativePlugin;
13
14/// Canonical key for tracking lots in the per-account inventory used
15/// by the cost-only "skip on REDUCED" check. Mirrors what Python
16/// beancount's `Inventory.add_amount` keys lots on:
17/// `(currency, Cost(number, currency, date, label))`. We carry the
18/// account too so we get per-account tracking.
19type LotKey = (String, String, Option<String>);
20
21/// Build the cost-fingerprint half of [`LotKey`]. `None` for postings
22/// without a cost spec; otherwise a stable string of the lot's
23/// distinguishing fields. Two postings with the same `LotKey` will
24/// match against each other in the inventory tracker, exactly as
25/// `Inventory.add_amount` would.
26///
27/// The per-unit number is parsed to [`Decimal`] and then *normalized*
28/// (trailing-zero-stripped) before stringifying — without that step,
29/// numerically equivalent costs like `"100"` and `"100.00"` would
30/// produce different lot keys, so a sell at `{100 USD}` against a
31/// prior buy at `{100.00 USD}` wouldn't classify as `REDUCED` and the
32/// phantom price emit this gate exists to prevent would slip back in.
33/// Caught by Copilot review on PR #1061.
34///
35/// We canonicalize `number_per` from `number_total` (dividing by
36/// `|units|`) when only the total is set — keeps the key consistent
37/// regardless of which form the cost was originally written in. After
38/// booking has run the `number_per` field is normally populated
39/// anyway, but we don't assume that.
40fn cost_fingerprint(cost: &CostData, units_number: Decimal) -> Option<String> {
41    let currency = cost.currency.as_deref()?;
42    let per_unit_decimal: Decimal = if let Some(per_str) = &cost.number_per {
43        Decimal::from_str(per_str).ok()?
44    } else if let Some(total_str) = &cost.number_total {
45        let total = Decimal::from_str(total_str).ok()?;
46        if units_number.is_zero() {
47            return None;
48        }
49        total / units_number.abs()
50    } else {
51        return None;
52    };
53    let per_unit = per_unit_decimal.normalize().to_string();
54    let date = cost.date.as_deref().unwrap_or("");
55    let label = cost.label.as_deref().unwrap_or("");
56    Some(format!("{per_unit}|{currency}|{date}|{label}"))
57}
58
59/// Plugin that generates price entries from transaction postings.
60///
61/// For each posting with a `@`/`@@` price annotation or a `{...}` cost
62/// spec, generates a corresponding `Price` directive. Mirrors Python
63/// beancount's `beancount.plugins.implicit_prices`.
64///
65/// Per-posting price math is delegated to
66/// [`rustledger_core::extract_per_unit_price`] — the same helper used
67/// by the BQL query path. Pre-fix (issue #992) this plugin had its own
68/// implementation that emitted `@@` total amounts as per-unit prices
69/// (off by a factor of `units`) AND emitted both an annotation-derived
70/// AND a cost-derived price for postings that had both. Both bugs
71/// disappear once the helper is the single source of truth.
72///
73/// Augment-vs-reduce gating: Python's plugin uses
74/// `Inventory.add_position` and skips emitting the cost-derived price
75/// when the posting matched as `MatchResult.REDUCED` (a sell against
76/// an existing lot). Without that check, sells written as `-N CCY {}`
77/// — which booking later resolves to a specific lot's cost — produce
78/// a phantom price entry per match. We mirror that here by tracking
79/// per-account positions keyed on `(account, units.currency,
80/// cost-fingerprint)` and treating a posting as REDUCED when the
81/// running quantity for its key has the opposite sign. Price
82/// annotations (`@`/`@@`) still emit unconditionally even on reducing
83/// postings — Python's `from_price` branch fires before the REDUCED
84/// check too. Without this gate rledger over-emitted ~4 prices per
85/// reducing-sell-with-`{}` posting on fixtures like fava-portfolio-
86/// returns (closes the residual ~5 over-emit cases left behind by
87/// #1048).
88///
89/// Pipeline assumption: this plugin operates on **post-booking**
90/// directives. Postings that would cross zero (e.g. a `-150` sell
91/// against a `+100` lot) have already been split by the booker into
92/// two postings — one fully-reducing leg against the existing lot
93/// and one augmenting/creating leg for the residual. Our inline
94/// inventory update sees them sequentially and correctly classifies
95/// the residual leg as not-REDUCED. If the plugin is ever moved
96/// earlier in the pipeline, the gate would over-suppress on
97/// pre-split crossing postings.
98///
99/// Lots with a cost spec that carries neither `number_per` nor
100/// `number_total` (e.g. bare `{2024-01-01}`) aren't tracked in the
101/// inventory — `cost_fingerprint` returns `None` and the posting
102/// passes through the cost-emit branch directly. Python's
103/// `Inventory.add_amount` would still track these, but since the
104/// cost-derived emit path also requires a number, the tracker
105/// participation doesn't change emit decisions.
106pub struct ImplicitPricesPlugin;
107
108impl NativePlugin for ImplicitPricesPlugin {
109    fn name(&self) -> &'static str {
110        "implicit_prices"
111    }
112
113    fn description(&self) -> &'static str {
114        "Generate price entries from transaction costs/prices"
115    }
116
117    fn process(&self, input: PluginInput) -> PluginOutput {
118        let mut generated_prices = Vec::new();
119
120        // Per-account lot quantities, keyed identically to Python's
121        // `Inventory.add_amount`. Used solely to detect REDUCED for
122        // the cost-derived emit gate.
123        let mut inventory: HashMap<LotKey, Decimal> = HashMap::new();
124
125        // Dedup of emitted prices by `(date, base_currency, number,
126        // quote_currency)` — mirrors Python's `new_price_entry_map`
127        // which silently drops a second emit for the same key on the
128        // same day. Without this, two postings on the same day that
129        // resolve to the same per-unit price (e.g., two buys at the
130        // same lot cost) produce two identical entries in `#prices`,
131        // inflating the count vs bean-check.
132        let mut emitted_keys: HashSet<(String, String, String, String)> = HashSet::new();
133
134        for wrapper in &input.directives {
135            if wrapper.directive_type != "transaction" {
136                continue;
137            }
138
139            let DirectiveData::Transaction(ref txn) = wrapper.data else {
140                continue;
141            };
142
143            for posting in &txn.postings {
144                let Some(ref units) = posting.units else {
145                    continue;
146                };
147                let Ok(units_number) = Decimal::from_str(&units.number) else {
148                    continue;
149                };
150
151                // Use the typed `view()` enum to derive `is_total` and
152                // pull the amount via exhaustive matching — the type
153                // system rejects code that confuses Unit and Total
154                // (which was exactly the #992 bug shape). We then
155                // build the helper's `Option<(is_total, number,
156                // currency)>` descriptor; the helper ties currency to
157                // value for us, so passing `None` on parse failure or
158                // an incomplete annotation cleanly falls through to
159                // cost without pairing a fall-through value with a
160                // stale annotation currency.
161                let annotation = posting
162                    .price
163                    .as_ref()
164                    .map(PriceAnnotationData::view)
165                    .and_then(|view| {
166                        let (is_total, amount) = match view {
167                            PriceAnnotationView::Unit(a) => (false, a),
168                            PriceAnnotationView::Total(a) => (true, a),
169                            // Incomplete annotations: helper can't use
170                            // them; drop the descriptor entirely.
171                            PriceAnnotationView::UnitIncomplete { .. }
172                            | PriceAnnotationView::TotalIncomplete { .. } => return None,
173                        };
174                        let number = Decimal::from_str(&amount.number).ok()?;
175                        Some((is_total, number, amount.currency.clone()))
176                    });
177
178                // Same shape for cost: only build the descriptor when
179                // a currency is present AND at least one of per/total
180                // parses.
181                let cost = posting.cost.as_ref().and_then(|c| {
182                    let currency = c.currency.clone()?;
183                    let per = c
184                        .number_per
185                        .as_ref()
186                        .and_then(|n| Decimal::from_str(n).ok());
187                    let total = c
188                        .number_total
189                        .as_ref()
190                        .and_then(|n| Decimal::from_str(n).ok());
191                    if per.is_none() && total.is_none() {
192                        return None;
193                    }
194                    Some((per, total, currency))
195                });
196
197                // Update the per-account lot tracker BEFORE deciding
198                // whether to emit. The pre-update quantity is what
199                // tells us whether this posting reduces an existing
200                // lot (Python's `MatchResult.REDUCED`).
201                let reduced = if let Some(c) = posting.cost.as_ref()
202                    && let Some(fp) = cost_fingerprint(c, units_number)
203                {
204                    let key = (posting.account.clone(), units.currency.clone(), Some(fp));
205                    let prior = inventory.get(&key).copied().unwrap_or(Decimal::ZERO);
206                    let was_reduction = !prior.is_zero()
207                        && prior.is_sign_negative() != units_number.is_sign_negative();
208                    inventory.insert(key, prior + units_number);
209                    was_reduction
210                } else {
211                    false
212                };
213
214                // Two-phase resolution to mirror Python's
215                // `if posting.price → emit; elif cost && !REDUCED → emit`:
216                // we ask the helper for the annotation-only price first
217                // (passing `None` for cost), and only fall back to the
218                // cost path when the posting is augmenting (or a
219                // first-time CREATED). Calling the helper twice is
220                // cheap and avoids duplicating its priority logic.
221                let from_annotation =
222                    extract_per_unit_price(units_number, annotation, None::<(_, _, String)>);
223                let chosen = match (from_annotation, reduced) {
224                    (Some(p), _) => Some(p),
225                    (None, false) => extract_per_unit_price(units_number, None, cost),
226                    (None, true) => None,
227                };
228
229                let Some((per_unit, quote_currency)) = chosen else {
230                    continue;
231                };
232
233                // Dedup key uses the *normalized* (trailing-zero-stripped)
234                // decimal string so two postings with the same numeric
235                // per-unit value but different scales (e.g. "100" vs
236                // "100.00") collapse to the same key — Python's
237                // `new_price_entry_map` compares the numeric value, not
238                // its string form. The emitted price keeps the
239                // un-normalized representation so user-facing output
240                // preserves whatever scale the cost spec produced.
241                // Caught by Copilot review on PR #1061.
242                let per_unit_str = per_unit.to_string();
243                let dedup_key = (
244                    wrapper.date.clone(),
245                    units.currency.clone(),
246                    per_unit.normalize().to_string(),
247                    quote_currency.clone(),
248                );
249                if !emitted_keys.insert(dedup_key) {
250                    // Same (date, base, number, quote) already emitted —
251                    // skip. Matches Python beancount's silent dedup.
252                    continue;
253                }
254
255                generated_prices.push(DirectiveWrapper {
256                    directive_type: "price".to_string(),
257                    date: wrapper.date.clone(),
258                    filename: None, // plugin-generated
259                    lineno: None,
260                    data: DirectiveData::Price(PriceData {
261                        currency: units.currency.clone(),
262                        amount: AmountData {
263                            number: per_unit_str,
264                            currency: quote_currency,
265                        },
266                        metadata: vec![],
267                    }),
268                });
269            }
270        }
271
272        // Keep all input directives, then insert generated price entries.
273        let mut ops: Vec<PluginOp> = (0..input.directives.len()).map(PluginOp::Keep).collect();
274        for w in generated_prices {
275            ops.push(PluginOp::Insert(w));
276        }
277
278        PluginOutput {
279            ops,
280            errors: Vec::new(),
281        }
282    }
283}