# PRD — pmf-frontend-ah (PayMyFines)

Evidence date: 2026-10-06. Source: `git@bitbucket.org:overdrive/pmf-frontend-ah.git`, `master` at `3e8d2a5` (2022-05-06).

There is no prior PRD in the repository or in Silicon Ark. Requirements below were recovered from the React app, static copy, and GitHub Actions. Provenance is marked on each item.

The Unity API, eNatis, iForce, Cognito, and iVeri are external. This PRD states what the browser does with their responses. It does not specify those systems' own rules except where a response string is handled in this UI.

## Product purpose

Individuals search, view, and pay traffic fines, and manage the identity details used to find those fines.

**Documented** in `README.md`: "An application used by individuals to search, pay and manage their fine information."

**Documented** in `TermsAndConditions.js` clause 5.1.3: the website lets a person check outstanding traffic fines and pay them, provided no warrant of arrest has been issued.

**Documented** on the home banner (`LandingPage.js`): "Pay your fines, and get back to the joy of driving."

**Implemented** operators and contacts:

- Legal name in the terms: TMT Services & Supplies (Pty) Ltd, Reg No `2000/022850/07`, called PayMyFines.
- Website named in the terms: `https://all.paymyfines.co.za`.
- Footer (`common/Footer.js`): payment enquiries `payments@paymyfines.co.za`, other enquiries `help@paymyfines.co.za`, postal address PO Box 234, Century City, Cape Town, 7446.

Whether that website URL is this build is **Unknown / Requires Confirmation**.

## Users and roles

Roles are string constants in `src/config/Roles.js`. The file comment says they are placeholders. The UI does not assign roles. Login stores `payload.data.strRole` from `GET /user/details`.

| Role | What the route table allows | Evidence | Provenance |
|---|---|---|---|
| GUEST | Home, About, Login, Register, Forgot password, quick search, fine detail, payment notification, terms, privacy. No cart, profile, outstanding fines, or admin | `RoutingConfig.js`. Missing `localStorage.roles` is treated as `[GUEST]` | Implemented |
| CUSTOMER | Guest public pages plus Outstanding Fines, Profile, Transactions, Cart | Same | Implemented |
| MANAGER | Same routes as CUSTOMER. No screen sets this role | `Roles.js`, `RoutingConfig.js` | Implemented in the route table. How a user becomes MANAGER is **Unknown** |
| ADMIN | Customer routes plus Admin dashboard and Settings | `RoutingConfig.js` | Implemented |
| SUPER_ADMIN | Same routes as ADMIN | Same | Implemented. Difference from ADMIN is **Unknown** |

`isLoggedIn()` in `src/utils/index.js` is true when `localStorage.roles` is set. It does not read the Cognito session.

**Requires confirmation.** Login writes `strRole` with `localStorage.setItem('roles', body.data.strRole)` (a raw string). `getAllowedRoutes` then `JSON.parse`s that value and intersects it with each route's permission array. Routing works only if `strRole` is a JSON array string such as `["CUSTOMER"]`. A plain role name would throw. The API shape was not in this repository.

Fine-detail routes use `permission: []`. An empty array is treated as "show to everyone", including guests.

## Main workflows

### W1 — Search a notice number from the home page

Trigger: visitor submits the home search, or clicks "FIND YOUR FINES".

Inputs: notice number. Hyphens are stripped before the request. The value must be non-blank and pass `validateFieldType(..., "Text")`.

Behaviour (`LandingPage.js`):

1. Caller id is the signed-in `UserID`, or the literal `GUEST`.
2. `GET /getfinesearch?Id={id}&noticeNumber={number}` on the `unity` API.
3. If `payload` is a string containing `is incorrect`, show "Invalid fine number".
4. Otherwise read `payload.eNatis` and `payload.iForce`.
5. Strings `Third party did not respond` and `No fines found` become popup text. If both sources report no fines, also show "Fine not found".
6. If the chosen object has `infringementNoticeNumber`, go to `/aartofinedetails?id={number}`. If it has `noticeNumber`, go to `/cpafinedetails?id={number}`.
7. When both sources return objects, the eNatis object replaces the iForce object. **Inferred** from the assignment order. Which source should win is **Unknown / Requires Confirmation**.
8. Any other `result !== true` shows "There was a problem trying to fetch fines".

Provenance: **Implemented**.

