# PRD — theear (The Ear)

Evidence date: 2026-10-01. Source: `git@bitbucket.org:overdrive/theear.git`, `master` at `f1a82c841` (2023-08-10).

There is no prior PRD in Silicon Ark. The repository has PDF guides under `documentation/` that were not text-extracted. Requirements below were recovered from PHP, SQL, and deployment files. Provenance is marked on each item.

## Product purpose

A musician uses The Ear in a browser to learn an instrument through paid or trial courses. Practice includes hearing drills, playgrounds, facets, quizzes, and video. An administrator operates the catalogue, people, institutions, money, and scheduled emails. An instructor can build courses when the `is_instructor` flag is set.

Provenance: **Implemented**. A standalone product statement was not found.

## Users and roles

Roles are rows in `role`, referenced by `users.role_id`. `is_instructor` is a separate flag.

| Role id | Name in code or SQL | What they do | Provenance |
|---|---|---|---|
| 1 | Admin. Comment in `Login::validate_admin_login`: "Role ID 1 is Admin role" | Admin session `admin_login`. Optional TOTP. Permission modules | Implemented. The `role.name` string for id 1 is not in the SQL files read |
| 2 | Used for students. `User_model::get_students` filters `role_id` 2. Registration sets `role_id` 2 | Learner session. Redirect to Earspace | Implemented. Display name in `role.name` is **Unknown / Requires Confirmation** |
| 3 | Tester. `User_model::add_tester` sets `role_id` 3. `update.24.liam.sql` inserts a role named `Tester` without a fixed id | Admin can list testers and their enrol history | Implemented. That the inserted row's id is 3 is **Inferred** (only true if ids 1 and 2 already existed) |
| 4 | `Institution User` | Created against an `institution_id`, `status` 0, then emailed a set-password code | Documented in `update.38.liam.sql` and `User_model::add_institution_user` |

`Login::validate_login` rejects `role_id` 1 and, for role 2, 3, or 4 with `status` 1 and `is_disabled` 0, opens a user session and redirects to `home/profile/earspace#student-area`.

Admin permissions (`application/views/backend/admin/admin_permission.php`): instructor, user, student, enrolment, sign up, deactivated, category, course, revenue, messaging, addon, theme, settings, coupon, affiliates, finance portal, testers, email_templates, institutions. An admin with no `permissions` row is treated as root (`is_root_admin`).

## Main workflows

### W1 — Learner registration

Trigger: `Login::register` from `home/sign_up`.

Inputs: first name, last name, email, password, confirm password, optional username, `enrol_instrument`, affiliate code from session if present. Cell number is stored as a single space.

Behaviour:

1. If frontend setting `recaptcha_status` is true and `check_recaptcha()` fails, redirect to login with a failure message.
2. Empty first name, last name, email, or password redirects to sign-up.
3. Password and confirm password are compared after `sha1`. Mismatch redirects to sign-up.
4. A verification code `rand(100000, 200000)` is stored.
5. If setting `student_email_verification` is `enable`, `users.status` is 0. Otherwise status is 1.
6. `role_id` is set to 2. A new email is inserted with `register_user`. If `check_duplication` returns `unverified_user`, the code is updated instead. An already-registered email redirects to login.

Provenance: **Implemented**.

### W2 — Learner sign-in

Trigger: `Login::validate_login`.

Inputs: email, password. Optional reCAPTCHA as in W1.

Behaviour:

1. Lookup `users` where email, `sha1(password)`, `status` 1, `is_disabled` 0, and `role_id` is not 1.
2. On success, session holds `user_id`, `role_id`, `role` (name from `role` via `get_user_role`), `name`, `is_instructor`, `user_login`.
3. `Crud_model::save_login` inserts `syslogin` (result, username, time, IP from `HTTP_X_FORWARDED_FOR` or `REMOTE_ADDR`).
4. Failure writes `syslogin` with "Invalid Login Credentials" and flashes that phrase.

Provenance: **Implemented**. The email template "Account Suspended" says an account is suspended after more than 8 failed logins in 24 hours (`update.1.rachel.sql`). `save_login` does not itself disable the user. Where the count of 8 is enforced was not found in `Login.php`. Provenance of the lockout rule: **Documented in the email template. Enforcement point Unknown / Requires Confirmation**.

### W3 — Admin sign-in and MFA

Trigger: `Login::validate_admin_login` from `home/admin_login`.

