# Business rules — dthf-hr

Evidence date: 2026-10-16.

Source: `master` at `93552d8`.

Rules below are taken from executable code or from comments that describe that code. Comment-only rules are marked **Inferred**. Nothing here is a signed policy document. `sysSecurityGroup` names and `sysSettings` values live in the database and were not read.

Secrets and staff names are not copied.

## Identity

| ID | Rule | Provenance |
|---|---|---|
| BR-001 | Login matches `sysUser.strEmail`. The user's `sysSecurityGroup.blnActive` must be 1. | **Implemented** in `login.php` |
| BR-002 | `sysUser.blnActive` must be 1 or the message is "User account is inactive." | **Implemented** |
| BR-003 | The password is checked with `NemoPassword::checkPassword` for type `User`. | **Implemented** |
| BR-004 | Security group id 2 is redirected to `staff.php`. Every other group is redirected to `my.projects.php`. | **Implemented**. Calling group 2 "HR manager" is **Inferred** from the comment |
| BR-005 | A script renders only when `sysSecurity.blnView` is 1 for that URL and group. Otherwise the user is sent to `message.php`. | **Implemented** in `Nemo::iniSecurity` |
| BR-006 | After more than five view denials in `$_SESSION["intAttempt"]`, the message id is `e10` instead of `r09`. A successful view resets the counter to 0. | **Implemented** |
| BR-007 | Password policy can be turned on with `PASSWORD_Enable_Policy`. Seed values in the password class are minimum length 8, history 8, restricted substrings `password` and `123`, and reset interval `60 MINUTE`. | **Implemented** as seeds. The live row wins if it already exists. **Unknown** which values are in production |
| BR-008 | A reset link is stored as a `sysPassword` row dated `2010-01-01`. When the token flag is on, the row must have `dtLastEdit` inside `PASSWORD_Reset_Interval`. | **Implemented** |
| BR-009 | Five most-recent login rows of types "Login failed: Incorrect password" and "Login successful" are inspected. Failures stop at the latest success. At five failures the user is told the account is suspended and a warning email is sent. `blnLogin = 0` is assigned and then `Save()` is commented out. Login never reads `blnLogin`. | **Implemented** |
| BR-010 | From the second failure up to the fifth, the login message includes how many attempts remain, using a limit of 5. | **Implemented** |
| BR-011 | Menu items are `sysMenuLevel2` rows with `blnMenuItem = 1` that have a `sysSecurity` row for the group. | **Implemented** |
| BR-012 | Saving a security group deletes `sysSecurity` for that group and inserts the posted flags. The same save writes URL, entity, notes, order, menu-item, and divider back onto `sysMenuLevel2`. | **Implemented** in `includes/security.group.cls.php` |

Group ids used by allocation and capture code, all **Inferred** as names from comments and **Implemented** as the numeric tests:

| Id | Comment name | Rule in code |
|---|---|---|
| 1 | dev admin | May save allocations in any status. May override an out-of-range blind-capture date. On `lamp`, or as this group, some import missing-data checks are skipped |
| 3 | SAM admin | Same allocation save right as group 1. Extra behaviour when the period is the company's max period. May override an out-of-range capture date |
| 4 | financial admin | Allocation save, delete, and submit disabled |
| 5 | project admin | Allocation add and delete disabled |
| 6 | div lead | Allocation submit disabled |
| 2 | HR | Comment says the allocation screen should not be reached. Login sends this group to staff instead |

## Staff and companies

| ID | Rule | Provenance |
|---|---|---|
| BR-020 | Display name `strStaff` is `strLastName`, comma, space, `strFirstName`. | **Implemented** in `Staff::Save` |
| BR-021 | A company link is stored only when a company id and a staff code are both posted. | **Implemented** |
| BR-022 | Removing a staff member from a company asks for confirmation and the confirm text says they will be removed from the data export. | **Implemented** as UI text in `includes/company.cls.php` |
| BR-023 | Job titles for a staff member and company must not have overlapping start and end dates. | **Implemented** in `Staff::checkJobTitleOverlap` |
| BR-024 | Excel staff import accepts a column headed `employeenumber` or `employee number` and writes `tblStaff.strEmployeeNumber`. | **Implemented** |
| BR-025 | Only active staff (`blnActive = 1`) are offered in the company staff reload (`jsReloadStaff`). | **Implemented** |
| BR-026 | Company list staff counts are all `tblCompanyStaff` rows. Project counts are active projects only. | **Implemented** |

