1use 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#[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 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
49 pub number: Decimal,
50 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
52 pub currency: InternedStr,
53 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
55 pub date: Option<NaiveDate>,
56 pub label: Option<String>,
58}
59
60impl Cost {
61 #[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 #[must_use]
74 pub const fn with_date(mut self, date: NaiveDate) -> Self {
75 self.date = Some(date);
76 self
77 }
78
79 #[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 #[must_use]
88 pub fn as_amount(&self) -> Amount {
89 Amount::new(self.number, self.currency.clone())
90 }
91
92 #[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#[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 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
152 pub number_per: Option<Decimal>,
153 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
155 pub number_total: Option<Decimal>,
156 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
158 pub currency: Option<InternedStr>,
159 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
161 pub date: Option<NaiveDate>,
162 pub label: Option<String>,
164 pub merge: bool,
166}
167
168impl CostSpec {
169 #[must_use]
171 pub fn empty() -> Self {
172 Self::default()
173 }
174
175 #[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 #[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 #[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 #[must_use]
198 pub const fn with_date(mut self, date: NaiveDate) -> Self {
199 self.date = Some(date);
200 self
201 }
202
203 #[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 #[must_use]
212 pub const fn with_merge(mut self) -> Self {
213 self.merge = true;
214 self
215 }
216
217 #[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 #[must_use]
232 pub fn matches(&self, cost: &Cost) -> bool {
233 if let Some(n) = &self.number_per {
235 if n != &cost.number {
236 return false;
237 }
238 }
239 if let Some(c) = &self.currency {
241 if c != &cost.currency {
242 return false;
243 }
244 }
245 if let Some(d) = &self.date {
247 if cost.date.as_ref() != Some(d) {
248 return false;
249 }
250 }
251 if let Some(l) = &self.label {
253 if cost.label.as_ref() != Some(l) {
254 return false;
255 }
256 }
257 true
258 }
259
260 #[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 assert!(CostSpec::empty().matches(&cost));
378
379 let spec = CostSpec::empty().with_number_per(dec!(150.00));
381 assert!(spec.matches(&cost));
382
383 let spec = CostSpec::empty().with_number_per(dec!(160.00));
385 assert!(!spec.matches(&cost));
386
387 let spec = CostSpec::empty().with_currency("USD");
389 assert!(spec.matches(&cost));
390
391 let spec = CostSpec::empty().with_date(date(2024, 1, 15));
393 assert!(spec.matches(&cost));
394
395 let spec = CostSpec::empty().with_label("lot1");
397 assert!(spec.matches(&cost));
398
399 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)); assert_eq!(cost.currency, "USD");
429 }
430}