Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/cli/src/input/tui/helpers/build_service_fees.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use inquire::{CustomType, Text, error::InquireResult};
use inquire::{Confirm, CustomType, Text, error::InquireResult};

use crate::{
Cadence, Granularity, InvoiceDataFromTuiError, Rate, Result, ServiceFees, UnitPrice,
Expand Down Expand Up @@ -33,10 +33,19 @@ pub fn build_service_fees(default: &ServiceFees) -> Result<ServiceFees> {

let rate = Rate::from((unit_price, granularity));

let off_on_bank_holidays = Confirm::new("Off on bank holidays?")
.with_help_message(
"If yes, public holidays in the vendor's country are deducted from billable \
working days (day/hour rates only). Looked up online and cached.",
)
.with_default(*default.off_on_bank_holidays())
.prompt()?;

Ok(ServiceFees::builder()
.name(name)
.cadence(cadence)
.rate(rate)
.off_on_bank_holidays(off_on_bank_holidays)
.build()
.unwrap())
}
Expand Down
6 changes: 5 additions & 1 deletion crates/core-invoice/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ documentation = "https://docs.rs/klirr-core-invoice"
repository = "https://github.com/Sajjon/klirr"

[dependencies]
klirr-foundation = { workspace = true, features = ["crypto", "exchange-rates"] }
klirr-foundation = { workspace = true, features = [
"crypto",
"exchange-rates",
"bank-holidays",
] }

bon.workspace = true
chrono.workspace = true
Expand Down
16 changes: 12 additions & 4 deletions crates/core-invoice/src/logic/calendar_logic.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
Cadence, Date, Error, Granularity, InvoiceNumber, Quantity, RecordOfPeriodsOff, RelativeTime,
Result, TimestampedInvoiceNumber,
BankHolidays, Cadence, Date, Error, Granularity, InvoiceNumber, Quantity, RecordOfPeriodsOff,
RelativeTime, Result, TimestampedInvoiceNumber,
};
use klirr_foundation::{
CalendarError, calculate_period_number, normalize_period_end_date_for_cadence as normalize,
Expand Down Expand Up @@ -145,6 +145,7 @@ pub fn calculate_invoice_number(
/// Granularity::Day,
/// Cadence::Monthly,
/// &RecordOfPeriodsOff::default(),
/// &BankHolidays::default(),
/// )
/// .unwrap();
///
Expand All @@ -155,9 +156,16 @@ pub fn quantity_in_period(
granularity: Granularity,
cadence: Cadence,
record_of_periods_off: &RecordOfPeriodsOff,
bank_holidays: &BankHolidays,
) -> Result<Quantity> {
quantity_in_period_inner(target_date, granularity, cadence, record_of_periods_off)
.map_err(map_calendar_error)
quantity_in_period_inner(
target_date,
granularity,
cadence,
record_of_periods_off,
bank_holidays,
)
.map_err(map_calendar_error)
}

#[cfg(test)]
Expand Down
89 changes: 89 additions & 0 deletions crates/core-invoice/src/logic/prepare_data/bank_holidays.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
use crate::{BankHolidays, CountryCode, Data, Date};
use log::{debug, warn};

/// The disk-cached bank-holiday fetcher, re-exported from the foundation crate.
pub type BankHolidaysFetcher<T = ()> = klirr_foundation::BankHolidaysFetcher<T>;

/// Resolves the public holidays to deduct from billable days for an invoice,
/// degrading gracefully so holiday lookup never blocks PDF generation.
///
/// Returns an empty set (no deduction) when:
/// - the vendor has not opted into `off_on_bank_holidays`,
/// - the vendor's free-text country cannot be mapped to an ISO country code, or
/// - the holiday API request fails (and nothing is cached).
///
/// Otherwise it returns the (possibly cached) holidays for the vendor's country
/// in the invoice period's year.
pub fn resolve_bank_holidays(data: &Data, target_period_end_date: &Date) -> BankHolidays {
if !data.service_fees().off_on_bank_holidays() {
return BankHolidays::default();
}

let country_name = data.vendor().postal_address().country();
let Some(country_code) = CountryCode::from_country_name(country_name) else {
warn!(
"off_on_bank_holidays is enabled but vendor country '{country_name}' could not be \
resolved to an ISO country code; skipping bank-holiday deduction."
);
return BankHolidays::default();
};

let year = *target_period_end_date.year();
debug!("Resolving bank holidays for {country_code} {year}.");
match BankHolidaysFetcher::default().holidays_for(&country_code, year) {
Ok(holidays) => holidays,
Err(error) => {
warn!(
"Failed to fetch bank holidays for {country_code} {year}: {error}. \
Skipping bank-holiday deduction."
);
BankHolidays::default()
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{HasSample, ServiceFees};
use test_log::test;

#[test]
fn disabled_flag_returns_empty() {
// Sample data has off_on_bank_holidays = false.
let data = Data::sample();
let period_end = Date::sample();
assert!(resolve_bank_holidays(&data, &period_end).is_empty());
}

#[test]
fn enabled_with_unresolved_country_returns_empty_without_network() {
// A vendor whose country cannot be mapped must degrade to empty without
// attempting (or depending on) any network call.
let service_fees = ServiceFees::builder()
.name("Consulting".to_string())
.rate(crate::Rate::daily(rust_decimal::dec!(100.0)))
.cadence(crate::Cadence::Monthly)
.off_on_bank_holidays(true)
.build()
.unwrap();

let vendor = crate::CompanyInformation::sample_vendor();
let address = vendor
.postal_address()
.clone()
.with_country("Atlantis".to_string());
let vendor = vendor.with_postal_address(address);

let data = Data::builder()
.information(crate::ProtoInvoiceInfo::sample())
.vendor(vendor)
.client(crate::CompanyInformation::sample_client())
.payment_info(crate::PaymentInformation::sample())
.service_fees(service_fees)
.expensed_periods(crate::ExpensedPeriods::sample())
.build();

assert!(resolve_bank_holidays(&data, &Date::sample()).is_empty());
}
}
2 changes: 2 additions & 0 deletions crates/core-invoice/src/logic/prepare_data/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
mod bank_holidays;
mod exchange_rates;
#[allow(clippy::module_inception)]
mod prepare_input_data;

pub use bank_holidays::*;
pub use exchange_rates::*;
pub use prepare_input_data::*;
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
Currency, Data, ExchangeRates, ExchangeRatesMap, Item, LineItemsPricedInSourceCurrency,
PreparedData, Result, ValidInput,
PreparedData, Result, ValidInput, normalize_period_end_date_for_cadence, resolve_bank_holidays,
};
use log::debug;
use log::info;
Expand Down Expand Up @@ -32,7 +32,10 @@ pub fn prepare_invoice_input_data(
fetcher: impl FetchExchangeRates,
) -> Result<PreparedData> {
info!("Preparing invoice input data for PDF generation...");
let partial = data.to_partial(input)?;
let cadence = *data.service_fees().cadence();
let target_period_end_date = normalize_period_end_date_for_cadence(*input.date(), cadence)?;
let bank_holidays = resolve_bank_holidays(&data, &target_period_end_date);
let partial = data.to_partial(input, &bank_holidays)?;
let currency = *partial.payment_info().currency();
let exchange_rates = fetcher.fetch_for_line_items(currency, partial.line_items())?;
let data_typst_compat = partial.to_typst(exchange_rates)?;
Expand Down
38 changes: 26 additions & 12 deletions crates/core-invoice/src/models/data/data.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::{
Cadence, CompanyInformation, DataFromDiskWithItemsOfKind, DataWithItemsPricedInSourceCurrency,
Error, ExpensedPeriods, HasSample, InvoiceInfoFull, InvoicedItems, Item,
LineItemsPricedInSourceCurrency, OutputPath, PaymentInformation, ProtoInvoiceInfo, Quantity,
Result, ServiceFees, TimeOff, ValidInput, calculate_invoice_number,
BankHolidays, Cadence, CompanyInformation, DataFromDiskWithItemsOfKind,
DataWithItemsPricedInSourceCurrency, Error, ExpensedPeriods, HasSample, InvoiceInfoFull,
InvoicedItems, Item, LineItemsPricedInSourceCurrency, OutputPath, PaymentInformation,
ProtoInvoiceInfo, Quantity, Result, ServiceFees, TimeOff, ValidInput, calculate_invoice_number,
normalize_period_end_date_for_cadence, quantity_in_period,
};
use bon::Builder;
Expand Down Expand Up @@ -109,11 +109,17 @@ impl Data {
target_period_end_date: &crate::Date,
cadence: Cadence,
time_off: &Option<TimeOff>,
bank_holidays: &BankHolidays,
) -> 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_end_date, granularity, cadence, periods_off)?;
let quantity_in_period = quantity_in_period(
target_period_end_date,
granularity,
cadence,
periods_off,
bank_holidays,
)?;
Ok(quantity_in_period - time_off.map(|d| *d).unwrap_or(Quantity::ZERO))
}

Expand All @@ -130,11 +136,15 @@ impl Data {
///
/// let data = Data::sample();
/// let input = ValidInput::sample();
/// let partial = data.to_partial(input).unwrap();
/// let partial = data.to_partial(input, &BankHolidays::default()).unwrap();
///
/// assert!(!partial.line_items().is_expenses());
/// ```
pub fn to_partial(self, input: ValidInput) -> Result<DataWithItemsPricedInSourceCurrency> {
pub fn to_partial(
self,
input: ValidInput,
bank_holidays: &BankHolidays,
) -> Result<DataWithItemsPricedInSourceCurrency> {
let items = input.items();
let cadence = *self.service_fees().cadence();
let target_period_end_date = normalize_period_end_date_for_cadence(*input.date(), cadence)?;
Expand Down Expand Up @@ -200,8 +210,12 @@ impl Data {
}
}

let quantity =
self.billable_quantity(&target_period_end_date, cadence, time_off)?;
let quantity = self.billable_quantity(
&target_period_end_date,
cadence,
time_off,
bank_holidays,
)?;
let service = Item::builder()
.name(self.service_fees.name().clone())
.transaction_date(invoice_date)
Expand Down Expand Up @@ -321,7 +335,7 @@ mod tests {
.items(InvoicedItems::Expenses)
.date(crate::Date::sample())
.build();
let partial = sut.to_partial(input).unwrap();
let partial = sut.to_partial(input, &BankHolidays::default()).unwrap();
assert!(partial.line_items().is_expenses());
}

Expand Down Expand Up @@ -350,7 +364,7 @@ mod tests {
.date(crate::Date::sample())
.build();

let result = sut.to_partial(input);
let result = sut.to_partial(input, &BankHolidays::default());

if let Err(Error::InvalidGranularityForTimeOff {
free_granularity,
Expand Down
43 changes: 43 additions & 0 deletions crates/core-invoice/src/models/data/submodels/service_fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ pub struct ServiceFees {
/// How often you invoice, cannot be
#[getset(get = "pub")]
cadence: Cadence,

/// When `true`, public holidays in the vendor's country are deducted from
/// billable working days (for day- and hour-granularity rates). Defaults to
/// `false`, which preserves the prior behavior of counting every weekday.
///
/// Holidays are looked up online (and cached to disk) using the vendor's
/// country; if the country cannot be resolved or the lookup fails, no
/// deduction is made.
#[getset(get = "pub")]
#[serde(default)]
off_on_bank_holidays: bool,
}

#[bon]
Expand All @@ -36,6 +47,7 @@ impl ServiceFees {
name: impl AsRef<str>,
rate: impl Into<Rate>,
cadence: Cadence,
#[builder(default)] off_on_bank_holidays: bool,
) -> Result<Self, Error> {
let rate = rate.into();
if !cadence.validate(rate.granularity()) {
Expand All @@ -45,6 +57,7 @@ impl ServiceFees {
name: name.as_ref().to_owned(),
rate,
cadence,
off_on_bank_holidays,
})
}
}
Expand Down Expand Up @@ -99,4 +112,34 @@ mod tests {
fn test_serde() {
assert_ron_snapshot!(Sut::sample())
}

#[test]
fn off_on_bank_holidays_defaults_to_false() {
assert!(!Sut::sample().off_on_bank_holidays());
}

#[test]
fn builder_sets_off_on_bank_holidays() {
let fees = Sut::builder()
.name("Consulting".to_string())
.rate(crate::Rate::daily(dec!(100.0)))
.cadence(crate::Cadence::Monthly)
.off_on_bank_holidays(true)
.build()
.unwrap();
assert!(fees.off_on_bank_holidays());
}

#[test]
fn deserializes_legacy_ron_without_flag_as_false() {
// RON persisted before the field existed must still load (serde default).
let legacy = r#"(
name: "Agreed Consulting Service",
rate: Daily(UnitPrice(777.0)),
cadence: Monthly,
)"#;
let fees: Sut = crate::deserialize_ron_str(legacy).unwrap();
assert!(!fees.off_on_bank_holidays());
assert_eq!(fees.name(), "Agreed Consulting Service");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ ServiceFees(
name: "Discreet Investigative Services",
rate: Daily(UnitPrice(777.0)),
cadence: Monthly,
off_on_bank_holidays: false,
)
4 changes: 2 additions & 2 deletions crates/core-invoice/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ pub use item_converted_into_target_currency::*;
pub use klirr_foundation::HasSample;
pub use klirr_foundation::OutputPath;
pub use klirr_foundation::{
CompanyInformation, Cost, Date, Day, Decimal, HexColor, Month, MonthHalf, PostalAddress,
Quantity, Rate, RelativeTime, StreetAddress, UnitPrice, Vat, Year,
BankHolidays, CompanyInformation, Cost, CountryCode, Date, Day, Decimal, HexColor, Month,
MonthHalf, PostalAddress, Quantity, Rate, RelativeTime, StreetAddress, UnitPrice, Vat, Year,
};
pub use l10n::*;
pub use layout::*;
Expand Down
Loading
Loading