## Allocations and month end

| ID | Rule | Provenance |
|---|---|---|
| BR-030 | An allocation is a staff member, a project, a `YYYY-MM` period, a percentage `percPSA`, and a status. | **Implemented** |
| BR-031 | Status values the PHP writes or compares are `Initial`, `Approved`, `Final`, and `Adjusted`. The column is an enum loaded from the database. Other enum members are **Unknown** | **Implemented** for those four strings |
| BR-032 | A staff code passes the month-end allocation check only when the summed percentage is exactly 100 and the status is `Approved`. Zero staff does not pass. | **Implemented** in `checkMonthEndPeriod`. Comment 20200429 says this replaced a `>= 99` test. **Inferred** history |
| BR-033 | The check groups by staff code. Staff with no staff code are still in the query (left join). An empty code is counted. | **Implemented** |
| BR-034 | Month end requires at least one active staff member allocated in the company's max period. | **Implemented** |
| BR-035 | Month end sets that period's allocations to `Final`, logs them, then inserts the next month for active staff only, status `Initial`, approved date empty, last user `Month End Process`, and logs the new rows. | **Implemented** |
| BR-036 | Inactive staff are not copied to the next period. | **Implemented**. Comment 20150525 states the same rule. **Inferred** as the original reason |
| BR-037 | The month-end checks company dropdown omits `CompanyID` 3 and 4. | **Implemented** |
| BR-038 | A comment says UCT LGB and RW companies never have HR/admin costs or finance-transaction costs. | **Inferred** from the 20190415 comment. It is **Unknown** whether those are ids 3 and 4 |
| BR-039 | Journal lines use these descriptions: "Project Staff Salary Allocation", "HR Support", "Salaries", "IT Support & Networking", "Finance Support". | **Implemented** constants |
| BR-040 | Comments say month-end support calculations exclude projects IT Support, HR & Payroll, and F A SUPSERV, and that finance-levy import also excludes "DTHF TRf JNL.s", "Monthly Salary Jnls", and "Multi Proj PMTS". | **Inferred** from class-header comments dated 20200330 and 20200407. The import class header is the evidence; each SQL filter was not re-quoted here |
| BR-041 | IT unit cost is `dblCostIT / sum(dblUnits)`, rounded to 5 decimal places in `updateCCIT_CostPerUnit`. Finance unit cost in `updateCCFIN_CostPerUnit` is rounded to 6. The 20200406 comment on the finance function says rounding moved from 5 to 6. | **Implemented** |
| BR-042 | A comment says IT support cost is unit cost × devices × PSA percent. | **Inferred** from the 20190704 comment on `month.end.checks.cls.php` |
| BR-043 | Fiscal spreadsheet columns start at `sysSettings` `FiscalStartPeriod`. If the selected period's month is before that month, the fiscal year starts in the previous calendar year. Twelve months are written. | **Implemented** in `generatePSA_Fiscal_XLS` |
| BR-044 | Salary import comments say inactive staff in the file are imported, and a password-protected workbook is rejected. | **Inferred** from comments in `includes/import.staff.costs.cls.php` (20190404, 20190220) |
| BR-045 | New timesheet control rows are created for staff who have imported salaries for the period, not for every active staff member. | **Inferred** from the 20190425 comment. The previous insert of all active staff is commented out in `iniTSC` |

## Timesheets

