# PRD — indawo_65 (Indawo Online)

Evidence date: 2026-10-12.

Source: `git@bitbucket.org:overdrive/indawo_65.git`, branch `INDAWO_84_2026` at `08157c5` (2026-03-24, "f ixed cronjob syncs"). The onboarding issue recorded ancestor `3699fee` (2026-02-17, "order 8.4 upgrades").

There is no prior PRD in Silicon Ark for this repository. `README.md`, `CHANGELOG.md`, `NewKnowledgeBase.md`, and `setup_composer.md` stop at PHP 8.4 setup and null-guard notes (through 2026-02-11). Requirements below are recovered from those files, `system.php`, page scripts, cron scripts, and the SQL files. Provenance is labelled.

Secrets in `system.php` (database passwords and SQL logins for named hosts, and the localhost SQL fallback) are not reproduced here.

This PRD describes `indawo_65` only. The later repository `indawo_84` shares the product name Indawo Online and the Sonar project key, and its Silicon Ark set describes behaviour (realtime sync queue, money impact, ODBC dashboard) that is not in this tree. Whether operators should treat `indawo_84` as the successor is **Unknown / Requires Confirmation**.

## Product purpose

**Documented.** `README.md` titles the project `indawo_online`. `composer.json` describes it as "PHP Indawo Project".

**Implemented.** The runnable product is an authenticated operations console. After login, `index.php` sends the user to `home.php`. Screens cover clients, contacts, jobs, enquiries, orders, invoices, pre-start agreements, budgets, assets, dashboards, reports, users, and settings. Nightly PHP crons copy lookup, client, and Foreman data from SQL Server into MySQL and produce cash-flow PDFs and order emails.

**Inferred.** The product exists so Indawo staff can work against an online MySQL copy of Foreman data and push selected job-field changes back to SQL Server when a suggestion is accepted. The inference comes from the cron headers ("Replicate SQL SERVER DB to MYSQL DB") and `JobSuggestion::Accept`. A product brief that states this purpose was not found.

## Users and roles

There is no role-seed file. Access is a user row plus a security group.

| Actor | Evidence | Status |
|---|---|---|
| Person who is not logged in | `index.php` sends them to `login.php` | **Implemented** |
| User whose email matches `sysUser.strEmail`, whose security group has `blnActive = 1`, whose `blnActive = 1`, and whose `blnLocked = 0` | `login.php` | **Implemented** |
| User who fails the password or login PIN | Login refused, message from `NemoPassword` | **Implemented** |
| User with `blnMFAEnabled = 1` and no valid remember-device cookie | Sent to `two.factor.authentication.verify.php` before the session is completed | **Implemented** |
| User with MFA not enabled and `blnMFARequired` set | Sent to `two.factor.authentication.setup.php` | **Implemented** |
| Signed-in user | Session object `$_SESSION["ONLINE"]` with id, email, security group, company id, company name, folder path | **Implemented** |
| Security group | Menu rights `blnView`, `blnNew`, `blnSave`, `blnDelete`, `blnSpecial` on `sysMenuLevel2` | **Implemented** (`includes/security.group.cls.php`) |
| Administrator using the user list | Can deactivate selected users, or all users the query treats as idle for six months | **Implemented** (`includes/user.cls.php`). Who is allowed to see that button was not traced past the page |
| Cron process | Scripts set the acting name to `CronJob` unless a session username exists | **Implemented**. What OS user runs them is **Unknown** |
| Deploy / scan operator | Bitbucket Pipelines SonarQube step on branch pattern `{master}` | **Implemented** pipeline. No deploy step |

The login form field is `strUsername` (or query `u`). The SQL compares it to `sysUser.strEmail`. Provenance: **Implemented**. The label on the control was not copied out of the HTML.

Self-registration was not found.

## Systems the product talks to

1. The operator's browser.
2. MySQL. Database name `indawo_online` on the host cases that were read, except `silicon-overdrive.co.za`, which uses `sil27_indawo_online` on `cmysql5-5.wadns.net:3306`. A comment there says the database is on the overdrive.co.za domain at WebAfrica.
3. Microsoft SQL Server over ODBC. Live-named hosts use server `CPTSQL02` and database `CLIENTS`. Older cases use `SQL-SERVERR2` and database `ind_INDAWO`, or a FreeTDS DSN. `setup_composer.md` documents a Windows DSN named `CPTSQL02` at `192.168.0.9` port `53950`.
4. SMTP, through `NemoEmail`, using system settings `SMTP Send As` and `SMTP BCC`. The SMTP host is **Unknown** from the files read.
5. TOTP via `robthree/twofactorauth`, issuer "Indawo Online", QR via Endroid. No external MFA vendor.
6. SonarQube from Bitbucket Pipelines. Host and token come from pipeline variables.

