# Technical documentation — pernod-ricard-prod

Evidence date: 2026-10-07.

Source: `master` at `19e9775` (2026-06-11), `git@bitbucket.org:overdrive/pernod-ricard-prod.git`.

## Architecture

Two PHP applications share one MySQL database and a custom framework whose classes are prefixed `Nemo`.

| Path | Role |
|---|---|
| `index.php` | Host-based redirect to `/supplier/` |
| `supplier/` | Supplier UI. Bootstraps `supplier/system.php` |
| `webadmin/` | Accounts-payable UI and cron scripts. Bootstraps `webadmin/system.php` |
| `supplier/_framework/`, `webadmin/_framework/` | Separate copies of the Nemo framework |
| `vendor/` | Composer dependencies, committed |
| `.env` | Not committed. Loaded by a local `loadEnv()` in each `system.php` |

A page is a PHP script that constructs `Nemo`, `NemoList`, `NemoDetails`, or `NemoReport`, switches on `$Action` (taken from the request by the framework), and calls `Display()`. Layout is `layouts/layout.menu.incl.php`: header, logout, database-driven menu, message, and the page body inside one POST form.

There is no front controller and no route table. Each feature is a script such as `invoice.php` or `my.invoice.php`.

## Languages and frameworks

- PHP. File comments dated 2022-09-15 record a PHP 8 upgrade. `composer.lock` allows PHP 8 for the required libraries. The server version is **Unknown / Requires Confirmation**.
- MySQL through `mysqli`. The database class is `NemoDatabase`.
- JavaScript in the pages is jQuery (a copy under `webadmin/js/` and `supplier/js/`), used for validation and the query panel. No separate front-end build.
- PDF support is the bundled FPDI/FPDF tree under `webadmin/fpdi/`.
- Excel export uses the Nemo report renderer. `phpoffice/phpspreadsheet` is required in Composer. Which export path calls PhpSpreadsheet was not traced past `renderExcel`. **Unknown** whether every export uses that package.
- Azure AD uses `jumbojett/openid-connect-php`.
- `vlucas/phpdotenv` is required and is not what `system.php` calls. `system.php` parses `.env` line by line.

`display_errors` is `1` in both `system.php` files. `error_reporting` is `E_ALL` without notices and warnings. Timezone is `Africa/Johannesburg`.

## Configuration

`.env.example` keys:

| Key | Use |
|---|---|
| `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME` | Shared MySQL connection |
| `DB_SESSIONPATH` | Directory name under each app folder for PHP session files. Empty falls back to `SESSION` |
| `DB_PROTOCOL` | `http` or `https`. The comment says not to use `https://`. The loader accepts a trailing `://` |
| `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_REDIRECT_URL` | Webadmin OpenID Connect. Example redirect is `https://your-host/webadmin/login.php` |
| `OAUTH_TOKEN_ENCRYPTION_KEY` | Optional 32-byte key for refresh tokens. Falls back to `AZURE_CLIENT_SECRET` |
| `SSO_OVERRIDE` | `false` in the example. `true` enables `?bypass=` |

Further configuration is rows in `sysSettings`, loaded into `$SystemSettings` at startup. Keys read by the application include:

- `ADMIN_Document_Path`
- `API_OAUTH_TOKEN_APIM`, `API_OAUTH_TOKEN_APIM_CLIENT_ID`, `API_OAUTH_TOKEN_APIM_SCOPE`, `API_OAUTH_TOKEN_APIM_CLIENT_SCOPE`, `API_OAUTH_TOKEN_APIM_GRANT_TYPE`
- `API_JDE_POST_INVOICE_URL`, `API_JDE_FETCH_JSON`, `API_JDE_DEVICENAME`, `API_JDE_USERNAME`, `API_JDE_PASSWORD`, `API_JDE_ENVIRONMENT`, `API_JDE_ROLE`
- `DUE_DATE_MODIFIER`, `OVERDUE_DATE_MODIFIER`, `DATE_OLDER_THAN_Interval`, `INACTIVE_ACCOUNT_Suspension_Interval`, `OverdueCC`
- `SMTP Send As`, `SMTP BCC`
- `PASSWORD_Enable_Policy`, `PASSWORD_Strength_Check`, `PASSWORD_Strength_Check_MinLength`, `PASSWORD_MaxAge`, `PASSWORD_MaxHistory`, `PASSWORD_Restricted_Words`, `PASSWORD_Reset_Interval`

