# Business rules — theear (The Ear)

Evidence date: 2026-10-01. Source: `master` at `f1a82c841`.

Rules are what the code or the SQL seed does. **Inferred** means the behaviour is visible but no comment or document states it as policy. Do not treat an inference as an approved rule.

## Identity

**BR-01 — Password storage.** The posted password is stored and compared as `sha1`. Evidence: `Login::validate_login`, `Login::register`, `User_model::add_user`. Provenance: **Implemented**.

**BR-02 — Who may use the learner login.** Email, sha1 password, `status` 1, `is_disabled` 0, and `role_id` not 1. Role 2, 3, and 4 then receive `user_login`. Evidence: `Login::validate_login`. Provenance: **Implemented**.

**BR-03 — Admin login is a separate URL.** `validate_admin_login` requires `role_id` 1. If `mfa_enabled` is 1, a TOTP code must match `mfa_secret` before `admin_login` is set. Evidence: `Login.php`. Provenance: **Implemented**.

**BR-04 — reCAPTCHA is optional.** Checked only when `get_frontend_settings('recaptcha_status')` is true. Evidence: `Login::validate_login`, `register`, `validate_admin_login`. Provenance: **Implemented**. The stored flag value is **Unknown** (it lives in the database).

**BR-05 — Public sign-up writes role 2.** `Login::register` sets `role_id` to 2, then `User_model::register_user` when the email is new, or `register_user_update_code` when `check_duplication` returns `unverified_user`. A duplicate of an already-verified email redirects to login with "you have already registered". Evidence: `Login::register`. Provenance: **Implemented**.

**BR-06 — Email must be unique on admin-created users.** `check_duplication` failure flashes email duplication and does not insert. Evidence: `User_model::add_user`, `add_institution_user`, `add_tester`, `add_shortcut_user`. Provenance: **Implemented**.

**BR-07 — Student email verification can block the account.** When setting `student_email_verification` is `enable`, registration sets `status` 0. Login requires `status` 1, so an unverified student cannot sign in. Evidence: `Login::register`, `validate_login`. Provenance: **Implemented**. Whether the setting is enabled in production is **Unknown**.

**BR-08 — Institution users start inactive.** `add_institution_user` sets `role_id` 4, `status` 0, and sends `send_password_set_email`. Evidence: `User_model.php`. Provenance: **Implemented**.

**BR-09 — Root admin.** An admin with no `permissions` row passes `is_root_admin`. Other admins need the module key passed to `check_permission`. Evidence: `common_helper.php`, `admin_permission.php`. Provenance: **Implemented**.

**BR-10 — Failed-login suspension is stated in email copy only.** Template "Account Suspended" says more than 8 failed logins in 24 hours. `save_login` only inserts `syslogin`. Enforcement was not found in `Login.php`. Provenance: **Documented in seed email. Enforcement Unknown / Requires Confirmation**.

## Catalogue and practice

**BR-11 — Instruments in the seed.** Drums, Electric Bass, Guitar, Piano, Saxophone, Trumpet, all `blnActive` 1. Evidence: `update.1.rachel.sql`. Provenance: **Documented in SQL**. Later deactivations in the live table are **Unknown**.

**BR-12 — Levels in the seed.** Intro Level (0), Level 1 (1), Level 2 (2). Evidence: same file. Provenance: **Documented in SQL**.

**BR-13 — Hearing mode set.** `hear_sing`, `hear_play`, `hear_write`, `read_sing`. Evidence: `hearing_content.hearing_mode` enum. Provenance: **Documented in SQL**.

**BR-14 — Lesson progress values.** `tracking.status` enum `new`, `in_progress`, `completed`. Provenance: **Documented in SQL**.

**BR-15 — One trial per user per course.** A second free trial is refused when any `payment` row already has `payment_type` `free_trial` for that pair. Evidence: `Crud_model::check_free_trial`, `Home` cart branch that echoes `free_trial_used`. Provenance: **Implemented**.

**BR-16 — Trial calendar.** Feedback job on day 7. Expiry warning on day 12. End-of-trial job on day 14, which sets `enrol.status` to `inactive` for one matching enrol row and updates the trial payment. Lower-level rows (`blnLowerLevel` 0 filter) are excluded from the mail selectors. Evidence: `cron_daily.php` comments and `DATE_ADD` intervals. Provenance: **Implemented**.

## Money

**BR-17 — Payment terms in the seed.** Subscription, Outright 3 Month, Outright 6 Month, Outright 12 Month. Evidence: `payment_terms` insert. Provenance: **Documented in SQL**.

