import re from typing import Dict, List, Set from .construction_payment_notice_dates_data import SCHEME_PERIODS, SCHEME_PERIODS_HISTORY from .construction_payment_notice_dates_types import PaymentNoticeDates, SchemePaymentKind from .dates_add_days import add_days from .dates_bank_holidays import UkRegion, bank_holidays from .dates_day_of_week import day_of_week 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), )