# PRD — webportal (Aloecate)

Evidence date: 2026-09-28. Source: `git@bitbucket.org:overdrive/webportal.git`, `master` at `0cd0456` (2023-04-24).

There is no prior PRD in the repository or in Silicon Ark. Every requirement below was recovered from the code, SQL scripts, and deployment files. Provenance is marked on each item.

## Product purpose

Staff operate Aloecate from a browser. App users, through a mobile client that is not in this repository, register, upload till-slip photos, record jobs, and request a cash-out of their balance. Staff review slips, maintain rebate rules, pay cash-outs, and send SMS.

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

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Staff user | Signs in with username or email and a password. Sees menu items allowed by their role | `Login.php`, `users`, `roles`, `role_permissions` | Implemented |
| Admin | Role id 1 in the June 2022 dump | `db/FullDBdump17062022.sql` | Documented in the dump. Live rows after 2022-06-17 are **Unknown** |
| Manager | Role id 2 in the starter seed and the dump | `db/starter.sql`, dump | Documented in SQL |
| App user | Role id 4 titled `App-User`. Uses the mobile API, not the staff session | `app_user.role` default 4, `Api.php` | Implemented |
| Anonymous API caller | Can call `/api/*`. REST auth and API keys are off | `application/config/rest.php` | Implemented |

Staff role titles in the dump: Admin, Manager, User, App-User.

## Main workflows

### W1 — Staff sign-in

Trigger: browser opens the site. Default controller is `dashboard`. `MY_Controller` redirects to `login` when `is_logged()` is false.

Inputs: username (minimum 5 characters; matched to `users.username` or `users.email`), password (minimum 6). Optional Google reCAPTCHA when setting `google_recaptcha_enabled` is `1`. Optional remember-me.

Behaviour (`Users_model::attempt` and `login`):

1. Password matches when `users.password` equals `hash('sha256', posted password)`.
2. `status` must be `'1'`. Otherwise the message is "You are not allowed to Login ! Contact Admin".
3. Unknown username: "Invalid Username/Email". Wrong password: "Invalid Password".
4. On success, session `login_token` is `sha1(id + password hash + time)`. Remember-me sets cookies for 7 days.
5. `setUserlang('es')` runs on every successful login.
6. An activity log row is written with type `user_login`.

Output: redirect to `/` (dashboard). Provenance: **Implemented**.

### W2 — App user registration and verification

Trigger: `POST api/register`.

Required inputs: `name`, `surname`, `cellnumber`, `pass`, `location`, `terms_accepted`. Email and `referral_code` are optional. The email-format rule is commented out.

Behaviour (`Api::register_post`, `User_model::insert`):

1. If `app_user.cellnumber` already exists, respond `result=error`, message "This mobile number has already been used".
2. Otherwise insert `app_user` with `type=user`, `verified=0`, a 4-digit `code` from `rand(1111, 9999)`, the posted password stored as `password`, the posted referral code as `referred_code`, and a new 5-character `referral_code`.
3. SMS: "Welcome to aloecate. Here is your onetime code:" plus the code. This path does not prefix `27` (that rewrite is commented out).
4. `POST api/register_verify` with matching `cellnumber` and `code` sets `verified=1`.

Provenance: **Implemented**. Whether the mobile app hashes `pass` before posting is **Unknown**. The portal stores the posted value as-is.

### W3 — App user login

Trigger: `POST api/login`.

Inputs: `cellnumber` matching `/^[0-9]{10,13}$/`, `pass`.

Behaviour: look up `app_user` where cell number, password, and `verified=1` all match. The response clears `password`. Failed validation returns `result=error`.

Provenance: **Implemented**. There is no session token. Later API methods that take an id in the URL do not check that the caller owns that id.

### W4 — Upload a slip

Trigger: `POST api/submit_slip` with file field `slip_image` and `app_user_id`.

Behaviour:

1. Upload to S3 bucket `aloecate-staging-uploads` at key `{app_user_id}/{originalName}-{time}.{ext}` via `aws_put_file` (default region `eu-west-1`).
2. Insert `aloecate_items` with that filename, `rebate=10`, and `amounts=10`. Product, totals, `file_id`, and `app_user_id` are not set on this insert.

Trigger: `POST api/submit_slip_detail` with `app_user_id`, `filename`, `your_slip`, `clear_slip`.

Behaviour:

1. Insert `file` with filename `{app_user_id}/{filename}` and the two slip flags.
2. Write `transactions` type `slip_upload`, amount `0`, balance unchanged.
3. Activity log type `slip_upload`. The Textract call is commented out.

Provenance: **Implemented**. This request does not credit the balance.

### W5 — Staff review a receipt and run Textract

Trigger: Receipts List (`files`). Permission `reciept_files`.

The list is `file` rows where `type=0` and `YEAR(created_at)=2023`, newest first. Query-string filters `ip` and `user` are passed to the view and are not applied in the query.

`files/view/{id}` (permission `files_view`) loads the file and matching `aloecate_items`.

`files/process/{id}` calls `aws_textract_s3_process` on `aloecate-staging-uploads` and `var_dump`s the result. It does not write items. No permission check on `process`.

`files/save_item/{id}` writes product, amount, totals, and rebate through `file_model->update`.

Provenance: **Implemented**.

### W6 — Aloecation Engine

Trigger: staff opens Aloecation Engine. The permission checked is `user_list`.

Save posts location, product, date_from, date_to, rebate_amount. Insert sets `status=active` and flashes "New Message Created Successfully".

Viewing a rule joins `app_user` to `file` to `aloecate_items`, with `LIKE` on product and on `app_user.location`, and a date range on `aloecate_items.date_created`. The dates are concatenated into the SQL string.

`rebate_process` posts an item id and rebate. When rebate is posted, `Aloecate_items_model::update` sets `processed=1` and writes activity type `rebate_processed`. It does not insert a transaction and does not change `app_user.balance`.

Provenance: **Implemented**. The engine searches items. It does not pay the rebate onto a balance.

### W7 — Cash-out

Trigger: `POST api/cashout/{app_user_id}` with `cash_out_amount`. Cell number and name are used only for the SMS.

Behaviour:

1. Insert `cashout` (`processed` stays 0). Balance is not reduced. The amount is not checked against the balance.
2. SMS "Hi {name}. Your cashout request is being processed" after dropping a leading 0 and prefixing `27`.

Staff list (`cashout`, permission `user_list`) loads rows where `YEAR(created_at)=2023` and attaches the current balance.

`cashout/submit/{id}`:

1. Subtract posted `cashout_amount` from `app_user.balance`. No floor at zero.
2. Set `processed=1`, `paid_out_amount`, and `process_date`. The update key is posted `cashout_id`.
3. Insert `transactions` type `cashout_process`.
4. Activity type `cashout_processed`.

Provenance: **Implemented**. No bank-transfer call exists here. **Unknown / Requires Confirmation** how money leaves the company.

### W8 — Referral reward

Trigger: `app_user/addpoint/{id}` with `cellnumber` and `referred_id`.

Behaviour: add `3` to the referred person's balance. If that update returns `result=true`, set `processed=1` on app user `{id}` and SMS "Hello {name}. you have been rewarded".

Provenance: **Implemented**. The amount 3 is a literal. The controller does not refuse a second call.

### W9 — Jobs

Trigger: `POST api/job_add/{app_user_id}` with employer, reference name, reference telephone, start date, end date.

Behaviour: insert `app_user_jobs` and set `app_user.location` to the employer name. The staff Jobs screen lists that table. The `job` table in the dump is unused by `application/`.

Provenance: **Implemented**.

### W10 — SMS manager

Trigger: staff saves a body and a JSON list of recipient ids. Permission on the list is `user_list`. Save itself has no `ifPermissions` call.

Behaviour: one `messages` row per recipient. If `include_sender` is posted, an extra row uses the staff user id and `is_backend_user=1`. Unprocessed rows (`processed` null) are sent through BulkSMS and then marked `processed=1` even when HTTP status is not 201.