**BR-18 — Checkout dates.** Subscription: next bill in 1 month, recurring amount from the total (or posted monthly price when switching level). Outright 6 Month: bill date in 6 months. Every other non-subscription plan: bill date in 12 months. Evidence: `Home::payfast_checkout`. Provenance: **Implemented**.

**BR-19 — Three-month term is not branched in checkout.** A plan string other than `Subscription` or `Outright 6 Month` receives the 12-month date. Conflict with BR-17. **Requires Confirmation**.

**BR-20 — Coupon types.** Once Off Coupon, Special Promotion Coupon, Limited Months Coupon. Evidence: `coupon_type` seed. Provenance: **Documented in SQL**. How each type changes the amount is in coupon application code that was not line-audited. Effect on price: **Requires Confirmation** beyond the type names.

**BR-21 — 100% coupon is its own payment path.** `Home::coupon_100_payment` exists. Daily job `RevokeCoupon100Access` exists. Provenance: **Implemented**. The duration of that access was not read. **Unknown**.

**BR-22 — Monthly failure grace.** Case comment on `CancelSubscriptionOnPaymentFailure`: "After the 5 days grace period". The job loads PayFast subscriptions where `payment_type` is `payfast`, token is not empty, `payment_term_id` is 1, and status is not `Cancelled`. Email template "Payment Failure Notification" tells the learner to update the card within 3 days. Conflict: 5 days in the cron comment, 3 days in the template. **Requires Confirmation** which number operations uses. The PayFast status check is **Implemented**; the day count in the comment was not re-derived from a date predicate in the portion read.

**BR-23 — Invoices from credit-signed PayFast rows.** `GenerateInvoices` selects `payfasttransactionshistory` where `invoice_generated` is 0 and `sign` is `CREDIT`, then queues work and sets `invoice_generated` to 1. Provenance: **Implemented**.

**BR-24 — Access revocation jobs exist** for 6-month outright, 12-month outright, cancelled monthly subscriptions, and lower-level courses. Provenance: **Implemented** as cron cases. Exact date predicates were not all read.

**BR-25 — Currency rates.** `Cron::updateCurrencies` and `Cron_model::updateCurrencies` update the `currency` table. `update.1.rachel.sql` adds `dblExchangeRate` and `blnMainCurrency`. Provenance: **Implemented**. The rate source inside the model was not read.

## Lifecycle mail

**BR-26 — Check-in.** Users with `status` 1 whose `date_added` is 3 days before today, with no `payment` row and no `syslogin` row, are queued as CheckIn. Header comment in `cron_hourly.php` says 2 days. The query uses 3. Provenance: **Implemented** (3 days).

**BR-27 — Still deciding.** Same filters at 8 days. The inner comment still says "past three days" for the login check, but the code only tests that no `syslogin` row exists at all. Provenance: **Implemented** as "no login row", not "no login in three days".

**BR-28 — End of trial mail is skipped if a PayFast payment exists** for that user and course. The enrol row is still set inactive in that same case, including when a PayFast payment exists (the status update is outside the `if (!$result_course_enrolment)` block). Provenance: **Implemented**. Whether inactivating the trial enrol also inactivates a paid enrol for the same course is **Requires Confirmation** (the update is `user_id` + `course_id`, limit 1, no `payment_type` filter).

## Access control exceptions

**BR-29 — Daily cron, hourly cron, and Xero entry points do not check `admin_login`.** The checks are commented out in `Admin::run_cron_daily`, `run_cron_hourly`, `xero_crons_authorization`, `xero_callback`, and `run_xero_manual_journals_cron`. Provenance: **Implemented**. This is a behaviour conflict with the rest of `Admin`, which does require the session.

**BR-30 — REST auth is off.** `rest_auth` is FALSE and `force_https` is FALSE. Individual `Api` methods that need a user decode `auth_token`. Discovery methods do not. Provenance: **Implemented**.

**BR-31 — JWT signing key is the literal** `academy-lms-api-token-handler`. Evidence: `TokenHandler.php`. Provenance: **Implemented**.

## Institutions and affiliates

**BR-32 — Institution status defaults to 1** on insert in the SQL. `date_deactivated` is `NOT NULL` with no default in that CREATE. Provenance: **Documented in SQL**. Whether inserts from PHP supply `date_deactivated` was not confirmed.

**BR-33 — Affiliate commission is a string column** `commission_percentage` on `affiliates`, plus `affiliate_payouts` with `processed_payments` JSON and `total_amount`. Provenance: **Documented in SQL**. The percentage formula in PHP was not line-audited.

## What is not a rule

No terms-of-sale document was read from the database-driven CMS pages (`terms_and_condition`, `privacy_policy`, `cookie_policy`). Those pages render stored content. The legal text is **Unknown** from this repository alone.

The PDF manuals were not extracted. They are not cited as rules here.