No SharePoint, OpenAI, Slack, or payment provider call sites were found in this pass.

## Major workflows

### WF-1 — Sign in

`GET` of `index.php` includes `system.php` and redirects to `home.php` when `$_SESSION["ONLINE"]` is set, otherwise to `login.php`.

`login.php` clears `$_SESSION["ONLINE"]` and destroys the session only when logout is explicit. The line that would clear the MFA remember-device cookie on logout is commented out.

On submit:

1. Load the user by email and require `sysSecurityGroup.blnActive = 1`.
2. Refuse when `blnActive` is not 1. Log "Login failed: Inactive User".
3. Refuse when `blnLocked` is not 0. Log "Login failed: Locked User". Comment dates the lock to 2014-06-23.
4. Require `NemoPassword::checkPasswordMatch` for entity `User` and `SystemSettings["LoginPIN"]` equal to the submitted PIN.
5. When `PASSWORD_Expiry_Check` equals the string `true` and `checkPasswordExpiry` returns expired, refuse and point the user at reset. The expired message is `NemoPassword::$PASSWORD_Expired`.
6. Otherwise evaluate MFA (WF-2). If MFA does not redirect, store session fields and redirect to `index.php`. Log "Login successful" with label "Indawo Online".

A wrong password logs "Login failed: Incorrect username, password or pin." and appends `CheckSecurityLock`. What that function does to the lock counter was not re-read. Provenance: **Implemented**.

### WF-2 — MFA

Remember-device cookie name `INDAWO_MFA_REMEMBER_COOKIE`. Constant `INDAWO_MFA_REMEMBER_DAYS` is 30. Comment in `login.php` says the cookie is set when the user chooses "Remember this device for 30 days".

If the cookie is present, its SHA-256 hash is looked up on `tblUserMFARememberedDevices` for that user. A matching unexpired row sets `dtUsedAt` and skips MFA. A cookie that does not match is cleared.

If `blnMFAEnabled` is 1 and the device was not remembered, the session stores `MFAPENDINGUSERID`, `MFAPENDINGSTARTEDAT`, and `MFANEXT = verify`, then redirects to `two.factor.authentication.verify.php`. The same redirect exists twice in the success branch (before and inside the later `else`). Provenance: **Implemented**.

If MFA is not enabled and `blnMFARequired` is set, `MFANEXT = setup` and the user is sent to `two.factor.authentication.setup.php`.

`MFATables.sql` adds `sysUser.blnMFAEnabled` and `blnMFARequired`, and creates `tblUserMFA` (one row per user), `tblUserMFARecoveryCodes`, and `tblUserMFARememberedDevices`. Whether this script has been applied on every database is **Unknown / Requires Confirmation**.

Commit `1b22361` deletes expired remember-device tokens when MFA is verified. The delete statement was not re-read line by line. Provenance: **Implemented** by that commit being in the branch history.

### WF-3 — Password expiry and reset

Policy keys live in `sysSettings` and are read by `NemoPassword`. The class contains an insert of default rows: strength check on, minimum length 8, expiry check `false`, expiry days 30, history 8, restricted words `password|123`, reset-link interval `60 MINUTE`. Those values are **Implemented** as seed SQL inside the class. The live `sysSettings` values are **Unknown**.

Login applies expiry only when the setting equals the string `true`.

`checkPasswordExpiry` returns expired (1) when `dtPasswordExpiry` is empty. When the object's expiry flag is truthy and today is on or after `dtPasswordExpiry`, it returns expired. Otherwise it returns not expired.

`savePassword`, when the setting equals `true`, computes expiry as 30 days from the password date (comment dated 2026-02-24). The INSERT column list is `Type, UserID, salt, hash, dtPassword` and the VALUES list also appends the expiry date. Column count and value count differ. Whether that insert succeeds is **Unknown / Requires Confirmation**. The 30 days is hardcoded. It does not read `PASSWORD_Expiry_Days`.

New users and resets go through `User::SendResetEmail` (`includes/user.cls.php`). `resetPassword.php` is the reset page.

### WF-4 — Company context

On login, `OWCOMPANYID` is `sysUser.refCompanyID`. Company name and `strFolderPath` come from `sysCompany`.

`getOWC()` returns the session company id. `whereOWC()` adds `refCompanyID =` that id. `setOWC($CompanyID)` replaces the session company, name, and folder path. Provenance: **Implemented** (`project.functions.inc.php`).

Which screens call `setOWC` was not fully listed. Job, order, and report pages that were sampled call `getOWC()` or `whereOWC()`.

### WF-5 — Job suggestion approval