`QuickFineSearch.js` (`/quickfinesearch`) does the same request with slightly different failure strings (`No response from iForce` / `No response from eNatis`). The route has `canNavigate: false` and `showInFooter: false`. The home page's link to it is commented out. Provenance: **Implemented**, not linked from navigation.

### W2 — Read a fine without paying

Trigger: navigation to `/aartofinedetails` or `/cpafinedetails` with query `id`. Optional `priorPage` shows a back link.

AARTO (`AARTOFineDetails.js`):

- `GET /queryinfringements?Id={userId|GUEST}&noticeNumber={id}`.
- HTTP 504 or 400 sets an API error state.
- Displays notice number, location city/town, issuing authority, vehicle licence, status and amount from the first `feesList.debt` row, date, charges, and summed demerit points.
- If `feesList.debt` is empty, sets `customerPaymentAllowed` false and shows status and amount `N/A`.
- ID number is shown to a signed-in user and replaced with `*` for a guest.
- Add to Cart is rendered only when `isLoggedIn()` is true, and is disabled when the fine is already in the cart or `customerPaymentAllowed === false`.

CPA (`CPAFineDetails.js`):

- `GET /getoutstandingfines?Id={userId|GUEST}&noticeNumber={id}`.
- Then `GET /getcharges?Id=...&noticeNumber=...`.
- If the fine has `evidence[0].evidenceToken`, `GET /evidence?Id=...&evidenceToken=...&includeImage=true`.
- If `paymentNotAllowedCode === 3`, the amount shown is the literal `NAG` instead of `R {totalAmountDue}`.
- Add to Cart follows the same signed-in rule as AARTO.

Provenance: **Implemented**. What `NAG` and code `3` mean in the source system is **Unknown / Requires Confirmation**. The terms' "warrant of arrest" rule is **Documented** and is not named in this code.

`handleOutsideCheckout` exists on the AARTO screen and pushes `/Cart`, but the render path that was read shows only "Add to Cart" for signed-in users. No guest checkout button was found.

### W3 — Register and confirm email

Trigger: guest opens `/register`, or login sends them to `/register?verify={email}` because Cognito `email_verified` is not true.

Inputs (`Register.js`): email, name, surname, cellphone, ID number and confirmation, ID type (default `02`), password and confirmation, reCAPTCHA token. A "Business Account" checkbox sets ID type `04`.

Client validation (`validateFieldType` in `src/utils/index.js`):

| Field type | Rule |
|---|---|
| `02` South African ID | 13-digit shape `YYMMDD` plus a Luhn-style check digit (`mod10CheckDigit`) |
| `03` Foreign ID | Always valid if non-blank |
| `04` Business registration | Always valid if non-blank |
| Name | Unicode letters, spaces, and `. ’ ' \`` `-` |
| Phone | Optional leading `+`, then 10 to 13 digits |
| Email | Standard-style pattern, lower case |
| Text | Letters, digits, spaces, and a fixed punctuation set |

Password rules in this UI: non-blank and both entries match. Cognito's own password policy is **Unknown**.

Steps:

1. `POST /recaptcha` with `{recaptcha}`. Continue only when `result === true` and `payload === 'true'`. Otherwise reset the widget and show "The recaptcha failed, please try again".
2. `Auth.signUp` with username and email attribute equal to the email.
3. User enters the emailed code. `Auth.confirmSignUp`. If Cognito says the user is already `CONFIRMED`, continue anyway.
4. `Auth.signIn`, then `GET /user/details` by Cognito uuid and by email.
5. If the email lookup payload is `User does not exist`, `POST /register` with name, surname, email, ID type, ID number, mobile, and Cognito uuid.
6. Otherwise `POST /updateregistration` with Cognito uuid and email.
7. Resend uses `Auth.resendSignUp`.

`isMultifactor` is hardcoded `true` with the comment "If coginto has multifactor-auth enabled, set this to true". No second factor screen was found beyond the email code. Provenance: **Implemented**. Whether Cognito MFA is actually on is **Unknown**.

The register submit line tells the user that clicking Submit agrees to Paymyfines.co.za Terms and Conditions and Privacy Policy. There is no separate checkbox.

Provenance: **Implemented**.

### W4 — Sign in

Trigger: guest submits `/login`.

Inputs: email (required, email pattern) and password (required, no strength rule here).

Steps (`Login.js`):

