# PRD — paymyfines-frontend

Evidence date: 2026-10-07.

Source: `git@bitbucket.org:overdrive/paymyfines-frontend.git`, `master` at `8279c82` (2025-02-27). Full hash `8279c82cf55d1fe86d34ed3538a802506e11d8d2`.

There is no prior PRD in the source repository or in Silicon Ark for this directory. `README.md` is the only product statement in the source tree. Provenance is labelled on each requirement.

Related Silicon Ark directories (`paymyfines-frontend-new`, `pmf-frontend-ah`, `pmf-backend-ah`, `paymyfines-worker`, `paymyfines-pdf-application`) are not referenced by this repository. A shared product is **Unknown / Requires Confirmation**. The home page in this tree is a notice search. The Silicon Ark PRD for `paymyfines-frontend-new` describes a maintenance home page. Those are different implementations.

## Product purpose

**Documented.** Individuals use the application to search, pay, and manage fine information (`README.md`).

**Documented.** The operator in the terms page is TMT Services & Supplies (Pty) Ltd (Reg No: 2000/022850/07), called PayMyFines. The terms say the site lets a person check outstanding traffic fines and pay them online when no warrant of arrest has been issued (`src/components/TermsAndConditions.js`, clause 5.1.3). The terms name the website `https://all.paymyfines.co.za`.

**Documented.** The about page says the platform is for viewing fine history and paying outstanding fines, and points payment security information at `https://iveri.com/payment-gateway/` (`src/components/About.js`).

**Implemented.** The home page (`/`) shows the heading "Pay your fines, and get back to the joy of driving." and a search box labelled "Search by Notice Number".

## Users and roles

Roles are string constants in `src/config/Roles.js`: `SUPER_ADMIN`, `ADMIN`, `MANAGER`, `CUSTOMER`, `GUEST`. The file comment says they are placeholders that can be replaced.

| Actor | How the UI treats them | Provenance |
|---|---|---|
| Guest | Default when `localStorage.roles` is null. Can open home, about, login, register, forgot password, terms, privacy, quick fine search, payment notification, and fine-detail routes whose `permission` array is empty | **Implemented** |
| Customer | Signed-in routes: outstanding fines, profile, transactions, cart. Same list as `MANAGER` | **Implemented** route table |
| Manager | Same customer routes. Not included on `/adminDash` or `/adminSettings` | **Implemented** route table. No screen treats `MANAGER` differently from `CUSTOMER` |
| Admin and super admin | Customer routes plus `/adminDash`, `/adminSettings`, and `/adminAnalytics` | **Implemented**. The UI does not distinguish `ADMIN` from `SUPER_ADMIN` |
| API | Login stores `body.data.strRole` from `GET /user/details` into `localStorage.roles` | **Implemented**. Which role values the API returns is **Unknown / Requires Confirmation** |

`getAllowedRoutes` (`src/utils/index.js`) `JSON.parse`s `localStorage.roles` and keeps a route when the route's `permission` array intersects the parsed value. A missing `permission` or an empty array allows the route for every visitor. Fine detail paths `/aartofinedetails` and `/cpafinedetails` use `permission: []`, so they stay available to guests.

If `strRole` is a plain word such as `CUSTOMER` rather than a JSON array, `JSON.parse` throws. The expected payload shape is **Unknown / Requires Confirmation**.

`isLoggedIn` is true when `localStorage.roles` is present. It does not call Cognito on each navigation. Route visibility is therefore a browser-storage check. **Inferred** from `src/utils/index.js` and `src/routes/Routing.js`.

## Functional requirements

### FR-001 — Home notice search

**Requirement.** Opening `/` shows a banner and a text field. Submitting a non-blank value that passes field type `Text` calls `GET /getfinesearch?Id={userId or GUEST}&noticeNumber={value}`. Hyphens in the typed value are removed before the call.

**Actor.** Any visitor. Logged-out calls use `Id=GUEST`. Logged-in calls use `getUserDetails().data.UserID`.

**Behaviour.**