Behaviour: same password hash and status checks, plus `role_id` 1. If `users.mfa_enabled` is 1, the session stores `mfa_user_id` and the browser goes to `home/mfa_verify`. `Login::validate_mfa` checks the posted `user_key` with `PragmaRX\Google2FA` against `users.mfa_secret`. Success sets `admin_login` and redirects to `admin/dashboard`.

Provenance: **Implemented**. Learner login does not use MFA.

### W4 — Browse and open a course

Trigger: `Home::courses`, `Home::course`, `Home::search`.

Default controller is `home` (`routes.php`). Unknown routes render `home/page_not_found`.

Provenance: **Implemented**. Which courses are public versus enrolled-only is decided in `Home::course` and `access_denied_courses`. The full gate was not line-audited. Treat "enrolled users can open lessons" as **Implemented** at `Home::lesson`, and the exact denial copy as **Requires Confirmation** against that private method.

### W5 — Free trial

Trigger: cart enrolment method `free_trial`, then `Home::free_trial_payment`.

Behaviour:

1. `Crud_model::check_free_trial` counts `payment` rows with `payment_type` `free_trial` for that user and course. If the count is greater than 0, the cart path echoes `free_trial_used`.
2. `free_trial_payment` calls `course_purchase` with method `free_trial` and amount 0, then `course_purchase_notification`.
3. Daily job `TrialFeedback` selects trials whose `date_added` is 7 days ago (`blnLowerLevel` 0).
4. `TrialExpiration` selects trials 12 days old. The comment says this is 2 days before the trial ends.
5. `EndOfFreeTrial` selects trials 14 days old. The comment says this is the last day. If no PayFast payment exists for that user and course, a mail job is queued. The same block sets `enrol.status` to `inactive` for that user and course (limit 1) and updates the free-trial `payment` row.

Provenance: **Implemented**. Trial length of 14 days is the comment plus the `INTERVAL +14 DAY` predicate. It is not a named constant.

### W6 — PayFast checkout

Trigger: `Home::payfast_checkout` when the user session is set, or when the argument is the string `true` (mobile request).

Inputs: posted total, `enrolment_plan`, `course_type`, optional `monthly_price`, `course_id`, `payment_id`.

Behaviour (`Home::payfast_checkout`):

1. Amount is the posted total when it matches the session total; otherwise the session total.
2. Plan `Subscription` sets a recurring amount and a billing date one month ahead.
3. Plan `Outright 6 Month` sets the billing date six months ahead.
4. Any other non-subscription plan, including the branch comment "12 month purchase", sets the billing date twelve months ahead.
5. The view `payfast_checkout` is loaded for the active frontend theme.

`payment_terms` seed (`update.1.rachel.sql`): Subscription, Outright 3 Month, Outright 6 Month, Outright 12 Month.

Discrepancy: the seed includes Outright 3 Month. The checkout method has no three-month branch. A posted plan that is not `Subscription` and not `Outright 6 Month` is dated as twelve months. **Requires Confirmation** — whether three-month is still offered in the UI.

Return URL `Home::payfast_payment` looks up the payment by `m_payment_id` and redirects to course management, profile, or enrol depending on `course_type`.

Provenance: **Implemented**.

### W7 — Practise (lesson, hearing, playground, facet)

Trigger: `Home::lesson`, `Home::playground`, `Home::facets`, `Home::update_user_hearing_status`, `Home::update_user_facet_status`.

Hearing modes stored on `hearing_content.hearing_mode`: `hear_sing`, `hear_play`, `hear_write`, `read_sing`. Columns include `notation_a`, `notation_b`, `reference_tone`, `midi_audio`.

`tracking.status` for a lesson: `new`, `in_progress`, `completed`.

Instrument landing pages (`Home::drums`, `guitar`, `piano`, `electric_bass`, `saxophone`, `trumpet`, and `instruments`) set a video URL on S3 bucket `the-ear-prod-transcode` (same walkthrough object on each of those methods at the inspected lines).

Provenance: **Implemented**.

### W8 — Institution onboarding

Trigger: admin `institution_form` / `institution_user_form`.

`institutions` columns in `update.37.liam.sql`: `name`, `domain`, `email`, `date_created`, `date_deactivated`, `status` default 1. The SQL names the schema `prod_22_aug_22`, which is an environment label in the script, not proof of the live database name.

