use crate::identifiable::{self, Identifiable};
use crate::money::{Money, MultiCurrencyAmount};
use crate::stakeholders::Stakeholder;
use crate::title::Title;
use bon::Builder;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Builder)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Purchase {
#[builder(skip = Uuid::new_v4())]
id: Uuid,
quantity: u32,
unit_price: Money,
date: Option<DateTime<Utc>>,
}
impl Identifiable for Purchase {
fn id(&self) -> Uuid {
self.id
}
}
impl Purchase {
#[must_use]
pub const fn id(&self) -> Uuid {
self.id
}
#[must_use]
pub const fn quantity(&self) -> u32 {
self.quantity
}
#[must_use]
pub const fn unit_price(&self) -> Money {
self.unit_price
}
#[must_use]
pub const fn date(&self) -> Option<DateTime<Utc>> {
self.date
}
#[must_use]
pub const fn total(&self) -> Money {
Money::from_minor_units(
self.unit_price
.minor_units()
.saturating_mul(self.quantity as u64), self.unit_price.currency(),
)
}
pub const fn set_quantity(&mut self, quantity: u32) {
self.quantity = quantity;
}
pub const fn set_unit_price(&mut self, unit_price: Money) {
self.unit_price = unit_price;
}
pub const fn set_date(&mut self, date: DateTime<Utc>) {
self.date = Some(date);
}
pub const fn clear_date(&mut self) {
self.date = None;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Resource {
id: Uuid,
title: Title,
contact: Option<Stakeholder>,
purchases: Vec<Purchase>,
hourly_rate: Option<Money>,
}
impl Identifiable for Resource {
fn id(&self) -> Uuid {
self.id
}
}
impl Resource {
#[must_use]
pub fn new(title: Title) -> Self {
Resource {
id: Uuid::new_v4(),
title,
contact: None,
purchases: Vec::new(),
hourly_rate: None,
}
}
#[must_use]
pub const fn at_hourly_rate(mut self, hourly_rate: Money) -> Self {
self.hourly_rate = Some(hourly_rate);
self
}
#[must_use]
pub fn with_contact(mut self, contact: Stakeholder) -> Self {
self.contact = Some(contact);
self
}
#[must_use]
pub const fn id(&self) -> Uuid {
self.id
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: Title) {
self.title = title;
}
#[must_use]
pub const fn contact(&self) -> Option<&Stakeholder> {
self.contact.as_ref()
}
pub fn set_contact(&mut self, contact: Stakeholder) {
self.contact = Some(contact);
}
pub fn clear_contact(&mut self) {
self.contact = None;
}
#[must_use]
pub const fn hourly_rate(&self) -> Option<Money> {
self.hourly_rate
}
pub const fn update_hourly_rate(&mut self, hourly_rate: Money) {
self.hourly_rate = Some(hourly_rate);
}
pub const fn remove_hourly_rate(&mut self) {
self.hourly_rate = None;
}
pub fn purchases(&self) -> impl Iterator<Item = &Purchase> {
self.purchases.iter()
}
pub fn add_purchase(&mut self, purchase: Purchase) -> Uuid {
let id = purchase.id();
identifiable::upsert(&mut self.purchases, purchase);
id
}
pub fn rm_purchase(&mut self, purchase_id: Uuid) -> Option<Purchase> {
identifiable::remove_by_id(&mut self.purchases, purchase_id)
}
pub fn purchase_mut(&mut self, purchase_id: Uuid) -> Option<&mut Purchase> {
identifiable::find_mut(&mut self.purchases, purchase_id)
}
#[must_use]
pub fn purchase_cost(&self) -> MultiCurrencyAmount {
self.purchases().map(Purchase::total).sum()
}
#[must_use]
pub fn usage_cost(&self, hours: u64, quantity: u32) -> Option<Money> {
let rate = self.hourly_rate?;
let amount = rate
.minor_units()
.saturating_mul(hours)
.saturating_mul(u64::from(quantity));
Some(Money::from_minor_units(amount, rate.currency()))
}
}
#[cfg(test)]
pub mod test_utils {
use proptest::prelude::*;
use super::{Purchase, Resource};
use crate::money::{Currency, Money};
use crate::title::test_utils::title_strategy;
pub fn resource_strategy() -> impl Strategy<Value = Resource> {
(title_strategy(), proptest::option::of(0u64..10_000)).prop_map(|(title, rate)| {
let mut resource = Resource::new(title);
if let Some(rate) = rate {
resource.update_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
}
resource
})
}
pub fn purchase_strategy() -> impl Strategy<Value = Purchase> {
(1u32..1000, 0u64..10_000).prop_map(|(quantity, unit_price)| {
Purchase::builder()
.quantity(quantity)
.unit_price(Money::from_minor_units(unit_price, Currency::EUR))
.build()
})
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use crate::money::{Currency, Money};
use crate::person::Person;
use crate::stakeholders::Stakeholder;
use crate::title::Title;
use super::test_utils::{purchase_strategy, resource_strategy};
use super::{Purchase, Resource};
use uuid::Uuid;
proptest! {
#[test]
fn purchase_total_is_quantity_times_unit_price(quantity in 0u32..10_000, unit_price in 0u64..10_000) {
let purchase = Purchase::builder().quantity(quantity).unit_price(Money::from_minor_units(unit_price, Currency::EUR)).build();
assert_eq!(purchase.total(), Money::from_minor_units(u64::from(quantity) * unit_price, Currency::EUR));
}
#[test]
fn purchase_cost_sums_every_purchase(purchases in prop::collection::vec(purchase_strategy(), 0..5)) {
let mut resource = Resource::new("Stimpack".parse().unwrap());
let expected: u64 = purchases.iter().map(|p| p.total().minor_units()).sum();
for purchase in purchases {
resource.add_purchase(purchase);
}
assert_eq!(
resource.purchase_cost().in_currency(Currency::EUR),
(expected != 0).then(|| Money::from_minor_units(expected, Currency::EUR)),
);
}
#[test]
fn usage_cost_is_rate_times_hours_times_quantity(rate in 0u64..1000, hours in 0u64..1000, quantity in 0u32..100) {
let mut resource = Resource::new("Excavator".parse().unwrap());
resource.update_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
assert_eq!(
resource.usage_cost(hours, quantity),
Some(Money::from_minor_units(rate * hours * u64::from(quantity), Currency::EUR)),
);
}
#[test]
fn a_resource_keeps_its_id_across_edits(mut resource in resource_strategy()) {
let id = resource.id();
resource.update_hourly_rate(Money::from_minor_units(123, Currency::USD));
resource.set_title("Reclassified".parse().unwrap());
assert_eq!(resource.id(), id);
}
}
#[test]
fn usage_cost_is_none_without_a_rate() {
let resource = Resource::new("Stimpack".parse().unwrap());
assert_eq!(resource.usage_cost(10, 1), None);
}
#[test]
fn a_resource_prices_its_rate_and_purchases_independently() {
let mut resource = Resource::new("Excavator".parse().unwrap());
resource.update_hourly_rate(Money::from_minor_units(100, Currency::USD));
resource.add_purchase(
Purchase::builder()
.quantity(1)
.unit_price(Money::from_minor_units(5000, Currency::EUR))
.build(),
);
assert_eq!(
resource.purchase_cost().in_currency(Currency::EUR),
Some(Money::from_minor_units(5000, Currency::EUR)),
);
assert_eq!(
resource.usage_cost(2, 1),
Some(Money::from_minor_units(200, Currency::USD)),
);
}
#[test]
fn an_invalid_title_cannot_be_built() {
assert!(" ".parse::<Title>().is_err());
assert!("x".repeat(101).parse::<Title>().is_err());
}
#[test]
fn a_contact_can_be_set_and_cleared() {
let peppino = Stakeholder::individual(Person::new("Mastro", "Peppino").unwrap(), None);
let mut timber = Resource::new("Timber".parse().unwrap());
assert!(timber.contact().is_none());
timber.set_contact(peppino.clone());
assert_eq!(timber.contact(), Some(&peppino));
timber.clear_contact();
assert!(timber.contact().is_none());
}
#[test]
fn rm_purchase_with_an_unknown_id_is_none() {
let mut resource = Resource::new("Stimpack".parse().unwrap());
assert!(resource.rm_purchase(Uuid::new_v4()).is_none());
let purchase_id = resource.add_purchase(
Purchase::builder()
.quantity(1)
.unit_price(Money::from_minor_units(1, Currency::EUR))
.build(),
);
assert!(resource.rm_purchase(Uuid::new_v4()).is_none());
assert!(resource.rm_purchase(purchase_id).is_some());
assert!(resource.rm_purchase(purchase_id).is_none());
}
#[test]
fn purchase_mut_with_an_unknown_id_is_none() {
let mut resource = Resource::new("Stimpack".parse().unwrap());
assert!(resource.purchase_mut(Uuid::new_v4()).is_none());
}
#[test]
fn purchases_are_iterated_in_insertion_order() {
let mut resource = Resource::new("Stimpack".parse().unwrap());
let unit_price = Money::from_minor_units(100, Currency::EUR);
let a = resource.add_purchase(
Purchase::builder()
.quantity(1)
.unit_price(unit_price)
.build(),
);
let b = resource.add_purchase(
Purchase::builder()
.quantity(2)
.unit_price(unit_price)
.build(),
);
let c = resource.add_purchase(
Purchase::builder()
.quantity(3)
.unit_price(unit_price)
.build(),
);
let ids: Vec<_> = resource.purchases().map(Purchase::id).collect();
assert_eq!(ids, vec![a, b, c]);
}
#[test]
fn add_purchase_with_an_existing_id_replaces_it_in_place() {
let mut resource = Resource::new("Stimpack".parse().unwrap());
let purchase = Purchase::builder()
.quantity(1)
.unit_price(Money::from_minor_units(100, Currency::EUR))
.build();
let id = resource.add_purchase(purchase.clone());
let mut updated = purchase;
updated.set_quantity(5);
let same_id = resource.add_purchase(updated);
assert_eq!(same_id, id);
assert_eq!(resource.purchases().count(), 1);
assert_eq!(resource.purchase_mut(id).unwrap().quantity(), 5);
}
#[test]
fn purchase_date_can_be_set_and_cleared() {
let mut purchase = Purchase::builder()
.quantity(1)
.unit_price(Money::from_minor_units(1, Currency::EUR))
.build();
assert_eq!(purchase.date(), None);
let now = chrono::Utc::now();
purchase.set_date(now);
assert_eq!(purchase.date(), Some(now));
purchase.clear_date();
assert_eq!(purchase.date(), None);
}
proptest! {
#[test]
fn purchase_setters_edit_in_place(
q0 in 0u32..1000, p0 in 0u64..1000,
q1 in 0u32..1000, p1 in 0u64..1000,
) {
let mut resource = Resource::new("Excavator".parse().unwrap());
let purchase_id =
resource.add_purchase(Purchase::builder().quantity(q0).unit_price(Money::from_minor_units(p0, Currency::EUR)).build());
let purchase = resource.purchase_mut(purchase_id).unwrap();
purchase.set_quantity(q1);
purchase.set_unit_price(Money::from_minor_units(p1, Currency::EUR));
let purchase = resource.purchases().next().unwrap();
assert_eq!(purchase.quantity(), q1);
assert_eq!(purchase.unit_price(), Money::from_minor_units(p1, Currency::EUR));
assert_eq!(purchase.total(), Money::from_minor_units(u64::from(q1) * p1, Currency::EUR));
}
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use super::{Purchase, Resource};
use crate::money::{Currency, Money};
use crate::person::Person;
use crate::stakeholders::Stakeholder;
#[test]
fn resource_serde_roundtrip() {
let mut resource = Resource::new("Backend Engineer".parse().unwrap()).with_contact(
Stakeholder::individual(Person::new("Margherita", "Hack").unwrap(), None),
);
resource.update_hourly_rate(Money::from_minor_units(4_500, Currency::USD));
resource.add_purchase(
Purchase::builder()
.quantity(2)
.unit_price(Money::from_minor_units(1_000, Currency::USD))
.build(),
);
let json = serde_json::to_string(&resource).unwrap();
let back: Resource = serde_json::from_str(&json).unwrap();
assert_eq!(resource, back);
}
}