# PRD — dthf-hr

Evidence date: 2026-10-16.

Source: `git@bitbucket.org:overdrive/dthf-hr.git`, `master` at `93552d8` (2026-06-19).

There is no prior PRD in the source repository or in Silicon Ark. The only product sentence in the repo is the README heading "Desmund Tutu HIV Foundation". Requirements below are recovered from the implementation. Provenance is labelled. Secrets in `system.php` are not reproduced.

## Product purpose

**Documented.** `README.md` names the product as the Desmond Tutu HIV Foundation, spelled "Desmund" in that file.

**Implemented.** The application is an internal HR and project-effort system:

- People sign in and land on staff administration or on project allocations, depending on security group.
- Staff belong to one or more companies, each with a staff code, and have job titles with start and end dates.
- For a company and a `YYYY-MM` period, each staff member is allocated a percentage of time to one or more projects.
- Month end checks those allocations, consumes imported salary and finance-levy figures, writes journals, marks the period final, and opens the next period.
- The system builds a timesheet PDF per staff member and period, emails it, and later records a signed copy through blind capture.
- Daily scripts remind people about outstanding timesheets.
- A leave import from PaySpace exists in code. As of this revision it is not shown on the staff screen or on the timesheet PDF.

**Unknown / Requires Confirmation:** the organisation's official product name, and whether "Desmund" in the README is an accepted spelling.

## Users and roles

Access to a page is **Implemented** as rows in `sysSecurity` for the signed-in `refSecurityGroupID`: flags `blnView`, `blnNew`, `blnSave`, `blnDelete`, `blnSpecial`. The menu shows `sysMenuLevel2` rows with `blnMenuItem = 1` for that group (`_nemo.menu.cls.php`). If `blnView` is not 1, `Nemo::iniSecurity()` sends the browser to `message.php` (`MID=r09`, or `MID=e10` after more than five attempts in the session counter).

Group names are not seeded in Git. Comments in `includes/project.staff.allocation2.cls.php`, `login.php`, and `index.php` name these ids:

| Id | Name used in comments | What the code does with it | Provenance |
|---|---|---|---|
| 1 | dev admin / admin dev | Can save project allocations regardless of status. Can override a blind-capture date-range checkbox. Import screens also allow this group, and the `lamp` server name, to ignore some missing-data checks | **Inferred** from comments and `if` branches |
| 2 | HR manager | After login, and from `index.php`, sent to `staff.php`. A comment says this group should not reach the allocation screen | **Inferred** |
| 3 | SAM admin | Same save rights as group 1 on allocations. On the current highest period, extra submit behaviour. Can override blind-capture date range | **Inferred** |
| 4 | financial admin / financial manager | Save and delete disabled on allocations. Submit disabled | **Inferred** |
| 5 | project admin | Add and delete disabled on allocations | **Inferred** |
| 6 | div lead | Submit disabled on allocations | **Inferred** |

Whether those comment names still match `sysSecurityGroup.strSecurityGroup` in the database is **Unknown / Requires Confirmation**.

Other actors:

| Actor | Evidence | Status |
|---|---|---|
| Signed-in user | `$_SESSION["USER"]` with `ID`, `USERNAME`, `EMAIL`, `SECURITYGROUPID` set in `login.php` | **Implemented** |
| Line manager | `tblStaff.refLineManagerID` and `blnLineManager`. Outstanding-timesheet mail copies this person | **Implemented** |
| Line manager's line manager | Looked up the same way and copied on later reminder days | **Implemented** in `cron.daily.php` |
| Host scheduler | PHP files named `cron.*.php`. No crontab in Git | **Inferred** that something on the server requests these URLs or runs them with PHP |
| PaySpace | HTTP client in `leave.api.php` | **Implemented** as a client. Credentials are absent from Git |
| Deploy operator | `bitbucket-pipelines.yml` git-ftp | **Inferred** |

`email.read.online.php` does not require a session. Given `UID`, it sets `tblEmail.strStatus` to `Read` unless the status is already `Error`.