New institution users: `role_id` 4, `status` 0, verification code, email via `send_password_set_email`. Duplicate email is rejected (`check_duplication`).

Provenance: **Implemented**.

### W9 — Admin catalogue and people

`Admin` methods cover categories, subcategories, courses (including pending), sections, lessons, quizzes, instructors and applications, users, deactivated users, testers, institutions, enrol history, coupons, affiliates, tickets, email templates, SMTP, payment settings, theme, languages, revenue and subscription reports, CloudFront invalidation.

Most admin methods redirect to login when `admin_login` is not true, then call `check_permission` for a module. Exceptions are called out in the business rules (cron and Xero entry points).

Provenance: **Implemented**.

### W10 — Scheduled lifecycle mail and invoices

`Admin::run_cron_daily($cronjob)` loads `cron_daily.php`, which switches on the job name and inserts `syscronjob` rows (or, for some jobs, calls PayFast immediately).

`Admin::run_cron_hourly` loads `cron_hourly.php`, which reads `syscronjob` where `status` is `New` and processes: GenerateInvoices, CheckIn, StillDeciding, and further cases in that file (trial and payment mails, credit notes).

Daily job names present in the switch: Test, GetPayfastDailyTransactions, SendUserPurchases, GenerateInvoices, CheckIn, TrialFeedback, TrialExpiration, EndOfFreeTrial, StillDeciding, PaymentFailureOnSubscriptionRenewal, NotificationOfSubscriptionChange6Months, NotificationOfSubscriptionChange12Months, AutomaticRenewalNotification6Months, AutomaticRenewalNotification12Months, CancelSubscriptionOnPaymentFailure, DeactivateDowngradedCourses, GenerateCreditNote, RevokeOnceOffAccess6Months, RevokeOnceOffAccess12Months, RevokeCancelledMonthlySubscriptions, RevokeLowerLevelCourseAccess, RevokeCoupon100Access, UpdateDiscountedSubscriptionAmounts.

Check-in: users whose `date_added` is 3 days ago, status 1, with no `payment` row and no `syslogin` row, are queued. The file header comment says "2 days"; the query uses `INTERVAL +3 DAY`. The query is the behaviour.

Still deciding: same shape at 8 days.

Provenance: **Implemented**.

### W11 — Learner and instructor APIs

`Api` (REST_Controller, default JSON) exposes course discovery without a token (`top_courses_get`, `categories_get`, `login_get`, and others). Methods that need a person read `auth_token` and decode it with `TokenHandler` (HS256, key `academy-lms-api-token-handler`).

`rest.php` sets `rest_auth` to FALSE and `force_https` to FALSE. Route protection is whatever each method does with the token.

`Api_instructor` covers instructor login, profile, course and lesson CRUD, pricing, sales and payout reports, withdrawal requests.

Provenance: **Implemented**.

## Integrations

| System | Direction | Trigger | Provenance |
|---|---|---|---|
| PayFast | Outbound API and redirect checkout | Checkout and daily jobs | Implemented |
| Xero Accounting API | Outbound manual journals | `admin/run_xero_manual_journals_cron` after OAuth views | Implemented |
| AWS S3 | Outbound presign and hardcoded transcode URLs | `get_signed_url`, instrument pages | Implemented |
| CloudFront | Invalidation | `Admin::ajax_cloudfront`, `createTheInvalidation` | Implemented |
| SMTP | Outbound mail | `get_settings` maps host, user, port, and secret | Implemented. Provider name is not in code; the secret key is `ses_smtp_password`, which suggests Amazon SES. **Inferred** |
| reCAPTCHA | Outbound verify | When `recaptcha_status` is true | Implemented. Site keys live in frontend settings, not in the repo |
| Google2FA | Local TOTP verify | Admin MFA | Implemented |

## Constraints

- Passwords are stored as `sha1` of the posted password (`Login.php`, `User_model`).
- `config['encryption_key']` is an empty string.
- One free trial payment row blocks another trial of the same course for that user.
- Institution users are created inactive until the set-password flow.
- Database access depends on functions that are not in this repository.

## Requirements that could not be confirmed

- Live site URL and which PayFast mode (test vs live) production uses. Test mode is read from `payfast_keys` JSON `test_mode`.
- The scheduler identity and network restrictions on cron URLs.
- Whether Outright 3 Month is still sold.
- Whether the 8-login suspension is enforced.
- Contents of the PDF manuals.
- `role.name` for ids 1 and 2.