| ID | Rule | Provenance |
|---|---|---|
| BR-050 | A blind-capture version must be present, must be a positive integer, must not already be captured, and must not be greater than the control version. | **Implemented** as the messages and checks in `system.php` and `includes/blind.bulk.capture.cls.php` |
| BR-051 | A staff code must be present and must match a staff code in the database. | **Implemented** |
| BR-052 | "Date Signed out of range" and "Date Signed cannot be in the future" are still defined. The ajax validation that appended them is commented out (20200921). Groups 1 and 3 may tick a checkbox to capture a row that still carries the out-of-range warning. | **Implemented** |
| BR-053 | A 20190614 comment says a signed date up to three months after the period is valid. | **Inferred**. The check that would enforce a range is in the commented ajax block |
| BR-054 | Sundays and dates in `hrPublicHoliday` are grey on the timesheet calendar. The day letter is the first letter of the English weekday, so Sunday and Saturday both become `S`. The grey test is `strDay == "S"`, so both weekend days are grey. | **Implemented** |
| BR-055 | Approved, active `tblLeave` rows whose range overlaps the period are loaded for the PDF. They are not painted. The staff record does not show the leave table. Both skips are commented "Temporarily remove leave from Timesheet" on 20260619. | **Implemented** |
| BR-056 | Leave short codes prepared for that paint are Annual `A`, Family Responsibility `FR`, Sick `S`, Special `SL`, Study `LST`, Maternity `M`, Paternity `PL`, Unauthorized `UU`, Unpaid `UP`. | **Implemented** as a map that the commented block would use |
| BR-057 | The PDF legend still contains the label "Special Leave". | **Implemented** |
| BR-058 | The hourly job emails `generateTimesheet` work to the staff email, and also to `strAlternativeEmail` when that field is in the payload. The PDF is attached from `PDF/`. The job is then marked `Processed`. | **Implemented** |
| BR-059 | PDF password encryption is not applied. The `pdfEncrypt` calls are commented. | **Implemented** as not run |
| BR-060 | Outstanding reminders run when the date is 8, 11, 15, or 20 days after the company's `dtMonthEnd`. | **Implemented** in `cron.daily.php` |
| BR-061 | Those emails always copy the line manager (`tblStaff.refLineManagerID` where `blnLineManager = 1`). On days 11, 15, and 20 they also copy that manager's line manager. | **Implemented** |
| BR-062 | A comment says the outstanding process changed from three weeks to six weeks (20190614) and that only working days since month end are processed (20190619). | **Inferred** from comments on `cron.daily.outstanding.timesheets.php`. The day offsets in `cron.daily.php` are the 8/11/15/20 rule above |
| BR-063 | The 5th-of-month roll in `cron.daily.outstanding.timesheets.php` compares `$dtToday` to the 5th, but `$dtToday` is only assigned in a comment. A comment in the live block says it is disabled in case it runs. | **Implemented** as a branch that does not receive the date it tests |
| BR-064 | `cron.daily.initialize.timesheets.php` rolls timesheets on the 4th when the previous period has `blnMonthEnd = 1`, and its header says the job was moved because the host allows 10 cron jobs. | **Implemented** in that file. Whether the host calls it is **Unknown** |

## Leave import

| ID | Rule | Provenance |
|---|---|---|
| BR-070 | Leave is pulled for the server's current year and month. | **Implemented** in `cron.employee.leave.php` |
| BR-071 | The token request uses scope `api.full_access`. Missing client id or secret returns HTTP 400 and does not call PaySpace. | **Implemented** |
| BR-072 | The company id is the first company of the first group on the token JSON (`group_companies[0].companies[0].company_id`). | **Implemented** |
| BR-073 | Each returned application is inserted with `INSERT IGNORE`. A duplicate of the unique key is skipped. | **Implemented** |
| BR-074 | The unique key is employee number, leave type, end date, start date, company run, transaction type, and status. | **Documented** in `database/tblLeave.sql` and **Implemented** as the table the insert targets. Whether the live table matches the file is **Unknown / Requires Confirmation** |
| BR-075 | `strLeaveStatus` must be `Approved` and `blnActive` must be 1 before a row is considered for the PDF query. | **Implemented** in the PDF query. The paint is commented out, so the filter currently has no visible effect |
| BR-076 | The staff leave list joins on `strEmployeeNumber`, not on `refStaffID`. The insert copies `EmployeeNumber` into both `strEmployeeNumber` and `refStaffID`. | **Implemented** |

## Mail, sessions, and exposure

| ID | Rule | Provenance |
|---|---|---|
| BR-080 | Outbound mail uses `sysSettings` `SMTP Send As` as the From address and `SMTP BCC` when the calling code sets BCC. | **Implemented** |
| BR-081 | Opening `email.read.online.php` with a `UID` marks that `tblEmail` row `Read` unless status is `Error`. No login is required. | **Implemented** |
| BR-082 | Requests that are not HTTPS are redirected to HTTPS. | **Implemented** in `.htaccess` |
| BR-083 | Session lifetime is set to 720000 seconds. Session files are stored under a directory that depends on the host name. | **Implemented** in `system.php` |
| BR-084 | `info.php` exposes `phpinfo()` without a login check. | **Implemented** |

## Rules not established

- The real security-group labels in `sysSecurityGroup`.
- Live password-policy numbers, fiscal start month, SMTP host, and cron enable flags.
- Whether company ids 3 and 4 are excluded from month end on purpose, and what those companies are.
- The host crontab.
- Whether leave display is still meant to be off.
- Any rule that exists only in the database, in email template HTML, or in a PDF already stored under `documents/`.
