# Business rules — doh_sims

Evidence date: 2026-10-16.

Source: `main` at `5747dca4`.

Rules are included only where a README, changelog, manifest, or class states them. **Inferred** means the rule is a reasonable reading of code and is not written as policy. Where the Registrations README and `ProgressionEligibilityService` disagree, both are recorded. The code is what this tree will run. It is not automatically the approved college rule.

## Identity

### BR-001 — South African ID shape

**Documented** (`modules/DOHCustomFields/README.md`).

- 13 numeric digits.
- Digits 1–6 are YYMMDD.
- Years 00–49 mean 2000–2049. Years 50–99 mean 1950–1999.
- Digit 13 is a Luhn check digit.
- The README describes digit 7 as gender (0–9) and digit 10 as citizenship (0 = SA citizen, 1 = permanent resident). The validation section that was read emphasises format, Luhn, date, DOB match, and uniqueness. Whether gender and citizenship digits are rejected when out of range is **Requires confirmation** against `SAIdNumberValidator.php` (named in the README, not re-read line by line).
- The date inside the ID must match the date-of-birth field.
- The same ID cannot belong to another person. Editing a user or an accepted application excludes that person.

### BR-002 — Student number shape and uniqueness

**Documented**, with an internal conflict.

- README: exactly 7 digits, `YY#####`, sequence 00001–99999, read-only, required, generated if empty.
- House label "Emergency Care" causes a random 7-digit number instead of the next sequence. **Documented**.
- Changelog v0.0.06: NULL is allowed, blanks become NULL, non-NULL values are unique. The oldest person row (smallest `gibbonPersonID`) keeps a duplicated number during migration. **Documented** migration behaviour.
- **Requires confirmation:** is a blank student number still a form error after v0.0.06?

### BR-003 — Who sees the DOH fields

**Documented** field visibility: students yes; staff, parents, other, application form, and public registration no; Data Updater yes. Changing the field name in System Admin can break validation. **Documented** warning.

### BR-004 — Class enrolment role names

**Documented** in DOHCustomFields changelog:

- v0.0.10 adds `Registrar` on `gibbonCourseClassPerson.role`.
- v0.0.11 adds `Programme Coordinator (Campus)`.

These are enrolment roles for import and Timetable Admin, not the MultiCampus role table.

## Campuses and access

### BR-005 — A campus is a house

**Documented** (MultiCampus README v2.0.0). Students belong to a campus through `gibbonPerson.gibbonHouseID`. Staff belong through `gibbonCampusPerson`.

### BR-006 — Campus role permissions

**Implemented** seed in `modules/MultiCampus/manifest.php`. See the role table in `prd.md`. Insert uses `ON DUPLICATE KEY UPDATE` on `name`.

**Inferred:** re-install updates the display name and does not, by itself, reset the six Y/N flags, because the update clause only sets `name`.

### BR-007 — Enforcement defaults

**Documented** settings:

| Setting | Default |
|---|---|
| `enableMultiCampus` | Y |
| `enforceCampusAccess` | Y |
| `allowCrossCampusView` | N |

System Admin bypasses campus filters. **Documented**.

CHE export campus rules (**Documented** in `CHE_File_Reports.md`):

- MultiCampus on, user without System Admin: limited to houses on `gibbonCampusPerson`.
- "All Campuses" still means those assigned houses, including on first load.
- System Admin, or MultiCampus off: every house.

### BR-008 — Registration campus becomes the student house

**Documented.** On accept, campus field `custom0207` is matched to `gibbonHouse.name`. No match or a blank value leaves the house as it was (including any Auto House Assign result). Example names in the README are "Addington" and "Umlazi". Those examples are not evidence of the live campus list.

### BR-009 — Impersonation

**Documented** on the System Admin page: administrators may impersonate another account for troubleshooting and testing. The other person can still log in. The administrator returns by logging out and back in. The page stops if the session's current role is not the user's primary role (`impersonateUser.php`). **Implemented** guard.

Who receives the action by default (which `gibbonRole` rows) was not read from `CHANGEDB.php`. **Unknown** until the action's permission rows are checked on a database.

## Registration and progression

The README calls these college policy. The service comments repeat "College policy" for the 24-credit and one-core carry limits. That wording is in the code comments. It is still **Documented** by the authors, not a signed academic rule in this repo.

### BR-010 — Who may register

**Documented.**

- Registrations must be enabled.
- Optional open and close dates.
- Only students who had an enrolment in the school year before the target year.
- Parents act for linked children. Staff with Manage Registration can confirm on behalf.
- The README says staff cannot override an eligibility block.

### BR-011 — Form group and modules on the form

**Documented.**

