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