1. `GET /user/details?strEmail={email}` before Cognito sign-in. The result is kept for the error branch.
2. `Auth.signIn`.
3. If `email_verified` is not true, a modal offers to open `/register?verify={email}`.
4. If verified, `GET /user/details?strCognitoUuid={username}`.
5. On `result === true`, store `userDetails`, set `roles` to `strRole`, and store `IDNumbers` as `id:type` strings for the primary ID and `additionalIds`. Then go to `/outstandingfines`.
6. If payload is `User does not exist`, a modal collects name, surname, cellphone, ID type (default `02`), and ID number, then `POST /register`. If that payload is `User Email Already exists`, `POST /updateregistration`, then the login submit runs again.

Errors:

- `NotAuthorizedException`: "Incorrect email or password".
- If `user/details` by email has `dtLastEdit === '1/1/0001 12:00:00 AM'`: "If this is your first time on the new site please reset your password by choosing the forgot password option".
- Other Amplify errors show `error.message`.
- Missing `userInfoResp`: "Oops something went wrong, Please try again later."

Provenance: **Implemented**. The sentinel date is the signal this UI uses for a migrated account. The migration rule itself lives outside this repo.

### W5 — Reset a password

Trigger: guest opens `/forgot-password`.

Inputs: email and confirm email, must match and pass the email pattern.

`ForgotPassword.js` calls `Auth.confirmSignUp(email, '000000')` only to classify the Cognito error:

| Cognito error | Flag returned | What the click handler does |
|---|---|---|
| `UserNotFoundException` | `true` | `Auth.forgotPassword`, then the code form |
| `NotAuthorizedException`, `AliasExistsException`, `CodeMismatchException`, `ExpiredCodeException`, and the default | `false` | Skip the reset email and show a new-password form |

The new-password form writes the password to `localStorage` key `asdf`, calls `Auth.signUp`, and sends the browser to `/register?verify={email}`. `Register.js` `linkAccount` reads and deletes `asdf` so it can `Auth.signIn` after confirmation.

The code form calls `Auth.forgotPasswordSubmit` (later in the same file).

Provenance: **Implemented**. The branch that treats an existing Cognito user as a sign-up, and a missing user as a reset, reads as inverted relative to the function name `userExists`. **Requires confirmation** before treating it as an approved migration rule. Storing the password in `localStorage` is **Implemented** behaviour, not a documented requirement.

### W6 — List outstanding fines and add them to the cart

Trigger: signed-in user opens `/outstandingfines`. Login lands here. Guests are not given this route.

Request (`OutstandingFines.js`): `GET /getallfines` with either `userId={UserID}` (ID filter `ALL`) or `strIdNumber` and `strIdType` from the `id:type` filter. Optional `strIssuingAuthority` when not `ALL`.

Normalisation:

- eNatis rows are tagged `api: "eNatis"`. iForce rows come from `payload.iForce[0].fines` and are tagged `api: "iForce"`.
- Missing ID becomes `N/A`. Dates are `YYYY/MM/DD` (first 10 characters, hyphens replaced).
- iForce: amount is `totalAmountDue`, status is `phase`, demerit points are `N/A`, authority is `districtName`.
- eNatis: if `paymentNotAllowedCode !== 3` and `feesList.debt` is non-empty, status is the first debt type and amount is the sum of `debtAmt`. Otherwise `customerPaymentAllowed` is set false and status and amount are `N/A`. Demerit points are the sum of `charge[].demeritPoints`.
- `Third party did not respond`, undefined payloads, or an empty iForce array produce a popup that not all infringements may be available.
- `result === false` or a thrown error: "An error occurred while attempting to obtain the fine information from the source system..." and tells the user to retry or use the customer service email. The screen does not name that email; the footer does.

Add to cart copies `item.original` into `localStorage` `cartItems`, keyed by infringement number. The control is disabled when the key exists or `customerPaymentAllowed === false`. A bulk add includes only rows with `customerPaymentAllowed === true`.

Filters (ID and issuing authority) and the table search string are stored in `localStorage` `backFilters`.

Provenance: **Implemented**.

### W7 — Pay the cart

Trigger: signed-in user opens `/Cart` and checks out. The route is not in the guest allow-list. The nav cart icon calls `history.push('/Cart')`.

Load: `GET /getsettings?userId={UserID}`. The row whose `strKey` trims to `convenience` supplies the per-line fee.

Totals (`Cart.js` `total`):

- AARTO line (object has `infringementNoticeNumber`): sum of `feesList.debt[].debtAmt`.
- Other line: `totalAmountDue`.
- Convenience = (number of cart keys) × fee.
- Cart total = fine total + convenience.

Checkout:

1. Build a client UUID (`generateUUID` in `src/utils/index.js`; if the first character is a digit it is prefixed with `a`).
2. `POST /postiveritransaction` with `striVeriNumber`, `userId`, `strStatus: "Sent to iVeri"`, `strAmount` = cart total, `intPaid: "NO"`, and an `infringements` array.
3. The code does not check `transactionapi.result` before continuing.
4. POST a hidden form to `https://portal.nedsecure.co.za/Lite/Authorise.aspx`.

iVeri fields that are fixed in code: currency `ZAR`, `Lite_Version` `4.0`, `Lite_Merchant_ApplicationID` `{4C03BE1B-05AF-48BF-972B-00DAE5F11AD9}`, `Lite_Order_Terminal` `77777001`, `Ecom_Payment_Card_Protocols` `iVeri`, colours `#ffffff` / `#1C4231`. Bill-to first and last name are both `strName`. Success, fail, error, and try-later URLs are `{apiGateway}/iveripayment?transactionID={uuid}&veriLocation={encoded origin/paymentNotification}&status=...`.

Each fine is an order line in rands, with the iVeri line amount in cents (`amount * 100`). One extra line is `Convenience Fee` for the convenience total. `Lite_Order_Amount` is the cart total in cents.

Infringement mapping:

| | iForce (`fine.api === "iForce"`) | else, labelled `Aarto` |
|---|---|---|
| Source string | `IForce` | `Aarto` |
| Number | `noticeNumber` | `infringementNoticeNumber` |
| Amount sent on the infringement | `totalAmountDue` | `payment.amount` |
| Date | `offenceDate` | `date` |
| Authority | `issuingAuthorityCode` | `location.issAuthority.description` |
| Vehicle | `vehicleRegistrationNumber` | `vehicle.licenseNumber` |
| Demerit | `N/A` | `charge[0].demeritPoints` |
| Convenience on the row | the per-line fee | the per-line fee |

**Discrepancy (implemented).** The on-screen AARTO amount and the iVeri product line sum every `feesList.debt` entry. The infringement record sent to `/postiveritransaction` uses `fine.payment.amount`. Those amounts are not shown to be the same. **Requires confirmation.**

Empty cart copy: "No Items in Cart". Remove deletes the localStorage key. There is no server-side cart.

Provenance: **Implemented**.

### W8 — Return from iVeri

Trigger: browser opens `/paymentNotification?status=...`. The route is allowed for every role, including guest. `PaymentNotification.js` does not call the API. `unityRequests` is imported and unused.

| `status` | Message | Then |
|---|---|---|
| `success` | Payment Successful | Clear `cartItems`. After 9 seconds go to `/` |
| `error` | Payment Unsuccessful | Show `liteResultDescription` if present. After 9 seconds go to `/Cart` |
| `fail` | Payment Failed | Same as error |
| `trylater` | Try Again Later | Same as error |
| anything else | blank status | Treated like a non-success (description shown, redirect to cart) |

Provenance: **Implemented**. Updating the Unity transaction from "Sent to iVeri" to paid is not done on this screen. **Unknown** whether `/iveripayment` does that before the redirect.

`public/iveri.html` redirects to `http://pay-my-fines.s3-website-eu-west-1.amazonaws.com/PaymentNotification` plus the query string. `documentation/iveri.php` redirects to `{veriLocation}/PaymentNotification` plus the query string. Checkout does not post to either file.

### W9 — Profile and extra ID numbers

Trigger: signed-in user opens `/profile`.

Load: `GET /user/details?userId={UserID}`. Fields shown: ID type, ID number, name, surname, email, mobile. ID type `04` checks the business-account control. `additionalIds` fill a table.

`maxNumberIds` on the payload is coerced from the strings `True`/`False` to booleans and then not stored or read again. Provenance: **Implemented** as a no-op. Any cap on extra IDs is **Unknown** (not enforced in this screen).

Update (`POST /update`) sends the profile object plus `additionalIds`, and sets `txtBillAddress` to the literal `Home`. Email is included. Password change, only if the new password field is non-blank, is `Auth.changePassword(currentUser, passwordOld, password)`. A failure opens the Amplify error in the modal. The success popup "Profile Details Updated" still shows after a password error. **Inferred** from the control flow.

Adding an ID is local until Update. Duplicate ID numbers (primary or additional) are rejected with "ID number already exists!". Delete is also local until Update. The popup says "Click update to save changes."

