Corp/backend/payroll_core/calculator.py

886 lines
32 KiB
Python

"""Deterministic reference implementation of the payroll calculator."""
from __future__ import annotations
import hashlib
import json
import re
from datetime import date, datetime
from decimal import Context, Decimal, DecimalException, localcontext
from typing import Protocol
from .domain import (
CalculationWarning,
ExpectedTotals,
InputLine,
LineKind,
PayrollLine,
PayrollPeriod,
PayrollRequest,
PayrollResult,
PayrollRules,
PayrollTotals,
PointPolicy,
RoundingPolicy,
decimal_text,
)
from .exceptions import PayrollIssue, PayrollValidationError
_CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
_CODE_RE = re.compile(r"^[A-Z][A-Z0-9_.-]{0,63}$")
_SYSTEM_LINE_PREFIX = "system:"
class PayrollCalculator(Protocol):
"""Port implemented by any compatible calculation provider."""
def calculate(
self,
request: PayrollRequest,
rules: PayrollRules,
) -> PayrollResult: ...
def _canonical_hash(payload: object) -> str:
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _is_finite_decimal(value: object) -> bool:
return isinstance(value, Decimal) and value.is_finite()
class DeterministicPayrollCalculator:
"""Pure calculator with explicit Decimal and rounding semantics."""
CALCULATOR_VERSION = "1.1.0"
DECIMAL_PRECISION = 80
MAX_DECIMAL_DIGITS = 30
MAX_FRACTIONAL_PLACES = 12
MAX_INPUT_LINES = 10_000
def calculate(
self,
request: PayrollRequest,
rules: PayrollRules,
) -> PayrollResult:
issues = self._validate(request, rules)
if issues:
raise PayrollValidationError(issues)
decimal_context = Context(
prec=self.DECIMAL_PRECISION,
rounding=rules.rounding.value,
)
with localcontext(decimal_context):
quantize = self._quantizer(rules)
lines: list[PayrollLine] = [
PayrollLine(
line_id="system:base",
code="BASE",
label="Base accrual",
kind=LineKind.EARNING,
amount=quantize(request.base_accrual),
source_ref=request.base_source_ref,
calculated=True,
)
]
warnings: list[CalculationWarning] = []
point_delta: Decimal | None = None
if rules.point_policy is PointPolicy.PROPORTIONAL_WITH_EXCESS:
point_delta = request.actual_points - request.target_points
point_base_accrual = (
request.point_base_accrual
if request.point_base_accrual is not None
else request.base_accrual
)
raw_point_amount = (
point_delta * request.point_rate
if point_delta > 0 and request.point_rate_overridden
else point_base_accrual
* (request.actual_points / request.target_points)
- point_base_accrual
)
point_kind = (
LineKind.ADJUSTMENT_DEBIT
if raw_point_amount < 0
else LineKind.ADJUSTMENT_CREDIT
)
lines.append(
PayrollLine(
line_id="system:point-adjustment",
code="POINT_ADJUSTMENT",
label="Point-based recalculation",
kind=point_kind,
amount=quantize(abs(raw_point_amount)),
source_ref="calculation:point-policy",
calculated=True,
)
)
elif rules.point_policy is PointPolicy.EXCESS_ONLY:
point_delta = request.actual_points - request.target_points
eligible_points = max(point_delta, Decimal("0"))
point_amount = quantize(eligible_points * request.point_rate)
lines.append(
PayrollLine(
line_id="system:point-excess",
code="POINT_EXCESS",
label="Points above target",
kind=LineKind.EARNING,
amount=point_amount,
source_ref="calculation:point-policy",
calculated=True,
)
)
if point_delta < 0:
warnings.append(
CalculationWarning(
code="POINTS_BELOW_TARGET_NO_DEDUCTION",
field="actual_points",
message=(
"Actual points are below target; the active "
"policy does not reduce pay."
),
context=(("point_delta", decimal_text(point_delta)),),
)
)
for input_line in sorted(
request.lines,
key=lambda item: item.line_id,
):
lines.append(self._to_result_line(input_line, quantize))
totals = self._calculate_totals(lines)
if totals.payable < 0 and not rules.allow_negative_payable:
raise PayrollValidationError(
[
PayrollIssue(
code="NEGATIVE_PAYABLE_NOT_ALLOWED",
field="lines",
message=(
"Deductions and recorded payments exceed available pay."
),
context={"payable": decimal_text(totals.payable)},
)
]
)
warnings.extend(
self._reconcile_expected(
request.expected_totals,
lines,
totals,
quantize,
)
)
input_hash = _canonical_hash(request.calculation_dict())
rules_payload = rules.to_dict()
ruleset_hash = _canonical_hash(rules_payload)
result_payload = {
"employee_ref": request.employee_ref,
"period": request.period.to_dict(),
"currency": request.currency,
"lines": [line.to_dict() for line in lines],
"point_delta": (
decimal_text(point_delta) if point_delta is not None else None
),
"totals": totals.to_dict(),
"ruleset_id": rules.ruleset_id,
"ruleset_version": rules.version,
"ruleset_hash": ruleset_hash,
"calculator_version": self.CALCULATOR_VERSION,
"rounding_policy": rules.rounding.value,
"input_hash": input_hash,
}
result_hash = _canonical_hash(result_payload)
reconciliation_hash = _canonical_hash(
{
"result_hash": result_hash,
"warnings": [warning.to_dict() for warning in warnings],
}
)
return PayrollResult(
employee_ref=request.employee_ref,
period=request.period,
currency=request.currency,
lines=tuple(lines),
point_delta=point_delta,
totals=totals,
ruleset_id=rules.ruleset_id,
ruleset_version=rules.version,
ruleset_hash=ruleset_hash,
calculator_version=self.CALCULATOR_VERSION,
rounding_policy=rules.rounding.value,
input_hash=input_hash,
result_hash=result_hash,
reconciliation_hash=reconciliation_hash,
warnings=tuple(warnings),
)
@staticmethod
def _quantizer(rules: PayrollRules):
def quantize(value: Decimal) -> Decimal:
try:
units = (value / rules.money_quantum).quantize(
Decimal("1"),
rounding=rules.rounding.value,
)
return units * rules.money_quantum
except DecimalException as exc:
raise PayrollValidationError(
[
PayrollIssue(
code="ARITHMETIC_ERROR",
message="A monetary value cannot be rounded safely.",
)
]
) from exc
return quantize
@staticmethod
def _to_result_line(input_line: InputLine, quantize) -> PayrollLine:
return PayrollLine(
line_id=input_line.line_id,
code=input_line.code,
label=input_line.label,
kind=input_line.kind,
amount=quantize(input_line.amount),
source_ref=input_line.source_ref,
reason=input_line.reason,
source_period=input_line.source_period,
is_retro=input_line.is_retro,
calculated=False,
)
@staticmethod
def _calculate_totals(lines: list[PayrollLine]) -> PayrollTotals:
earnings = sum(
(line.amount for line in lines if line.kind is LineKind.EARNING),
Decimal("0"),
)
adjustment_credits = sum(
(line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_CREDIT),
Decimal("0"),
)
adjustment_debits = sum(
(line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_DEBIT),
Decimal("0"),
)
deductions = sum(
(line.amount for line in lines if line.kind is LineKind.DEDUCTION),
Decimal("0"),
)
payments = sum(
(line.amount for line in lines if line.kind is LineKind.PAYMENT),
Decimal("0"),
)
adjustment_total = adjustment_credits - adjustment_debits
gross = earnings + adjustment_total
net_pay = gross - deductions
return PayrollTotals(
gross_before_adjustments=earnings,
adjustment_total=adjustment_total,
gross_after_adjustments=gross,
deduction_total=deductions,
net_pay=net_pay,
payment_total=payments,
payable=net_pay - payments,
)
@staticmethod
def _reconcile_expected(
expected: ExpectedTotals | None,
lines: list[PayrollLine],
totals: PayrollTotals,
quantize,
) -> list[CalculationWarning]:
if expected is None:
return []
point_line = next(
(
line
for line in lines
if line.line_id
in {"system:point-excess", "system:point-adjustment"}
),
None,
)
point_amount = (
-point_line.amount
if point_line is not None
and point_line.kind is LineKind.ADJUSTMENT_DEBIT
else point_line.amount
if point_line is not None
else Decimal("0")
)
gross_before_point_recalculation = (
totals.gross_after_adjustments - point_amount
)
checks = (
("point_amount", expected.point_amount, point_amount),
("gross", expected.gross, gross_before_point_recalculation),
(
"recalculated_gross",
expected.recalculated_gross,
totals.recalculated_gross,
),
("payable", expected.payable, totals.payable),
)
warnings = []
for field_name, expected_value, actual_value in checks:
if expected_value is None:
continue
normalized_expected = quantize(expected_value)
if normalized_expected != actual_value:
warnings.append(
CalculationWarning(
code="LEGACY_EXPECTED_TOTAL_MISMATCH",
field=f"expected_totals.{field_name}",
message=(
"The calculated amount differs from the legacy "
"control total."
),
context=(
("expected", decimal_text(normalized_expected)),
("actual", decimal_text(actual_value)),
),
)
)
return warnings
def _validate(
self,
request: PayrollRequest,
rules: PayrollRules,
) -> list[PayrollIssue]:
issues: list[PayrollIssue] = []
if not isinstance(request, PayrollRequest):
return [
PayrollIssue(
code="INVALID_REQUEST",
field="request",
message="Request must be a PayrollRequest instance.",
)
]
if not isinstance(rules, PayrollRules):
return [
PayrollIssue(
code="INVALID_RULESET",
field="rules",
message="Rules must be a PayrollRules instance.",
)
]
if not self._is_nonempty_string(request.employee_ref):
issues.append(
PayrollIssue(
code="MISSING_EMPLOYEE_REF",
field="employee_ref",
message="Employee reference is required.",
)
)
period_is_valid = self._validate_period(
request.period,
"period",
issues,
)
if not isinstance(request.currency, str) or not _CURRENCY_RE.fullmatch(
request.currency
):
issues.append(
PayrollIssue(
code="INVALID_CURRENCY",
field="currency",
message="Currency must be a three-letter uppercase code.",
)
)
self._validate_decimal(
request.base_accrual,
"base_accrual",
issues,
minimum=Decimal("0"),
)
self._validate_decimal(
request.point_base_accrual,
"point_base_accrual",
issues,
minimum=Decimal("0"),
allow_none=True,
)
if not self._is_nonempty_string(request.base_source_ref):
issues.append(
PayrollIssue(
code="MISSING_SOURCE_REF",
field="base_source_ref",
message="Base accrual must identify its source.",
)
)
self._validate_decimal(
request.point_rate,
"point_rate",
issues,
minimum=Decimal("0"),
)
if not isinstance(request.point_rate_overridden, bool):
issues.append(
PayrollIssue(
code="INVALID_POINT_RATE_MODE",
field="point_rate_overridden",
message="Point rate override marker must be boolean.",
)
)
if not self._is_nonempty_string(rules.ruleset_id):
issues.append(
PayrollIssue(
code="INVALID_RULESET",
field="rules.ruleset_id",
message="Ruleset identifier is required.",
)
)
if not self._is_nonempty_string(rules.version):
issues.append(
PayrollIssue(
code="INVALID_RULESET",
field="rules.version",
message="Ruleset version is required.",
)
)
rules_from_valid = isinstance(rules.effective_from, date) and not isinstance(
rules.effective_from,
datetime,
)
rules_to_valid = rules.effective_to is None or (
isinstance(rules.effective_to, date)
and not isinstance(rules.effective_to, datetime)
)
if not rules_from_valid:
issues.append(
PayrollIssue(
code="INVALID_RULESET_PERIOD",
field="rules.effective_from",
message="Ruleset effective_from must be a date.",
)
)
if not rules_to_valid:
issues.append(
PayrollIssue(
code="INVALID_RULESET_PERIOD",
field="rules.effective_to",
message="Ruleset effective_to must be a date or None.",
)
)
if (
rules_from_valid
and rules_to_valid
and rules.effective_to is not None
and rules.effective_from > rules.effective_to
):
issues.append(
PayrollIssue(
code="INVALID_RULESET_PERIOD",
field="rules.effective_to",
message="Ruleset effective dates are invalid.",
)
)
rules_outside_period = False
if period_is_valid and rules_from_valid and rules_to_valid:
rules_outside_period = (
request.period.start < rules.effective_from
or rules.effective_to is not None
and request.period.end > rules.effective_to
)
if rules_outside_period:
issues.append(
PayrollIssue(
code="RULESET_NOT_EFFECTIVE",
field="period",
message="Ruleset is not effective for the full period.",
)
)
if not isinstance(rules.point_policy, PointPolicy):
issues.append(
PayrollIssue(
code="UNSUPPORTED_POINT_POLICY",
field="rules.point_policy",
message="Point policy is not supported.",
)
)
if not isinstance(rules.rounding, RoundingPolicy):
issues.append(
PayrollIssue(
code="UNSUPPORTED_ROUNDING_POLICY",
field="rules.rounding",
message="Rounding policy is not supported.",
)
)
if not isinstance(rules.allow_negative_payable, bool):
issues.append(
PayrollIssue(
code="INVALID_RULE_OPTION",
field="rules.allow_negative_payable",
message="allow_negative_payable must be a boolean.",
)
)
self._validate_decimal(
rules.money_quantum,
"rules.money_quantum",
issues,
minimum=Decimal("0"),
exclusive_minimum=True,
)
if rules.point_policy in {
PointPolicy.EXCESS_ONLY,
PointPolicy.PROPORTIONAL_WITH_EXCESS,
}:
self._validate_decimal(
request.target_points,
"target_points",
issues,
minimum=Decimal("0"),
exclusive_minimum=True,
)
self._validate_decimal(
request.actual_points,
"actual_points",
issues,
minimum=Decimal("0"),
)
elif rules.point_policy is PointPolicy.DISABLED:
has_target = request.target_points is not None
has_actual = request.actual_points is not None
if has_target != has_actual:
issues.append(
PayrollIssue(
code="INCOMPLETE_POINT_METRICS",
field="target_points",
message="Target and actual points must be supplied together.",
)
)
if has_target:
self._validate_decimal(
request.target_points,
"target_points",
issues,
minimum=Decimal("0"),
exclusive_minimum=True,
)
if has_actual:
self._validate_decimal(
request.actual_points,
"actual_points",
issues,
minimum=Decimal("0"),
)
seen_line_ids: set[str] = set()
if not isinstance(request.lines, tuple):
issues.append(
PayrollIssue(
code="INVALID_LINES_CONTAINER",
field="lines",
message="Input lines must be an immutable tuple.",
)
)
lines = ()
else:
lines = request.lines
if len(lines) > self.MAX_INPUT_LINES:
issues.append(
PayrollIssue(
code="TOO_MANY_INPUT_LINES",
field="lines",
message=f"At most {self.MAX_INPUT_LINES} input lines are allowed.",
)
)
for index, line in enumerate(lines):
prefix = f"lines[{index}]"
if not isinstance(line, InputLine):
issues.append(
PayrollIssue(
code="INVALID_COMPONENT",
field=prefix,
message="Every input line must be an InputLine instance.",
)
)
continue
line_id_is_valid = self._is_nonempty_string(line.line_id)
if not line_id_is_valid:
issues.append(
PayrollIssue(
code="MISSING_COMPONENT_ID",
field=f"{prefix}.line_id",
message="Input line identifier is required.",
)
)
else:
if line.line_id.startswith(_SYSTEM_LINE_PREFIX):
issues.append(
PayrollIssue(
code="RESERVED_COMPONENT_ID",
field=f"{prefix}.line_id",
message="Input line uses a reserved system prefix.",
)
)
if line.line_id in seen_line_ids:
issues.append(
PayrollIssue(
code="DUPLICATE_COMPONENT_ID",
field=f"{prefix}.line_id",
message="Input line identifiers must be unique.",
)
)
seen_line_ids.add(line.line_id)
if not isinstance(line.code, str) or not _CODE_RE.fullmatch(line.code):
issues.append(
PayrollIssue(
code="INVALID_COMPONENT_CODE",
field=f"{prefix}.code",
message="Component code must use canonical uppercase form.",
)
)
if not self._is_nonempty_string(line.label):
issues.append(
PayrollIssue(
code="MISSING_COMPONENT_LABEL",
field=f"{prefix}.label",
message="Input line label is required.",
)
)
kind_is_valid = isinstance(line.kind, LineKind)
if not kind_is_valid:
issues.append(
PayrollIssue(
code="INVALID_COMPONENT_KIND",
field=f"{prefix}.kind",
message="Input line kind is not supported.",
)
)
self._validate_decimal(
line.amount,
f"{prefix}.amount",
issues,
minimum=Decimal("0"),
)
if not self._is_nonempty_string(line.source_ref):
issues.append(
PayrollIssue(
code="MISSING_SOURCE_REF",
field=f"{prefix}.source_ref",
message="Every input line must identify its source.",
)
)
if (
kind_is_valid
and line.kind
in {
LineKind.ADJUSTMENT_CREDIT,
LineKind.ADJUSTMENT_DEBIT,
}
and not self._is_nonempty_string(line.reason)
):
issues.append(
PayrollIssue(
code="MISSING_ADJUSTMENT_REASON",
field=f"{prefix}.reason",
message="Adjustment reason is required.",
)
)
is_retro_valid = isinstance(line.is_retro, bool)
if not is_retro_valid:
issues.append(
PayrollIssue(
code="INVALID_RETRO_FLAG",
field=f"{prefix}.is_retro",
message="is_retro must be a boolean.",
)
)
if line.is_retro is True and line.source_period is None:
issues.append(
PayrollIssue(
code="MISSING_RETRO_SOURCE_PERIOD",
field=f"{prefix}.source_period",
message="Retro input must reference the affected period.",
)
)
if line.source_period is not None:
source_period_valid = self._validate_period(
line.source_period,
f"{prefix}.source_period",
issues,
)
if is_retro_valid and line.is_retro is not True:
issues.append(
PayrollIssue(
code="UNEXPECTED_SOURCE_PERIOD",
field=f"{prefix}.source_period",
message="A source period requires is_retro=True.",
)
)
elif (
is_retro_valid
and line.is_retro is True
and source_period_valid
and period_is_valid
and line.source_period.end >= request.period.start
):
issues.append(
PayrollIssue(
code="INVALID_RETRO_SOURCE_PERIOD",
field=f"{prefix}.source_period",
message="Retro source period must end before this period.",
)
)
if request.expected_totals is not None and not isinstance(
request.expected_totals,
ExpectedTotals,
):
issues.append(
PayrollIssue(
code="INVALID_EXPECTED_TOTALS",
field="expected_totals",
message="Expected totals must be an ExpectedTotals instance.",
)
)
elif request.expected_totals is not None:
for field_name in (
"point_amount",
"gross",
"recalculated_gross",
"payable",
):
self._validate_decimal(
getattr(request.expected_totals, field_name),
f"expected_totals.{field_name}",
issues,
minimum=(
None
if field_name == "point_amount"
or field_name == "payable" and rules.allow_negative_payable
else Decimal("0")
),
allow_none=True,
)
return issues
@staticmethod
def _is_nonempty_string(value: object) -> bool:
return isinstance(value, str) and bool(value.strip())
@staticmethod
def _validate_period(
value: object,
field_name: str,
issues: list[PayrollIssue],
) -> bool:
if not isinstance(value, PayrollPeriod):
issues.append(
PayrollIssue(
code="INVALID_PERIOD",
field=field_name,
message="Period must be a PayrollPeriod instance.",
)
)
return False
if (
not isinstance(value.start, date)
or isinstance(value.start, datetime)
or not isinstance(value.end, date)
or isinstance(value.end, datetime)
):
issues.append(
PayrollIssue(
code="INVALID_PERIOD",
field=field_name,
message="Period boundaries must be dates.",
)
)
return False
if value.start > value.end:
issues.append(
PayrollIssue(
code="INVALID_PERIOD",
field=field_name,
message="Period start must not be after period end.",
)
)
return False
return True
@staticmethod
def _validate_decimal(
value: object,
field_name: str,
issues: list[PayrollIssue],
*,
minimum: Decimal | None,
exclusive_minimum: bool = False,
allow_none: bool = False,
) -> None:
if value is None and allow_none:
return
if not isinstance(value, Decimal):
issues.append(
PayrollIssue(
code="INVALID_DECIMAL_TYPE",
field=field_name,
message="Monetary and quantity values must use Decimal.",
)
)
return
if not _is_finite_decimal(value):
issues.append(
PayrollIssue(
code="NON_FINITE_DECIMAL",
field=field_name,
message="NaN and infinite values are not supported.",
)
)
return
decimal_tuple = value.as_tuple()
fractional_places = max(-decimal_tuple.exponent, 0)
integer_digits = max(value.adjusted() + 1, 0) if value else 0
if (
len(decimal_tuple.digits)
> DeterministicPayrollCalculator.MAX_DECIMAL_DIGITS
or integer_digits > DeterministicPayrollCalculator.MAX_DECIMAL_DIGITS
or fractional_places > DeterministicPayrollCalculator.MAX_FRACTIONAL_PLACES
):
issues.append(
PayrollIssue(
code="DECIMAL_OUT_OF_RANGE",
field=field_name,
message="Decimal precision or scale exceeds safe limits.",
)
)
return
below_minimum = minimum is not None and value < minimum
equals_exclusive_minimum = (
minimum is not None and exclusive_minimum and value == minimum
)
if below_minimum or equals_exclusive_minimum:
comparator = "greater than" if exclusive_minimum else "at least"
issues.append(
PayrollIssue(
code="NEGATIVE_OR_INVALID_AMOUNT",
field=field_name,
message=(f"Value must be {comparator} {decimal_text(minimum)}."),
)
)