- A payload string that includes "is incorrect" opens a modal "Invalid fine number".
- iForce value `"Third party did not respond"` or a string containing "No fines found" is reported in a modal. The same applies to eNatis.
- Both sources reporting no fines also shows "Fine not found".
- A payload object key `infringementNoticeNumber` opens `/aartofinedetails?id={number}`.
- A payload object key `noticeNumber` opens `/cpafinedetails?id={number}`.
- `result !== true` shows "There was a problem trying to fetch fines".

**Evidence.** `src/components/LandingPage.js`, route `/` in `src/config/RoutingConfig.js`.

**Status.** Implemented.

**Confidence.** High.

**Note.** The invalid-feedback element reads `errors.noticeNumberValue`. Validation writes `errors.infringementNumberValue`. The inline error text for a blank search is not bound to the field that validation sets.

### FR-002 — Quick fine search

**Requirement.** `/quickfinesearch` looks up a notice via `GET /getfinesearch` and opens AARTO or CPA detail. It is not in the navigation (`canNavigate: false`). Guest is allowed. The home page's link to this route is commented out.

Response strings differ from the home function: "No response from iForce" and "No response from eNatis". If both of those strings are present, the modal is "There was a problem trying to fetch fines" and the function returns. A commented length check (more than 10 and less than 16 characters) is not active. The active check is non-blank plus field type `Text`.

**Evidence.** `src/components/QuickFineSearch.js`, `src/config/RoutingConfig.js`, commented link in `LandingPage.js`.

**Status.** Implemented, hidden from navigation.

**Confidence.** High.

### FR-003 — Outstanding fines

**Requirement.** `/outstandingfines` loads fines for the signed-in user.

**Trigger.** Navigation to the route, and changes to the ID-number filter or issuing-authority filter.

**Inputs.**

- Filter `ALL` sends `userId={UserID}`.
- Another ID sends `strIdNumber` and `strIdType`, split from a `id:type` string stored in `localStorage.IDNumbers`.
- An issuing authority other than `ALL` adds `strIssuingAuthority`.

**Call.** `GET /getallfines`.

**Behaviour.**

- eNatis rows are tagged `api: "eNatis"`. iForce rows come from `payload.iForce[0].fines` and are tagged `api: "iForce"`.
- Strings "Third party did not respond" produce a modal that not all infringements may be available.
- Payload string "No fines found" leaves the list empty without that modal.
- `result === false` shows a retry message that mentions a customer service email address. The component does not name the address.
- HTTP status 400, 500, or 504 stops the spinner. The helper returns `{status}` and does not throw.
- eNatis rows with `paymentNotAllowedCode !== 3` and a non-empty `feesList.debt` sum every `debtAmt` and use the first debt type as status. Otherwise `customerPaymentAllowed` is set false and amount and status are "N/A".
- The table is searchable, sortable, paginated, and exportable. Columns omit `original`.
- Info opens `/aartofinedetails` or `/cpafinedetails` with `priorPage=outstandingfines`.
- Add to cart stores `item.original` in `localStorage` key `cartItems`, keyed by infringement number. A second add of the same key does nothing.
- "Add all" adds only rows whose `customerPaymentAllowed === true`, then opens `/Cart` if the cart object has any keys.
- Filters are written to `localStorage.backFilters` before opening detail.

**Actor.** `SUPER_ADMIN`, `ADMIN`, `MANAGER`, `CUSTOMER`.

**Evidence.** `src/components/OutstandingFines.js`.

**Status.** Implemented.

**Confidence.** High for the UI. The meaning of payment code `3` is **Unknown**.

### FR-004 — AARTO / eNatis detail

**Requirement.** `/aartofinedetails?id={notice}` calls `GET /queryinfringements?Id={userId or GUEST}&noticeNumber={id}`.

**Behaviour.**

- Status 400 or 504 sets an API error flag.
- On success the screen shows notice number, city/town, issuing authority, vehicle licence, ID number, offence date, status, amount, demerit total, and charge descriptions.
- Amount and status come from `feesList.debt[0]` only. An empty debt list sets `customerPaymentAllowed` false and shows "N/A".
- Guests see `*` instead of the ID number.
- Signed-in users see Add to Cart. The button is disabled when the fine is already in the cart or `customerPaymentAllowed === false`.
- A `priorPage` query value renders a back link to that path.

