use std::collections::{HashMap, HashSet}; use super::funejson::Value; use super::construction_payment_notice_dates_data::{SchemePeriod, SCHEME_PERIODS, SCHEME_PERIODS_HISTORY}; use super::dates_add_days::add_days; use super::dates_bank_holidays::bank_holidays; use super::dates_day_of_week::day_of_week; const SUNDAY: i64 = 7; fn check_date(name: &str, value: &str) { let bytes = value.as_bytes(); let shaped = bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes .iter() .enumerate() .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()); if !shaped { panic!("{} must be an ISO date (YYYY-MM-DD), received \"{}\"", name, value); } add_days(value, 0); // rejects impossible dates such as 2026-02-30 } fn is_sunday(iso: &str) -> bool { day_of_week(iso) == SUNDAY } // Schedule 1 of the Banking and Financial Dealings Act 1971 names some fixed // dates that gov.uk's list leaves out when they fall on a Saturday (it lists // the weekday substitute instead). s.116 excludes the statutory day as well. fn statutory_days(region: &str, year: i64) -> Vec { let y = format!("{:04}", year); let dec25 = format!("{}-12-25", y); let dec26 = format!("{}-12-26", y); let mut days = vec![dec25.clone()]; // s.116(3) names Christmas Day itself if region == "england-and-wales" { if !is_sunday(&dec26) { days.push(dec26.clone()); } if is_sunday(&dec25) || is_sunday(&dec26) { days.push(format!("{}-12-27", y)); } } else if region == "scotland" { let jan1 = format!("{}-01-01", y); let jan2 = format!("{}-01-02", y); if !is_sunday(&jan1) { days.push(jan1.clone()); } if !is_sunday(&jan2) { days.push(jan2.clone()); } if is_sunday(&jan1) || is_sunday(&jan2) { days.push(format!("{}-01-03", y)); } if is_sunday(&dec25) { days.push(dec26); } } days } struct Reckoner<'a> { region: &'a str, excluded_by_year: HashMap>, } impl<'a> Reckoner<'a> { fn is_excluded(&mut self, iso: &str) -> bool { let year: i64 = iso[0..4].parse().unwrap(); let region = self.region; let set = self.excluded_by_year.entry(year).or_insert_with(|| { let mut set: HashSet = bank_holidays(region, year).into_iter().map(|h| h.date).collect(); for d in statutory_days(region, year) { set.insert(d); } set }); set.contains(iso) } // Count `days` reckonable days after `from` (before it when negative). fn reckon(&mut self, from: &str, days: i64) -> String { let step = if days < 0 { -1 } else { 1 }; let mut remaining = days.abs(); let mut day = from.to_string(); while remaining > 0 { day = add_days(&day, step); if !self.is_excluded(&day) { remaining -= 1; } } day } } /// The Scheme for Construction Contracts' default payment deadlines. /// /// Every period is reckoned as s.116 of the Housing Grants, Construction and /// Regeneration Act 1996 says: it starts the day after the date it runs from, /// weekends count, and Christmas Day, Good Friday and that part of the UK's /// bank holidays do not. That is why this is not dates.add-business-days. /// /// # Panics /// Panics on a malformed date, a region or contract date the Scheme data does /// not cover, or a year without published bank holidays. pub fn payment_notice_dates( kind: &str, work_date: &str, claim_date: &str, contract_date: &str, region: &str, ) -> PaymentNoticeDates { check_date("workDate", work_date); check_date("claimDate", claim_date); check_date("contractDate", contract_date); let mut rule: Option<&SchemePeriod> = None; for row in SCHEME_PERIODS.iter() { if row.region != region || row.kind != kind { continue; } if contract_date < row.valid_from { continue; } if let Some(valid_to) = row.valid_to { if contract_date > valid_to { continue; } } if rule.map_or(true, |r| row.valid_from > r.valid_from) { rule = Some(row); } } let rule = match rule { Some(rule) => rule, None => { if SCHEME_PERIODS_HISTORY != "full" { let mut earliest: Option<&'static str> = None; for row in SCHEME_PERIODS.iter() { if row.region != region || row.kind != kind { continue; } if earliest.map_or(true, |e| row.valid_from < e) { earliest = Some(row.valid_from); } } if let Some(earliest) = earliest { if contract_date < earliest { panic!( "no Scheme for Construction Contracts periods for {}/{} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for older contracts.", region, kind, contract_date, SCHEME_PERIODS_HISTORY, earliest ); } } } panic!( "no Scheme for Construction Contracts periods for {}/{} for a contract entered into on {}", region, kind, contract_date ); } }; let mut reckoner = Reckoner { region, excluded_by_year: HashMap::new(), }; let expiry = reckoner.reckon(work_date, rule.due_days); let due_date = if claim_date > expiry.as_str() { claim_date.to_string() } else { expiry }; let final_date = reckoner.reckon(&due_date, rule.final_date_days); PaymentNoticeDates { payment_notice_by: reckoner.reckon(&due_date, rule.payment_notice_days), pay_less_notice_by: reckoner.reckon(&final_date, -rule.pay_less_notice_days), due_date, final_date_for_payment: final_date, } } pub fn payment_notice_dates_to_value(dates: &PaymentNoticeDates) -> Value { Value::obj(vec![ ("dueDate", Value::str(&dates.due_date)), ("paymentNoticeBy", Value::str(&dates.payment_notice_by)), ("finalDateForPayment", Value::str(&dates.final_date_for_payment)), ("payLessNoticeBy", Value::str(&dates.pay_less_notice_by)), ]) } pub fn fune_vector(args: &[Value]) -> Value { payment_notice_dates_to_value(&payment_notice_dates( args[0].as_str(), args[1].as_str(), args[2].as_str(), args[3].as_str(), args[4].as_str(), )) }