# Business rules — sarugby.online

Evidence date: 2026-09-28. Source: `master` at `8a0437d`.

Rules are taken from controller conditions and from sentences in emails and thank-you HTML. Nothing in this file was supplied by a separate business-rules document. Where the code behaves a certain way and nobody wrote it down as policy, the rule is marked **Inferred** or **Implemented**.

Email text that the code does not enforce is marked **Documented** and not treated as an enforced rule.

## Tournaments and fixtures

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-001 | A tournament with `Hide != 1` can be loaded for the year page. Bucketing then keeps only `Hide == 0`. | `IndexController::indexAction` | Implemented |
| BR-002 | `Level` 1 is international, 2 is national, 3 is provincial. | Same action, array keys | **Inferred** from the array names in code. The numbers are not labelled in a comment. |
| BR-003 | `HightedTourn == 1` places the tournament in Featured as well as in its level bucket. The level bucket is still filled for that row. | Same action | Implemented |
| BR-004 | Current means `StartDate <= today` and `EndDate >= today`. Completed means `EndDate < today`. Upcoming means `StartDate > today`. Comparison is the server date, and the front controller sets `Africa/Johannesburg`. | Same action | Implemented |
| BR-005 | Upcoming groups are sorted by `StartDate` ascending. | Same action | Implemented |
| BR-006 | The year filter defaults to the calendar year of "now" when `year` is empty. Tournaments must have `StartDate` on or after 1 January of that year. | Same action | Implemented |
| BR-007 | Log rows for a tournament with `Structure == 2` are grouped by pool. Pool names come from JSON on the log row. | `TournamentsController::logs` | Implemented |
| BR-008 | Open tournaments processed by `/cron/matches` update fixtures from wins and losses. Pool score methods run only when `Structure == 2`. | `CronController::matchesAction` | Implemented |
| BR-009 | Players for a squad with `SquadType > 12` are read from `gir_profile`. Otherwise they are read from `profile`. | `TournamentsController::getmembers` | Implemented. Meaning of the type codes: **Unknown / Requires Confirmation** |
| BR-010 | Player sort is jersey number when `SquadType < 2`, rank number when `SquadType > 4` and `< 12` or `SquadType == 12`, otherwise last name. | Same method | Implemented |
| BR-011 | Age in years is calculated from a `Y-m-d` birthday, decrementing if the month/day has not occurred yet. Empty birthday yields an empty string. | `TournamentsController::age` | Implemented |

## News

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-012 | The news page lists every article from `News_Articles_SA_Rugby` and every tournament with `Hide != 1`. No date or publish flag was seen in `NewsController`. | `NewsController::indexAction` | Implemented. A finer publish rule may exist inside `Model_NewsArticles::getAll` and was not re-read line by line. |

## Footprint accounts

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-013 | Login is attempted only when a `co_credetials` row matches the username and `FPLogin = 1`. Otherwise the username is replaced before authentication. | `default` `LoginController::indexAction` | Implemented. The meaning of `FPLogin` is **Inferred** as "allowed to use this login". |
| BR-014 | The stored password is compared as MD5 of the typed password. | Same action, `Zend_Auth_Adapter_DbTable` on `co_credetials.Password` | Implemented |
| BR-015 | One fixed password in the controller authenticates as the user without using MD5 of what was typed. | Same action | Implemented. Security defect. Requires human review. The password is not recorded here. |
| BR-016 | After a successful login, `profile.Status > 0` redirects to `/`. Any other status redirects to `/first`. | Same action | Implemented. What status values mean, and what `/first` should show, is **Unknown**. No `first` controller is in the tree. |
| BR-017 | An account whose credential password is an empty string is treated as expired: a reset link is emailed and the user is not logged in. | Same action, `sendResetLink` | Implemented. The email says the password expired because it was not changed "in a while". No age check is in the code. The "in a while" sentence is **Documented** only. |
| BR-018 | If the email exists on `profile` but not on `co_credetials`, a credential row is inserted and the same reset mail is sent. | Same action | Implemented |
| BR-019 | Reset and login links in this flow are `http://` plus the current host, not forced to HTTPS inside the mail body. | `sendResetLink` | Implemented |
| BR-020 | An already authenticated Footprint user who opens the login action is redirected to `/`. | Same action | Implemented |

