# PRD — saruefiling (SARU E-Filing)

Evidence date: 2026-10-04. Source: `git@bitbucket.org:overdrive/saruefiling.git`, `master` at `9b734e0` (2019-07-03).

There is no prior PRD in the repository or in Silicon Ark for this remote. Requirements below were recovered from controllers, views, models, configuration, and commit messages. Provenance is marked on each item.

Secrets that are hardcoded (database password, SMTP passwords, a login bypass password, a scout token) are not copied into this document.

This PRD is for `saruefiling.git`. The later tree in `saruefiling.com.git` is a separate document set. Do not read rules from that set back into this one.

## Product purpose

SARU E-Filing lets rugby organisations complete structured returns, submit them so the form tells the user it will lock, and record approval by a person whose `JobTitle` is 35 or 23. Users whose `UnionType` is less than 3 can list forms across organisations and reopen a submitted or completed form. Users whose `UnionType` is greater than 2 are forced onto their own union when listing forms.

The same signed-in application also manages personnel and organisations, and administers plans, budgets, and grant categories. Older Footprint modules for squads, matches, scouting, and courses remain in the code.

Provenance: **Implemented**. A written product goal beyond the screen title, the Footprint logo, and the mail signatures was not found.

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| E-filing user | Signs in with email and password when `EFLogin` is 1 | `LoginController::indexAction` | Implemented |
| New account | Lands on `/first` when profile `Status` is 0. `JobType` 2 gets a player form; any other `JobType` gets a personnel form. Saving sets `Status` to 1 | `IndexController::init`, `FirstController` | Implemented |
| National / higher union user | `UnionType` less than 3. Dashboard lists projects for the current year. Can list forms across unions. Can reopen a form whose status is 1 or 2 | `IndexController::indexAction`, `epg/form.phtml` | Implemented. The business name for each `UnionType` number is **Unknown / Requires Confirmation** |
| Union user | `UnionType` 3 or 11. Dashboard renders `index/union.phtml`. Form lists are forced to their own `UnionID` | `IndexController`, `EpgController::indexAction` | Implemented |
| Other union type | `UnionType` greater than 3 and not 11. Dashboard redirects to that person's own member profile | `IndexController::indexAction` | Implemented |
| Job title 35 | Approve control is shown. `approveformAction` writes `PresidentID`, `ApprovedPresident`, and `PresidentApprovDate` | `epg/form.phtml`, `EpgController::approveformAction` | Implemented. The label "president" is **Inferred** from the column names. The button text is `Approve` |
| Job title 23 | Approve control is shown. `approveformAction` writes `CEO_ID`, `ApprovedCEO`, `CEOApprovDate`, and also writes `ApprovedPresident` to the same user id | `epg/form.phtml`, `approveformAction` | Implemented. The label "CEO" is **Inferred** from the column names. Writing `ApprovedPresident` from this branch is implemented behaviour and **Requires Confirmation** as an intended rule |
| Job titles 30 and 22 | Used only inside private `addunion()` as fallbacks when no `JobTitle` 23 profile exists for a union | `EpgController::addunion` | Implemented. They do not see the Approve control |
| Form member or approver | User ids in the form's `MembersArray` or `ApproveArray` may open a confidential form. Other users with `UnionType` greater than 2 are redirected to `/epg/` | `EpgController::formAction` | Implemented |
| Menu-gated user | JSON `Role` keys `members`, `organisations`, `forms`, `grants` show those menus. Action names under each key show child links | `menu/default.phtml` | Implemented |
| Template editor | Form Templates link when `UnionType` is 1, or `UserID` is 582, or `UserID` is 1, and the role includes `forms.Actions.financial` | `menu/default.phtml` | Implemented. Why those two user ids are hardcoded is **Unknown** |
| Settings user | Access control, sent mail, and mail templates. The settings menu is included when `UnionType` is less than 3, including when `Active` is 0 | `default.phtml`, `menu/settings.phtml` | Implemented |
| Inactive profile | `Active` is 0. Header shows `Inactive`. The main menu is not rendered | `default.phtml` | Implemented |
| Scout mobile client | Request parameters `user` and `pass`. Succeeds only when the role JSON has `scout.Actions.app`, then returns a JSON name and stores a token | `AppController::loginAction` | Implemented. Whether a mobile app still calls it is **Unknown** |
| Anonymous visitor | `/login` only, for the controllers whose `init` redirects when Zend_Auth has no identity | Controllers' `init` | Implemented |

