import re from .money_amount import Money, money from .payroll_student_loan_data import STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON from .payroll_student_loan_types import StudentLoanPlan from .payroll_tax_period_types import PayFrequency ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") PLANS = ("plan-1", "plan-2", "plan-4", "plan-5", "postgraduate") PERIODS = {"weekly": (1, 52), "fortnightly": (2, 52), "four-weekly": (4, 52), "monthly": (1, 12)} def student_loan(earnings: Money, plan: StudentLoanPlan, frequency: PayFrequency, pay_date: str) -> Money: """The student or postgraduate loan deduction for one pay period. The period threshold is the annual one scaled exactly by weeks/52 or months/12 (Education (Student Loans) (Repayment) Regulations 2009 reg. 44(2)), not HMRC's printed weekly and monthly figures, which are that fraction cut to the penny. The deduction then drops its pence (reg. 44(3)), so the whole calculation stays in integers: pence times the period denominator. """ if earnings.currency != "GBP": raise ValueError("student loan deductions must be in GBP, received %s" % (earnings.currency,)) if earnings.minor < 0: raise ValueError("earnings must not be negative, received %s" % (earnings.minor,)) if plan not in PLANS: raise ValueError('unknown student loan plan "%s"' % (plan,)) if frequency not in PERIODS: raise ValueError('unknown pay frequency "%s"' % (frequency,)) if not isinstance(pay_date, str) or not ISO_DATE.match(pay_date): raise ValueError('payDate must be an ISO date (YYYY-MM-DD), received "%s"' % (pay_date,)) periods, per_year = PERIODS[frequency] rule = next( ( r for r in STUDENT_LOAN_THRESHOLDS if r.plan == plan and pay_date >= r.valid_from and (r.valid_to is None or pay_date <= r.valid_to) ), None, ) if rule is None: if STUDENT_LOAN_THRESHOLDS_HISTORY != "full" and STUDENT_LOAN_THRESHOLDS_HORIZON is not None and pay_date < STUDENT_LOAN_THRESHOLDS_HORIZON: raise ValueError( "no student loan threshold for %s on %s: this build was installed with history=%s, so it only carries rules from %s. " "Reinstall with history=full for earlier tax years." % (plan, pay_date, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON) ) raise ValueError("no student loan threshold for %s on %s" % (plan, pay_date)) # Everything below is in pence x per_year, so the threshold is exact. excess = earnings.minor * per_year - rule.annual_threshold * periods if excess <= 0: return money(0, "GBP") pounds = (excess * rule.basis_points) // (10000 * per_year * 100) return money(pounds * 100, "GBP")