# FULL ERP UPGRADE PLAN — Code Quality: B-/C+ → A

**Project**: Club ERP (Custom PHP 8.1+ Framework)
**Current State**: 1,196 PHP files, ~126K lines, 57 modules, 0 tests, no Composer
**Goal**: Production-grade code quality without rewriting the framework

---

## VERIFIED FACTS (From Full Audit — 2026-05-13)

These facts were verified by reading every core file and grepping the entire codebase. Any step in this plan that contradicts these facts is WRONG — do not hallucinate alternatives.

### Deployment Facts (CRITICAL — READ FIRST)
- **Pipeline**: GitLab push → CapRover force deploy → Docker container rebuild
- **Dockerfile**: `php:8.2-apache`, uses `COPY . /var/www/html/` (copies entire repo into image)
- **NO `.gitignore`** exists in this repo — only `.DS_Store` is untracked
- **`.env` IS committed to git** (and also generated dynamically by `entrypoint.sh` from CapRover env vars at container start — the committed `.env` is OVERWRITTEN at boot)
- **`vendor/` is NOT tracked** (0 files) and NOT in the Dockerfile — there is NO `composer install` step. Currently no Composer at all.
- **Container boot sequence**: `entrypoint.sh` → generates `.env` from env vars → runs `docker/boot.php` → `boot.php` requires Autoloader, runs migrations + seeds → `apache2-foreground`
- **`docker/boot.php`** directly requires `app/Core/Autoloader.php` and calls `Autoloader::register()` (line 65). Also uses `App\Core\Migration\MigrationRunner` and `App\Core\Seeder\SeederRunner` classes.
- **No rollback mechanism** — force deploy means broken code = broken production until next push
- **No CI pipeline file** (`.gitlab-ci.yml`) found — deploy is likely a raw git push to CapRover's git receiver
- **Apache DocumentRoot**: `/var/www/html/public`
- **OPcache enabled** in production (`opcache.validate_timestamps = 1`, revalidate every 2s)
- **No `.gitignore` file exists** — this means adding `vendor/` requires creating one AND modifying the Dockerfile

