# Product requirements — pernod-ricard-prod

Evidence date: 2026-10-07.

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

Requirements below are recovered from the implementation. Each one is labelled:

- **Documented** — stated in a committed comment, `.env.example`, or on-screen copy.
- **Implemented** — present in code that runs for a user action or a cron script.
- **Inferred** — a reasonable reading of that behaviour, not written down as a requirement.
- **Unknown** — not established.

There is no requirements document in the source repository.

## Product purpose

Suppliers of Pernod Ricard South Africa submit invoices and supporting documents through a portal. Accounts-payable staff review those invoices, send them to JDE for a voucher match, and track payment. The public site root sends the browser to the supplier application.

Provenance: **Inferred** from the two applications, the invoice status values, and the JDE submit and payment-import code. The on-screen name "PRSA Supplier Portal" and the production hosts are **Implemented** (`supplier/index.php`, `index.php`).

## Users and roles

| Actor | How they sign in | What the code allows |
|---|---|---|
| Supplier | Email and password on `supplier/login.php`, then a 6-digit code on `supplier/login2fa.php`. Security group id `10` is inserted for a new supplier user | Own invoices, profile, password and terms gate on first use or when the password is aged |
| Webadmin user | "Sign In with Microsoft" on `webadmin/login.php`, completed in `webadmin/auth.php`. The Azure email must already exist on an active `sysUser` whose security group is active | Screens granted by `sysSecurity` for that group: view, save, delete, new, and a special flag |
| Supplier who opens webadmin | Same email lookup. Group id `10` is refused | Redirect to the supplier portal with "Supplier Access Restricted: Log in through the supplier portal" |
| Cron | `webadmin/cron.daily.php` sets the session user to id `-1`, name `CRON`, security group `1` | Due dates, status changes, JDE payment import, emails, inactive suppliers |

Menu entries and the flags `blnView`, `blnSave`, `blnDelete`, `blnNew`, and `blnSpecial` come from `sysSecurity` joined to the user's security group (`webadmin/_framework/_nemo.cls.php`). The names of groups other than `'Supplier'` are **Unknown**. They live in `sysSecurityGroup` and were not in the repository.

Hardcoded group checks on the invoice screen, from comments in `webadmin/invoice.php`:

- Security group id `<= 3`: show "Pay Invoice" / "Pay Invoices". Comment: "only security groups AP Manager and up".
- Security group id `<= 2`: show "Undo Payment" when status is `Paid`. Comment: "Admin (and up) only".

Provenance: **Implemented** as numeric comparisons. The names "AP Manager" and "Admin" are **Documented** only in those comments. The actual group names in the database are **Unknown / Requires Confirmation**.

When `blnSpecial` is `0`, invoice, supplier, remittance, and report screens limit suppliers with `whereDepartmentHR()` / `checkDepartmentHR()`. A failed check redirects to `message.php` ("You do not have sufficient access to view this department's records"). Provenance: **Implemented**. What `blnSpecial` means to the business is **Inferred** as "not restricted to a department".

## Workflows

### Open the site

1. `.htaccess` redirects HTTP to HTTPS.
2. `index.php` sends production hosts `prsa-supplierportal.co.za` and `www.prsa-supplierportal.co.za` to `https://www.prsa-supplierportal.co.za/supplier/`.
3. Any other host is sent to `{scheme}://{host}/supplier/` with HTTP 302.

Provenance: **Implemented**.

### Supplier sign-in

1. The user posts email and password. The user must be active, and the security group must be active.
2. `NemoPassword::checkPassword("Supplier", ...)` checks the password.
3. On success the app generates a 6-digit code, stores it in `tblEmailAuthCodes`, emails the template "One-Time Password", and opens `login2fa.php`.
4. Verify succeeds when the latest active row matches the email and code and `dtCreatedAt` is still in the future. The code comment says expiry is 5 minutes. The column written is `dtCreatedAt` set to now plus 5 minutes, and the check compares that column to the current time.
5. A matching code creates `$_SESSION["USER"]` and opens `supplier/index.php`.
6. Five recent rows in `sysLogin` of "Login failed: Incorrect password" with no later success call `CheckSecurityLock`. The attempt count `5` is hardcoded in `CheckSecurityLock`.

Failed cases recorded in `sysLogin`: incorrect password, inactive user, user not found or group inactive, email not sent, OTP expired.

"Forgot Password?" posts to `webadmin/ajaxfunctions.php` with `strPasswordType=Supplier` and uses the template "Reset Password".

Provenance: **Implemented**.

### Supplier first login and aged password

`supplier/index.php` loads `blnTCAccepted` and `NemoPassword::checkExpiry`. If terms are not accepted, or the password is aged, the dashboard is replaced by a form. Terms link to `documents/TC/General Terms and Conditions Services.docx`. Saving terms sets `blnTCAccepted = 1` and `dtTCAccepted`. A new password is rejected when it is in the last history window (`PASSWORD_MaxHistory`, seeded as `8` if the settings row is missing).

