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        write!(f, "{{{} {}", self.number, self.currency)?;
134        if let Some(date) = self.date {
135            write!(f, ", {date}")?;
136        }
137        if let Some(label) = &self.label {
138            write!(f, ", \"{label}\"")?;
139        }
140        write!(f, "}}")
141    }
142}
143
144/// A cost specification for matching or creating costs.
145///
146/// Unlike [`Cost`], all fields are optional to allow partial matching.
147/// This is used in postings where the user may specify only some
148/// cost components (e.g., just the date to match a specific lot).
149///
150/// # Matching Rules
151///
152/// A `CostSpec` matches a `Cost` if all specified fields match:
153/// - If `number` is `Some`, it must equal the cost's number
154/// - If `currency` is `Some`, it must equal the cost's currency
155/// - If `date` is `Some`, it must equal the cost's date
156/// - If `label` is `Some`, it must equal the cost's label
157///
158/// # Examples
159///
160/// ```
161/// use rustledger_core::{Cost, CostSpec};
162/// use rust_decimal_macros::dec;
163///
164/// let cost = Cost::new(dec!(150.00), "USD")
165///     .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
166///
167/// // Match by date only
168/// let spec = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
169/// assert!(spec.matches(&cost));
170///
171/// // Match by wrong date
172/// let spec2 = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 16).unwrap());
173/// assert!(!spec2.matches(&cost));
174/// ```
175#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[cfg_attr(
177    feature = "rkyv",
178    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
179)]
180pub struct CostSpec {
181    /// Cost per unit (if specified)
182    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
183    pub number_per: Option<Decimal>,
184    /// Total cost (if specified) - alternative to `number_per`
185    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
186    pub number_total: Option<Decimal>,
187    /// Currency of the cost (if specified)
188    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
189    pub currency: Option<InternedStr>,
190    /// Acquisition date (if specified)
191    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
192    pub date: Option<NaiveDate>,
193    /// Lot label (if specified)
194    pub label: Option<String>,
195    /// Whether to merge with existing lot (average cost)
196    pub merge: bool,
197}
198
199impl CostSpec {
200    /// Create an empty cost spec.
201    #[must_use]
202    pub fn empty() -> Self {
203        Self::default()
204    }
205
206    /// Set the per-unit cost.
207    #[must_use]
208    pub const fn with_number_per(mut self, number: Decimal) -> Self {
209        self.number_per = Some(number);
210        self
211    }
212
213    /// Set the total cost.
214    #[must_use]
215    pub const fn with_number_total(mut self, number: Decimal) -> Self {
216        self.number_total = Some(number);
217        self
218    }
219
220    /// Set the currency.
221    #[must_use]
222    pub fn with_currency(mut self, currency: impl Into<InternedStr>) -> Self {
223        self.currency = Some(currency.into());
224        self
225    }
226
227    /// Set the date.
228    #[must_use]
229    pub const fn with_date(mut self, date: NaiveDate) -> Self {
230        self.date = Some(date);
231        self
232    }
233
234    /// Set the label.
235    #[must_use]
236    pub fn with_label(mut self, label: impl Into<String>) -> Self {
237        self.label = Some(label.into());
238        self
239    }
240
241    /// Set the merge flag (for average cost booking).
242    #[must_use]
243    pub const fn with_merge(mut self) -> Self {
244        self.merge = true;
245        self
246    }
247
248    /// Check if this is an empty cost spec (all fields None).
249    #[must_use]
250    pub const fn is_empty(&self) -> bool {
251        self.number_per.is_none()
252            && self.number_total.is_none()
253            && self.currency.is_none()
254            && self.date.is_none()
255            && self.label.is_none()
256            && !self.merge
257    }
258
259    /// Check if this cost spec matches a cost.
260    ///
261    /// All specified fields must match the corresponding cost fields.
262    #[must_use]
263    pub fn matches(&self, cost: &Cost) -> bool {
264        // Check per-unit cost
265        if let Some(n) = &self.number_per
266            && n != &cost.number
267        {
268            return false;
269        }
270        // Check currency
271        if let Some(c) = &self.currency
272            && c != &cost.currency
273        {
274            return false;
275        }
276        // Check date
277        if let Some(d) = &self.date
278            && cost.date.as_ref() != Some(d)
279        {
280            return false;
281        }
282        // Check label
283        if let Some(l) = &self.label
284            && cost.label.as_ref() != Some(l)
285        {
286            return false;
287        }
288        true
289    }
290
291    /// Resolve this cost spec to a concrete cost, given the number of units.
292    ///
293    /// If `number_total` is specified, the per-unit cost is calculated as
294    /// `number_total / units`. Full precision is preserved to avoid cost basis
295    /// errors when the position is later sold.
296    ///
297    /// Returns `None` if required fields (currency) are missing.
298    #[must_use]
299    pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
300        let currency = self.currency.clone()?;
301
302        let number = if let Some(per) = self.number_per {
303            // User-specified per-unit cost
304            per
305        } else if let Some(total) = self.number_total {
306            // Calculated from total - preserve full precision
307            total / units.abs()
308        } else {
309            return None;
310        };
311
312        Some(Cost {
313            number,
314            currency,
315            date: self.date.or(Some(date)),
316            label: self.label.clone(),
317        })
318    }
319}
320
321impl fmt::Display for CostSpec {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        write!(f, "{{")?;
324        // Max 6 elements: number_per, number_total, currency, date, label, merge
325        let mut parts = Vec::with_capacity(6);
326
327        if let Some(n) = self.number_per {
328            parts.push(format!("{n}"));
329        }
330        if let Some(n) = self.number_total {
331            parts.push(format!("# {n}"));
332        }
333        if let Some(c) = &self.currency {
334            parts.push(c.to_string());
335        }
336        if let Some(d) = self.date {
337            parts.push(d.to_string());
338        }
339        if let Some(l) = &self.label {
340            parts.push(format!("\"{l}\""));
341        }
342        if self.merge {
343            parts.push("*".to_string());
344        }
345
346        write!(f, "{}", parts.join(", "))?;
347        write!(f, "}}")
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use rust_decimal_macros::dec;
355
356    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
357        crate::naive_date(year, month, day).unwrap()
358    }
359
360    #[test]
361    fn test_cost_new() {
362        let cost = Cost::new(dec!(150.00), "USD");
363        assert_eq!(cost.number, dec!(150.00));
364        assert_eq!(cost.currency, "USD");
365        assert!(cost.date.is_none());
366        assert!(cost.label.is_none());
367    }
368
369    #[test]
370    fn test_cost_builder() {
371        let cost = Cost::new(dec!(150.00), "USD")
372            .with_date(date(2024, 1, 15))
373            .with_label("lot1");
374
375        assert_eq!(cost.date, Some(date(2024, 1, 15)));
376        assert_eq!(cost.label, Some("lot1".to_string()));
377    }
378
379    #[test]
380    fn test_cost_total() {
381        let cost = Cost::new(dec!(150.00), "USD");
382        let total = cost.total_cost(dec!(10));
383        assert_eq!(total.number, dec!(1500.00));
384        assert_eq!(total.currency, "USD");
385    }
386
387    #[test]
388    fn test_cost_display() {
389        let cost = Cost::new(dec!(150.00), "USD")
390            .with_date(date(2024, 1, 15))
391            .with_label("lot1");
392        let s = format!("{cost}");
393        assert!(s.contains("150.00"));
394        assert!(s.contains("USD"));
395        assert!(s.contains("2024-01-15"));
396        assert!(s.contains("lot1"));
397    }
398
399    #[test]
400    fn test_cost_spec_empty() {
401        let spec = CostSpec::empty();
402        assert!(spec.is_empty());
403    }
404
405    #[test]
406    fn test_cost_spec_matches() {
407        let cost = Cost::new(dec!(150.00), "USD")
408            .with_date(date(2024, 1, 15))
409            .with_label("lot1");
410
411        // Empty spec matches everything
412        assert!(CostSpec::empty().matches(&cost));
413
414        // Match by number
415        let spec = CostSpec::empty().with_number_per(dec!(150.00));
416        assert!(spec.matches(&cost));
417
418        // Wrong number
419        let spec = CostSpec::empty().with_number_per(dec!(160.00));
420        assert!(!spec.matches(&cost));
421
422        // Match by currency
423        let spec = CostSpec::empty().with_currency("USD");
424        assert!(spec.matches(&cost));
425
426        // Match by date
427        let spec = CostSpec::empty().with_date(date(2024, 1, 15));
428        assert!(spec.matches(&cost));
429
430        // Match by label
431        let spec = CostSpec::empty().with_label("lot1");
432        assert!(spec.matches(&cost));
433
434        // Match by all
435        let spec = CostSpec::empty()
436            .with_number_per(dec!(150.00))
437            .with_currency("USD")
438            .with_date(date(2024, 1, 15))
439            .with_label("lot1");
440        assert!(spec.matches(&cost));
441    }
442
443    #[test]
444    fn test_cost_spec_resolve() {
445        let spec = CostSpec::empty()
446            .with_number_per(dec!(150.00))
447            .with_currency("USD");
448
449        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
450        assert_eq!(cost.number, dec!(150.00));
451        assert_eq!(cost.currency, "USD");
452        assert_eq!(cost.date, Some(date(2024, 1, 15)));
453    }
454
455    #[test]
456    fn test_cost_spec_resolve_total() {
457        let spec = CostSpec::empty()
458            .with_number_total(dec!(1500.00))
459            .with_currency("USD");
460
461        let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
462        assert_eq!(cost.number, dec!(150.00)); // 1500 / 10
463        assert_eq!(cost.currency, "USD");
464    }
465}