- Form group at entry is prefilled from `gibbonFormGroup.gibbonFormGroupIDNext` and kept on save so the student cannot change it.
- If programmes are linked to form groups, the form group list filters by the selected programme.
- At least one module (next year or repeat) is required to confirm.
- Modules offered: `gibbonCourseClassMap` for the next form group, else classes for the entry year and year group.
- Intended start date and form group at entry stay editable on the Student section when next-year modules are hidden.

### BR-012 — Year group is the programme level

**Documented.** One `gibbonYearGroup` row is one programme level. The README example is "First Year Nursing, Second Year Nursing". Whether every installation uses year groups that way is **Requires confirmation**.

### BR-013 — Pass, fail, and credits (code)

**Implemented** in `ProgressionEligibilityService` (class docblock and methods read on 2026-10-16).

- Pass/fail looks only at the markbook course total (`activity_type = 'course'`).
- A numeric course total passes when it is greater than or equal to the class `pass_mark`.
- Otherwise a grade sequence less than or equal to the scale's `lowestAcceptable` passes.
- No course-total row is not a pass. For second-failure counting, no course-total row is also not a hard fail.
- Earned credits sum the course-total entry's credits, falling back to theory + clinical.
- Failed and carried credit loads use `gibbonCourse.credits`.
- Minimum required credits are `gibbonYearGroup.minimum_credits` when that value is greater than 0. A null or zero minimum means the minimum check does not fail for lack of a threshold (`minCredits` null, and `isEligibleForDiploma` returns true on credits when there is no minimum and nothing failed).

**Conflict.** The Registrations README still describes separate minimum theoretical credits and minimum clinical credits, and pass marks stored on `gibbonCourseClass`. The service that was read uses one year-group minimum and course-level `pass_mark` / `is_core`. **Requires confirmation** which columns the registration screens still write.

### BR-014 — Carry limit of 24 credits

**Documented** in the README and in the service docblock.

- More than 24 failed credits on one module, or a combined failed total over 24, blocks "enrol to next year". Only repeating failed modules is allowed.
- Exactly 24 does not trip `failedModuleWith24Credits`.
- When any next-year module is selected, total failed credits for the year being completed must not exceed 24.
- Repeat-only (no next-year selection) is the documented exception.

### BR-015 — Core modules carried

**Documented** and partly **Implemented**.

- README: a core module has a carry limit of one. More than one failed core forbids new modules. The repeat list may contain at most one core.
- Service: `countCoreInClassList` counts `gibbonCourse.is_core = Y`. If the column is missing, the count is 0.
- Service: `max_core_carried` is read from the year group when the column exists. The README's "max 1" may therefore be data, not a hard-coded 1. **Requires confirmation** what value is stored on each year group.

### BR-016 — Second failure

**Implemented** in `isExcludedSecondFailure`. The README's "stub returns false" is out of date.

- Same course is the pair of lower-cased course name and short name.
- A hard fail in two different school years excludes the student.
- A pass is not a fail.
- `SP`, a value containing `SUPP`, or a numeric mark inside the course supplementary min/max and below the pass mark is not a hard fail.
- Optional school-year argument limits counting to years whose `sequenceNumber` is less than or equal to that year's sequence.
- The SQL in the method includes `ccp.role = 'Student' AND (ccp.role IS NULL OR ccp.role NOT LIKE '%- Left')`. The second predicate cannot be true when role is already `'Student'` unless SQL NULL handling is involved. **Inferred:** the left-role exclusion may not work as the comment hopes. **Requires confirmation.**

### BR-017 — Progression blocker codes

**Implemented.** `failedBlockingModule` is true when a failed module matches configured codes. The class comment example is `CLN`. The actual list comes from `getProgressionBlockerCourseCodes` (form, person, and year group arguments). The configured codes in production data are **Unknown**.

### BR-018 — WIL

**Documented.**

- Minimum WIL hours live on the year group (0–9999, optional).
- When a minimum is set, registration needs hours met, proof submitted, and staff approval.
- Students upload proof. The upload sets "proof submitted". Students do not set Approved.
- Staff set Approved after checking hours and proof.
- Files under `uploads/Registrations/WIL_proof/`.

### BR-019 — Diploma completion helper

**Implemented** `isEligibleForDiploma`:

- Any failed class returns false.
- If the caller passes `requiredCredits`, that number is the bar. Otherwise the year-group minimum is the bar.
- If there is no minimum, the method returns true once nothing is failed.

**Conflict.** README Phase 3 says diploma means all modules passed and clinical credits at least 1970. That constant is not in this method. **Requires confirmation** before anyone treats 1970 as a live rule.

### BR-020 — What the student sees when blocked