Provenance: **Implemented**. The live values of `PASSWORD_MaxAge` and related keys are rows in `sysSettings`. The seed defaults in `_nemo.password.cls.php` apply only when that class creates the `sysPassword` settings. Whether production uses those defaults is **Unknown / Requires Confirmation**.

### Webadmin sign-in

1. The visible control is "Sign In with Microsoft".
2. `handleAzureCallback()` uses OpenID Connect against `https://login.microsoftonline.com/{AZURE_TENANT_ID}/v2.0` with scopes `openid`, `email`, `profile`, `offline_access`, `User.Read`.
3. `webadmin/auth.php` reads `preferred_username` or the userinfo email, loads `sysUser`, refuses group `10` and inactive users, stores the session, and encrypts the refresh token.
4. Missing Azure settings redirect to login with "Invalid OAuth configuration. Please contact your administrator." The function `oidc()` also `var_dump`s the client id, secret, tenant, and redirect URL before that redirect when any of them is empty.

The email-and-password `Login` branch remains in `webadmin/login.php` and is not shown. The form markup is inside an HTML comment.

`SSO_OVERRIDE=true` in `.env` allows `?bypass=email` on any webadmin URL (`webadmin/sso_bypass.php`). `.env.example` says this is "Dev/staging only". Provenance: **Documented** in `.env.example`, **Implemented** in `sso_bypass.php`.

Help link on the supplier login (the webadmin copy is inside the commented form): `mailto:helpdesk@xprocure.com?Subject=PR Supplier Portal Query`. Provenance: **Implemented**.

### Capture an invoice (supplier)

`supplier/my.invoice.php` edits `tblInvoice`. The list adds `whereCompanyID()` when `blnSpecial` is `0`, so the supplier sees invoices for `refCompanyID` (set to the supplier id when the user is created). A new invoice requires an uploaded invoice file. Both amount fields must be numeric and not zero (client-side check). The PO number field strips non-digits on blur and must not be `0`.

Documents stored against the invoice: invoice file, purchase order, GR, supplier statement, other file, credit note. The "other" upload is chosen from a list: Credit Note, GR, Purchase Order, Supplier Statement, Other.

Submit requires a purchase-order file. `Invoice::SubmitInvoice` (the webadmin class, called from the supplier page) posts the invoice to JDE. Status becomes `Submitted to JDE`, then either `Successful 100% match in JDE` (response field `PV` is set and not `0`; that value is stored as `strPaymentReferenceNumber`) or `Unsuccessful match` (errors stored in `txtErrors`).

The supplier cannot save or upload when status is `Submitted to JDE`, `Successful 100% match in JDE`, or `Paid`.

Provenance: **Implemented**.

### Process an invoice (webadmin)

`webadmin/invoice.php` is the same record with extra actions:

- Save, unless status is `Submitted to JDE`, `Successful 100% match in JDE`, or `Unsuccessful match` (on an unsuccessful match the user may submit again and may not save).
- Query Invoice: appends the note to `txtQuery` and emails template "Invoice Query".
- Pay Invoice / Pay Invoices: sets `strStatus = 'Paid'`, sets `dtPaymentDate`, emails "Invoice Paid". Paid rows are skipped. Deleted rows are skipped.
- Undo Payment: only the button is limited to group id `<= 2`. The update sets status back to `New` when the row is `Paid` and not deleted.
- Delete: sets `blnDeleted = 1` when status is not `Paid`.
- Export: the list is rendered to Excel.

A deleted invoice hides save, pay, and query, and the toolbar label includes `DELETED`.

Department-restricted users who open another department's invoice are sent to `message.php`.

Provenance: **Implemented**.

### Payments from JDE

`webadmin/cron.daily.php` task 5 calls `ImportJDEPayments`. For each day from yesterday (or a passed start date) through tomorrow it posts a check-date request to `API_JDE_FETCH_JSON`. Rows are kept only when currency is `ZAR` and document company is `430`. They land in `tmpPayment` with status `New`.

`process_TMP` then:

1. Marks staging rows `Warning` when `strPaymentReferenceNumber` does not match an invoice.
2. Inserts into `tblPayment` when the PO, payment reference, payment date, amount, and payment number are not already there, and the reference matches an invoice.
3. Sets an invoice to `Paid` when the rounded invoice amount including VAT plus the rounded sum of its payment amounts is `<= 0`, and the invoice is not already `Paid`.
4. Sets an invoice to `Partial Payment` when the rounded payment sum is not `0` and the same addition is `> 0`, and the invoice is not already `Partial Payment`.

Provenance: **Implemented**. The sign of the payment amount (the sum is added to the invoice amount) is **Inferred** from that SQL: stored payment amounts behave as negatives when a full payment brings the total to zero or below. No comment states that rule in words.