## Company registration and access

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-021 | `companiesold` registration is rejected when `RegistrationNumber` already exists on `Companies_Profile`. | `Companies_RegisterController::check_Company` | Implemented |
| BR-022 | `companiesold` registration is rejected when `IDNumber` or `Email` already exists on `Company_Members`. | `check_IDNumber` | Implemented |
| BR-023 | The first person on that path is stored as `Rol = Administrator`. | `newcompanyAction` | Implemented |
| BR-024 | `Country` equal to `SOUTH AFRICA` sets `IDType = 1`. Any other value sets `IDType = 2`. | Same action | Implemented. **Inferred** that 1 means South African ID and 2 means another document. Not confirmed by a lookup table in this action. |
| BR-025 | The registration email says an administrator reviews the company and that activation can take up to 48 hours. | Email body in `newcompanyAction` | **Documented**. No 48-hour timer or review state change is in this action. |
| BR-026 | The same email says the company cannot sign up to an event until additional documents are supplied. | Email body | **Documented**. Not enforced in the registration action. |
| BR-027 | Email confirmation (`Validate_Key` match) sets `Email_Confirmed = 1`, `Email_Confirmed_Date` to the calendar date, and `AllowLogin = 1`, and clears the key. | `validateemailAction` | Implemented |
| BR-028 | Company login uses table `CompaniesLogin`, columns `Email` and `password`, password MD5 except for one fixed password that substitutes the stored hash. | `Companies_LoginController` | Implemented. The fixed password is a security defect and is not recorded here. |
| BR-029 | Company password reset sets `AllowLogin = 1` and `Email_Confirmed = 1` and emails the new password in clear text. | `resetAction` | Implemented |
| BR-030 | Permit, user, event, company, and company-home actions in `companiesold` require a Zend_Auth identity. Missing identity redirects to `/companies/login/`. | Controller `init` methods | Implemented |
| BR-031 | People added to a company receive a generated password only when `AllowLogin` is submitted. Otherwise `AllowLogin` is stored as 0. | `UsersController::addAction` | Implemented |
| BR-032 | Allowed role labels offered on the user screen are Administrator, Director, Management, Operations, Driver, Technician, and General Staff. | `UsersController::init` | Implemented as the list assigned to the view. Whether the database constrains the column is **Unknown**. |
| BR-033 | A new person's `CID` is the signed-in company, not a value from the form. | `addAction` | Implemented |
| BR-034 | `companyonl` registration rejects an existing ID number or email, then stores `CID` as the existing company id for that registration number, or `0` if none exists. It does not create a company in `newcompanyAction`. | `companyonl` `IndexController::newcompanyAction` | Implemented. Conflicts with BR-021. Which path is authoritative is **Unknown / Requires Confirmation**. |

## Permits

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-035 | Permit creation in this code uses accreditation event id `27` only. | `PermitsController::createAction`, `addpermitAction` (`$AC=27`, `getEvent(27)`) | Implemented. Whether 27 is a permanent business rule is **Unknown / Requires Confirmation**. |
| BR-036 | A new permit is stored with `Status = 2`. | `addpermitAction` | Implemented. The meaning of status 2 is **Unknown / Requires Confirmation**. |
| BR-037 | The human-facing permit number is `PE` plus zero-padding to 6 characters plus the insert id. A separate `PermitRefSerial` is a random 13-digit string. | Same action | Implemented |
| BR-038 | An occupant with `Type == 1` is rewritten from `Company_Members` when the ID number matches. The driver is stored on the same occupant JSON with `Driver = 1`. | `addpermitAction`, `GetMemberDetails` | Implemented |
| BR-039 | The request email says changes for the next day are not accepted after 13:00, and that load-out times are first come, first served. | Email body | **Documented**. `addpermitAction` does not check the clock or existing bookings. |
| BR-040 | The email says administrators may change the requested date and time. | Email body | **Documented**. No admin editor is in this repository. |
| BR-041 | The email lists 14 health-and-safety documents required for work permits. | Email body | **Documented**. Not validated in code. |
| BR-042 | Load-in versus load-out wording is selected by comparing the permit surname to `2`. | Email body in `addpermitAction` | Implemented as written. **Inferred** defect: surname is not a load-type flag. |
| BR-043 | A permit is shown only when `getPermit` returns a row for the id and the signed-in `CID`. Otherwise the profile action redirects to `/companies`. | `profiletestAction` | Implemented for that action. `profileAction` was not fully re-read. |

