1use crate::identifiable::{self, Identifiable};
2use crate::money::{Money, MultiCurrencyAmount};
3use crate::stakeholders::Stakeholder;
4use crate::title::Title;
5use bon::Builder;
6use chrono::{DateTime, Utc};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, PartialEq, Eq, Builder)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct Purchase {
27 #[builder(skip = Uuid::new_v4())]
29 id: Uuid,
30 quantity: u32,
32 unit_price: Money,
34 date: Option<DateTime<Utc>>,
36}
37
38impl Identifiable for Purchase {
39 fn id(&self) -> Uuid {
40 self.id
41 }
42}
43
44impl Purchase {
45 #[must_use]
47 pub const fn id(&self) -> Uuid {
48 self.id
49 }
50
51 #[must_use]
53 pub const fn quantity(&self) -> u32 {
54 self.quantity
55 }
56
57 #[must_use]
59 pub const fn unit_price(&self) -> Money {
60 self.unit_price
61 }
62
63 #[must_use]
65 pub const fn date(&self) -> Option<DateTime<Utc>> {
66 self.date
67 }
68
69 #[must_use]
71 pub const fn total(&self) -> Money {
72 Money::from_minor_units(
73 self.unit_price
74 .minor_units()
75 .saturating_mul(self.quantity as u64), self.unit_price.currency(),
77 )
78 }
79
80 pub const fn set_quantity(&mut self, quantity: u32) {
82 self.quantity = quantity;
83 }
84
85 pub const fn set_unit_price(&mut self, unit_price: Money) {
87 self.unit_price = unit_price;
88 }
89
90 pub const fn set_date(&mut self, date: DateTime<Utc>) {
92 self.date = Some(date);
93 }
94
95 pub const fn clear_date(&mut self) {
97 self.date = None;
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct Resource {
129 id: Uuid,
131 title: Title,
133 contact: Option<Stakeholder>,
135 purchases: Vec<Purchase>,
137 hourly_rate: Option<Money>,
140}
141
142impl Identifiable for Resource {
143 fn id(&self) -> Uuid {
144 self.id
145 }
146}
147
148impl Resource {
149 #[must_use]
164 pub fn new(title: Title) -> Self {
165 Resource {
166 id: Uuid::new_v4(),
167 title,
168 contact: None,
169 purchases: Vec::new(),
170 hourly_rate: None,
171 }
172 }
173
174 #[must_use]
186 pub const fn at_hourly_rate(mut self, hourly_rate: Money) -> Self {
187 self.hourly_rate = Some(hourly_rate);
188 self
189 }
190
191 #[must_use]
203 pub fn with_contact(mut self, contact: Stakeholder) -> Self {
204 self.contact = Some(contact);
205 self
206 }
207
208 #[must_use]
210 pub const fn id(&self) -> Uuid {
211 self.id
212 }
213
214 #[must_use]
216 pub fn title(&self) -> &str {
217 &self.title
218 }
219
220 pub fn set_title(&mut self, title: Title) {
222 self.title = title;
223 }
224
225 #[must_use]
227 pub const fn contact(&self) -> Option<&Stakeholder> {
228 self.contact.as_ref()
229 }
230
231 pub fn set_contact(&mut self, contact: Stakeholder) {
233 self.contact = Some(contact);
234 }
235
236 pub fn clear_contact(&mut self) {
238 self.contact = None;
239 }
240
241 #[must_use]
243 pub const fn hourly_rate(&self) -> Option<Money> {
244 self.hourly_rate
245 }
246
247 pub const fn update_hourly_rate(&mut self, hourly_rate: Money) {
258 self.hourly_rate = Some(hourly_rate);
259 }
260
261 pub const fn remove_hourly_rate(&mut self) {
263 self.hourly_rate = None;
264 }
265
266 pub fn purchases(&self) -> impl Iterator<Item = &Purchase> {
268 self.purchases.iter()
269 }
270
271 pub fn add_purchase(&mut self, purchase: Purchase) -> Uuid {
286 let id = purchase.id();
287 identifiable::upsert(&mut self.purchases, purchase);
288 id
289 }
290
291 pub fn rm_purchase(&mut self, purchase_id: Uuid) -> Option<Purchase> {
294 identifiable::remove_by_id(&mut self.purchases, purchase_id)
295 }
296
297 pub fn purchase_mut(&mut self, purchase_id: Uuid) -> Option<&mut Purchase> {
312 identifiable::find_mut(&mut self.purchases, purchase_id)
313 }
314
315 #[must_use]
333 pub fn purchase_cost(&self) -> MultiCurrencyAmount {
334 self.purchases().map(Purchase::total).sum()
335 }
336
337 #[must_use]
353 pub fn usage_cost(&self, hours: u64, quantity: u32) -> Option<Money> {
354 let rate = self.hourly_rate?;
355 let amount = rate
356 .minor_units()
357 .saturating_mul(hours)
358 .saturating_mul(u64::from(quantity));
359 Some(Money::from_minor_units(amount, rate.currency()))
360 }
361}
362
363#[cfg(test)]
364pub mod test_utils {
366 use proptest::prelude::*;
367
368 use super::{Purchase, Resource};
369 use crate::money::{Currency, Money};
370 use crate::title::test_utils::title_strategy;
371
372 pub fn resource_strategy() -> impl Strategy<Value = Resource> {
374 (title_strategy(), proptest::option::of(0u64..10_000)).prop_map(|(title, rate)| {
375 let mut resource = Resource::new(title);
376 if let Some(rate) = rate {
377 resource.update_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
378 }
379 resource
380 })
381 }
382
383 pub fn purchase_strategy() -> impl Strategy<Value = Purchase> {
385 (1u32..1000, 0u64..10_000).prop_map(|(quantity, unit_price)| {
386 Purchase::builder()
387 .quantity(quantity)
388 .unit_price(Money::from_minor_units(unit_price, Currency::EUR))
389 .build()
390 })
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use proptest::prelude::*;
397
398 use crate::money::{Currency, Money};
399 use crate::person::Person;
400 use crate::stakeholders::Stakeholder;
401 use crate::title::Title;
402
403 use super::test_utils::{purchase_strategy, resource_strategy};
404 use super::{Purchase, Resource};
405 use uuid::Uuid;
406
407 proptest! {
408 #[test]
409 fn purchase_total_is_quantity_times_unit_price(quantity in 0u32..10_000, unit_price in 0u64..10_000) {
410 let purchase = Purchase::builder().quantity(quantity).unit_price(Money::from_minor_units(unit_price, Currency::EUR)).build();
411 assert_eq!(purchase.total(), Money::from_minor_units(u64::from(quantity) * unit_price, Currency::EUR));
412 }
413
414 #[test]
415 fn purchase_cost_sums_every_purchase(purchases in prop::collection::vec(purchase_strategy(), 0..5)) {
416 let mut resource = Resource::new("Stimpack".parse().unwrap());
417 let expected: u64 = purchases.iter().map(|p| p.total().minor_units()).sum();
418 for purchase in purchases {
419 resource.add_purchase(purchase);
420 }
421 assert_eq!(
422 resource.purchase_cost().in_currency(Currency::EUR),
423 (expected != 0).then(|| Money::from_minor_units(expected, Currency::EUR)),
424 );
425 }
426
427 #[test]
428 fn usage_cost_is_rate_times_hours_times_quantity(rate in 0u64..1000, hours in 0u64..1000, quantity in 0u32..100) {
429 let mut resource = Resource::new("Excavator".parse().unwrap());
430 resource.update_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
431 assert_eq!(
432 resource.usage_cost(hours, quantity),
433 Some(Money::from_minor_units(rate * hours * u64::from(quantity), Currency::EUR)),
434 );
435 }
436
437 #[test]
438 fn a_resource_keeps_its_id_across_edits(mut resource in resource_strategy()) {
439 let id = resource.id();
440 resource.update_hourly_rate(Money::from_minor_units(123, Currency::USD));
441 resource.set_title("Reclassified".parse().unwrap());
442 assert_eq!(resource.id(), id);
443 }
444 }
445
446 #[test]
447 fn usage_cost_is_none_without_a_rate() {
448 let resource = Resource::new("Stimpack".parse().unwrap());
449 assert_eq!(resource.usage_cost(10, 1), None);
450 }
451
452 #[test]
453 fn a_resource_prices_its_rate_and_purchases_independently() {
454 let mut resource = Resource::new("Excavator".parse().unwrap());
455 resource.update_hourly_rate(Money::from_minor_units(100, Currency::USD));
456 resource.add_purchase(
457 Purchase::builder()
458 .quantity(1)
459 .unit_price(Money::from_minor_units(5000, Currency::EUR))
460 .build(),
461 );
462
463 assert_eq!(
464 resource.purchase_cost().in_currency(Currency::EUR),
465 Some(Money::from_minor_units(5000, Currency::EUR)),
466 );
467 assert_eq!(
468 resource.usage_cost(2, 1),
469 Some(Money::from_minor_units(200, Currency::USD)),
470 );
471 }
472
473 #[test]
474 fn an_invalid_title_cannot_be_built() {
475 assert!(" ".parse::<Title>().is_err());
476 assert!("x".repeat(101).parse::<Title>().is_err());
477 }
478
479 #[test]
480 fn a_contact_can_be_set_and_cleared() {
481 let peppino = Stakeholder::individual(Person::new("Mastro", "Peppino").unwrap(), None);
482 let mut timber = Resource::new("Timber".parse().unwrap());
483 assert!(timber.contact().is_none());
484
485 timber.set_contact(peppino.clone());
486 assert_eq!(timber.contact(), Some(&peppino));
487
488 timber.clear_contact();
489 assert!(timber.contact().is_none());
490 }
491
492 #[test]
493 fn rm_purchase_with_an_unknown_id_is_none() {
494 let mut resource = Resource::new("Stimpack".parse().unwrap());
495 assert!(resource.rm_purchase(Uuid::new_v4()).is_none());
496 let purchase_id = resource.add_purchase(
497 Purchase::builder()
498 .quantity(1)
499 .unit_price(Money::from_minor_units(1, Currency::EUR))
500 .build(),
501 );
502 assert!(resource.rm_purchase(Uuid::new_v4()).is_none());
503 assert!(resource.rm_purchase(purchase_id).is_some());
504 assert!(resource.rm_purchase(purchase_id).is_none());
505 }
506
507 #[test]
508 fn purchase_mut_with_an_unknown_id_is_none() {
509 let mut resource = Resource::new("Stimpack".parse().unwrap());
510 assert!(resource.purchase_mut(Uuid::new_v4()).is_none());
511 }
512
513 #[test]
514 fn purchases_are_iterated_in_insertion_order() {
515 let mut resource = Resource::new("Stimpack".parse().unwrap());
516 let unit_price = Money::from_minor_units(100, Currency::EUR);
517 let a = resource.add_purchase(
518 Purchase::builder()
519 .quantity(1)
520 .unit_price(unit_price)
521 .build(),
522 );
523 let b = resource.add_purchase(
524 Purchase::builder()
525 .quantity(2)
526 .unit_price(unit_price)
527 .build(),
528 );
529 let c = resource.add_purchase(
530 Purchase::builder()
531 .quantity(3)
532 .unit_price(unit_price)
533 .build(),
534 );
535
536 let ids: Vec<_> = resource.purchases().map(Purchase::id).collect();
537 assert_eq!(ids, vec![a, b, c]);
538 }
539
540 #[test]
541 fn add_purchase_with_an_existing_id_replaces_it_in_place() {
542 let mut resource = Resource::new("Stimpack".parse().unwrap());
543 let purchase = Purchase::builder()
544 .quantity(1)
545 .unit_price(Money::from_minor_units(100, Currency::EUR))
546 .build();
547 let id = resource.add_purchase(purchase.clone());
548
549 let mut updated = purchase;
550 updated.set_quantity(5);
551 let same_id = resource.add_purchase(updated);
552
553 assert_eq!(same_id, id);
554 assert_eq!(resource.purchases().count(), 1);
555 assert_eq!(resource.purchase_mut(id).unwrap().quantity(), 5);
556 }
557
558 #[test]
559 fn purchase_date_can_be_set_and_cleared() {
560 let mut purchase = Purchase::builder()
561 .quantity(1)
562 .unit_price(Money::from_minor_units(1, Currency::EUR))
563 .build();
564 assert_eq!(purchase.date(), None);
565 let now = chrono::Utc::now();
566 purchase.set_date(now);
567 assert_eq!(purchase.date(), Some(now));
568 purchase.clear_date();
569 assert_eq!(purchase.date(), None);
570 }
571
572 proptest! {
573 #[test]
574 fn purchase_setters_edit_in_place(
575 q0 in 0u32..1000, p0 in 0u64..1000,
576 q1 in 0u32..1000, p1 in 0u64..1000,
577 ) {
578 let mut resource = Resource::new("Excavator".parse().unwrap());
579 let purchase_id =
580 resource.add_purchase(Purchase::builder().quantity(q0).unit_price(Money::from_minor_units(p0, Currency::EUR)).build());
581
582 let purchase = resource.purchase_mut(purchase_id).unwrap();
583 purchase.set_quantity(q1);
584 purchase.set_unit_price(Money::from_minor_units(p1, Currency::EUR));
585
586 let purchase = resource.purchases().next().unwrap();
587 assert_eq!(purchase.quantity(), q1);
588 assert_eq!(purchase.unit_price(), Money::from_minor_units(p1, Currency::EUR));
589 assert_eq!(purchase.total(), Money::from_minor_units(u64::from(q1) * p1, Currency::EUR));
590 }
591 }
592}
593
594#[cfg(all(test, feature = "serde"))]
595mod serde_tests {
596 use super::{Purchase, Resource};
597 use crate::money::{Currency, Money};
598 use crate::person::Person;
599 use crate::stakeholders::Stakeholder;
600
601 #[test]
602 fn resource_serde_roundtrip() {
603 let mut resource = Resource::new("Backend Engineer".parse().unwrap()).with_contact(
604 Stakeholder::individual(Person::new("Margherita", "Hack").unwrap(), None),
605 );
606 resource.update_hourly_rate(Money::from_minor_units(4_500, Currency::USD));
607 resource.add_purchase(
608 Purchase::builder()
609 .quantity(2)
610 .unit_price(Money::from_minor_units(1_000, Currency::USD))
611 .build(),
612 );
613
614 let json = serde_json::to_string(&resource).unwrap();
615 let back: Resource = serde_json::from_str(&json).unwrap();
616 assert_eq!(resource, back);
617 }
618}