construction.payment-notice-dates@1.0.0
impl/rust.rs
6,920 bytes · the Rust implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use std::collections::{HashMap, HashSet};
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::construction_payment_notice_dates_data::{SchemePeriod, SCHEME_PERIODS, SCHEME_PERIODS_HISTORY}; ← this capability’s own data, compiled from data/scheme-periods.json into the same file by fune build
use super::dates_add_days::add_days; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_bank_holidays::bank_holidays; ← from dates.bank-holidays ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week; ← from dates.day-of-week ^1.0.0 · built alongside by fune
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<String> {
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<i64, HashSet<String>>,
}
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<String> = 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(),
))
}