A comment in `GetJDEToken` shows the JDE token URL pattern `https://pyorchsa.pernod-ricard.com/jderest/orchestrator/COR55ZA0001_VoucherMatchV3` as an example beside `API_JDE_POST_INVOICE_URL`. The value used at runtime is the setting, not that comment.

## Data

No `CREATE TABLE` script for the application tables is in the repository. The password class can create `sysPassword` settings rows. Tables named by SQL in the current (undated) code:

| Table | Role |
|---|---|
| `sysUser` | Users. Email `strEmail`, group `refSecurityGroupID`, company `refCompanyID`, department `strDepartment`, active `blnActive`, terms `blnTCAccepted`, profile path column `Profile:PicturePath` |
| `sysSecurityGroup` | Group name and `blnActive` |
| `sysSecurity` | Per-group, per-menu flags |
| `sysMenuLevel1`, `sysMenuLevel2` | Menu labels and URLs |
| `sysSettings` | Key/value configuration |
| `sysLogin` | Login audit (`strResult`, `strUsername`, `strDateTime`) |
| `sysPassword` | Password history (created by the password class if missing) |
| `tblSupplier` | Supplier, email, account code, terms `intTerms`, AP team |
| `tblAPTeam` | AP team name and email |
| `tblInvoice` | Invoice header, amounts, status, file names, JDE errors, payment reference |
| `tblPayment` | Payments linked to an invoice |
| `tmpPayment` | JDE import staging |
| `tblEmailAuthCodes` | Supplier one-time codes |
| `apiToken` | Cached OAuth (`VendorKey` `SpiderP`) and JDE (`VendorKey` `JDE`) tokens |
| `tblCronLog` | Cron section, info, warning, error |

An old comment in `supplier/my.invoice.php` names the database `pernoricard_2016`. The running name is `DB_NAME`. **Unknown / Requires Confirmation** whether that old name is still used.

Invoice file columns: `strFilename`, `strPurchaseOrderFilename`, `strGRFilename`, `strSupplierStatementFilename`, `strOtherFilename`, `strCreditNoteFilename`.

## Authentication

**Supplier.** `NemoPassword::checkPassword("Supplier", user id, password)`. Session keys: `ID`, `USERNAME`, `EMAIL`, `SECURITYGROUPID`, `PROFILEIMG`, `COMPANYID`, `DEPARTMENT`. Set only after the one-time code matches. Session files are stored under `supplier/{DB_SESSIONPATH}`. `session.gc_maxlifetime` is 7200.

**Webadmin.** OpenID Connect authorization against the tenant v2 endpoint. Scopes include `offline_access`. Refresh tokens are encrypted with `oauthEncryptionKey()` and stored by `saveTokensToDatabase`. Session files are under `webadmin/{DB_SESSIONPATH}`. Group id `10` is rejected after a successful Azure match.

`oidc()` prints the Azure client id, client secret, tenant id, and redirect URL with `var_dump` when any of them is empty, then redirects to the login page. **Implemented** (`webadmin/oauthfunctions.php`).

**SSO bypass.** Implemented only for webadmin, and only when `SSO_OVERRIDE` is enabled. It does not check a password.

## Documents and storage

Uploads are moved into `ADMIN_Document_Path`. The directory is created with mode `0777` if missing. `.gitignore` ignores `webadmin/invoices/*` except an `.htaccess` keep-file.

Downloads:

- Webadmin: `download_document.php?type=invoice|remittance|payment&id=&doc=`. `doc` is a token (`invoice`, `po`, `gr`, `other`, `statement`, `credit`), not a column name.
- Supplier: `supplier/download_document.php`.

