construction.payment-notice-dates@1.0.0
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),
};
}