Commit 520392ae authored by Mahmoud Aglan's avatar Mahmoud Aglan

THE CODE UPGRADE

parent 91ab2d74
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
/vendor/
/node_modules/
/composer
.php-cs-fixer.cache
.phpunit.result.cache
/storage/logs/*.log
/storage/cache/*
.DS_Store
<?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/')
);
......@@ -12,8 +12,12 @@ RUN apt-get update && apt-get install -y \
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) \
......@@ -38,6 +42,10 @@ 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 \
......
.PHONY: test analyse fix fix-dry 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
# 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)
<?php
declare(strict_types=1);
namespace App\Core;
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]];
}
public static function paginated(array $data, array $pagination, array $meta = []): array
{
return [
'success' => true,
'data' => $data,
'meta' => array_merge($meta, ['pagination' => $pagination]),
'errors' => null,
];
}
}
......@@ -14,6 +14,7 @@ final class App
private ?object $currentEmployee = null;
private ?array $currentBranch = null;
private array $bindings = [];
private array $factories = [];
private string $basePath;
private bool $booted = false;
......@@ -231,7 +232,7 @@ final class App
return $this->router;
}
public function config(string $key = null, $default = null)
public function config(?string $key = null, $default = null)
{
if ($key === null) {
return $this->config;
......@@ -300,8 +301,21 @@ final class App
$this->bindings[$key] = $value;
}
public function singleton(string $key, callable $factory): void
{
$this->factories[$key] = $factory;
unset($this->bindings[$key]);
}
public function resolve(string $key, $default = null)
{
return $this->bindings[$key] ?? $default;
if (isset($this->bindings[$key])) {
return $this->bindings[$key];
}
if (isset($this->factories[$key])) {
$this->bindings[$key] = ($this->factories[$key])($this);
return $this->bindings[$key];
}
return $default;
}
}
\ No newline at end of file
......@@ -5,45 +5,6 @@ namespace App\Core;
final class Autoloader
{
private static bool $registered = false;
private static array $prefixes = [
'App\\Core\\' => 'app/Core/',
'App\\Modules\\' => 'app/Modules/',
'App\\Middleware\\' => 'app/Middleware/',
'App\\Shared\\' => 'app/Shared/',
];
public static function register(): void
{
if (self::$registered) {
return;
}
spl_autoload_register([self::class, 'load']);
self::$registered = true;
$helpersPath = self::basePath() . '/app/Core/Helpers.php';
if (file_exists($helpersPath)) {
require_once $helpersPath;
}
}
public static function load(string $class): void
{
foreach (self::$prefixes as $prefix => $dir) {
if (strpos($class, $prefix) === 0) {
$relativeClass = substr($class, strlen($prefix));
$file = self::basePath() . '/' . $dir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
}
}
public static function basePath(): string
{
return dirname(__DIR__, 2);
......
<?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') ?: []);
}
}
......@@ -58,6 +58,25 @@ abstract class Controller
return $data;
}
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 Exceptions\ValidationException($result->errors());
}
return $result->validated();
}
protected function authorize(string $permission): void
{
$employee = App::getInstance()->currentEmployee();
......
......@@ -11,6 +11,7 @@ class Database
private array $afterUpdateHooks = [];
private array $afterDeleteHooks = [];
private array $tableCache = [];
private array $tableQueryCounts = [];
public function __construct(string $host = '127.0.0.1', int $port = 3306, string $dbName = '', string $user = 'root', string $pass = '', string $charset = 'utf8mb4')
{
......@@ -35,8 +36,25 @@ class Database
public function query(string $sql, array $params = []): \PDOStatement
{
$start = microtime(true);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$elapsed = microtime(true) - $start;
DebugBar::recordQuery($elapsed);
if ($elapsed > 0.1) {
Logger::warning("Slow query ({$elapsed}s)", ['sql' => substr($sql, 0, 500)]);
}
if (preg_match('/(?:FROM|INTO|UPDATE)\s+`?(\w+)`?/i', $sql, $m)) {
$table = $m[1];
$this->tableQueryCounts[$table] = ($this->tableQueryCounts[$table] ?? 0) + 1;
if ($this->tableQueryCounts[$table] === 11) {
Logger::warning("N+1 detected: table '{$table}' queried 11+ times in single request");
}
}
return $stmt;
}
......
<?php
declare(strict_types=1);
namespace App\Core;
final class DebugBar
{
private static float $startTime;
private static int $queryCount = 0;
private static float $queryTime = 0.0;
public static function init(): void
{
self::$startTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true);
}
public static function recordQuery(float $elapsed): void
{
self::$queryCount++;
self::$queryTime += $elapsed;
}
public static function render(): string
{
$totalTime = round((microtime(true) - self::$startTime) * 1000);
$memory = round(memory_get_peak_usage(true) / 1024 / 1024, 1);
$queryTime = round(self::$queryTime * 1000);
$app = App::getInstance();
$route = '-';
$permission = '-';
$employee = '-';
try {
$emp = $app->currentEmployee();
if ($emp) {
$employee = $emp->full_name_ar ?? ($emp->id ?? '-');
}
} catch (\Throwable $e) {
}
return <<<HTML
<div id="debug-bar" style="position:fixed;bottom:0;left:0;right:0;background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:11px;padding:6px 15px;z-index:99999;direction:ltr;text-align:left;display:flex;gap:20px;align-items:center;border-top:2px solid #0D7377;">
<span style="color:#4ecdc4;font-weight:bold;">DEBUG</span>
<span>Time: <b>{$totalTime}ms</b></span>
<span>Queries: <b>{self::$queryCount}</b> ({$queryTime}ms)</span>
<span>Memory: <b>{$memory}MB</b></span>
<span>Employee: <b>{$employee}</b></span>
</div>
HTML;
}
}
......@@ -6,6 +6,8 @@ namespace App\Core;
final class EventBus
{
private static array $listeners = [];
private static bool $testMode = false;
private static array $dispatched = [];
public static function listen(string $event, callable $handler, int $priority = 0): void
{
......@@ -17,6 +19,11 @@ final class EventBus
public static function dispatch(string $event, array $data = []): array
{
if (self::$testMode) {
self::$dispatched[] = ['event' => $event, 'data' => $data];
return [];
}
if (!isset(self::$listeners[$event])) {
return [];
}
......@@ -50,6 +57,23 @@ final class EventBus
unset(self::$listeners[$event]);
}
public static function enableTestMode(): void
{
self::$testMode = true;
self::$dispatched = [];
}
public static function disableTestMode(): void
{
self::$testMode = false;
self::$dispatched = [];
}
public static function getDispatched(): array
{
return self::$dispatched;
}
public static function dispatchAsync(string $event, array $data = []): void
{
try {
......
......@@ -13,6 +13,23 @@ final class ExceptionHandler
public static function handleException(\Throwable $e): void
{
if ($e instanceof Exceptions\ValidationException) {
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
header('Location: ' . $referer);
exit;
}
if ($e instanceof Exceptions\BusinessLogicException) {
try {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => $e->getMessage()]]);
} catch (\Throwable $t) {
}
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
header('Location: ' . $referer);
exit;
}
Logger::error($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
......
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
class BusinessLogicException extends \RuntimeException
{
public function __construct(string $message = 'Business logic error')
{
parent::__construct($message, 409);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
class ForbiddenException extends \RuntimeException
{
public function __construct(string $message = 'Forbidden')
{
parent::__construct($message, 403);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
class NotFoundException extends \RuntimeException
{
public function __construct(string $message = 'Not found')
{
parent::__construct($message, 404);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
class UnauthorizedException extends \RuntimeException
{
public function __construct(string $message = 'Unauthorized')
{
parent::__construct($message, 401);
}
}
<?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;
}
}
......@@ -36,7 +36,7 @@ if (!function_exists('db')) {
}
if (!function_exists('config')) {
function config(string $key = null, $default = null)
function config(?string $key = null, $default = null)
{
$app = \App\Core\App::getInstance();
if ($key === null) {
......@@ -47,7 +47,7 @@ if (!function_exists('config')) {
}
if (!function_exists('session')) {
function session(string $key = null, $default = null)
function session(?string $key = null, $default = null)
{
$sess = \App\Core\App::getInstance()->session();
if ($key === null) {
......
......@@ -27,6 +27,12 @@ final class Logger
private static function write(string $level, string $message, array $context): void
{
$levels = ['DEBUG' => 0, 'INFO' => 1, 'WARNING' => 2, 'ERROR' => 3];
$minLevel = $_ENV['LOG_LEVEL'] ?? 'DEBUG';
if (($levels[$level] ?? 0) < ($levels[$minLevel] ?? 0)) {
return;
}
$logDir = Autoloader::basePath() . '/storage/logs';
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
......@@ -44,6 +50,10 @@ final class Logger
// ignore
}
$context['_url'] = $_SERVER['REQUEST_URI'] ?? 'cli';
$context['_method'] = $_SERVER['REQUEST_METHOD'] ?? 'CLI';
$context['_ip'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$contextStr = !empty($context) ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = sprintf(
"[%s] [%s] [employee_id:%d] %s%s\n",
......
......@@ -200,7 +200,7 @@ final class QueryBuilder
return $this;
}
private function buildSql(string $selectOverride = null): array
private function buildSql(?string $selectOverride = null): array
{
$select = $selectOverride ?? implode(', ', $this->selects);
$sql = "SELECT {$select} FROM `{$this->table}`";
......
<?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),
];
}
}
......@@ -105,6 +105,7 @@ final class Router
$middlewareMap = [
'csrf' => \App\Middleware\CSRFMiddleware::class,
'auth' => \App\Middleware\AuthMiddleware::class,
'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class,
......
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Users\Models\Employee;
final class ApiAuthMiddleware
{
public function handle(Request $request, callable $next): Response
{
$token = $request->bearerToken();
if (!$token) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Authentication required'],
], 401);
}
$db = App::getInstance()->db();
$tokenRow = $db->selectOne(
"SELECT employee_id, expires_at FROM api_tokens WHERE token = ? AND is_revoked = 0",
[$token]
);
if (!$tokenRow) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Invalid token'],
], 401);
}
if ($tokenRow['expires_at'] && $tokenRow['expires_at'] < date('Y-m-d H:i:s')) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Token expired'],
], 401);
}
$employee = Employee::find((int) $tokenRow['employee_id']);
if (!$employee || !$employee->is_active) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Account inactive'],
], 401);
}
App::getInstance()->setCurrentEmployee($employee);
$db->update('api_tokens', [
'last_used_at' => date('Y-m-d H:i:s'),
], '`token` = ?', [$token]);
return $next($request);
}
}
......@@ -51,17 +51,17 @@ class BankAccountController extends Controller
]);
}
public function store(): Response
public function store(Request $request): Response
{
$this->authorize('accounting.bank_account.manage');
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'account_name_ar' => 'required',
'bank_name_ar' => 'required',
'account_number' => 'required',
]);
$isDefault = (int) ($_POST['is_default'] ?? 0);
$isDefault = (int) ($request->post('is_default', 0));
if ($isDefault) {
$db = App::getInstance()->db();
$db->execute("UPDATE bank_accounts SET is_default = 0 WHERE is_default = 1");
......@@ -69,29 +69,29 @@ class BankAccountController extends Controller
$ba = BankAccount::create([
'account_name_ar' => $data['account_name_ar'],
'account_name_en' => $_POST['account_name_en'] ?? '',
'account_name_en' => $request->post('account_name_en', ''),
'bank_name_ar' => $data['bank_name_ar'],
'bank_name_en' => $_POST['bank_name_en'] ?? null,
'bank_name_en' => $request->post('bank_name_en'),
'account_number' => $data['account_number'],
'iban' => $_POST['iban'] ?? null,
'swift_code' => $_POST['swift_code'] ?? null,
'branch_name' => $_POST['branch_name'] ?? null,
'currency' => $_POST['currency'] ?? 'EGP',
'gl_account_id' => !empty($_POST['gl_account_id']) ? (int) $_POST['gl_account_id'] : null,
'opening_balance' => $_POST['opening_balance'] ?? '0.00',
'current_balance' => $_POST['opening_balance'] ?? '0.00',
'iban' => $request->post('iban'),
'swift_code' => $request->post('swift_code'),
'branch_name' => $request->post('branch_name'),
'currency' => $request->post('currency', 'EGP'),
'gl_account_id' => $request->post('gl_account_id') ? (int) $request->post('gl_account_id') : null,
'opening_balance' => $request->post('opening_balance', '0.00'),
'current_balance' => $request->post('opening_balance', '0.00'),
'is_default' => $isDefault,
'is_active' => 1,
'notes' => $_POST['notes'] ?? null,
'notes' => $request->post('notes'),
]);
// Link GL account
if (!empty($_POST['gl_account_id'])) {
if ($request->post('gl_account_id')) {
$db = App::getInstance()->db();
$db->update('chart_of_accounts', [
'is_bank_account' => 1,
'bank_account_id' => $ba->id,
], '`id` = ?', [(int) $_POST['gl_account_id']]);
], '`id` = ?', [(int) $request->post('gl_account_id')]);
}
$session = App::getInstance()->session();
......@@ -124,21 +124,21 @@ class BankAccountController extends Controller
$account = BankAccount::findOrFail((int) $id);
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'account_name_ar' => 'required',
'bank_name_ar' => 'required',
]);
$account->update([
'account_name_ar' => $data['account_name_ar'],
'account_name_en' => $_POST['account_name_en'] ?? '',
'account_name_en' => $request->post('account_name_en', ''),
'bank_name_ar' => $data['bank_name_ar'],
'bank_name_en' => $_POST['bank_name_en'] ?? null,
'iban' => $_POST['iban'] ?? null,
'swift_code' => $_POST['swift_code'] ?? null,
'branch_name' => $_POST['branch_name'] ?? null,
'is_active' => (int) ($_POST['is_active'] ?? 1),
'notes' => $_POST['notes'] ?? null,
'bank_name_en' => $request->post('bank_name_en'),
'iban' => $request->post('iban'),
'swift_code' => $request->post('swift_code'),
'branch_name' => $request->post('branch_name'),
'is_active' => (int) ($request->post('is_active', 1)),
'notes' => $request->post('notes'),
]);
$session = App::getInstance()->session();
......
......@@ -13,13 +13,13 @@ use App\Modules\Accounting\Services\BankReconciliationService;
class BankReconciliationController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
$this->authorize('accounting.bank_recon.view');
$db = App::getInstance()->db();
$statusFilter = $_GET['status'] ?? '';
$bankAccountFilter = !empty($_GET['bank_account_id']) ? (int) $_GET['bank_account_id'] : 0;
$statusFilter = $request->get('status', '');
$bankAccountFilter = $request->get('bank_account_id') ? (int) $request->get('bank_account_id') : 0;
$where = 'br.is_archived = 0';
$params = [];
......@@ -63,17 +63,17 @@ class BankReconciliationController extends Controller
]);
}
public function store(): Response
public function store(Request $request): Response
{
$this->authorize('accounting.bank_recon.manage');
$this->validate($_POST, [
$this->validate($request->all(), [
'bank_account_id' => 'required|numeric',
'statement_date' => 'required',
'statement_balance' => 'required|numeric',
]);
$result = BankReconciliationService::create($_POST);
$result = BankReconciliationService::create($request->all());
$session = App::getInstance()->session();
if ($result['success']) {
......@@ -110,13 +110,13 @@ class BankReconciliationController extends Controller
{
$this->authorize('accounting.bank_recon.manage');
$this->validate($_POST, [
$this->validate($request->all(), [
'item_type' => 'required',
'description_ar' => 'required',
'amount' => 'required|numeric',
]);
$result = BankReconciliationService::addItem((int) $id, $_POST);
$result = BankReconciliationService::addItem((int) $id, $request->all());
$session = App::getInstance()->session();
if ($result['success']) {
......
......@@ -12,12 +12,12 @@ use App\Modules\Accounting\Models\CostCenter;
class ChartOfAccountsController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
$this->authorize('accounting.coa.view');
$search = trim($_GET['search'] ?? '');
$typeFilter = $_GET['type'] ?? '';
$search = trim($request->get('search', ''));
$typeFilter = $request->get('type', '');
$tree = Account::getTree();
......@@ -46,11 +46,11 @@ class ChartOfAccountsController extends Controller
]);
}
public function store(): Response
public function store(Request $request): Response
{
$this->authorize('accounting.coa.manage');
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'account_code' => 'required',
'name_ar' => 'required',
'name_en' => 'required',
......@@ -65,7 +65,7 @@ class ChartOfAccountsController extends Controller
return $this->redirect('/accounting/chart-of-accounts/create');
}
$parentId = !empty($_POST['parent_id']) ? (int) $_POST['parent_id'] : null;
$parentId = $request->post('parent_id') ? (int) $request->post('parent_id') : null;
$level = 1;
if ($parentId) {
$parent = Account::find($parentId);
......@@ -82,12 +82,12 @@ class ChartOfAccountsController extends Controller
'account_nature' => $data['account_nature'],
'parent_id' => $parentId,
'level' => $level,
'is_header' => (int) ($_POST['is_header'] ?? 0),
'is_header' => (int) ($request->post('is_header', 0)),
'is_active' => 1,
'description_ar' => $_POST['description_ar'] ?? null,
'description_en' => $_POST['description_en'] ?? null,
'opening_balance' => $_POST['opening_balance'] ?? '0.00',
'cost_center_id' => !empty($_POST['cost_center_id']) ? (int) $_POST['cost_center_id'] : null,
'description_ar' => $request->post('description_ar'),
'description_en' => $request->post('description_en'),
'opening_balance' => $request->post('opening_balance', '0.00'),
'cost_center_id' => $request->post('cost_center_id') ? (int) $request->post('cost_center_id') : null,
]);
$session = App::getInstance()->session();
......@@ -123,7 +123,7 @@ class ChartOfAccountsController extends Controller
$account = Account::findOrFail((int) $id);
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'name_ar' => 'required',
'name_en' => 'required',
'account_type' => 'required|in:asset,liability,equity,revenue,expense',
......@@ -131,7 +131,7 @@ class ChartOfAccountsController extends Controller
]);
// Cannot change code of system accounts
if ((int) $account->is_system === 1 && ($_POST['account_code'] ?? '') !== $account->account_code) {
if ((int) $account->is_system === 1 && ($request->post('account_code', '') !== $account->account_code)) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'لا يمكن تعديل كود حساب نظامي']]);
return $this->redirect('/accounting/chart-of-accounts/' . (int) $id . '/edit');
......@@ -142,11 +142,11 @@ class ChartOfAccountsController extends Controller
'name_en' => $data['name_en'],
'account_type' => $data['account_type'],
'account_nature' => $data['account_nature'],
'is_header' => (int) ($_POST['is_header'] ?? 0),
'is_active' => (int) ($_POST['is_active'] ?? 1),
'description_ar' => $_POST['description_ar'] ?? null,
'description_en' => $_POST['description_en'] ?? null,
'cost_center_id' => !empty($_POST['cost_center_id']) ? (int) $_POST['cost_center_id'] : null,
'is_header' => (int) ($request->post('is_header', 0)),
'is_active' => (int) ($request->post('is_active', 1)),
'description_ar' => $request->post('description_ar'),
'description_en' => $request->post('description_en'),
'cost_center_id' => $request->post('cost_center_id') ? (int) $request->post('cost_center_id') : null,
]);
$session = App::getInstance()->session();
......@@ -154,11 +154,11 @@ class ChartOfAccountsController extends Controller
return $this->redirect('/accounting/chart-of-accounts');
}
public function search(): Response
public function search(Request $request): Response
{
$this->authorize('accounting.coa.view');
$term = $_GET['q'] ?? '';
$term = $request->get('q', '');
$results = Account::search($term);
return $this->json($results);
......
......@@ -48,11 +48,11 @@ class CostCenterController extends Controller
]);
}
public function store(): Response
public function store(Request $request): Response
{
$this->authorize('accounting.cost_center.manage');
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'code' => 'required',
'name_ar' => 'required',
'name_en' => 'required',
......@@ -70,10 +70,10 @@ class CostCenterController extends Controller
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'type' => $data['type'],
'branch_id' => !empty($_POST['branch_id']) ? (int) $_POST['branch_id'] : null,
'parent_id' => !empty($_POST['parent_id']) ? (int) $_POST['parent_id'] : null,
'branch_id' => $request->post('branch_id') ? (int) $request->post('branch_id') : null,
'parent_id' => $request->post('parent_id') ? (int) $request->post('parent_id') : null,
'is_active' => 1,
'description' => $_POST['description'] ?? null,
'description' => $request->post('description'),
]);
$session = App::getInstance()->session();
......@@ -109,7 +109,7 @@ class CostCenterController extends Controller
$center = CostCenter::findOrFail((int) $id);
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'name_ar' => 'required',
'name_en' => 'required',
'type' => 'required|in:cost_center,profit_center',
......@@ -119,10 +119,10 @@ class CostCenterController extends Controller
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'type' => $data['type'],
'branch_id' => !empty($_POST['branch_id']) ? (int) $_POST['branch_id'] : null,
'parent_id' => !empty($_POST['parent_id']) ? (int) $_POST['parent_id'] : null,
'is_active' => (int) ($_POST['is_active'] ?? 1),
'description' => $_POST['description'] ?? null,
'branch_id' => $request->post('branch_id') ? (int) $request->post('branch_id') : null,
'parent_id' => $request->post('parent_id') ? (int) $request->post('parent_id') : null,
'is_active' => (int) ($request->post('is_active', 1)),
'description' => $request->post('description'),
]);
$session = App::getInstance()->session();
......
......@@ -40,11 +40,11 @@ class FiscalYearController extends Controller
]);
}
public function store(): Response
public function store(Request $request): Response
{
$this->authorize('accounting.fiscal_year.manage');
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'name_ar' => 'required',
'name_en' => 'required',
'start_date' => 'required',
......@@ -73,7 +73,7 @@ class FiscalYearController extends Controller
return $this->redirect('/accounting/fiscal-years/create');
}
$isCurrent = (int) ($_POST['is_current'] ?? 0);
$isCurrent = (int) ($request->post('is_current', 0));
if ($isCurrent) {
$db->execute("UPDATE fiscal_years SET is_current = 0 WHERE is_current = 1");
}
......@@ -85,7 +85,7 @@ class FiscalYearController extends Controller
'end_date' => $data['end_date'],
'is_current' => $isCurrent,
'status' => 'open',
'notes' => $_POST['notes'] ?? null,
'notes' => $request->post('notes'),
]);
$session = App::getInstance()->session();
......
......@@ -16,18 +16,18 @@ use App\Modules\Accounting\Services\JournalService;
class JournalEntryController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
$this->authorize('accounting.journal.view');
$page = max(1, (int) ($_GET['page'] ?? 1));
$page = max(1, (int) ($request->get('page', 1)));
$filters = [
'status' => $_GET['status'] ?? null,
'date_from' => $_GET['date_from'] ?? null,
'date_to' => $_GET['date_to'] ?? null,
'source_module' => $_GET['source_module'] ?? null,
'fiscal_year_id' => $_GET['fiscal_year_id'] ?? null,
'search' => $_GET['search'] ?? null,
'status' => $request->get('status'),
'date_from' => $request->get('date_from'),
'date_to' => $request->get('date_to'),
'source_module' => $request->get('source_module'),
'fiscal_year_id' => $request->get('fiscal_year_id'),
'search' => $request->get('search'),
];
$result = JournalEntry::search($filters, $page);
......@@ -66,21 +66,24 @@ class JournalEntryController extends Controller
]);
}
public function store(): Response
public function store(Request $request): Response
{
$this->authorize('accounting.journal.create');
$data = $this->validate($_POST, [
$data = $this->validate($request->all(), [
'entry_date' => 'required',
'description_ar' => 'required',
]);
// Parse lines from POST
$lines = [];
$lineAccounts = $_POST['line_account_id'] ?? [];
$lineDebits = $_POST['line_debit'] ?? [];
$lineCredits = $_POST['line_credit'] ?? [];
$lineDescriptions = $_POST['line_description'] ?? [];
$lineAccounts = $request->post('line_account_id', []);
$lineDebits = $request->post('line_debit', []);
$lineCredits = $request->post('line_credit', []);
$lineDescriptions = $request->post('line_description', []);
$lineCostCenterIds = $request->post('line_cost_center_id', []);
$lineBranchIds = $request->post('line_branch_id', []);
for ($i = 0; $i < count($lineAccounts); $i++) {
if (empty($lineAccounts[$i])) {
......@@ -91,20 +94,20 @@ class JournalEntryController extends Controller
'debit' => $lineDebits[$i] ?? '0.00',
'credit' => $lineCredits[$i] ?? '0.00',
'description_ar' => $lineDescriptions[$i] ?? null,
'cost_center_id' => !empty($_POST['line_cost_center_id'][$i]) ? (int) $_POST['line_cost_center_id'][$i] : null,
'branch_id' => !empty($_POST['line_branch_id'][$i]) ? (int) $_POST['line_branch_id'][$i] : null,
'cost_center_id' => !empty($lineCostCenterIds[$i]) ? (int) $lineCostCenterIds[$i] : null,
'branch_id' => !empty($lineBranchIds[$i]) ? (int) $lineBranchIds[$i] : null,
];
}
$result = JournalService::createEntry([
'entry_date' => $data['entry_date'],
'description_ar' => $data['description_ar'],
'description_en' => $_POST['description_en'] ?? null,
'description_en' => $request->post('description_en'),
'reference_type' => 'manual',
'reference_number' => $_POST['reference_number'] ?? null,
'branch_id' => !empty($_POST['branch_id']) ? (int) $_POST['branch_id'] : null,
'cost_center_id' => !empty($_POST['cost_center_id']) ? (int) $_POST['cost_center_id'] : null,
'notes' => $_POST['notes'] ?? null,
'reference_number' => $request->post('reference_number'),
'branch_id' => $request->post('branch_id') ? (int) $request->post('branch_id') : null,
'cost_center_id' => $request->post('cost_center_id') ? (int) $request->post('cost_center_id') : null,
'notes' => $request->post('notes'),
], $lines, false);
$session = App::getInstance()->session();
......@@ -177,11 +180,11 @@ class JournalEntryController extends Controller
return $this->redirect('/accounting/journal-entries/' . (int) $id);
}
public function searchAccounts(): Response
public function searchAccounts(Request $request): Response
{
$this->authorize('accounting.journal.view');
$term = $_GET['q'] ?? '';
$term = $request->get('q', '');
$results = Account::search($term);
return $this->json($results);
......
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\FiscalYear;
......@@ -11,12 +12,12 @@ use App\Modules\Accounting\Services\PeriodClosingService;
class PeriodClosingController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
$this->authorize('accounting.period.view');
$currentFY = FiscalYear::current();
$fiscalYearId = (int) ($_GET['fiscal_year_id'] ?? ($currentFY ? $currentFY->id : 0));
$fiscalYearId = (int) ($request->get('fiscal_year_id') ?? ($currentFY ? $currentFY->id : 0));
$periods = [];
$fiscalYear = null;
......@@ -38,12 +39,12 @@ class PeriodClosingController extends Controller
]);
}
public function closeMonth(): Response
public function closeMonth(Request $request): Response
{
$this->authorize('accounting.period.close');
$fiscalYearId = (int) ($_POST['fiscal_year_id'] ?? 0);
$period = $_POST['period'] ?? '';
$fiscalYearId = (int) ($request->post('fiscal_year_id', 0));
$period = $request->post('period', '');
if ($fiscalYearId <= 0 || empty($period)) {
$session = App::getInstance()->session();
......@@ -63,13 +64,13 @@ class PeriodClosingController extends Controller
return $this->redirect('/accounting/period-closing?fiscal_year_id=' . $fiscalYearId);
}
public function reopenMonth(): Response
public function reopenMonth(Request $request): Response
{
$this->authorize('accounting.period.reopen');
$fiscalYearId = (int) ($_POST['fiscal_year_id'] ?? 0);
$period = $_POST['period'] ?? '';
$reason = $_POST['reason'] ?? '';
$fiscalYearId = (int) ($request->post('fiscal_year_id', 0));
$period = $request->post('period', '');
$reason = $request->post('reason', '');
if ($fiscalYearId <= 0 || empty($period) || empty($reason)) {
$session = App::getInstance()->session();
......
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\FiscalYear;
......@@ -13,6 +14,8 @@ use App\Modules\Accounting\Models\AccountReceivable;
use App\Modules\Accounting\Services\LedgerService;
use App\Modules\Accounting\Services\FinancialReportService;
use App\Modules\Accounting\Services\GLSyncService;
use App\Modules\Accounting\Services\Reports\TreasuryReportService;
use App\Modules\Accounting\Services\Reports\RevenueAnalysisService;
use App\Modules\Payments\Services\PaymentService;
class ReportController extends Controller
......@@ -173,15 +176,15 @@ class ReportController extends Controller
]);
}
public function trialBalance(): Response
public function trialBalance(Request $request): Response
{
$this->authorize('accounting.reports.trial_balance');
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$dateFrom = $request->get('date_from') ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId);
......@@ -202,15 +205,15 @@ class ReportController extends Controller
]);
}
public function trialBalanceExportCsv(): Response
public function trialBalanceExportCsv(Request $request): Response
{
$this->authorize('accounting.reports.trial_balance');
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$dateFrom = $request->get('date_from') ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId);
......@@ -262,18 +265,18 @@ class ReportController extends Controller
exit;
}
public function generalLedger(): Response
public function generalLedger(Request $request): Response
{
$this->authorize('accounting.reports.general_ledger');
$db = App::getInstance()->db();
$accountId = (int) ($_GET['account_id'] ?? 0);
$accountSearch = trim($_GET['account_search'] ?? '');
$accountId = (int) ($request->get('account_id', 0));
$accountSearch = trim($request->get('account_search', ''));
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$dateFrom = $request->get('date_from') ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
if ($accountId === 0 && $accountSearch !== '') {
$found = $db->selectOne(
......@@ -314,15 +317,15 @@ class ReportController extends Controller
]);
}
public function incomeStatement(): Response
public function incomeStatement(Request $request): Response
{
$this->authorize('accounting.reports.income_statement');
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$dateFrom = $request->get('date_from') ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$result = FinancialReportService::getIncomeStatement($dateFrom, $dateTo, $costCenterId, $branchId);
......@@ -343,13 +346,13 @@ class ReportController extends Controller
]);
}
public function balanceSheet(): Response
public function balanceSheet(Request $request): Response
{
$this->authorize('accounting.reports.balance_sheet');
$asOfDate = $_GET['as_of_date'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$asOfDate = $request->get('as_of_date', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$result = FinancialReportService::getBalanceSheet($asOfDate, $costCenterId, $branchId);
......@@ -369,13 +372,13 @@ class ReportController extends Controller
]);
}
public function balanceSheetExportCsv(): Response
public function balanceSheetExportCsv(Request $request): Response
{
$this->authorize('accounting.reports.balance_sheet');
$asOfDate = $_GET['as_of_date'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$asOfDate = $request->get('as_of_date', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$result = FinancialReportService::getBalanceSheet($asOfDate, $costCenterId, $branchId);
......@@ -419,11 +422,11 @@ class ReportController extends Controller
exit;
}
public function consolidatedBalanceSheet(): Response
public function consolidatedBalanceSheet(Request $request): Response
{
$this->authorize('accounting.reports.consolidated');
$asOfDate = $_GET['as_of_date'] ?? date('Y-m-d');
$asOfDate = $request->get('as_of_date', date('Y-m-d'));
$result = FinancialReportService::getConsolidatedBalanceSheet($asOfDate);
return $this->view('Accounting/Views/reports/consolidated_balance_sheet', [
......@@ -432,11 +435,11 @@ class ReportController extends Controller
]);
}
public function accountsReceivable(): Response
public function accountsReceivable(Request $request): Response
{
$this->authorize('accounting.reports.ar');
$memberId = !empty($_GET['member_id']) ? (int) $_GET['member_id'] : null;
$memberId = $request->get('member_id') ? (int) $request->get('member_id') : null;
$outstanding = AccountReceivable::getOutstanding($memberId);
$aging = AccountReceivable::getAgingSummary();
......@@ -447,11 +450,11 @@ class ReportController extends Controller
]);
}
public function accountsPayable(): Response
public function accountsPayable(Request $request): Response
{
$this->authorize('accounting.reports.ap');
$supplierId = !empty($_GET['supplier_id']) ? (int) $_GET['supplier_id'] : null;
$supplierId = $request->get('supplier_id') ? (int) $request->get('supplier_id') : null;
$outstanding = AccountPayable::getOutstanding($supplierId);
$aging = AccountPayable::getAgingSummary();
......@@ -462,15 +465,15 @@ class ReportController extends Controller
]);
}
public function memberStatement(): Response
public function memberStatement(Request $request): Response
{
$this->authorize('accounting.reports.member_statement');
$db = App::getInstance()->db();
$memberId = (int) ($_GET['member_id'] ?? 0);
$memberSearch = trim($_GET['member_search'] ?? '');
$dateFrom = $_GET['date_from'] ?? date('Y-01-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$memberId = (int) ($request->get('member_id', 0));
$memberSearch = trim($request->get('member_search', ''));
$dateFrom = $request->get('date_from', date('Y-01-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
if ($memberId === 0 && $memberSearch !== '') {
$found = $db->selectOne(
......@@ -505,253 +508,54 @@ class ReportController extends Controller
]);
}
/**
* Treasury & Payments report — all payments across all modules.
*/
public function treasury(): Response
public function treasury(Request $request): Response
{
$this->authorize('accounting.reports.treasury');
$db = App::getInstance()->db();
$dateFrom = $_GET['date_from'] ?? date('Y-m-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$paymentType = $_GET['payment_type'] ?? '';
$paymentMethod = $_GET['payment_method'] ?? '';
$search = trim($_GET['search'] ?? '');
$voidedFilter = $_GET['voided'] ?? '0'; // 0 = active, 1 = voided, all = both
$where = 'p.payment_date >= ? AND p.payment_date <= ?';
$params = [$dateFrom, $dateTo];
if ($voidedFilter === '0') {
$where .= ' AND p.is_voided = 0';
} elseif ($voidedFilter === '1') {
$where .= ' AND p.is_voided = 1';
}
if ($paymentType !== '') {
$where .= ' AND p.payment_type = ?';
$params[] = $paymentType;
}
if ($paymentMethod !== '') {
$where .= ' AND p.payment_method = ?';
$params[] = $paymentMethod;
}
if ($search !== '') {
$where .= ' AND (m.full_name_ar LIKE ? OR m.form_number LIKE ? OR r.receipt_number LIKE ? OR p.notes LIKE ?)';
$searchTerm = '%' . $search . '%';
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm]);
}
// Totals
$totalsRow = $db->selectOne(
"SELECT COALESCE(SUM(p.amount), 0) as total_amount, COUNT(*) as total_count
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}",
$params
);
// Paginated results
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$totalCount = (int) ($totalsRow['total_count'] ?? 0);
$totalPages = max(1, (int) ceil($totalCount / $perPage));
$payments = $db->select(
"SELECT p.*, r.receipt_number, m.full_name_ar as member_name, m.form_number,
e.full_name_ar as received_by_name
FROM payments p
LEFT JOIN receipts r ON r.id = p.receipt_id
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN employees e ON e.id = p.received_by_employee_id
WHERE {$where}
ORDER BY p.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
// Summary by method (for the filtered period)
$byMethod = $db->select(
"SELECT p.payment_method, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}
GROUP BY p.payment_method ORDER BY total DESC",
$params
);
$filters = [
'date_from' => $request->get('date_from', date('Y-m-01')),
'date_to' => $request->get('date_to', date('Y-m-d')),
'payment_type' => $request->get('payment_type', ''),
'payment_method' => $request->get('payment_method', ''),
'search' => $request->get('search', ''),
'voided' => $request->get('voided', '0'),
'page' => $request->get('page', 1),
];
// Summary by type (for the filtered period)
$byType = $db->select(
"SELECT p.payment_type, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}
GROUP BY p.payment_type ORDER BY total DESC",
$params
);
$service = new TreasuryReportService();
$result = $service->generate($filters);
$methodLabels = [
'cash' => 'نقدي', 'check' => 'شيك', 'visa' => 'فيزا', 'bank_transfer' => 'تحويل بنكي',
];
return $this->view('Accounting/Views/reports/treasury', [
'payments' => $payments,
'total_amount' => $totalsRow['total_amount'] ?? '0.00',
'total_count' => $totalCount,
'by_method' => $byMethod,
'by_type' => $byType,
'method_labels' => $methodLabels,
'page' => $page,
'total_pages' => $totalPages,
'filters' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'payment_type' => $paymentType,
'payment_method' => $paymentMethod,
'search' => $search,
'voided' => $voidedFilter,
],
]);
return $this->view('Accounting/Views/reports/treasury', array_merge($result, [
'method_labels' => $methodLabels,
'filters' => $filters,
]));
}
/**
* Revenue Analysis — breakdown by type, method, period, branch.
*/
public function revenueAnalysis(): Response
public function revenueAnalysis(Request $request): Response
{
$this->authorize('accounting.reports.revenue_analysis');
$db = App::getInstance()->db();
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$branchWhere = '';
$params = [$dateFrom, $dateTo];
if ($branchId !== null) {
$branchWhere = ' AND m.branch_id = ?';
$params[] = $branchId;
}
// Revenue by payment type
$byType = $db->select(
"SELECT p.payment_type, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$branchWhere}
GROUP BY p.payment_type ORDER BY total DESC",
$params
);
// Revenue by payment method
$byMethod = $db->select(
"SELECT p.payment_method, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$branchWhere}
GROUP BY p.payment_method ORDER BY total DESC",
$params
);
// Monthly breakdown
$monthly = $db->select(
"SELECT DATE_FORMAT(p.payment_date, '%Y-%m') as month,
COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$branchWhere}
GROUP BY month ORDER BY month ASC",
$params
);
// Daily breakdown (current month only)
$monthStart = date('Y-m-01');
$dailyParams = [$monthStart, date('Y-m-d')];
$dailyBranchWhere = '';
if ($branchId !== null) {
$dailyBranchWhere = ' AND m.branch_id = ?';
$dailyParams[] = $branchId;
}
$daily = $db->select(
"SELECT p.payment_date as day, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$dailyBranchWhere}
GROUP BY p.payment_date ORDER BY p.payment_date ASC",
$dailyParams
);
// Grand total
$grandTotal = '0.00';
$grandCount = 0;
foreach ($byType as $t) {
$grandTotal = bcadd($grandTotal, (string) $t['total'], 2);
$grandCount += (int) $t['cnt'];
}
// Revenue by branch (if no specific branch filter)
$byBranch = [];
if ($branchId === null) {
$byBranch = $db->select(
"SELECT COALESCE(b.name_ar, 'بدون فرع') as branch_name, b.id as branch_id,
COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN branches b ON b.id = m.branch_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0
GROUP BY b.id, b.name_ar ORDER BY total DESC",
[$dateFrom, $dateTo]
);
}
$filters = [
'date_from' => $request->get('date_from'),
'date_to' => $request->get('date_to'),
'branch_id' => $request->get('branch_id'),
];
// Voided payments summary
$voidedParams = [$dateFrom, $dateTo];
if ($branchId !== null) {
$voidedParams[] = $branchId;
}
$voidedRow = $db->selectOne(
"SELECT COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 1" .
($branchId !== null ? ' AND m.branch_id = ?' : ''),
$voidedParams
);
$service = new RevenueAnalysisService();
$result = $service->generate($filters);
$methodLabels = [
'cash' => 'نقدي', 'check' => 'شيك', 'visa' => 'فيزا', 'bank_transfer' => 'تحويل بنكي',
];
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Accounting/Views/reports/revenue_analysis', [
'by_type' => $byType,
'by_method' => $byMethod,
'by_branch' => $byBranch,
'monthly' => $monthly,
'daily' => $daily,
'grand_total' => $grandTotal,
'grand_count' => $grandCount,
'voided_total' => $voidedRow['total'] ?? '0.00',
'voided_count' => (int) ($voidedRow['cnt'] ?? 0),
return $this->view('Accounting/Views/reports/revenue_analysis', array_merge($result, [
'method_labels' => $methodLabels,
'branches' => $branches,
'filters' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'branch_id' => $branchId,
],
]);
'filters' => $filters,
]));
}
/**
......
......@@ -8,56 +8,56 @@ return [
// ── Fiscal Years ─────────────────────────────────────────
['GET', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@index', ['auth'], 'accounting.fiscal_year.view'],
['GET', '/accounting/fiscal-years/create', 'Accounting\Controllers\FiscalYearController@create', ['auth'], 'accounting.fiscal_year.manage'],
['POST', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@store', ['auth'], 'accounting.fiscal_year.manage'],
['POST', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@store', ['auth', 'csrf'], 'accounting.fiscal_year.manage'],
['GET', '/accounting/fiscal-years/{id:\d+}', 'Accounting\Controllers\FiscalYearController@show', ['auth'], 'accounting.fiscal_year.view'],
['POST', '/accounting/fiscal-years/{id:\d+}/close', 'Accounting\Controllers\FiscalYearController@close', ['auth'], 'accounting.fiscal_year.close'],
['POST', '/accounting/fiscal-years/{id:\d+}/close', 'Accounting\Controllers\FiscalYearController@close', ['auth', 'csrf'], 'accounting.fiscal_year.close'],
// ── Chart of Accounts ────────────────────────────────────
['GET', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@index', ['auth'], 'accounting.coa.view'],
['GET', '/accounting/chart-of-accounts/create', 'Accounting\Controllers\ChartOfAccountsController@create', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@store', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@store', ['auth', 'csrf'], 'accounting.coa.manage'],
['GET', '/accounting/chart-of-accounts/{id:\d+}/edit', 'Accounting\Controllers\ChartOfAccountsController@edit', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts/{id:\d+}', 'Accounting\Controllers\ChartOfAccountsController@update', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts/{id:\d+}', 'Accounting\Controllers\ChartOfAccountsController@update', ['auth', 'csrf'], 'accounting.coa.manage'],
['GET', '/accounting/chart-of-accounts/search', 'Accounting\Controllers\ChartOfAccountsController@search', ['auth'], 'accounting.coa.view'],
// ── Cost Centers ─────────────────────────────────────────
['GET', '/accounting/cost-centers', 'Accounting\Controllers\CostCenterController@index', ['auth'], 'accounting.cost_center.view'],
['GET', '/accounting/cost-centers/create', 'Accounting\Controllers\CostCenterController@create', ['auth'], 'accounting.cost_center.manage'],
['POST', '/accounting/cost-centers', 'Accounting\Controllers\CostCenterController@store', ['auth'], 'accounting.cost_center.manage'],
['POST', '/accounting/cost-centers', 'Accounting\Controllers\CostCenterController@store', ['auth', 'csrf'], 'accounting.cost_center.manage'],
['GET', '/accounting/cost-centers/{id:\d+}/edit', 'Accounting\Controllers\CostCenterController@edit', ['auth'], 'accounting.cost_center.manage'],
['POST', '/accounting/cost-centers/{id:\d+}', 'Accounting\Controllers\CostCenterController@update', ['auth'], 'accounting.cost_center.manage'],
['POST', '/accounting/cost-centers/{id:\d+}', 'Accounting\Controllers\CostCenterController@update', ['auth', 'csrf'], 'accounting.cost_center.manage'],
// ── Bank Accounts ────────────────────────────────────────
['GET', '/accounting/bank-accounts', 'Accounting\Controllers\BankAccountController@index', ['auth'], 'accounting.bank_account.view'],
['GET', '/accounting/bank-accounts/create', 'Accounting\Controllers\BankAccountController@create', ['auth'], 'accounting.bank_account.manage'],
['POST', '/accounting/bank-accounts', 'Accounting\Controllers\BankAccountController@store', ['auth'], 'accounting.bank_account.manage'],
['POST', '/accounting/bank-accounts', 'Accounting\Controllers\BankAccountController@store', ['auth', 'csrf'], 'accounting.bank_account.manage'],
['GET', '/accounting/bank-accounts/{id:\d+}/edit', 'Accounting\Controllers\BankAccountController@edit', ['auth'], 'accounting.bank_account.manage'],
['POST', '/accounting/bank-accounts/{id:\d+}', 'Accounting\Controllers\BankAccountController@update', ['auth'], 'accounting.bank_account.manage'],
['POST', '/accounting/bank-accounts/{id:\d+}', 'Accounting\Controllers\BankAccountController@update', ['auth', 'csrf'], 'accounting.bank_account.manage'],
// ── Journal Entries ──────────────────────────────────────
['GET', '/accounting/journal-entries', 'Accounting\Controllers\JournalEntryController@index', ['auth'], 'accounting.journal.view'],
['GET', '/accounting/journal-entries/create', 'Accounting\Controllers\JournalEntryController@create', ['auth'], 'accounting.journal.create'],
['POST', '/accounting/journal-entries', 'Accounting\Controllers\JournalEntryController@store', ['auth'], 'accounting.journal.create'],
['POST', '/accounting/journal-entries', 'Accounting\Controllers\JournalEntryController@store', ['auth', 'csrf'], 'accounting.journal.create'],
['GET', '/accounting/journal-entries/{id:\d+}', 'Accounting\Controllers\JournalEntryController@show', ['auth'], 'accounting.journal.view'],
['POST', '/accounting/journal-entries/{id:\d+}/post', 'Accounting\Controllers\JournalEntryController@post', ['auth'], 'accounting.journal.post'],
['POST', '/accounting/journal-entries/{id:\d+}/reverse', 'Accounting\Controllers\JournalEntryController@reverse', ['auth'], 'accounting.journal.reverse'],
['POST', '/accounting/journal-entries/{id:\d+}/post', 'Accounting\Controllers\JournalEntryController@post', ['auth', 'csrf'], 'accounting.journal.post'],
['POST', '/accounting/journal-entries/{id:\d+}/reverse', 'Accounting\Controllers\JournalEntryController@reverse', ['auth', 'csrf'], 'accounting.journal.reverse'],
['GET', '/accounting/journal-entries/search-accounts', 'Accounting\Controllers\JournalEntryController@searchAccounts', ['auth'], 'accounting.journal.view'],
// ── Bank Reconciliation ──────────────────────────────────
['GET', '/accounting/bank-reconciliation', 'Accounting\Controllers\BankReconciliationController@index', ['auth'], 'accounting.bank_recon.view'],
['GET', '/accounting/bank-reconciliation/create', 'Accounting\Controllers\BankReconciliationController@create', ['auth'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation', 'Accounting\Controllers\BankReconciliationController@store', ['auth'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation', 'Accounting\Controllers\BankReconciliationController@store', ['auth', 'csrf'], 'accounting.bank_recon.manage'],
['GET', '/accounting/bank-reconciliation/{id:\d+}', 'Accounting\Controllers\BankReconciliationController@show', ['auth'], 'accounting.bank_recon.view'],
['POST', '/accounting/bank-reconciliation/{id:\d+}/add-item', 'Accounting\Controllers\BankReconciliationController@addItem', ['auth'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation/{id:\d+}/complete', 'Accounting\Controllers\BankReconciliationController@complete', ['auth'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation/{id:\d+}/add-item', 'Accounting\Controllers\BankReconciliationController@addItem', ['auth', 'csrf'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation/{id:\d+}/complete', 'Accounting\Controllers\BankReconciliationController@complete', ['auth', 'csrf'], 'accounting.bank_recon.manage'],
// ── Period Closing ───────────────────────────────────────
['GET', '/accounting/period-closing', 'Accounting\Controllers\PeriodClosingController@index', ['auth'], 'accounting.period.view'],
['POST', '/accounting/period-closing/close-month', 'Accounting\Controllers\PeriodClosingController@closeMonth', ['auth'], 'accounting.period.close'],
['POST', '/accounting/period-closing/reopen-month', 'Accounting\Controllers\PeriodClosingController@reopenMonth', ['auth'], 'accounting.period.reopen'],
['POST', '/accounting/period-closing/close-month', 'Accounting\Controllers\PeriodClosingController@closeMonth', ['auth', 'csrf'], 'accounting.period.close'],
['POST', '/accounting/period-closing/reopen-month', 'Accounting\Controllers\PeriodClosingController@reopenMonth', ['auth', 'csrf'], 'accounting.period.reopen'],
// ── GL Sync (super-admin) ───────────────────────────────
['POST', '/accounting/sync-gl', 'Accounting\Controllers\ReportController@syncGL', ['auth'], 'accounting.fiscal_year.manage'],
['POST', '/accounting/sync-gl', 'Accounting\Controllers\ReportController@syncGL', ['auth', 'csrf'], 'accounting.fiscal_year.manage'],
// ── Reports ──────────────────────────────────────────────
['GET', '/accounting/reports/trial-balance', 'Accounting\Controllers\ReportController@trialBalance', ['auth'], 'accounting.reports.trial_balance'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Reports;
use App\Core\App;
use App\Core\Database;
use App\Modules\Accounting\Models\FiscalYear;
final class RevenueAnalysisService
{
private Database $db;
public function __construct(?Database $db = null)
{
$this->db = $db ?? App::getInstance()->db();
}
public function generate(array $filters): array
{
$currentFY = FiscalYear::current();
$dateFrom = $filters['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $filters['date_to'] ?? date('Y-m-d');
$branchId = !empty($filters['branch_id']) ? (int) $filters['branch_id'] : null;
$branchWhere = '';
$params = [$dateFrom, $dateTo];
if ($branchId !== null) {
$branchWhere = ' AND m.branch_id = ?';
$params[] = $branchId;
}
$byType = $this->db->select(
"SELECT p.payment_type, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$branchWhere}
GROUP BY p.payment_type ORDER BY total DESC",
$params
);
$byMethod = $this->db->select(
"SELECT p.payment_method, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$branchWhere}
GROUP BY p.payment_method ORDER BY total DESC",
$params
);
$monthly = $this->db->select(
"SELECT DATE_FORMAT(p.payment_date, '%Y-%m') as month,
COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$branchWhere}
GROUP BY month ORDER BY month ASC",
$params
);
$monthStart = date('Y-m-01');
$dailyParams = [$monthStart, date('Y-m-d')];
$dailyBranchWhere = '';
if ($branchId !== null) {
$dailyBranchWhere = ' AND m.branch_id = ?';
$dailyParams[] = $branchId;
}
$daily = $this->db->select(
"SELECT p.payment_date as day, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0 {$dailyBranchWhere}
GROUP BY p.payment_date ORDER BY p.payment_date ASC",
$dailyParams
);
$grandTotal = '0.00';
$grandCount = 0;
foreach ($byType as $t) {
$grandTotal = bcadd($grandTotal, (string) $t['total'], 2);
$grandCount += (int) $t['cnt'];
}
$byBranch = [];
if ($branchId === null) {
$byBranch = $this->db->select(
"SELECT COALESCE(b.name_ar, 'بدون فرع') as branch_name, b.id as branch_id,
COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN branches b ON b.id = m.branch_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 0
GROUP BY b.id, b.name_ar ORDER BY total DESC",
[$dateFrom, $dateTo]
);
}
$voidedParams = [$dateFrom, $dateTo];
if ($branchId !== null) {
$voidedParams[] = $branchId;
}
$voidedRow = $this->db->selectOne(
"SELECT COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.payment_date >= ? AND p.payment_date <= ? AND p.is_voided = 1" .
($branchId !== null ? ' AND m.branch_id = ?' : ''),
$voidedParams
);
$branches = $this->db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return [
'by_type' => $byType,
'by_method' => $byMethod,
'by_branch' => $byBranch,
'monthly' => $monthly,
'daily' => $daily,
'grand_total' => $grandTotal,
'grand_count' => $grandCount,
'voided_total' => $voidedRow['total'] ?? '0.00',
'voided_count' => (int) ($voidedRow['cnt'] ?? 0),
'branches' => $branches,
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Reports;
use App\Core\App;
use App\Core\Database;
final class TreasuryReportService
{
private Database $db;
public function __construct(?Database $db = null)
{
$this->db = $db ?? App::getInstance()->db();
}
public function generate(array $filters): array
{
$dateFrom = $filters['date_from'] ?? date('Y-m-01');
$dateTo = $filters['date_to'] ?? date('Y-m-d');
$paymentType = $filters['payment_type'] ?? '';
$paymentMethod = $filters['payment_method'] ?? '';
$search = trim($filters['search'] ?? '');
$voidedFilter = $filters['voided'] ?? '0';
$where = 'p.payment_date >= ? AND p.payment_date <= ?';
$params = [$dateFrom, $dateTo];
if ($voidedFilter === '0') {
$where .= ' AND p.is_voided = 0';
} elseif ($voidedFilter === '1') {
$where .= ' AND p.is_voided = 1';
}
if ($paymentType !== '') {
$where .= ' AND p.payment_type = ?';
$params[] = $paymentType;
}
if ($paymentMethod !== '') {
$where .= ' AND p.payment_method = ?';
$params[] = $paymentMethod;
}
if ($search !== '') {
$where .= ' AND (m.full_name_ar LIKE ? OR m.form_number LIKE ? OR r.receipt_number LIKE ? OR p.notes LIKE ?)';
$searchTerm = '%' . $search . '%';
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm]);
}
$totalsRow = $this->db->selectOne(
"SELECT COALESCE(SUM(p.amount), 0) as total_amount, COUNT(*) as total_count
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}",
$params
);
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$totalCount = (int) ($totalsRow['total_count'] ?? 0);
$totalPages = max(1, (int) ceil($totalCount / $perPage));
$payments = $this->db->select(
"SELECT p.*, r.receipt_number, m.full_name_ar as member_name, m.form_number,
e.full_name_ar as received_by_name
FROM payments p
LEFT JOIN receipts r ON r.id = p.receipt_id
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN employees e ON e.id = p.received_by_employee_id
WHERE {$where}
ORDER BY p.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
$byMethod = $this->db->select(
"SELECT p.payment_method, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}
GROUP BY p.payment_method ORDER BY total DESC",
$params
);
$byType = $this->db->select(
"SELECT p.payment_type, COALESCE(SUM(p.amount), 0) as total, COUNT(*) as cnt
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}
GROUP BY p.payment_type ORDER BY total DESC",
$params
);
return [
'payments' => $payments,
'total_amount' => $totalsRow['total_amount'] ?? '0.00',
'total_count' => $totalCount,
'by_method' => $byMethod,
'by_type' => $byType,
'page' => $page,
'total_pages' => $totalPages,
];
}
}
......@@ -60,8 +60,8 @@ class EnrollWizardController extends Controller
public function getAvailability(Request $request): Response
{
$db = App::getInstance()->db();
$disciplineId = (int) ($_GET['discipline_id'] ?? 0);
$groupType = $_GET['group_type'] ?? 'group';
$disciplineId = (int) ($request->get('discipline_id', '0'));
$groupType = $request->get('group_type', 'group');
if (!$disciplineId) {
return $this->json(['success' => false]);
......@@ -85,7 +85,7 @@ class EnrollWizardController extends Controller
public function getLevels(Request $request): Response
{
$db = App::getInstance()->db();
$disciplineId = (int) ($_GET['discipline_id'] ?? 0);
$disciplineId = (int) ($request->get('discipline_id', '0'));
if (!$disciplineId) {
return $this->json(['success' => false]);
......@@ -106,9 +106,9 @@ class EnrollWizardController extends Controller
public function getPrice(Request $request): Response
{
$disciplineId = (int) ($_GET['discipline_id'] ?? 0);
$groupType = $_GET['group_type'] ?? 'group';
$isMember = ($_GET['is_member'] ?? '1') === '1';
$disciplineId = (int) ($request->get('discipline_id', '0'));
$groupType = $request->get('group_type', 'group');
$isMember = ($request->get('is_member', '1')) === '1';
$db = App::getInstance()->db();
$pricing = $db->selectOne(
......@@ -136,10 +136,10 @@ class EnrollWizardController extends Controller
$this->authorize('academy.enroll');
$db = App::getInstance()->db();
$playerId = (int) ($_POST['player_id'] ?? 0);
$disciplineId = (int) ($_POST['discipline_id'] ?? 0);
$levelId = (int) ($_POST['level_id'] ?? 0);
$groupType = $_POST['group_type'] ?? 'group';
$playerId = (int) ($request->post('player_id') ?? 0);
$disciplineId = (int) ($request->post('discipline_id') ?? 0);
$levelId = (int) ($request->post('level_id') ?? 0);
$groupType = $request->post('group_type') ?? 'group';
if (!$playerId || !$disciplineId) {
return $this->redirect('/activity-subscriptions/enroll')->withError('يجب تحديد اللاعب والنشاط');
......
......@@ -12,7 +12,7 @@ class PlayerSearchController extends Controller
{
public function search(Request $request): Response
{
$q = trim((string) ($_GET['q'] ?? ''));
$q = trim((string) ($request->get('q', '')));
if (mb_strlen($q) < 2) {
return $this->json(['success' => true, 'players' => []]);
}
......
......@@ -4,8 +4,8 @@ declare(strict_types=1);
return [
['GET', '/carnets', 'Carnets\Controllers\CarnetController@index', ['auth'], 'carnet.view'],
['GET', '/carnets/issue/{memberId}', 'Carnets\Controllers\CarnetController@issue', ['auth'], 'carnet.issue'],
['POST', '/carnets/issue/{memberId}', 'Carnets\Controllers\CarnetController@issue', ['auth'], 'carnet.issue'],
['POST', '/carnets/issue/{memberId}', 'Carnets\Controllers\CarnetController@issue', ['auth', 'csrf'], 'carnet.issue'],
['GET', '/carnets/{id}/print', 'Carnets\Controllers\CarnetController@print', ['auth'], 'carnet.print'],
['POST', '/carnets/{id}/deactivate', 'Carnets\Controllers\CarnetController@deactivate', ['auth'], 'carnet.deactivate'],
['POST', '/carnets/{id}/deactivate', 'Carnets\Controllers\CarnetController@deactivate', ['auth', 'csrf'], 'carnet.deactivate'],
['GET', '/carnets/replace/{memberId}', 'Carnets\Controllers\CarnetController@replace', ['auth'], 'carnet.issue'],
];
\ No newline at end of file
......@@ -4,6 +4,6 @@ declare(strict_types=1);
return [
['GET', '/cashier', 'Cashier\Controllers\CashierController@queue', ['auth'], 'cashier.view_queue'],
['GET', '/cashier/{id}', 'Cashier\Controllers\CashierController@process', ['auth'], 'cashier.process_payment'],
['POST', '/cashier/{id}/complete', 'Cashier\Controllers\CashierController@complete', ['auth'], 'cashier.process_payment'],
['POST', '/cashier/{id}/cancel', 'Cashier\Controllers\CashierController@cancel', ['auth'], 'cashier.cancel_request'],
['POST', '/cashier/{id}/complete', 'Cashier\Controllers\CashierController@complete', ['auth', 'csrf'], 'cashier.process_payment'],
['POST', '/cashier/{id}/cancel', 'Cashier\Controllers\CashierController@cancel', ['auth', 'csrf'], 'cashier.cancel_request'],
];
......@@ -3,11 +3,11 @@ declare(strict_types=1);
return [
['GET', '/members/{memberId}/children/create', 'Children\Controllers\ChildController@create', ['auth'], 'child.add'],
['POST', '/members/{memberId}/children', 'Children\Controllers\ChildController@store', ['auth'], 'child.add'],
['POST', '/members/{memberId}/children', 'Children\Controllers\ChildController@store', ['auth', 'csrf'], 'child.add'],
['GET', '/members/{memberId}/children/{id}', 'Children\Controllers\ChildController@show', ['auth'], 'child.view'],
['GET', '/members/{memberId}/children/{id}/edit', 'Children\Controllers\ChildController@edit', ['auth'], 'child.edit'],
['POST', '/members/{memberId}/children/{id}', 'Children\Controllers\ChildController@update', ['auth'], 'child.edit'],
['POST', '/members/{memberId}/children/{id}/archive', 'Children\Controllers\ChildController@archive', ['auth'], 'child.remove'],
['POST', '/members/{memberId}/children/{id}/freeze', 'Children\Controllers\ChildController@freeze', ['auth'], 'child.freeze'],
['POST', '/members/{memberId}/children/{id}', 'Children\Controllers\ChildController@update', ['auth', 'csrf'], 'child.edit'],
['POST', '/members/{memberId}/children/{id}/archive', 'Children\Controllers\ChildController@archive', ['auth', 'csrf'], 'child.remove'],
['POST', '/members/{memberId}/children/{id}/freeze', 'Children\Controllers\ChildController@freeze', ['auth', 'csrf'], 'child.freeze'],
['POST', '/api/children/calculate-fee', 'Children\Controllers\ChildController@calculateFee', ['auth'], 'child.add'],
];
\ No newline at end of file
......@@ -4,8 +4,8 @@ declare(strict_types=1);
return [
['GET', '/death', 'Death\Controllers\DeathController@index', ['auth'], 'transfer.view'],
['GET', '/death/create/{memberId}', 'Death\Controllers\DeathController@create', ['auth'], 'transfer.initiate'],
['POST', '/death/store/{memberId}', 'Death\Controllers\DeathController@store', ['auth'], 'transfer.initiate'],
['POST', '/death/store/{memberId}', 'Death\Controllers\DeathController@store', ['auth', 'csrf'], 'transfer.initiate'],
['GET', '/death/{id}', 'Death\Controllers\DeathController@show', ['auth'], 'transfer.view'],
['POST', '/death/{id}/pay', 'Death\Controllers\DeathController@pay', ['auth'], 'payment.collect'],
['POST', '/death/{id}/complete', 'Death\Controllers\DeathController@complete', ['auth'], 'transfer.approve'],
['POST', '/death/{id}/pay', 'Death\Controllers\DeathController@pay', ['auth', 'csrf'], 'payment.collect'],
['POST', '/death/{id}/complete', 'Death\Controllers\DeathController@complete', ['auth', 'csrf'], 'transfer.approve'],
];
\ No newline at end of file
......@@ -4,8 +4,8 @@ declare(strict_types=1);
return [
['GET', '/divorce', 'Divorce\Controllers\DivorceController@index', ['auth'], 'transfer.view'],
['GET', '/divorce/create/{memberId}', 'Divorce\Controllers\DivorceController@create', ['auth'], 'transfer.initiate'],
['POST', '/divorce/store/{memberId}', 'Divorce\Controllers\DivorceController@store', ['auth'], 'transfer.initiate'],
['POST', '/divorce/store/{memberId}', 'Divorce\Controllers\DivorceController@store', ['auth', 'csrf'], 'transfer.initiate'],
['GET', '/divorce/{id}', 'Divorce\Controllers\DivorceController@show', ['auth'], 'transfer.view'],
['POST', '/divorce/{id}/pay', 'Divorce\Controllers\DivorceController@pay', ['auth'], 'payment.collect'],
['POST', '/divorce/{id}/complete', 'Divorce\Controllers\DivorceController@complete',['auth'], 'transfer.approve'],
['POST', '/divorce/{id}/pay', 'Divorce\Controllers\DivorceController@pay', ['auth', 'csrf'], 'payment.collect'],
['POST', '/divorce/{id}/complete', 'Divorce\Controllers\DivorceController@complete',['auth', 'csrf'], 'transfer.approve'],
];
\ No newline at end of file
......@@ -4,9 +4,9 @@ declare(strict_types=1);
return [
['GET', '/documents', 'Documents\Controllers\DocumentController@index', ['auth'], 'document.view'],
['GET', '/documents/upload/{memberId}', 'Documents\Controllers\DocumentController@upload', ['auth'], 'document.upload'],
['POST', '/documents/upload/{memberId}', 'Documents\Controllers\DocumentController@upload', ['auth'], 'document.upload'],
['POST', '/documents/upload/{memberId}', 'Documents\Controllers\DocumentController@upload', ['auth', 'csrf'], 'document.upload'],
['GET', '/documents/{id}/download', 'Documents\Controllers\DocumentController@download', ['auth'], 'document.view'],
['GET', '/documents/{id}/preview', 'Documents\Controllers\DocumentController@preview', ['auth'], 'document.view'],
['POST', '/documents/{id}/archive', 'Documents\Controllers\DocumentController@archive', ['auth'], 'document.delete'],
['POST', '/documents/{id}/archive', 'Documents\Controllers\DocumentController@archive', ['auth', 'csrf'], 'document.delete'],
['GET', '/documents/member/{memberId}', 'Documents\Controllers\DocumentController@memberDocuments', ['auth'], 'document.view'],
];
\ No newline at end of file
......@@ -41,25 +41,23 @@ class FacilityGridController extends Controller
'cols_count' => 'required|numeric|min:1|max:50',
];
if (!$this->validate($_POST, $rules)) {
return $this->redirect('/facility-grids/create');
}
$data = $this->validated($request->all(), $rules);
$grid = FacilityGrid::create([
'facility_id' => (int) $_POST['facility_id'],
'name_ar' => $_POST['name_ar'],
'name_en' => $_POST['name_en'] ?? null,
'grid_type' => $_POST['grid_type'],
'rows_count' => (int) $_POST['rows_count'],
'cols_count' => (int) $_POST['cols_count'],
'row_label_ar' => $_POST['row_label_ar'] ?? 'حارة',
'col_label_ar' => $_POST['col_label_ar'] ?? 'قسم',
'physical_length'=> !empty($_POST['physical_length']) ? (float) $_POST['physical_length'] : null,
'physical_width' => !empty($_POST['physical_width']) ? (float) $_POST['physical_width'] : null,
'facility_id' => (int) $data['facility_id'],
'name_ar' => $data['name_ar'],
'name_en' => $request->post('name_en'),
'grid_type' => $data['grid_type'],
'rows_count' => (int) $data['rows_count'],
'cols_count' => (int) $data['cols_count'],
'row_label_ar' => $request->post('row_label_ar') ?? 'حارة',
'col_label_ar' => $request->post('col_label_ar') ?? 'قسم',
'physical_length'=> $request->post('physical_length') ? (float) $request->post('physical_length') : null,
'physical_width' => $request->post('physical_width') ? (float) $request->post('physical_width') : null,
'is_active' => 1,
]);
$this->generateZones((int) $grid->id, (int) $_POST['rows_count'], (int) $_POST['cols_count']);
$this->generateZones((int) $grid->id, (int) $data['rows_count'], (int) $data['cols_count']);
return $this->redirect('/facility-grids/' . $grid->id)->withSuccess('تم إنشاء الشبكة بنجاح');
}
......@@ -71,9 +69,9 @@ class FacilityGridController extends Controller
$grid = FacilityGrid::find((int) $id);
if (!$grid) return $this->redirect('/facility-grids')->withError('الشبكة غير موجودة');
$date = $_GET['date'] ?? date('Y-m-d');
$time = $_GET['time'] ?? null;
$planMonth = $_GET['month'] ?? substr($date, 0, 7);
$date = $request->get('date', date('Y-m-d'));
$time = $request->get('time');
$planMonth = $request->get('month', substr($date, 0, 7));
$state = GridStateService::getState((int) $id, $date, $time);
return $this->view('FacilityGrids.Views.show', [
......@@ -112,31 +110,29 @@ class FacilityGridController extends Controller
'cols_count' => 'required|numeric|min:1|max:50',
];
if (!$this->validate($_POST, $rules)) {
return $this->redirect('/facility-grids/' . $id . '/edit');
}
$data = $this->validated($request->all(), $rules);
$grid->update([
'name_ar' => $_POST['name_ar'],
'name_en' => $_POST['name_en'] ?? null,
'grid_type' => $_POST['grid_type'],
'rows_count' => (int) $_POST['rows_count'],
'cols_count' => (int) $_POST['cols_count'],
'row_label_ar' => $_POST['row_label_ar'] ?? 'حارة',
'col_label_ar' => $_POST['col_label_ar'] ?? 'قسم',
'physical_length'=> !empty($_POST['physical_length']) ? (float) $_POST['physical_length'] : null,
'physical_width' => !empty($_POST['physical_width']) ? (float) $_POST['physical_width'] : null,
'name_ar' => $data['name_ar'],
'name_en' => $request->post('name_en'),
'grid_type' => $data['grid_type'],
'rows_count' => (int) $data['rows_count'],
'cols_count' => (int) $data['cols_count'],
'row_label_ar' => $request->post('row_label_ar') ?? 'حارة',
'col_label_ar' => $request->post('col_label_ar') ?? 'قسم',
'physical_length'=> $request->post('physical_length') ? (float) $request->post('physical_length') : null,
'physical_width' => $request->post('physical_width') ? (float) $request->post('physical_width') : null,
]);
$this->regenerateZones((int) $id, (int) $_POST['rows_count'], (int) $_POST['cols_count']);
$this->regenerateZones((int) $id, (int) $data['rows_count'], (int) $data['cols_count']);
return $this->redirect('/facility-grids/' . $id)->withSuccess('تم تحديث الشبكة');
}
public function apiState(Request $request, string $id): Response
{
$date = $_GET['date'] ?? date('Y-m-d');
$time = $_GET['time'] ?? null;
$date = $request->get('date', date('Y-m-d'));
$time = $request->get('time');
$state = GridStateService::getState((int) $id, $date, $time);
return $this->json(['success' => true, 'data' => $state]);
}
......
......@@ -16,10 +16,10 @@ class GridDashboardController extends Controller
$this->authorize('facility_grid.view');
$db = App::getInstance()->db();
$gridId = (int) ($_GET['grid_id'] ?? 0);
$filter = $_GET['filter'] ?? 'day';
$from = $_GET['from'] ?? date('Y-m-d');
$to = $_GET['to'] ?? date('Y-m-d');
$gridId = (int) ($request->get('grid_id', '0'));
$filter = $request->get('filter', 'day');
$from = $request->get('from', date('Y-m-d'));
$to = $request->get('to', date('Y-m-d'));
switch ($filter) {
case 'week':
......@@ -35,8 +35,8 @@ class GridDashboardController extends Controller
$to = date('Y-12-31');
break;
case 'custom':
$from = $_GET['from'] ?? date('Y-m-d');
$to = $_GET['to'] ?? date('Y-m-d');
$from = $request->get('from', date('Y-m-d'));
$to = $request->get('to', date('Y-m-d'));
break;
}
......
......@@ -48,12 +48,10 @@ class ZoneScheduleController extends Controller
'effective_from' => 'required|date',
];
if (!$this->validate($_POST, $rules)) {
return $this->json(['success' => false, 'message' => 'بيانات غير صالحة']);
}
$data = $this->validated($request->all(), $rules);
$selectionType = $_POST['zone_selection_type'];
$selectionJson = $_POST['zone_selection_json'] ?? '[]';
$selectionType = $data['zone_selection_type'];
$selectionJson = $request->post('zone_selection_json') ?? '[]';
$positions = GridStateService::resolvePositions([
'zone_selection_type' => $selectionType,
......@@ -63,11 +61,11 @@ class ZoneScheduleController extends Controller
$conflicts = GridStateService::checkConflicts(
$gridId,
$positions,
(int) $_POST['day_of_week'],
$_POST['start_time'],
$_POST['end_time'],
$_POST['effective_from'],
$_POST['effective_to'] ?? null
(int) $data['day_of_week'],
$data['start_time'],
$data['end_time'],
$data['effective_from'],
$request->post('effective_to')
);
if (!empty($conflicts)) {
......@@ -79,30 +77,30 @@ class ZoneScheduleController extends Controller
}
$maxOccupants = FacilityZoneSchedule::getMaxOccupants($selectionType);
$planMonth = $_POST['plan_month'] ?? date('Y-m');
$planMonth = $request->post('plan_month') ?? date('Y-m');
$schedule = FacilityZoneSchedule::create([
'grid_id' => $gridId,
'plan_month' => $planMonth,
'schedule_name' => $_POST['schedule_name'],
'schedule_name' => $data['schedule_name'],
'zone_selection_type' => $selectionType,
'zone_selection_json' => $selectionJson,
'day_of_week' => (int) $_POST['day_of_week'],
'start_time' => $_POST['start_time'],
'end_time' => $_POST['end_time'],
'activity_type' => $_POST['activity_type'] ?? null,
'coach_id' => !empty($_POST['coach_id']) ? (int) $_POST['coach_id'] : null,
'coach_name' => $_POST['coach_name'] ?? null,
'academy_id' => !empty($_POST['academy_id']) ? (int) $_POST['academy_id'] : null,
'academy_name' => $_POST['academy_name'] ?? null,
'age_group' => $_POST['age_group'] ?? null,
'gender' => $_POST['gender'] ?? 'male',
'day_of_week' => (int) $data['day_of_week'],
'start_time' => $data['start_time'],
'end_time' => $data['end_time'],
'activity_type' => $request->post('activity_type'),
'coach_id' => $request->post('coach_id') ? (int) $request->post('coach_id') : null,
'coach_name' => $request->post('coach_name'),
'academy_id' => $request->post('academy_id') ? (int) $request->post('academy_id') : null,
'academy_name' => $request->post('academy_name'),
'age_group' => $request->post('age_group'),
'gender' => $request->post('gender') ?? 'male',
'max_occupants' => $maxOccupants,
'color' => $_POST['color'] ?? '#3B82F6',
'effective_from' => $_POST['effective_from'],
'effective_to' => $_POST['effective_to'] ?? null,
'color' => $request->post('color') ?? '#3B82F6',
'effective_from' => $data['effective_from'],
'effective_to' => $request->post('effective_to'),
'is_active' => 1,
'notes' => $_POST['notes'] ?? null,
'notes' => $request->post('notes'),
]);
return $this->json([
......@@ -124,8 +122,8 @@ class ZoneScheduleController extends Controller
}
$grid = FacilityGrid::find($gridId);
$selectionType = $_POST['zone_selection_type'] ?? $schedule->zone_selection_type;
$selectionJson = $_POST['zone_selection_json'] ?? $schedule->zone_selection_json;
$selectionType = $request->post('zone_selection_type') ?? $schedule->zone_selection_type;
$selectionJson = $request->post('zone_selection_json') ?? $schedule->zone_selection_json;
$positions = GridStateService::resolvePositions([
'zone_selection_type' => $selectionType,
......@@ -135,11 +133,11 @@ class ZoneScheduleController extends Controller
$conflicts = GridStateService::checkConflicts(
$gridId,
$positions,
(int) ($_POST['day_of_week'] ?? $schedule->day_of_week),
$_POST['start_time'] ?? $schedule->start_time,
$_POST['end_time'] ?? $schedule->end_time,
$_POST['effective_from'] ?? $schedule->effective_from,
$_POST['effective_to'] ?? $schedule->effective_to,
(int) ($request->post('day_of_week') ?? $schedule->day_of_week),
$request->post('start_time') ?? $schedule->start_time,
$request->post('end_time') ?? $schedule->end_time,
$request->post('effective_from') ?? $schedule->effective_from,
$request->post('effective_to') ?? $schedule->effective_to,
$scheduleId
);
......@@ -152,24 +150,24 @@ class ZoneScheduleController extends Controller
}
$schedule->update([
'schedule_name' => $_POST['schedule_name'] ?? $schedule->schedule_name,
'schedule_name' => $request->post('schedule_name') ?? $schedule->schedule_name,
'zone_selection_type' => $selectionType,
'zone_selection_json' => $selectionJson,
'day_of_week' => (int) ($_POST['day_of_week'] ?? $schedule->day_of_week),
'start_time' => $_POST['start_time'] ?? $schedule->start_time,
'end_time' => $_POST['end_time'] ?? $schedule->end_time,
'activity_type' => $_POST['activity_type'] ?? $schedule->activity_type,
'coach_id' => !empty($_POST['coach_id']) ? (int) $_POST['coach_id'] : $schedule->coach_id,
'coach_name' => $_POST['coach_name'] ?? $schedule->coach_name,
'academy_id' => !empty($_POST['academy_id']) ? (int) $_POST['academy_id'] : $schedule->academy_id,
'academy_name' => $_POST['academy_name'] ?? $schedule->academy_name,
'age_group' => $_POST['age_group'] ?? $schedule->age_group,
'gender' => $_POST['gender'] ?? $schedule->gender,
'max_occupants' => !empty($_POST['max_occupants']) ? (int) $_POST['max_occupants'] : (int) $schedule->max_occupants,
'color' => $_POST['color'] ?? $schedule->color,
'effective_from' => $_POST['effective_from'] ?? $schedule->effective_from,
'effective_to' => $_POST['effective_to'] ?? $schedule->effective_to,
'notes' => $_POST['notes'] ?? $schedule->notes,
'day_of_week' => (int) ($request->post('day_of_week') ?? $schedule->day_of_week),
'start_time' => $request->post('start_time') ?? $schedule->start_time,
'end_time' => $request->post('end_time') ?? $schedule->end_time,
'activity_type' => $request->post('activity_type') ?? $schedule->activity_type,
'coach_id' => $request->post('coach_id') ? (int) $request->post('coach_id') : $schedule->coach_id,
'coach_name' => $request->post('coach_name') ?? $schedule->coach_name,
'academy_id' => $request->post('academy_id') ? (int) $request->post('academy_id') : $schedule->academy_id,
'academy_name' => $request->post('academy_name') ?? $schedule->academy_name,
'age_group' => $request->post('age_group') ?? $schedule->age_group,
'gender' => $request->post('gender') ?? $schedule->gender,
'max_occupants' => $request->post('max_occupants') ? (int) $request->post('max_occupants') : (int) $schedule->max_occupants,
'color' => $request->post('color') ?? $schedule->color,
'effective_from' => $request->post('effective_from') ?? $schedule->effective_from,
'effective_to' => $request->post('effective_to') ?? $schedule->effective_to,
'notes' => $request->post('notes') ?? $schedule->notes,
]);
return $this->json(['success' => true, 'message' => 'تم تحديث الجدول']);
......@@ -197,19 +195,19 @@ class ZoneScheduleController extends Controller
if (!$grid) return $this->json(['success' => false]);
$positions = GridStateService::resolvePositions([
'zone_selection_type' => $_GET['type'] ?? 'cells',
'zone_selection_json' => $_GET['json'] ?? '[]',
'zone_selection_type' => $request->get('type', 'cells'),
'zone_selection_json' => $request->get('json', '[]'),
], (int) $grid->rows_count, (int) $grid->cols_count);
$conflicts = GridStateService::checkConflicts(
$gridId,
$positions,
(int) ($_GET['day'] ?? 0),
$_GET['start'] ?? '00:00',
$_GET['end'] ?? '23:59',
$_GET['from'] ?? date('Y-m-d'),
$_GET['to'] ?? null,
!empty($_GET['exclude']) ? (int) $_GET['exclude'] : null
(int) ($request->get('day', '0')),
$request->get('start', '00:00'),
$request->get('end', '23:59'),
$request->get('from', date('Y-m-d')),
$request->get('to'),
$request->get('exclude') ? (int) $request->get('exclude') : null
);
return $this->json(['success' => true, 'conflicts' => $conflicts]);
......
......@@ -16,10 +16,10 @@ class ZoneTraineeController extends Controller
$gridId = (int) $gridId;
$db = App::getInstance()->db();
$zoneId = (int) ($_POST['zone_id'] ?? 0);
$playerId = !empty($_POST['player_id']) ? (int) $_POST['player_id'] : null;
$traineeName = $_POST['trainee_name'] ?? null;
$scheduleId = !empty($_POST['schedule_id']) ? (int) $_POST['schedule_id'] : null;
$zoneId = (int) ($request->post('zone_id') ?? 0);
$playerId = $request->post('player_id') ? (int) $request->post('player_id') : null;
$traineeName = $request->post('trainee_name');
$scheduleId = $request->post('schedule_id') ? (int) $request->post('schedule_id') : null;
if (!$zoneId || (!$playerId && !$traineeName)) {
return $this->json(['success' => false, 'message' => 'بيانات غير كاملة']);
......@@ -67,7 +67,7 @@ class ZoneTraineeController extends Controller
$traineeId = (int) $traineeId;
$db = App::getInstance()->db();
$targetZoneId = (int) ($_POST['target_zone_id'] ?? 0);
$targetZoneId = (int) ($request->post('target_zone_id') ?? 0);
if (!$targetZoneId) {
return $this->json(['success' => false, 'message' => 'لم يتم تحديد المنطقة']);
}
......
......@@ -4,11 +4,11 @@ declare(strict_types=1);
return [
['GET', '/violations', 'Fines\Controllers\ViolationController@index', ['auth'], 'fine.view'],
['GET', '/violations/create/{memberId}', 'Fines\Controllers\ViolationController@create', ['auth'], 'fine.impose'],
['POST', '/violations/store/{memberId}', 'Fines\Controllers\ViolationController@store', ['auth'], 'fine.impose'],
['POST', '/violations/store/{memberId}', 'Fines\Controllers\ViolationController@store', ['auth', 'csrf'], 'fine.impose'],
['GET', '/fines', 'Fines\Controllers\FineController@index', ['auth'], 'fine.view'],
['POST', '/fines/impose/{violationId}', 'Fines\Controllers\FineController@impose', ['auth'], 'fine.impose'],
['POST', '/fines/{id}/pay', 'Fines\Controllers\FineController@pay', ['auth'], 'fine.collect'],
['POST', '/fines/{id}/appeal', 'Fines\Controllers\FineController@submitAppeal', ['auth'], 'fine.view'],
['POST', '/fines/{id}/appeal-decide', 'Fines\Controllers\FineController@decideAppeal', ['auth'], 'fine.impose'],
['POST', '/fines/{id}/waive', 'Fines\Controllers\FineController@waive', ['auth'], 'fine.waive'],
['POST', '/fines/impose/{violationId}', 'Fines\Controllers\FineController@impose', ['auth', 'csrf'], 'fine.impose'],
['POST', '/fines/{id}/pay', 'Fines\Controllers\FineController@pay', ['auth', 'csrf'], 'fine.collect'],
['POST', '/fines/{id}/appeal', 'Fines\Controllers\FineController@submitAppeal', ['auth', 'csrf'], 'fine.view'],
['POST', '/fines/{id}/appeal-decide', 'Fines\Controllers\FineController@decideAppeal', ['auth', 'csrf'], 'fine.impose'],
['POST', '/fines/{id}/waive', 'Fines\Controllers\FineController@waive', ['auth', 'csrf'], 'fine.waive'],
];
\ No newline at end of file
......@@ -4,5 +4,5 @@ declare(strict_types=1);
return [
['GET', '/foreign', 'Foreign\Controllers\ForeignController@index', ['auth'], 'member.view'],
['GET', '/members/{memberId}/foreign/create', 'Foreign\Controllers\ForeignController@create', ['auth'], 'member.create'],
['POST', '/members/{memberId}/foreign', 'Foreign\Controllers\ForeignController@store', ['auth'], 'member.create'],
['POST', '/members/{memberId}/foreign', 'Foreign\Controllers\ForeignController@store', ['auth', 'csrf'], 'member.create'],
];
\ No newline at end of file
......@@ -4,5 +4,5 @@ declare(strict_types=1);
return [
['GET', '/honorary', 'Honorary\Controllers\HonoraryController@index', ['auth'], 'member.view'],
['GET', '/members/{memberId}/honorary/create', 'Honorary\Controllers\HonoraryController@create', ['auth'], 'member.change_status'],
['POST', '/members/{memberId}/honorary', 'Honorary\Controllers\HonoraryController@store', ['auth'], 'member.change_status'],
['POST', '/members/{memberId}/honorary', 'Honorary\Controllers\HonoraryController@store', ['auth', 'csrf'], 'member.change_status'],
];
\ No newline at end of file
......@@ -5,5 +5,5 @@ return [
['GET', '/installments', 'Installments\Controllers\InstallmentController@index', ['auth'], 'installment.view'],
['GET', '/installments/create/{memberId}', 'Installments\Controllers\InstallmentController@create', ['auth'], 'installment.create'],
['GET', '/installments/{id}', 'Installments\Controllers\InstallmentController@show', ['auth'], 'installment.view'],
['POST', '/installments/{planId}/pay/{scheduleId}', 'Installments\Controllers\InstallmentController@payInstallment', ['auth'], 'installment.pay'],
['POST', '/installments/{planId}/pay/{scheduleId}', 'Installments\Controllers\InstallmentController@payInstallment', ['auth', 'csrf'], 'installment.pay'],
];
\ No newline at end of file
......@@ -4,9 +4,9 @@ declare(strict_types=1);
return [
['GET', '/interviews', 'Interviews\Controllers\InterviewController@index', ['auth'], 'interview.view'],
['GET', '/interviews/schedule/{memberId}', 'Interviews\Controllers\InterviewController@schedule', ['auth'], 'interview.schedule'],
['POST', '/interviews/schedule/{memberId}', 'Interviews\Controllers\InterviewController@storeSchedule', ['auth'], 'interview.schedule'],
['POST', '/interviews/{id}/reschedule', 'Interviews\Controllers\InterviewController@reschedule', ['auth'], 'interview.schedule'],
['POST', '/interviews/schedule/{memberId}', 'Interviews\Controllers\InterviewController@storeSchedule', ['auth', 'csrf'], 'interview.schedule'],
['POST', '/interviews/{id}/reschedule', 'Interviews\Controllers\InterviewController@reschedule', ['auth', 'csrf'], 'interview.schedule'],
['GET', '/interviews/{id}/decide', 'Interviews\Controllers\InterviewController@decide', ['auth'], 'interview.decide'],
['POST', '/interviews/{id}/decide', 'Interviews\Controllers\InterviewController@decide', ['auth'], 'interview.decide'],
['POST', '/interviews/{id}/decide', 'Interviews\Controllers\InterviewController@decide', ['auth', 'csrf'], 'interview.decide'],
['GET', '/interviews/member/{memberId}', 'Interviews\Controllers\InterviewController@memberInterviews', ['auth'], 'interview.view'],
];
\ No newline at end of file
......@@ -4,17 +4,17 @@ declare(strict_types=1);
return [
['GET', '/members', 'Members\Controllers\MemberController@index', ['auth'], 'member.view'],
['GET', '/members/create', 'Members\Controllers\MemberController@create', ['auth'], 'member.create'],
['POST', '/members', 'Members\Controllers\MemberController@store', ['auth'], 'member.create'],
['POST', '/members', 'Members\Controllers\MemberController@store', ['auth', 'csrf'], 'member.create'],
['GET', '/members/search', 'Members\Controllers\MemberController@search', ['auth'], 'member.view'],
['GET', '/members/{id}', 'Members\Controllers\MemberController@show', ['auth'], 'member.view'],
['GET', '/members/{id}/edit', 'Members\Controllers\MemberController@edit', ['auth'], 'member.edit'],
['POST', '/members/{id}', 'Members\Controllers\MemberController@update', ['auth'], 'member.edit'],
['POST', '/members/{id}/status', 'Members\Controllers\MemberController@changeStatus', ['auth'], 'member.change_status'],
['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth'], 'member.pay_form_fee'],
['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth'], 'member.pay_membership'],
['POST', '/members/{id}/pay-addition', 'Members\Controllers\MemberController@payAdditionFee', ['auth'], 'member.pay_membership'],
['POST', '/members/{id}', 'Members\Controllers\MemberController@update', ['auth', 'csrf'], 'member.edit'],
['POST', '/members/{id}/status', 'Members\Controllers\MemberController@changeStatus', ['auth', 'csrf'], 'member.change_status'],
['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth', 'csrf'], 'member.pay_form_fee'],
['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth', 'csrf'], 'member.pay_membership'],
['POST', '/members/{id}/pay-addition', 'Members\Controllers\MemberController@payAdditionFee', ['auth', 'csrf'], 'member.pay_membership'],
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth'], 'member.fill_form'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'],
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'],
];
\ No newline at end of file
......@@ -3,9 +3,9 @@ declare(strict_types=1);
return [
['GET', '/notifications/templates', 'Notifications\Controllers\NotificationController@templates', ['auth'], 'sms.view_log'],
['POST', '/notifications/templates/{id}', 'Notifications\Controllers\NotificationController@updateTemplate', ['auth'], 'sms.edit_templates'],
['POST', '/notifications/templates/{id}', 'Notifications\Controllers\NotificationController@updateTemplate', ['auth', 'csrf'], 'sms.edit_templates'],
['GET', '/notifications/log', 'Notifications\Controllers\NotificationController@log', ['auth'], 'sms.view_log'],
['GET', '/notifications/send', 'Notifications\Controllers\NotificationController@sendForm', ['auth'], 'sms.send_single'],
['POST', '/notifications/send', 'Notifications\Controllers\NotificationController@send', ['auth'], 'sms.send_single'],
['POST', '/notifications/send-bulk', 'Notifications\Controllers\NotificationController@sendBulk', ['auth'], 'sms.send_bulk'],
['POST', '/notifications/send', 'Notifications\Controllers\NotificationController@send', ['auth', 'csrf'], 'sms.send_single'],
['POST', '/notifications/send-bulk', 'Notifications\Controllers\NotificationController@sendBulk', ['auth', 'csrf'], 'sms.send_bulk'],
];
\ No newline at end of file
......@@ -5,8 +5,8 @@ return [
['GET', '/payments', 'Payments\Controllers\PaymentController@index', ['auth'], 'payment.view'],
['GET', '/payments/daily-report', 'Payments\Controllers\PaymentController@dailyReport', ['auth'], 'payment.view'],
['GET', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@process', ['auth'], 'payment.create'],
['POST', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@store', ['auth'], 'payment.create'],
['POST', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@store', ['auth', 'csrf'], 'payment.create'],
['GET', '/payments/member/{memberId}', 'Payments\Controllers\PaymentController@memberHistory', ['auth'], 'payment.view'],
['GET', '/payments/{id}', 'Payments\Controllers\PaymentController@show', ['auth'], 'payment.view'],
['POST', '/payments/{id}/void', 'Payments\Controllers\PaymentController@void', ['auth'], 'payment.void'],
['POST', '/payments/{id}/void', 'Payments\Controllers\PaymentController@void', ['auth', 'csrf'], 'payment.void'],
];
\ No newline at end of file
......@@ -16,58 +16,58 @@ return [
// ── Purchase Requisitions ──
['GET', '/procurement/requisitions', RequisitionController::class . '@index', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/create', RequisitionController::class . '@create', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions', RequisitionController::class . '@store', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions', RequisitionController::class . '@store', ['auth', 'csrf'], 'procurement.pr.create'],
['GET', '/procurement/requisitions/{id}', RequisitionController::class . '@show', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/{id}/edit', RequisitionController::class . '@edit', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/update', RequisitionController::class . '@update', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/submit', RequisitionController::class . '@submit', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/approve', RequisitionController::class . '@approve', ['auth'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/reject', RequisitionController::class . '@reject', ['auth'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/convert', RequisitionController::class . '@convert', ['auth'], 'procurement.pr.convert'],
['POST', '/procurement/requisitions/{id}/cancel', RequisitionController::class . '@cancel', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/update', RequisitionController::class . '@update', ['auth', 'csrf'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/submit', RequisitionController::class . '@submit', ['auth', 'csrf'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/approve', RequisitionController::class . '@approve', ['auth', 'csrf'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/reject', RequisitionController::class . '@reject', ['auth', 'csrf'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/convert', RequisitionController::class . '@convert', ['auth', 'csrf'], 'procurement.pr.convert'],
['POST', '/procurement/requisitions/{id}/cancel', RequisitionController::class . '@cancel', ['auth', 'csrf'], 'procurement.pr.create'],
// ── Goods Received Notes ──
['GET', '/procurement/grn', GoodsReceivedNoteController::class . '@index', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/create', GoodsReceivedNoteController::class . '@create', ['auth'], 'procurement.grn.create'],
['POST', '/procurement/grn', GoodsReceivedNoteController::class . '@store', ['auth'], 'procurement.grn.create'],
['POST', '/procurement/grn', GoodsReceivedNoteController::class . '@store', ['auth', 'csrf'], 'procurement.grn.create'],
['GET', '/procurement/grn/{id}', GoodsReceivedNoteController::class . '@show', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspectForm', ['auth'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspect', ['auth'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/cancel', GoodsReceivedNoteController::class . '@cancel', ['auth'], 'procurement.grn.create'],
['POST', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspect', ['auth', 'csrf'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/cancel', GoodsReceivedNoteController::class . '@cancel', ['auth', 'csrf'], 'procurement.grn.create'],
// ── Vendor Invoices ──
['GET', '/procurement/invoices', VendorInvoiceController::class . '@index', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/create', VendorInvoiceController::class . '@create', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices', VendorInvoiceController::class . '@store', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices', VendorInvoiceController::class . '@store', ['auth', 'csrf'], 'procurement.invoice.create'],
['GET', '/procurement/invoices/{id}', VendorInvoiceController::class . '@show', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/{id}/edit', VendorInvoiceController::class . '@edit', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/update', VendorInvoiceController::class . '@update', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/verify', VendorInvoiceController::class . '@verify', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/approve', VendorInvoiceController::class . '@approve', ['auth'], 'procurement.invoice.approve'],
['POST', '/procurement/invoices/{id}/cancel', VendorInvoiceController::class . '@cancel', ['auth'], 'procurement.invoice.approve'],
['POST', '/procurement/invoices/{id}/update', VendorInvoiceController::class . '@update', ['auth', 'csrf'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/verify', VendorInvoiceController::class . '@verify', ['auth', 'csrf'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/approve', VendorInvoiceController::class . '@approve', ['auth', 'csrf'], 'procurement.invoice.approve'],
['POST', '/procurement/invoices/{id}/cancel', VendorInvoiceController::class . '@cancel', ['auth', 'csrf'], 'procurement.invoice.approve'],
['GET', '/procurement/invoices/{id}/match', VendorInvoiceController::class . '@matchView', ['auth'], 'procurement.invoice.view'],
// ── Vendor Payments ──
['GET', '/procurement/payments', VendorPaymentController::class . '@index', ['auth'], 'procurement.payment.view'],
['GET', '/procurement/payments/create', VendorPaymentController::class . '@create', ['auth'], 'procurement.payment.create'],
['POST', '/procurement/payments', VendorPaymentController::class . '@store', ['auth'], 'procurement.payment.create'],
['POST', '/procurement/payments', VendorPaymentController::class . '@store', ['auth', 'csrf'], 'procurement.payment.create'],
['GET', '/procurement/payments/{id}', VendorPaymentController::class . '@show', ['auth'], 'procurement.payment.view'],
['POST', '/procurement/payments/{id}/approve', VendorPaymentController::class . '@approve', ['auth'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/complete', VendorPaymentController::class . '@complete', ['auth'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/void', VendorPaymentController::class . '@void', ['auth'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/approve', VendorPaymentController::class . '@approve', ['auth', 'csrf'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/complete', VendorPaymentController::class . '@complete', ['auth', 'csrf'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/void', VendorPaymentController::class . '@void', ['auth', 'csrf'], 'procurement.payment.approve'],
// ── Return to Vendor ──
['GET', '/procurement/rtv', ReturnToVendorController::class . '@index', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/create', ReturnToVendorController::class . '@create', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv', ReturnToVendorController::class . '@store', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv', ReturnToVendorController::class . '@store', ['auth', 'csrf'], 'procurement.rtv.create'],
['GET', '/procurement/rtv/{id}', ReturnToVendorController::class . '@show', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/{id}/edit', ReturnToVendorController::class . '@edit', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/update', ReturnToVendorController::class . '@update', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/submit', ReturnToVendorController::class . '@submit', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/approve', ReturnToVendorController::class . '@approve', ['auth'], 'procurement.rtv.approve'],
['POST', '/procurement/rtv/{id}/ship', ReturnToVendorController::class . '@ship', ['auth'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/complete', ReturnToVendorController::class . '@complete', ['auth'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/cancel', ReturnToVendorController::class . '@cancel', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/update', ReturnToVendorController::class . '@update', ['auth', 'csrf'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/submit', ReturnToVendorController::class . '@submit', ['auth', 'csrf'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/approve', ReturnToVendorController::class . '@approve', ['auth', 'csrf'], 'procurement.rtv.approve'],
['POST', '/procurement/rtv/{id}/ship', ReturnToVendorController::class . '@ship', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/complete', ReturnToVendorController::class . '@complete', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/cancel', ReturnToVendorController::class . '@cancel', ['auth', 'csrf'], 'procurement.rtv.create'],
// ── Reports ──
['GET', '/procurement/reports/purchase-volume', ProcurementReportController::class . '@purchaseVolume', ['auth'], 'procurement.report'],
......
......@@ -5,5 +5,5 @@ return [
['GET', '/receipts', 'Receipts\Controllers\ReceiptController@index', ['auth'], 'payment.view'],
['GET', '/receipts/voided', 'Receipts\Controllers\ReceiptController@voided', ['auth'], 'payment.void_receipt'],
['GET', '/receipts/{id}/print', 'Receipts\Controllers\ReceiptController@printReceipt', ['auth'], 'payment.view'],
['POST', '/receipts/{id}/void', 'Receipts\Controllers\ReceiptController@voidReceipt', ['auth'], 'payment.void_receipt'],
['POST', '/receipts/{id}/void', 'Receipts\Controllers\ReceiptController@voidReceipt', ['auth', 'csrf'], 'payment.void_receipt'],
];
\ No newline at end of file
......@@ -4,5 +4,5 @@ declare(strict_types=1);
return [
['GET', '/seasonal', 'Seasonal\Controllers\SeasonalController@index', ['auth'], 'temp.view'],
['GET', '/members/{memberId}/seasonal/create', 'Seasonal\Controllers\SeasonalController@create', ['auth'], 'temp.add'],
['POST', '/members/{memberId}/seasonal', 'Seasonal\Controllers\SeasonalController@store', ['auth'], 'temp.add'],
['POST', '/members/{memberId}/seasonal', 'Seasonal\Controllers\SeasonalController@store', ['auth', 'csrf'], 'temp.add'],
];
\ No newline at end of file
......@@ -5,5 +5,5 @@ return [
['GET', '/sports', 'Sports\Controllers\SportsController@index', ['auth'], 'sports.view'],
['GET', '/members/{memberId}/sports/create', 'Sports\Controllers\SportsController@create', ['auth'], 'sports.add'],
['POST', '/members/{memberId}/sports', 'Sports\Controllers\SportsController@store', ['auth', 'csrf'], 'sports.add'],
['POST', '/members/{memberId}/sports/check-conversion', 'Sports\Controllers\SportsController@checkConversion', ['auth'], 'sports.convert'],
['POST', '/members/{memberId}/sports/check-conversion', 'Sports\Controllers\SportsController@checkConversion', ['auth', 'csrf'], 'sports.convert'],
];
\ No newline at end of file
......@@ -3,10 +3,10 @@ declare(strict_types=1);
return [
['GET', '/members/{memberId}/spouses/create', 'Spouses\Controllers\SpouseController@create', ['auth'], 'spouse.add'],
['POST', '/members/{memberId}/spouses', 'Spouses\Controllers\SpouseController@store', ['auth'], 'spouse.add'],
['POST', '/members/{memberId}/spouses', 'Spouses\Controllers\SpouseController@store', ['auth', 'csrf'], 'spouse.add'],
['GET', '/members/{memberId}/spouses/{id}', 'Spouses\Controllers\SpouseController@show', ['auth'], 'spouse.view'],
['GET', '/members/{memberId}/spouses/{id}/edit', 'Spouses\Controllers\SpouseController@edit', ['auth'], 'spouse.edit'],
['POST', '/members/{memberId}/spouses/{id}', 'Spouses\Controllers\SpouseController@update', ['auth'], 'spouse.edit'],
['POST', '/members/{memberId}/spouses/{id}/archive', 'Spouses\Controllers\SpouseController@archive', ['auth'], 'spouse.remove'],
['POST', '/members/{memberId}/spouses/{id}', 'Spouses\Controllers\SpouseController@update', ['auth', 'csrf'], 'spouse.edit'],
['POST', '/members/{memberId}/spouses/{id}/archive', 'Spouses\Controllers\SpouseController@archive', ['auth', 'csrf'], 'spouse.remove'],
['POST', '/api/spouses/calculate-fee', 'Spouses\Controllers\SpouseController@calculateFee', ['auth'], 'spouse.add'],
];
\ No newline at end of file
......@@ -4,8 +4,8 @@ declare(strict_types=1);
return [
['GET', '/subscriptions', 'Subscriptions\Controllers\SubscriptionController@index', ['auth'], 'subscription.view'],
['GET', '/subscriptions/batch-generate', 'Subscriptions\Controllers\SubscriptionController@batchGenerateForm', ['auth'], 'subscription.generate_batch'],
['POST', '/subscriptions/batch-generate', 'Subscriptions\Controllers\SubscriptionController@batchGenerate', ['auth'], 'subscription.generate_batch'],
['POST', '/subscriptions/batch-generate', 'Subscriptions\Controllers\SubscriptionController@batchGenerate', ['auth', 'csrf'], 'subscription.generate_batch'],
['GET', '/members/{memberId}/subscriptions', 'Subscriptions\Controllers\SubscriptionController@memberSubscriptions',['auth'], 'subscription.view'],
['POST', '/subscriptions/{id}/pay', 'Subscriptions\Controllers\SubscriptionController@pay', ['auth'], 'subscription.collect'],
['POST', '/subscriptions/{id}/exempt', 'Subscriptions\Controllers\SubscriptionController@exempt', ['auth'], 'subscription.exempt'],
['POST', '/subscriptions/{id}/pay', 'Subscriptions\Controllers\SubscriptionController@pay', ['auth', 'csrf'], 'subscription.collect'],
['POST', '/subscriptions/{id}/exempt', 'Subscriptions\Controllers\SubscriptionController@exempt', ['auth', 'csrf'], 'subscription.exempt'],
];
\ No newline at end of file
......@@ -4,11 +4,11 @@ declare(strict_types=1);
return [
['GET', '/support', 'Support\Controllers\TicketController@index', ['auth'], 'support.view'],
['GET', '/support/create', 'Support\Controllers\TicketController@create', ['auth'], 'support.create'],
['POST', '/support', 'Support\Controllers\TicketController@store', ['auth'], 'support.create'],
['POST', '/support', 'Support\Controllers\TicketController@store', ['auth', 'csrf'], 'support.create'],
['GET', '/support/{id:\d+}', 'Support\Controllers\TicketController@show', ['auth'], 'support.view'],
['POST', '/support/{id:\d+}/reply', 'Support\Controllers\TicketController@reply', ['auth'], 'support.reply'],
['POST', '/support/{id:\d+}/close', 'Support\Controllers\TicketController@close', ['auth'], 'support.close'],
['POST', '/support/{id:\d+}/reopen', 'Support\Controllers\TicketController@reopen', ['auth'], 'support.manage'],
['POST', '/support/{id:\d+}/assign', 'Support\Controllers\TicketController@assign', ['auth'], 'support.assign'],
['POST', '/support/{id:\d+}/reply', 'Support\Controllers\TicketController@reply', ['auth', 'csrf'], 'support.reply'],
['POST', '/support/{id:\d+}/close', 'Support\Controllers\TicketController@close', ['auth', 'csrf'], 'support.close'],
['POST', '/support/{id:\d+}/reopen', 'Support\Controllers\TicketController@reopen', ['auth', 'csrf'], 'support.manage'],
['POST', '/support/{id:\d+}/assign', 'Support\Controllers\TicketController@assign', ['auth', 'csrf'], 'support.assign'],
['GET', '/support/attachments/{id:\d+}', 'Support\Controllers\TicketController@download', ['auth'], 'support.view'],
];
......@@ -4,7 +4,7 @@ declare(strict_types=1);
return [
['GET', '/temporary', 'Temporary\Controllers\TemporaryController@index', ['auth'], 'temp.view'],
['GET', '/members/{memberId}/temporary/create', 'Temporary\Controllers\TemporaryController@create', ['auth'], 'temp.add'],
['POST', '/members/{memberId}/temporary', 'Temporary\Controllers\TemporaryController@store', ['auth'], 'temp.add'],
['POST', '/members/{memberId}/temporary', 'Temporary\Controllers\TemporaryController@store', ['auth', 'csrf'], 'temp.add'],
['GET', '/members/{memberId}/temporary/{id}', 'Temporary\Controllers\TemporaryController@show', ['auth'], 'temp.view'],
['POST', '/members/{memberId}/temporary/{id}/archive', 'Temporary\Controllers\TemporaryController@archive', ['auth'], 'temp.remove'],
['POST', '/members/{memberId}/temporary/{id}/archive', 'Temporary\Controllers\TemporaryController@archive', ['auth', 'csrf'], 'temp.remove'],
];
\ No newline at end of file
......@@ -4,12 +4,12 @@ declare(strict_types=1);
return [
['GET', '/transfers', 'Transfers\Controllers\TransferController@index', ['auth'], 'transfer.view'],
['GET', '/transfers/create/{memberId}', 'Transfers\Controllers\TransferController@create', ['auth'], 'transfer.initiate'],
['POST', '/transfers/store/{memberId}', 'Transfers\Controllers\TransferController@store', ['auth'], 'transfer.initiate'],
['POST', '/transfers/store/{memberId}', 'Transfers\Controllers\TransferController@store', ['auth', 'csrf'], 'transfer.initiate'],
['GET', '/transfers/{id}', 'Transfers\Controllers\TransferController@show', ['auth'], 'transfer.view'],
['POST', '/transfers/{id}/pay', 'Transfers\Controllers\TransferController@pay', ['auth'], 'payment.collect'],
['POST', '/transfers/{id}/approve', 'Transfers\Controllers\TransferController@approve', ['auth'], 'transfer.approve'],
['POST', '/transfers/{id}/reject', 'Transfers\Controllers\TransferController@reject', ['auth'], 'transfer.approve'],
['POST', '/transfers/{id}/complete', 'Transfers\Controllers\TransferController@complete',['auth'], 'transfer.approve'],
['POST', '/transfers/{id}/pay', 'Transfers\Controllers\TransferController@pay', ['auth', 'csrf'], 'payment.collect'],
['POST', '/transfers/{id}/approve', 'Transfers\Controllers\TransferController@approve', ['auth', 'csrf'], 'transfer.approve'],
['POST', '/transfers/{id}/reject', 'Transfers\Controllers\TransferController@reject', ['auth', 'csrf'], 'transfer.approve'],
['POST', '/transfers/{id}/complete', 'Transfers\Controllers\TransferController@complete',['auth', 'csrf'], 'transfer.approve'],
['GET', '/transfers/preview/{memberId}', 'Transfers\Controllers\TransferController@preview', ['auth'], 'transfer.initiate'],
['POST', '/api/transfers/calculate-fee', 'Transfers\Controllers\TransferController@calculateFee', ['auth'], 'transfer.initiate'],
];
\ No newline at end of file
......@@ -4,10 +4,10 @@ declare(strict_types=1);
return [
['GET', '/waivers', 'Waiver\Controllers\WaiverController@index', ['auth'], 'waiver.view'],
['GET', '/waivers/create/{memberId}', 'Waiver\Controllers\WaiverController@create', ['auth'], 'waiver.initiate'],
['POST', '/waivers/store/{memberId}', 'Waiver\Controllers\WaiverController@store', ['auth'], 'waiver.initiate'],
['POST', '/waivers/store/{memberId}', 'Waiver\Controllers\WaiverController@store', ['auth', 'csrf'], 'waiver.initiate'],
['GET', '/waivers/{id}', 'Waiver\Controllers\WaiverController@show', ['auth'], 'waiver.view'],
['POST', '/waivers/{id}/pay', 'Waiver\Controllers\WaiverController@pay', ['auth'], 'payment.collect'],
['POST', '/waivers/{id}/approve', 'Waiver\Controllers\WaiverController@approve', ['auth'], 'waiver.approve'],
['POST', '/waivers/{id}/reject', 'Waiver\Controllers\WaiverController@reject', ['auth'], 'waiver.approve'],
['POST', '/waivers/{id}/complete', 'Waiver\Controllers\WaiverController@complete',['auth'], 'waiver.approve'],
['POST', '/waivers/{id}/pay', 'Waiver\Controllers\WaiverController@pay', ['auth', 'csrf'], 'payment.collect'],
['POST', '/waivers/{id}/approve', 'Waiver\Controllers\WaiverController@approve', ['auth', 'csrf'], 'waiver.approve'],
['POST', '/waivers/{id}/reject', 'Waiver\Controllers\WaiverController@reject', ['auth', 'csrf'], 'waiver.approve'],
['POST', '/waivers/{id}/complete', 'Waiver\Controllers\WaiverController@complete',['auth', 'csrf'], 'waiver.approve'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
$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>
......@@ -189,5 +189,8 @@ window.addEventListener('load', function() {
});
</script>
<?= $__template->yield('scripts', '') ?>
<?php if ($app->isDebug()): ?>
<?= \App\Core\DebugBar::render() ?>
<?php endif; ?>
</body>
</html>
......@@ -18,8 +18,7 @@ if (PHP_SAPI !== 'cli') {
die('CLI only.');
}
require_once __DIR__ . '/app/Core/Autoloader.php';
\App\Core\Autoloader::register();
require_once __DIR__ . '/vendor/autoload.php';
// Load .env
$envFile = __DIR__ . '/.env';
......@@ -147,6 +146,32 @@ switch ($command) {
}
break;
case 'routes':
$app = \App\Core\App::getInstance();
$app->boot();
$routes = [];
$modulesDir = __DIR__ . '/app/Modules';
$routeFiles = glob($modulesDir . '/*/Routes.php');
sort($routeFiles);
foreach ($routeFiles as $file) {
$moduleRoutes = require $file;
if (is_array($moduleRoutes)) {
foreach ($moduleRoutes as $r) {
if (is_array($r) && count($r) >= 3) {
$routes[] = $r;
}
}
}
}
echo str_pad('METHOD', 8) . str_pad('PATH', 50) . str_pad('HANDLER', 55) . str_pad('MIDDLEWARE', 20) . "PERMISSION\n";
echo str_repeat('─', 145) . "\n";
foreach ($routes as $r) {
$mw = implode(',', $r[3] ?? []);
echo str_pad($r[0], 8) . str_pad($r[1], 50) . str_pad($r[2], 55) . str_pad($mw, 20) . ($r[4] ?? '') . "\n";
}
echo "\nTotal: " . count($routes) . " routes\n";
break;
case 'help':
default:
echo "THE CLUB ERP — CLI Commands\n";
......@@ -157,6 +182,7 @@ switch ($command) {
echo " php cli.php seed Run all pending seeds\n";
echo " php cli.php seed:run <Name> Run specific seed\n";
echo " php cli.php cron Run background jobs\n";
echo " php cli.php routes List all routes\n";
echo " php cli.php help Show this help\n";
break;
}
......
{
"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/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -7,8 +7,7 @@ declare(strict_types=1);
*/
$basePath = dirname(__DIR__);
require_once $basePath . '/app/Core/Autoloader.php';
\App\Core\Autoloader::register();
require_once $basePath . '/vendor/autoload.php';
$app = \App\Core\App::getInstance();
$app->boot();
......
......@@ -61,8 +61,7 @@ echo "========================================\n";
chdir('/var/www/html');
require_once '/var/www/html/app/Core/Autoloader.php';
\App\Core\Autoloader::register();
require_once '/var/www/html/vendor/autoload.php';
// Load .env
$envFile = '/var/www/html/.env';
......
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+#'
<?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>
......@@ -2097,3 +2097,42 @@ code {
display: block;
opacity: 1;
}
/* ══════════════════════════════════════════════════
UTILITY CLASSES
══════════════════════════════════════════════════ */
.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; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.text-xs { font-size: 11px; }
.text-sm { font-size: 12px; }
.text-base { font-size: 13px; }
.text-lg { font-size: 16px; }
.text-muted { color: var(--text-muted); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
.text-warning { color: var(--warning); }
.text-info { color: var(--info); }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.ltr { direction: ltr; text-align: right; }
.min-w-120 { min-width: 120px; }
.min-w-250 { min-width: 250px; }
.mb-xs { margin-bottom: 5px; }
.mb-sm { margin-bottom: 10px; }
.mb-md { margin-bottom: 20px; }
.mb-lg { margin-bottom: 30px; }
.mt-sm { margin-top: 10px; }
.mt-md { margin-top: 20px; }
.p-sm { padding: 10px; }
.p-md { padding: 15px; }
.p-lg { padding: 20px; }
.w-full { width: 100%; }
.hidden { display: none; }
......@@ -38,12 +38,14 @@ if (file_exists($envFile)) {
}
}
// ── Autoloader ──
require_once BASE_PATH . '/app/Core/Autoloader.php';
App\Core\Autoloader::register();
// ── Autoloader (Composer) ──
require_once BASE_PATH . '/vendor/autoload.php';
// ── Helpers (must be loaded before Config since config files call env()) ──
require_once BASE_PATH . '/app/Core/Helpers.php';
// ── Security headers ──
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');
// ── Exception handler ──
$exHandler = new App\Core\ExceptionHandler();
......@@ -51,6 +53,7 @@ $exHandler->register();
// ── Boot application ──
try {
App\Core\DebugBar::init();
$app = App\Core\App::getInstance();
$app->boot();
......
<?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();
}
}
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Core;
use App\Core\QueryBuilder;
use Tests\TestCase;
class QueryBuilderTest extends TestCase
{
private QueryBuilder $qb;
protected function setUp(): void
{
parent::setUp();
$this->qb = new QueryBuilder($this->createMockDb());
}
private function createMockDb(): \App\Core\Database
{
$mock = $this->createMock(\App\Core\Database::class);
return $mock;
}
public function testBasicSelect(): void
{
$sql = $this->qb->table('members')->noSoftDelete()->toSql();
$this->assertSame('SELECT * FROM `members`', $sql);
}
public function testSelectSpecificColumns(): void
{
$sql = $this->qb->table('members')->select(['id', 'name'])->noSoftDelete()->toSql();
$this->assertSame('SELECT id, name FROM `members`', $sql);
}
public function testWhereClause(): void
{
$sql = $this->qb->table('members')->noSoftDelete()->where('status', '=', 'active')->toSql();
$this->assertSame('SELECT * FROM `members` WHERE `status` = ?', $sql);
}
public function testMultipleWheres(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->where('status', '=', 'active')
->where('branch_id', '=', 1)
->toSql();
$this->assertSame('SELECT * FROM `members` WHERE `status` = ? AND `branch_id` = ?', $sql);
}
public function testOrWhere(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->where('status', '=', 'active')
->orWhere('status', '=', 'pending')
->toSql();
$this->assertSame('SELECT * FROM `members` WHERE `status` = ? OR `status` = ?', $sql);
}
public function testWhereIn(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->whereIn('id', [1, 2, 3])
->toSql();
$this->assertSame('SELECT * FROM `members` WHERE `id` IN (?, ?, ?)', $sql);
}
public function testWhereInEmpty(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->whereIn('id', [])
->toSql();
$this->assertSame('SELECT * FROM `members` WHERE 1 = 0', $sql);
}
public function testWhereNull(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->whereNull('deleted_at')
->toSql();
$this->assertSame('SELECT * FROM `members` WHERE `deleted_at` IS NULL', $sql);
}
public function testWhereNotNull(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->whereNotNull('email')
->toSql();
$this->assertSame('SELECT * FROM `members` WHERE `email` IS NOT NULL', $sql);
}
public function testWhereBetween(): void
{
$sql = $this->qb->table('payments')->noSoftDelete()
->whereBetween('amount', 100, 500)
->toSql();
$this->assertSame('SELECT * FROM `payments` WHERE `amount` BETWEEN ? AND ?', $sql);
}
public function testJoin(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->join('branches', 'members.branch_id = branches.id')
->toSql();
$this->assertSame('SELECT * FROM `members` INNER JOIN `branches` ON members.branch_id = branches.id', $sql);
}
public function testLeftJoin(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->leftJoin('photos', 'members.id = photos.member_id')
->toSql();
$this->assertSame('SELECT * FROM `members` LEFT JOIN `photos` ON members.id = photos.member_id', $sql);
}
public function testOrderBy(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->orderBy('name', 'ASC')
->toSql();
$this->assertSame('SELECT * FROM `members` ORDER BY `name` ASC', $sql);
}
public function testOrderByDesc(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->orderBy('created_at', 'DESC')
->toSql();
$this->assertSame('SELECT * FROM `members` ORDER BY `created_at` DESC', $sql);
}
public function testGroupBy(): void
{
$sql = $this->qb->table('payments')->noSoftDelete()
->select('branch_id, SUM(amount) as total')
->groupBy('branch_id')
->toSql();
$this->assertSame('SELECT branch_id, SUM(amount) as total FROM `payments` GROUP BY `branch_id`', $sql);
}
public function testHaving(): void
{
$sql = $this->qb->table('payments')->noSoftDelete()
->select('branch_id, SUM(amount) as total')
->groupBy('branch_id')
->having('total > 1000')
->toSql();
$this->assertSame('SELECT branch_id, SUM(amount) as total FROM `payments` GROUP BY `branch_id` HAVING total > 1000', $sql);
}
public function testLimitOffset(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->limit(10)
->offset(20)
->toSql();
$this->assertSame('SELECT * FROM `members` LIMIT 10 OFFSET 20', $sql);
}
public function testSoftDeleteFilterApplied(): void
{
$sql = $this->qb->table('members')->toSql();
$this->assertStringContainsString('is_archived', $sql);
}
public function testWithArchivedRemovesFilter(): void
{
$sql = $this->qb->table('members')->withArchived()->toSql();
$this->assertStringNotContainsString('is_archived', $sql);
}
public function testOnlyArchived(): void
{
$sql = $this->qb->table('members')->onlyArchived()->toSql();
$this->assertStringContainsString('`is_archived` = 1', $sql);
}
public function testWhenTrue(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->when(true, fn($q) => $q->where('status', '=', 'active'))
->toSql();
$this->assertStringContainsString('`status` = ?', $sql);
}
public function testWhenFalse(): void
{
$sql = $this->qb->table('members')->noSoftDelete()
->when(false, fn($q) => $q->where('status', '=', 'active'))
->toSql();
$this->assertStringNotContainsString('status', $sql);
}
public function testImmutability(): void
{
$base = $this->qb->table('members')->noSoftDelete();
$withWhere = $base->where('status', '=', 'active');
$this->assertSame('SELECT * FROM `members`', $base->toSql());
$this->assertSame('SELECT * FROM `members` WHERE `status` = ?', $withWhere->toSql());
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Core;
use Tests\TestCase;
class RouterTest extends TestCase
{
private function matchPath(string $routePath, string $requestPath): array|false
{
$routePath = '/' . trim($routePath, '/');
$requestPath = '/' . trim($requestPath, '/');
if ($routePath === $requestPath) {
return [];
}
$pattern = preg_replace_callback('/\{(\w+)(?::([^}]+))?\}/', function ($matches) {
$name = $matches[1];
$constraint = $matches[2] ?? '[^/]+';
return '(?P<' . $name . '>' . $constraint . ')';
}, $routePath);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $requestPath, $matches)) {
$params = [];
foreach ($matches as $key => $value) {
if (is_string($key)) {
$params[$key] = $value;
}
}
return $params;
}
return false;
}
public function testExactMatch(): void
{
$this->assertSame([], $this->matchPath('/dashboard', '/dashboard'));
}
public function testExactMatchWithTrailingSlash(): void
{
$this->assertSame([], $this->matchPath('/dashboard/', '/dashboard'));
}
public function testNoMatch(): void
{
$this->assertFalse($this->matchPath('/dashboard', '/members'));
}
public function testSingleParam(): void
{
$result = $this->matchPath('/members/{id}', '/members/42');
$this->assertSame(['id' => '42'], $result);
}
public function testParamWithConstraint(): void
{
$result = $this->matchPath('/members/{id:\d+}', '/members/42');
$this->assertSame(['id' => '42'], $result);
}
public function testParamConstraintRejects(): void
{
$result = $this->matchPath('/members/{id:\d+}', '/members/abc');
$this->assertFalse($result);
}
public function testMultipleParams(): void
{
$result = $this->matchPath('/branches/{branch}/members/{id}', '/branches/1/members/42');
$this->assertSame(['branch' => '1', 'id' => '42'], $result);
}
public function testRootPath(): void
{
$this->assertSame([], $this->matchPath('/', '/'));
}
public function testNestedPathNoMatch(): void
{
$this->assertFalse($this->matchPath('/members', '/members/42'));
}
public function testParamWithSlug(): void
{
$result = $this->matchPath('/reports/{type}', '/reports/daily-summary');
$this->assertSame(['type' => 'daily-summary'], $result);
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Core;
use App\Core\Validator;
use Tests\TestCase;
class ValidatorTest extends TestCase
{
private Validator $validator;
protected function setUp(): void
{
parent::setUp();
$this->validator = new Validator();
}
public function testRequiredPassesWithValue(): void
{
$result = $this->validator->validate(['name' => 'test'], ['name' => 'required']);
$this->assertTrue($result->passes());
$this->assertSame(['name' => 'test'], $result->validated());
}
public function testRequiredFailsWithEmpty(): void
{
$result = $this->validator->validate(['name' => ''], ['name' => 'required']);
$this->assertTrue($result->fails());
$this->assertArrayHasKey('name', $result->errors());
}
public function testRequiredFailsWithNull(): void
{
$result = $this->validator->validate([], ['name' => 'required']);
$this->assertTrue($result->fails());
}
public function testStringPasses(): void
{
$result = $this->validator->validate(['name' => 'hello'], ['name' => 'string']);
$this->assertTrue($result->passes());
}
public function testIntegerPasses(): void
{
$result = $this->validator->validate(['age' => '25'], ['age' => 'integer']);
$this->assertTrue($result->passes());
}
public function testIntegerFailsWithFloat(): void
{
$result = $this->validator->validate(['age' => '25.5'], ['age' => 'integer']);
$this->assertTrue($result->fails());
}
public function testNumericPasses(): void
{
$result = $this->validator->validate(['price' => '99.99'], ['price' => 'numeric']);
$this->assertTrue($result->passes());
}
public function testEmailPasses(): void
{
$result = $this->validator->validate(['email' => 'test@example.com'], ['email' => 'email']);
$this->assertTrue($result->passes());
}
public function testEmailFails(): void
{
$result = $this->validator->validate(['email' => 'not-an-email'], ['email' => 'email']);
$this->assertTrue($result->fails());
}
public function testDatePasses(): void
{
$result = $this->validator->validate(['dob' => '1990-05-15'], ['dob' => 'date']);
$this->assertTrue($result->passes());
}
public function testDateFailsInvalid(): void
{
$result = $this->validator->validate(['dob' => '2024-13-45'], ['dob' => 'date']);
$this->assertTrue($result->fails());
}
public function testMinStringLength(): void
{
$result = $this->validator->validate(['name' => 'ab'], ['name' => 'min:3']);
$this->assertTrue($result->fails());
}
public function testMaxStringLength(): void
{
$result = $this->validator->validate(['name' => 'abcdef'], ['name' => 'max:5']);
$this->assertTrue($result->fails());
}
public function testInPasses(): void
{
$result = $this->validator->validate(['status' => 'active'], ['status' => 'in:active,inactive']);
$this->assertTrue($result->passes());
}
public function testInFails(): void
{
$result = $this->validator->validate(['status' => 'deleted'], ['status' => 'in:active,inactive']);
$this->assertTrue($result->fails());
}
public function testNullableSkipsValidation(): void
{
$result = $this->validator->validate(['phone' => ''], ['phone' => 'nullable|phone_eg']);
$this->assertTrue($result->passes());
}
public function testNullableAllowsNull(): void
{
$result = $this->validator->validate([], ['phone' => 'nullable|phone_eg']);
$this->assertTrue($result->passes());
}
public function testPhoneEgPasses(): void
{
$result = $this->validator->validate(['phone' => '01012345678'], ['phone' => 'phone_eg']);
$this->assertTrue($result->passes());
}
public function testPhoneEgFails(): void
{
$result = $this->validator->validate(['phone' => '123'], ['phone' => 'phone_eg']);
$this->assertTrue($result->fails());
}
public function testNationalIdPasses(): void
{
$result = $this->validator->validate(['nid' => '29005151234567'], ['nid' => 'national_id']);
$this->assertTrue($result->passes());
}
public function testNationalIdFailsWrongLength(): void
{
$result = $this->validator->validate(['nid' => '123'], ['nid' => 'national_id']);
$this->assertTrue($result->fails());
}
public function testBetweenPasses(): void
{
$result = $this->validator->validate(['age' => '25'], ['age' => 'between:18,60']);
$this->assertTrue($result->passes());
}
public function testBetweenFails(): void
{
$result = $this->validator->validate(['age' => '10'], ['age' => 'between:18,60']);
$this->assertTrue($result->fails());
}
public function testConfirmedPasses(): void
{
$data = ['password' => 'secret', 'password_confirmation' => 'secret'];
$result = $this->validator->validate($data, ['password' => 'confirmed']);
$this->assertTrue($result->passes());
}
public function testConfirmedFails(): void
{
$data = ['password' => 'secret', 'password_confirmation' => 'different'];
$result = $this->validator->validate($data, ['password' => 'confirmed']);
$this->assertTrue($result->fails());
}
public function testMultipleRulesAllPass(): void
{
$result = $this->validator->validate(['name' => 'Ahmed'], ['name' => 'required|string|min:2|max:50']);
$this->assertTrue($result->passes());
}
public function testMultipleFieldsValidation(): void
{
$data = ['name' => 'Ahmed', 'email' => 'bad'];
$rules = ['name' => 'required|string', 'email' => 'required|email'];
$result = $this->validator->validate($data, $rules);
$this->assertTrue($result->fails());
$this->assertArrayNotHasKey('name', $result->errors());
$this->assertArrayHasKey('email', $result->errors());
}
public function testDigitsPasses(): void
{
$result = $this->validator->validate(['code' => '1234'], ['code' => 'digits:4']);
$this->assertTrue($result->passes());
}
public function testDigitsFails(): void
{
$result = $this->validator->validate(['code' => '12'], ['code' => 'digits:4']);
$this->assertTrue($result->fails());
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment