klirr_core/models/data/
data.rs1use crate::prelude::*;
2
3#[derive(
6 Clone, Debug, Serialize, Deserialize, PartialEq, Builder, Getters, WithSetters, Setters,
7)]
8pub struct Data<Period: IsPeriod> {
9 #[getset(get = "pub")]
11 information: ProtoInvoiceInfo<Period>,
12
13 #[getset(get = "pub")]
15 vendor: CompanyInformation,
16
17 #[getset(get = "pub", set_with = "pub")]
19 client: CompanyInformation,
20
21 #[getset(get = "pub")]
25 payment_info: PaymentInformation,
26
27 #[getset(get = "pub")]
29 service_fees: ServiceFees,
30
31 #[getset(get = "pub", set = "pub")]
33 expensed_periods: ExpensedPeriods<Period>,
34}
35
36impl<Period: IsPeriod> Data<Period> {
37 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 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 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 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 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 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 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 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 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}