### Autoloader Facts
- `app/Core/Autoloader.php` maps 4 prefixes: `App\Core\` → `app/Core/`, `App\Modules\` → `app/Modules/`, `App\Middleware\` → `app/Middleware/`, `App\Shared\` → `app/Shared/`
- `Autoloader::basePath()` is called by `Logger.php` (line 30) and `Config.php` (line 12) for filesystem paths — NOT for autoloading
- 4 entry points register the autoloader: `public/index.php`, `cli.php`, `cron/runner.php`, `docker/boot.php`
- `Autoloader::register()` also eagerly requires `app/Core/Helpers.php`

### Request/Validation Facts
- `Request` class already has: `get()`, `post()`, `input()`, `all()`, `only()`, `file()`, `hasFile()`, `method()`, `path()`, `isAjax()`, `bearerToken()`, `routeParam()`, etc. — it's COMPLETE
- **229 lines** in controllers still use raw `$_POST`/`$_GET`/`$_REQUEST`
- `Controller::validate()` ALWAYS returns the raw `$data` array, even on failure — it never halts, never redirects, never throws
- **15+ controllers proceed with DB writes after failed validation** (Pattern 1 — fire-and-forget)
- **3 controllers have broken boolean checks** (`FacilityGridController`, `ZoneScheduleController`) that never trigger because validate() returns a truthy array
- **ZERO controllers** use `ValidationResult->fails()` directly — all rely on the broken base method

### EventBus Facts
- Simple static pub/sub: `dispatch(string, array)` → calls handlers synchronously
- **36 event listeners** registered across all module bootstraps
- Critical events: `payment.completed`, `payment.voided`, `sale.completed`, `fine.imposed`, `fine.paid`, `subscription.paid`, `installment.paid`, `hr.payroll.paid`
- **Accounting module** is the biggest listener — auto-posts journal entries from 17 different events
- Event chains exist: `payment_request.completed` → Cashier → dispatches `member.activated`, `installment.plan_created`, etc.
- **NO circular event loops** — flows are strictly DAG-shaped
- `member.profile_data` events use pass-by-reference (`array &$data`) for view enrichment — STAY IN CONTROLLERS
- Only **1 listener** (`employee.created` in HR) subscribes to Model lifecycle auto-dispatched events
- All other critical integrations use **manual domain event dispatch from Services**

### Model/DB Facts
- **590 calls** to Model static methods (`::create`, `::find`, `::findOrFail`, `::query()`)
- **313 calls** to Model instance methods (`->save()`, `->update()`, `->archive()`)
- **889 calls** to `App::getInstance()->db()` directly
- Model::create() auto-sets: `created_at`, `updated_at`, `created_by` (from currentEmployee)
- Model::update() auto-sets: `updated_at`, `updated_by`
- Model::archive() auto-sets: `is_archived`, `archived_at`, `archived_by`
- `PaymentService` already uses raw `$db->insert()` (NOT `Payment::create()`) — the critical payment flow ALREADY bypasses Model
- Only `AccountBalance` model disables event dispatch (`$dispatchEvents = false`)

### Template/View Facts
- Template engine does NOT use Autoloader — has its own `resolvePath()` using `dirname(__DIR__, 2)`
- Views call static methods directly: `BrandingService::clubNameEn()`, `MenuRegistry::getAll()`, `CSRF::token()`
- `App::getInstance()->currentEmployee()` is used in sidebar, header, and 18+ other locations
- Adding interfaces will NOT break views AS LONG AS `App::getInstance()` return types don't change

### Auth/Middleware Facts
- Router instantiates middleware via `new $class()` (zero constructor args) at line 122
- Router instantiates controllers via `new $controllerClass($request)` at line 140
- **AuthMiddleware** sets currentEmployee on App singleton at line 38
- **115 POST routes LACK CSRF middleware** (38% of state-changing routes!) — PRE-EXISTING security gap
- **ExceptionHandler** routes by `$e->getCode()` integer (401/403/404 → specific pages, else → 500)
- ExceptionHandler does NOT use `instanceof` — custom exception classes are safe to add if they set the correct integer code

### Cross-Module Coupling Facts
- **Payments ↔ Members** is bidirectional (circular via imports)
- Members module imports: Cashier, Forms, Payments, Pricing, Rules, ServiceCatalog, Workflow
- Transfers imports: Archive, Cashier, Forms, Members, Payments, Pricing, Rules, ServiceCatalog
- Accounting imports Payments (PaymentService)

---

## PHASE 0: FOUNDATION (Do First — Everything Else Depends On This)

### 0.0 — Fix Deployment Prerequisites (MUST DO BEFORE ANYTHING ELSE)

**Why**: There is NO `.gitignore`. Adding Composer means `vendor/` (50MB+) must either be committed (bad) or excluded (requires `.gitignore` + Dockerfile change). Since deploy is `COPY . /var/www/html/`, we need Composer to run INSIDE the Docker build.

**Steps**:

1. Create `.gitignore`:
   ```
   /vendor/
   /node_modules/
   .php-cs-fixer.cache
   .phpunit.result.cache
   /storage/logs/*.log
   /storage/cache/*
   .DS_Store
   ```
   **NOTE**: Do NOT gitignore `.env` — it's already committed and the entrypoint overwrites it anyway. The committed `.env` serves as documentation/defaults.

2. Modify `Dockerfile` — add Composer install step BEFORE the `COPY`:
   ```dockerfile
   FROM php:8.2-apache

   # ── System dependencies ──
   RUN apt-get update && apt-get install -y \
       libpng-dev \
       libjpeg-dev \
       libfreetype6-dev \
       libicu-dev \
       libzip-dev \
       libonig-dev \
       zip \
       unzip \
       default-mysql-client \
       dos2unix \
       git \
       && rm -rf /var/lib/apt/lists/*

   # ── Install Composer ──
   COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

   # ── PHP extensions ──
   RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
       && docker-php-ext-install -j$(nproc) \
           pdo_mysql \
           bcmath \
           mbstring \
           gd \
           intl \
           zip \
           opcache \
           fileinfo

   # ── Apache modules ──
   RUN a2enmod rewrite headers

   # ── PHP configuration ──
   COPY docker/php.ini /usr/local/etc/php/conf.d/99-club-erp.ini

   # ── Apache vhost ──
   COPY docker/000-default.conf /etc/apache2/sites-available/000-default.conf

   # ── Copy application ──
   COPY . /var/www/html/

   # ── Install PHP dependencies (production only, no dev) ──
   WORKDIR /var/www/html
   RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts

   # ── Create storage directories ──
   RUN mkdir -p \
       /var/www/html/storage/logs \
       /var/www/html/storage/uploads/documents \
       /var/www/html/storage/uploads/photos \
       /var/www/html/storage/uploads/forms \
       /var/www/html/storage/cache \
       /var/www/html/storage/sessions

   # ── Permissions ──
   RUN chown -R www-data:www-data /var/www/html/storage \
       && chmod -R 775 /var/www/html/storage \
       && chown -R www-data:www-data /var/www/html/public \
       && chmod -R 755 /var/www/html/public

   # ── Environment defaults (overridden by CapRover env vars) ──
   ENV APP_URL=http://localhost
   ENV APP_DEBUG=true
   ENV APP_ENV=local
   ENV DB_HOST=srv-captain--mysql-db
   ENV DB_PORT=3306
   ENV DB_NAME=the_club_erp
   ENV DB_USER=root
   ENV DB_PASS=Alarcade123#
   ENV SMS_PROVIDER=
   ENV SMS_API_KEY=
   ENV SMS_SENDER_ID=

   # ── Entrypoint ──
   COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
   RUN dos2unix /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh

   EXPOSE 80

   ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
   ```

   **Key changes from current Dockerfile**:
   - Added `git` to apt-get (Composer may need it)
   - Added `COPY --from=composer:2 /usr/bin/composer /usr/bin/composer` (multi-stage copy, no extra image)
   - Added `RUN composer install --no-dev --optimize-autoloader` after `COPY . /var/www/html/`
   - Added `WORKDIR /var/www/html` before composer install
   - `--no-dev` means PHPUnit/PHPStan/CS-Fixer are NOT in the production container (they're `require-dev`)
   - `--optimize-autoloader` generates a classmap for faster autoloading in production

3. Modify `docker/boot.php` — replace Autoloader with Composer:
   ```php
   // REPLACE line 65:
   // require_once '/var/www/html/app/Core/Autoloader.php';
   // \App\Core\Autoloader::register();

   // WITH:
   require_once '/var/www/html/vendor/autoload.php';
   ```

4. **TEST LOCALLY before pushing**:
   ```bash
   docker build -t club-erp-test .
   docker run --rm -e DB_HOST=host.docker.internal -e DB_PORT=3306 -e DB_NAME=the_club_erp -e DB_USER=root -e DB_PASS='' club-erp-test
   ```
   Verify: container starts, migrations run, Apache responds on port 80.

**DANGER**: If you push a broken Dockerfile, production goes down with NO rollback. Test the Docker build locally FIRST. If the build fails, CapRover will keep the old container running (it only replaces on successful build). But if the build succeeds and the app crashes at runtime (boot.php fails), production is down.

---

### 0.1 — Add Composer (Autoloading + Package Ecosystem)

**Steps**:
1. Create `composer.json` in project root:
   ```json
   {
     "name": "club/erp",
     "type": "project",
     "require": {
       "php": ">=8.1"
     },
     "require-dev": {
       "phpunit/phpunit": "^10.5",
       "phpstan/phpstan": "^1.10",
       "friendsofphp/php-cs-fixer": "^3.0"
     },
     "autoload": {
       "psr-4": {
         "App\\Core\\": "app/Core/",
         "App\\Modules\\": "app/Modules/",
         "App\\Middleware\\": "app/Middleware/",
         "App\\Shared\\": "app/Shared/"
       },
       "files": ["app/Core/Helpers.php"]
     },
     "autoload-dev": {
       "psr-4": {
         "Tests\\": "tests/"
       }
     }
   }
   ```
2. Run `composer install`
3. In `public/index.php` — replace `require` of Autoloader + `Autoloader::register()` with `require __DIR__ . '/../vendor/autoload.php'`
4. In `cli.php` — same replacement
5. In `cron/runner.php` — same replacement
6. In `docker/boot.php` — same replacement
7. **DO NOT DELETE `app/Core/Autoloader.php`** — keep class with only `basePath()` method intact:
   ```php
   final class Autoloader
   {
       public static function basePath(): string
       {
           return dirname(__DIR__, 2);
       }
   }
   ```
   This preserves the 2 call sites in `Logger.php` and `Config.php` without modification.
8. Verify: boot app via browser, run `php cli.php migrate:status`, run `cron/runner.php`

**DANGER**: If `cron/runner.php` or `docker/boot.php` are missed, those entry points break silently (cron fails at night, Docker container won't start).

---

### 0.2 — Add PHPStan (Static Analysis)

**Steps**:
1. Create `phpstan.neon`:
   ```neon
   parameters:
     level: 3
     paths:
       - app
     excludePaths:
       - app/Modules/*/Views/*
       - app/Core/Autoloader.php
     ignoreErrors:
       - '#Access to an undefined property object::\$\w+#'
       - '#Call to an undefined method object::\w+#'
   ```
   Start at level 3 (NOT 5). Level 5 will produce 500+ errors from the untyped `currentEmployee()` returning `?object`. Raise level after Phase 1.3 (interfaces).

2. Run `vendor/bin/phpstan analyse` — fix level 3 errors first (undefined variables, wrong arg counts)
3. Add `make analyse` to Makefile

---

### 0.3 — Add PHP-CS-Fixer (Code Style)

Same as before — no safety concerns. Formatting is pure cosmetics.

**Steps**:
1. Create `.php-cs-fixer.php`:
   ```php
   <?php
   return (new PhpCsFixer\Config())
       ->setRules([
           '@PSR12' => true,
           'array_syntax' => ['syntax' => 'short'],
           'no_unused_imports' => true,
           'ordered_imports' => ['sort_algorithm' => 'alpha'],
           'single_quote' => true,
           'trailing_comma_in_multiline' => true,
       ])
       ->setFinder(
           PhpCsFixer\Finder::create()
               ->in(__DIR__ . '/app')
               ->name('*.php')
               ->notPath('Modules/*/Views/')
       );
   ```
2. Run `vendor/bin/php-cs-fixer fix` — commit as isolated formatting commit

---

### 0.4 — Add Makefile

```makefile
.PHONY: test analyse fix migrate seed