`Role` is a JSON document of modules and action names, not a single role enum. `JobTitle` is a separate numeric field used for approval. Provenance: **Implemented**.

## Main workflows

### W1 — Sign in

Trigger: GET `/login`. Layout is disabled.

1. If Zend_Auth already has an identity, redirect to `/`.
2. POST `data[Email]` and `data[Password]`.
3. Load `co_credetials` where `Username` equals the email and `EFLogin` is 1. If that row is missing, the email is replaced with a random id so the later authentication cannot succeed.
4. Password is compared as `md5` of the typed password, unless the typed password equals a hardcoded bypass string, in which case the stored hash is submitted instead. If that bypass matches and more than one credential row is counted, the action redirects to `/` before authentication.
5. `Zend_Auth_Adapter_DbTable` authenticates against `co_credetials.Username` / `Password`.
6. On success, the profile row is written into the auth storage and `LastLogin` is updated. If profile `Status` is greater than 0, redirect to `/`. Otherwise redirect to `/first`.
7. On failure, if the username exists and the password column is empty, or if the email exists on `profile` but not on credentials, a reset key is stored and `sendMailNew` is called. Otherwise the page shows `Email and/or password is invalid!`.

Logout (`/login/logout`) clears the identity, destroys the session, and redirects to `/login/`.

Password reset (`/login/resetpassword` and `/login/reset/user/{key}`) stores an `md5` reset key and emails a link. The message text says the Footprint password expired because it has not been changed in a while, and it is signed `Footprint Team`. No code in this action compares a password age.

The reset message is sent by `LoginController::sendMailNew` through SMTP host `mail.sarugby.co.za` port 25, from `accreditation@sarugby.co.za`, subject `SARugby Notification`. A commented block in the same controller used `support@footprint.net` / `SARU E-Filing` and is not the path that runs.

Provenance: **Implemented** (`LoginController`). The bypass string is a security defect; it is not repeated here.

### W2 — First profile

Trigger: profile `Status` is 0 after login, or `IndexController::init` redirects to `/first`.

`JobType` 2 renders `first/players`. Any other value renders `first/personnal`. A posted save sets `Status` to 1, `FullName` from first and last name, and `DateModified`.

Provenance: **Implemented** (`FirstController`). `FirstController_old.php` declares the same class name and is a legacy copy; the router uses `FirstController.php`.

### W3 — Dashboard

Trigger: GET `/` with an identity and `Status` greater than 0.

- `UnionType` less than 3: load projects for the current calendar year into `PROJECTS`.
- `UnionType` 3 or 11: render `index/union.phtml`.
- Otherwise: redirect to `/members/profile/id/{UserID}`.

The same action also counts squads and schools whose dates fall in the previous calendar year, and loads `gir_profile` rows by sex for the current year and by `DateCreated` for this month, last month, this week, and last week.

`IndexController::init` sets a cookie named `efilingApp` for one hour.

Provenance: **Implemented**.

### W4 — Open and fill an e-filing form

Trigger: menu `Efiling Forms` goes to `/epg/index/year/{current year}` when the role includes `forms.Actions.index`.

`EpgController::indexAction` reads year, type, form, and status filters. If `UnionType` is greater than 2, the union id is replaced with the signed-in user's `UnionID`.

`formAction` loads headers, the `epg` row, the union, and the `epg_forms` row. Confidential forms (`Confidential` = 1) are refused to `UnionType` greater than 2 unless the user id is in `MembersArray` or `ApproveArray`. `UnionType` less than 3 is not blocked.

`savedataAction` merges posted page data into the JSON `formdata` column.

- When `UnionType` is less than 3 and the status parameter is 1: set `status` to 1, set `AdminDateModified`, call `notifications`. Do not set `DateSubmitted`.
- When `UnionType` is less than 3 and the status parameter is not 1: save `formdata` and `AdminDateModified` only.
- Otherwise, when the status parameter is 1: set `status` to 1, `DateSubmitted`, `SubmittedBy`, and `AdminDateModified`, and call `notifications`.
- Otherwise: save `formdata`, set `DateCompleted` and `AdminUserID`, and do not change `status`.

The submit control posts `status/1`. The confirm dialog says the form will be locked and the user will not be able to change it. For `UnionType` greater than 2, the submit button is suppressed when `status` is already 1.

Provenance: **Implemented** (`EpgController`, `epg/form.phtml`, `epg/loadform.phtml`).

### W5 — Approval and reopen

List labels in `epg/index.phtml`:

