Skip to main content

rustledger_core/
cost.rs

1//! Cost and cost specification types.
2//!
3//! A [`Cost`] represents the acquisition cost of a position (lot). It includes
4//! the per-unit cost, currency, optional acquisition date, and optional label.
5//!
6//! A [`CostSpec`] is used for matching against existing costs or specifying
7//! new costs when all fields may not be known.
8
9use crate::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::Amount;
15use crate::intern::InternedStr;
16
17// Note: We no longer auto-quantize calculated values during cost storage.
18// Python beancount preserves full precision during booking and only rounds
19// at display time. Premature rounding of per-unit costs (e.g., from
20// total cost / units) causes cost basis errors when selling.
21// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded to 170.17,
22// because 1.763 * 170.17 = 300.00971 ≠ 300.00.
23#[cfg(feature = "rkyv")]
24use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate};
25
26/// A cost represents the acquisition cost of a position (lot).
27///
28/// When you buy 10 shares of AAPL at $150 on 2024-01-15, the cost is:
29/// - number: 150
30/// - currency: "USD"
31/// - date: Some(2024-01-15)
32/// - label: None (or Some("lot1") if labeled)
33///
34/// # Examples
35///
36/// ```
37/// use rustledger_core::Cost;
38/// use rust_decimal_macros::dec;
39///
40/// let cost = Cost::new(dec!(150.00), "USD")
41///     .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
42///
43/// assert_eq!(cost.number, dec!(150.00));
44/// assert_eq!(cost.currency, "USD");
45/// assert!(cost.date.is_some());
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[cfg_attr(
49    feature = "rkyv",
50    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
51)]
52pub struct Cost {
53    /// Cost per unit
54    #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
55    pub number: Decimal,
56    /// Currency of the cost
57    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
58    pub currency: InternedStr,
59    /// Acquisition date (optional, for lot identification)
60    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
61    pub date: Option<NaiveDate>,
62    /// Lot label (optional, for explicit lot identification)
63    pub label: Option<String>,
64}
65
66impl Cost {
67    /// Create a new cost with the given number and currency.
68    ///
69    /// Create a new cost with exact precision.
70    /// Use this for user-specified values that should preserve their precision.
71    #[must_use]
72    pub fn new(number: Decimal, currency: impl Into<InternedStr>) -> Self {
73        Self {
74            number,
75            currency: currency.into(),
76            date: None,
77            label: None,
78        }
79    }
80
81    /// Create a new cost for calculated values.
82    ///
83    /// Previously this auto-quantized, but we now preserve full precision
84    /// to avoid cost basis errors. Rounding should only happen at display time.
85    #[must_use]
86    pub fn new_calculated(number: Decimal, currency: impl Into<InternedStr>) -> Self {
87        Self::new(number, currency)
88    }
89
90    /// Add a date to this cost.
91    #[must_use]
92    pub const fn with_date(mut self, date: NaiveDate) -> Self {
93        self.date = Some(date);
94        self
95    }
96
97    /// Add an optional date to this cost.
98    #[must_use]
99    pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
100        self.date = date;
101        self
102    }
103
104    /// Add a label to this cost.
105    #[must_use]
106    pub fn with_label(mut self, label: impl Into<String>) -> Self {
107        self.label = Some(label.into());
108        self
109    }
110
111    /// Add an optional label to this cost.
112    #[must_use]
113    pub fn with_label_opt(mut self, label: Option<String>) -> Self {
114        self.label = label;
115        self
116    }
117
118    /// Get the cost as an amount.
119    #[must_use]
120    pub fn as_amount(&self) -> Amount {
121        Amount::new(self.number, self.currency.clone())
122    }
123
124    /// Calculate the total cost for a given number of units.
125    #[must_use]
126    pub fn total_cost(&self, units: Decimal) -> Amount {
127        Amount::new(units * self.number, self.currency.clone())
128    }
129}
130
131impl fmt::Display for Cost {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        // Match Beancount's `Position.__str__` format: `{ 520 USD}` —
134        // single space after the opening brace, no space before the
135        // closing brace. The space matters for BQL-output compat: the
136        // compat harness diffs row-by-row against bean-query, and the
137        // pre-fix `{520 USD}` form accounted for ~137 of 510 file ×
138        // query mismatches. Verified against beanquery 0.2.0 + beancount
139        // 3.2.3 (matches what CI installs and what the dev shell now
140        // ships via the compat container — see PR #1047). Source-level
141        // `format_cost_spec` (used by `rledger format` to round-trip
142        // ledger files) keeps the no-space `{N CCY}` form because that
143        // matches Beancount's `print` command output, not its
144        // `Position.__str__`.
145        write!(f, "{{ {} {}", self.number, self.currency)?;
146        if let Some(date) = self.date {
147            write!(f, ", {date}")?;
148        }
149        if let Some(label) = &self.label {
150            // Escape via `format::escape_string` so labels containing
151            // `"`, `\`, or `\n` round-trip safely. Without this a label
152            // like `say "hi"` would render as `"say "hi""` — a parse
153            // error if anyone tried to feed it back to a Beancount-
154            // compatible reader.
155            write!(f, ", \"{}\"", crate::format::escape_string(label))?;
156        }
157        write!(f, "}}")
158    }
159}
160
161/// A cost specification for matching or creating costs.
162///
163/// Unlike [`Cost`], all fields are optional to allow partial matching.
164/// This is used in postings where the user may specify only some
165/// cost components (e.g., just the date to match a specific lot).
166///
167/// # Matching Rules
168///
169/// A `CostSpec` matches a `Cost` if all specified fields match:
170/// - If `number` is `Some`, it must equal the cost's number
171/// - If `currency` is `Some`, it must equal the cost's currency
172/// - If `date` is `Some`, it must equal the cost's date
173/// - If `label` is `Some`, it must equal the cost's label
174///
175/// # Examples
176///
177/// ```
178/// use rustledger_core::{Cost, CostSpec};
179/// use rust_decimal_macros::dec;
180///
181/// let cost = Cost::new(dec!(150.00), "USD")
182///     .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
183///
184/// // Match by date only
185/// let spec = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
186/// assert!(spec.matches(&cost));
187///
188/// // Match by wrong date
189/// let spec2 = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 16).unwrap());
190/// assert!(!spec2.matches(&cost));
191/// ```
192#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
193#[cfg_attr(
194    feature = "rkyv",
195    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
196)]
197pub struct CostSpec {
198    /// Cost per unit (if specified)
199    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
200    pub number_per: Option<Decimal>,
201    /// Total cost (if specified) - alternative to `number_per`
202    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
203    pub number_total: Option<Decimal>,
204    /// Currency of the cost (if specified)
205    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
206    pub currency: Option<InternedStr>,
207    /// Acquisition date (if specified)
208    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
209    pub date: Option<NaiveDate>,
210    /// Lot label (if specified)
211    pub label: Option<String>,
212    /// Whether to merge with existing lot (average cost)
213    pub merge: bool,
214}
215
216impl CostSpec {
217    /// Create an empty cost spec.
218    #[must_use]
219    pub fn empty() -> Self {
220        Self::default()
221    }
222
223    /// Set the per-unit cost.
224    #[must_use]
225    pub const fn with_number_per(mut self, number: Decimal) -> Self {
226        self.number_per = Some(number);
227        self
228    }
229
230    /// Set the total cost.
231    #[must_use]
232    pub const fn with_number_total(mut self, number: Decimal) -> Self {
233        self.number_total = Some(number);
234        self
235    }
236
237    /// Set the currency.
238    #[must_use]
239    pub fn with_currency(mut self, currency: impl Into<InternedStr>) -> Self {
240        self.currency = Some(currency.into());
241        self
242    }
243
244    /// Set the date.
245    #[must_use]
246    pub const fn with_date(mut self, date: NaiveDate) -> Self {
247        self.date = Some(date);
248        self
249    }
250
251    /// Set the label.
252    #[must_use]
253    pub fn with_label(mut self, label: impl Into<String>) -> Self {
254        self.label = Some(label.into());
255        self
256    }
257
258    /// Set the merge flag (for average cost booking).
259    #[must_use]
260    pub const fn with_merge(mut self) -> Self {
261        self.merge = true;
262        self
263    }
264
265    /// Check if this is an empty cost spec (all fields None).
266    #[must_use]
267    pub const fn is_empty(&self) -> bool {
268        self.number_per.is_none()
269            && self.number_total.is_none()
270            && self.currency.is_none()
271            && self.date.is_none()
272            && self.label.is_none()
273            && !self.merge
274    }
275
276    /// Check if this cost spec matches a cost.
277    ///
278    /// All specified fields must match the corresponding cost fields.
279    #[must_use]
280    pub fn matches(&self, cost: &Cost) -> bool {
281        // Check per-unit cost
282        if let Some(n) = &self.number_per
283            && n != &cost.number
284        {
285            return false;
286        }
287        // Check currency
288        if let Some(c) = &self.currency
289            && c != &cost.currency
290        {
291            return false;
292        }
293        // Check date
294        if let Some(d) = &self.date
295            && cost.date.as_ref() != Some(d)
296        {
297            return false;
298        }
299        // Check label
300        if let Some(l) = &self.label
301            && cost.label.as_ref() != Some(l)
302        {
303            return false;
304        }
305        true
306    }
307
308    /// Resolve this cost spec to a concrete cost, given the number of units.
309    ///
310    /// If `number_total` is specified, the per-unit cost is calculated as
311    /// `number_total / units`. Full precision is preserved to avoid cost basis
312    /// errors when the position is later sold.
313    ///
314    /// Returns `None` if required fields (currency) are missing.
315    #[must_use]
316    pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
317        let currency = self.currency.clone()?;
318
319        let number = if let Some(per) = self.number_per {
320            // User-specified per-unit cost
321            per
322        } else if let Some(total) = self.number_total {
323            // Calculated from total - preserve full precision
324            total / units.abs()
325        } else {
326            return None;
327        };
328
329        Some(Cost {
330            number,
331            currency,
332            date: self.date.or(Some(date)),
333            label: self.label.clone(),
334        })
335    }
336}
337
338impl fmt::Display for CostSpec {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        write!(f, "{{")?;
341        // Max 6 elements: number_per, number_total, currency, date, label, merge
342        let mut parts = Vec::with_capacity(6);
343
344        if let Some(n) = self.number_per {
345            parts.push(format!("{n}"));
346        }
347        if let Some(n) = self.number_total {
348            parts.push(format!("# {n}"));
349        }
350        if let Some(c) = &self.currency {
351            parts.push(c.to_string());
352        }
353        if let Some(d) = self.date {
354            parts.push(d.to_string());
355        }
356        if let Some(l) = &self.label {
357            parts.push(format!("\"{l}\""));
358        }
359        if self.merge {
360            parts.push("*".to_string());
361        }
362
363        write!(f, "{}", parts.join(", "))?;
364        write!(f, "}}")
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use rust_decimal_macros::dec;
372
373    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
374        crate::naive_date(year, month, day).unwrap()
375    }
376
377    #[test]
378    fn test_cost_new() {
379        let cost = Cost::new(dec!(150.00), "USD");
380        assert_eq!(cost.number, dec!(150.00));
381        assert_eq!(cost.currency, "USD");
382        assert!(cost.date.is_none());
383        assert!(cost.label.is_none());
384    }
385
386    #[test]
387    fn test_cost_builder() {
388        let cost = Cost::new(dec!(150.00), "USD")
389            .with_date(date(2024, 1, 15))
390            .with_label("lot1");
391
392        assert_eq!(cost.date, Some(date(2024, 1, 15)));
393        assert_eq!(cost.label, Some("lot1".to_string()));
394    }
395
396    #[test]
397    fn test_cost_total() {
398        let cost = Cost::new(dec!(150.00), "USD");
399        let total = cost.total_cost(dec!(10));
400        assert_eq!(total.number, dec!(1500.00));
401        assert_eq!(total.currency, "USD");
402    }
403
404    #[test]
405    fn test_cost_display() {
406        let cost = Cost::new(dec!(150.00), "USD")
407            .with_date(date(2024, 1, 15))
408            .with_label("lot1");
409        let s = format!("{cost}");
410        assert!(s.contains("150.00"));
411        assert!(s.contains("USD"));
412        assert!(s.contains("2024-01-15"));
413        assert!(s.contains("lot1"));
414    }
415
416    /// Exact-format regression covering both fixes in this PR:
417    /// - leading space inside `{` (matches Beancount Position.__str__)
418    /// - special-character escaping in labels via `format::escape_string`
419    #[test]
420    fn test_cost_display_escapes_special_characters_in_label() {
421        // Bare per-unit cost — pin the leading-space form.
422        let bare = Cost::new(dec!(520), "USD");
423        assert_eq!(format!("{bare}"), "{ 520 USD}");
424
425        // With date.
426        let dated = Cost::new(dec!(520.00), "USD").with_date(date(2024, 1, 15));
427        assert_eq!(format!("{dated}"), "{ 520.00 USD, 2024-01-15}");
428
429        // Embedded double-quote.
430        let quoted = Cost::new(dec!(100.00), "USD")
431            .with_date(date(2024, 1, 15))
432            .with_label("say \"hi\"");
433        assert_eq!(
434            format!("{quoted}"),
435            "{ 100.00 USD, 2024-01-15, \"say \\\"hi\\\"\"}"
436        );
437
438        // Embedded backslash.
439        let backslash = Cost::new(dec!(50.00), "USD").with_label("path\\to\\lot");
440        assert_eq!(
441            format!("{backslash}"),
442            "{ 50.00 USD, \"path\\\\to\\\\lot\"}"
443        );
444
445        // Embedded newline.
446        let newline = Cost::new(dec!(75.00), "USD").with_label("line1\nline2");
447        assert_eq!(format!("{newline}"), "{ 75.00 USD, \"line1\\nline2\"}");
448
449        // Plain label still works (no escaping changes for safe chars).
450        let plain = Cost::new(dec!(540.00), "USD")
451            .with_date(date(2024, 2, 15))
452            .with_label("lot-A");
453        assert_eq!(format!("{plain}"), "{ 540.00 USD, 2024-02-15, \"lot-A\"}");
454    }
455
456    #[test]
457    fn test_cost_spec_empty() {
458        let spec = CostSpec::empty();
459        assert!(spec.is_empty());
460    }
461
462    #[test]
463    fn test_cost_spec_matches() {
464        let cost = Cost::new(dec!(150.00), "USD")
465            .with_date(date(2024, 1, 15))
466            .with_label("lot1");
467
468        // Empty spec matches everything
469        assert!(CostSpec::empty().matches(&cost));
470
471        // Match by number
472        let spec = CostSpec::empty().with_number_per(dec!(150.00));
473        assert!(spec.matches(&cost));
474
475        // Wrong number
476        let spec = CostSpec::empty().with_number_per(dec!(160.00));
477        assert!(!spec.matches(&cost));
478
479        // Match by currency
480        let spec = CostSpec::empty().with_currency("USD");
481        assert!(spec.matches(&cost));
482
483        // Match by date
484        let spec = CostSpec::empty().with_date(date(2024, 1, 15));
485        assert!(spec.matches(&cost));
486
487        // Match by label
488        let spec = CostSpec::empty().with_label("lot1");
489        assert!(spec.matches(&cost));
490
491        // Match by all
492        let spec = CostSpec::empty()
493            .with_number_per(dec!(150.00))
494            .with_currency("USD")
495            .with_date(date(2024, 1, 15))
496            .with_label("lot1");
497        assert!(spec.matches(&cost));
498    }
499
500    #[test]
501    fn test_cost_spec_resolve() {
502        let spec = CostSpec::empty()
503            .with_number_per(dec!(150.00))
504            .with_currency("USD");
505
506        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
507        assert_eq!(cost.number, dec!(150.00));
508        assert_eq!(cost.currency, "USD");
509        assert_eq!(cost.date, Some(date(2024, 1, 15)));
510    }
511
512    #[test]
513    fn test_cost_spec_resolve_total() {
514        let spec = CostSpec::empty()
515            .with_number_total(dec!(1500.00))
516            .with_currency("USD");
517
518        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
519        assert_eq!(cost.number, dec!(150.00)); // 1500 / 10
520        assert_eq!(cost.currency, "USD");
521    }
522}