test:
	vendor/bin/phpunit

analyse:
	vendor/bin/phpstan analyse

fix:
	vendor/bin/php-cs-fixer fix

fix-dry:
	vendor/bin/php-cs-fixer fix --dry-run --diff

migrate:
	php cli.php migrate

seed:
	php cli.php seed
```

---

## PHASE 1: CORE FRAMEWORK HARDENING

### 1.1 — Fix the Validation System (CRITICAL BUG FIX)

**The Problem**: 15+ controllers write invalid data to the database because `validate()` never halts execution. This is an EXISTING BUG, not a new risk.

**SAFE approach** — add a NEW method, keep the old one untouched:

1. Create `app/Core/Exceptions/ValidationException.php`:
   ```php
   <?php
   declare(strict_types=1);
   namespace App\Core\Exceptions;

   class ValidationException extends \RuntimeException
   {
       private array $errors;
       public function __construct(array $errors)
       {
           $this->errors = $errors;
           parent::__construct('Validation failed', 422);
       }
       public function errors(): array { return $this->errors; }
   }
   ```

2. Add to `app/Core/Controller.php` (NEW method — `validate()` stays unchanged):
   ```php
   protected function validated(array $data, array $rules): array
   {
       $validator = new Validator();
       $result = $validator->validate($data, $rules);
       if ($result->fails()) {
           $session = App::getInstance()->session();
           $session->flash('_old_input', $data);
           $alerts = [];
           foreach ($result->errors() as $field => $fieldErrors) {
               foreach ($fieldErrors as $error) {
                   $alerts[] = ['type' => 'error', 'message' => $error];
               }
           }
           $session->flash('_alerts', $alerts);
           throw new \App\Core\Exceptions\ValidationException($result->errors());
       }
       return $result->validated();
   }
   ```

3. Add to `app/Core/ExceptionHandler.php` — handle ValidationException by redirecting back:
   ```php
   if ($e instanceof \App\Core\Exceptions\ValidationException) {
       $referer = $_SERVER['HTTP_REFERER'] ?? '/';
       header('Location: ' . $referer);
       exit;
   }
   ```
   This goes BEFORE the generic code-based routing (it won't conflict because ExceptionHandler currently only checks codes 401/403/404).

4. **Migration strategy**: Replace `$this->validate($_POST, ...)` with `$data = $this->validated($request->all(), ...)` one controller at a time. The old method continues working (badly, but without crashes).

**DO NOT**: Change the return type or behavior of the existing `validate()` method. 15+ controllers depend on it returning an array.

---

### 1.2 — Migrate Controllers from $_POST to $request

**The Request class already has every method needed.** This is purely find-and-replace.

**IMPORTANT**: Controllers receive `Request $request` as a method parameter (e.g., `public function store(Request $request): Response`). NOT in the constructor. The constructor pattern is `new Controller()` then the router calls the action method with `$request` as the first arg.

**Migration pattern**:
```php
// BEFORE:
$data = $this->validate($_POST, $rules);
$name = $_POST['name'] ?? '';