- `status` 0, and no approver ids: `DRAFT`
- `status` greater than 0: `SUBMITTED`, unless an approver id is set
- `ApprovedPresident` greater than 0, or `ApprovedCEO` greater than 0: `APPROVED`

`epg/list.phtml` also labels `status` 2 as `COMPLETED`. No assignment of `status` to 2 was found in `EpgController`. How a row reaches 2 is **Unknown / Requires Confirmation**.

Approve button:

- `JobTitle` 35 and the user id is not already `ApprovedPresident`
- `JobTitle` 23 and the user id is not already `ApprovedCEO`

The button posts `/epg/approveform/unionid/{id}/id/{form}`.

Reopen: a user with `UnionType` less than 3 sees a control when `status` is 1 or 2. It posts `/epg/activateform`, which sets `status` to 0 and, when `AdminUserID` is greater than 0, sends template mail through `EmailLayout`.

Provenance: **Implemented**.

### W6 — Form templates

`/builder` is linked as `Form Tamplates` (spelled that way in the menu) for the template-editor case in the roles table. `BuilderController` creates forms, headers, subheaders, sections, and rows, and can save a target.

`/epg/add` is emitted as a menu item whose link text is commented out, so the visible label is empty. The action exists on `EpgController`.

Provenance: **Implemented**.

### W7 — Personnel and organisations

Menu items `List Personnel`, `Add Personnel`, `All Organisations`, and `Create Organisation` depend on role action flags. New personnel credentials are inserted with `EFLogin` 1 and an MD5 password (`MembersController` around the add and reset-account paths). Role editing can set `EFLogin` from a posted flag, defaulting the local array to 0 before that check.

Provenance: **Implemented**.

### W8 — Plan, budget, and grants

The menu group is `Plan & Budget` when the role includes `grants`.

- `grants.Actions.profile` links to `/plan`
- `UnionType` less than 3 and `grants.Actions.edit` links to `/plan/manage`
- `grants.Actions.Categories` links to `/categories`
- `grants.Actions.Classifications` links to `/classifications`

`GrantsController` is not a top-level menu target. It still implements request, approve, reject, and reactivate actions. On `grantsrequest`:

- `rejectAction` sets `Status` 4 and stores a reason
- `reactivateAction` sets `Status` 1
- `approvedAction` sets `Status` 3, an allocated amount, `AllocatedUserID`, and `DateAllocated`
- `approvegrantAction` sets `Status` 3 and `Rate` when an allocated amount is posted; otherwise it updates `Rate` only

The words that should be shown for statuses 1, 3, and 4 were not found as labels in `application/views/scripts/grants/`. **Unknown / Requires Confirmation**.

Provenance: **Implemented**.

### W9 — Scout application API

`AppController::loginAction` does not use the Zend login screen. It loads credentials by username and MD5 password, requires `scout.Actions.app`, stores `uniqid()` as `Token`, and echoes JSON `{UserID, Name}`. Failure echoes `User not found!` or a zero user id.

`sendAction` replaces the request token with a hardcoded token before loading scout details. That token is not repeated here. `scoutAction` writes the `q` parameter to `q.txt` and calls `readSave`. `imageAction` stores a posted image on `Images` for a player id and scout id.

Provenance: **Implemented**. These actions are not in the HTML menu.

## Functional requirements

### FR-001 — E-filing login gate

The system accepts a login only when a `co_credetials` row matches the email and `EFLogin` is 1.

Actor: visitor. Trigger: POST `/login`. Output: redirect to `/` or `/first`, or an error, or a reset email.

Evidence: `LoginController`. Implementation: **Implemented**. Confidence: high.

### FR-002 — Incomplete profile

When `profile.Status` is 0, authenticated requests that pass through `IndexController::init` go to `/first` until a player or personnel save sets `Status` to 1.

Evidence: `IndexController`, `FirstController`. Implementation: **Implemented**. Confidence: high.

### FR-003 — Union scope on forms

A user with `UnionType` greater than 2 can list e-filing forms only for their own `UnionID`. A user with `UnionType` less than 3 can filter across unions.

Evidence: `EpgController::indexAction`. Implementation: **Implemented**. Confidence: high.

### FR-004 — Submit locks the form for union users

Submitting posts status 1. The dialog states the form will lock. The load-form script hides Submit when status is already 1 and `UnionType` is greater than 2.

