use crate::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::Amount;
use crate::intern::InternedStr;
#[cfg(feature = "rkyv")]
use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Cost {
#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
pub number: Decimal,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub currency: InternedStr,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
pub date: Option<NaiveDate>,
pub label: Option<String>,
}
impl Cost {
#[must_use]
pub fn new(number: Decimal, currency: impl Into<InternedStr>) -> Self {
Self {
number,
currency: currency.into(),
date: None,
label: None,
}
}
#[must_use]
pub fn new_calculated(number: Decimal, currency: impl Into<InternedStr>) -> Self {
Self::new(number, currency)
}
#[must_use]
pub const fn with_date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
#[must_use]
pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
self.date = date;
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_label_opt(mut self, label: Option<String>) -> Self {
self.label = label;
self
}
#[must_use]
pub fn as_amount(&self) -> Amount {
Amount::new(self.number, self.currency.clone())
}
#[must_use]
pub fn total_cost(&self, units: Decimal) -> Amount {
Amount::new(units * self.number, self.currency.clone())
}
}
impl fmt::Display for Cost {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{{{} {}", self.number, self.currency)?;
if let Some(date) = self.date {
write!(f, ", {date}")?;
}
if let Some(label) = &self.label {
write!(f, ", \"{label}\"")?;
}
write!(f, "}}")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct CostSpec {
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
pub number_per: Option<Decimal>,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
pub number_total: Option<Decimal>,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
pub currency: Option<InternedStr>,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
pub date: Option<NaiveDate>,
pub label: Option<String>,
pub merge: bool,
}
impl CostSpec {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub const fn with_number_per(mut self, number: Decimal) -> Self {
self.number_per = Some(number);
self
}
#[must_use]
pub const fn with_number_total(mut self, number: Decimal) -> Self {
self.number_total = Some(number);
self
}
#[must_use]
pub fn with_currency(mut self, currency: impl Into<InternedStr>) -> Self {
self.currency = Some(currency.into());
self
}
#[must_use]
pub const fn with_date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub const fn with_merge(mut self) -> Self {
self.merge = true;
self
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.number_per.is_none()
&& self.number_total.is_none()
&& self.currency.is_none()
&& self.date.is_none()
&& self.label.is_none()
&& !self.merge
}
#[must_use]
pub fn matches(&self, cost: &Cost) -> bool {
if let Some(n) = &self.number_per
&& n != &cost.number
{
return false;
}
if let Some(c) = &self.currency
&& c != &cost.currency
{
return false;
}
if let Some(d) = &self.date
&& cost.date.as_ref() != Some(d)
{
return false;
}
if let Some(l) = &self.label
&& cost.label.as_ref() != Some(l)
{
return false;
}
true
}
#[must_use]
pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
let currency = self.currency.clone()?;
let number = if let Some(per) = self.number_per {
per
} else if let Some(total) = self.number_total {
total / units.abs()
} else {
return None;
};
Some(Cost {
number,
currency,
date: self.date.or(Some(date)),
label: self.label.clone(),
})
}
}
impl fmt::Display for CostSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{{")?;
let mut parts = Vec::with_capacity(6);
if let Some(n) = self.number_per {
parts.push(format!("{n}"));
}
if let Some(n) = self.number_total {
parts.push(format!("# {n}"));
}
if let Some(c) = &self.currency {
parts.push(c.to_string());
}
if let Some(d) = self.date {
parts.push(d.to_string());
}
if let Some(l) = &self.label {
parts.push(format!("\"{l}\""));
}
if self.merge {
parts.push("*".to_string());
}
write!(f, "{}", parts.join(", "))?;
write!(f, "}}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
crate::naive_date(year, month, day).unwrap()
}
#[test]
fn test_cost_new() {
let cost = Cost::new(dec!(150.00), "USD");
assert_eq!(cost.number, dec!(150.00));
assert_eq!(cost.currency, "USD");
assert!(cost.date.is_none());
assert!(cost.label.is_none());
}
#[test]
fn test_cost_builder() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
assert_eq!(cost.date, Some(date(2024, 1, 15)));
assert_eq!(cost.label, Some("lot1".to_string()));
}
#[test]
fn test_cost_total() {
let cost = Cost::new(dec!(150.00), "USD");
let total = cost.total_cost(dec!(10));
assert_eq!(total.number, dec!(1500.00));
assert_eq!(total.currency, "USD");
}
#[test]
fn test_cost_display() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
let s = format!("{cost}");
assert!(s.contains("150.00"));
assert!(s.contains("USD"));
assert!(s.contains("2024-01-15"));
assert!(s.contains("lot1"));
}
#[test]
fn test_cost_spec_empty() {
let spec = CostSpec::empty();
assert!(spec.is_empty());
}
#[test]
fn test_cost_spec_matches() {
let cost = Cost::new(dec!(150.00), "USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
assert!(CostSpec::empty().matches(&cost));
let spec = CostSpec::empty().with_number_per(dec!(150.00));
assert!(spec.matches(&cost));
let spec = CostSpec::empty().with_number_per(dec!(160.00));
assert!(!spec.matches(&cost));
let spec = CostSpec::empty().with_currency("USD");
assert!(spec.matches(&cost));
let spec = CostSpec::empty().with_date(date(2024, 1, 15));
assert!(spec.matches(&cost));
let spec = CostSpec::empty().with_label("lot1");
assert!(spec.matches(&cost));
let spec = CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD")
.with_date(date(2024, 1, 15))
.with_label("lot1");
assert!(spec.matches(&cost));
}
#[test]
fn test_cost_spec_resolve() {
let spec = CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD");
let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
assert_eq!(cost.number, dec!(150.00));
assert_eq!(cost.currency, "USD");
assert_eq!(cost.date, Some(date(2024, 1, 15)));
}
#[test]
fn test_cost_spec_resolve_total() {
let spec = CostSpec::empty()
.with_number_total(dec!(1500.00))
.with_currency("USD");
let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
assert_eq!(cost.number, dec!(150.00)); assert_eq!(cost.currency, "USD");
}
}