Functional Weave
Code in Rust

construction.payment-notice-dates@1.0.2

impl/typescript.ts

4,767 bytes · the TypeScript 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.

import { addDays } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { type UkRegion, bankHolidays } from "./dates_bank_holidays.ts";  ← from dates.bank-holidays ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts";  ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { SCHEME_PERIODS, SCHEME_PERIODS_HISTORY } from "./construction_payment_notice_dates_data.ts";  ← this capability’s own data, compiled from data/scheme-periods.json into the same file by fune build
import { type PaymentNoticeDates, type SchemePaymentKind } from "./construction_payment_notice_dates_types.ts";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const SUNDAY = 7;

function checkDate(name: string, value: string): void {
  if (typeof value !== "string" || !ISO_DATE.test(value)) {
    throw new RangeError(`${name} must be an ISO date (YYYY-MM-DD), received "${value}"`);
  }
  addDays(value, 0); // rejects impossible dates such as 2026-02-30
}

function isSunday(iso: string): boolean {
  return dayOfWeek(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.
function statutoryDays(region: string, year: number): string[] {
  const y = String(year).padStart(4, "0");
  const dec25 = `${y}-12-25`;
  const dec26 = `${y}-12-26`;
  const days = [dec25]; // s.116(3) names Christmas Day itself
  if (region === "england-and-wales") {
    if (!isSunday(dec26)) days.push(dec26);
    if (isSunday(dec25) || isSunday(dec26)) days.push(`${y}-12-27`);
  } else if (region === "scotland") {
    const jan1 = `${y}-01-01`;
    const jan2 = `${y}-01-02`;
    if (!isSunday(jan1)) days.push(jan1);
    if (!isSunday(jan2)) days.push(jan2);
    if (isSunday(jan1) || isSunday(jan2)) days.push(`${y}-01-03`);
    if (isSunday(dec25)) days.push(dec26);
  }
  return days;
}

/**
 * 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.
 */
export function paymentNoticeDates(
  kind: SchemePaymentKind,
  workDate: string,
  claimDate: string,
  contractDate: string,
  region: UkRegion
): PaymentNoticeDates {
  checkDate("workDate", workDate);
  checkDate("claimDate", claimDate);
  checkDate("contractDate", contractDate);

  let rule: (typeof SCHEME_PERIODS)[number] | null = null;
  for (const row of SCHEME_PERIODS) {
    if (row.region !== region || row.kind !== kind) continue;
    if (contractDate < row.validFrom) continue;
    if (row.validTo !== null && contractDate > row.validTo) continue;
    if (rule === null || row.validFrom > rule.validFrom) rule = row;
  }
  if (rule === null) {
    if (SCHEME_PERIODS_HISTORY !== "full") {
      let earliest: string | null = null;
      for (const row of SCHEME_PERIODS) {
        if (row.region !== region || row.kind !== kind) continue;
        if (earliest === null || row.validFrom < earliest) earliest = row.validFrom;
      }
      if (earliest !== null && contractDate < earliest) {
        throw new RangeError(
          `no Scheme for Construction Contracts periods for ${region}/${kind} on ${contractDate}: this build was installed with history=${SCHEME_PERIODS_HISTORY}, ` +
            `so it only carries rules from ${earliest}. Reinstall with history=full for older contracts.`
        );
      }
    }
    throw new RangeError(
      `no Scheme for Construction Contracts periods for ${region}/${kind} for a contract entered into on ${contractDate}`
    );
  }

  const excludedByYear = new Map<number, Set<string>>();
  const isExcluded = (iso: string): boolean => {
    const year = Number(iso.slice(0, 4));
    let set = excludedByYear.get(year);
    if (set === undefined) {
      set = new Set(bankHolidays(region, year).map((h) => h.date));
      for (const d of statutoryDays(region, year)) set.add(d);
      excludedByYear.set(year, set);
    }
    return set.has(iso);
  };
  // Count `days` reckonable days after `from` (before it when negative).
  const reckon = (from: string, days: number): string => {
    const step = days < 0 ? -1 : 1;
    let remaining = Math.abs(days);
    let day = from;
    while (remaining > 0) {
      day = addDays(day, step);
      if (!isExcluded(day)) remaining -= 1;
    }
    return day;
  };

  const expiry = reckon(workDate, rule.dueDays);
  const dueDate = claimDate > expiry ? claimDate : expiry;
  const finalDateForPayment = reckon(dueDate, rule.finalDateDays);
  return {
    dueDate,
    paymentNoticeBy: reckon(dueDate, rule.paymentNoticeDays),
    finalDateForPayment,
    payLessNoticeBy: reckon(finalDateForPayment, -rule.payLessNoticeDays),
  };
}