Evidence: `epg/loadform.phtml`, `savedataAction`. Implementation: **Implemented**. Confidence: high. Whether a national user (`UnionType` less than 3) can keep editing after submit is **Requires Confirmation**: their save path sets status 1 but the hide-submit check is only for `UnionType` greater than 2.

### FR-005 — Two-step approval

Job title 35 records a president approval. Job title 23 records a CEO approval. The list shows `APPROVED` when either approver id is greater than 0. The list does not require both.

Evidence: `approveformAction`, `epg/index.phtml`. Implementation: **Implemented**. Confidence: high. Whether the business rule is "either" or "both" is **Requires Confirmation** because the screen treats either id as approved.

### FR-006 — Reopen

A user with `UnionType` less than 3 can set a submitted or completed form back to status 0.

Evidence: `epg/form.phtml`, `activateformAction`. Implementation: **Implemented**. Confidence: high.

### FR-007 — Confidential forms

A confidential form is visible to `UnionType` less than 3, and to users listed on the form, and is refused to other union users.

Evidence: `formAction`. Implementation: **Implemented**. Confidence: high.

### FR-008 — Role menu

The left navigation shows members, organisations, forms, and plan/budget only when the corresponding key exists on the profile `Role` JSON, and only when `Active` is 1.

Evidence: `menu/default.phtml`, `layouts/default.phtml`. Implementation: **Implemented**. Confidence: high.

### FR-009 — Settings for higher unions

Access control and email template screens are linked when `UnionType` is less than 3, even if the main menu is hidden because `Active` is 0.

Evidence: `layouts/default.phtml`, `menu/settings.phtml`. Implementation: **Implemented**. Confidence: high.

### FR-010 — Grant decision writes

Reject stores status 4. Reactivate stores status 1. Approve stores status 3 with an amount and the acting user id.

Evidence: `GrantsController`. Implementation: **Implemented**. Confidence: medium for the business meaning of the numbers.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| MySQL `teamwizz_SARU` | App reads and writes | Every screen that uses a model | Profiles, credentials, forms, grants, squads, gir rows | Username and password in `application.ini` | Not handled beyond PHP errors. `public/index.php` turns `display_errors` on |
| SMTP `mail.sarugby.co.za` | Outbound | Password reset | HTML body, subject `SARugby Notification` | Login on port 25 from `LoginController::sendMailNew` | Exception is caught and discarded |
| SMTP `mail.footprintapp.net` | Outbound | `EmailLayout::sendMail` | HTML body, from `support@saruefiling.com`, name `SA Rugby Efiling` | Login credentials in `EmailLayout.php` | A row is written to `Email_report` with `Sent` 0 or 1 |
| FTP `saruefiling.overdriveqa.co.za` | Deploy | Bitbucket pipeline on `master` or `development` | Repository tree via `git ftp init` | `FTP_USERNAME_*` and `FTP_PASSWORD_*` pipeline variables | Not described in the YAML |
| Scout client | Inbound HTTP | `POST` style params on `/app/login`, `/app/scout`, `/app/image` | Username, password, JSON payload, image | MD5 password and role flag | Plain text `User not found!` or an empty name |

No payment, SMS, or GraphQL integration is present in this revision.

## Constraints

- Timezone is `Africa/Johannesburg` in `public/index.php` and again in `EpgController`.
- Locale default is `af_ZA`.
- PHP short open tags must be enabled. Provenance: **Documented** (`README.md`).
- Production `application.ini` sets `display_errors` to 0. `public/index.php` then sets `display_errors` and `display_startup_errors` to 1. The front controller wins. Provenance: **Implemented**.
- Memory limit is raised to 800M in `index.php`, `IndexController`, and `EpgController`.
- Passwords are MD5. There is no complexity or lockout rule in the login action.
- Queries are built by concatenating request values into SQL fragments. That is how the code behaves. It is not restated here as a procedure.

## Error and empty behaviour

- Missing login fields show `Please enter your login details`.
- Bad credentials show `Email and/or password is invalid!`.
- `EpgController` redirects to `/login/` when there is no identity, except the action named `pdf`.
- A form list with no rows renders an empty table body (`count($this->list) > 0` guard in `epg/index.phtml`).
- `ErrorController::errorAction` exists. Its copy was not expanded for this PRD beyond the action name.
- Files named `Efiling_Grnd_Error*` under `public/` are committed error output from 2018 and 2019. Their contents were not copied here.

## Out of scope for this revision

- Anything that exists only in `saruefiling.com.git` after 2019-07-03, including later approval job titles and any GraphQL sync added there.
- A written roadmap. None was found.