## Systems the product talks to

1. The user's browser. Layouts are `layouts/layout.back.incl.php` and `layouts/layout.menu.incl.php`. jQuery lives under `layouts/js/`.
2. MySQL. Connection is `mysqli_connect` in `system.php`. Failure prints a critical-error object and does not continue into settings load.
3. SMTP, using `sysSettings` values `SMTP Send As` and `SMTP BCC`. `NemoEmail` builds a MIME message. The concrete mailer host is **Unknown** because it is a database setting.
4. PaySpace and YourHCM, only from `leave.api.php`.
5. FTP, only if the Bitbucket pipeline is run. Variables named in the file: `FTP_USERNAME_LIVE`, `FTP_PASSWORD_LIVE`, `FTP_HOST_LIVE`, and the QA equivalents. Values are not in Git.
6. The filesystem: session files under the path in `system.php`, generated PDFs under `PDF/`, and a large `documents/` tree of already generated timesheet PDFs.

`system.functions.inc.php` contains an SMS send helper. Nothing in the HR screens inspected calls it as part of login, timesheets, or month end. Whether any live page still calls it is **Unknown / Requires Confirmation**.

## Major workflows

### WF-1 — Sign in

`login.php` clears `$_SESSION["USER"]` and shows email, password, and a forgot-password link.

On `Action=Login`:

1. Load `sysUser` joined to `sysSecurityGroup` where `strEmail` matches and `sysSecurityGroup.blnActive = 1`.
2. If no row: log "Login failed: User not found or SG not active". Message: "Login Details are invalid."
3. If `blnActive` is not 1: log "Login failed: Inactive User". Message: "User account is inactive."
4. If `NemoPassword::checkPassword("User", UserID, password)` succeeds: store the session fields above, log "Login successful", and redirect. Group 2 goes to `staff.php`. Every other group goes to `my.projects.php`.
5. If the password does not match: log "Login failed: Incorrect password", message "Username or Password is incorrect.", then append the result of `CheckSecurityLock`.

`index.php` repeats the group 2 redirect and otherwise sends the user to `my.projects.php`. The old home-page list is commented out. A commented line mentions `http://www.dthf-hr.co.za/login.php`.

`Nemo` (the authenticated shell) redirects to `SystemSettings["SessionExRedirect"]` when `$_SESSION["USER"]` is missing.

Provenance: **Implemented**.

Two other login actions exist:

- `Action=Redirect` loads `sysUser` by email and `strPasswordMD5`, copies `strPassword` from that row, then falls through into `Action=Login`. The variable `$strPasswordMD5` is not set in the lines above that query.
- `Action=RedirectKnowmore` loads the user by email only, copies `strPassword`, then falls through into `Login`.

Provenance: **Implemented** branches. Whether any current client posts these actions is **Unknown / Requires Confirmation**.

### WF-2 — Reset a password

The login page calls `ajaxfunctions.php` with `type=SendResetEmail`.

`SendResetEmail`:

- Looks up `sysUser` by the posted email, unquoted in the SQL string.
- If exactly one row: creates a password record via `NemoPassword` with date `2010-01-01` (comment: default aged date), loads email template "Reset Password", and sends a link to `resetPassword.php?strEmail=...&ResetKey=...`.
- Returns `1` or `0`. The login page tells the user the reset email was sent.

`NemoPassword::getLastPassword` can require `dtLastEdit` within `PASSWORD_Reset_Interval` when it is called with the token flag. The constructor seeds that setting as `60 MINUTE` if the settings insert runs. Live values come from `sysSettings`.

Provenance: **Implemented**.

### WF-3 — Work on project allocations

`my.projects.php` hosts `ProjectStaffAllocation` from `includes/project.staff.allocation2.cls.php`. Filters include company, period, project, staff, and a view of project versus staff. `ViewBy=Staff` copies filters toward `my.projects.staff.php`.

