dodil/erp-brick-payroll

DODIL ERP brick: payroll

src/brick_payroll/infrastructure/filing.py

"""The two capabilities this brick asks of the outside world, behind ONE port: hmrc.rti_submit (an FPS or an EPS) and
pension.submit_contributions (a contribution file). Nothing here talks to HMRC or a pension provider, and no credential is ever held.

The spec names them as payroll's own contract (module.yaml seams PRL-13, PRL-15) until erp-platform's capabilities.yaml has them; it does not yet, so
the contract below is the spec's input shape and checked before anything is handed over. A provider is whoever the composition registered
(`register`), or, if erp-platform later names the capability, whoever its CapabilitySeam resolves; with NO provider the answer is `manual` - a person
sends the file the brick already built, by its deadline (capabilities.yaml rule 8: no provider is a first-class path).

submit() answers a dict, never raises for the provider's trouble:
  {"status": "accepted", "correlation_id": ...}          HMRC / the scheme took it
  {"status": "rejected", "errors": [{code, field, message}]}   it was refused: the admin corrects it (a corrected FPS is n+1)
  {"status": "manual", "message": ...}                    no provider: a task for a person, with the file
  {"status": "failed", "message": ...}                    the provider did not answer: retried on the next pass (the key makes a retry safe)"""
from typing import Callable, Optional

CONTRACT = {
    "hmrc.rti_submit": {"kind": ("fps", "eps"), "required": ("kind", "paye_ref", "accounts_office_ref", "tax_year", "tax_period", "payment_date",
                                                              "file_object_key", "sha256"), "summary": "Send this {kind} to HMRC"},
    "pension.submit_contributions": {"required": ("scheme_code", "pay_run_id", "pay_date", "format", "file_object_key", "sha256", "totals"),
                                     "summary": "Send this contribution file to the pension scheme"},
}

_providers: dict = {}


class InvalidFilingInput(Exception):
    """Our bug: the input does not match the spec's contract - raised before anything is handed over."""


def register(capability: str, provider: Callable) -> None:
    """A composition (or a test) names who provides a capability: provider(key, input) -> the answer dict above."""
    _providers[capability] = provider


def unregister(capability: str) -> None:
    _providers.pop(capability, None)


def _check(capability: str, data: dict) -> None:
    spec = CONTRACT.get(capability)
    if spec is None:
        raise InvalidFilingInput(f"{capability} is not a capability this brick asks for")
    missing = [f for f in spec["required"] if data.get(f) in (None, "")]
    if missing:
        raise InvalidFilingInput(f"{capability} needs {', '.join(missing)}")
    if "kind" in spec and data["kind"] not in spec["kind"]:
        raise InvalidFilingInput(f"{capability} kind is one of {', '.join(spec['kind'])}")


def plan(capability: str, data: dict) -> str:
    """What a person does when there is no provider, in words: the same input a provider would have been sent."""
    spec = CONTRACT[capability]
    what = spec["summary"].format(kind=str(data.get("kind", "")).upper())
    due = data.get("due_on") or data.get("payment_date") or data.get("pay_date")
    return f"{what}: upload {data['file_object_key']} (sha256 {data['sha256'][:12]}...) by {due}."


def submit(capability: str, key: str, data: dict, due_on) -> Optional[dict]:
    """Hand a built file to its capability. `due_on` is the deadline a manual task carries."""
    _check(capability, data)
    provider = _providers.get(capability)
    if provider is None:
        return {"status": "manual", "message": plan(capability, {**data, "due_on": due_on.isoformat() if due_on else None})}
    try:
        got = provider(key, dict(data)) or {}
    except Exception as e:                       # a provider that blows up is a failed, retryable answer: the file is built and kept
        return {"status": "failed", "message": f"{capability} provider failed ({e.__class__.__name__}: {e})"}
    if got.get("status") not in ("accepted", "rejected", "manual", "failed"):
        return {"status": "failed", "message": f"{capability} answered {got.get('status')!r}"}
    return got