1use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::Amount;
15use crate::intern::InternedStr;
16
17#[cfg(feature = "rkyv")]
24use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate};
25
26#[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 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
56 pub number: Decimal,
57 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
59 pub currency: InternedStr,
60 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
62 pub date: Option<NaiveDate>,
63 pub label: Option<String>,
65}
66
67impl Cost {
68 #[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 #[must_use]
87 pub fn new_calculated(number: Decimal, currency: impl Into<InternedStr>) -> Self {
88 Self::new(number, currency)
89 }
90
91 #[must_use]
93 pub const fn with_date(mut self, date: NaiveDate) -> Self {
94 self.date = Some(date);
95 self
96 }
97
98 #[must_use]
100 pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
101 self.date = date;
102 self
103 }
104
105 #[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 #[must_use]
114 pub fn with_label_opt(mut self, label: Option<String>) -> Self {
115 self.label = label;
116 self
117 }
118
119 #[must_use]
121 pub fn as_amount(&self) -> Amount {
122 Amount::new(self.number, self.currency.clone())
123 }
124
125 #[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 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#[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 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
186 pub number_per: Option<Decimal>,
187 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
189 pub number_total: Option<Decimal>,
190 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
192 pub currency: Option<InternedStr>,
193 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
195 pub date: Option<NaiveDate>,
196 pub label: Option<String>,
198 pub merge: bool,
200}
201
202impl CostSpec {
203 #[must_use]
205 pub fn empty() -> Self {
206 Self::default()
207 }
208
209 #[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 #[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 #[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 #[must_use]
232 pub const fn with_date(mut self, date: NaiveDate) -> Self {
233 self.date = Some(date);
234 self
235 }
236
237 #[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 #[must_use]
246 pub const fn with_merge(mut self) -> Self {
247 self.merge = true;
248 self
249 }
250
251 #[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 #[must_use]
266 pub fn matches(&self, cost: &Cost) -> bool {
267 if let Some(n) = &self.number_per {
269 if n != &cost.number {
270 return false;
271 }
272 }
273 if let Some(c) = &self.currency {
275 if c != &cost.currency {
276 return false;
277 }
278 }
279 if let Some(d) = &self.date {
281 if cost.date.as_ref() != Some(d) {
282 return false;
283 }
284 }
285 if let Some(l) = &self.label {
287 if cost.label.as_ref() != Some(l) {
288 return false;
289 }
290 }
291 true
292 }
293
294 #[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 per
308 } else if let Some(total) = self.number_total {
309 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 assert!(CostSpec::empty().matches(&cost));
415
416 let spec = CostSpec::empty().with_number_per(dec!(150.00));
418 assert!(spec.matches(&cost));
419
420 let spec = CostSpec::empty().with_number_per(dec!(160.00));
422 assert!(!spec.matches(&cost));
423
424 let spec = CostSpec::empty().with_currency("USD");
426 assert!(spec.matches(&cost));
427
428 let spec = CostSpec::empty().with_date(date(2024, 1, 15));
430 assert!(spec.matches(&cost));
431
432 let spec = CostSpec::empty().with_label("lot1");
434 assert!(spec.matches(&cost));
435
436 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)); assert_eq!(cost.currency, "USD");
466 }
467}