Skip to main content

klirr_core/models/data/
data.rs

1use crate::prelude::*;
2
3/// The input data for the invoice, which includes information about the invoice,
4/// the vendor, and the client and the products/services included in the invoice.
5#[derive(
6    Clone, Debug, Serialize, Deserialize, PartialEq, Builder, Getters, WithSetters, Setters,
7)]
8pub struct Data<Period: IsPeriod> {
9    /// Information about this specific invoice.
10    #[getset(get = "pub")]
11    information: ProtoInvoiceInfo<Period>,
12
13    /// The company that issued the invoice, the vendor/seller/supplier/issuer.
14    #[getset(get = "pub")]
15    vendor: CompanyInformation,
16
17    /// The company that pays the invoice, the customer/buyer.
18    #[getset(get = "pub", set_with = "pub")]
19    client: CompanyInformation,
20
21    /// Payment information for the vendor, used for international transfers.
22    /// This includes the IBAN, bank name, and BIC.
23    /// This is used to ensure that the client can pay the invoice correctly.
24    #[getset(get = "pub")]
25    payment_info: PaymentInformation,
26
27    /// Price of service, if applicable.
28    #[getset(get = "pub")]
29    service_fees: ServiceFees,
30
31    /// Any expenses that you might have incurred.
32    #[getset(get = "pub", set = "pub")]
33    expensed_periods: ExpensedPeriods<Period>,
34}
35
36impl<Period: IsPeriod> Data<Period> {
37    /// Validates the invoice information and returns a `Result<Self>`.
38    /// If the information is valid, it returns `Ok(self)`.
39    /// If the information is invalid
40    /// it returns an `Error` with the validation error.
41    /// # Errors
42    /// Returns an error if the invoice information is invalid.
43    /// # Examples
44    /// ```
45    /// extern crate klirr_core;
46    /// use klirr_core::prelude::*;
47    /// let data = Data::<YearAndMonth>::sample();
48    /// let result = data.validate();
49    /// assert!(result.is_ok(), "Expected validation to succeed, got: {:?}", result);
50    /// ```
51    pub fn validate(self) -> Result<Self> {
52        self.information.validate()?;
53        Ok(self)
54    }
55
56    fn billable_quantity(
57        &self,
58        target_period: &Period,
59        cadence: Cadence,
60        time_off: &Option<TimeOff>,
61    ) -> Result<Quantity> {
62        let granularity = self.service_fees().rate().granularity();
63        let periods_off = self.information().record_of_periods_off();
64        let quantity_in_period =
65            quantity_in_period(target_period, granularity, cadence, periods_off)?;
66        let billable_quantity = quantity_in_period - time_off.map(|d| *d).unwrap_or(Quantity::ZERO);
67        Ok(billable_quantity)
68    }
69
70    /// Converts the `Data` into a `DataWithItemsPricedInSourceCurrency`
71    /// using the provided `ValidInput`.
72    /// This method prepares the invoice data for rendering by creating an
73    /// `InvoiceInfoFull` and populating it with the necessary information.
74    /// It also calculates the invoice date, due date, and prepares the output path.
75    /// # Errors
76    /// Returns an error if the month is invalid or if there are issues with
77    /// retrieving expenses for the month.
78    /// # Examples
79    /// ```
80    /// extern crate klirr_core;
81    /// use klirr_core::prelude::*;
82    /// let data = Data::<YearAndMonth>::sample();
83    /// let input = ValidInput::sample();
84    /// let result = data.to_partial(input);
85    /// assert!(result.is_ok(), "Expected conversion to succeed, got: {:?}", result);
86    /// ```
87    pub fn to_partial(self, input: ValidInput) -> Result<DataWithItemsPricedInSourceCurrency> {
88        let target_period =
89            match Into::<PeriodAnno>::into(self.information().offset().period().clone()) {
90                PeriodAnno::YearMonthAndFortnight(_) => {
91                    Period::try_from_period_anno(PeriodAnno::from(*input.period()))?
92                }
93                PeriodAnno::YearAndMonth(_) => Period::try_from_period_anno(PeriodAnno::from(
94                    YearAndMonth::from(*input.period()),
95                ))?,
96            };
97        let items = input.items();
98        let invoice_date = target_period.to_date_end_of_period();
99        let due_date = invoice_date.advance(self.payment_info().terms());
100        let is_expenses = items.is_expenses();
101
102        let number = calculate_invoice_number(
103            self.information().offset(),
104            &target_period,
105            is_expenses,
106            self.information().record_of_periods_off(),
107        )?;
108        let is_expenses_str_or_empty = if is_expenses { "_expenses" } else { "" };
109        let vendor_name = self.vendor.company_name().replace(' ', "_");
110
111        let output_path = input
112            .maybe_output_path()
113            .as_ref()
114            .cloned()
115            .map(OutputPath::AbsolutePath)
116            .unwrap_or_else(|| {
117                OutputPath::Name(format!(
118                    "{}_{}{}_invoice_{}.pdf",
119                    invoice_date, vendor_name, is_expenses_str_or_empty, number
120                ))
121            });
122
123        let full_info = InvoiceInfoFull::builder()
124            .due_date(due_date)
125            .invoice_date(invoice_date)
126            .emphasize_color_hex(
127                self.information()
128                    .emphasize_color_hex()
129                    .clone()
130                    .unwrap_or_default(),
131            )
132            .maybe_footer_text(self.information().footer_text().clone())
133            .number(number)
134            .maybe_purchase_order(self.information().purchase_order().clone())
135            .build();
136
137        let input_unpriced =
138            DataFromDiskWithItemsOfKind::<LineItemsPricedInSourceCurrency>::builder()
139                .client(self.client.clone())
140                .information(full_info)
141                .line_items(match items {
142                    InvoicedItems::Service { time_off } => {
143                        if let Some(time_off) = time_off {
144                            if time_off.granularity() != self.service_fees().rate().granularity() {
145                                return Err(Error::InvalidGranularityForTimeOff {
146                                    free_granularity: time_off.granularity(),
147                                    service_fees_granularity: self
148                                        .service_fees()
149                                        .rate()
150                                        .granularity(),
151                                });
152                            }
153                        }
154
155                        let quantity = self.billable_quantity(
156                            &target_period,
157                            *self.service_fees().cadence(),
158                            time_off,
159                        )?;
160                        let service = Item::builder()
161                            .name(self.service_fees.name().clone())
162                            .transaction_date(invoice_date)
163                            .quantity(quantity)
164                            .unit_price(self.service_fees.unit_price())
165                            .currency(*self.payment_info.currency())
166                            .build();
167                        LineItemsPricedInSourceCurrency::Service(service)
168                    }
169                    InvoicedItems::Expenses => {
170                        let expenses = self.expensed_periods.get(&target_period)?;
171                        LineItemsPricedInSourceCurrency::Expenses(expenses.clone())
172                    }
173                })
174                .payment_info(self.payment_info)
175                .vendor(self.vendor)
176                .output_path(output_path)
177                .build();
178
179        Ok(input_unpriced)
180    }
181}
182
183impl<Period: IsPeriod + HasSample> HasSample for Data<Period> {
184    fn sample() -> Self {
185        Data::builder()
186            .information(ProtoInvoiceInfo::sample())
187            .client(CompanyInformation::sample_client())
188            .vendor(CompanyInformation::sample_vendor())
189            .payment_info(PaymentInformation::sample())
190            .service_fees(ServiceFees::sample())
191            .expensed_periods(ExpensedPeriods::sample())
192            .build()
193    }
194
195    fn sample_other() -> Self {
196        Data::builder()
197            .information(ProtoInvoiceInfo::sample_other())
198            .client(CompanyInformation::sample_client())
199            .vendor(CompanyInformation::sample_vendor())
200            .payment_info(PaymentInformation::sample_other())
201            .service_fees(ServiceFees::sample_other())
202            .expensed_periods(ExpensedPeriods::sample_other())
203            .build()
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use insta::assert_ron_snapshot;
210
211    use super::*;
212    use test_log::test;
213
214    type Sut = Data<YearAndMonth>;
215
216    #[test]
217    fn equality() {
218        assert_eq!(Sut::sample(), Sut::sample());
219        assert_eq!(Sut::sample_other(), Sut::sample_other());
220    }
221
222    #[test]
223    fn inequality() {
224        assert_ne!(Sut::sample(), Sut::sample_other());
225    }
226
227    #[test]
228    fn test_serialization_sample() {
229        assert_ron_snapshot!(Sut::sample())
230    }
231
232    #[test]
233    fn expenses() {
234        let sut = Sut::sample();
235        let input = ValidInput::builder()
236            .items(InvoicedItems::Expenses)
237            .period(
238                YearMonthAndFortnight::builder()
239                    .year(2025.into())
240                    .month(Month::May)
241                    .half(MonthHalf::First)
242                    .build(),
243            )
244            .build();
245        let partial = sut.to_partial(input).unwrap();
246        assert!(partial.line_items().is_expenses());
247    }
248
249    #[test]
250    fn test_worked_days_when_ooo_is_greater_than_0() {
251        let sut = Sut::sample();
252        let partial = sut
253            .to_partial(
254                ValidInput::builder()
255                    .items(InvoicedItems::Service {
256                        time_off: Some(TimeOff::Days(Quantity::from(dec!(2.0)))),
257                    })
258                    .period(YearMonthAndFortnight::sample())
259                    .build(),
260            )
261            .unwrap();
262        assert_eq!(
263            partial
264                .line_items()
265                .clone()
266                .try_unwrap_service()
267                .unwrap()
268                .quantity(),
269            &Quantity::from(dec!(22.0))
270        );
271    }
272    #[test]
273    fn to_partial_with_free_time_with_invalid_granularity_hour_instead_of_expected_day() {
274        // Create service fees with Hour granularity (more granular than Day)
275        let service_fees_hour = ServiceFees::builder()
276            .name("Hourly Consulting Services".to_string())
277            .rate(Rate::hourly(dec!(150.0)))
278            .cadence(Cadence::Monthly)
279            .build()
280            .expect("Should build service fees");
281
282        // Create data with Hour granularity service fees
283        let sut = Data::<YearAndMonth>::builder()
284            .information(ProtoInvoiceInfo::sample())
285            .vendor(CompanyInformation::sample_vendor())
286            .client(CompanyInformation::sample_client())
287            .payment_info(PaymentInformation::sample())
288            .service_fees(service_fees_hour)
289            .expensed_periods(ExpensedPeriods::sample())
290            .build();
291
292        let input = ValidInput::builder()
293            .items(InvoicedItems::Service {
294                // Free time is Day granularity, but service is Hour granularity
295                // Day > Hour in the granularity ordering, so this should fail
296                time_off: Some(TimeOff::Days(Quantity::from(dec!(2.0)))),
297            })
298            .period(YearMonthAndFortnight::sample())
299            .build();
300
301        let result = sut.to_partial(input);
302
303        assert!(
304            result.is_err(),
305            "Expected InvalidGranularityForTimeOff error"
306        );
307
308        if let Err(Error::InvalidGranularityForTimeOff {
309            free_granularity,
310            service_fees_granularity,
311        }) = result
312        {
313            assert_eq!(free_granularity, Granularity::Day);
314            assert_eq!(service_fees_granularity, Granularity::Hour);
315        } else {
316            panic!(
317                "Expected InvalidGranularityForTimeOff error, got: {:?}",
318                result
319            );
320        }
321    }
322
323    #[test]
324    fn to_partial_with_free_time_with_invalid_granularity_hour_for_day_service() {
325        // Create service fees with Day granularity (less granular than Hour)
326        let service_fees_day = ServiceFees::builder()
327            .name("Daily Consulting Services".to_string())
328            .rate(Rate::daily(dec!(1000.0)))
329            .cadence(Cadence::Monthly)
330            .build()
331            .expect("Should build service fees");
332
333        // Create data with Day granularity service fees
334        let sut = Data::<YearAndMonth>::builder()
335            .information(ProtoInvoiceInfo::sample())
336            .vendor(CompanyInformation::sample_vendor())
337            .client(CompanyInformation::sample_client())
338            .payment_info(PaymentInformation::sample())
339            .service_fees(service_fees_day)
340            .expensed_periods(ExpensedPeriods::sample())
341            .build();
342
343        let input = ValidInput::builder()
344            .items(InvoicedItems::Service {
345                // Free time is Hour granularity, service is Day granularity
346                // Hour < Day in the granularity ordering, so this should succeed
347                // because free time can be more granular than service granularity
348                time_off: Some(TimeOff::Hours(Quantity::from(dec!(8.0)))),
349            })
350            .period(YearMonthAndFortnight::sample())
351            .build();
352
353        let result = sut.to_partial(input);
354
355        // Should fail with InvalidGranularityForTimeOff error
356        assert!(
357            result.is_err(),
358            "Expected InvalidGranularityForTimeOff error"
359        );
360
361        if let Err(Error::InvalidGranularityForTimeOff {
362            free_granularity,
363            service_fees_granularity,
364        }) = result
365        {
366            assert_eq!(free_granularity, Granularity::Hour);
367            assert_eq!(service_fees_granularity, Granularity::Day);
368        } else {
369            panic!(
370                "Expected InvalidGranularityForTimeOff error, got: {:?}",
371                result
372            );
373        }
374    }
375
376    #[test]
377    fn test_to_partial_when_offset_is_year_month_and_fortnight() {
378        let offset_period = YearMonthAndFortnight::builder()
379            .year(2025.into())
380            .month(Month::May)
381            .half(MonthHalf::First)
382            .build();
383        let sut = Data::<YearMonthAndFortnight>::builder()
384            .information(
385                ProtoInvoiceInfo::builder()
386                    .offset(
387                        TimestampedInvoiceNumber::<YearMonthAndFortnight>::builder()
388                            .offset(100.into())
389                            .period(offset_period)
390                            .build(),
391                    )
392                    .build(),
393            )
394            .vendor(CompanyInformation::sample_vendor())
395            .client(CompanyInformation::sample_client())
396            .payment_info(PaymentInformation::sample())
397            .service_fees(ServiceFees::sample())
398            .expensed_periods(ExpensedPeriods::sample())
399            .build();
400        let target_period = YearMonthAndFortnight::builder()
401            .year(2025.into())
402            .month(Month::May)
403            .half(MonthHalf::First)
404            .build();
405        let input = ValidInput::builder()
406            .items(InvoicedItems::Service { time_off: None })
407            .period(target_period)
408            .build();
409        let partial = sut.to_partial(input).unwrap();
410        let invoice_date = partial.information().invoice_date();
411        assert_eq!(*invoice_date, target_period.to_date_end_of_period());
412    }
413}