# PRD — greenfields_web

Evidence date: 2026-10-13. Source: `git@bitbucket.org:overdrive/greenfields_web.git`, `main` at `c8fce1f` (2025-04-11, "base commit").

There is no prior PRD in the repository or in Silicon Ark. `README.md` and `overview.md` are the only product write-ups in the tree. `overview.md` infers features from Composer packages; those inferences are not copied here unless the code also does the thing.

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned** (a comment says the work is not done yet), **Unknown / Requires Confirmation**.

Secrets that are committed in `.env`, `app/Config/Email.php`, `public/deploy.php`, and the commented Africa's Talking helper are not copied into this PRD.

## Product purpose

**Implemented.** Signed-in operators use a CodeIgniter admin shell to process EDI orders and invoices between buyer and seller companies, review faults, and read volume and value reports.

**Documented, and in conflict.** The repository is named greenfields_web. The README calls the project Admin Overdrive Starter. The login title, mail footer, and `composer.json` description call it Transactflow. System settings and `public/deploy.php` still say Label / SAWIS. Which name customers see is **Unknown / Requires Confirmation**.

## Users and roles

Seeded in `app/Database/Seeds/UserRole.php`.

| ID | Role | What the code does with it | Provenance |
|---|---|---|---|
| 1 | Developer | Session company scope is all companies, both type ids 1 and 2, all stores. Menu seeder grants Developer the seeded menus. | **Implemented** (`Welcome::setCompanyUsersAndAdminSessionVars`, `UserAccess` seeder) |
| 2 | Admin | Same all-company session scope as Developer. Comment in that method: "Silicon internal roles". | **Implemented** |
| 3 | Company Admin | Loads companies from `UserModel::getUserCompanies` into `companyAccessIDs` and `companyType`. Store scope is `['all']`. | **Implemented** |
| 4 | Company User | Seeded as a role. `setCompanyUsersAndAdminSessionVars` has no branch for role 4, so those session keys are not set. | **Implemented** gap. What a company user is allowed to see is **Unknown / Requires Confirmation** |

`Home::index` also treats role ids 5 and 7 as special (5 is commented as designer, 7 is grouped with "dev admins"). Those ids are not in the role seeder. The commented SAWIS login maps SAWIS group names onto roles 1, 7, 2, and 5. Provenance: **Implemented** dead branch plus **Planned** only in the sense that the SAWIS method is commented out.

Other actors:

| Actor | Behaviour | Provenance |
|---|---|---|
| Anonymous visitor | Login, forgot-password, reset-password, and 2FA form are outside the login filter. `Welcome::register()` is empty. | **Implemented** |
| Cron caller | `GET cronJobs` is outside both auth filters. On production it requires a query key. Weekends and hours outside 08:00–22:59 exit immediately. | **Implemented** |
| Mail pixel | `GET tracking-pixel` is outside both auth filters. | **Implemented** |
| Slack trigger | `GET slack` is outside both auth filters and calls `Slack::sendMessage`. | **Implemented** |
| API client of `organisations` and `store-api` | Routes exist and are outside both auth filters. The controllers those routes name are not in the tree. | **Implemented** route only |

The Users seeder inserts `admin@overdrive.co.za` as role 1 with `password_hash` of a short numeric literal. Treat that as a local seed, not as a production account, until someone confirms where the seeder has been run.

## Main workflows

### W1 — Sign in

Trigger: `GET /` or `POST GetLogin` (`Welcome::index`). The login filter's except list says `getLogin`.

Inputs: `inputEmail`, `inputPassword`.

Behaviour:

1. Constructor checks the request IP against `BlockedUsersModel::getBlockedIP`. A blocked IP sets session `userBlocked` and returns from the constructor. The model table is `blockedusers`. The migration creates `blocked_users_list`. Those names differ.
2. If `isLoggedIn` is already true, company session vars are set and the browser goes to `home`.
3. Empty email redisplays `common/login`.
4. Inactive user (`active` 0): flash "Your access has been revoked!" and redirect to base URL. Login history is not written on this branch.
5. Unknown user: history status 1, reason "User does not exist", same wrong-id flash as a bad password.
6. `password_verify` failure: history status 1, reason "Wrong Password".
7. Success: `UserModel::check2faEmailConfirmationDate`. If the latest active `email_authentication_codes` row has `status` 1 and `updated_at` is less than 1 day old, the session is created and history status is 2. Otherwise a 6-digit code is stored and mailed with subject "2FA Validation", and the browser goes to `2fa-validation`. The code is included in the mail body.
8. Session keys on success: `username`, `role`, `isLoggedIn`, `userID`, `labelID` (empty string). Then company scope keys from W1b.