The approvals list reads `tblJobSuggestions`. Status 0 renders a checkbox. Status 1 renders `images/accept.png`. Status 2 renders `images/delete.png`. Boolean fields display as Yes or No. Contact, job type, and planning status ids are replaced with names. The sales-person suggested value is read from `strCstrSalesPersonontact`, which is not a column name used for the current value (`strSalesPerson`). Provenance: **Implemented**. That property name looks like a typo. Whether the suggested sales person displays is **Requires Confirmation**.

`Decline` sets `intSuggestionStatus = 2` and does not update `tblForeman`.

`Accept` sets status 1, then `UPDATE tblForeman SET <strFieldName> = <txtSuggestedValue>` for that job number and company, then `new SyncRecord("Foreman", "ID", $ID, null, 0, "UPDATE")` and `Save(0)`. The field name and value are concatenated into SQL. Provenance: **Implemented**. This is not an endorsement of the SQL shape.

There is no `refApprovedByUserID` write in `Accept` or `Decline`. An "Approved / Declined By" column is not in this list. That column exists in the later `indawo_84` tree, not here.

Pre-start agreement inserts suggestions at `intSuggestionStatus = 0` (`includes/prestart.agreement.cls.php`). One later statement in that file sets a max suggestion id to status 1. The surrounding condition was not fully traced.

### WF-6 — Orders and authorisation

Order screens are `order.php` and `includes/order.cls.php`. Authorisation is `order.authorisation.php`.

Filter labels for admin and production status: `-1` All, `0` Pending, `1` Accept, `2` Declined. A comment in the class says `-1` Not Required, `0` Pending, `1` Accepted, `2` Declined. The outstanding-authorisation email labels `1` as Approved and `2` as Declined, and leaves `0` blank. Provenance: **Implemented** difference in words.

Accept and decline paths call `SendEmailAuthorisation` with `Approved` or `Declined`. CC is setting `EmailOrderAuthorisation`.

`cron.daily.orderauth.php` selects authorisations where admin status is 0 or production status is 0. If the count is not zero, it emails template "Order Authorisation Request" to `EmailOrderAuthorisation`. The company row used for the logo is `sysCompany` id 1. Subject is "Order Authorisations Needed" plus a timestamp computed as current hour plus 2. Provenance: **Implemented**. Why the hour is shifted is **Unknown**.

### WF-7 — Monthly order cancel

`cron.daily.ordercancel.php` runs the cancel body only when the calendar date is the 15th. It selects `tblOrder.refOrderStatus = 'New'`, `dtDateRequested` before the first day of the previous month, and `refCompanyID = 1`. Comment from 2017-02-20 says this cancels orders and emails a list. The PDF and supplier email steps were not re-read past the SELECT.

Companion scripts set `$OrderCancelCompany` to 1, 4, 6, and 9 (`cron.daily.ordercancel.1.php`, `.4.php`, `.6.php`, `.9.php`) and filter on that id. Provenance: **Implemented**. Whether all four are scheduled is **Unknown / Requires Confirmation**.

### WF-8 — Nightly sync

Cron file headers, **Documented** in the scripts and **Implemented** as those files:

| Script | Header says it syncs |
|---|---|
| `Cron.daily.1.Lookups.php` | Client type, sales person, job type, supplier. Also, on the first calendar day of the month when `EnablePINPolicy` equals `True`, generates a 4-character PIN, stores it as `LoginPIN`, and emails template "Login PIN" to every active user |
| `Cron.daily.2.Clients.php` | Client, contact |
| `Cron.daily.3.Foreman.php` | Foreman. Raises `max_execution_time` to unlimited |
| `Cron.daily.4.Foreman.php` | Foreman actual, invoices, receipts |
| `Cron.daily.5.Foreman.php` | Costings, open items, cost codes, job start/end date history |
| `Cron.daily.6.DataDumps.php` | Data dump normal, quote, and full |

The lookup cron comment describes the batch shape: disable foreign keys, truncate and refill a staging table, delete live rows whose sync key is not NULL, insert from staging, run local evals, email a log. Provenance: **Documented** in that comment. Each SQL statement in all six files was not re-read.

`_sync.cls.php` class `SyncRecord` saves, deletes, and updates job percent complete and CM codes on the remote side. `Sync::Execute` runs a batch. There is no queue table in this tree.

`cron.daily.repeater.update.php` prints "This is only a temperory file for cron.daily.repeater.update.php" and stops. Provenance: **Implemented** stub.

### WF-9 — Cash-flow PDF

`cron.CashFlow.php` builds output only when the day of the month is 23 or the weekday is Monday. Comment: "MUST BE MONDAY. BECAUSE IT USES WEEK NUMBER IN NAME."

