# Business rules — timesheet

Evidence date: 2026-10-01.

Source: `git@bitbucket.org:overdrive/timesheet.git`, `master` at `3baa25ba`.

Rules below are what the code enforces. They are **Inferred** unless a comment or the README states them. The README states no business rules. Nothing here was confirmed with a product owner.

Secrets (database passwords, the external login PIN literal) are not copied into this file.

## Access

### BR-001 — Login needs an active user in an active security group

**Inferred** from `login.php`.

The email must match `sysUser.strEmail`. The user's `sysSecurityGroup.blnActive` must be 1. The user's `blnActive` must be 1. Otherwise login stops with the messages in the PRD (WF-1).

### BR-002 — Login needs the password hash and the external PIN

**Inferred** from `login.php`.

`NemoPassword::checkPassword` must succeed and the posted External Login Pin must match the constant compiled into `login.php`. A wrong password, a wrong PIN, and a missing user do not all produce the same message. Wrong PIN or wrong password share "Username, Password or External Login Pin is incorrect." Missing user or inactive group says "Login Details are invalid."

### BR-003 — Five bad passwords deactivate the account

**Inferred** from `CheckSecurityLock`.

The last five `sysLogin` rows for that user, among results "Login failed: Incorrect password" and "Login successful", are inspected newest first. Counting stops at a success. If five failures are counted, `blnActive` is set to 0 and `strLastUser` becomes `Security Lockout`. An email is built from the attempt table. The attempt limit in code is 5, not a system setting.

### BR-004 — Page access is view-flag, not a role name

**Inferred** from `Nemo::iniSecurity` and `NemoMenu::BuildMenu`.

A security group may view, create, save, delete, or use a "special" action per menu URL. Only `blnView` is checked before the page renders. This inspection did not find the list and detail classes consulting `blnNew`, `blnSave`, `blnDelete`, or `blnSpecial` before writing. **Requires Confirmation** whether those flags are enforced anywhere beyond the security-group editor that stores them.

### BR-005 — Logged-out Nemo pages go to login

**Inferred** from the `Nemo` constructor.

`login.php`, `resetPassword.php`, and the logged-out branch of `message.php` use `NemoBasic` and do not require `USER`.

## Time entry

### BR-006 — A time line is a user plus a project item plus a date

**Inferred** from the joins. Every list and the eIT copy assume `tblUserProjectItem.refProjectItemID` joins to `tblProjectItem`, then `tblProject`, then `tblClient`, and `refUserID` joins to `sysUser`.

A ticket item is optional. Posted ticket item `0` is saved as NULL.

### BR-007 — Hours default to the clock difference

**Inferred** from `timesheet.php`.

The form computes hours from start and end. One-decimal comparison on edit decides the override checkbox. The user can type a different hour value when override is on. New lines start at the current half-hour and end one hour later (`getTimesheetStartEndTime`).

### BR-008 — Amount is hours times the rate stored on the line

**Inferred** from the list SQL: `format(dblHours * dblRate, 0)`.

The rate copied onto a no-bill extra line comes from the no-bill project item's `tblRate.dblRate`. Report action "Update Timesheet Item" also reloads `dblRate` from the project item's rate when the line is edited from the report.

### BR-009 — Zero-rate lines do not count toward the target banner

**Inferred** from `getUserOfficeTotal`.

Billed hour and amount sums require `dblRate != 0`. Office totals only include users with `blnActive = 1` and `blnOfficeTarget = 1`. The personal block uses the filtered user, or the same office-target users when the filter user id is 0.

Rand targets are target hours times `sysSettings.intBaseRate`. The setting's live value is **Unknown**.

### BR-010 — Target colours

**Inferred** from `getUserOfficeTotal`.

Percentage under 80 is red (`#ff3e32`). From 80 up to but not including 100 is orange. Above 120 is blue (`#1594d9`). From 100 up to and including 120 is green (`#72c957`). These are display rules, not gates on save.

### BR-011 — No-bill can write a second line

**Inferred** from `Timesheet::Save`.

If `chkNoBill` is posted as `on`, a second insert is executed for the no-bill project item, times, hours, that item's rate, and no-bill notes. There is no check in this function that the second insert succeeded.

### BR-012 — Deleting a timesheet line removes the row

**Inferred** from `Timesheet::Delete`.

`DELETE FROM tblUserProjectItem`. Clients, projects, and the client-class pattern use `blnActive = 0` instead. Timesheet delete does not.

### BR-013 — Invoiced is a flag, not a document

**Inferred** from `blnInvoiced` and `markAsInvoiced`.

Mark as invoiced sets the flag to 1 and rewrites `strLastUser`. It does not create an invoice record in this database. The report update action can also set the flag back from the posted checkbox, including to 0.

## Estimates and reminders

### BR-014 — Overrun mail fires once per threshold

**Inferred** from `Timesheet::Save`.

When spent hours on the ticket item are at least 50% of `dblHoursEst`, both numbers are non-zero, and the ticket has an owner email:

- At or above 100%, and `bln100PercentEmailSent` is 0: send and set that flag.
- Else at or above 75%, and `bln75PercentEmailSent` is 0: send and set that flag.
- Else at or above 50%, and `bln50PercentEmailSent` is 0: send and set that flag.

One save sends at most one of those emails. Crossing from under 50% straight past 100% sends only the 100% mail and does not set the 50% and 75% flags in that same save.

Template name: `Project Item Over Run`. CC header is `sysSettings` value `SMTP CC`.

Division by the estimate is unguarded before the zero check is applied in the `if`. A zero estimate can produce a PHP warning and then skip the send. **Implemented** as ordered in the source.

### BR-015 — A reminder day is target divided by 20

**Inferred** from `cron.reminder.php`.

`(dblTarget/20)` is the threshold named `target` in the SQL. Mail is sent when `target > 0` and summed hours are lower than that.

Only `blnActive = 1` users are selected.

### BR-016 — No reminder on a weekend or a listed public holiday

**Inferred** from `cron.reminder.php`.

Saturday and Sunday stop the script. A row in `tblPublicHolidays` for today's date stops it. The previous-working-day walk also skips Saturday, Sunday, and holiday dates. The month-end counter reset uses the last calendar weekday and does not consult `tblPublicHolidays`.

### BR-017 — Morning reminder looks at the previous working day and copies managers

**Inferred** from `cron.reminder.php`.

Hour `0`–`12` uses template `TimeEntryReminderMorning`, counts hours on the previous working day, CCs two fixed Overdrive addresses, and CCs a third when `dblRemindCount > 2`. It increments `dblRemindCount`.

Hour `13` onward uses template `TimeEntryReminder` and today's hours. It does not add those CC headers and does not increment the counter. A person reminded this morning is eligible again in the afternoon only when the stored reminder hour is before 16.

`dblRemindCount` is zeroed on the last weekday of the month.

## eIT copy

### BR-018 — eIT receives fixed billing codes

**Inferred** from `Timesheet::CopyToEit`. The comments in the function state the code meanings.

| Query field | Value in code | Comment in code |
|---|---|---|
| `dblUnit` | same as hours | Billable hours equal actual hours |
| `dblMultiplier` | `1` | |
| `strLocation` | `OFF-SITE` | |
| `refCodeFixID` | `82` | Support - Dev |
| `refCodeFaultID` | `52` | Support |
| `strFault` | `Support` | |
| `strDivision` | `SOFTWARE` | |
| `dblSperc` | `100` | Department SOFTWARE |
| `strBookSigned` | `No` | |

Date is sent with hyphens removed. Invoice notes go to `strFix`. Internal notes go to `strFollowUpFix`. `refUserProjectItemID` is the local id.

The client must have `eitClientID` and the user must have `eitUserID` or eIT receives empty ids. The PHP still calls the URL.

Returned `BillingID` is written to `tblUserProjectItem.eitBillingID` when it differs from the previous value.

### BR-019 — An existing eIT id is refreshed on save

**Inferred** from `Timesheet::Save`.

If the row's `eitBillingID` is not 0, save calls `CopyToEit` after the Convergence call. `CopyToEit` expects an array of ids. Save passes the raw id. The function then takes the non-array branch and returns "No rows selected." **Implemented.** This looks like a defect. **Requires Confirmation** before anyone "fixes" it as a requirement.

## Convergence

### BR-020 — Convergence task id is resolved in a fixed order

**Inferred** from `SaveConvergenceTime`.

1. `tblTicketItem.refTaskID` when the ticket item id argument is set and that column is not 0.
2. Else a hardcoded map of ticket item id to task id inside the function. Many entries are commented as NA.
3. Else a hardcoded map of `tblClient.strClient` to a task id. Names in code include RGBC, Sawis, SA Bullion, Pernod Ricard, Silicon, Internal, Ascot, American Shutters, Personal Laundry, Desmond Tutu HIV Centre, Old Mutual Wealth, and Adventure Bootcamp.

If the task id stays 0, nothing is sent.

The HTTP call is GET `type=SaveTime` on the Convergence host. A returned `TimeID` is stored on `tblUserProjectItem.TimeID`.

## QuickBooks and IIF

### BR-021 — QuickBooks lines use amount as unit price and quantity 1

**Inferred** from `timesheet.php` action `QB Invoice`.

Each timesheet row becomes a line with `Qty = 1`, `name = Software`, `UnitPrice` equal to the formatted amount with commas removed, description from invoice notes. Every customer name in the payload is the literal `A Import`, not `tblClient.strClient`. The client name is only the PHP array key.

The browser is redirected to the decoded curl body. Failures are not handled in this case block.

### BR-022 — IIF export groups selected lines by client

**Inferred** from `includes/iifExport.cls.php`.

Selected `UserProjectItemID` values are loaded with client, date, invoice notes, project, rate, and hours. A header row is emitted per client, then one row per time line ordered by date and time in.

## Projects and tickets

### BR-023 — A new project can copy a template's items

**Inferred** from `Project::Save`.