**Evidence.** `src/components/AARTOFineDetails.js`.

**Status.** Implemented. Empty `permission` array, so guests can open the page.

**Confidence.** High.

**Discrepancy.** The outstanding-fines list sums every debt line. This detail screen displays only the first debt amount. The cart, for an AARTO line, sums every debt line again (`Cart.js`).

### FR-005 — CPA / iForce detail

**Requirement.** `/cpafinedetails?id={notice}` calls `GET /getoutstandingfines`. On success it calls `GET /getcharges` and, when `evidence.length > 0`, `GET /evidence` with the first evidence token and `includeImage=true`.

**Behaviour.** The form shows notice, location, district, vehicle, phase, offence date, summons, court name, court date, warrant number (or "N/A"), amount due, charges, and an evidence image when the evidence call returns. Guests see `*` for the ID number. Add to Cart is shown only when `isLoggedIn()` is true and is disabled when the notice is already in the cart or `customerPaymentAllowed === false`.

**Evidence.** `src/components/CPAFineDetails.js`.

**Status.** Implemented.

**Confidence.** High.

**Note.** The screen displays `warrantOfArrestNumber`. It does not compare that field to the terms rule about warrants. Whether the API sets `customerPaymentAllowed` from a warrant is **Unknown**.

### FR-006 — Cart and checkout

**Requirement.** `/Cart` lists `localStorage.cartItems`. On load it calls `GET /getsettings?userId={UserID}` and reads the setting whose `strKey` trims to `convenience`.

**Totals.** Each cart key adds one convenience amount. An AARTO item (key `infringementNoticeNumber`) sums `feesList.debt[].debtAmt`. Any other item uses `totalAmountDue`. The displayed total is fine amounts plus convenience.

**Checkout.** The signed-in user submits a browser form POST to `https://portal.nedsecure.co.za/Lite/Authorise.aspx`.

Before the POST, the UI calls `POST /postiveritransaction` with `striVeriNumber` (a generated id), `userId`, status "Sent to iVeri", amount, `intPaid` "NO", and an `infringements` array. iForce lines are sent as source `IForce`. Other lines are sent as source `Aarto`. The code does not branch on the transaction response before submitting the iVeri form.

Currency is `ZAR`. Line amounts on the iVeri fields `Lite_Order_LineItems_Amount_*` and `Lite_Order_Amount` are the rand amounts times 100. A final line is labelled "Convenience Fee". Return URLs point at `{apiGateway}/iveripayment?transactionID={uuid}&veriLocation={encoded origin/paymentNotification}&status={success|fail|error|trylater}`.

`Lite_Merchant_ApplicationID`, `Lite_Order_Terminal` (`77777001`), `Lite_Version` `4.0`, and protocol `iVeri` are hardcoded in `src/components/Cart.js`.

**Actor.** Signed-in roles. `canNavigate` is false. The cart icon in `TopNav.js` pushes `/Cart`.

**Empty state.** Heading "No Items in Cart".

**Evidence.** `src/components/Cart.js`, `src/aws-config.js`, `src/components/common/TopNav.js`.

**Status.** Implemented.

**Confidence.** High for the client sequence. Whether `/iveripayment` forwards the browser back to `/paymentNotification` is **Unknown** (the handler is not in this repository). A commented URL `https://video.medmin.co.za/iveri.php` is not used.

**Implemented detail.** `Ecom_BillTo_Postal_Name_Last` is set from `strName`, the same field as the first name. `strSurname` is not sent on that field.

### FR-007 — Payment result

**Requirement.** `/paymentNotification` reads query `status`.

| status | Message | Then |
|---|---|---|
| `success` | Payment Successful | Clears `cartItems`. After 9 seconds, navigates to `/` |
| `error` | Payment Unsuccessful | Shows `liteResultDescription`. After 9 seconds, navigates to `/Cart` |
| `fail` | Payment Failed | Same as error |
| `trylater` | Try Again Later | Same as error |

The tick image is the same component for every status. The route allows guests. `canNavigate` is false.