`Welcome::confirmCode` checks the posted code against `email_authentication_codes.new_code` for that email and, on match, sets `status` 1. A wrong code writes history status 4. The posted password is verified again before the code check.

Provenance: **Implemented**.

### W1b — Company scope after login

Trigger: successful login or an already-open session hitting `Welcome::index`.

- Role 1 or 2: `companyType` `[1, 2]`, `companyAccessIDs` `['all']`, `companyStoresAccessIDs` `['all']`.
- Role 3: company ids and `type_desc` values from the user's company links. Stores `['all']`.
- Any other role, including seeded role 4: no company session keys are set.

`type_desc` 1 is treated as buyer and 2 as seller in `CronJobs` and `CompanyModel::getCompaniesBuyers` / `getCompaniesSellers`. A comment in `CronJobs::moveExportedOrders` says 3 means buyer and seller. Queries that filter `type_desc = 2` do not include 3.

Provenance: **Implemented**. The meaning of type 3 is **Inferred** from that comment.

### W2 — Password reset

Trigger: `forgot-password`, then `POST sendResetPasswordLink`.

1. Inactive account: revoked flash, redirect. If `$user` is missing, the `active` read runs before the username check (PHP notice / error is possible).
2. Known user: 32-byte hex token stored in `password_reset` (insert or update, `active` 1). Mail contains `reset-password?token=…&email=…`. History status 6. Success flash includes the email address.
3. Unknown user: history status 6 with reason "non-existing user". The flash text is still "Reset password link sent to {email}" but it is `notif_error`. No mail is sent.
4. `checkResetPassword` loads the form only when `password_reset` has that email, token, and `active` 1. No expiry column is read. Invalid token: history status 5.
5. `resetPassword` reads the token and email from the session's previous URL, not from the POST body. Passwords must match (`inputPassword` and `inputPasswordConfirmation`). No length rule is applied in this method. On success, `UserModel::updateUser` is called and `checkPasswordResetToken(..., true)` sets `active` 0.

There is no migration for `password_reset` in this tree.

Provenance: **Implemented**.

### W3 — Menu authorisation

Trigger: any request not in the filter except lists. `app/Config/Filters.php`.

1. `isLoggedIn`: session flag must be true, else redirect to `/`.
2. `isGranted`: first URL segment must match `user_menu.url` for the session role via `UserModel::checkUserAccess`. Unknown segment redirects to `/`. Known segment without access redirects to `blocked`.

Seeded menu URLs (`UserMenu` seeder): `home`, `users`, `menuManagement`, `crudGenerator`, `media`, `system_settings`, `orders`, `company`, `system_users`.

`orders` and `company` are seeded, and views exist under `app/Views/edioverdrive/`, but there is no `Orders` or `Company` controller. Auto-routing is on (`Routes.php` `$routes->setAutoRoute(true)`), so those URLs depend on classes that are not in the repository.

Excepted from both filters (so no login and no menu check): `/`, `getLogin`, `register`, `organisations` and children, `tracking-pixel`, `show404`, `404`, `cronJobs`, `identity-unknown`, `store-api` and children, `2fa-validation`, `confirmCode`, `forgot-password`, `sendResetPasswordLink`, `reset-password`, `resetPassword`, `slack`.

`home`, `blocked`, and `chatGPT/*` skip the menu check and still require login (`chatGPT` is not in the login except list). `GetLogin` is also routed as `POST GetLogin`.

CSRF and honeypot are commented out of the global filter list.

Provenance: **Implemented**.

### W4 — Scheduled EDI run

Trigger: `GET cronJobs`. Optional query: `override=true` (ignore recurrence), `action` (only that cron action name), `key` (required when `CI_ENVIRONMENT` is `production`).

Window: PHP `date('N')` and `date('G')`. Saturday and Sunday exit. Hours before 08:00 or from 23:00 exit. A comment says the window was widened from 17:00 to 23:00. Timezone is set in `BaseController`, which cron instantiates.

The production key is a hardcoded constant on `CronJobs`. The value is not recorded here. Non-production does not check the key. The committed `.env` sets `CI_ENVIRONMENT` to `development`.

Active rows in `cronjob_actions` run in `order` when `recurrence` minutes have passed since the latest `cronjob_log.updated_at` for that function. Recurrence `0` never runs unless `override=true`. The method named in `cronjob_actions.function` must exist on `CronJobs`.

Job methods present:

| Method | What it does | Provenance |
|---|---|---|
| `getAllOrders` | Buyer companies (`type_desc` 1), limit `items_process_total` or 10, then `GetXMLData::index` | **Implemented** |
| `exportAllOrders` | Seller companies (`type_desc` 2), then export. Instantiates `OrdersModel`, which is not in the tree | **Implemented** call; model **missing** |
| `getAllInvoices` | Sellers, then invoice intake path | **Implemented** |
| `exportAllInvoices` | Sellers, then invoice export | **Implemented** |
| `moveExportedOrders` | Sellers, including a comment that type 3 is both buyer and seller | **Implemented** |
| `sendScheduledEmails` | `sent_queue` rows with `processed` 0 and a `send_date` that is due, limit from the job or 10 | **Implemented** |
| `sendApprovalEmails` | Approval mail path. `LabelModel` construction in this file is commented out | **Implemented** with a commented dependency |
| `processFaultFinder` | Calls fault processing | **Implemented** |
| `sendDailyReports` | Builds combined invoice and order HTML tables | **Implemented**. Uses `OrdersModel` |
| `sendDailyReportsToClients` | Client report tables. Sellers `type_desc` 2 | **Implemented** |
| `checkServerLogs` | Server-monitoring log check | **Implemented** |
| `sendCompaniesBillingReport` | Monthly invoice and order billing tables | **Implemented** |

`cronjob_log` rows are written with `created_by` and `updated_by` 9999.

Provenance of the window and the job list: **Implemented**. Which `cronjob_actions` rows exist in a real database is **Unknown / Requires Confirmation**. The migration creates the table and does not seed job names.

### W5 — Pull orders for a company

Trigger: `GetXMLData::index($companyIDs)`. Cron passes buyer company ids. The method default is `[1]`.

Reads `company_transport_methods` joined to `transport_method_options.name`, `active` 1, ordered by `order`.

| `type` name | `method_identifier` | Behaviour |
|---|---|---|
| Folder | `orders` | `processFolderXMLOrders` |
| Folder | `invoices` | `processFolderXMLInvoices` |
| Folder | anything else | Error "Unsupported transport method" |
| API | `orders` and endpoint exactly `https://externalservicesqa.shopriteholdings.co.za/b2bservice/api/VendorOrder` | Shoprite REST via `Rest::processRestRequest` |
| API | `orders` and any other endpoint | SOAP via `Soap::processSoapRequest` (Pick n Pay) |
| API | `invoices` | Empty branch. No work |
| Email | `orders` or `invoices` | Comments say the functions do not exist yet. No work |
| Anything else | | Error result |

Shoprite path: HTTP GET with Basic auth from the method's `api_key` and `api_secret`, header `ContractID` hardcoded in `Rest.php`, header `UIUser` set to `api_key:api_secret`, `Accept: application/xml`, SSL verification off. Body is saved under `writable/company_orders_xml/shoprite/rest-orders/`. Orders whose XML has `orderMessage` are parsed by `getOrderDetailsShoprite`, saved by `saveOrderToDb`, then confirmed with `Rest::processRestOrderConfirmation`.

Pick n Pay path: WSDL defaults to `https://test.pnpportal.co.za/ediservices_test/wsdl/PullService.wsdl` and endpoint `https://uat.pnpportal.co.za/ediServices/pullservice.svc`. If `api_endpoint` equals `https://www.pnpportal.co.za/ediServices/pullservice.svc`, production WSDL and endpoint are used. Peer verification is off. A comment says "always default to QA just incase".

Folder path: files move to a done or error location (`moveFileToDone`, `moveFileToErrors`). Invoice XML parsers present: `getInvoiceDetailsGodrej`, `getInvoiceDetailsNamaqua`. Order parser: `getOrderDetails`.

The transport seeder inserts only the name `Folder`. Names `API` and `Email` are not seeded. A database that only ran that seeder cannot select the API or Email branches.

Provenance: **Implemented**. Email transport and API invoices are **Planned** (comments). Whether production rows use the QA Shoprite URL or another host is **Unknown / Requires Confirmation**.

### W6 — Export invoices

Triggered from cron export methods, which call into Soap, Rest, or Email.

| Generator | Where | Provenance |
|---|---|---|
| `Soap::generateSOAPInvoicePnP` | Pick n Pay SOAP invoice | **Implemented** |
| `Soap::generateSOAPInvoiceM2North` | M2North SOAP invoice | **Implemented** |
| `Rest::generateRESTInvoiceShoprite` | Shoprite REST invoice | **Implemented** |
| `Email::generateEmailXmlInvoicesMakro` | Makro invoice XML by email | **Implemented** |