A period is `YYYY-MM`. Allocation percentage is `tblProjectStaffAllocation.percPSA`. Status is `strStatus`. The allowed values are the MySQL enum on that column, loaded at runtime by `getProjectStaffAllocationStatusArray()` from `information_schema`. The PHP that was read uses these status strings: `Initial`, `Approved`, `Final`, `Adjusted`. The full enum is **Unknown** without the database.

`checkMonthEndPeriod` counts a staff code as passed only when the summed `percPSA` is exactly 100 and `strStatus` is `Approved`. A comment dated 20200429 says the check changed from `>= 99` to `== 100`.

Month end (`MonthEnd::doMonthEnd`):

- Finds the max `strPeriod` for the company.
- If at least one active staff member has an allocation in that period: sets those rows to `Final`, writes `logProjectStaffAllocation`, inserts the next period for **active** staff only with status `Initial`, approved date null, last user `Month End Process`, logs those new rows, and calls `CompanyStaffControlCheck::GenerateCompanyStaffChecks` for the next period. Returns success.
- Otherwise returns failure: "At least one staff member for the selected company must be active."
- A 20150525 comment says inactive staff are not carried forward. That matches the `blnActive = 1` clause on the insert.

Button visibility by group and by whether the period is the max period is a long set of branches in `project.staff.allocation2.cls.php`. Provenance: **Implemented**. The comments are the only names for the groups.

### WF-4 — Month-end checks, salary import, finance levies, journals

`month.end.checks.php` / `includes/month.end.checks.cls.php` is the check screen. The company dropdown lists active companies whose `CompanyID` is not 3 and not 4.

Journal description constants in that class:

| Constant | Text written as the description |
|---|---|
| `$strDescription_PSSA` | Project Staff Salary Allocation |
| `$strDescription_PAC` | HR Support |
| `$strDescription_PSSL` | Salaries |
| `$strDescription_PSIC` | IT Support & Networking |
| `$strDescription_PFL` | Finance Support |

Comments say earlier names were "Project Admin Costs", "Project Staff Salary Levy", "Project Staff IT Costs", and "Project Finance Levy", and that the current names were requested in 20190327. Provenance: **Implemented** as the current constants. The request itself is a comment, not a separate requirements document.

Comments on the same class, treated as **Inferred** rules because they describe formula changes that later code comments say were applied:

- Month end should include salary journals, so salary costs must be present before month end.
- PSA checks for month end consider staff who have imported salary costs for the period.
- IT cost uses unit cost × devices × PSA percent (20190704 comment).
- IT, HR, and finance support exclude projects named in comments: IT Support, HR & Payroll, and F A SUPSERV (20200330).
- UCT LGB and RW companies never have HR/admin costs or finance-transaction costs (20190415). Whether those are company ids 3 and 4 is **Unknown / Requires Confirmation**.

`import.staff.costs.php` reads an Excel file through PhpSpreadsheet (`includes/import.staff.costs.cls.php`). Comments record a 3-sheet file with no total rows (20190524) and a request to import inactive staff as well as active staff (20190404). A password-protected workbook is rejected (20190220 comment).

`import.fin.levies.php` reads finance transactions. Comments say it ignores the project-number row and counts transactions beneath it, and that it excludes IT Support, HR & Payroll, F A SUPSERV, "DTHF TRf JNL.s", "Monthly Salary Jnls", and "Multi Proj PMTS".

`journal.php` lists `tblJournal` by period. `company.journals.php` is the company journal screen.

`project.functions.inc.php` rolls timesheet control (`rollME_TSC`) and company control check (`rollME_CCC`) into the next period, and recalculates IT and finance cost per unit. `updateCCIT_CostPerUnit` rounds to 5 decimal places. `updateCCFIN_CostPerUnit` rounds to 6, with the comment "20200406 - updated rounding from 5 to 6 decimals". Provenance: **Implemented**.

Fiscal columns on the month-end spreadsheet use `sysSettings` key `FiscalStartPeriod` (`generatePSA_Fiscal_XLS`). The value is not in Git.

