rustledger_core/implicit_prices.rs
1//! Shared implicit-price extraction logic.
2//!
3//! Mirrors Python beancount's `implicit_prices` plugin behavior. Used
4//! by BOTH the BQL query path (`rustledger-query::price`) and the
5//! native `implicit_prices` plugin (`rustledger-plugin`). Centralizing
6//! avoids the parallel-implementations divergence that produced
7//! [issue #992]: the plugin emitted `@@` total amounts as per-unit
8//! prices, while the query path correctly divided them.
9//!
10//! The helper is generic over the currency type (`T`) because the
11//! plugin and query paths use different transaction representations
12//! (`crate::Transaction` with `InternedStr` vs
13//! `rustledger_plugin_types::TransactionData` with `String`). Each
14//! caller assembles its annotation/cost descriptors with its own
15//! currency type and the helper returns the per-unit price already
16//! paired with the matching currency — making mismatched
17//! (number, currency) pairs impossible to construct.
18//!
19//! [issue #992]: https://github.com/rustledger/rustledger/issues/992
20
21use rust_decimal::Decimal;
22
23/// Decide the per-unit price implied by a posting and the quote
24/// currency to pair with it.
25///
26/// The currency is returned alongside the per-unit `Decimal` so that
27/// callers can never accidentally pair a cost-derived value with the
28/// annotation currency. The helper matches each value to the currency
29/// that came in with it.
30///
31/// Resolution order, mirroring upstream beancount's
32/// `beancount.plugins.implicit_prices`:
33///
34/// 1. **Price annotation** (`@` or `@@`) — if a parsed number and
35/// currency are present (`annotation` is `Some`).
36/// For `@@` (`is_total = true`), divides the total by
37/// `units_number.abs()`. For `@` (`is_total = false`), returns the
38/// number directly.
39/// 2. **Cost spec** — only as a fallback when no usable price
40/// annotation. `CostNumber::PerUnit` and `PerUnitFromTotal` carry
41/// a per-unit value directly. `CostNumber::Total` is divided by
42/// `units_number.abs()`. (Pre-#1164 the cost spec could carry a
43/// `number_per` *and* a `number_total` simultaneously; the typed
44/// enum makes that state unrepresentable.)
45/// 3. **No price** — returns `None`.
46///
47/// Edge cases:
48/// - Zero units with a total-form input (annotation `@@` or
49/// `CostNumber::Total`): can't compute per-unit, falls through to
50/// the next priority. If nothing else is available, returns `None`.
51/// - Zero units with a per-unit-form input (annotation `@`,
52/// `CostNumber::PerUnit`, or `PerUnitFromTotal`): the per-unit
53/// amount is returned as-is — "1 share = $X regardless of how many
54/// shares you transacted."
55///
56/// # Parameters
57///
58/// - `units_number`: the posting's unit count (sign-insensitive — the
59/// helper uses `.abs()` internally for total-form division).
60/// - `annotation`: `Some((is_total, amount, currency))` if the posting
61/// has a usable `@`/`@@` annotation. Callers should pass `None` for
62/// incomplete annotations (e.g. `@ EUR` without a number) so the
63/// helper falls through cleanly.
64/// - `cost`: `Some((number, currency))` if the posting has a `{...}`
65/// cost spec. The number is the typed `Option<CostNumber>` from the
66/// spec — `None` means the spec carried no number at all (`{}`),
67/// `Some(CostNumber::PerUnit)` and `Some(CostNumber::Total)` carry
68/// their respective shapes. The mutual-exclusion invariant is
69/// enforced by the type — pre-#1164 this was a `(Option<Decimal>,
70/// Option<Decimal>)` pair that the helper had to defensively
71/// tie-break.
72#[must_use]
73pub fn extract_per_unit_price<T>(
74 units_number: Decimal,
75 annotation: Option<(bool, Decimal, T)>,
76 cost: Option<(Option<crate::CostNumber>, T)>,
77) -> Option<(Decimal, T)> {
78 // Priority 1: price annotation.
79 if let Some((is_total, amount, currency)) = annotation {
80 if is_total {
81 if !units_number.is_zero() {
82 return Some((amount / units_number.abs(), currency));
83 }
84 // Zero units + @@ → can't compute per-unit, fall through
85 // to cost. Currency is dropped along with the value, so the
86 // cost branch picks the cost's currency, not this one.
87 } else {
88 return Some((amount, currency));
89 }
90 }
91
92 // Priority 2: cost spec. The pre-#1164 per-vs-total tie-break
93 // becomes a single match: PerUnit and PerUnitFromTotal already
94 // carry the per-unit value; Total carries a magnitude that
95 // divides by `|units|` here.
96 if let Some((number, currency)) = cost {
97 match number {
98 Some(crate::CostNumber::PerUnit { value: per }) => {
99 return Some((per, currency));
100 }
101 Some(crate::CostNumber::PerUnitFromTotal(b)) => {
102 return Some((b.per_unit, currency));
103 }
104 Some(crate::CostNumber::Total { value: total }) if !units_number.is_zero() => {
105 return Some((total / units_number.abs(), currency));
106 }
107 // Compound `{a # b}`: effective per-unit is a + b/|N|.
108 Some(crate::CostNumber::Compound { per_unit, total }) if !units_number.is_zero() => {
109 return Some((per_unit + total / units_number.abs(), currency));
110 }
111 Some(crate::CostNumber::Total { value: _ } | crate::CostNumber::Compound { .. })
112 | None => {}
113 }
114 }
115
116 None
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use rust_decimal_macros::dec;
123
124 // Tests use `&'static str` for the currency type for readability.
125 // Real callers pass `InternedStr` (query path) or `String`
126 // (plugin path).
127
128 // ===== Annotation cases =====
129
130 #[test]
131 fn unit_annotation_returns_amount_directly() {
132 // @ 1.40 EUR with 5 units → 1.40 (per-unit, used as-is).
133 let p = extract_per_unit_price(dec!(5), Some((false, dec!(1.40), "EUR")), None);
134 assert_eq!(p, Some((dec!(1.40), "EUR")));
135 }
136
137 #[test]
138 fn total_annotation_divides_by_unit_count() {
139 // @@ 1500 USD with 10 units → 1500 / 10 = 150.
140 let p = extract_per_unit_price(dec!(10), Some((true, dec!(1500), "USD")), None);
141 assert_eq!(p, Some((dec!(150), "USD")));
142 }
143
144 #[test]
145 fn total_annotation_uses_abs_unit_count() {
146 // The classic #992 reproducer: @@ 15152.07 EUR with -27204.53 BAM
147 // must produce 15152.07 / 27204.53 ≈ 0.557 (NOT -0.557, NOT 15152.07).
148 let p = extract_per_unit_price(dec!(-27204.53), Some((true, dec!(15152.07), "EUR")), None);
149 let expected = dec!(15152.07) / dec!(27204.53);
150 assert_eq!(p, Some((expected, "EUR")));
151 assert!(p.unwrap().0 > dec!(0.55) && p.unwrap().0 < dec!(0.56));
152 }
153
154 #[test]
155 fn total_annotation_with_zero_units_falls_through_to_cost() {
156 // @@ 100 EUR on 0 units → can't compute per-unit, fall through.
157 // Cost is `{50 USD}` so we return (50, "USD"), NOT (50, "EUR")
158 // — that's the Copilot-flagged bug from PR #997. Returning the
159 // currency alongside the value makes the mismatched pair
160 // impossible to construct.
161 let p = extract_per_unit_price(
162 dec!(0),
163 Some((true, dec!(100), "EUR")),
164 Some((Some(crate::CostNumber::PerUnit { value: dec!(50) }), "USD")),
165 );
166 assert_eq!(p, Some((dec!(50), "USD")));
167 }
168
169 #[test]
170 fn total_annotation_with_zero_units_and_no_cost_returns_none() {
171 let p = extract_per_unit_price::<&str>(dec!(0), Some((true, dec!(100), "EUR")), None);
172 assert_eq!(p, None);
173 }
174
175 // ===== Cost cases =====
176
177 #[test]
178 fn cost_per_unit_used_when_no_annotation() {
179 // 10 ABC {50.00 USD} → 50.00.
180 let p = extract_per_unit_price(
181 dec!(10),
182 None,
183 Some((
184 Some(crate::CostNumber::PerUnit { value: dec!(50.00) }),
185 "USD",
186 )),
187 );
188 assert_eq!(p, Some((dec!(50.00), "USD")));
189 }
190
191 #[test]
192 fn cost_total_divides_by_unit_count() {
193 // 10 ABC {{500 USD}} → 500 / 10 = 50.
194 let p = extract_per_unit_price(
195 dec!(10),
196 None,
197 Some((Some(crate::CostNumber::Total { value: dec!(500) }), "USD")),
198 );
199 assert_eq!(p, Some((dec!(50), "USD")));
200 }
201
202 #[test]
203 fn cost_total_with_zero_units_returns_none() {
204 let p = extract_per_unit_price::<&str>(
205 dec!(0),
206 None,
207 Some((Some(crate::CostNumber::Total { value: dec!(500) }), "USD")),
208 );
209 assert_eq!(p, None);
210 }
211
212 // ===== Priority interactions =====
213
214 #[test]
215 fn annotation_wins_over_cost_when_both_present() {
216 // 5 ABC {1.25 EUR} @ 1.40 EUR → 1.40 (annotation wins).
217 // Currency comes from the annotation — but in this case both
218 // happen to be EUR so the test cannot distinguish a buggy
219 // unconditional `annotation_currency.or(cost_currency)` from
220 // the correct source-aware pick. See the zero-units test for
221 // that distinction.
222 let p = extract_per_unit_price(
223 dec!(5),
224 Some((false, dec!(1.40), "EUR")),
225 Some((
226 Some(crate::CostNumber::PerUnit { value: dec!(1.25) }),
227 "EUR",
228 )),
229 );
230 assert_eq!(p, Some((dec!(1.40), "EUR")));
231 }
232
233 #[test]
234 fn total_annotation_wins_over_cost_per_unit() {
235 // -10 ABC {1.25 EUR} @@ 14 EUR → 14 / 10 = 1.40 (annotation wins).
236 let p = extract_per_unit_price(
237 dec!(-10),
238 Some((true, dec!(14), "EUR")),
239 Some((
240 Some(crate::CostNumber::PerUnit { value: dec!(1.25) }),
241 "EUR",
242 )),
243 );
244 assert_eq!(p, Some((dec!(1.4), "EUR")));
245 }
246
247 // Note: the pre-#1164 "cost_per wins over cost_total when both are set"
248 // tie-break test is gone — the type system now forbids the both-set
249 // state at construction time, so there's nothing to tie-break.
250
251 // ===== Empty cases =====
252
253 #[test]
254 fn no_inputs_returns_none() {
255 let p = extract_per_unit_price::<&str>(dec!(10), None, None);
256 assert_eq!(p, None);
257 }
258
259 #[test]
260 fn annotation_without_amount_falls_through_to_cost() {
261 // Incomplete annotation like `@ EUR` (no number) → caller
262 // passes `None` for annotation → fall through. Cost present →
263 // use it. The returned currency is the cost's, not anything
264 // remembered from the dropped annotation.
265 let p = extract_per_unit_price(
266 dec!(10),
267 None,
268 Some((Some(crate::CostNumber::PerUnit { value: dec!(7) }), "USD")),
269 );
270 assert_eq!(p, Some((dec!(7), "USD")));
271 }
272}