// AFTER:
$data = $this->validated($request->all(), $rules);
$name = $request->post('name', '');
```

**Order**: Do Tier 4 (admin modules) first as practice, then Tier 1 (core business).

**Execution**: Run `grep -rn '\$_POST\|\$_GET\|\$_REQUEST' app/Modules/{ModuleName}/Controllers/` before and after to verify zero remaining raw superglobal access per module.

---

### 1.3 — Custom Exception Classes

**SAFE because**: ExceptionHandler uses `$e->getCode()` integer routing, not `instanceof`. New exception classes just need to set the correct code.

Create `app/Core/Exceptions/`:
```
NotFoundException.php        — extends \RuntimeException, code 404
ForbiddenException.php       — extends \RuntimeException, code 403
UnauthorizedException.php    — extends \RuntimeException, code 401
ValidationException.php      — already created in 1.1, code 422
BusinessLogicException.php   — extends \RuntimeException, code 409
```

Then UPDATE ExceptionHandler to add `instanceof` checks ABOVE the generic code routing:
```php
// Add these BEFORE the existing code-based switch
if ($e instanceof ValidationException) {
    // redirect back (already added in 1.1)
}
if ($e instanceof BusinessLogicException) {
    // flash error message, redirect back
}
// ... existing code continues for everything else
```

**DO NOT**: Remove or change the existing code-based routing — it's the fallback.

---

### 1.4 — DI Container (Lightweight — Additive Only)

**SAFE because**: We're EXTENDING `App`'s existing `bind()`/`resolve()`, not replacing them.

**DANGER**: Do NOT change how Router instantiates middleware (`new $class()` with zero args) or controllers (`new $controllerClass($request)` with one arg). The DI container is for SERVICE resolution, not for middleware/controller instantiation.

Add to `app/Core/App.php`:
```php
private array $factories = [];

public function singleton(string $key, callable $factory): void
{
    $this->factories[$key] = $factory;
    unset($this->bindings[$key]); // clear any existing instance
}

public function resolve(string $key, $default = null)
{
    // Existing instance?
    if (isset($this->bindings[$key])) {
        return $this->bindings[$key];
    }
    // Factory registered?
    if (isset($this->factories[$key])) {
        $this->bindings[$key] = ($this->factories[$key])($this);
        return $this->bindings[$key];
    }
    return $default;
}
```

**Usage**: Services that need DB can be resolved via container OR instantiated directly. Both patterns work.

**DO NOT**: Make controllers or middleware resolve through the container. They use direct instantiation in `Router.php` and that's fine.

---

## PHASE 2: SERVICE EXTRACTION

### 2.0 — Ground Rules (READ BEFORE TOUCHING ANY MODULE)

1. **Preserve domain event dispatch signatures exactly** — same event name, same `$data` array keys. Listeners in other modules depend on these.
2. **If a Model lifecycle event matters, keep using the Model** — the `employee.created` event in HR bootstrap depends on `Employee::create()`. Do NOT bypass the Model for Employee creation.
3. **Auto-tracked fields** — if you bypass Model and use raw `$db->insert()`, you MUST manually set: `created_at`, `updated_at`, `created_by`. Same for `$db->update()`: `updated_at`, `updated_by`.
4. **Reference-passing events stay in controllers** — `member.profile_data` uses `array &$data` for view enrichment. This pattern is controller/view-layer only and should NOT move into services.
5. **Injectable constructor** — every service must accept `?Database $db = null` so tests can inject a mock/test DB.
6. **One service = one responsibility** — don't create a God service per module.

### 2.1 — Service Template

```php
<?php
declare(strict_types=1);

namespace App\Modules\{Module}\Services;

use App\Core\App;
use App\Core\Database;
use App\Core\EventBus;

final class {Name}Service
{
    private Database $db;

    public function __construct(?Database $db = null)
    {
        $this->db = $db ?? App::getInstance()->db();
    }