Provenance: **Implemented**. The `messages` table is not created in `starter.sql` or the June 2022 dump. `db/update.2.liam.sql` only adds `is_backend_user`. Columns written by the model are `message`, `to`, `is_backend_user`, and `processed`.

### W11 — Password reset

App user: `POST api/reset_password` stores a new 4-digit code and SMS "Aloecate: Here is your reset password code:". Verify and `update_password` follow.

Staff: `login/forget` emails template `reset_password`. `login/new_password?token=` accepts `users.reset_token`.

Provenance: **Implemented**. `application/config/email.php` is still `smtp.example.com`. `aws_helper.php` comments name SES parameters. Which mail path production uses is **Unknown / Requires Confirmation**.

## Functional requirements

### FR-001 — Staff session gate

Every controller that extends `MY_Controller` redirects to `login` when no staff session exists, and stops with "Database is not configured" when DB username, host, or database name is empty.

Evidence: `application/core/MY_Controller.php`. `Login`, `Logout`, and `Api` do not extend it. Implementation: Implemented. Confidence: High.

### FR-002 — Permission checks

Actions call `ifPermissions` with a code. Codes used in PHP and present in the June 2022 dump: `users_list`, `users_add`, `users_edit`, `users_delete`, `users_view`, `activity_log_list`, `activity_log_view`, `roles_list`, `roles_add`, `roles_edit`, `permissions_list`, `permissions_add`, `permissions_edit`, `permissions_delete`, `company_settings`, `backup_db`, `email_templates`, `general_settings`, `user_list`, `user_add`, `user_edit`, `user_delete`, `user_view`, `jobs_list`, `reciept_files`, `files_view`.

`jobs_list` is seeded and is not what `Jobs.php` checks (`user_list`). `Settings` also checks `login_theme`, which is not in the dump inserts. **Unknown** whether that code exists in the live database.

Dashboard, `files/process`, and `cashout/submit` do not call `ifPermissions`. Engine, cash-out list, messages list, jobs, transactions, and items check `user_list`.

Implementation: Implemented. Confidence: High.

### FR-003 — Mobile API surface

REST default format is JSON. `rest_auth` is false. `rest_enable_keys` is false. `Access-Control-Allow-Origin` is `*`.

| Method | Route | Purpose |
|---|---|---|
| `register_post` | `POST api/register` | Create app user and SMS a code |
| `register_verify_post` | `POST api/register_verify` | Set verified |
| `login_post` | `POST api/login` | Return the user with password blanked |
| `reset_password_post` | `POST api/reset_password` | SMS a reset code |
| `reset_password_verify_post` | `POST api/reset_password_verify` | Check reset code |
| `update_password_post` | `POST api/update_password` | Set a new password |
| `user_update_post` | `POST api/user_update/{id}` | Name, surname, email, bank fields |
| `user_get` | `GET api/user` | All `app_user` rows |
| `app_user_balance_get` | `GET api/app_user_balance/{id}` | One balance |
| `submit_slip_post` | `POST api/submit_slip` | S3 upload and a placeholder item |
| `submit_slip_detail_post` | `POST api/submit_slip_detail` | File row and a zero-amount transaction |
| `jobs_get` | `GET api/jobs/{id}` | Jobs for one user |
| `job_add_post` | `POST api/job_add/{id}` | Add a job and set location |
| `transactions_get` | `GET api/transactions/{id}` | Transactions for one user |
| `cashout_post` | `POST api/cashout/{id}` | Create a cash-out request |
| `user_post` / `user_delete` | create or delete | Present. `user_post` accepts name, pass, and type |

`Rest.php` only exposes `test_post`. Evidence: `Api.php`, `rest.php`. Implementation: Implemented. Confidence: High.

### FR-004 — Receipt line items

`aloecate_items` stores filename, product, totals, amounts, rebate, date_created, file_id, and processed. Staff can list, search, and mark a row processed when a rebate is posted.

Evidence: dump, `Aloecate_items_model`, `Aloecate_items.php`. Implementation: Implemented. Confidence: High.

