dodil/erp-brick-hr
src/brick_hr/domain/packs/gb.py
"""The gb localisation pack — Great Britain and Northern Ireland: the default catalogues, intake fields, document
types, compliance records, warnings, the published bank-holiday rows, and starter playbooks.
Every legal rule names its source; anything that cannot be cited stays a question for the customer:
* right to work — Immigration, Asylum and Nationality Act 2006 s.15 and the Home Office's "An employer's guide
to right to work checks" (the check before employment starts; copies kept for the employment + 2 years);
* written statement of particulars — Employment Rights Act 1996 s.1 (from 6 April 2020, on or before day one);
* off-payroll working (IR35) — ITEPA 2003 Part 2 Chapter 10: a medium or large client gives a status
determination statement with reasons; a small client does not (Companies Act 2006 thresholds);
* statutory minimum notice — Employment Rights Act 1996 s.86 (domain/services.notice_warning);
* retention — UK GDPR Art. 5(1)(e), 12(3), 15, 17. The periods below are DEFAULTS to start the conversation
from, not advice: confirm each with the customer's adviser.
Bank holidays are loaded from the published list (https://www.gov.uk/bank-holidays.json, fetched 2026-10-17 and
shipped as gb_bank_holidays.json), never typed from memory.
"""
import importlib.resources
import json
PACK = "gb"
COMPLIANCE_KINDS = ("right_to_work_check", "status_determination", "written_statement_given")
RTW_CHECK_TYPES = ("manual_document_check", "identity_service_provider", "home_office_online_check")
CALENDARS = {"england-and-wales": "gb-eaw", "scotland": "gb-sct", "northern-ireland": "gb-nir"}
# What the deviations from these playbooks are weighed against (domain/deviations.py). A GATE is kept foundation:
# dropping it from a step is refused. A SAFEGUARD is best practice: removing it is allowed, and flagged.
GATES = {
"right_to_work_check": "the right-to-work check before employment starts is the employer's statutory excuse "
"(Immigration, Asylum and Nationality Act 2006 s.15)",
"status_determination": "a medium or large client gives a status determination statement before the work "
"starts (ITEPA 2003 Part 2 Chapter 10)",
}
SAFEGUARDS = {
"right_to_work_check": "the right-to-work check is recorded before the start date (IANA 2006 s.15)",
"send_contract": "the written statement of particulars is given on or before day one (Employment Rights Act "
"1996 s.1)",
"suspend_account": "access ends on the last day",
"laptop_returned": "the company's equipment comes back",
}
EMPLOYEES = ["employee_ft", "employee_pt", "employee_fixed"]
FREELANCERS = ["freelancer_sole", "freelancer_company"]
def holidays() -> list:
raw = json.loads(importlib.resources.files("brick_hr.domain.packs").joinpath("gb_bank_holidays.json").read_text())
rows = []
for division, cal in CALENDARS.items():
for e in raw[division]["events"]:
rows.append({"holiday_calendar": cal, "holiday_date": e["date"], "name": e["title"]})
return rows
def catalogues() -> dict:
"""The default rows, by catalogue. The customer's own values (a customer's teams, jobs, reasons…) are rows set
on top of these, never code."""
wt = lambda code, label, cat, payroll, roles, via=False: { # noqa: E731
"worker_type": code, "label": label, "category": cat, "payroll": payroll, "via_company": via,
"bp_roles": roles, "active": True}
return {
"worker_type": [
wt("employee_ft", "Employee (full-time)", "employee", True, ["employee"]),
wt("employee_pt", "Employee (part-time)", "employee", True, ["employee"]),
wt("employee_fixed", "Employee (fixed-term)", "employee", True, ["employee"]),
wt("freelancer_sole", "Freelancer (sole trader)", "freelancer", False, ["worker", "supplier"]),
wt("freelancer_company", "Freelancer (via a limited company)", "freelancer", False, ["worker"], True),
wt("intern", "Intern", "intern", True, ["employee"]),
],
"event_type": [{"event_type": c, "label": l, "kind": k, "active": True} for c, l, k in (
("hire", "Hire", "Hired"), ("transfer", "Transfer", "Transferred"), ("pay_change", "Pay change", "PayChanged"),
("extension", "Contract extension", "ContractExtended"), ("leave", "Leave", "Left"),
("rehire", "Rehire", "Rehired"), ("conversion", "Conversion", "Converted"))],
"event_reason": [{"event_type": et, "reason": r, "label": l, "immediate": imm, "active": True}
for et, r, l, imm in (
("leave", "resignation", "Resignation", False), ("leave", "end_of_fixed_term", "End of fixed term", False),
("leave", "end_of_project", "End of project", False), ("leave", "redundancy", "Redundancy", False),
("leave", "dismissal", "Dismissal", True), ("leave", "retirement", "Retirement", False),
("leave", "mutual_agreement", "Mutual agreement", False), ("leave", "converted", "Converted", False),
("pay_change", "annual_review", "Annual review", False), ("pay_change", "promotion", "Promotion", False),
("pay_change", "market_adjustment", "Market adjustment", False),
("transfer", "reorganisation", "Reorganisation", False), ("transfer", "request", "Employee request", False),
("transfer", "promotion", "Promotion", False), ("hire", "new_position", "New position", False),
("hire", "replacement", "Replacement", False), ("extension", "project_extended", "Project extended", False),
("conversion", "converted", "Converted", False), ("rehire", "returning", "Returning", False))],
"infogroup": [{"event_type": et, "record": r, "required": req} for et, r, req in (
("hire", "detail", True), ("hire", "engagement", True), ("hire", "assignment", True), ("hire", "pay", False),
("rehire", "engagement", True), ("rehire", "assignment", True), ("rehire", "pay", False),
("conversion", "engagement", True), ("conversion", "assignment", True), ("conversion", "pay", True),
("transfer", "assignment", True), ("pay_change", "pay", True), ("extension", "engagement", True),
("leave", "engagement", True))],
"rate_basis": [{"rate_basis": b, "label": l, "active": True} for b, l in (
("hour", "per hour"), ("day", "day rate"), ("month", "per month"), ("year", "salary per year"),
("fixed", "fixed fee"))],
"pay_component_type": [
{"component": "car_allowance", "label": "Car allowance", "worker_types": EMPLOYEES, "rate_basis": "month",
"active": True},
{"component": "london_weighting", "label": "London weighting", "worker_types": EMPLOYEES + ["intern"],
"rate_basis": "year", "active": True}],
"responsible_group": [{"group_code": g, "label": l, "resolution": r, "active": True} for g, l, r in (
("manager", "The derived manager", "manager"), ("hr", "Operations / HR", "pool_role:hr_admin"),
("it", "IT / ops", "pool_role:it"), ("finance", "Finance", "pool_role:finance"),
("buddy", "The buddy", "run_party"), ("takeover", "Whoever takes the work over", "run_party"),
("director", "A director (signer)", "run_party"), ("starter", "The person themselves", "person"))],
"document_type": [{"document_type": d, "label": l, "access_class": c, "expires": x, "retention_category": rc,
"sensitive_kind": sk, "active": True} for d, l, c, x, rc, sk in (
("employment_contract", "Employment contract (with the written statement)", "pay", False, "documents_pay", None),
("freelance_agreement", "Freelance services agreement", "pay", False, "documents_pay", None),
("leaver_letter", "Leaver letter", "personnel", False, "documents_personnel", None),
("nda", "Non-disclosure agreement", "personnel", False, "documents_personnel", None),
("right_to_work", "Right-to-work evidence", "sensitive", True, "right_to_work", "right_to_work_evidence"),
("other", "Other personnel document", "personnel", False, "documents_personnel", None))],
"intake_field": [{"pack": PACK, "field": f, "label": l, "worker_types": w, "required": True,
"sensitive_kind": k, "input": i, "sort_no": n, "active": True} for n, (f, l, w, k, i) in
enumerate((
("home_address", "Home address", [], "home_address", "text"),
("ni_number", "National Insurance number", EMPLOYEES + ["intern", "freelancer_sole"], "ni_number", "text"),
("bank_account_name", "Bank account name", EMPLOYEES + ["intern", "freelancer_sole"], "bank", "text"),
("bank_sort_code", "Sort code", EMPLOYEES + ["intern", "freelancer_sole"], "bank", "text"),
("bank_account_number", "Account number", EMPLOYEES + ["intern", "freelancer_sole"], "bank", "text"),
("company_number", "Company number", ["freelancer_company"], "company_number", "text"),
("vat_number", "VAT number", ["freelancer_company"], "vat_number", "text"),
("emergency_contact", "Emergency contact (name and phone)", [], "emergency_contact", "text"),
("right_to_work", "Right-to-work document", EMPLOYEES + ["intern"], "right_to_work_evidence", "file")), 1)],
"draft_instruction": [
{"draft_code": "welcome_email", "label": "Welcome email",
"instructions": "Write a short, warm welcome email to a new starter from their manager. Mention their "
"job, team, start date, their buddy and their first project. No more than 150 words.",
"fields": ["preferred_name", "job_title", "org_unit_name", "start_date", "buddy_name", "manager_name",
"first_project", "location_name"]},
{"draft_code": "handover", "label": "Handover list",
"instructions": "List, one item per line, what this leaver looks after and who should take it over: "
"open steps, people they manage, units they lead, the groups and spaces of their team.",
"fields": ["preferred_name", "job_title", "org_unit_name", "last_day", "open_steps", "line",
"units_led", "unit_access"]},
{"draft_code": "leaver_letter", "label": "Leaver letter",
"instructions": "Write a short, kind letter confirming the last day and thanking the person.",
"fields": ["preferred_name", "last_day", "job_title", "reason_label"]}],
"retention_rule": [{"category": c, "label": l, "keep_months": m, "action": a, "basis": b}
for c, l, m, a, b in (
("payroll", "Pay and payroll records", 72, "anonymise", "legal_obligation"),
("sensitive_bank", "Bank details", 12, "purge", "contract"),
("sensitive_identity", "NI number, tax and identity data", 72, "purge", "legal_obligation"),
("sensitive_contact", "Home address, emergency contact", 6, "purge", "legitimate_interest"),
("right_to_work", "Right-to-work copies (employment + 2 years)", 24, "purge", "legal_obligation"),
("documents_pay", "Contracts", 72, "purge", "legal_obligation"),
("documents_personnel", "Other personnel documents", 72, "purge", "legitimate_interest"),
("person_detail", "Names and contact", 72, "anonymise", "legitimate_interest"),
("runs", "Onboarding and offboarding runs", 24, "anonymise", "legitimate_interest"))],
"settings": [{"key": "timezone", "value": "Europe/London"}, {"key": "pack", "value": PACK}],
"holiday": holidays(),
}
def _step(no, key, kind, title, group, anchor="run.created", offset=0, unit="days", time=None, **kw) -> dict:
return {"step_no": no, "key": key, "kind": kind, "title": title, "responsible_group": group,
"timing": {"anchor": anchor, "offset": offset, "unit": unit, "time": time}, **kw}
ACCOUNT = {"given_name": "{given_name}", "family_name": "{family_name}", "primary_email": "{account_email}",
"recovery_email": "{personal_email}", "org_unit": "{ou_path}", "initial_password_delivery": "recovery_email"}
ADD_GROUP = {"group": "{target}", "member_email": "{account_email}", "role": "{target_role}"}
ADD_SPACE = {"space": "{target}", "member_email": "{account_email}"}
REMOVE_GROUP = {"group": "{target}", "member_email": "{account_email}"}
WELCOME_MAIL = {"from_email": "{manager_email}", "to": "{personal_email_list}", "cc": [],
"subject": "Welcome, {preferred_name}", "body_text": "{draft.welcome_email}", "body_html": None}
DAY_ONE = {"organizer": "{manager_email}", "title": "Welcome, {preferred_name}", "start": "{start_date}T09:30:00",
"end": "{start_date}T10:30:00", "timezone": "{timezone}", "attendees": "{first_week_attendees}",
"description": "Day-one welcome", "location": "{location_name}"}
BUDDY_COFFEE = {**DAY_ONE, "title": "Buddy coffee", "start": "{start_plus_1}T10:00:00",
"end": "{start_plus_1}T10:30:00", "description": "Coffee with your buddy"}
def onboarding(playbook_id: str, name: str, worker_types: list, contract: str, event_types=("hire",)) -> dict:
freelancer = contract == "freelance_agreement"
steps = [
_step(1, "starter_details", "intake", "Starter fills in their details", "hr"),
_step(2, "approve_rate", "approval", "Approve the off-template rate", "hr",
condition={"if": [{"spec": "rate_outside_band"}]}, approval_group="hr"),
_step(3, "send_contract", "document", "Contract sent and signed", "hr", template_kind=contract,
capability="contracts.draft", depends_on=[2],
condition={"requires": [{"spec": "pack_requires", "record": "status_determination",
"if": [{"spec": "via_company"},
{"spec": "client_size_is", "args": ["medium", "large"]}]}]}
if freelancer else None),
_step(4, *(("supplier_setup", "task", "Supplier set-up for the freelancer") if freelancer else
("right_to_work_check", "task", "Right-to-work check recorded")),
"hr" if not freelancer else "finance", "start_date", -2, "working_days", "12:00"),
_step(5, "create_account", "action", "Create the work account", "it", "start_date", -3, "working_days", "09:00",
capability="identity.provision_user", input_template=ACCOUNT, depends_on=[3],
condition={"requires": [{"spec": "pack_requires", "record": "right_to_work_check",
"if": [{"spec": "worker_type_is", "args": EMPLOYEES + ["intern"]}]}]}),
_step(6, "add_to_groups", "action", "Add to the team's groups", "it", "start_date", -3, "working_days", "09:00",
capability="groups.add_member", input_template=ADD_GROUP, depends_on=[5], fan_out="access:group"),
_step(7, "add_to_chat_spaces", "action", "Add to the team's chat spaces", "it", "start_date", -3, "working_days", "09:00",
capability="chat.add_to_space", input_template=ADD_SPACE, depends_on=[5], fan_out="access:chat_space"),
_step(8, "welcome_email", "draft", "Welcome email drafted and approved", "manager", "start_date", -5, "working_days", "09:00",
draft_instruction="welcome_email"),
_step(9, "send_welcome_email", "action", "Send the welcome email", "manager", "start_date", -1, "working_days", "10:00",
capability="mail.send", input_template=WELCOME_MAIL, depends_on=[8]),
_step(10, "day_one_welcome", "action", "Day-one welcome in the calendar", "manager", "start_date", -2, "working_days", "09:00",
capability="calendar.create_event", input_template=DAY_ONE, depends_on=[5]),
_step(11, "laptop_ready", "task", "Laptop ready", "it", "start_date", -1, "working_days", "16:00"),
_step(12, "buddy_coffee", "task", "Buddy coffee", "buddy", "start_date", 1, "working_days", "10:00"),
_step(13, "first_project_brief", "task", "First project brief", "manager", "start_date", 0, "days", "09:30"),
_step(14, "week_one_review", "task", "Week-one review", "manager", "start_date", 4, "working_days", "15:00"),
_step(15, *(("raise_purchase_order", "task", "Purchase order raised") if freelancer else
("payroll_setup", "task", "Payroll set-up")), "finance",
"start_date", -5, "working_days", "09:00"),
]
return {"playbook_id": playbook_id, "name": name, "event_types": list(event_types), "worker_types": worker_types,
"reasons": [], "org_unit_id": None, "steps": steps}
def offboarding(playbook_id: str, name: str, worker_types: list, freelancer: bool) -> dict:
steps = [
_step(1, "handover_list", "draft", "Handover list drafted and approved", "manager", draft_instruction="handover"),
_step(2, "approve_access_removal", "approval", "Approve access removal on the last day", "hr", approval_group="hr"),
_step(3, "suspend_account", "action", "Suspend the account", "it", "last_day", 0, "days", "17:30",
capability="identity.suspend_user",
input_template={"primary_email": "{account_email}", "reason": "left on {last_day}"}, depends_on=[2]),
_step(4, "transfer_drive", "action", "Move their Drive to the manager", "it", "last_day", 0, "days", "17:30",
capability="drive.transfer_ownership",
input_template={"from_email": "{account_email}", "to_email": "{manager_email}"}, depends_on=[2]),
_step(5, "out_of_office", "action", "Turn on the out-of-office reply", "it", "last_day", 0, "days", "17:30",
capability="mail.set_autoreply",
input_template={"mailbox": "{account_email}", "subject": "{preferred_name} has left",
"body_text": "{preferred_name} has left. Please contact {manager_email}.",
"start": "{last_day}T17:30:00", "end": None}, depends_on=[2]),
_step(6, "remove_from_groups", "action", "Remove from the team's groups", "it", "last_day", 0, "days", "17:30",
capability="groups.remove_member", input_template=REMOVE_GROUP, depends_on=[2], fan_out="access:group"),
_step(7, "remove_from_chat_spaces", "action", "Remove from the team's chat spaces", "it", "last_day", 0, "days", "17:30",
capability="chat.remove_from_space", input_template={"space": "{target}", "member_email": "{account_email}"},
depends_on=[2], fan_out="access:chat_space"),
_step(8, "approve_account_deletion", "approval", "Approve deleting the account", "it", "last_day", 23, "days", "09:00", approval_group="it"),
_step(9, "delete_account", "action", "Delete the account and free the licence", "it", "last_day", 30, "days", "09:00",
capability="identity.delete_user", input_template={"primary_email": "{account_email}"},
depends_on=[8, 4]),
_step(10, *(("final_invoice", "task", "Final invoice") if freelancer else ("final_pay", "task", "Final pay")),
"finance", "last_day", 0, "days", "12:00"),
_step(11, "laptop_returned", "task", "Laptop returned", "it", "last_day", 0, "days", "16:00"),
_step(12, "exit_survey", "task", "Exit survey", "hr", "last_day", -3, "working_days", "10:00"),
# HR-54 (0.6.0): the ERP login goes with the rest of the access — the composition's pool user disabled and
# its sessions revoked (identity.revoke_app_user, erp-platform's contract since 0.9.7); a manual task without one.
_step(13, "revoke_erp_login", "action", "Revoke their ERP sign-in", "it", "last_day", 0, "days", "17:30",
capability="identity.revoke_app_user",
input_template={"pool": "{user_pool}", "email": "{work_email}", "sub": "{pool_user_sub}",
"reason": "left on {last_day}"},
depends_on=[2]), # after the access-removal approval
]
if not freelancer:
# APQC 7.6.2.4 (0.6.0): on a redundancy only, HR offers outplacement support (career transition: CV and
# job-search help) soon after the leave is recorded, and records whether it was accepted. A task by
# default; with an outplacement provider connected it can be an action with the same two outcomes.
steps.append(_step(14, "offer_outplacement", "task", "Offer outplacement support", "hr", "run.created", 2,
"working_days", "10:00", condition={"if": [{"spec": "reason_is", "args": ["redundancy"]}]},
outcomes=["accepted", "declined"]))
return {"playbook_id": playbook_id, "name": name, "event_types": ["leave"], "worker_types": worker_types,
"reasons": [], "org_unit_id": None, "steps": steps}
def retirement() -> dict:
"""APQC 7.6.2.1 Manage retirement (0.6.0): the employee offboarding for the leave reason `retirement` — rows only,
chosen over employee-offboarding because it names the reason (the most specific playbook wins). It keeps every
access step and approval of the offboarding and adds the notice to the pension scheme, the knowledge handover
and the long-service recognition."""
body = offboarding("employee-retirement", "Employee retirement", EMPLOYEES, False)
steps = [s for s in body["steps"] if s["key"] != "offer_outplacement"]
steps += [
_step(14, "pension_notice", "task", "Tell the pension scheme they are retiring", "finance", "last_day", -20,
"working_days", "10:00"),
_step(15, "knowledge_handover", "task", "Knowledge handover sessions held", "manager", "last_day", -5,
"working_days", "10:00", depends_on=[1]),
_step(16, "long_service_recognition", "task", "Long-service recognition", "manager", "last_day", -2,
"working_days", "15:00"),
]
return {**body, "reasons": ["retirement"], "steps": steps}
def conversion() -> dict:
return {"playbook_id": "freelancer-to-employee", "name": "Freelancer → employee", "event_types": ["conversion"],
"worker_types": EMPLOYEES, "reasons": [], "org_unit_id": None, "steps": [
_step(1, "send_contract", "document", "Employment contract sent and signed", "hr", template_kind="employment_contract",
capability="contracts.draft"),
_step(2, "payroll_setup", "task", "Payroll set-up", "finance", "event_date", -5, "working_days", "09:00"),
_step(3, "remove_supplier_record", "task", "Remove the supplier record", "finance", "event_date", 0, "days", "09:00",
condition={"if": [{"spec": "worker_type_is", "args": ["freelancer_sole"], "of": "previous"}]}),
_step(4, "confirm_access_unchanged", "task", "Confirm access is unchanged", "it", "event_date", 0, "days", "09:00")]}
def change() -> dict:
return {"playbook_id": "transfer", "name": "Transfer: the access delta", "event_types": ["transfer"],
"worker_types": [], "reasons": [], "org_unit_id": None, "steps": [
_step(1, "add_to_new_groups", "action", "Add to the new team's groups", "it", "event_date", 0, "days", "09:00",
capability="groups.add_member", input_template=ADD_GROUP, fan_out="access:group"),
_step(2, "add_to_new_chat_spaces", "action", "Add to the new team's chat spaces", "it", "event_date", 0, "days", "09:00",
capability="chat.add_to_space", input_template=ADD_SPACE, fan_out="access:chat_space"),
_step(3, "approve_old_access_removal", "approval", "Approve removing the old team's access", "hr", approval_group="hr"),
_step(4, "remove_from_old_groups", "action", "Remove from the old team's groups", "it", "event_date", 0, "days", "09:00",
capability="groups.remove_member", input_template=REMOVE_GROUP, depends_on=[3],
fan_out="access:group")]}
def starter_playbooks() -> list:
"""Starting checklists the customer edits (they are rows: keep_playbook makes a new version of each)."""
return [
onboarding("employee-onboarding", "Employee onboarding", EMPLOYEES + ["intern"], "employment_contract"),
onboarding("freelancer-onboarding", "Freelancer onboarding", FREELANCERS, "freelance_agreement"),
onboarding("employee-rehire", "Rehire (employee)", EMPLOYEES + ["intern"], "employment_contract", ("rehire",)),
onboarding("freelancer-rehire", "Rehire (freelancer)", FREELANCERS, "freelance_agreement", ("rehire",)),
offboarding("employee-offboarding", "Employee offboarding", EMPLOYEES + ["intern"], False),
offboarding("freelancer-offboarding", "Freelancer offboarding", FREELANCERS, True),
retirement(),
conversion(),
change(),
]
__all__ = ["PACK", "GATES", "SAFEGUARDS", "catalogues", "holidays", "starter_playbooks", "COMPLIANCE_KINDS", "RTW_CHECK_TYPES", "EMPLOYEES",
"FREELANCERS"]