**Evidence.** `src/components/PaymentNotification.js`.

**Status.** Implemented.

**Confidence.** High. The page does not call the Unity API to confirm payment. It trusts the query string.

### FR-008 — Login

**Requirement.** `/login` is available to `GUEST`. Email must pass field type `Email`. Password must be non-blank.

**Behaviour.**

- `Auth.signIn` with email and password.
- `NotAuthorizedException` shows "Incorrect email or password". Other errors show `error.message`.
- If `email_verified` is not true, a modal offers a redirect to `/register?verify={email}`.
- If verified, `GET /user/details?strCognitoUuid={username}`.
- `result === true`: store `strRole` in `localStorage.roles`, store the payload with `useLocalStorage('userDetails')`, store `IDNumbers` as `number:type` strings from the primary ID and `additionalIds`, then go to `/outstandingfines`.
- Payload string "User does not exist": a details modal collects name, surname, cellphone, and ID, then `POST /register`. If that payload is "User Email Already exists", it also `POST /updateregistration` with Cognito uuid and email, then runs login again.

**Evidence.** `src/components/Login.js`.

**Status.** Implemented.

**Confidence.** High.

### FR-009 — Registration

**Requirement.** `/register` is available to `GUEST`. Submit first `POST /recaptcha` with the widget token. The form continues only when `result === true` and `payload === 'true'`. Otherwise the modal is "The recaptcha failed, please try again" and the widget resets.

**Fields.** Email, name, surname, cellphone, ID type, ID, ID confirmation, password, password confirmation. Defaults: ID type `02`. A business checkbox sets type `04` and clearing it sets `02`.

**Validation.** See business rules. Password must be non-blank and match the confirmation. No minimum length is checked in this file. Cognito may still reject a short password. That pool policy is **Unknown**.

**Then.** `Auth.signUp` with username and attribute email. `UsernameExistsException` shows the Cognito message on the email field. A code form calls `Auth.confirmSignUp`. Resend calls `Auth.resendSignUp`.

**Linking.** After confirmation the code signs in, loads `GET /user/details` by Cognito uuid and by email, `POST /register` through `unityRequests`, and `POST /updateregistration` when the email already exists. If `localStorage.asdf` is set, that value is used as the password and the key is removed.

**Evidence.** `src/components/Register.js`, `src/config/google.js`.

**Status.** Implemented.

**Confidence.** High.

### FR-010 — Forgot password

**Requirement.** `/forgot-password` is available to `GUEST`. Email and confirm-email must match and pass field type `Email`.

**Behaviour.** `userExists` calls `Auth.confirmSignUp(username, '000000')` and maps Cognito error codes. The comment cites an Amplify GitHub issue. If the function decides the user exists, it calls `Auth.forgotPassword`, then `Auth.forgotPasswordSubmit` with the code and new password. If it decides the user does not exist, it shows a password form, writes the new password to `localStorage` key `asdf`, and calls `Auth.signUp`.

**Evidence.** `src/components/ForgotPassword.js`.

**Status.** Implemented.

**Confidence.** High for the control flow. Whether storing the password under `asdf` is an approved step is **Unknown / Requires Confirmation**.

### FR-011 — Profile

**Requirement.** `/profile` loads `GET /user/details?userId={UserID}` and lets the signed-in user edit name, surname, email, mobile, primary ID, and additional IDs.

**Save.** Validates the fields, writes `userProfileDetails` and `userDetails` in local storage, sets `txtBillAddress` to the literal `"Home"`, then `POST /update`. If the new password field is non-blank, it calls `Auth.changePassword` with the old password. The new and old passwords must differ, and the old password must be non-blank. The success modal "Profile Details Updated" is shown even when the password call fails; the password error is a separate modal.

Deleting an additional ID updates local state and tells the user to click update to save. It does not call the API by itself.

**Evidence.** `src/components/Profile.js`.

**Status.** Implemented.

**Confidence.** High. The profile route does not re-check the Cognito session before rendering. `getUserDetails()` will throw if `userDetails` is missing.

### FR-012 — Transactions