### FR-005 — Balance and transactions

`app_user.balance` is an integer defaulting to 0. `transactions` stores app_user_id, transaction_type, amount, balance_before, balance_after, transaction_date. Types written by this code: `slip_upload` (amount 0) and `cashout_process`.

The only balance increase found in controllers is `App_user::addpoint` (+3). Slip rebate does not credit the balance.

Evidence: `db/update.5.liam.sql`, `Api.php`, `Cashout_model.php`, `App_user.php`. Implementation: Implemented. Confidence: High.

### FR-006 — Staff settings and backup

Staff with `company_settings` see General, Company, email templates, backup, permissions, roles, and admin users. Backup requires `backup_db`.

Evidence: `nav.php`, `Settings.php`, `Backup.php`. Implementation: Implemented. Confidence: High.

### FR-007 — Activity log

Staff with `activity_log_list` open the log. View requires `activity_log_view`. Column `type` comes from `db/update.3.liam.sql`. Types written in the code read for this PRD: `user_login`, `slip_upload`, `rebate_processed`, `cashout_processed`, `message_created`.

Implementation: Implemented. Confidence: High.

## Integrations

### BulkSMS

Outbound HTTP. Send: `http://api.bulksms.com/v1/messages`. Balance: `https://api.bulksms.com/v1/profile`. HTTP Basic credentials are hardcoded in four PHP files. Values are omitted. Send errors are not surfaced. Broadcast rows are marked processed regardless of HTTP status. Dashboard expects `credits.balance` and does not guard a failed profile call. SSL peer verification is disabled in `sms_balance`.

### AWS S3 and Textract

Slip images go to bucket `aloecate-staging-uploads`. Textract uses `AnalyzeDocument` with FORMS and TABLES. Default region `eu-west-1`. `aws_put_file` uses the default credential chain. Textract and presign use secret `s3_textract`. Presigned URLs last 10 minutes.

`submit_slip_post` inserts an `aloecate_items` row even when the upload result is falsy, because the insert is outside the success branch.

### AWS Secrets Manager

`database.php` reads secret `db_password` keys `host`, `username`, `password`, `dbName`. Region `eu-west-1`. A comment also names `af-south-1` and Xero parameter names. No Xero client code exists under `application/`.

### Google reCAPTCHA

Optional on staff login. Siteverify against Google. Keys live in `settings` when enabled. Seed values are not copied here.

### Email

Staff reset and `Api::send_email` use CI's email library. The committed SMTP file is a placeholder.

## Constraints

- Base URL is hardcoded to `https://webportal.aloecate.co.za/`.
- `ENVIRONMENT` is `production` unless `CI_ENV` is set. DB debug is on when environment is not production.
- Staff pages set the timezone from `setting('timezone')`.
- API authentication switches are off.
- Receipt and cash-out lists include only 2023.
- Personal data and credentials in `db/FullDBdump17062022.sql` and the SMS helpers are not repeated in this PRD.

## Error and edge behaviour

- Duplicate cellphone: error JSON, no second row.
- Validation failure: `result=error` and the stripped message.
- Verify with an unknown pair: `result=false`, message `error`.
- Cash-out insert failure: the method ends without a JSON body.
- `app_user_balance_get` calls `$this->response` three times.
- Empty database configuration: "Database is not configured".
- Dashboard percentage is `items_total / items_scanned * 100` when `items_total > 0`. `items_scanned` is the count where `processed=1`. `items_total` is the count where `processed != 10`. **Inferred** defect: the ratio is inverted, and a zero scanned count is not guarded.
- Item search does not validate the date strings before placing them in SQL.

## Unknown / requires confirmation

- Mobile app location, store listing, and whether it is still calling this API.
- Live `settings` values (company name, timezone, reCAPTCHA).
- Whether staff copy Textract text into items by hand. No code writes Textract blocks into `aloecate_items`.
- Whether cash-out "processed" means the bank was paid.
- Whether a rebate of 10 on every upload is intentional.
