Skip to main content

planter_core/
resources.rs

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/// A one-time cost: buying `quantity` units at `unit_price` each, optionally on a given `date`.
10/// A [`Resource`] can have several over its life (an initial buy, later resupply at a new
11/// price).
12/// Built with [`Purchase::builder`]
13///
14/// # Example
15/// ```
16/// use planter_core::{resources::Purchase, money::{Money, Currency}};
17///
18/// let purchase = Purchase::builder()
19///     .quantity(5)
20///     .unit_price(Money::from_minor_units(150, Currency::EUR))
21///     .build();
22/// assert_eq!(purchase.total(), Money::from_minor_units(750, Currency::EUR));
23/// ```
24#[derive(Debug, Clone, PartialEq, Eq, Builder)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct Purchase {
27    /// The stable identifier of this purchase, generated on construction.
28    #[builder(skip = Uuid::new_v4())]
29    id: Uuid,
30    /// How many units were bought.
31    quantity: u32,
32    /// Price of one unit.
33    unit_price: Money,
34    /// When the purchase happened.
35    date: Option<DateTime<Utc>>,
36}
37
38impl Identifiable for Purchase {
39    fn id(&self) -> Uuid {
40        self.id
41    }
42}
43
44impl Purchase {
45    /// Returns the stable identifier of this purchase.
46    #[must_use]
47    pub const fn id(&self) -> Uuid {
48        self.id
49    }
50
51    /// Returns how many units this purchase bought.
52    #[must_use]
53    pub const fn quantity(&self) -> u32 {
54        self.quantity
55    }
56
57    /// Returns the price of one unit.
58    #[must_use]
59    pub const fn unit_price(&self) -> Money {
60        self.unit_price
61    }
62
63    /// Returns when this purchase happens/happened, if known.
64    #[must_use]
65    pub const fn date(&self) -> Option<DateTime<Utc>> {
66        self.date
67    }
68
69    /// Returns the total cost of this purchase (`quantity * unit_price`, saturating).
70    #[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), // u32 -> u64 is lossless
76            self.unit_price.currency(),
77        )
78    }
79
80    /// Sets how many units this purchase bought.
81    pub const fn set_quantity(&mut self, quantity: u32) {
82        self.quantity = quantity;
83    }
84
85    /// Sets the price of one unit.
86    pub const fn set_unit_price(&mut self, unit_price: Money) {
87        self.unit_price = unit_price;
88    }
89
90    /// Sets when this purchase happens/happened.
91    pub const fn set_date(&mut self, date: DateTime<Utc>) {
92        self.date = Some(date);
93    }
94
95    /// Clears this purchase's date.
96    pub const fn clear_date(&mut self) {
97        self.date = None;
98    }
99}
100
101/// A resource is just a named, cost-bearing thing: "Timber", "Excavator", "Legal counsel".
102/// Cost comes from two independent parts, either of which may be absent:
103/// - [`purchases`](Self::purchases): one-time costs (buying materials, a machine, a licence),
104/// - [`hourly_rate`](Self::hourly_rate): a cost per hour a task engages it (wages, rental, fuel
105///   or wear).
106///
107/// An employee has only a rate, raw steel has only a purchase, a bought generator that also
108/// burns fuel has both.
109///
110/// Optionally a resource has a [`contact`](Self::contact): whoever is responsible for it or
111/// supplies it. If a resource is a person, the contact might be the person itself.
112///
113/// ```
114/// use planter_core::{resources::{Purchase, Resource}, money::{Money, Currency}};
115///
116/// let mut excavator = Resource::new("Excavator".parse().unwrap())
117///     .at_hourly_rate(Money::from_minor_units(3_000, Currency::EUR));
118/// excavator.add_purchase(
119///     Purchase::builder().quantity(1).unit_price(Money::from_minor_units(10_000, Currency::EUR)).build(),
120/// );
121/// ```
122///
123/// Each priced part carries its own [`Money`] currency independently: a resource's hourly rate
124/// and its purchases need not agree (a truck bought in EUR might be fueled at an hourly rate
125/// billed in USD). A resource with no rate and no purchases contributes nothing to cost.
126#[derive(Debug, Clone, PartialEq, Eq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct Resource {
129    /// The stable identifier of this resource, generated on construction.
130    id: Uuid,
131    /// A human-readable title: "Timber", "Excavator", "Legal counsel".
132    title: Title,
133    /// Who to contact about this resource. `None` when nobody is on record.
134    contact: Option<Stakeholder>,
135    /// One-time costs recorded against this resource, in the order they were added.
136    purchases: Vec<Purchase>,
137    /// Cost per hour a task engages this resource (wages, rental, fuel or wear). `None` for
138    /// resources with no time-based cost, such as raw materials.
139    hourly_rate: Option<Money>,
140}
141
142impl Identifiable for Resource {
143    fn id(&self) -> Uuid {
144        self.id
145    }
146}
147
148impl Resource {
149    /// Creates a resource with the given title, no contact, no purchases and no rate.
150    ///
151    /// The title is a validated [`Title`]; build one with `"…".parse()` or
152    /// [`Title::try_new`](crate::title::Title::try_new).
153    ///
154    /// # Example
155    /// ```
156    /// use planter_core::resources::Resource;
157    ///
158    /// let stimpack = Resource::new("Stimpack".parse().unwrap());
159    /// assert_eq!(stimpack.title(), "Stimpack");
160    /// assert_eq!(stimpack.purchases().count(), 0);
161    /// assert_eq!(stimpack.hourly_rate(), None);
162    /// ```
163    #[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    /// Sets this resource's hourly rate and returns `self`. Chainable form of
175    /// [`Self::update_hourly_rate`].
176    ///
177    /// # Example
178    /// ```
179    /// use planter_core::{resources::Resource, money::{Money, Currency}};
180    ///
181    /// let drill = Resource::new("Excavator".parse().unwrap())
182    ///     .at_hourly_rate(Money::from_minor_units(2_000, Currency::EUR));
183    /// assert_eq!(drill.hourly_rate(), Some(Money::from_minor_units(2_000, Currency::EUR)));
184    /// ```
185    #[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    /// Sets this resource's contact and returns `self`. Chainable form of
192    /// [`Self::set_contact`].
193    ///
194    /// # Example
195    /// ```
196    /// use planter_core::{person::Person, resources::Resource, stakeholders::Stakeholder};
197    ///
198    /// let peppino = Stakeholder::individual(Person::new("Mastro", "Peppino").unwrap(), None);
199    /// let timber = Resource::new("Timber".parse().unwrap()).with_contact(peppino);
200    /// assert!(timber.contact().is_some());
201    /// ```
202    #[must_use]
203    pub fn with_contact(mut self, contact: Stakeholder) -> Self {
204        self.contact = Some(contact);
205        self
206    }
207
208    /// Returns the stable identifier of this resource.
209    #[must_use]
210    pub const fn id(&self) -> Uuid {
211        self.id
212    }
213
214    /// Returns this resource's title.
215    #[must_use]
216    pub fn title(&self) -> &str {
217        &self.title
218    }
219
220    /// Retitles this resource.
221    pub fn set_title(&mut self, title: Title) {
222        self.title = title;
223    }
224
225    /// Returns this resource's contact, if one is on record.
226    #[must_use]
227    pub const fn contact(&self) -> Option<&Stakeholder> {
228        self.contact.as_ref()
229    }
230
231    /// Sets this resource's contact, replacing any previous one.
232    pub fn set_contact(&mut self, contact: Stakeholder) {
233        self.contact = Some(contact);
234    }
235
236    /// Clears this resource's contact.
237    pub fn clear_contact(&mut self) {
238        self.contact = None;
239    }
240
241    /// Returns the hourly rate, if set.
242    #[must_use]
243    pub const fn hourly_rate(&self) -> Option<Money> {
244        self.hourly_rate
245    }
246
247    /// Sets the hourly rate.
248    ///
249    /// # Example
250    /// ```
251    /// use planter_core::{resources::Resource, money::{Money, Currency}};
252    ///
253    /// let mut worker = Resource::new("Backend Engineer".parse().unwrap());
254    /// worker.update_hourly_rate(Money::from_minor_units(4_500, Currency::EUR));
255    /// assert_eq!(worker.hourly_rate(), Some(Money::from_minor_units(4_500, Currency::EUR)));
256    /// ```
257    pub const fn update_hourly_rate(&mut self, hourly_rate: Money) {
258        self.hourly_rate = Some(hourly_rate);
259    }
260
261    /// Clears the hourly rate.
262    pub const fn remove_hourly_rate(&mut self) {
263        self.hourly_rate = None;
264    }
265
266    /// Returns the purchases recorded for this resource, in the order they were added.
267    pub fn purchases(&self) -> impl Iterator<Item = &Purchase> {
268        self.purchases.iter()
269    }
270
271    /// Records a purchase against this resource, returning its [`Purchase::id`]. Adding a
272    /// purchase whose id already exists on this resource replaces it in place, without
273    /// duplicating its slot in [`Self::purchases`].
274    ///
275    /// # Example
276    /// ```
277    /// use planter_core::{resources::{Purchase, Resource}, money::{Money, Currency}};
278    ///
279    /// let mut stimpack = Resource::new("Stimpack".parse().unwrap());
280    /// stimpack.add_purchase(
281    ///     Purchase::builder().quantity(40).unit_price(Money::from_minor_units(500, Currency::EUR)).build(),
282    /// );
283    /// assert_eq!(stimpack.purchases().count(), 1);
284    /// ```
285    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    /// Removes the purchase with the given id, returning it, or `None` if this resource has no
292    /// such purchase.
293    pub fn rm_purchase(&mut self, purchase_id: Uuid) -> Option<Purchase> {
294        identifiable::remove_by_id(&mut self.purchases, purchase_id)
295    }
296
297    /// Mutable access to one of this resource's purchases, for editing it in place. `None` if
298    /// this resource has no purchase with that id.
299    ///
300    /// # Example
301    /// ```
302    /// use planter_core::{resources::{Purchase, Resource}, money::{Money, Currency}};
303    ///
304    /// let mut stimpack = Resource::new("Stimpack".parse().unwrap());
305    /// let purchase_id = stimpack.add_purchase(
306    ///     Purchase::builder().quantity(40).unit_price(Money::from_minor_units(500, Currency::EUR)).build(),
307    /// );
308    /// stimpack.purchase_mut(purchase_id).unwrap().set_quantity(60);
309    /// assert_eq!(stimpack.purchases().next().unwrap().quantity(), 60);
310    /// ```
311    pub fn purchase_mut(&mut self, purchase_id: Uuid) -> Option<&mut Purchase> {
312        identifiable::find_mut(&mut self.purchases, purchase_id)
313    }
314
315    /// Returns the total of every [`Purchase`] recorded for this resource, grouped by currency.
316    /// Empty when there are no purchases.
317    ///
318    /// # Example
319    /// ```
320    /// use planter_core::resources::{Purchase, Resource};
321    /// use planter_core::money::{Currency, Money};
322    ///
323    /// let mut stimpack = Resource::new("Stimpack".parse().unwrap());
324    /// stimpack.add_purchase(
325    ///     Purchase::builder().quantity(40).unit_price(Money::from_minor_units(500, Currency::EUR)).build(),
326    /// );
327    /// assert_eq!(
328    ///     stimpack.purchase_cost().in_currency(Currency::EUR),
329    ///     Some(Money::from_minor_units(20_000, Currency::EUR)),
330    /// );
331    /// ```
332    #[must_use]
333    pub fn purchase_cost(&self) -> MultiCurrencyAmount {
334        self.purchases().map(Purchase::total).sum()
335    }
336
337    /// Returns the cost this resource contributes for a task that engages `quantity` of it for
338    /// `hours` hours: `hourly_rate * hours * quantity` (saturating). `None` when the resource
339    /// has no hourly rate.
340    ///
341    /// # Example
342    /// ```
343    /// use planter_core::{resources::Resource, money::{Currency, Money}};
344    ///
345    /// let mut digger = Resource::new("Excavator".parse().unwrap());
346    /// digger.update_hourly_rate(Money::from_minor_units(3_000, Currency::EUR));
347    /// assert_eq!(
348    ///     digger.usage_cost(4, 1),
349    ///     Some(Money::from_minor_units(12_000, Currency::EUR)),
350    /// );
351    /// ```
352    #[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)]
364/// Utilities to test resources.
365pub 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    /// A random `Resource` priced in EUR, with an optional rate and no contact.
373    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    /// A random `Purchase` priced in EUR.
384    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}