from typing import List, Optional, Sequence from .dates_add_days import add_days, days_between, parse_iso_date from .dates_add_months import add_months from .dates_day_of_week import day_of_week from .math_round_div import round_div from .money_amount import Money, money from .payroll_statutory_sick_pay_data import SSP_RULES, SSP_RULES_HISTORY, SspRule from .payroll_statutory_sick_pay_types import SicknessPeriod, SickPay, SickPayWeek # Periods of incapacity separated by no more than 8 weeks are one period (SSCBA 1992 s.152(3)). LINK_GAP_DAYS = 56 # 28 weeks' entitlement, counted in 1/420ths of a week so any 1-7 qualifying-day week divides exactly. WEEK_UNITS = 420 MAX_UNITS = 28 * WEEK_UNITS def _rule_for(date: str) -> int: for i, rule in enumerate(SSP_RULES): if date >= rule.valid_from and (rule.valid_to is None or date <= rule.valid_to): return i if SSP_RULES_HISTORY != "full": earliest = min((r.valid_from for r in SSP_RULES), default=None) if earliest is not None and date < earliest: raise ValueError( "no SSP rule for %s: this build was installed with history=%s, so it only carries rules from %s. " "Reinstall with history=full for older sickness." % (date, SSP_RULES_HISTORY, earliest) ) raise ValueError("no SSP rule for %s" % (date,)) def statutory_sick_pay(periods: Sequence[SicknessPeriod], qualifying_weekdays: Sequence[int], normal_weekly_earnings: Money) -> SickPay: """Statutory Sick Pay due for a run of sickness spells. Every day is judged by the rules in force on that day, which is how the 6 April 2026 reforms land: a spell straddling that date serves waiting days before it and is paid from the first qualifying day after it. """ if len(qualifying_weekdays) == 0: raise ValueError("there must be at least one qualifying day a week") qualifying = set() for d in qualifying_weekdays: if isinstance(d, bool) or not isinstance(d, int) or d < 1 or d > 7: raise ValueError("qualifying weekdays must be 1 (Monday) to 7 (Sunday), received %s" % (d,)) if d in qualifying: raise ValueError("qualifying weekday %s is listed twice" % (d,)) qualifying.add(d) per_week = len(qualifying) if normal_weekly_earnings.currency != "GBP": raise ValueError("SSP is paid in GBP, received %s" % (normal_weekly_earnings.currency,)) if normal_weekly_earnings.minor < 0: raise ValueError("normalWeeklyEarnings must not be negative") lines: List[dict] = [] def line_for(week_start: str, rule: int) -> dict: for line in lines: if line["week_start"] == week_start and line["rule"] == rule: return line line = {"week_start": week_start, "rule": rule, "paid": 0, "waiting": 0} lines.append(line) return line previous_end: Optional[str] = None last_piw_end: Optional[str] = None series_end = "" waiting_served = 0 units_used = 0 exhausted = False for period in periods: parse_iso_date(period.start) parse_iso_date(period.end) if period.end < period.start: raise ValueError("sickness period %s to %s ends before it starts" % (period.start, period.end)) if previous_end is not None and period.start <= previous_end: raise ValueError("sickness periods must be in date order and must not overlap") previous_end = period.end # Too short to be a period of incapacity under the rules of its first # day: it is not paid and does not link anything. length = days_between(period.start, period.end) + 1 if length < SSP_RULES[_rule_for(period.start)].minimum_period_days: continue if last_piw_end is None or days_between(last_piw_end, period.start) - 1 > LINK_GAP_DAYS: # A new period of entitlement: waiting days and the 28 weeks start # again, and it ends 3 years on at the latest (SSP (General) Regs 1982 reg 3(3)). waiting_served = 0 units_used = 0 series_end = add_months(period.start, 36) last_piw_end = period.end day = period.start while day <= period.end: weekday = day_of_week(day) current = day day = add_days(day, 1) if weekday not in qualifying: continue index = _rule_for(current) rule = SSP_RULES[index] if rule.lower_earnings_limit is not None and normal_weekly_earnings.minor < rule.lower_earnings_limit: continue week_start = add_days(current, -(weekday % 7)) if waiting_served < rule.waiting_days: waiting_served += 1 line_for(week_start, index)["waiting"] += 1 continue units = WEEK_UNITS // per_week if current >= series_end or units_used + units > MAX_UNITS: exhausted = True continue units_used += units line_for(week_start, index)["paid"] += 1 lines.sort(key=lambda l: (l["week_start"], l["rule"])) weeks = [ SickPayWeek( week_start=l["week_start"], paid_days=l["paid"], waiting_days=l["waiting"], amount=money(_week_amount(SSP_RULES[l["rule"]], normal_weekly_earnings.minor, l["paid"], per_week), "GBP"), ) for l in lines ] return SickPay( total=money(sum(w.amount.minor for w in weeks), "GBP"), paid_days=sum(w.paid_days for w in weeks), waiting_days=sum(w.waiting_days for w in weeks), weeks=weeks, entitlement_exhausted=exhausted, ) def _week_amount(rule: SspRule, earnings: int, days: int, per_week: int) -> int: """SSP for some days of one week: the weekly rate times days over qualifying days in the week, rounded up to the penny, as HMRC's daily-rate tables do. The weekly rate is kept exact (in ten-thousandths of a penny) so that 80% of earnings is never rounded twice.""" rate = rule.weekly_rate * 10000 if rule.earnings_cap_basis_points is not None: rate = min(rate, earnings * rule.earnings_cap_basis_points) return round_div(rate * days, 10000 * per_week, "up")