Both require a session. Webadmin also blocks group `10`.

## External integrations

**Azure AD.** Browser redirect and callback. User match is by email. No account provision.

**JDE, invoice.** `Invoice::GetOAUTHToken` then `GetJDEToken` then `PostToJDE`. HTTP timeout for the submit request is raised with `set_time_limit(300)`. Expired rows in `apiToken` are deleted before a new token is requested (`dtTokenExpiry` before `$DT`).

**JDE, payments.** `ImportJDEPayments::import_jde_payments` posts a JSON body with `deviceName` and input name `Check/ Itm Date 1`. The response path read is `CSR55ZA0047_F0414PaymentFile.rowset`. Curl retries with a random sleep of 5 to 10 seconds. Failures are written to `tblCronLog` section `JDE Import`.

**Mail.** `NemoEmail` loads a template by name and sends with the SMTP settings. From-address is `SMTP Send As` where the caller sets it.

**Deploy.** `bitbucket-pipelines.yml`:

- Image `python:3.11`.
- Branch `master`: `git ftp push` to `ftp://$FTP_HOST_QA/prsa.overdriveqa.co.za`.
- Branch `production`: `git ftp push` to `ftp://$FTP_HOST_PROD/public_html`.
- Pipeline variables `FTP_USERNAME_QA`, `FTP_PASSWORD_QA`, `FTP_HOST_QA`, and the `PROD` equivalents are not in the repository.

`git ftp init` lines are commented.

## Background jobs

| Script | What it changes |
|---|---|
| `webadmin/cron.daily.php` | Due dates, invoice status, `tmpPayment` / `tblPayment`, invoice paid or partial, `sysUser.blnActive` for inactive suppliers, `tblCronLog`. Emails only when `$isDebug === false`. Committed value is `true`. Sets a fake session user `CRON` / group `1` |
| `webadmin/cron.inactive-users.php` | Sets `blnActive = 0` for supplier users idle more than 180 days, or never logged in and created more than 6 months ago. Logs to `tblCronLog` |
| `webadmin/cron.daily20210215.php` | Older copy. Not included by the current daily script |
| `webadmin/cron.test.php` | Present. Not included by the current daily script |

No crontab, Windows task, or pipeline schedule is in the repository. **Unknown / Requires Confirmation** how these are invoked. The daily script expects to be requested with an empty `$Action` so it falls through the switch into the task block. The only `break` is at the end of that block.

## Security notes from the code

- Credentials are environment variables, not literals in `system.php` (comment 2025-11-11).
- `.env` is gitignored.
- Refresh tokens are encrypted.
- Document download is authenticated and the invoices directory is gitignored.
- `display_errors` is on, and a failed database connection prints `mysqli_connect_error()`.
- Empty Azure configuration is dumped to the response before the redirect.
- Upload directories are chmod `0777`.
- Several SQL statements concatenate ids and email into the query. Some paths use `$xdb->qs()`. The inactive-user cron casts the user id to int. This is a description of the code, not a review finding list.
- `webadmin/test_jde_token_request.php` is a token test script in the web root.

## Development and testing

- No PHPUnit (or other) test suite was found.
- No README.
- Composer is the dependency manifest. `vendor/` is in the tree, so a deploy by `git ftp` includes it.
- Sonar files named by the September 2026 scan (`sonar-project.properties`, `sonarqube_scan.bat`) are not in this revision. Commit messages in the security branch mention SonarQube updates. There is no Sonar step in `bitbucket-pipelines.yml`.
- Local entry: any host other than the two production names redirects to `/supplier/` on that same host (`index.php`, commit `9b9c1b8`).

## Historical copies

Files whose names end in a date (`20210215`, `20210216`, `20210302`, `20210326`) are earlier snapshots kept beside the current scripts. The current invoice, cron, and framework files are the ones without those suffixes. Do not treat both as live unless a `sysMenuLevel2.strUrl` row points at the dated name. Those rows are not in git.
