# Business rules — indawo_84

Evidence date: 2026-10-12. Revision: `main` `6ce3e8d`.

Rules below are taken from code or from in-repo documents. Each rule is marked **Documented**, **Implemented**, or **Inferred**. A rule that the May 2026 technical document states, and that this pass did not re-read in the cron source, stays **Documented** even when the script file exists.

No rule was invented to fill a gap. Gaps are marked **Unknown / Requires Confirmation**.

## Identity and access

BR-001 — Login identity is the user's email (`sysUser.strEmail`), not a separate username field. The security group must be active (`sysSecurityGroup.blnActive = 1`). **Implemented** (`login.php`).

BR-002 — A user with `blnActive` not equal to 1 cannot log in. **Implemented** (`login.php`).

BR-003 — A user with `blnLocked` not equal to 0 cannot log in. Comment in `login.php` dates the lock to 2014-06-23. **Implemented**.

BR-004 — Login requires both a matching password (`NemoPassword::checkPasswordMatch` for entity `User`) and a login PIN equal to `SystemSettings["LoginPIN"]`. **Implemented** (`login.php`).

BR-005 — When `PASSWORD_Expiry_Check` equals the string `true` and `NemoPassword::checkPasswordExpiry` says the password is expired, login fails. The message tells the user to reset. **Implemented** (`login.php`).

BR-006 — Logout clears `$_SESSION["ONLINE"]` and destroys the session. Clearing the MFA remember-device cookie on logout is commented out, so a remembered device can survive logout. **Implemented** (`login.php`). **Inferred** consequence of the comment: the cookie is intentionally kept. Whether that is still wanted is **Requires Confirmation**.

BR-007 — MFA remember-device: if cookie `INDAWO_MFA_REMEMBER_COOKIE` hashes (SHA-256) to an unexpired `tblUserMFARememberedDevices` row for that user, MFA is skipped and `dtUsedAt` is set to now. **Implemented** (`login.php`, `MFATables.sql`). The cookie lifetime of 30 days is described in a comment. The expiry column is `dtExpiresAt`. The 30-day figure is **Documented** in that comment; the writer of the cookie was not re-read.

BR-008 — Menu permission flags are view, new, save, delete, and special, per security group and menu item (`sysSecurity` joined to `sysMenuLevel2`). **Implemented** (`includes/security.group.cls.php`).

BR-009 — A cron script requested over HTTP runs only when the session's security group has `blnView = 1` on a menu row whose URL is that file, or when `odbc=1` and PHP basic-auth user are both set. **Implemented** (`http_cron_gate.php`).

BR-010 — Session company is `$_SESSION["ONLINE"]->OWCOMPANYID`. List filters that call `whereOWC()` include only that company's rows. **Implemented** (`project.functions.inc.php`). Changing company loads `strCompany` and `strFolderPath` from `sysCompany`. **Implemented** (`setOWC`).

## Job suggestions

BR-011 — A new suggestion written by the pre-start agreement path is stored with `intSuggestionStatus = 0`. **Implemented** (`includes/prestart.agreement.cls.php`).

BR-012 — Accept sets `intSuggestionStatus = 1` and `refApprovedByUserID` to the signed-in user's id, then writes `txtSuggestedValue` into `tblForeman.<strFieldName>` for the suggestion's job number and company. **Implemented** (`JobSuggestion::Accept`). The field name is concatenated into SQL. That is **Implemented** behaviour, not an endorsement of the SQL shape.

BR-013 — Decline sets `intSuggestionStatus = 2` and the same approver id. The decline method does not update `tblForeman`. **Implemented** (`JobSuggestion::Decline`).

BR-014 — The approvals list shows an approver name only when `refApprovedByUserID > 0`. The column heading is "Approved / Declined By". The list returns at most 100 rows, newest suggested first. **Implemented** (`Foreman::getJobSuggestionList`).

BR-015 — Money impact:

- Total cost = labour + material + subcontractor, separately for current and suggested.
- Gross profit = contract value (invoice) − total cost.
- GP% = gross profit / contract value × 100. If contract value is 0, GP% is blank (null), not zero.
- GP% change = suggested GP% − current GP% when both exist.
- A money or points change that rounds to less than half a cent (or 0.005 points) displays as zero.
- A negative change is coloured `#c0392b`. A positive change is coloured `#1a7f37`.

**Implemented** (`jobMoneyImpact`, `jobMoneyChangeHtml`). The same colours are used for cost lines and for profit lines. Whether a cost increase should be red is **Inferred** from the colour function; the code does not special-case cost versus profit.

BR-016 — Order budget verdict, given available-with-this-order and the order amount:

- Absolute available under 0.005: "Exactly on budget."
- Available greater than 0: "Within budget" (green).
- Available negative, but available plus the order amount is greater than or equal to 0: "Puts this line over budget" (red).
- Otherwise: "Already over without this order" (red).

**Implemented** (`jobMoneyOrderVerdictHtml`).

BR-017 — Dates `0000-00-00` and `2000-01-01`, blank values, and invalid calendar dates are treated as no date. Day difference is actual/suggested minus the baseline, in UTC whole days. A positive day change (later) is red. A negative day change (earlier) is green. Duration difference uses the same colour rule. **Implemented** (`jobDateNormalize`, `jobDateDayChangeHtml`, `jobDateNowHtml`).

## Orders

BR-018 — An authorisation is pending for sort purposes when admin status is 0 or production status is 0. Those rows sort ahead of decided rows. **Implemented** (`includes/order.authorisation.cls.php`).

BR-019 — Admin status `1` and `2` are distinct branches in the authorisation list renderer. The labels for 0, 1, and 2 were not copied out of the HTML in this pass. **Implemented** that both columns exist. The display words are **Unknown** here; read the status text in `order.authorisation.cls.php` before treating 1 as "approved".

BR-020 — Pending order-authorisation email: the May document says the cron queries authorisations where admin status is 0 or production status is 0, and sends template "Order Authorisation Request" only when the count is not zero. Recipient is setting `EmailOrderAuthorisation`. Company logo uses `sysCompany` id 1. Schedule in that document: 07:00 and 16:00 SAST daily. **Documented**. Script file exists.

BR-021 — Monthly cancel: the May document says cancel runs only when the calendar date is the 15th, for `tblOrder.refOrderStatus = 'New'`, `dtDateRequested` before the first day of the previous month, and `refCompanyID = 1`. Each cancel generates a PDF and emails the supplier. A summary uses template "Order Cancelled List" and setting `EmailOrderCancelled`. A `sysOrderCancelLog` row is inserted when the script starts. **Documented**. Script `cron.daily.ordercancel.php` exists. Extra scripts `cron.daily.ordercancel.1.php`, `.4.php`, `.6.php`, and `.9.php` exist. Whether they apply the same rule to other company ids is **Unknown / Requires Confirmation**.

## Cash and job cost

BR-022 — Cash under/over, from 2026-10-07:

`AmountPaid - ((actual labour + actual material) * 1.3 + actual subcontractor)`.

The previous rule `AmountPaid - (actual labour + actual material + actual subcontractor) * 1.3` is left in a comment labelled original calculation (2026-10-07). The new rule is in both the cash-flow PDF and the sales-person dashboard. **Implemented** (`41f6b52`).

The 1.3 factor is **Implemented**. A written business definition of what 1.3 represents (overhead, VAT, or something else) was not found. The commit message calls it the "week ending overhead rule". **Documented** by that commit message. The business meaning of 1.3 is **Requires Confirmation**.

BR-023 — Gross profit amount in that same block is amount paid minus actual prime (labour + material + subcontractor), and GP% divides that by actual invoiced, or 0 when actual invoiced is 0. **Implemented** in the same functions. This GP% uses 0 when invoiced is 0. Suggestion GP% in BR-015 uses blank when contract value is 0. The two formulas are not the same. **Implemented** difference.

BR-024 — Cash-flow PDFs are produced only when the script's day-of-month is 23 or the weekday is Monday. Other scheduled runs do nothing. The May document states this. **Documented**. Output folders in Git are named `{CompanyID}-{company name}` under `Cash Flow Report/`. **Implemented** as tracked files. `.gitignore` also ignores `'Cash Flow Report'/`. **Implemented** conflict between ignore rule and tracked files.

## Foreman sync

BR-025 — Batch pull deletes and replaces MySQL rows that already have a non-NULL sync key. Local rows whose sync key is NULL are kept. **Documented** (May technical document §4.14 and §5.4).

BR-026 — Cron 3 records a `tblForemanStatusUpdater` row when the staged completion percent differs from the online row. The May document says this cron does not push that percent to SQL Server. A user approval path (`SaveJobStatus` in `ajax.functions.php`) does call `UpdateJobPercComplete()`. **Documented**. Whether anything outside this repository consumes the cron-written queue rows is **Unknown / Requires Confirmation** (the May document already asks that question).

BR-027 — Realtime push entities are only `Client`, `Contact`, `Foreman`, and `Foreman Job Start End Date History`. **Implemented** (`getRealtimeSyncEntityNames`). The June plan text also names costings, open items, and cost codes. Those names are not in the implemented list. **Documented** plan versus **Implemented** list. **Requires Confirmation** if cost lines should be queued the same way.