    // Public methods here — each does ONE thing
}
```

### 2.2 — Extraction: Accounting ReportController (811 lines — worst offender)

**File**: `app/Modules/Accounting/Controllers/ReportController.php`

Split into:
- `app/Modules/Accounting/Services/Reports/TrialBalanceService.php`
- `app/Modules/Accounting/Services/Reports/GeneralLedgerService.php`
- `app/Modules/Accounting/Services/Reports/IncomeStatementService.php`
- `app/Modules/Accounting/Services/Reports/BalanceSheetService.php`
- `app/Modules/Accounting/Services/Reports/TreasuryReportService.php`
- `app/Modules/Accounting/Services/Reports/RevenueAnalysisService.php`

Each service has one public method: `generate(array $filters): array`
Controller becomes: `return $this->view('...', $service->generate($request->only([...])))`

**SAFE because**: Report methods are read-only (SELECT queries). No events dispatched. No side effects. Zero risk.

### 2.3 — Extraction: MemberController (519 lines)

Already has services: `MemberSearchService`, `BillingService`, `MemberNumberGenerator`, `MembershipRulesService`.

**Move**:
- `index()` SQL → extend `MemberSearchService` with a `search(array $filters, int $page): array` method
- `store()` logic → new `MemberRegistrationService::register(array $data): Member`
- `isSuperAdmin()` → move to `app/Modules/Users/Models/Employee.php` as instance method (or a shared `AuthService`)

**DANGER on `store()`**: The member creation dispatches events AND creates payment requests AND triggers workflow. When extracting:
- Keep `EventBus::dispatch('member.created', [...])` in the service
- Keep the `PaymentRequestService` call in the service
- Keep the `WorkflowEngine` call in the service
- The `member.profile_data` enrichment (if any) stays in the CONTROLLER (reference-passing)

### 2.4 — Extraction: PaymentController (429 lines)

**Already partially extracted** — `PaymentService` exists and handles the critical `payment.completed` event dispatch + raw DB insert.

**Move remaining**: list/filter queries into `PaymentQueryService`.

**DO NOT TOUCH**: `PaymentService::processPayment()` — it's the most critical code path. Accounting auto-posts depend on the `payment.completed` event it dispatches. Only refactor if adding tests simultaneously.

### 2.5 — Extraction Priority Order

| Priority | Module | Risk | Notes |
|----------|--------|------|-------|
| 1 | Accounting Reports | NONE | Read-only, no events |
| 2 | Members (index/search) | LOW | Read-only query extraction |
| 3 | HR (EmployeeProfile) | LOW | Mostly CRUD, 1 event to preserve |
| 4 | Facilities | LOW | CRUD + booking logic |
| 5 | Rentals | LOW | Contract CRUD |
| 6 | TrainingGroups | LOW | CRUD |
| 7 | Members (store/update) | MEDIUM | Events + workflow |
| 8 | Payments (remaining) | MEDIUM | Critical event chain |
| 9 | PlayerAffairs | MEDIUM | Cross-module events |
| 10 | Accounting (non-reports) | HIGH | Journal posting, period closing |

---

## PHASE 3: REPOSITORY PATTERN

### 3.0 — Decision: When Repository vs When Model

**Use Repository** (raw `$db`):
- Complex queries with multiple joins, subqueries, aggregations
- List/search methods that return arrays (not Model instances)
- Reporting queries
- Bulk operations

**Keep using Model** (static methods):
- Simple CRUD where you need: auto-timestamps, auto-`created_by`/`updated_by`, event dispatch
- Finding a single record by ID
- The `Employee::create()` case where `employee.created` event must fire

**NEVER** create a Repository that duplicates what Model already does cleanly. Repository is for COMPLEX QUERIES, not for reimplementing basic CRUD.

### 3.1 — Base Repository

Create `app/Core/Repository.php`:
```php
<?php
declare(strict_types=1);

namespace App\Core;

abstract class Repository
{
    protected Database $db;
    protected static string $table = '';

    public function __construct(?Database $db = null)
    {
        $this->db = $db ?? App::getInstance()->db();
    }

    public function find(int $id): ?array
    {
        return $this->db->selectOne(
            "SELECT * FROM `" . static::$table . "` WHERE id = ? AND is_archived = 0",
            [$id]
        );
    }

    public function findOrFail(int $id): array
    {
        $row = $this->find($id);
        if ($row === null) {
            throw new \RuntimeException('Record not found', 404);
        }
        return $row;
    }

    protected function paginate(string $countSql, string $dataSql, array $params, int $page, int $perPage): array
    {
        $total = (int) ($this->db->selectOne($countSql, $params)['cnt'] ?? 0);
        $offset = ($page - 1) * $perPage;
        $data = $this->db->select($dataSql . " LIMIT {$perPage} OFFSET {$offset}", $params);
        return [
            'data' => $data,
            'pagination' => Pagination::paginate($total, $perPage, $page),
        ];
    }
}
```

**NOTE on `is_archived`**: NOT all tables have this column (e.g., `fines`, `installment_plans`). Repositories for those tables must override `find()` to remove the `is_archived` clause. Check the migration before implementing.

### 3.2 — Target Repositories

Only create repositories for modules with complex queries:
- `app/Modules/Members/Repositories/MemberRepository.php` — search, filtering, stats
- `app/Modules/Accounting/Repositories/AccountRepository.php` — tree traversal, balance calculations
- `app/Modules/Accounting/Repositories/JournalEntryRepository.php` — ledger queries, period filtering
- `app/Modules/HR/Repositories/EmployeeRepository.php` — staffing queries
- `app/Modules/Payments/Repositories/PaymentRepository.php` — payment history, totals

**DO NOT** create repositories for simple CRUD modules (Branches, Roles, Settings). The Model + QueryBuilder is sufficient there.

---

## PHASE 4: TESTING

### 4.1 — Infrastructure

Create:
```
tests/
├── Unit/
│   ├── Core/
│   └── Modules/
├── Integration/
├── TestCase.php
└── DatabaseTestCase.php
```

`phpunit.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="Unit">
      <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="Integration">
      <directory>tests/Integration</directory>
    </testsuite>
  </testsuites>
  <php>
    <env name="DB_NAME" value="club_erp_test"/>
    <env name="APP_ENV" value="testing"/>
  </php>
</phpunit>
```

`tests/TestCase.php`:
```php
<?php
declare(strict_types=1);
namespace Tests;

