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