## Blue Card

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-044 | Incident cause must be one of: Tackle, Ball Carry, Ruck, Scrum, Line-out, Open play, Collision, Unknown. | Options assigned in `indexAction` | Implemented as the options rendered. The submit action does not re-validate the value. |
| BR-045 | Applicant role must be one of: Referee, Coach, Team Manager, Physiotherapist, Medical Doctor, Parent, Guardian, Player. | Same | Implemented as options. Not re-validated on submit. |
| BR-046 | Playing positions are the 15 labels hard-coded in `indexAction` (1 loose-head prop through 15 full back). | Same | Implemented as options. |
| BR-047 | When the applicant role is Player, the player name is the applicant and the player email template is used. Otherwise the player name fields are used, and the letter says "she" only when `PlayerGender` is `Female`, else "he". | `submitAction` | Implemented |
| BR-048 | Every non-empty `ContactEmail` is copied on the message. If `ApplicantEmail` is empty, each contact is mailed separately without the CC list. | `submitAction` | Implemented |
| BR-049 | The consent row is inserted into `BlueCard_Consents` after the mail attempt. Mail failure does not block the insert in the code that was read. | `submitAction` | Implemented |
| BR-050 | The thank-you text tells the user to contact Sports Concussion SA (`011-3047724` office hours, `0825746918` after hours, `sportsconcussion@mweb.co.za`) and to read BokSmart concussion pages. | Thank-you HTML | **Documented** |

## Accreditation export

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-051 | Queued accreditation applications are turned into A6 portrait PDFs at 300 dpi. | `CronController::addtozip` | Implemented |
| BR-052 | Only applications with `AC == 14` are POSTed to AppWizard. | `queingAction` | Implemented. Why 14 is special is **Unknown / Requires Confirmation**. |
| BR-053 | The AppWizard payload uses guest type `G=Guest`, account `A=acrSARugby`, plus barcode, first name, surname, ID number, category, concatenated zone numbers, and the user picture. An empty barcode is left empty (the fallback to `Ref` is commented out). | `senddata_to_Api` | Implemented |

## Menus

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-054 | A nutrition row's allergen list is JSON. The page shows allergen records whose ids are in that list. | `SessionController::nutritionAction` | Implemented |
| BR-055 | Menu pages do not require a login. | No auth check in `SessionController::init` | Implemented |

## Access and security rules that the code does not implement

These are absences, not requirements:

- Public cron and ajax export actions do not check a caller.
- No captcha was found on login or registration.
- SQL fragments in login and registration concatenate request values into `fetchRow` / `getQuery` strings.
- `public/index.php` turns `display_errors` on.

Provenance: **Implemented** as missing controls. They are recorded so they are not documented later as accepted policy.

## Conflicts that need a human decision

1. Company uniqueness is enforced on `companiesold` and not on the `companyonl` create path (BR-021 versus BR-034).
2. The 48-hour review and the 13:00 permit cutoff are written in emails and not applied in the actions (BR-025, BR-039).
3. Redirects use `/companies/` while the module directory is `companiesold`.
4. A fixed password on each login bypasses the stored hash (BR-015, BR-028).