If `radProjectTemplate` is posted and is not `- None -`, project items are inserted from `tblProjectItemTemplate` for that `strProjectTemplate`, copying name and `refRateID`. `INSERT IGNORE` is used.

### BR-024 — Create-project-ticket copies items into an open medium ticket

**Inferred** from `Project::CreateProjectTicket`.

New ticket: same client and project, title and notes from the project, priority `Medium`, status `Open`, assigned date today. Each project item becomes a ticket item with the item's estimated hours and notes, `blnActive = 1`.

Which button calls this method was not confirmed in the page switch that was read. **Requires Confirmation.**

### BR-025 — Ticket status and priority are four and three fixed strings

**Inferred** from `includes/ticket.cls.php` filters.

Status: `Open`, `Busy`, `On Hold`, `Closed`. Priority: `High`, `Medium`, `Low`. The timesheet ticket dropdown hides items on tickets whose `refStatus` is `Closed`, and hides items with `blnCompleted = 1`, except the item already on the line.

Unassigned tickets use `refUserID = -1`.

### BR-026 — Project item breakdown colours high consumption

**Inferred** from `Timesheet::getProjectItemBreakdown`.

Percent spent is summed hours over `dblHoursEst`. At or above 80 the row is styled as a warning. At or above 100 the total styling changes. This is display only.

## Reference data

### BR-027 — Inactive clients and projects leave the active pickers

**Inferred** from filter SQL.

Timesheet client filter, report client filter, and project client filter use `blnActive = 1`. User pickers use `sysUser.blnActive = 1`.

Client and project "delete" sets `blnActive = 0`. The word on the button is still delete (`Client::Delete` returns "Records Deleted.").

### BR-028 — Overview people are a subset of active users

**Inferred** from `Target::getTimeSheetUsers`.

`blnActive = 1 AND blnOverview = 1`. Comment dated 2016-08-30 says the office column and this flag were added then.

### BR-029 — Public holiday match is the calendar date

**Inferred** from `cron.reminder.php` and `PublicHolidays::isPublicHoliday`.

Reminder SQL compares `dtDate` to `Y-m-d`. `isPublicHoliday` has a bug-shaped loop: it fetches `$rst` and then reads `$rstCheck`, which is not set in that function. **Implemented** as written. Callers that need a reliable holiday check should be confirmed. The reminder cron does not use this method. It queries the table itself.

## Password policy

### BR-030 — Stored passwords are salted SHA-256

**Inferred** from `_nemo.password.cls.php`.

`hash("sha256", salt + password)`. Salt is 32 random bytes, hex encoded. Rows live in `sysPassword` (`Type` enum `User`).

Settings read at runtime, with the constructor's first-run seed as the only documented defaults:

| Setting | Seed value in code | Meaning in comments |
|---|---|---|
| `PASSWORD_Enable_Policy` | `1` | 1 = use sysPassword |
| `PASSWORD_Strength_Check` | `true` | JS expression |
| `PASSWORD_MaxAge` | `false` | `false` means no expiry |
| `PASSWORD_MaxHistory` | `8` | previous passwords rejected |
| `PASSWORD_Restricted_Words` | `password\|1234` | substring ban |
| `PASSWORD_MinLength` | `10` | minimum length |

The seed `INSERT` is missing a comma before the `PASSWORD_MinLength` row. First-run installation of an empty database would fail that statement. **Implemented** source text. Live databases that already have `sysPassword` never run it.

When strength check is on, the JS rule requires a lower-case letter, an upper-case letter, a digit, and length at least `PASSWORD_MinLength`. Restricted words are case-sensitive substring checks.

Reset flow rejects a password whose hash matches one of the last `PASSWORD_MaxHistory` rows (`checkLastPasswords` returns false when a match is found, and the caller treats that as "already used").

`checkExpiry` is written against `PASSWORD_MaxAge` but was not seen called from `login.php`.

### BR-031 — Forgot-password hash is dated 2010-01-01

**Inferred** from `SendResetEmail`.

The temporary hash is saved with date `2010-01-01`, commented as "Default Aged date". The email link's `ResetKey` is that hash. `resetPassword.php` accepts the form when the latest hash equals `ResetKey`.

## Mail identity

### BR-032 — Reminder copies are fixed addresses

**Inferred** from `cron.reminder.php`.

Morning CC: `jaco@overdrive.co.za`, `gareth@overdrive.co.za`. After more than two morning counts in the month: also `olivia@overdrive.co.za`.

These are code, not `sysSettings`.

FIM mail goes to `grp-fim@overdrive.co.za` with from `Timesheet <info@timesheet.co.za>`.

## Rules that must not be invented

The following were not established:

- Contractual SLA response times. `tblSLAContracts` stores hours and dates. The SLA report file was not interpreted as a penalty rule.
- Tax, currency, or rounding policy beyond `format(..., 0)` on the timesheet amount column and `FORMAT(dblRate, 2)` on the rate list.
- Who may invoice. The button exists. The special-permission flag's effect is unconfirmed.
- Approval of a timesheet before it can be billed.
- A rule that stops backdated or future-dated entries. No such check was found in `Timesheet::Save`.
