Functional Weave
Code in Python

construction.payment-notice-dates@1.0.2

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(),
    ))
}