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