### WF-5 — Timesheet issue, capture, and chase

Timesheet control is `timesheet.control.php` and `includes/timesheet.control.cls.php`.

`TimesheetControl::iniTSC` inserts `tblTimesheet` rows. A 20190425 comment says new timesheets are created only for staff who have imported salaries for the period. The earlier "all active staff" insert is commented out above the current query.

`CreateTimesheetPDF`:

- Loads public holidays from `hrPublicHoliday.dtEffectiveDate` and greys those days and Sundays (first letter `S`).
- Loads `tblLeave` rows for the staff `strEmployeeNumber` where `blnActive = 1`, `strLeaveStatus = 'Approved'`, and the date range overlaps the period month.
- Maps leave types to short codes: Annual `A`, Family Responsibility `FR`, Sick `S`, Special `SL`, Study `LST`, Maternity `M`, Paternity `PL`, Unauthorized `UU`, Unpaid `UP`.
- Does **not** write those codes or a tick onto the calendar. Both blocks are commented with "20260619 - Temporarily remove leave from Timesheet".
- The PDF legend still has a "Special Leave" cell (comment 20200324).
- Writes the file under `PDF/` and can be queued on `sysCronJob` as `generateTimesheet`.

`cron.hourly.php` selects `sysCronJob` rows. The active case is `generateTimesheet`: build the PDF, email the staff address (plus `strAlternativeEmail` when that field is in the payload), attach `PDF/{file}`, set the job to `Processed`. Cases `Timesheets Email All` and `Timesheet Rejection` are inside a comment. A 20200407 comment says generate-timesheet and "Timesheet Email All" were paused at someone's request; the `generateTimesheet` case in the current file is not inside that comment. Provenance: **Implemented** as an active case. Whether the host still schedules `cron.hourly.php` is **Unknown / Requires Confirmation**.

Blind capture (`blind.capture.php`, `blind.bulk.capture.php`) records a signed timesheet version against `tblTimesheetBlind`. Validation messages defined in `system.php`:

- "Timesheet Version missing."
- "Timesheet Version should be more than zero."
- "Staff Code missing."
- "Incorrect Staff Code."
- "Timesheet Version already captured."
- "Blind Capture Version cannot be greater than control version."
- "Date Signed out of range."
- "Date Signed cannot be in the future."

`ajaxfunctions.php` has the date-range and future-date checks commented out under "Disable Date Validation - 20200921". The bulk-capture screen can still show the out-of-range warning, and groups 1 and 3 can tick `chkCaptureOFDate` to capture despite that warning (`includes/blind.bulk.capture.cls.php`). A 20190614 comment says a signed date may be up to three months after the period. That check is in the commented block.

`cron.daily.php` runs two tasks unless a query flag turns the others off:

- `CRONDAILY_PROCESS_Outstanding_Timesheet_Reminders_ENABLED`
- `CRONDAILY_PROCESS_Outstanding_Timesheet_Report_ENABLED`

Reminders are considered when today is 8, 11, 15, or 20 days after `dtMonthEnd`. Day 8 copies the line manager. Days 11, 15, and 20 also copy that manager's line manager. The flags' on/off values are `sysSettings` rows, not constants in Git. The script then sets the task variables to 1 in a loop before consulting those setting names further down. Provenance: **Implemented** in `cron.daily.php`. Whether the setting values can still disable a task is **Requires Confirmation** against the rest of that file and the live `sysSettings` rows.

`cron.daily.outstanding.timesheets.php` contains a live `if ($dtToday == date("Y-m-05"))` block that calls `rollME_TSC` and sets `dtMonthEnd`. `$dtToday`, `$strPrevPeriod`, and `$strPeriod` are assigned only in comments above that `if`. A comment inside the block says "Disabed - Incase this runs". Provenance: **Implemented** as a condition that does not have its inputs set. `cron.daily.initialize.timesheets.php` has a similar block for the 4th of the month, with the variables assigned, and a header saying the job was moved because the host already had 10 cron jobs.

