construction.payment-notice-dates@1.0.2
impl/python.py
4,958 bytes · the Python 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 re
from typing import Dict, List, Set
from .construction_payment_notice_dates_data import SCHEME_PERIODS, SCHEME_PERIODS_HISTORY ← this capability’s own data, compiled from data/scheme-periods.json into the same file by fune build
from .construction_payment_notice_dates_types import PaymentNoticeDates, SchemePaymentKind
from .dates_add_days import add_days ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_bank_holidays import UkRegion, bank_holidays ← from dates.bank-holidays ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week ← from dates.day-of-week ^1.0.0 · built alongside by fune
ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
SUNDAY = 7
def _check_date(name: str, value: str) -> None:
if not isinstance(value, str) or not ISO_DATE.fullmatch(value):
raise ValueError('%s must be an ISO date (YYYY-MM-DD), received "%s"' % (name, value))
add_days(value, 0) # rejects impossible dates such as 2026-02-30
def _is_sunday(iso: str) -> bool:
return day_of_week(iso) == SUNDAY
def _statutory_days(region: str, year: int) -> List[str]:
# 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.
y = "%04d" % year
dec25 = y + "-12-25"
dec26 = y + "-12-26"
days = [dec25] # s.116(3) names Christmas Day itself
if region == "england-and-wales":
if not _is_sunday(dec26):
days.append(dec26)
if _is_sunday(dec25) or _is_sunday(dec26):
days.append(y + "-12-27")
elif region == "scotland":
jan1 = y + "-01-01"
jan2 = y + "-01-02"
if not _is_sunday(jan1):
days.append(jan1)
if not _is_sunday(jan2):
days.append(jan2)
if _is_sunday(jan1) or _is_sunday(jan2):
days.append(y + "-01-03")
if _is_sunday(dec25):
days.append(dec26)
return days
def payment_notice_dates(
kind: SchemePaymentKind, work_date: str, claim_date: str, contract_date: str, region: UkRegion
) -> PaymentNoticeDates:
"""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.
"""
_check_date("workDate", work_date)
_check_date("claimDate", claim_date)
_check_date("contractDate", contract_date)
rule = None
for row in SCHEME_PERIODS:
if row.region != region or row.kind != kind:
continue
if contract_date < row.valid_from:
continue
if row.valid_to is not None and contract_date > row.valid_to:
continue
if rule is None or row.valid_from > rule.valid_from:
rule = row
if rule is None:
if SCHEME_PERIODS_HISTORY != "full":
earliest = None
for row in SCHEME_PERIODS:
if row.region != region or row.kind != kind:
continue
if earliest is None or row.valid_from < earliest:
earliest = row.valid_from
if earliest is not None and contract_date < earliest:
raise ValueError(
"no Scheme for Construction Contracts periods for %s/%s on %s: this build was installed with history=%s, "
"so it only carries rules from %s. Reinstall with history=full for older contracts."
% (region, kind, contract_date, SCHEME_PERIODS_HISTORY, earliest)
)
raise ValueError(
"no Scheme for Construction Contracts periods for %s/%s for a contract entered into on %s"
% (region, kind, contract_date)
)
excluded_by_year: Dict[int, Set[str]] = {}
def is_excluded(iso: str) -> bool:
year = int(iso[0:4])
excluded = excluded_by_year.get(year)
if excluded is None:
excluded = set(h.date for h in bank_holidays(region, year))
excluded.update(_statutory_days(region, year))
excluded_by_year[year] = excluded
return iso in excluded
def reckon(start: str, days: int) -> str:
# Count `days` reckonable days after `start` (before it when negative).
step = -1 if days < 0 else 1
remaining = abs(days)
day = start
while remaining > 0:
day = add_days(day, step)
if not is_excluded(day):
remaining -= 1
return day
expiry = reckon(work_date, rule.due_days)
due_date = claim_date if claim_date > expiry else expiry
final_date = reckon(due_date, rule.final_date_days)
return PaymentNoticeDates(
due_date=due_date,
payment_notice_by=reckon(due_date, rule.payment_notice_days),
final_date_for_payment=final_date,
pay_less_notice_by=reckon(final_date, -rule.pay_less_notice_days),
)