**Requirement.** `/transactions` calls `GET /transactions?userId={UserID}` and, when an ID filter is not `ALL`, adds `strIdNumber` and `strIdType`. A payload array is shown in an exportable table. Amounts are prefixed with "R ". `dtPaid` is formatted `YYYY/MM/DD`. An info icon opens a detail modal. Several identifier and response columns are omitted from the table (including `strIVeriNumber` and `strVeriResponse`).

**Evidence.** `src/components/Transactions.js`.

**Status.** Implemented.

**Confidence.** High. The modal field list was not re-specified here beyond the omit list in that file.

### FR-013 — Admin dashboard

**Requirement.** `/adminDash` is limited to `SUPER_ADMIN` and `ADMIN`.

**Behaviour.** Default date range is the previous calendar month through today (the code walks the prior month back to the last day of the month before that). It calls `GET /getgraphs?userId=&startDate=&endDate=` and groups rows by `GraphType`: `Table`, `CounterTab`, and a fixed set of chart types. Invalid table `GraphX` JSON is logged and replaced with `{}`. `result !== true` shows "There was an error fetching the graphs!".

It also calls `GET /reports` with `issuingAuthority` (including the value `ALL`), `startSettlementDate`, and `endSettlementDate`. Returned issuing authorities are stored in `localStorage.financeIssuingAuthList` and used as a filter.

**Evidence.** `src/components/AdminDashboard.js`.

**Status.** Implemented.

**Confidence.** High. Graph SQL is executed by the API, not by this repository.

### FR-014 — Admin settings

**Requirement.** `/adminSettings` loads `GET /getsettings` and `GET /getgraphsettings`.

**Behaviour.**

- Saving a text setting requires the string `{code}` in the value. The value is `encodeURI`'d and sent with `POST /postsettings`.
- The convenience fee is sent as an integer on the same path, key `convenience`.
- Graph types offered in the form are hardcoded: Table, CounterTab, AreaGraph, LineMarkGraph, PieGraph, RadarGraph, StackGraph, VerticalBarGraph.
- Create and edit call `POST /postgraphsettings`. Delete sends the same path with `Delete: true`.
- The settings screen uses the TinyMCE wrapper `src/components/common/WYSIWYG.js`.

**Evidence.** `src/components/AdminSettings.js`.

**Status.** Implemented.

**Confidence.** High. Who may change a live convenience fee is a product decision **requiring confirmation**. The route only checks the client role list.

### FR-015 — Admin analytics

**Requirement.** `/adminAnalytics` loads Google `gapi.auth2` and asks the admin to sign in with a Google client id in `src/components/AnalyticsUtils.js` (scope `analytics.readonly` on init; the button scope is `profile email`). After sign-in, `AnalyticsReport.js` requests `https://analyticsreporting.googleapis.com/v4/reports:batchGet` for metric `ga:users` by `ga:date` over the last 10 days. `viewId` is the literal `YOUR_VIEW_ID`.

**Evidence.** `AdminAnalytics.js`, `AnalyticsUtils.js`, `AnalyticsReport.js`, Google platform script in `public/index.html`.

**Status.** Implemented as a screen. The report cannot succeed until a real view id is supplied. That id is **Unknown**.

**Confidence.** High.

### FR-016 — About, terms, privacy, not found

**Requirement.**

- `/about` is static marketing copy. It is in the nav and the footer.
- `/TermsAndConditions` and `/PrivacyPolicy` are static. `canNavigate` is false and `showInFooter` is true, so they appear in the footer, not the top nav.
- Terms stop at clause 5.1.6 and refer to contact clauses 9 and 10, which are not in the file.
- Privacy describes collection, use, cookies, and a POPI paragraph that says personal data is transferred to Amazon Web Services.
- An unknown path renders `NotFound`. The logged-in link target is `/home`. No route is defined for `/home`. The guest link target is `/`.

**Evidence.** `About.js`, `TermsAndConditions.js`, `PrivacyPolicy.js`, `NotFound.js`, `RoutingConfig.js`.

**Status.** Implemented.

**Confidence.** High.

### FR-017 — Sign out

**Requirement.** The top nav, when `isLoggedIn()` is true, calls `Auth.signOut`, removes `localStorage.roles`, and the cart icon navigates to `/Cart`.