### WF-6 — Pull leave from PaySpace

`cron.employee.leave.php` includes `leave.api.php` and calls `getEmployeeLeaves` for the current year and month.

`getEmployeeLeaves`:

1. Posts `client_id`, `client_secret`, and `scope=api.full_access` to `https://identity.yourhcm.com/connect/token`.
2. If the client id or secret is empty, responds 400 "Missing credentials" and does not obtain a token.
3. On HTTP 200 with `access_token`, reads `group_companies[0].companies[0].company_id`.
4. GETs `https://api.payspace.com/odata/v2.0/{companyId}/EmployeeLeaveApplication/{year}/{month}` with a bearer token.
5. Inserts each `value` element into `tblLeave` with `INSERT IGNORE`.

`saveToken` can insert `tblApiToken` and is not called by `getEmployeeLeaves`.

`refStaffID` is set from `intval` of `EmployeeNumber`. The staff leave query joins `tblLeave.strEmployeeNumber` to `tblStaff.strEmployeeNumber`, not to `refStaffID`. Provenance: **Implemented**. Whether storing the employee number in `refStaffID` is intentional is **Unknown / Requires Confirmation**.

The staff screen does not render the leave table. See WF-5 for the PDF.

Schema for `tblLeave` is `database/tblLeave.sql`. Unique key `idx_composite` is (`strEmployeeNumber`, `strLeaveType`, `dtLeaveEndDate`, `dtLeaveStartDate`, `strLeaveCompanyRun`, `strLeaveTransactionType`, `strLeaveStatus`). `INSERT IGNORE` therefore skips a repeat of that combination.

`database/tblstaff0260107.sql` is a single `ALTER TABLE tblStaff ADD COLUMN strEmployeeNumber VARCHAR(30) NOT NULL`.

### WF-7 — Maintain staff, companies, projects, holidays, users

- `staff.php` saves `tblStaff` (`strStaff` is built as `last name, first name`), company links and staff codes on `tblCompanyStaff`, and job titles. `Staff::checkJobTitleOverlap` rejects overlapping ranges. Excel import maps an employee-number column onto `strEmployeeNumber`.
- `company.php` lists companies with staff count and active project count, and can remove a staff member from a company. The confirm text says removal also removes them from the data export.
- `project.php`, `sites.php`, `public.holiday.php`, `user.php`, `security.group.php`, `settings.php`, `email.php`, `email.template.php` are the other maintenance screens. Security-group save rewrites `sysSecurity` for that group and can edit the menu rows themselves (`includes/security.group.cls.php`).

Provenance: **Implemented**.

## Functional requirements recovered from the code