Sample XML under `writable/invoice_xml_templates/` and `writable/orders_xml_templates/` is named for M2North, Pick n Pay Godrej, and a default TAF orders export. Those files are examples in the tree, not proof of which companies are live.

`OrdersModel` is constructed on the export path and is not in the repository. That path cannot complete on this tree alone.

### W7 — Fault finding

Trigger: `GET faultfinder` and the POST process routes in `Routes.php`, and cron `processFaultFinder`.

`FaultFinder` loads orders, invoices, or products and records `fault_finder_log` (`FaultFinderModel` table).

Seeded fault types (`FaultType` seeder), each with severity and a mail list:

| Name | Severity |
|---|---|
| `gln_not_found` | 3 |
| `bc_not_found` | 3 |
| `min_order_qty` | 1 |
| `min_order_val` | 1 |
| `product_price_fault` | 1 |
| `invoice_price_fault` | 1 |

`checkCalculationFaults` compares invoice line calculations and allows a difference argument (`$diffAllowed`). `checkGlnFound` flags a document GLN that is not found. `isInRangeCheck` tests a value against min and max.

`processOrdersForFaults` and `processProductsForFaults` construct `OrdersModel` and `ProductsModel`, which are not in the tree. Invoice processing uses `InvoicesModel`, which is present (`table` `invoices`).

The fault-type seeder is not called by the Users seeder. Mail-list addresses in the seeder are Overdrive staff addresses.

Provenance: **Implemented**, with missing models on the order and product paths.

### W8 — Reports

Trigger: `Reports` endpoints called from `app/Views/reports/` (daily and monthly order and invoice charts, product chart, BI filters by company, region, and store, uninvoiced orders, unmatched invoices, executive summaries).

`Reports.php` constructs `OrdersModel` and `ProductsModel` throughout. Those classes are not in the tree. `InvoicesModel` is in the tree and is used for invoice queries.

Company filter data is expected from company tables. There is no `Company` controller, so the company maintenance screen seeded as menu `company` has no controller to render it. Views under `app/Views/edioverdrive/company/` exist.

Provenance: **Implemented** controller and views; order and product report methods are blocked by missing classes.

### W9 — Users, roles, and system settings

`Users` creates and updates users and roles and toggles menu, category, and submenu permissions. `SystemUsers` lists company users and can change active flags, role, and company access.

`SystemSettings::index` title is "Label System Settings". It loads cron actions, the `settings` row named `email_schedule`, and `email_template_types`. Email template methods construct `EmailTemplateModel`, which is not in the tree. Other settings actions that are routed: cron CRUD and status, public holidays, email logs, login history, blocked-user active flag, companies selected for fault checks, transport-method active flag, fault type create/update.

Provenance: **Implemented**, except email-template persistence which depends on a missing model. Holiday and email-template tables are only partly covered by migrations (`settings`, `label_email_template`, `sent_queue`).

### W10 — ChatGPT page

`GET chatGPT` renders `projects/chatgpt`. `GET chatGPT/sent` returns JSON from `chatGPTMessage()`. Login is required. Menu grant is not. `OPENAI_API_KEY` in `.env` is commented out. How the helper authenticates when the key is commented is **Unknown / Requires Confirmation** until that helper is configured in a running environment.

Provenance: **Implemented** page. Live OpenAI calls depend on configuration that is commented in the committed `.env`.

## Functional requirements

| ID | Requirement | Provenance |
|---|---|---|
| FR-001 | The operator signs in with email and password. Inactive accounts are rejected. Unknown user and bad password share one error message. | **Implemented** |
| FR-002 | After a successful password check, a new email code is required when there is no active code, the code status is not 1, or the code is at least 1 day old. The code is a 6-digit integer mailed to the username. | **Implemented** |
| FR-003 | Developer and Admin sessions see all companies. Company Admin sessions see linked companies. Company User sessions do not get company scope keys. | **Implemented** |
| FR-004 | A request whose first segment is not a menu URL for that role is redirected home or to `blocked`. | **Implemented** |
| FR-005 | Self-registration through `Welcome::register` does nothing. | **Implemented** |
| FR-006 | Password reset emails a token link and accepts a matching password pair. The token has no time limit in `checkPasswordResetToken`. | **Implemented** |
| FR-007 | Cron HTTP runs only on weekdays between 08:00 and 22:59, and only production requires the key. | **Implemented** |
| FR-008 | Order intake follows each active company transport method: folder XML, Shoprite REST when the endpoint matches the QA Shoprite URL, otherwise SOAP. | **Implemented** |
| FR-009 | Email transport and API invoice intake do not process documents. | **Planned** (comments) |
| FR-010 | Invoice export generators exist for Pick n Pay SOAP, M2North SOAP, Shoprite REST, and Makro email XML. | **Implemented** |
| FR-011 | Fault types cover missing GLN, missing barcode, minimum order quantity, minimum order value, product price, and invoice price, plus calculation checks. | **Implemented** in code and seeder. Seeder is not part of the default Users seed |
| FR-012 | Report screens request order, invoice, and product aggregates. Order and product model classes are absent. | **Implemented** views and controller; models missing |
| FR-013 | Outbound mail is sent as Transactflow Notifications from `notifications@transactflow.io` through `smtp.transactflow.io` port 465 SSL. BCC depends on whether `base_url()` contains `transactflow.io`. | **Implemented** |
| FR-014 | `GET cronJobs`, `GET slack`, `GET tracking-pixel`, and the organisation and store-api routes do not require a logged-in session. | **Implemented** |