**Documented.** The re-registration form is not shown. An error is shown instead, including phrases the README quotes: "You must repeat failed modules" and "Excluded – failed same module twice". The same hide applies to the student page, the parent/student confirm page, and staff confirm-on-behalf. Submit handlers block as well.

## Money

### BR-021 — Proforma contents

**Documented** (Invoice Customizer changelog, including the Unreleased section sitting above version 1.4.4):

- Auto-generate runs only when `autoGenerateEnabled` is `Y`.
- Invoice lines are Finance fee rows that match a selected module name.
- R0.00 fee rows are included.
- Modules with no matching fee do not get a synthetic line.
- A zero total still creates payment tracking.
- New auto invoices are status Pending.
- Email includes a PDF.
- Registration submit generates a proforma only if one does not already exist. **Documented** in the Registrations README.

Fee category, due days, and email template are settings. **Documented**.

### BR-022 — Public balance lookup

**Documented.**

- Only when `enablePaymentStatus` is `Y`.
- Email must exist on an admissions account.
- Only rows with `(totalAmountDue - amountPaid) > 0` are the outstanding set described in troubleshooting.
- The README's security section says the page shows payment data for the email that was typed, and that rate limiting should be considered for production. That is an admission that a rate limit may be absent on this page.

### BR-023 — Upstream finance gateways

PayPal and Stripe are available through Omnipay because Gibbon core depends on them. Whether DOH takes card payments is **Unknown**. No DOH-specific fee table is hard-coded in the files read.

## Statutory reporting

### BR-024 — CHE files are not a submission

**Documented.** Files 61, 63, 65, 66, 67, and 68 are Excel extracts shaped like HEMIS files. They are not certified and are not sent to CHE by this module.

### BR-025 — Code mapping order

**Documented** in `CHE_File_Reports.md`.

- Active `gibbonCHEReportCode` rows rewrite cell values after the SQL row is built.
- A scoped row (`fileId:columnKey`) wins over a global row (empty target).
- Legacy values without a colon are treated as file 61. Module update v1.0.12 rewrites them to `61:…`.
- Provider code falls back from the person's campus mapping, then settings `cheProviderCode` / `cheProviderEtqaId`, then the organisation short name for `Provider_Code`.

### BR-026 — Empty CHE rows are omitted

**Documented.** Files 61, 63, 65, 66, 67, and 68 skip a person when the only non-empty value would be `Date_Stamp`.

### BR-027 — National ID source order for file 61

**Documented.** Custom field ids in `che_file61_field_keys.php`, then configured field names, then ID Card `documentNumber`, then JSON keys. Passport numbers belong in `Person_Alternate_Id`, not `National_Id`.

## Moodle

### BR-028 — Match people before writing grades

**Documented.** A Moodle user is applied only when username, email, or idnumber matches a Gibbon person. At least one must match.

### BR-029 — Group name picks the class

**Documented.** If Moodle returns `group_name` or `groupname`, grades go to the class whose `nameShort` or `name` matches. Otherwise Gibbon uses the student's existing class enrolment.

### BR-030 — Cron will not overlap the interval

**Documented.** A scheduled run is skipped when the last run is newer than Sync Interval minutes. HTTP cron requires the secret token. Anyone with the token can start a sync. **Documented** warning.

## Platform rules that affect behaviour

### BR-031 — Database must be at the code version

**Implemented.** `index.php` sends an authorised user to the System Admin update page when `$versionDB` is older than `$versionCode`.

### BR-032 — Process posts need CSRF and nonce

**Implemented** in `gibbon.php` for scripts whose name contains `Process.php` when `$_POST` has more than one value.

### BR-033 — MFA

**Documented** (v29.0.01):

- Secrets in `gibbonMfa`, AES-256-GCM.
- Missing table means MFA is not configured, so login still works before the database update.
- Lockout columns `verifyFailCount` and `lockedUntil`.
- Public flows named: admissions continue-application, payment status, proof-of-payment upload, via `publicMfaChallenge.php` after email or access-code checks.
- Admins can reset MFA on Edit User when MFA data exists.

Which roles are forced to enrol a token is **Unknown** (a preference and a setting, not a hard-coded DOH rule in the files read).

## Rules that must not be invented from silence

- The pass mark for a specific programme. It is data (`pass_mark` or a grade scale).
- Fee amounts. They are Finance fee rows.
- The live list of campuses.
- Whether a student with a passport and no SA ID may enrol. The SA ID field is documented as required for students, and the validator "only validates South African ID format". A passport path is **Unknown**.
- Academic appeals, supplementary exam scheduling, and who may change a mark after release. Not stated in the files read, beyond the supplementary band used to avoid counting a second failure.