Cash under/over in that file is `AmountPaid - (ActualPrime * 1.3)`. Provenance: **Implemented**. What 1.3 means (overhead, VAT, or another factor) is **Unknown / Requires Confirmation**.

The sales-person dashboard in `includes/dashboard.users.cls.php` computes cash under/over as cash receipts minus total actual cost. Total actual cost is material + labour + subcontractor + overheads. Overheads are `(material + labour) * (OverheadsActualPercentage / 100)`. That is not the same formula as the cash-flow PDF. Provenance: **Implemented** difference.

### WF-10 — Deactivate idle users

`User::DeactivateUsersOlderThan6Months` selects login rows whose result is "Login successful" or "Login successful (MFA verified)", grouped by user, and keeps groups whose max login is null or older than six months. It sets `blnActive = 0`. The `OR` in the WHERE is not parenthesised with the rest of a user filter. Provenance: **Implemented**. Whether the query matches the comment ("users who have not logged in") is **Requires Confirmation**.

On success the code loads template "User Account Deactivation Notification", then replaces the recipient with `russel@overdrive.co.za` under a comment "for testing", and sets BCC to that address and then to `jacques@overdrive.co.za` (the second assignment replaces the first on the `Bcc` property). The administrator summary uses template "User Account Deactivation Administrator Notification". A comment says the admin email is still up for discussion. `louis.s@Indawo.biz` is commented out. The address used is again the test address. Provenance: **Implemented**. Whether this override is still meant for production is **Requires Confirmation**.

`DeactivateSelectedUsers` deactivates posted user ids with the same mail override.

Templates are inserted by `includes/UserAccountDeactivation.sql`. The user-facing body says the account was deactivated because of no login for six months. That sentence does not match the selected-user path, which does not check six months. Provenance: **Documented** template versus **Implemented** selected-user path.

The user-list toolbar asks for confirmation before the six-month action (`user.php`).

## Functional requirements recovered from the implementation

FR-001 — Anonymous visitors are sent to `login.php`. Signed-in visitors are sent to `home.php`. **Implemented** (`index.php`).

FR-002 — Login identity is the email address. The security group must be active. The user must be active and not locked. **Implemented**.

FR-003 — Login requires the password hash for entity `User` and the current `LoginPIN`. **Implemented**.

FR-004 — On the first day of a month, when `EnablePINPolicy` is `True`, cron 1 replaces `LoginPIN` and emails it to active users. **Implemented**.

FR-005 — When password-expiry checking is the string `true`, an expired or missing expiry date blocks login. **Implemented**. Saving a new expiry date is **Requires Confirmation** because the INSERT lists five columns and six values, and hardcodes 30 days.

FR-006 — MFA can be required, enabled, or skipped for a remembered device for 30 days. **Implemented**.

FR-007 — Menu rights are view, new, save, delete, and special, per security group and menu item. **Implemented**.

FR-008 — Lists that call `whereOWC()` show the session company only. **Implemented**.

FR-009 — A pending job suggestion can be accepted into `tblForeman` and pushed with `SyncRecord`, or declined without writing the job. **Implemented**.

FR-010 — Order authorisation has admin and production statuses. Pending is 0. The outstanding email runs when either status is 0. **Implemented**.

FR-011 — On the 15th, New orders requested before the first of the previous month can be cancelled for company 1, and the companion scripts target companies 1, 4, 6, and 9. **Implemented**. Scheduling is **Unknown**.

FR-012 — Cash-flow PDFs are produced on Monday or the 23rd, with under/over `AmountPaid - (ActualPrime * 1.3)`. **Implemented**.

FR-013 — An operator can deactivate selected users or run the six-month deactivation. Mail currently targets the hardcoded test recipients. **Implemented**.

FR-014 — SonarQube analysis is the only pipeline step, on branches matching `{master}`. **Implemented**.

## Constraints

- PHP 8.4 is required by `composer.json` and `setup_composer.md`.
- Production install instructions say `composer install` only, never `composer update`, and not to commit `vendor/`.
- `.env` must not be committed. `setup_composer.md` says `chmod 640` on production.
- Non-local hosts do not read database passwords from `.env`. They use literals in `system.php`.
- There is no `.htaccess` in this checkout. Cron URLs are not gated by an in-repo Apache rule.
- No PHPUnit suite and no `require-dev` block. The `test/` directory had no files in this checkout.

## Out of scope for this repository's current tree

- Realtime sync queue, morning catch-up, and evening retry (present in `indawo_84`, absent here).
- ODBC connection dashboard.
- Job money-impact and date-difference panels from `indawo_84`.
- A written product roadmap. None was found.
