"""ContractType — an aggregate of one row (ct_type): what may be signed online, with whom, and kept how long.
INVARIANTS (enforced here, on every save):
* a DEED type has esign_allowed = false — it can never be sent for online signing (Law Com No 386: a witness
to a deed must be physically present); it is signed elsewhere and the executed copy uploaded;
* every type names a retention rule and an approval policy that exist, and the policy's rules each carry a
fallback group (policies.ApprovalRule refuses one without);
* the counterparty is ALWAYS a business partner: a type lists the bp roles it may be with (employee, worker,
supplier, customer). `allow_roleless_person` lets a PERSON partner with no role yet be the counterparty — hr
gives a starter their employee/worker role on the start date, and the contract is signed before it.
"""
from dataclasses import dataclass, field
from brick_contracts.domain.errors import Invalid, RuleBroken
from brick_contracts.domain.values import COMMERCIAL, DEED, EMPLOYMENT, SIMPLE
BP_ROLE_WORDS = ("employee", "worker", "supplier", "customer", "carrier", "partner", "contractor")
PACK_CHECKS = ("era_s1", "ir35", "ip_assignment")
@dataclass
class ContractType:
type_code: str
label: str
allowed_bp_roles: tuple
execution: str = SIMPLE
esign_allowed: bool = True
sensitivity: str = COMMERCIAL
retention_rule: str = "simple-6y"
approval_policy: str = "standard"
pack: str = ""
pack_checks: tuple = ()
allow_roleless_person: bool = False
status: str = "active"
notes: dict = field(default_factory=dict)
@classmethod
def define(cls, row: dict, config) -> "ContractType":
"""Build (and check) a type from what an admin keeps. `config` is the current Configuration."""
code = str(row.get("type_code") or "").strip().lower()
if not code or not code.replace("_", "").replace("-", "").isalnum() or len(code) > 64:
raise Invalid("type_code is a short code of letters, digits, - and _ (employment_contract)")
roles = tuple(sorted({str(r).strip().lower() for r in (row.get("allowed_bp_roles") or []) if str(r).strip()}))
if not roles:
raise Invalid(f"{code}: name the business-partner roles this type may be with (employee, worker, "
"supplier, customer)")
execution = str(row.get("execution") or SIMPLE).upper()
if execution not in (SIMPLE, DEED):
raise Invalid(f"{code}: execution is SIMPLE or DEED")
esign = bool(row.get("esign_allowed", True))
if execution == DEED:
esign = False # the invariant: a deed is never signed online here
sensitivity = str(row.get("sensitivity") or COMMERCIAL).lower()
if sensitivity not in (COMMERCIAL, EMPLOYMENT):
raise Invalid(f"{code}: sensitivity is commercial or employment")
retention = str(row.get("retention_rule") or "").strip()
policy = str(row.get("approval_policy") or "").strip()
if not retention or retention not in config.retention:
raise RuleBroken(f"{code}: every type names a retention rule that exists (have: "
f"{', '.join(sorted(config.retention)) or 'none — keep one first'})")
if not policy or policy not in config.policies:
raise RuleBroken(f"{code}: every type names an approval policy with a fallback group (have: "
f"{', '.join(sorted(config.policies)) or 'none — keep one first'})")
checks = tuple(sorted({str(c).strip().lower() for c in (row.get("pack_checks") or []) if str(c).strip()}))
unknown = set(checks) - set(PACK_CHECKS)
if unknown:
raise Invalid(f"{code}: unknown pack checks {sorted(unknown)} (known: {', '.join(PACK_CHECKS)})")
status = str(row.get("status") or "active").lower()
if status not in ("active", "retired"):
raise Invalid(f"{code}: status is active or retired")
return cls(code, str(row.get("label") or code), roles, execution, esign, sensitivity, retention, policy,
str(row.get("pack") or ""), checks, bool(row.get("allow_roleless_person", False)), status)
@property
def is_deed(self) -> bool:
return self.execution == DEED
@property
def employment_sensitive(self) -> bool:
return self.sensitivity == EMPLOYMENT
def check_counterparty(self, partner: dict) -> None:
"""The counterparty holds a role this type allows (or is a role-less person partner, where allowed)."""
roles = {str(r).lower() for r in partner.get("roles") or []}
if roles & set(self.allowed_bp_roles):
return
if not roles and partner.get("kind") == "person" and self.allow_roleless_person:
return
article = "an" if self.label[:1].lower() in "aeiou" else "a"
wanted = " or ".join(self.allowed_bp_roles)
has = f"it holds {', '.join(sorted(roles))}" if roles else "it holds no role"
raise RuleBroken(f"{article} {self.label} needs a partner with the {wanted} role — {has}")
def can_esign(self) -> bool:
return self.esign_allowed and not self.is_deed and self.status == "active"
def as_row(self) -> dict:
import json
return {"type_code": self.type_code, "label": self.label, "allowed_bp_roles": json.dumps(list(self.allowed_bp_roles)),
"execution": self.execution, "esign_allowed": self.esign_allowed, "sensitivity": self.sensitivity,
"retention_rule": self.retention_rule, "approval_policy": self.approval_policy, "pack": self.pack,
"pack_checks": json.dumps(list(self.pack_checks)), "allow_roleless_person": self.allow_roleless_person,
"status": self.status}
def as_json(self) -> dict:
return {"type_code": self.type_code, "label": self.label, "allowed_bp_roles": list(self.allowed_bp_roles),
"execution": self.execution, "esign_allowed": self.esign_allowed, "sensitivity": self.sensitivity,
"retention_rule": self.retention_rule, "approval_policy": self.approval_policy, "pack": self.pack,
"pack_checks": list(self.pack_checks), "allow_roleless_person": self.allow_roleless_person,
"status": self.status}
def type_from_row(r: dict) -> ContractType:
"""A stored row back into a type, without re-checking references (they were checked when it was kept)."""
import json
def lst(v):
if not v:
return ()
return tuple(json.loads(v)) if isinstance(v, str) else tuple(v)
return ContractType(r["type_code"], r.get("label") or r["type_code"], lst(r.get("allowed_bp_roles")),
r.get("execution") or SIMPLE, bool(r.get("esign_allowed")), r.get("sensitivity") or COMMERCIAL,
r.get("retention_rule") or "", r.get("approval_policy") or "", r.get("pack") or "",
lst(r.get("pack_checks")), bool(r.get("allow_roleless_person")), r.get("status") or "active")
__all__ = ["ContractType", "type_from_row", "BP_ROLE_WORDS", "PACK_CHECKS"]