The older CSV/FTP importer `import.payments.CSV.cls.php` is still in the tree. The call in `cron.daily.php` is commented out and replaced by the JSON importer. Provenance: **Implemented** class, **not invoked** by the current cron.

### Daily invoice lifecycle

`cron.daily.php`, after the JDE work in the same request:

1. **Due date.** For invoices with an empty due date, due date is month-end of the invoice date plus the supplier's `intTerms` days. If that day-of-month is 15 or earlier, the due day is forced to the 15th (16th if Sunday, 17th if Saturday). Otherwise it is month-end of that result, plus one day if Sunday or two days if Saturday. Comment in the script: "DueDate = MonthEnd(InvoiceDate) + Supplier.Terms".
2. **Status.** Invoices that are not `Paid` or `Partial Payment` and not deleted become `Due` when `dtDueDate` is on or before a date from `sysSettings` key `DUE_DATE_MODIFIER`, and `Overdue` when on or before `OVERDUE_DATE_MODIFIER`. Saturday and Sunday push that threshold forward by two or one days. A row that meets both conditions is stored as `Overdue` because that assignment runs second.
3. **Mail.** Due invoices are grouped by AP team and would use template "Due Invoices". Overdue invoices email each AP team and, separately, `sysSettings` key `OverdueCC` with template "Overdue Invoices". These sends are inside `if($isDebug === false)`. The committed file sets `$isDebug = true`.

Provenance: **Implemented**. Whether the debug flag is true in the deployed copy is **Unknown / Requires Confirmation**.

### Supplier administration

Saving a new supplier inserts a `sysUser` with `refSecurityGroupID` `10`, company id equal to the new supplier id, and the supplier name and email. The save message asks "New User created. Send Welcome Email now?" Welcome mail uses template "Send Welcome Email" and is sent only when a user is linked to that supplier email.

Provenance: **Implemented** (`webadmin/includes/supplier.cls.php`).

## Integrations

| System | Direction | Trigger | Data |
|---|---|---|---|
| Azure AD | Outbound browser redirect, inbound callback | Webadmin "Sign In with Microsoft" | Email, tokens. Refresh token stored encrypted |
| JDE voucher match | Outbound HTTP | Submit Invoice | Invoice inputs from `GetInvoiceInputs`; response `PV` or error text |
| JDE payment file | Outbound HTTP | Cron task 5 | Check date in; payment rows out, filtered to ZAR and company 430 |
| OAuth token endpoint | Outbound HTTP | Before JDE calls | `API_OAUTH_TOKEN_APIM*` settings. Cached in `apiToken` as vendor `SpiderP`. JDE session token cached as vendor `JDE` |
| SMTP | Outbound | Login OTP, resets, invoice events, cron | Templates named in code, bodies in the database |
| FTP deploy | Bitbucket to host | Push to `master` or `production` | Application tree |

JDE URLs, device name, username, password, environment, and role are `sysSettings` keys (`API_JDE_POST_INVOICE_URL`, `API_JDE_FETCH_JSON`, `API_JDE_DEVICENAME`, `API_JDE_USERNAME`, `API_JDE_PASSWORD`, `API_JDE_ENVIRONMENT`, `API_JDE_ROLE`). A comment next to the environment key says `JPY910 => JUASSA`. The live value is **Unknown**.

Authentication of the OAuth call is **Implemented**. Failure messages returned to the user are "OAUTH authentication failed", "JDE authentication failed", and "JDE Invoice submission failed". Submit is refused with "Please upload Purchase Order before submitting the Invoice" when `strPurchaseOrderFilename` is empty.

## Constraints

- Supplier and webadmin share `DB_HOST`, `DB_USER`, `DB_PASS`, and `DB_NAME`.
- Supplier session lifetime is set to 7200 seconds. Webadmin does not set that value in `system.php`.
- Both `system.php` files set `display_errors` to `1`.
- Uploaded documents are not served as static files from the invoices folder. `.gitignore` ignores `webadmin/invoices/*` except `.htaccess`. Downloads go through `webadmin/download_document.php` and `supplier/download_document.php`.
- Invoice status values declared in a SQL comment inside `invoice.cls.php`: `New`, `Due`, `Overdue`, `Partial Payment`, `Paid`, `Submitted to JDE`, `Unsuccessful match`, `Successful 100% match in JDE`. Default in that statement is `New`.

## Behaviour that needs confirmation

- The names and ordering of security groups, besides id `10` = Supplier.
- The live `sysSettings` values for password policy, due-date modifiers, inactive interval, SMTP, document path, and JDE.
- Whether `cron.daily.php` runs with `$isDebug = true` in production, which would skip the escalation and payment emails in that file.
- Which menu URLs are actually assigned, including whether dated `*20210215*` pages are still linked.
- Whether the database named in an old comment, `pernoricard_2016`, is still the production database name. Current code reads the name from `.env`.