BR-028 — When `realTimeSync` is false, a normal page does not write to SQL Server. The skip is stored on `sysSyncQueue` with an error note containing `realTimeSync=false`, and morning/evening retry ignore those rows. **Implemented** (`env` comments, `_sync.cls.php`, `includes/realtime_retry.inc.php`).

BR-029 — ODBC dashboard test data must be marked so cleanup can delete it without touching ordinary rows. Cleanup selects pending queue rows tied to that test data. **Implemented** (`includes/odbc_test_cleanup.inc.php`, `README-odbc-dashboard.txt`). The marker string used in the README is "ODBC Test".

BR-030 — Morning catch-up runs after the SQL-to-MySQL pull (file header: end of Cron 5 and of `cron.daily.manuel.php`, before data dumps). It restores pending user edits onto MySQL from `txtAttemptValues`, then pushes if ODBC is up. **Documented** in `cron.morning.realtime-catchup.php`. **Implemented** in `includes/realtime_morning_catchup.inc.php`.

BR-031 — Evening retry covers open unsynced queue items, default last 7 days, with no later success. File header: 20:00 SAST, email `ettiene@overdrive.co.za`. **Implemented** header and `includes/realtime_retry.inc.php`. Crontab presence is **Unknown / Requires Confirmation**.

BR-032 — On the 1st of the month, Cron 1 generates a new login PIN and emails active users when PIN policy is enabled. The May document says the split crons then set `sysSyncLog.blnEmailed = 1`, which suppresses a sync digest. **Documented**.

BR-033 — Manual sync refuses to start when the last `sysSyncLog` note is not `Sync Complete` and less than 15 minutes have passed since `dtLastEdit`. **Documented** (May document §4.13).

BR-034 — Empty asset-movement dates are stored as NULL so MySQL 8 strict mode accepts them. **Implemented** (commit `5534919`). Empty client/contact ratings and empty integers must be SQL NULL, not empty strings, under STRICT. **Implemented** as regression expectations in `tests/ett_odbc_regressions.php`.

BR-035 — Order list date comparisons must not use `0000-00-00` under `NO_ZERO_DATE`. Order edit supplier dropdown must remain valid under `ONLY_FULL_GROUP_BY`. **Implemented** as regression expectations (`tests/ett_odbc_regressions.php`, commits `0b7970e`, `36aea37`).

## Files and documents

BR-036 — A job or enquiry whose first-edit date is missing, invalid, or older than three years is not in the SharePoint era. **Implemented** (`Foreman::isSharePointEra`, cutoff `strtotime("-3 years")`).

BR-037 — If the disk path exists, the folder UI state is `icon`. If it does not exist and the record is SharePoint-era, the state is `create`. Otherwise the state is `archive_alert`. **Implemented** (`Foreman::folderUiState`).

BR-038 — A browser file URL is a SharePoint URL when the record is SharePoint-era or the file exists on disk. Otherwise it is empty (no `filesys.indawo.local` link). Default site `https://indawocape.sharepoint.com/sites/OnlineProjects`, library `Indawo Online`, overridable by `SHAREPOINT_SITE` and `SHAREPOINT_LIBRARY`. **Implemented** (`Foreman::browserFileUrl`, `publicFileUrl`).

BR-039 — Localhost filesystem output uses the project `indawo_filesystem/` directory. Any other host uses `/var/www/html/indawo_filesystem/`. **Implemented** (`getFilesystemBase`).

## Documents that are not current rules

BR-040 — `docs/INDAWO-Online-Technical-Documentation.md` (2026-05-26) says there is no sync queue and no application email on ODBC failure. `main` now queues realtime attempts and has sync-error and evening-retry mail paths. Treat section 1, §5.4, and §6.1 of that document as historical for queue behaviour. The nightly entity list and the Monday/23rd cash-flow gate were not contradicted by the files read in this pass.

BR-041 — `CHANGELOG.md` is not a complete rule log after 2026-02-11.

## Requires confirmation

1. Business meaning of the 1.3 overhead factor, and whether GP% should be blank or zero when the divisor is 0 (BR-015 versus BR-023).
2. Labels for order authorisation statuses 0, 1, and 2.
3. Whether decline (status 2) is the only non-accept terminal state.
4. Whether order cancel for companies other than 1 is still required (the extra cron files).
5. Live crontab, including repeater, Saturday chain, evening retry, and Friday ODBC cleanup.
6. Whether costings and open items should be realtime-queued (plan) or only batch-pulled (code).
7. Whether a remembered MFA device should survive logout.