`IDNumbers` in localStorage is rewritten on update so the fines and transactions filters can use the new list.

Provenance: **Implemented**.

### W10 — Transactions

Trigger: signed-in user opens `/transactions` (profile child route; `canNavigate` is false on the top-level transactions route, true on the profile child).

`GET /transactions?userId={UserID}` plus optional `strIdNumber` and `strIdType`.

The table sums `strAmount`, formats amounts as `R {amount}`, and formats `dtPaid` as `YYYY/MM/DD`. An info icon opens a detail modal. Several identifier and status columns are omitted from the grid (`omitColumns` in `Transactions.js`) and are still on the row for the modal.

Empty or non-array payload becomes an empty list. `result !== true` is logged and leaves the spinner off. No user-facing error string.

Provenance: **Implemented**.

### W11 — Admin dashboard

Trigger: `SUPER_ADMIN` or `ADMIN` opens `/adminDash`. Default date range is the previous calendar month through today (`AdminDashboard.js`).

1. `GET /getgraphs?userId={UserID}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`.
2. `GraphType` `Table` → table tab. `CounterTab` → counters. `LineMarkGraph`, `VerticalBarGraph`, `RadialBarGraph`, `StackGraph`, `PieGraph`, `AreaGraph`, `ScatterGraph`, `RadarGraph` → graph tab.
3. `GET /reports` with `issuingAuthority` (the value `ALL`, or the selected authority). Rows build a finance issuing-authority list from `IssuingAuthority` / `issuingAuthorityDesc`.

`AdminDashboardTest.js` uses local sample data. Its routes are commented out in `RoutingConfig.js`. Provenance: **Implemented** as unused code.

`/adminAnalytics` is registered with `canNavigate: false` and is commented out of the Admin menu. Provenance: **Implemented**, not linked.

### W12 — Admin settings

Trigger: admin opens `/adminSettings` (menu child of Admin).

Load: `GET /getsettings?userId=` and `GET /getgraphsettings?userId=`.

Email templates: every settings key except `userId` is an accordion. Save is refused unless the template text contains the literal `{code}`. `POST /postsettings` with `userId` and that key URI-encoded.

The convenience editor is rendered only when the key is exactly `convenience ` (trailing space). The cart reads `strKey.trim() === "convenience"`. **Discrepancy:** a key of `convenience` without the trailing space is edited as an email template, not as the fee. **Requires confirmation** of the stored key.

Convenience save: `POST /postsettings` with `{userId, convenience: parseInt(fee)}`.

Graph editor graph types offered in the dropdown: `Table`, `CounterTab`, `AreaGraph`, `LineMarkGraph`, `PieGraph`, `RadarGraph`, `StackGraph`, `VerticalBarGraph`. Create, update, and delete all `POST /postgraphsettings`. Delete is a post of the graph row, not a separate HTTP method. The SQL text is shortened for display (`shortenSql`). The query is sent back to the API. This UI does not show an allow-list of SQL.

Provenance: **Implemented**.

### W13 — Sign out

`TopNav.js` calls `Auth.signOut` and deletes `roles`, `userDetails`, `userProfileDetails`, `cartItems`, `IDNumbers`, and `backFilters`, then goes to `/`.

Provenance: **Implemented**. `issuingAuthList`, `financeIssuingAuthList`, and `outstandingFines` are not in that delete list.

## Integrations

| System | Direction | Trigger | Data | Failure in this UI |
|---|---|---|---|---|
| Amazon Cognito `eu-west-1_gzZa8Iitq` | Browser → Cognito | Register, login, confirm, resend, forgot password, change password, logout | Email as username, password, verification code | Amplify `error.message`, or the mapped login strings in W4 |
| Unity API `https://puru95wxc8.execute-api.eu-west-1.amazonaws.com/production` | Browser → API Gateway | Every data screen | See workflow paths. Amplify attaches the Cognito session when one exists. `unityRequests` catch returns `{status}` only | Search and fines screens show the strings in W1, W2, W6. Settings show "Settings Not Updated" / "Convenience Fee Not Updated". Cart checkout still submits the iVeri form |
| eNatis | Indirect, inside Unity payloads | Search, outstanding list, AARTO detail | Notice, vehicle, charges, debts, demerit points | "did not respond" / "No fines found" popups |
| iForce | Indirect | Search, outstanding list, CPA detail, charges, evidence | Notice, phase, amount, evidence token and image | Same class of popup; evidence/charges HTTP 400 or 504 set `apiError` |
| iVeri Lite / Nedsecure | Browser form POST, then redirect back | Cart checkout | Cardholder name, email, ZAR amounts in cents, fine line items, convenience line, return URLs | Return `status` of fail, error, or trylater. Description query `liteResultDescription` |
| Google reCAPTCHA v2 | Browser widget, token posted to Unity `/recaptcha` | Register only | Site key in `src/config/google.js`. Commented keys for another v2 and a v3 are not used | Popup and widget reset |
| Matomo | Browser → `analytics.siliconsoftware.net` | Every page load (`public/index.html`) | Site id `2`, document title, domain allow-list `*.master.d39rr3ejryd4rk.amplifyapp.com` | Not handled |
| Google Analytics | Browser | Every page load | Measurement id `G-6M1WRZYG8D` | Not handled |
| S3 `tmt-pmf-prod-invoices` | Configured on Amplify Storage | None found under `src/` | Bucket name only | Not applicable in this UI |