use PHPUnit\Framework\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    // Common helpers
}
```

`tests/DatabaseTestCase.php`:
```php
<?php
declare(strict_types=1);
namespace Tests;

use App\Core\Database;

abstract class DatabaseTestCase extends TestCase
{
    protected Database $db;

    protected function setUp(): void
    {
        parent::setUp();
        $this->db = new Database(
            env('DB_HOST', '127.0.0.1'),
            (int) env('DB_PORT', 3306),
            env('DB_NAME', 'club_erp_test'),
            env('DB_USER', 'root'),
            env('DB_PASS', '')
        );
        $this->db->beginTransaction();
    }

    protected function tearDown(): void
    {
        $this->db->rollBack();
        parent::tearDown();
    }
}
```

### 4.2 — What to Test (Priority = Money + Complexity)

| Priority | File | Why |
|----------|------|-----|
| 1 | `app/Core/QueryBuilder.php` | Everything depends on it |
| 2 | `app/Core/Validator.php` | 26 rules, all need coverage |
| 3 | `app/Core/Router.php` (matchPath) | Route param extraction |
| 4 | `MembershipRulesService.php` (1191 lines) | Membership eligibility — handles money |
| 5 | `AccountingIntegrationService.php` (1183 lines) | Journal auto-posting — accounting accuracy |
| 6 | `PayrollCalculationService.php` (422 lines) | Salary math |
| 7 | `PaymentService.php` | Payment processing |
| 8 | `WorkflowEngine.php` (422 lines) | State machine — affects member lifecycle |
| 9 | `PeriodClosingService.php` (383 lines) | Fiscal year close — irreversible |

### 4.3 — Testing Services Pattern

Services accept `?Database $db = null` in constructor → inject test DB in tests:
```php
public function testPaymentCompleted(): void
{
    $service = new PaymentService($this->db);
    // arrange: insert test member, test payment
    // act: $service->processPayment(...)
    // assert: journal entry created, event dispatched
}
```

For EventBus testing — dispatch is static and synchronous. Add a test helper:
```php
EventBus::$testMode = true; // Captures dispatched events instead of calling handlers
$dispatched = EventBus::getDispatched(); // Returns array of [event, data]
```

---

## PHASE 5: SECURITY HARDENING

### 5.1 — CSRF on ALL POST Routes (CRITICAL — 115 Routes Missing It)

**This is a pre-existing security vulnerability.**

**Steps**:
1. Run:
   ```bash
   grep -n "'POST'" app/Modules/*/Routes.php | grep -v "csrf"
   ```
2. Add `'csrf'` to the middleware array of every POST route found
3. **Exception**: Routes under `/api/` that use bearer token auth (CSRFMiddleware already skips these)

**DANGER**: Adding CSRF to routes that are called via AJAX without the token will break those requests. Before adding:
- Check if the route has AJAX callers in `public/assets/js/` files
- Ensure `app.js` sends `X-CSRF-TOKEN` header with every AJAX request:
  ```javascript
  // Should already exist — verify in public/assets/js/app.js
  headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }
  ```

### 5.2 — Security Headers

Add to `public/index.php` BEFORE response is sent (or create a `SecurityHeadersMiddleware`):
```php
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
```

### 5.3 — ORDER BY Injection Audit

**Steps**:
1. Run:
   ```bash
   grep -rn 'ORDER BY.*\$' app/Modules/*/Controllers/*.php
   grep -rn 'ORDER BY.*\$' app/Modules/*/Services/*.php
   ```
2. Any ORDER BY that interpolates a user-supplied variable → whitelist allowed column names:
   ```php
   $allowed = ['name', 'created_at', 'amount'];
   $sort = in_array($request->get('sort'), $allowed) ? $request->get('sort') : 'created_at';
   ```

---

## PHASE 6: VIEW LAYER CLEANUP

### 6.1 — CSS Utility Classes

Add to `public/assets/css/main.css`:
```css
/* Utility classes to replace inline styles */
.flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.gap-xs { gap: 5px; }
.gap-sm { gap: 10px; }
.gap-md { gap: 15px; }
.gap-lg { gap: 20px; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.text-xs { font-size: 11px; }
.text-sm { font-size: 12px; }
.text-base { font-size: 13px; }
.text-muted { color: #6B7280; }
.text-success { color: #059669; }
.text-danger { color: #DC2626; }
.text-warning { color: #D97706; }
.text-info { color: #0284C7; }
.font-semibold { font-weight: 600; }
.ltr { direction: ltr; text-align: right; }
.min-w-120 { min-width: 120px; }
.min-w-250 { min-width: 250px; }
.mb-sm { margin-bottom: 10px; }
.mb-md { margin-bottom: 20px; }
.p-md { padding: 15px; }
```

### 6.2 — Status Badge Component

Create `app/Shared/Components/status-badge.php`:
```php
<?php
// Usage: $__template->include('Shared.Components.status-badge', ['status' => $status, 'config' => Member::getStatusConfig()])
$cfg = $config[$status] ?? ['label_ar' => $status, 'color' => '#6B7280'];
?>
<span style="color:<?= e($cfg['color']) ?>;font-weight:600;font-size:13px;">● <?= e($cfg['label_ar']) ?></span>
```

Then in models, add status config:
```php
public static function getStatusConfig(): array
{
    return [
        'active' => ['label_ar' => 'فعال', 'color' => '#059669'],
        // ...
    ];
}
```

### 6.3 — View Migration Order

Replace inline styles module by module. Start with high-traffic views:
1. Members index/show/create/edit
2. Payments index/show
3. Dashboard
4. Accounting reports
5. Everything else

**This is LOW RISK** — purely cosmetic. If a CSS class is wrong, it's immediately visible and trivially fixable.

---

## PHASE 7: ERROR HANDLING & LOGGING

### 7.1 — Structured Logging

Enhance `app/Core/Logger.php`:
```php
private static function write(string $level, string $message, array $context): void
{
    // Add level filtering
    $levels = ['DEBUG' => 0, 'INFO' => 1, 'WARNING' => 2, 'ERROR' => 3];
    $minLevel = $_ENV['LOG_LEVEL'] ?? 'DEBUG';
    if (($levels[$level] ?? 0) < ($levels[$minLevel] ?? 0)) {
        return;
    }

    // Add request context automatically
    $context['_url'] = $_SERVER['REQUEST_URI'] ?? 'cli';
    $context['_method'] = $_SERVER['REQUEST_METHOD'] ?? 'CLI';
    $context['_ip'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';

    // ... rest of existing write logic ...
}
```

### 7.2 — Slow Query Logging

Add to `app/Core/Database.php` in the `query()` method:
```php
public function query(string $sql, array $params = []): \PDOStatement
{
    $start = microtime(true);
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($params);
    $elapsed = microtime(true) - $start;

    if ($elapsed > 0.1) { // 100ms threshold
        Logger::warning("Slow query ({$elapsed}s)", ['sql' => substr($sql, 0, 500)]);
    }

    return $stmt;
}
```

---

## PHASE 8: PERFORMANCE

### 8.1 — File-Based Cache

Create `app/Core/Cache.php`:
```php
<?php
declare(strict_types=1);

namespace App\Core;

final class Cache
{
    private static ?string $dir = null;

    private static function dir(): string
    {
        if (self::$dir === null) {
            self::$dir = Autoloader::basePath() . '/storage/cache';
            if (!is_dir(self::$dir)) {
                @mkdir(self::$dir, 0775, true);
            }
        }
        return self::$dir;
    }

    public static function remember(string $key, int $ttl, callable $factory): mixed
    {
        $file = self::dir() . '/' . md5($key) . '.cache';
        if (file_exists($file) && (time() - filemtime($file)) < $ttl) {
            return unserialize(file_get_contents($file));
        }
        $value = $factory();
        file_put_contents($file, serialize($value), LOCK_EX);
        return $value;
    }

    public static function forget(string $key): void
    {
        @unlink(self::dir() . '/' . md5($key) . '.cache');
    }

    public static function flush(): void
    {
        array_map('unlink', glob(self::dir() . '/*.cache') ?: []);
    }
}
```

**Cache targets** (things that rarely change but are loaded on every request):
- Sidebar menu (built from 57 module bootstraps) — cache for 300s
- Permission list (full permission tree) — cache for 300s
- Chart of accounts tree — cache for 600s
- Branch list — cache for 600s
- `system_config` table — already loaded per-request, cache for 60s

### 8.2 — N+1 Query Detection (Development Only)

Add a query counter to Database that warns when the same table is queried >10 times in a single request (indicates N+1):
```php
// Only in debug mode
private array $queryLog = [];
// In query():
if (App::getInstance()->isDebug()) {
    $this->queryLog[] = $sql;
    // ... check for N+1 pattern
}
```

---

## PHASE 9: API LAYER (Optional — Only If Mobile/SPA Needed)

### 9.1 — API Auth Middleware

Create `app/Middleware/ApiAuthMiddleware.php`:
- Reads `Authorization: Bearer <token>` header
- Validates token against `api_tokens` table
- Sets currentEmployee on App singleton (same as AuthMiddleware)
- Returns 401 JSON on failure

### 9.2 — API Routes Convention

Add to existing module `Routes.php` files:
```php
['GET',  '/api/v1/members',         'Members\Controllers\Api\MemberController@index', ['api_auth'], 'member.view'],
```

**DO NOT** create a separate routing system. Use the existing Router — it already handles JSON responses via `$this->json()`.

### 9.3 — API Response Helper

Create `app/Core/ApiResponse.php`:
```php
final class ApiResponse
{
    public static function success($data, array $meta = []): array
    {
        return ['success' => true, 'data' => $data, 'meta' => $meta, 'errors' => null];
    }

    public static function error(string $message, int $code = 400, array $errors = []): array
    {
        return ['success' => false, 'data' => null, 'meta' => [], 'errors' => $errors ?: [$message]];
    }
}
```

---

## PHASE 10: DEVELOPER EXPERIENCE

### 10.1 — .editorconfig

```ini
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.php]
indent_style = space
indent_size = 4

[*.{js,css}]
indent_style = space
indent_size = 2

[Makefile]
indent_style = tab
```

### 10.2 — Debug Bar (Dev Only)

Create `app/Core/DebugBar.php` — injects at bottom of layout when `APP_DEBUG=true`:
- Query count + total time
- Memory usage
- Current route + permission
- Current employee + role

Include in `app/Shared/Layout/main.php`:
```php
<?php if (App::getInstance()->isDebug()): ?>
    <?php $__template->include('Shared.Components.debug-bar', ['debugData' => App::getInstance()->resolve('_debug')]); ?>
<?php endif; ?>
```

### 10.3 — Route List CLI Command

Add to `cli.php`:
```php
case 'routes':
    // Load all module routes then print table
    // Lists: METHOD | PATH | HANDLER | MIDDLEWARE | PERMISSION
```

---

## EXECUTION TIMELINE

| Week | Phase | Deliverable | Risk |
|------|-------|-------------|------|
| 1 | 0 (Foundation) | Composer, PHPStan level 3, CS-Fixer, Makefile | NONE |
| 2 | 1.1-1.3 (Validation + Exceptions) | `validated()` method, exception classes | LOW |
| 2 | 5.1 (CSRF fix) | All POST routes protected | LOW-MED |
| 3-4 | 2 (Service Extraction — Tier 1) | Accounting reports, Member search | LOW |
| 5-6 | 2 (Service Extraction — Tier 2) | Member store, Payments, HR | MEDIUM |
| 5 | 4.1-4.2 (Test infra + core tests) | PHPUnit running, QueryBuilder/Validator tested | NONE |
| 6-7 | 4.3 (Service tests) | Tests for extracted services | NONE |
| 7 | 3 (Repository Pattern) | Base repository + Member/Accounting repos | LOW |
| 8 | 6 (View cleanup) | CSS utilities, status components | NONE |
| 8 | 7 (Logging) | Structured logs, slow query logging | NONE |
| 9 | 8 (Cache) | File cache for menu/permissions/config | LOW |
| 10 | 1.2 (Migrate $_POST → $request) | All controllers use Request object | LOW |
| 10 | 5.2-5.3 (Security headers, ORDER BY audit) | Hardened | NONE |
| 11-12 | 9 (API layer, if needed) | JSON API endpoints | LOW |

**Total: 10-12 weeks focused work.**

---

## RULES FOR AI EXECUTION (READ THIS EVERY TIME)

### Deployment Rules (HIGHEST PRIORITY)
1. **ALWAYS test Docker build locally** before any push: `docker build -t club-erp-test .`
2. **NEVER push Dockerfile changes without verified local build** — broken build = production down
3. **`docker/boot.php` is the production boot path** — if it crashes, container fails to start, CapRover shows 502
4. **`composer install --no-dev` runs in Docker build** — code must work WITHOUT dev dependencies at runtime
5. **There is NO CI pipeline** — no automated tests run before deploy. Until CI is added, run `make test && make analyse` manually before push
6. **Force deploy has NO rollback** — to "rollback", you must push a revert commit
7. **The committed `.env` is irrelevant in production** — `entrypoint.sh` overwrites it from CapRover env vars every container start

### Framework Rules
8. **NEVER change Router's middleware/controller instantiation** (lines 122, 140 of `Router.php`)
9. **NEVER change `validate()` return type** — add `validated()` as new method
10. **NEVER remove `Autoloader::basePath()`** — Logger and Config depend on it
11. **NEVER bypass Model::create() for Employee** — the `employee.created` event listener depends on it
12. **ALWAYS preserve event dispatch signatures** — same event name string, same $data array keys
13. **ALWAYS keep `App::getInstance()` working** — views, middleware, helpers all depend on it
14. **ALWAYS set timestamps and audit fields** if bypassing Model for raw DB writes
15. **NEVER move `member.profile_data` enrichment into services** — it uses pass-by-reference and belongs in controller/view layer
16. **ALWAYS check if table has `is_archived`** before adding it to Repository queries — `fines`, `installment_plans` don't have it
17. **ALWAYS run `php cli.php migrate:status`** after any core change to verify CLI still boots
18. **ALWAYS test that `cron/runner.php` boots** after autoloader changes
19. **NEVER add constructor args to middleware classes** — Router uses `new $class()` with zero args
20. **When adding CSRF to routes**, check for AJAX callers first — they need the token header
21. **The Payments↔Members bidirectional coupling is known** — do NOT try to break it in this upgrade (that's a future architecture task)
22. **One module at a time** — never refactor multiple modules in a single commit

---

## FILES CREATED/MODIFIED CHECKLIST

### New Files (Phase 0):
- [ ] `composer.json`
- [ ] `phpstan.neon`
- [ ] `.php-cs-fixer.php`
- [ ] `Makefile`
- [ ] `.editorconfig`

### New Files (Phase 1):
- [ ] `app/Core/Exceptions/ValidationException.php`
- [ ] `app/Core/Exceptions/NotFoundException.php`
- [ ] `app/Core/Exceptions/ForbiddenException.php`
- [ ] `app/Core/Exceptions/UnauthorizedException.php`
- [ ] `app/Core/Exceptions/BusinessLogicException.php`

### New Files (Phase 3):
- [ ] `app/Core/Repository.php`

### New Files (Phase 4):
- [ ] `phpunit.xml`
- [ ] `tests/TestCase.php`
- [ ] `tests/DatabaseTestCase.php`
- [ ] `tests/Unit/Core/QueryBuilderTest.php`
- [ ] `tests/Unit/Core/ValidatorTest.php`
- [ ] `tests/Unit/Core/RouterTest.php`

### New Files (Phase 5):
- [ ] `app/Shared/Components/status-badge.php`

### New Files (Phase 7-8):
- [ ] `app/Core/Cache.php`
- [ ] `app/Core/DebugBar.php`

### Modified Files:
- [ ] `public/index.php` — Composer autoload + security headers
- [ ] `cli.php` — Composer autoload + new commands
- [ ] `cron/runner.php` — Composer autoload
- [ ] `docker/boot.php` — Composer autoload
- [ ] `app/Core/Autoloader.php` — strip to basePath() only
- [ ] `app/Core/App.php` — DI container additions (additive only)
- [ ] `app/Core/Controller.php` — add `validated()` method (keep `validate()` intact)
- [ ] `app/Core/ExceptionHandler.php` — add instanceof checks above existing logic
- [ ] `app/Core/Database.php` — slow query logging
- [ ] `app/Core/Logger.php` — level filtering, request context
- [ ] `public/assets/css/main.css` — utility classes
- [ ] `app/Shared/Layout/main.php` — debug bar conditional
- [ ] 121 controller files (gradually, one module at a time)
- [ ] 115 Routes.php POST routes (add CSRF)
