use crate::prelude::*;
#[derive(
Clone, Debug, Serialize, Deserialize, PartialEq, Builder, Getters, WithSetters, Setters,
)]
pub struct Data<Period: IsPeriod> {
#[getset(get = "pub")]
information: ProtoInvoiceInfo<Period>,
#[getset(get = "pub")]
vendor: CompanyInformation,
#[getset(get = "pub", set_with = "pub")]
client: CompanyInformation,
#[getset(get = "pub")]
payment_info: PaymentInformation,
#[getset(get = "pub")]
service_fees: ServiceFees,
#[getset(get = "pub", set = "pub")]
expensed_periods: ExpensedPeriods<Period>,
}
impl<Period: IsPeriod> Data<Period> {
pub fn validate(self) -> Result<Self> {
self.information.validate()?;
Ok(self)
}
fn billable_quantity(
&self,
target_period: &Period,
cadence: Cadence,
time_off: &Option<TimeOff>,
) -> Result<Quantity> {
let granularity = self.service_fees().rate().granularity();
let periods_off = self.information().record_of_periods_off();
let quantity_in_period =
quantity_in_period(target_period, granularity, cadence, periods_off)?;
let billable_quantity = quantity_in_period - time_off.map(|d| *d).unwrap_or(Quantity::ZERO);
Ok(billable_quantity)
}
pub fn to_partial(self, input: ValidInput) -> Result<DataWithItemsPricedInSourceCurrency> {
let target_period =
match Into::<PeriodAnno>::into(self.information().offset().period().clone()) {
PeriodAnno::YearMonthAndFortnight(_) => {
Period::try_from_period_anno(PeriodAnno::from(*input.period()))?
}
PeriodAnno::YearAndMonth(_) => Period::try_from_period_anno(PeriodAnno::from(
YearAndMonth::from(*input.period()),
))?,
};
let items = input.items();
let invoice_date = target_period.to_date_end_of_period();
let due_date = invoice_date.advance(self.payment_info().terms());
let is_expenses = items.is_expenses();
let number = calculate_invoice_number(
self.information().offset(),
&target_period,
is_expenses,
self.information().record_of_periods_off(),
)?;
let is_expenses_str_or_empty = if is_expenses { "_expenses" } else { "" };
let vendor_name = self.vendor.company_name().replace(' ', "_");
let output_path = input
.maybe_output_path()
.as_ref()
.cloned()
.map(OutputPath::AbsolutePath)
.unwrap_or_else(|| {
OutputPath::Name(format!(
"{}_{}{}_invoice_{}.pdf",
invoice_date, vendor_name, is_expenses_str_or_empty, number
))
});
let full_info = InvoiceInfoFull::builder()
.due_date(due_date)
.invoice_date(invoice_date)
.emphasize_color_hex(
self.information()
.emphasize_color_hex()
.clone()
.unwrap_or_default(),
)
.maybe_footer_text(self.information().footer_text().clone())
.number(number)
.maybe_purchase_order(self.information().purchase_order().clone())
.build();
let input_unpriced =
DataFromDiskWithItemsOfKind::<LineItemsPricedInSourceCurrency>::builder()
.client(self.client.clone())
.information(full_info)
.line_items(match items {
InvoicedItems::Service { time_off } => {
if let Some(time_off) = time_off {
if time_off.granularity() != self.service_fees().rate().granularity() {
return Err(Error::InvalidGranularityForTimeOff {
free_granularity: time_off.granularity(),
service_fees_granularity: self
.service_fees()
.rate()
.granularity(),
});
}
}
let quantity = self.billable_quantity(
&target_period,
*self.service_fees().cadence(),
time_off,
)?;
let service = Item::builder()
.name(self.service_fees.name().clone())
.transaction_date(invoice_date)
.quantity(quantity)
.unit_price(self.service_fees.unit_price())
.currency(*self.payment_info.currency())
.build();
LineItemsPricedInSourceCurrency::Service(service)
}
InvoicedItems::Expenses => {
let expenses = self.expensed_periods.get(&target_period)?;
LineItemsPricedInSourceCurrency::Expenses(expenses.clone())
}
})
.payment_info(self.payment_info)
.vendor(self.vendor)
.output_path(output_path)
.build();
Ok(input_unpriced)
}
}
impl<Period: IsPeriod + HasSample> HasSample for Data<Period> {
fn sample() -> Self {
Data::builder()
.information(ProtoInvoiceInfo::sample())
.client(CompanyInformation::sample_client())
.vendor(CompanyInformation::sample_vendor())
.payment_info(PaymentInformation::sample())
.service_fees(ServiceFees::sample())
.expensed_periods(ExpensedPeriods::sample())
.build()
}
fn sample_other() -> Self {
Data::builder()
.information(ProtoInvoiceInfo::sample_other())
.client(CompanyInformation::sample_client())
.vendor(CompanyInformation::sample_vendor())
.payment_info(PaymentInformation::sample_other())
.service_fees(ServiceFees::sample_other())
.expensed_periods(ExpensedPeriods::sample_other())
.build()
}
}
#[cfg(test)]
mod tests {
use insta::assert_ron_snapshot;
use super::*;
use test_log::test;
type Sut = Data<YearAndMonth>;
#[test]
fn equality() {
assert_eq!(Sut::sample(), Sut::sample());
assert_eq!(Sut::sample_other(), Sut::sample_other());
}
#[test]
fn inequality() {
assert_ne!(Sut::sample(), Sut::sample_other());
}
#[test]
fn test_serialization_sample() {
assert_ron_snapshot!(Sut::sample())
}
#[test]
fn expenses() {
let sut = Sut::sample();
let input = ValidInput::builder()
.items(InvoicedItems::Expenses)
.period(
YearMonthAndFortnight::builder()
.year(2025.into())
.month(Month::May)
.half(MonthHalf::First)
.build(),
)
.build();
let partial = sut.to_partial(input).unwrap();
assert!(partial.line_items().is_expenses());
}
#[test]
fn test_worked_days_when_ooo_is_greater_than_0() {
let sut = Sut::sample();
let partial = sut
.to_partial(
ValidInput::builder()
.items(InvoicedItems::Service {
time_off: Some(TimeOff::Days(Quantity::from(dec!(2.0)))),
})
.period(YearMonthAndFortnight::sample())
.build(),
)
.unwrap();
assert_eq!(
partial
.line_items()
.clone()
.try_unwrap_service()
.unwrap()
.quantity(),
&Quantity::from(dec!(22.0))
);
}
#[test]
fn to_partial_with_free_time_with_invalid_granularity_hour_instead_of_expected_day() {
let service_fees_hour = ServiceFees::builder()
.name("Hourly Consulting Services".to_string())
.rate(Rate::hourly(dec!(150.0)))
.cadence(Cadence::Monthly)
.build()
.expect("Should build service fees");
let sut = Data::<YearAndMonth>::builder()
.information(ProtoInvoiceInfo::sample())
.vendor(CompanyInformation::sample_vendor())
.client(CompanyInformation::sample_client())
.payment_info(PaymentInformation::sample())
.service_fees(service_fees_hour)
.expensed_periods(ExpensedPeriods::sample())
.build();
let input = ValidInput::builder()
.items(InvoicedItems::Service {
time_off: Some(TimeOff::Days(Quantity::from(dec!(2.0)))),
})
.period(YearMonthAndFortnight::sample())
.build();
let result = sut.to_partial(input);
assert!(
result.is_err(),
"Expected InvalidGranularityForTimeOff error"
);
if let Err(Error::InvalidGranularityForTimeOff {
free_granularity,
service_fees_granularity,
}) = result
{
assert_eq!(free_granularity, Granularity::Day);
assert_eq!(service_fees_granularity, Granularity::Hour);
} else {
panic!(
"Expected InvalidGranularityForTimeOff error, got: {:?}",
result
);
}
}
#[test]
fn to_partial_with_free_time_with_invalid_granularity_hour_for_day_service() {
let service_fees_day = ServiceFees::builder()
.name("Daily Consulting Services".to_string())
.rate(Rate::daily(dec!(1000.0)))
.cadence(Cadence::Monthly)
.build()
.expect("Should build service fees");
let sut = Data::<YearAndMonth>::builder()
.information(ProtoInvoiceInfo::sample())
.vendor(CompanyInformation::sample_vendor())
.client(CompanyInformation::sample_client())
.payment_info(PaymentInformation::sample())
.service_fees(service_fees_day)
.expensed_periods(ExpensedPeriods::sample())
.build();
let input = ValidInput::builder()
.items(InvoicedItems::Service {
time_off: Some(TimeOff::Hours(Quantity::from(dec!(8.0)))),
})
.period(YearMonthAndFortnight::sample())
.build();
let result = sut.to_partial(input);
assert!(
result.is_err(),
"Expected InvalidGranularityForTimeOff error"
);
if let Err(Error::InvalidGranularityForTimeOff {
free_granularity,
service_fees_granularity,
}) = result
{
assert_eq!(free_granularity, Granularity::Hour);
assert_eq!(service_fees_granularity, Granularity::Day);
} else {
panic!(
"Expected InvalidGranularityForTimeOff error, got: {:?}",
result
);
}
}
#[test]
fn test_to_partial_when_offset_is_year_month_and_fortnight() {
let offset_period = YearMonthAndFortnight::builder()
.year(2025.into())
.month(Month::May)
.half(MonthHalf::First)
.build();
let sut = Data::<YearMonthAndFortnight>::builder()
.information(
ProtoInvoiceInfo::builder()
.offset(
TimestampedInvoiceNumber::<YearMonthAndFortnight>::builder()
.offset(100.into())
.period(offset_period)
.build(),
)
.build(),
)
.vendor(CompanyInformation::sample_vendor())
.client(CompanyInformation::sample_client())
.payment_info(PaymentInformation::sample())
.service_fees(ServiceFees::sample())
.expensed_periods(ExpensedPeriods::sample())
.build();
let target_period = YearMonthAndFortnight::builder()
.year(2025.into())
.month(Month::May)
.half(MonthHalf::First)
.build();
let input = ValidInput::builder()
.items(InvoicedItems::Service { time_off: None })
.period(target_period)
.build();
let partial = sut.to_partial(input).unwrap();
let invoice_date = partial.information().invoice_date();
assert_eq!(*invoice_date, target_period.to_date_end_of_period());
}
}