Authentication on the Unity calls is Amplify's default for an API endpoint with Auth configured (Cognito). Custom authorizer headers were not set in `unityRequests.js`. **Requires confirmation** of what the API Gateway authorizer accepts for `GUEST` calls, which happen with no signed-in user.

## Important constraints

- Currency is ZAR. iVeri line amounts are integer cents.
- Cart state is `localStorage` key `cartItems`. It does not survive logout (logout deletes it) and is not shared across browsers.
- Client route guards are not a server permission model. A guest who knows `/outstandingfines` is still filtered by `getAllowedRoutes` before the route is registered. The Unity API must enforce its own auth. That enforcement is **not in this repo**.
- reCAPTCHA is on register only, not on login or search.
- Service worker is unregistered (`src/index.js`).
- Terms text stops during clause 5 and refers to clause 9 and clause 10 for contact details. Those clauses are not in `TermsAndConditions.js`. Footer contacts are a different surface.

## Requirements discovered from the implementation

| ID | Requirement | Provenance | Confidence |
|---|---|---|---|
| FR-001 | A visitor can search by notice number and open AARTO or CPA detail without an account | Implemented | High |
| FR-002 | Notice search strips hyphens and rejects blank or non-text values | Implemented | High |
| FR-003 | A guest does not see Add to Cart. A signed-in user can add a payable fine once | Implemented | High |
| FR-004 | Registration requires email, name, surname, mobile, ID, matching passwords, and a passing reCAPTCHA, then an email code | Implemented | High |
| FR-005 | South African ID type `02` is check-digit validated. Types `03` and `04` are not | Implemented | High |
| FR-006 | Login stores role, profile, and ID list, then opens outstanding fines | Implemented | High |
| FR-007 | A Cognito user with no Unity row is asked for profile details and registered | Implemented | High |
| FR-008 | Outstanding fines merge eNatis and iForce and warn when a source does not respond | Implemented | High |
| FR-009 | eNatis rows with `paymentNotAllowedCode === 3` or no debts cannot be paid in the UI | Implemented | High |
| FR-010 | CPA amount displays `NAG` when `paymentNotAllowedCode === 3` | Implemented | High |
| FR-011 | Checkout adds a per-line convenience fee from settings and posts ZAR line items to iVeri Lite | Implemented | High |
| FR-012 | A successful return clears the cart. Other statuses return the user to the cart | Implemented | High |
| FR-013 | Profile update can change name, ID, mobile, email, additional IDs, and optionally the Cognito password | Implemented | High |
| FR-014 | Transactions can be filtered by the user's stored ID numbers | Implemented | High |
| FR-015 | Admins can edit settings templates that contain `{code}`, the convenience fee, and graph definitions, and can view graphs and a finance report | Implemented | High |
| FR-016 | Paying is blocked when a warrant of arrest exists | Documented in terms. Not named in code. Mapping to `paymentNotAllowedCode` 3 is **Requires confirmation** | Low |
| FR-017 | Role names SUPER_ADMIN, ADMIN, MANAGER, CUSTOMER, GUEST are the product's access model | Inferred. Source comment says placeholder roles. API assigns `strRole` | Low |

## Non-goals of this repository

- Issuing a fine, cancelling a fine, or representing the motorist at the RTIA.
- Taking cash or EFT inside this UI. The only payment transport found is the iVeri card form. The terms say credit card. Whether iVeri also offers debit cards is **Unknown**.
- A contact-us form. Complaints in the terms mention a "Contact Us" service that this tree does not implement.