## Integrations

| System | Direction | Trigger | Data | Auth as coded | Failure |
|---|---|---|---|---|---|
| MySQL `adminoverdrivev3` | App reads and writes | Every request via `BaseController` | Users, menus, EDI documents, cron logs | `.env`: host `localhost`, user `root`, empty password | Query exceptions surface as PHP errors. `DBDebug` is on when environment is not production |
| Pick n Pay EDI SOAP | Outbound pull and invoice push | Transport type API when the endpoint is not the Shoprite QA URL | Orders and invoices | Key and secret on the company transport row. WSDL selection as in W5 | Exceptions become error result rows. SSL verify off |
| Shoprite B2B REST | Outbound pull, confirm, invoice | Endpoint equals the QA VendorOrder URL | XML orders | Basic auth plus hardcoded `ContractID` | Non-200 is echoed. Exceptions return an error string. SSL verify off |
| Transactflow SMTP | Outbound mail | `sendMail` | HTML body, optional attachments | Username and password hardcoded in `app/Config/Email.php` | `$email->send()` result is returned. Failures are not given a user-facing map in the helper |
| Slack | Outbound | `sendMail` except types `internalReport`, `externalReport`, `invoiceExport`; also `public/deploy.php` and `Slack` controller | Subject and recipient text | Webhook URL is passed into `sendSlackMessage`. `deploy.php` contains its own webhook | SSL verify off. Deploy posts even when documenting a bad token or IP |
| OpenAI | Outbound | ChatGPT sent action | Prompt text | `OPENAI_API_KEY` commented in `.env` | **Unknown** without a running key |
| SonarQube | CI only | Push to `main` | Source scan | `SONAR_HOST_URL` and `SONAR_TOKEN` from Bitbucket variables | Pipeline has no other step |
| SAWIS database | None active | Commented method | Would have read `sysUser` / `sysPassword` | Commented `sawisAPIQa` connection | Not active |

Salesforce (`salesforce_helper.php`) and Africa's Talking (`africastalking_helper.php`) do not perform calls. The Africa's Talking file is commented and contains a sandbox credential. That credential is not copied here.

## Important constraints

- PHP `date` and the cron window use `Africa/Johannesburg` only after the controller constructor runs.
- Auto-routing is on, so public controller methods are URL-addressable when filters allow them.
- CSRF is disabled in the global filters.
- `public/info.php` calls `phpinfo()`.
- `public/deploy.php` runs `git pull` when a query token matches and the client IP is in a hardcoded list. The token, a Slack bot token, and a webhook are in that file. They are not copied here. The script label is `SAWIS: Label`. The pipeline does not call this file.
- `.env` is tracked. It contains `encryption.key`, `CI_ENVIRONMENT=development`, and `CI_DEVELOPER=jaco`.
- Session files under `writable/session/` are tracked.
- `password_reset` and the EDI tables have no migrations in this repository. A migrate-only database cannot run W4–W8.
- Buyer vs seller is an integer `type_desc`, not a separate product.

## Behaviour that needs a human decision

1. Is this repository the Transactflow EDI console, an Admin Overdrive starter, a SAWIS Label deploy, or a Greenfields-named checkout of one of those?
2. Is `c8fce1f` what any environment runs, or a one-day import?
3. Should the missing `Orders`, `Company`, `Products`, `OrdersModel`, `ProductsModel`, and `EmailTemplateModel` classes be treated as a defect or as an intentional trim?
4. Are the QA Shoprite and Pick n Pay endpoints the intended defaults?
5. Should the seeded Developer account and the committed `.env` / SMTP / deploy secrets be rotated? This PRD does not confirm that they are used outside the repository.