| ID | Requirement | Provenance |
|---|---|---|
| FR-001 | A person signs in with email and password. The account and its security group must both be active. The password check is `NemoPassword`, not a raw MD5 compare (the MD5 compare is commented). | **Implemented** |
| FR-002 | Security group 2 is sent to `staff.php`. Other groups are sent to `my.projects.php`. | **Implemented**. The "HR manager" label is **Inferred** |
| FR-003 | A page loads only when `sysSecurity.blnView = 1` for that script and group. Otherwise the user is sent to `message.php`. | **Implemented** |
| FR-004 | Password policy settings are read from `sysSettings`. The password class can insert defaults: policy enabled, minimum length 8, history 8, restricted words `password` and `123`, reset-link interval `60 MINUTE`. | **Implemented** as insert-if-used defaults. Live values are **Unknown** |
| FR-005 | Five failed passwords in the recent login log produce the message that the account has been suspended and send a warning email. The `Save()` that would set `blnLogin = 0` is commented out. Login does not read `blnLogin`. | **Implemented** |
| FR-006 | Forgot password emails a link when exactly one `sysUser` row matches the email. | **Implemented** |
| FR-007 | Staff, company membership, staff code, and job title are editable. Job-title dates must not overlap for the same staff and company. | **Implemented** |
| FR-008 | Project allocation for a period is a percentage and a status. Month end requires each included staff code to total exactly 100 percent at status `Approved`. | **Implemented** |
| FR-009 | Month end sets the current period to `Final` and copies active staff to the next period as `Initial`. | **Implemented** |
| FR-010 | Salary costs and finance levies are imported from Excel and feed month-end journals. | **Implemented** |
| FR-011 | Timesheet PDFs are generated for a staff member and period, queued, and emailed by the hourly script. | **Implemented** |
| FR-012 | Blind capture records a signed version and rejects a version above the control version, a duplicate version, a missing version, or a staff code that does not match. | **Implemented** |
| FR-013 | Outstanding timesheet reminders are eligible 8, 11, 15, and 20 days after month end, with the line-manager copy rules in WF-5. | **Implemented** in `cron.daily.php` |
| FR-014 | Approved leave can be stored in `tblLeave` and was built to appear on the timesheet. Display on the PDF and on the staff record is commented out. | **Implemented** as stored and as hidden |
| FR-015 | Public holidays grey the timesheet calendar. | **Implemented** |
| FR-016 | HTTPS is forced by `.htaccess` when the request is not already HTTPS. | **Implemented** |

## Important constraints

- Periods are calendar months stored as `YYYY-MM`.
- Session garbage-collection lifetime is set to `720000` seconds in `system.php`. Session files are stored on disk at a per-host path. `.gitignore` ignores `SESSION`.
- `display_errors` is on. `error_reporting` is `E_ALL` without `E_STRICT`, `E_NOTICE`, or `E_WARNING`.
- `info.php` calls `phpinfo()` with no session check.
- Database passwords are hard-coded in `system.php`. They are omitted from this PRD. Human review should treat that file as exposed secrets.
- `documents/` in Git contains generated timesheet PDFs. Filenames include staff names. Some of those paths exceed the Windows MAX_PATH limit and did not materialise in the inspection checkout. The Git objects are still in the repository.
- There is no automated test suite in `composer.json` or in a tests directory that was found.

## Error conditions

| Condition | Behaviour | Provenance |
|---|---|---|
| Database connect fails | Prints "Database Connection has Failed!" and the driver error. Settings are not loaded | **Implemented** |
| Unknown or inactive user, or inactive security group | Login message, row written to `sysLogin` | **Implemented** |
| Wrong password | Login message, then remaining-attempt text from the second failure, and a suspension email plus suspension text at five failures. The account flag is not saved | **Implemented** |
| No `blnView` | Redirect to `message.php` | **Implemented** |
| Month end with no active allocated staff | Returned message "At least one staff member for the selected company must be active." | **Implemented** |
| Allocation check not 100 percent approved | `checkMonthEndPeriod` returns the pass count and `false` | **Implemented** |
| PaySpace credentials missing | HTTP 400 JSON `Missing credentials` | **Implemented** |
| PaySpace token HTTP error | JSON error using the token endpoint's `error` field when present | **Implemented** |
| Duplicate leave natural key | `INSERT IGNORE` skips the row | **Implemented** |
| Blind-capture version or staff-code failure | The message constants in `system.php` | **Implemented** |

## Open product questions

1. Are security groups 1–6 still the roles named in the comments?
2. Are company ids 3 and 4 the UCT LGB and RW companies excluded from the month-end company list?
3. Is the 2026-06-19 removal of leave from the staff screen and the PDF still temporary?
4. Where are PaySpace `CLIENT_ID` and `CLIENT_SECRET` supposed to be set?
5. Which cron files does the host actually schedule, and did the 10-cron-job limit change?
6. What are the live `sysSettings` values, including `FiscalStartPeriod`, SMTP, password policy, and the `CRONDAILY_*` flags?
7. Is `Action=Redirect` / `Action=RedirectKnowmore` still used by another system?
8. Should committed PDFs under `documents/` and credentials in `system.php` remain in Git?