**Evidence.** `src/components/common/TopNav.js`.

**Status.** Implemented. Sign-out does not clear `userDetails`, `cartItems`, or `IDNumbers`.

**Confidence.** High.

## Workflows

### Guest looks up a notice

1. Open `/`.
2. Enter a notice number and submit.
3. The app calls `/getfinesearch` with `Id=GUEST`.
4. The app opens AARTO or CPA detail. The ID number is masked. Add to Cart is not shown.

### Customer pays fines

1. Register or log in. Login lands on `/outstandingfines`.
2. Filter by ID or issuing authority if needed.
3. Add payable rows to the cart, or open detail and add one fine.
4. Open `/Cart`. The convenience fee is loaded from settings.
5. Checkout posts the transaction to Unity, then posts the card form to Nedsecure.
6. The gateway is expected to return through `/iveripayment` to `/paymentNotification?status=...`.
7. Success clears the cart and returns home after 9 seconds.

### Admin reviews a date range

1. Sign in as a role the API stored that intersects `ADMIN` or `SUPER_ADMIN`.
2. Open `/adminDash`.
3. Change dates or issuing authority.
4. The UI reloads graphs and the finance report.

## Integrations

| System | Direction | Trigger | Data | Auth in this repo | Failure the UI shows |
|---|---|---|---|---|---|
| Amazon Cognito | Both | Register, login, confirm, forgot password, change password, sign out | Email, password, confirmation code, uuid | User pool and app client in `src/aws-config.js`. `mandatorySignIn: true` | Cognito `error.message`, or "Incorrect email or password" |
| Unity API (API Gateway) | UI to API | Screens listed in FR-001–FR-014 | Fine, user, settings, payment rows | Amplify endpoint name `unity`. No extra header is set in `unityRequests.js` | `{status}` on HTTP error; modals when `result` is false |
| iVeri Nedsecure Lite | Browser POST | Cart checkout | Payer name, email, line items, ZAR amounts in cents | Merchant application id and terminal hardcoded in `Cart.js` | Return query `status` on `/paymentNotification`. The POST itself is a full-page navigation |
| Google reCAPTCHA | Browser widget, then `POST /recaptcha` | Register submit | Site key and token | Site key in `src/config/google.js` | "The recaptcha failed, please try again" |
| Matomo | Browser to `analytics.siliconsoftware.net` | Every page load | Page view, site id 2, domain filter `*.master.d39rr3ejryd4rk.amplifyapp.com` | None in the snippet | Not handled |
| Google Analytics `G-6M1WRZYG8D` | Browser | Every page load | Page view | Measurement id in `public/index.html` | Not handled |
| Google Analytics Reporting | Admin analytics page | After Google sign-in | Users by date, view id placeholder | OAuth client id in `AnalyticsUtils.js` | `console.error` |

eNatis and iForce are not called by URL from this repository. They appear as keys on Unity payloads.

## Constraints

- Single API base URL. The path includes the stage name `development`. No second environment block is in `src/aws-config.js`.
- Cart and profile depend on `localStorage` (`cartItems`, `userDetails`, `roles`, `IDNumbers`).
- Amounts displayed to the user are rands. Amounts sent to iVeri Lite order fields are rands times 100.
- Guest fine search is allowed. Guest checkout is not on the route table.
- The Bitbucket pipeline scans with SonarQube and does not build or deploy.
- Service worker registration is commented as available and the code calls `unregister()` (`src/index.js`).

## Requirements that could not be confirmed

- Live site URL versus the terms URL, the Amplify hostname, and the S3 website in `public/iveri.html`.
- Role strings the API actually returns, and whether `JSON.parse` is the intended read.
- Warrant-of-arrest enforcement. Documented in terms. Not implemented as a field check in the cart.
- Per-card daily transaction limit. Documented in terms clause 5.1.4. Not implemented in this UI.
- Cognito password policy.
- Whether `mandatorySignIn: true` blocks unauthenticated Amplify calls for guests. The guest screens still call the API.
- Backend validation, retries, and idempotency of `/postiveritransaction` and `/iveripayment`.
