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, PluginError, PluginInput, PluginOp,
5 PluginOutput, 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, RegularPlugin};
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 per-unit from total (dividing by `|units|`) when
36/// only the raw `Total` form is set — keeps the key consistent
37/// regardless of which form the cost was originally written in. After
38/// booking has run, the post-booking `PerUnitFromTotal` variant
39/// carries per-unit already, but we don't assume that.
40fn cost_fingerprint(cost: &CostData, units_number: Decimal) -> Option<String> {
41 let currency = cost.currency.as_deref()?;
42 // `per_unit()` covers both PerUnit and PerUnitFromTotal (both
43 // carry per-unit by host construction). Raw Total needs division
44 // here; bare-`{}` returns None.
45 let cn = cost.number.as_ref()?;
46 let per_unit_decimal: Decimal = if let Some(per_str) = cn.per_unit() {
47 Decimal::from_str(per_str).ok()?
48 } else {
49 let total_str = cn.total()?;
50 let total = Decimal::from_str(total_str).ok()?;
51 if units_number.is_zero() {
52 return None;
53 }
54 total / units_number.abs()
55 };
56 let per_unit = per_unit_decimal.normalize().to_string();
57 let date = cost.date.as_deref().unwrap_or("");
58 let label = cost.label.as_deref().unwrap_or("");
59 Some(format!("{per_unit}|{currency}|{date}|{label}"))
60}
61
62/// Plugin that generates price entries from transaction postings.
63///
64/// For each posting with a `@`/`@@` price annotation or a `{...}` cost
65/// spec, generates a corresponding `Price` directive. Mirrors Python
66/// beancount's `beancount.plugins.implicit_prices`.
67///
68/// Per-posting price math is delegated to
69/// [`rustledger_core::extract_per_unit_price`] — the same helper used
70/// by the BQL query path. Pre-fix (issue #992) this plugin had its own
71/// implementation that emitted `@@` total amounts as per-unit prices
72/// (off by a factor of `units`) AND emitted both an annotation-derived
73/// AND a cost-derived price for postings that had both. Both bugs
74/// disappear once the helper is the single source of truth.
75///
76/// Augment-vs-reduce gating: Python's plugin uses
77/// `Inventory.add_position` and skips emitting the cost-derived price
78/// when the posting matched as `MatchResult.REDUCED` (a sell against
79/// an existing lot). Without that check, sells written as `-N CCY {}`
80/// — which booking later resolves to a specific lot's cost — produce
81/// a phantom price entry per match. We mirror that here by tracking
82/// per-account positions keyed on `(account, units.currency,
83/// cost-fingerprint)` and treating a posting as REDUCED when the
84/// running quantity for its key has the opposite sign. Price
85/// annotations (`@`/`@@`) still emit unconditionally even on reducing
86/// postings — Python's `from_price` branch fires before the REDUCED
87/// check too. Without this gate rledger over-emitted ~4 prices per
88/// reducing-sell-with-`{}` posting on fixtures like fava-portfolio-
89/// returns (closes the residual ~5 over-emit cases left behind by
90/// #1048).
91///
92/// Pipeline assumption: this plugin operates on **post-booking**
93/// directives. Postings that would cross zero (e.g. a `-150` sell
94/// against a `+100` lot) have already been split by the booker into
95/// two postings — one fully-reducing leg against the existing lot
96/// and one augmenting/creating leg for the residual. Our inline
97/// inventory update sees them sequentially and correctly classifies
98/// the residual leg as not-REDUCED. If the plugin is ever moved
99/// earlier in the pipeline, the gate would over-suppress on
100/// pre-split crossing postings.
101///
102/// Lots with a cost spec that carries no `number` at all (e.g. bare
103/// `{2024-01-01}` — `CostNumber` is `None`) aren't tracked in the
104/// inventory — `cost_fingerprint` returns `None` and the posting
105/// passes through the cost-emit branch directly. Python's
106/// `Inventory.add_amount` would still track these, but since the
107/// cost-derived emit path also requires a number, the tracker
108/// participation doesn't change emit decisions.
109pub struct ImplicitPricesPlugin;
110
111impl NativePlugin for ImplicitPricesPlugin {
112 fn name(&self) -> &'static str {
113 "implicit_prices"
114 }
115
116 fn description(&self) -> &'static str {
117 "Generate price entries from transaction costs/prices"
118 }
119
120 fn process(&self, input: PluginInput) -> PluginOutput {
121 let mut generated_prices = Vec::new();
122 let mut errors: Vec<PluginError> = Vec::new();
123
124 // Per-account lot quantities, keyed identically to Python's
125 // `Inventory.add_amount`. Used solely to detect REDUCED for
126 // the cost-derived emit gate.
127 let mut inventory: HashMap<LotKey, Decimal> = HashMap::new();
128
129 // Dedup of emitted prices by `(date, base_currency, number,
130 // quote_currency)` — mirrors Python's `new_price_entry_map`
131 // which silently drops a second emit for the same key on the
132 // same day. Without this, two postings on the same day that
133 // resolve to the same per-unit price (e.g., two buys at the
134 // same lot cost) produce two identical entries in `#prices`,
135 // inflating the count vs bean-check.
136 let mut emitted_keys: HashSet<(String, String, String, String)> = HashSet::new();
137
138 for wrapper in &input.directives {
139 if wrapper.directive_type != "transaction" {
140 continue;
141 }
142
143 let DirectiveData::Transaction(ref txn) = wrapper.data else {
144 continue;
145 };
146
147 for posting in &txn.postings {
148 let Some(ref units) = posting.units else {
149 continue;
150 };
151 let Ok(units_number) = Decimal::from_str(&units.number) else {
152 continue;
153 };
154
155 // Use the typed `view()` enum to derive `is_total` and
156 // pull the amount via exhaustive matching — the type
157 // system rejects code that confuses Unit and Total
158 // (which was exactly the #992 bug shape). We then
159 // build the helper's `Option<(is_total, number,
160 // currency)>` descriptor; the helper ties currency to
161 // value for us, so passing `None` on parse failure or
162 // an incomplete annotation cleanly falls through to
163 // cost without pairing a fall-through value with a
164 // stale annotation currency.
165 let annotation = posting
166 .price
167 .as_ref()
168 .map(PriceAnnotationData::view)
169 .and_then(|view| {
170 let (is_total, amount) = match view {
171 PriceAnnotationView::Unit(a) => (false, a),
172 PriceAnnotationView::Total(a) => (true, a),
173 // Incomplete annotations: helper can't use
174 // them; drop the descriptor entirely.
175 PriceAnnotationView::UnitIncomplete { .. }
176 | PriceAnnotationView::TotalIncomplete { .. } => return None,
177 };
178 let number = Decimal::from_str(&amount.number).ok()?;
179 Some((is_total, number, amount.currency.clone()))
180 });
181
182 // Same shape for cost: only build the descriptor when
183 // a currency is present AND the cost number parses.
184 // Translate from wire format (CostNumberData) to core
185 // CostNumber for the shared helper. Conversion failures
186 // (e.g. a plugin upstream emitted inconsistent
187 // `PerUnitFromTotal`) surface as plugin warnings rather
188 // than silent drops — a plugin author whose buggy
189 // emission produces zero implicit prices now gets a
190 // signal (review A-4.5). `units_number` is already
191 // parsed above (line 150); reuse it instead of
192 // re-parsing.
193 let cost_result = posting.cost.as_ref().and_then(|c| {
194 let currency = c.currency.clone()?;
195 let number = match &c.number {
196 Some(rustledger_plugin_types::CostNumberData::PerUnit { value: n }) => {
197 match Decimal::from_str(n) {
198 Ok(d) => Some(rustledger_core::CostNumber::PerUnit { value: d }),
199 Err(_) => {
200 return Some(Err(format!(
201 "implicit_prices: posting on account {:?} has cost \
202 per_unit {n:?} that doesn't parse as a decimal",
203 posting.account
204 )));
205 }
206 }
207 }
208 Some(rustledger_plugin_types::CostNumberData::Total { value: n }) => {
209 match Decimal::from_str(n) {
210 Ok(d) => Some(rustledger_core::CostNumber::Total { value: d }),
211 Err(_) => {
212 return Some(Err(format!(
213 "implicit_prices: posting on account {:?} has cost \
214 total {n:?} that doesn't parse as a decimal",
215 posting.account
216 )));
217 }
218 }
219 }
220 Some(rustledger_plugin_types::CostNumberData::PerUnitFromTotal {
221 per_unit,
222 total,
223 }) => {
224 let per_unit_d = match Decimal::from_str(per_unit) {
225 Ok(d) => d,
226 Err(_) => {
227 return Some(Err(format!(
228 "implicit_prices: posting on account {:?} has \
229 PerUnitFromTotal per_unit {per_unit:?} that doesn't \
230 parse as a decimal",
231 posting.account
232 )));
233 }
234 };
235 let total_d = match Decimal::from_str(total) {
236 Ok(d) => d,
237 Err(_) => {
238 return Some(Err(format!(
239 "implicit_prices: posting on account {:?} has \
240 PerUnitFromTotal total {total:?} that doesn't parse \
241 as a decimal",
242 posting.account
243 )));
244 }
245 };
246 match rustledger_core::BookedCost::try_new(
247 per_unit_d,
248 total_d,
249 units_number,
250 ) {
251 Ok(b) => Some(rustledger_core::CostNumber::PerUnitFromTotal(b)),
252 Err(e) => {
253 return Some(Err(format!(
254 "implicit_prices: posting on account {:?}: {e}",
255 posting.account
256 )));
257 }
258 }
259 }
260 None => return None,
261 };
262 Some(Ok((number, currency)))
263 });
264 let cost = match cost_result {
265 Some(Ok(c)) => Some(c),
266 Some(Err(msg)) => {
267 errors.push(PluginError::warning(msg));
268 None
269 }
270 None => None,
271 };
272
273 // Update the per-account lot tracker BEFORE deciding
274 // whether to emit. The pre-update quantity is what
275 // tells us whether this posting reduces an existing
276 // lot (Python's `MatchResult.REDUCED`).
277 let reduced = if let Some(c) = posting.cost.as_ref()
278 && let Some(fp) = cost_fingerprint(c, units_number)
279 {
280 let key = (posting.account.clone(), units.currency.clone(), Some(fp));
281 let prior = inventory.get(&key).copied().unwrap_or(Decimal::ZERO);
282 let was_reduction = !prior.is_zero()
283 && prior.is_sign_negative() != units_number.is_sign_negative();
284 inventory.insert(key, prior + units_number);
285 was_reduction
286 } else {
287 false
288 };
289
290 // Two-phase resolution to mirror Python's
291 // `if posting.price → emit; elif cost && !REDUCED → emit`:
292 // we ask the helper for the annotation-only price first
293 // (passing `None` for cost), and only fall back to the
294 // cost path when the posting is augmenting (or a
295 // first-time CREATED). Calling the helper twice is
296 // cheap and avoids duplicating its priority logic.
297 let from_annotation = extract_per_unit_price(
298 units_number,
299 annotation,
300 None::<(Option<rustledger_core::CostNumber>, String)>,
301 );
302 let chosen = match (from_annotation, reduced) {
303 (Some(p), _) => Some(p),
304 (None, false) => extract_per_unit_price(units_number, None, cost),
305 (None, true) => None,
306 };
307
308 let Some((per_unit, quote_currency)) = chosen else {
309 continue;
310 };
311
312 // Dedup key uses the *normalized* (trailing-zero-stripped)
313 // decimal string so two postings with the same numeric
314 // per-unit value but different scales (e.g. "100" vs
315 // "100.00") collapse to the same key — Python's
316 // `new_price_entry_map` compares the numeric value, not
317 // its string form. The emitted price keeps the
318 // un-normalized representation so user-facing output
319 // preserves whatever scale the cost spec produced.
320 // Caught by Copilot review on PR #1061.
321 let per_unit_str = per_unit.to_string();
322 let dedup_key = (
323 wrapper.date.clone(),
324 units.currency.clone(),
325 per_unit.normalize().to_string(),
326 quote_currency.clone(),
327 );
328 if !emitted_keys.insert(dedup_key) {
329 // Same (date, base, number, quote) already emitted —
330 // skip. Matches Python beancount's silent dedup.
331 continue;
332 }
333
334 generated_prices.push(DirectiveWrapper {
335 directive_type: "price".to_string(),
336 date: wrapper.date.clone(),
337 filename: None, // plugin-generated
338 lineno: None,
339 data: DirectiveData::Price(PriceData {
340 currency: units.currency.clone(),
341 amount: AmountData {
342 number: per_unit_str,
343 currency: quote_currency,
344 },
345 metadata: vec![],
346 }),
347 });
348 }
349 }
350
351 // Keep all input directives, then insert generated price entries.
352 let mut ops: Vec<PluginOp> = (0..input.directives.len()).map(PluginOp::Keep).collect();
353 for w in generated_prices {
354 ops.push(PluginOp::Insert(w));
355 }
356
357 PluginOutput { ops, errors }
358 }
359}
360
361impl RegularPlugin for ImplicitPricesPlugin {}