Commit 83f24d7a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(waiver): Round 2 UX — comprehensive debt check, children details, payment in target's name

- checkDebtsComprehensive() returns per-person breakdown (name, type, debt_type, period, amount)
- getDependentDetails() calculates age, DOB, age category for children
- New sendToCashier route creates payment request in TARGET member's name (buyer pays)
- Detailed receipt breakdown with both member names and per-category fees
- show.php: per-person debt table, children comparison, status indicators, go-to-payment button
- create.php: detailed debt display with person labels, children age table
- Status flow: requested → approved → send to cashier → fee_paid → complete
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent aca4bef0
...@@ -105,3 +105,491 @@ Connection params from `config/database.php` which reads from `.env`: `DB_HOST`, ...@@ -105,3 +105,491 @@ Connection params from `config/database.php` which reads from `.env`: `DB_HOST`,
- **Column names vary by table**: Not all tables have `is_archived` — check the migration before assuming. `fines` has no `is_archived` or `imposed_date` (use `created_at`). `hr_payroll_runs` uses `paid_at` not `payment_date`. `installment_plans` has no `is_archived`. - **Column names vary by table**: Not all tables have `is_archived` — check the migration before assuming. `fines` has no `is_archived` or `imposed_date` (use `created_at`). `hr_payroll_runs` uses `paid_at` not `payment_date`. `installment_plans` has no `is_archived`.
- **`declare(strict_types=1)`**: Used in all PHP files. Type mismatches (e.g., passing array to string param) cause fatal errors. - **`declare(strict_types=1)`**: Used in all PHP files. Type mismatches (e.g., passing array to string param) cause fatal errors.
- **ExceptionHandler**: Respects exception code for HTTP status (401, 403, 404 return proper pages; everything else is 500). - **ExceptionHandler**: Respects exception code for HTTP status (401, 403, 404 return proper pages; everything else is 500).
---
# MODULE ARCHITECTURE MAP SYSTEM (MANDATORY)
This instruction overrides all implementation workflows.
Before modifying, creating, refactoring, deleting, or analyzing any module, you MUST first locate and read that module's Architecture Map.
Architecture Maps are not documentation.
Architecture Maps are not notes.
Architecture Maps are not reports.
Architecture Maps are a mandatory knowledge system and source of truth used to maintain continuity across AI sessions and human developers.
Every development task must begin and end with the Architecture Map.
---
## REQUIRED WORKFLOW FOR EVERY TASK
Whenever a request affects a module:
### Step 1: Locate Architecture Map
Search for the module's Architecture Map at `docs/architecture-maps/{ModuleName}.md`.
If an Architecture Map exists:
* Read it completely.
* Use it as the starting context.
* Validate it against the current codebase.
* Update it with any new discoveries before making changes.
If an Architecture Map does NOT exist:
STOP.
Do not implement the requested change.
Do not start coding.
Do not make assumptions.
Instead, create the Architecture Map first using the Architecture Discovery Process described below.
Only after the initial Architecture Map exists may implementation begin.
---
## ARCHITECTURE MAPS ARE REQUIRED
No module is allowed to exist without an Architecture Map.
No feature is allowed to be modified without consulting its Architecture Map.
No task is considered complete until the Architecture Map has been updated.
Failure to update the Architecture Map means the task is incomplete.
---
## ARCHITECTURE DISCOVERY PROCESS
When creating a new Architecture Map:
You must build it incrementally.
Never attempt to understand the entire module in a single pass.
Instead:
1. Scan the module structure.
2. Identify major components.
3. Document findings.
4. Continue deeper analysis.
5. Append findings to the Architecture Map.
6. Refine existing entries.
7. Repeat until sufficient understanding exists.
The Architecture Map is a living document that grows over time.
It must never be regenerated from scratch.
It must be continuously improved and expanded.
---
## DATABASE TRUTH RULE
Migration files are NOT the source of truth.
Migration files may be outdated, incomplete, or inaccurate.
Whenever database structure matters:
* Connect directly to the database.
* Inspect actual tables.
* Inspect actual columns.
* Inspect actual relationships.
* Inspect actual indexes.
* Inspect actual constraints.
* Inspect actual views.
* Inspect actual triggers.
* Inspect actual procedures.
The live database is the source of truth.
If migrations conflict with the live database, trust the database.
Document actual database structure inside the Architecture Map.
---
## MANDATORY UPDATE RULE
Every code change must trigger an Architecture Map update.
After making changes:
1. Re-analyze affected components.
2. Identify new dependencies.
3. Identify changed dependencies.
4. Identify new workflows.
5. Identify changed workflows.
6. Update diagrams, flows, relationships, and references.
7. Save all findings into the Architecture Map.
This is mandatory.
There are no exceptions.
---
## ARCHITECTURE MAP PURPOSE
The Architecture Map exists to become the module's permanent knowledge graph.
Its purpose is to allow future AI sessions and human developers to:
* Understand the module quickly.
* Avoid re-discovering architecture.
* Avoid editing dead code.
* Avoid breaking live functionality.
* Avoid duplicating logic.
* Understand dependencies.
* Predict side effects.
* Implement changes safely.
* Reduce context usage and token consumption.
* Maintain continuity between development sessions.
The Architecture Map should eventually contain enough information that a developer or AI can understand how the module works before reading large portions of the codebase.
---
## REQUIRED CONTENT
Each Architecture Map must continuously document:
* Module purpose
* System responsibilities
* File structure
* Entry points
* Controllers
* Services
* Repositories
* Jobs
* Events
* Listeners
* Middleware
* API endpoints
* Database schema (from live DB, not migrations)
* Relationships
* Business rules
* User workflows
* Data flows
* State flows
* Permission model
* External integrations
* Internal integrations
* Dependency graphs
* Risk areas
* Legacy code
* Dead code
* Technical debt
* Change impact analysis
Most importantly:
Document relationships between systems, not just the systems themselves.
The Architecture Map must explain how everything connects.
---
## ENFORCEMENT
Before any task begins:
Verify Architecture Map exists at `docs/architecture-maps/{ModuleName}.md`.
If missing:
Create it first.
Before any task completes:
Update Architecture Map.
If Architecture Map is not updated:
The task is not complete.
Architecture Maps are mandatory project infrastructure and must be maintained with the same importance as production code.
---
# CROSS-MODULE DEPENDENCY GRAPH SYSTEM (MANDATORY)
Module Architecture Maps explain how a module works internally.
They do NOT fully explain how modules interact with each other.
To prevent incomplete implementations, missed cascading changes, broken workflows, hidden regressions, and cross-module inconsistencies, the system must maintain a second knowledge layer called the Cross-Module Dependency Graph.
This graph is a mandatory project artifact stored at `docs/architecture-maps/DEPENDENCY-GRAPH.md`.
It must be consulted before any implementation begins and updated after every implementation is completed.
---
## PURPOSE
The purpose of the Dependency Graph is to answer questions such as:
* If this field changes, what else breaks?
* If this workflow changes, what downstream systems are affected?
* If this API changes, who consumes it?
* If this table changes, what reports depend on it?
* If this permission changes, what modules are affected?
* If this event changes, what listeners will stop working?
* If this status changes, which automations fail?
* If this business rule changes, which workflows become invalid?
The Dependency Graph exists specifically to identify cascading changes.
---
## REQUIRED WORKFLOW
Before implementing any request:
Step 1: Read the affected Module Architecture Maps.
Step 2: Read the Cross-Module Dependency Graph.
Step 3: Identify all directly affected modules.
Step 4: Identify all indirectly affected modules.
Step 5: Build an Impact Analysis.
Step 6: Only then begin implementation.
No implementation should begin until dependency analysis has been completed.
---
## IF THE DEPENDENCY GRAPH DOES NOT EXIST
If the Dependency Graph does not exist:
STOP.
Do not begin implementation.
Create the Dependency Graph first.
Then continue with implementation.
---
## IMPACT ANALYSIS IS MANDATORY
For every requested change, produce an internal impact analysis identifying:
* **Direct Impact** — Files and systems being modified.
* **Upstream Dependencies** — Systems providing data or functionality.
* **Downstream Dependencies** — Systems consuming data or functionality.
* **Cascading Changes** — Changes required in other modules.
* **Risk Assessment** — Potential regressions.
* **Validation Requirements** — Areas requiring testing.
Implementation should never be performed until this analysis is complete.
---
## REQUIRED RELATIONSHIPS TO TRACK
The Dependency Graph must track:
* Module → Module Dependencies
* Database Dependencies (tables, views, procedures, shared entities)
* API Dependencies (providers and consumers)
* Event Dependencies (events, listeners, subscribers)
* Workflow Dependencies (business processes spanning multiple modules)
* Permission Dependencies (shared roles, policies, access controls)
* Reporting Dependencies (reports, dashboards, exports)
* Notification Dependencies (SMS, push notifications, alerts)
* Automation Dependencies (jobs, schedulers, workflows, triggers)
* Configuration Dependencies (settings, system options, rule engine keys)
---
## MANDATORY UPDATE RULE
Every implementation must update:
1. The affected Module Architecture Maps.
2. The Cross-Module Dependency Graph.
A task is not complete until both artifacts are updated.
---
## ENFORCEMENT
Before implementation:
Read Architecture Maps. Read Dependency Graph. Perform Impact Analysis.
After implementation:
Update Architecture Maps. Update Dependency Graph.
If any of these are missing or outdated, the task is incomplete.
The Dependency Graph is considered critical project infrastructure and must be maintained with the same importance as production code.
---
# DOMAIN LOGIC PRESERVATION PROTOCOL (MANDATORY)
The goal of every implementation is not to satisfy the request. The goal is to preserve and improve the correctness of the overall system.
A request describes a symptom. A request does not automatically describe the correct solution.
Before implementing any change, determine:
1. What behavior is being observed.
2. What behavior is expected.
3. What business rule is being violated.
4. Where that business rule truly belongs.
5. Which systems depend on that business rule.
Never assume the screen, form, API, controller, service, or file mentioned in the request is the correct place for the fix.
---
## ROOT CAUSE BEFORE FIX
Before modifying code, identify:
* Root cause.
* Business rule.
* Source of truth.
* Affected workflows.
* Affected modules.
Do not implement a fix until root cause analysis is complete.
---
## BUSINESS RULE FIRST
Every bug must be translated into a business rule.
Wrong: "The user can enter a negative quantity."
Correct: "Inventory quantities must never become negative."
The second statement describes a business rule. The first describes only one manifestation of the problem.
Always solve the business rule. Never solve only the symptom.
---
## SOURCE OF TRUTH RULE
Every business rule must have a source of truth (database constraint, domain service, validation service, workflow engine, permission system, state machine).
Before implementing a fix, identify where the source of truth should exist.
Whenever possible:
* Fix the source of truth.
* Avoid implementing duplicate validations in multiple locations.
* Avoid fixing only the UI.
* Avoid fixing only a single endpoint.
* Avoid fixing only a single workflow.
---
## SYSTEM-WIDE IMPACT REVIEW
Before implementing any change, ask:
* Where else can this action happen?
* Which APIs perform the same action?
* Which jobs perform the same action?
* Which imports perform the same action?
* Which automations perform the same action?
* Which modules perform the same action?
The solution must account for all valid entry points.
---
## NO LOCAL OPTIMIZATION
Never optimize for making a ticket pass. Never optimize for making a screen behave correctly in isolation. Never optimize for making a test pass.
Optimize for preserving the integrity of the entire system.
A fix that solves one screen while breaking architecture is a failed fix. A fix that solves one workflow while creating inconsistencies elsewhere is a failed fix.
---
## ARCHITECTURAL REVIEW REQUIRED
Before implementation, determine whether the proposed fix:
* Preserves existing business rules.
* Violates existing business rules.
* Introduces duplicate logic.
* Creates conflicting logic.
* Creates alternative sources of truth.
* Breaks system consistency.
If any of these occur, redesign the solution.
---
## MULTI-WORKFLOW VALIDATION
For every change, identify:
* Primary workflow.
* Secondary workflows.
* Administrative workflows.
* Automated workflows.
* Integration workflows.
Verify that the solution works consistently across all of them.
---
## IMPLEMENTATION DECISION RECORD
Before coding, document internally:
* **Issue:** What is happening.
* **Expected Behavior:** What should happen.
* **Business Rule:** Underlying rule.
* **Root Cause:** Actual cause.
* **Source of Truth:** Where rule should live.
* **Affected Modules:** List.
* **Affected Workflows:** List.
* **Chosen Solution:** Why this approach preserves system integrity.
* **Rejected Solutions:** Why simpler fixes were not used.
Only after this analysis may implementation begin.
---
## ENFORCEMENT
A change is considered incomplete if:
* It fixes only the symptom.
* It ignores root cause.
* It creates duplicate business logic.
* It introduces additional sources of truth.
* It does not evaluate downstream effects.
* It does not validate system-wide consistency.
The objective is not to satisfy the request. The objective is to preserve the correctness, consistency, and integrity of the entire ERP system.
...@@ -17,13 +17,14 @@ abstract class ApiController extends Controller ...@@ -17,13 +17,14 @@ abstract class ApiController extends Controller
], $status); ], $status);
} }
protected function errorResponse(string $message, int $status = 400, array $details = []): Response protected function errorResponse(string $message, int $status = 400, array $details = [], ?string $code = null): Response
{ {
return $this->json([ return $this->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => null, 'meta' => null,
'errors' => [ 'errors' => [
'code' => $code,
'message' => $message, 'message' => $message,
'details' => $details ?: null, 'details' => $details ?: null,
], ],
......
...@@ -14,6 +14,7 @@ final class App ...@@ -14,6 +14,7 @@ final class App
private ?object $currentEmployee = null; private ?object $currentEmployee = null;
private ?object $currentPlayer = null; private ?object $currentPlayer = null;
private ?object $currentCoach = null; private ?object $currentCoach = null;
private ?object $currentParent = null;
private ?array $currentBranch = null; private ?array $currentBranch = null;
private array $bindings = []; private array $bindings = [];
private array $factories = []; private array $factories = [];
...@@ -384,6 +385,16 @@ final class App ...@@ -384,6 +385,16 @@ final class App
return $this->currentCoach; return $this->currentCoach;
} }
public function setCurrentParent(object $parent): void
{
$this->currentParent = $parent;
}
public function currentParent(): ?object
{
return $this->currentParent;
}
public function setCurrentBranch(?array $branch): void public function setCurrentBranch(?array $branch): void
{ {
$this->currentBranch = $branch; $this->currentBranch = $branch;
......
...@@ -108,6 +108,7 @@ final class Router ...@@ -108,6 +108,7 @@ final class Router
'api_auth' => \App\Middleware\ApiAuthMiddleware::class, 'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'player_auth' => \App\Middleware\PlayerApiAuthMiddleware::class, 'player_auth' => \App\Middleware\PlayerApiAuthMiddleware::class,
'coach_auth' => \App\Middleware\CoachApiAuthMiddleware::class, 'coach_auth' => \App\Middleware\CoachApiAuthMiddleware::class,
'parent_auth' => \App\Middleware\ParentApiAuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class, 'cors' => \App\Middleware\CorsMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class, 'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class, 'audit' => \App\Middleware\AuditMiddleware::class,
......
...@@ -6,6 +6,7 @@ namespace App\Middleware; ...@@ -6,6 +6,7 @@ namespace App\Middleware;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\ApiErrorCodes;
final class CoachApiAuthMiddleware final class CoachApiAuthMiddleware
{ {
...@@ -16,8 +17,8 @@ final class CoachApiAuthMiddleware ...@@ -16,8 +17,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Authentication required'], 'errors' => ['code' => ApiErrorCodes::AUTH_REQUIRED, 'message' => 'Authentication required'],
], 401); ], 401);
} }
...@@ -31,8 +32,8 @@ final class CoachApiAuthMiddleware ...@@ -31,8 +32,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Invalid token'], 'errors' => ['code' => ApiErrorCodes::AUTH_INVALID, 'message' => 'Invalid token'],
], 401); ], 401);
} }
...@@ -40,8 +41,8 @@ final class CoachApiAuthMiddleware ...@@ -40,8 +41,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Token expired'], 'errors' => ['code' => ApiErrorCodes::AUTH_EXPIRED, 'message' => 'Token expired'],
], 401); ], 401);
} }
...@@ -54,8 +55,8 @@ final class CoachApiAuthMiddleware ...@@ -54,8 +55,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Account inactive'], 'errors' => ['code' => ApiErrorCodes::AUTH_INACTIVE, 'message' => 'Account inactive'],
], 401); ], 401);
} }
......
...@@ -6,6 +6,7 @@ namespace App\Middleware; ...@@ -6,6 +6,7 @@ namespace App\Middleware;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\ApiErrorCodes;
final class PlayerApiAuthMiddleware final class PlayerApiAuthMiddleware
{ {
...@@ -16,8 +17,8 @@ final class PlayerApiAuthMiddleware ...@@ -16,8 +17,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Authentication required'], 'errors' => ['code' => ApiErrorCodes::AUTH_REQUIRED, 'message' => 'Authentication required'],
], 401); ], 401);
} }
...@@ -31,8 +32,8 @@ final class PlayerApiAuthMiddleware ...@@ -31,8 +32,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Invalid token'], 'errors' => ['code' => ApiErrorCodes::AUTH_INVALID, 'message' => 'Invalid token'],
], 401); ], 401);
} }
...@@ -40,8 +41,8 @@ final class PlayerApiAuthMiddleware ...@@ -40,8 +41,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Token expired'], 'errors' => ['code' => ApiErrorCodes::AUTH_EXPIRED, 'message' => 'Token expired'],
], 401); ], 401);
} }
...@@ -54,8 +55,8 @@ final class PlayerApiAuthMiddleware ...@@ -54,8 +55,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([ return (new Response())->json([
'success' => false, 'success' => false,
'data' => null, 'data' => null,
'meta' => [], 'meta' => null,
'errors' => ['Account inactive'], 'errors' => ['code' => ApiErrorCodes::AUTH_INACTIVE, 'message' => 'Account inactive'],
], 401); ], 401);
} }
......
...@@ -7,6 +7,7 @@ use App\Core\Middleware\MiddlewareInterface; ...@@ -7,6 +7,7 @@ use App\Core\Middleware\MiddlewareInterface;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\ApiErrorCodes;
final class RateLimitMiddleware implements MiddlewareInterface final class RateLimitMiddleware implements MiddlewareInterface
{ {
...@@ -16,22 +17,46 @@ final class RateLimitMiddleware implements MiddlewareInterface ...@@ -16,22 +17,46 @@ final class RateLimitMiddleware implements MiddlewareInterface
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
$ip = $request->ip(); $ip = $request->ip();
$key = 'rate_limit_' . md5($ip . $request->path()); $routeKey = md5($ip . ':' . $request->method() . ':' . $request->path());
$session = App::getInstance()->session(); $db = App::getInstance()->db();
$data = $session->get($key, ['count' => 0, 'reset_at' => time() + $this->windowSeconds]); if (!$db) {
return $next($request);
}
$now = date('Y-m-d H:i:s');
$windowStart = date('Y-m-d H:i:s', time() - $this->windowSeconds);
$row = $db->selectOne(
"SELECT hits, window_start FROM api_rate_limits WHERE ip = ? AND route_key = ?",
[$ip, $routeKey]
);
if (time() > $data['reset_at']) { if (!$row || $row['window_start'] < $windowStart) {
$data = ['count' => 0, 'reset_at' => time() + $this->windowSeconds]; $db->query(
"INSERT INTO api_rate_limits (ip, route_key, hits, window_start) VALUES (?, ?, 1, ?) "
. "ON DUPLICATE KEY UPDATE hits = 1, window_start = ?",
[$ip, $routeKey, $now, $now]
);
return $next($request);
} }
$data['count']++; if ((int) $row['hits'] >= $this->maxRequests) {
$session->set($key, $data); $retryAfter = $this->windowSeconds - (time() - strtotime($row['window_start']));
if ($data['count'] > $this->maxRequests) { if ($request->isAjax() || $request->isJson() || $request->bearerToken()) {
if ($request->isAjax() || $request->isJson()) { return (new Response())->json([
return (new Response())->json(['error' => 'تم تجاوز الحد الأقصى للطلبات. حاول لاحقاً.'], 429); 'success' => false,
'data' => null,
'meta' => null,
'errors' => [
'code' => ApiErrorCodes::RATE_LIMITED,
'message' => 'تم تجاوز الحد الأقصى للطلبات. حاول لاحقاً.',
'details' => ['retry_after' => max($retryAfter, 1)],
],
], 429)->header('Retry-After', (string) max($retryAfter, 1));
} }
return (new Response())->html( return (new Response())->html(
'<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>كثرة الطلبات</title>' '<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>كثرة الطلبات</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:80px;}</style></head>' . '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:80px;}</style></head>'
...@@ -40,6 +65,11 @@ final class RateLimitMiddleware implements MiddlewareInterface ...@@ -40,6 +65,11 @@ final class RateLimitMiddleware implements MiddlewareInterface
); );
} }
$db->query(
"UPDATE api_rate_limits SET hits = hits + 1 WHERE ip = ? AND route_key = ?",
[$ip, $routeKey]
);
return $next($request); return $next($request);
} }
} }
...@@ -47,4 +47,23 @@ return [ ...@@ -47,4 +47,23 @@ return [
// Profile // Profile
['GET', '/api/v1/player/profile', 'PlayerApi\Controllers\Api\ProfileController@show', ['cors', 'player_auth'], null], ['GET', '/api/v1/player/profile', 'PlayerApi\Controllers\Api\ProfileController@show', ['cors', 'player_auth'], null],
['PUT', '/api/v1/player/profile', 'PlayerApi\Controllers\Api\ProfileController@update', ['cors', 'player_auth'], null], ['PUT', '/api/v1/player/profile', 'PlayerApi\Controllers\Api\ProfileController@update', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/profile/photo', 'PlayerApi\Controllers\Api\PhotoController@upload', ['cors', 'player_auth'], null],
// Financials
['GET', '/api/v1/player/financials/summary', 'PlayerApi\Controllers\Api\FinancialController@summary', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/financials/subscriptions', 'PlayerApi\Controllers\Api\FinancialController@subscriptions', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/financials/payments', 'PlayerApi\Controllers\Api\FinancialController@payments', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/financials/fines', 'PlayerApi\Controllers\Api\FinancialController@fines', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/financials/installments', 'PlayerApi\Controllers\Api\FinancialController@installments', ['cors', 'player_auth'], null],
// Attendance
['GET', '/api/v1/player/attendance', 'PlayerApi\Controllers\Api\AttendanceController@history', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/attendance/summary', 'PlayerApi\Controllers\Api\AttendanceController@summary', ['cors', 'player_auth'], null],
// Achievements
['GET', '/api/v1/player/achievements', 'PlayerApi\Controllers\Api\AchievementController@index', ['cors', 'player_auth'], null],
// Device / Push Token
['POST', '/api/v1/player/device', 'PlayerApi\Controllers\Api\DeviceController@register', ['cors', 'player_auth'], null],
['DELETE', '/api/v1/player/device', 'PlayerApi\Controllers\Api\DeviceController@unregister', ['cors', 'player_auth'], null],
]; ];
...@@ -4,6 +4,7 @@ declare(strict_types=1); ...@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\PlayerApi\Services; namespace App\Modules\PlayerApi\Services;
use App\Core\App; use App\Core\App;
use App\Shared\Services\PushNotificationService;
final class PlayerNotificationService final class PlayerNotificationService
{ {
...@@ -17,6 +18,8 @@ final class PlayerNotificationService ...@@ -17,6 +18,8 @@ final class PlayerNotificationService
'body_ar' => $body, 'body_ar' => $body,
'data_json' => !empty($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : null, 'data_json' => !empty($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : null,
]); ]);
PushNotificationService::send('player', $playerId, $title, $body, array_merge($data, ['category' => $category]));
} }
public static function getForPlayer(int $playerId, ?string $category = null, ?bool $isRead = null, int $page = 1, int $perPage = 25): array public static function getForPlayer(int $playerId, ?string $category = null, ?bool $isRead = null, int $page = 1, int $perPage = 25): array
......
...@@ -24,4 +24,26 @@ return [ ...@@ -24,4 +24,26 @@ return [
// Replacement // Replacement
['GET', '/api/v1/coach/replacements', 'TrainerPortal\Controllers\Api\ReplacementController@available', ['cors', 'coach_auth'], null], ['GET', '/api/v1/coach/replacements', 'TrainerPortal\Controllers\Api\ReplacementController@available', ['cors', 'coach_auth'], null],
['POST', '/api/v1/coach/replacements/{id:\d+}/volunteer', 'TrainerPortal\Controllers\Api\ReplacementController@volunteer', ['cors', 'coach_auth'], null], ['POST', '/api/v1/coach/replacements/{id:\d+}/volunteer', 'TrainerPortal\Controllers\Api\ReplacementController@volunteer', ['cors', 'coach_auth'], null],
// Attendance Marking
['GET', '/api/v1/coach/sessions/{id:\d+}/attendance', 'TrainerPortal\Controllers\Api\AttendanceController@show', ['cors', 'coach_auth'], null],
['POST', '/api/v1/coach/sessions/{id:\d+}/attendance', 'TrainerPortal\Controllers\Api\AttendanceController@mark', ['cors', 'coach_auth'], null],
// Evaluations
['GET', '/api/v1/coach/groups/{id:\d+}/evaluation-criteria', 'TrainerPortal\Controllers\Api\EvaluationController@criteria', ['cors', 'coach_auth'], null],
['POST', '/api/v1/coach/evaluations', 'TrainerPortal\Controllers\Api\EvaluationController@store', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/evaluations', 'TrainerPortal\Controllers\Api\EvaluationController@index', ['cors', 'coach_auth'], null],
// Notifications
['GET', '/api/v1/coach/notifications', 'TrainerPortal\Controllers\Api\NotificationController@list', ['cors', 'coach_auth'], null],
['POST', '/api/v1/coach/notifications/{id:\d+}/read', 'TrainerPortal\Controllers\Api\NotificationController@markRead', ['cors', 'coach_auth'], null],
['POST', '/api/v1/coach/notifications/read-all', 'TrainerPortal\Controllers\Api\NotificationController@markAllRead', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/notifications/unread-count', 'TrainerPortal\Controllers\Api\NotificationController@unreadCount', ['cors', 'coach_auth'], null],
// Device / Push Token
['POST', '/api/v1/coach/device', 'TrainerPortal\Controllers\Api\DeviceController@register', ['cors', 'coach_auth'], null],
['DELETE', '/api/v1/coach/device', 'TrainerPortal\Controllers\Api\DeviceController@unregister', ['cors', 'coach_auth'], null],
// Profile Photo
['POST', '/api/v1/coach/profile/photo', 'TrainerPortal\Controllers\Api\PhotoController@upload', ['cors', 'coach_auth'], null],
]; ];
...@@ -36,7 +36,8 @@ class WaiverController extends Controller ...@@ -36,7 +36,8 @@ class WaiverController extends Controller
$waiverFee = bcdiv(bcmul($membershipValue, $waiverPct, 4), '100', 2); $waiverFee = bcdiv(bcmul($membershipValue, $waiverPct, 4), '100', 2);
$dependents = WaiverProcessor::countDependents((int) $memberId); $dependents = WaiverProcessor::countDependents((int) $memberId);
$debtCheck = WaiverProcessor::checkDebts((int) $memberId); $dependentDetails = WaiverProcessor::getDependentDetails((int) $memberId);
$debtCheck = WaiverProcessor::checkDebtsComprehensive((int) $memberId);
return $this->view('Waiver.Views.create', [ return $this->view('Waiver.Views.create', [
'member' => $member, 'member' => $member,
...@@ -44,6 +45,7 @@ class WaiverController extends Controller ...@@ -44,6 +45,7 @@ class WaiverController extends Controller
'waiver_pct' => $waiverPct, 'waiver_pct' => $waiverPct,
'waiver_fee' => $waiverFee, 'waiver_fee' => $waiverFee,
'dependents' => $dependents, 'dependents' => $dependents,
'dependent_details' => $dependentDetails,
'total_dependents' => $dependents['total'], 'total_dependents' => $dependents['total'],
'debt_check' => $debtCheck, 'debt_check' => $debtCheck,
]); ]);
...@@ -56,21 +58,31 @@ class WaiverController extends Controller ...@@ -56,21 +58,31 @@ class WaiverController extends Controller
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (!$member['membership_number']) return $this->redirect("/members/{$memberId}")->withError('العضو ليس لديه رقم عضوية'); if (!$member['membership_number']) return $this->redirect("/members/{$memberId}")->withError('العضو ليس لديه رقم عضوية');
// Check debts — cannot proceed if unpaid // Check debts on source member (including dependents)
$debtCheck = WaiverProcessor::checkDebts((int) $memberId); $debtCheck = WaiverProcessor::checkDebtsComprehensive((int) $memberId);
if (!$debtCheck['clear']) { if (!$debtCheck['clear']) {
return $this->redirect("/members/{$memberId}")->withError( return $this->redirect("/members/{$memberId}")->withError(
'لا يمكن تقديم طلب التنازل — يوجد مديونية بمبلغ ' . money($debtCheck['total']) . ' يجب سدادها أولاً' 'لا يمكن تقديم طلب التنازل — يوجد مديونية على المتنازل بمبلغ ' . money($debtCheck['total']) . ' يجب سدادها أولاً'
); );
} }
// Check debts on target member if specified (including dependents)
$targetMemberId = (int) $request->post('target_member_id', 0);
if ($targetMemberId > 0) {
$targetDebtCheck = WaiverProcessor::checkDebtsComprehensive($targetMemberId);
if (!$targetDebtCheck['clear']) {
return $this->redirect("/members/{$memberId}")->withError(
'لا يمكن تقديم طلب التنازل — يوجد مديونية على المتنازل إليه بمبلغ ' . money($targetDebtCheck['total']) . ' يجب سدادها أولاً'
);
}
}
$membershipValue = self::getCurrentMembershipValue($db, $member); $membershipValue = self::getCurrentMembershipValue($db, $member);
$waiverPctData = RuleEngine::get('WAIVER_FEE'); $waiverPctData = RuleEngine::get('WAIVER_FEE');
$waiverPct = $waiverPctData['percentage'] ?? '30.00'; $waiverPct = $waiverPctData['percentage'] ?? '30.00';
$waiverFee = bcdiv(bcmul($membershipValue, $waiverPct, 4), '100', 2); $waiverFee = bcdiv(bcmul($membershipValue, $waiverPct, 4), '100', 2);
$dependents = WaiverProcessor::countDependents((int) $memberId); $dependents = WaiverProcessor::countDependents((int) $memberId);
$targetMemberId = (int) $request->post('target_member_id', 0);
// Handle document uploads // Handle document uploads
$waiverDocPath = null; $waiverDocPath = null;
...@@ -92,9 +104,13 @@ class WaiverController extends Controller ...@@ -92,9 +104,13 @@ class WaiverController extends Controller
'waiver_fee_percentage' => $waiverPct, 'waiver_fee_percentage' => $waiverPct,
'waiver_fee_amount' => $waiverFee, 'waiver_fee_amount' => $waiverFee,
'original_dependent_count' => $dependents['total'], 'original_dependent_count' => $dependents['total'],
'original_spouses_count' => $dependents['spouses'],
'original_children_count' => $dependents['children'],
'original_temporary_count' => $dependents['temporary'],
'board_approval_required' => 1, 'board_approval_required' => 1,
'annual_renewal_paid' => 1, 'annual_renewal_paid' => 1,
'debts_cleared' => 1, 'debts_cleared' => 1,
'target_debts_cleared' => $targetMemberId > 0 ? 1 : 0,
'status' => 'requested', 'status' => 'requested',
'notes' => trim($request->post('notes', '')) ?: null, 'notes' => trim($request->post('notes', '')) ?: null,
'waiver_request_doc_path' => $waiverDocPath, 'waiver_request_doc_path' => $waiverDocPath,
...@@ -103,41 +119,11 @@ class WaiverController extends Controller ...@@ -103,41 +119,11 @@ class WaiverController extends Controller
EventBus::dispatch('waiver.requested', ['waiver_id' => (int) $waiver->id, 'member_id' => (int) $memberId]); EventBus::dispatch('waiver.requested', ['waiver_id' => (int) $waiver->id, 'member_id' => (int) $memberId]);
if (bccomp($waiverFee, '0', 2) > 0) {
$breakdown = [
'📋 نوع العملية: تنازل عن العضوية',
'💰 قيمة العضوية الحالية: ' . money($membershipValue),
'📊 نسبة رسوم التنازل: ' . $waiverPct . '%',
'💵 الرسوم: ' . $waiverPct . '% × ' . money($membershipValue) . ' = ' . money($waiverFee),
'👥 عدد التابعين الأصلي: ' . $dependents['total'] . ' (زوجات: ' . $dependents['spouses'] . ' — أبناء: ' . $dependents['children'] . ' — مؤقتين: ' . $dependents['temporary'] . ')',
'═══════════════════════════',
'💵 الإجمالي: ' . money($waiverFee),
];
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $waiverFee,
'payment_type' => 'waiver_fee',
'related_entity_type' => 'waiver_requests',
'related_entity_id' => (int) $waiver->id,
'description_ar' => 'رسوم تنازل — طلب #' . $waiver->id,
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]);
if ($result['success']) {
return $this->redirect("/waivers/{$waiver->id}")->withSuccess( return $this->redirect("/waivers/{$waiver->id}")->withSuccess(
'تم تقديم طلب التنازل وإرسال طلب الدفع للخزينة — ' . money($waiverFee) . ' (' . $waiverPct . '%) — رقم الطلب: ' . $result['request_number'] 'تم تقديم طلب التنازل بنجاح — في انتظار اعتماد مجلس الأمناء وتحديد الرسوم'
);
}
return $this->redirect("/waivers/{$waiver->id}")->withError(
'تم تقديم الطلب لكن فشل إنشاء طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف')
); );
} }
return $this->redirect("/waivers/{$waiver->id}")->withSuccess('تم تقديم طلب التنازل — الرسوم: ' . money($waiverFee) . ' (' . $waiverPct . '%)');
}
public function show(Request $request, string $id): Response public function show(Request $request, string $id): Response
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
...@@ -152,57 +138,40 @@ class WaiverController extends Controller ...@@ -152,57 +138,40 @@ class WaiverController extends Controller
); );
if (!$waiver) return $this->redirect('/waivers')->withError('الطلب غير موجود'); if (!$waiver) return $this->redirect('/waivers')->withError('الطلب غير موجود');
// Get live dependent counts for display
$sourceDeps = WaiverProcessor::countDependents((int) $waiver['source_member_id']); $sourceDeps = WaiverProcessor::countDependents((int) $waiver['source_member_id']);
$targetDeps = $waiver['target_member_id'] ? WaiverProcessor::countDependents((int) $waiver['target_member_id']) : null; $targetDeps = $waiver['target_member_id'] ? WaiverProcessor::countDependents((int) $waiver['target_member_id']) : null;
return $this->view('Waiver.Views.show', [ // Detailed dependent data
'waiver' => $waiver, $sourceDetails = WaiverProcessor::getDependentDetails((int) $waiver['source_member_id']);
'source_deps' => $sourceDeps, $targetDetails = $waiver['target_member_id'] ? WaiverProcessor::getDependentDetails((int) $waiver['target_member_id']) : null;
'target_deps' => $targetDeps,
]);
}
public function pay(Request $request, string $id): Response $originalDeps = [
{ 'spouses' => (int) ($waiver['original_spouses_count'] ?? $sourceDeps['spouses']),
$db = App::getInstance()->db(); 'children' => (int) ($waiver['original_children_count'] ?? $sourceDeps['children']),
$waiver = $db->selectOne("SELECT * FROM waiver_requests WHERE id = ?", [(int) $id]); 'temporary' => (int) ($waiver['original_temporary_count'] ?? $sourceDeps['temporary']),
if (!$waiver) return $this->redirect('/waivers')->withError('الطلب غير موجود'); 'total' => (int) ($waiver['original_dependent_count'] ?? $sourceDeps['total']),
if (\in_array($waiver['status'], ['completed', 'fee_paid', 'rejected'])) { ];
return $this->redirect("/waivers/{$id}")->withError('لا يمكن الدفع لهذا الطلب');
}
$amount = $waiver['waiver_fee_amount'] ?? '0.00';
// Include excess fee if any
$excessFee = $waiver['excess_fee_amount'] ?? '0.00';
$totalPayment = bcadd((string) $amount, (string) $excessFee, 2);
if (bccomp($totalPayment, '0', 2) <= 0) {
return $this->redirect("/waivers/{$id}")->withError('لا توجد رسوم مطلوبة');
}
$result = PaymentService::processPayment([
'member_id' => (int) $waiver['source_member_id'],
'amount' => $totalPayment,
'payment_type' => 'waiver_fee',
'payment_method' => $request->post('payment_method', 'cash'),
'related_entity_type' => 'waiver_requests',
'related_entity_id' => (int) $id,
'description' => 'رسوم تنازل — طلب #' . $id . (bccomp($excessFee, '0', 2) > 0 ? ' (يشمل رسوم تابعين إضافيين)' : ''),
]);
if (!$result['success']) { $comparison = null;
return $this->redirect("/waivers/{$id}")->withError($result['error']); if ($targetDeps) {
$comparison = WaiverProcessor::compareDependents($originalDeps, $targetDeps);
} }
$db->update('waiver_requests', [ // Comprehensive debt check for both members (live)
'status' => 'fee_paid', $sourceDebtCheck = WaiverProcessor::checkDebtsComprehensive((int) $waiver['source_member_id']);
'updated_at' => date('Y-m-d H:i:s'), $targetDebtCheck = $waiver['target_member_id'] ? WaiverProcessor::checkDebtsComprehensive((int) $waiver['target_member_id']) : null;
], '`id` = ?', [(int) $id]);
EventBus::dispatch('waiver.fee_paid', ['waiver_id' => (int) $id, 'payment_id' => $result['payment_id']]);
return $this->redirect("/waivers/{$id}")->withSuccess('تم دفع رسوم التنازل — إيصال: ' . $result['receipt_number']); return $this->view('Waiver.Views.show', [
'waiver' => $waiver,
'source_deps' => $sourceDeps,
'target_deps' => $targetDeps,
'source_details' => $sourceDetails,
'target_details' => $targetDetails,
'original_deps' => $originalDeps,
'comparison' => $comparison,
'source_debt_check' => $sourceDebtCheck,
'target_debt_check' => $targetDebtCheck,
]);
} }
public function approve(Request $request, string $id): Response public function approve(Request $request, string $id): Response
...@@ -213,66 +182,91 @@ class WaiverController extends Controller ...@@ -213,66 +182,91 @@ class WaiverController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$boardRef = trim($request->post('board_decision_reference', '')); $boardRef = trim($request->post('board_decision_reference', ''));
$membershipValue = $waiver['membership_value_at_waiver'];
// Handle excess dependents — board sets the percentage // Per-category fee configuration from board
$excessPct = trim($request->post('excess_fee_percentage', '')); $spouseFeeType = $request->post('spouse_fee_type', '') ?: null;
$excessFeeAmount = '0.00'; $spouseFeeRate = trim($request->post('spouse_fee_rate', ''));
$excessCount = 0; $childFeeType = $request->post('child_fee_type', '') ?: null;
$childFeeRate = trim($request->post('child_fee_rate', ''));
$tempFeeType = $request->post('temporary_fee_type', '') ?: null;
$tempFeeRate = trim($request->post('temporary_fee_rate', ''));
// Calculate per-category excess
$excessSpouses = 0;
$excessChildren = 0;
$excessTemporary = 0;
$spouseFeeTotal = '0.00';
$childFeeTotal = '0.00';
$tempFeeTotal = '0.00';
$originalDeps = [
'spouses' => (int) ($waiver['original_spouses_count'] ?? 0),
'children' => (int) ($waiver['original_children_count'] ?? 0),
'temporary' => (int) ($waiver['original_temporary_count'] ?? 0),
];
if ($waiver['target_member_id']) { if ($waiver['target_member_id']) {
$targetDeps = WaiverProcessor::countDependents((int) $waiver['target_member_id']); $targetDeps = WaiverProcessor::countDependents((int) $waiver['target_member_id']);
$originalCount = (int) $waiver['original_dependent_count']; $comparison = WaiverProcessor::compareDependents($originalDeps, $targetDeps);
if ($targetDeps['total'] > $originalCount) {
$excessCount = $targetDeps['total'] - $originalCount; $excessSpouses = $comparison['excess_spouses'];
if ($excessPct !== '' && is_numeric($excessPct)) { $excessChildren = $comparison['excess_children'];
$membershipValue = $waiver['membership_value_at_waiver']; $excessTemporary = $comparison['excess_temporary'];
$excessFeeAmount = bcdiv(bcmul((string) $membershipValue, $excessPct, 4), '100', 2);
$excessFeeAmount = bcmul($excessFeeAmount, (string) $excessCount, 2); $feeConfig = [
} 'spouse_fee_type' => $spouseFeeType,
} 'spouse_fee_rate' => $spouseFeeRate,
'child_fee_type' => $childFeeType,
'child_fee_rate' => $childFeeRate,
'temporary_fee_type' => $tempFeeType,
'temporary_fee_rate' => $tempFeeRate,
];
$fees = WaiverProcessor::calculateExcessFees($comparison, $feeConfig, (string) $membershipValue);
$spouseFeeTotal = $fees['spouse_fee_total'];
$childFeeTotal = $fees['child_fee_total'];
$tempFeeTotal = $fees['temporary_fee_total'];
} }
$totalExcessFee = bcadd(bcadd($spouseFeeTotal, $childFeeTotal, 2), $tempFeeTotal, 2);
$totalExcessCount = $excessSpouses + $excessChildren + $excessTemporary;
$updateData = [ $updateData = [
'status' => 'approved', 'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null, 'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'), 'approved_at' => date('Y-m-d H:i:s'),
'board_decision_reference' => $boardRef ?: null, 'board_decision_reference' => $boardRef ?: null,
'excess_dependent_count' => $totalExcessCount,
'excess_spouses_count' => $excessSpouses,
'excess_children_count' => $excessChildren,
'excess_temporary_count' => $excessTemporary,
'excess_fee_percentage' => null,
'excess_fee_amount' => $totalExcessFee,
'spouse_fee_type' => $spouseFeeType,
'spouse_fee_rate' => ($spouseFeeRate !== '' && is_numeric($spouseFeeRate)) ? $spouseFeeRate : null,
'spouse_fee_total' => $spouseFeeTotal,
'child_fee_type' => $childFeeType,
'child_fee_rate' => ($childFeeRate !== '' && is_numeric($childFeeRate)) ? $childFeeRate : null,
'child_fee_total' => $childFeeTotal,
'temporary_fee_type' => $tempFeeType,
'temporary_fee_rate' => ($tempFeeRate !== '' && is_numeric($tempFeeRate)) ? $tempFeeRate : null,
'temporary_fee_total' => $tempFeeTotal,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]; ];
if ($excessCount > 0) {
$updateData['excess_dependent_count'] = $excessCount;
$updateData['excess_fee_percentage'] = $excessPct !== '' ? $excessPct : null;
$updateData['excess_fee_amount'] = $excessFeeAmount;
}
$db->update('waiver_requests', $updateData, '`id` = ?', [(int) $id]); $db->update('waiver_requests', $updateData, '`id` = ?', [(int) $id]);
// Send payment request including excess fees if applicable $msg = 'تمت الموافقة على طلب التنازل';
$totalFee = bcadd((string) ($waiver['waiver_fee_amount'] ?? '0'), $excessFeeAmount, 2); if ($totalExcessCount > 0) {
if (bccomp($totalFee, '0', 2) > 0) { $parts = [];
$breakdown = [ if ($excessSpouses > 0) $parts[] = $excessSpouses . ' زوجة';
'📋 نوع العملية: تنازل عن العضوية (معتمد من مجلس الأمناء)', if ($excessChildren > 0) $parts[] = $excessChildren . ' ابن';
'💰 رسوم التنازل: ' . money($waiver['waiver_fee_amount']), if ($excessTemporary > 0) $parts[] = $excessTemporary . ' مؤقت';
]; $msg .= ' — رسوم إضافية عن: ' . implode(' + ', $parts) . ' = ' . money($totalExcessFee);
if (bccomp($excessFeeAmount, '0', 2) > 0) {
$breakdown[] = '👥 رسوم أعضاء إضافيين: ' . $excessCount . ' عضو × ' . $excessPct . '% = ' . money($excessFeeAmount);
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي: ' . money($totalFee);
PaymentRequestService::createRequest([
'member_id' => (int) $waiver['source_member_id'],
'amount' => $totalFee,
'payment_type' => 'waiver_fee',
'related_entity_type' => 'waiver_requests',
'related_entity_id' => (int) $id,
'description_ar' => 'رسوم تنازل (معتمد) — طلب #' . $id,
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]);
} }
return $this->redirect("/waivers/{$id}")->withSuccess('تمت الموافقة على طلب التنازل' . ($excessCount > 0 ? ' — تم احتساب رسوم إضافية عن ' . $excessCount . ' عضو زائد' : '')); return $this->redirect("/waivers/{$id}")->withSuccess($msg);
} }
public function reject(Request $request, string $id): Response public function reject(Request $request, string $id): Response
...@@ -294,6 +288,89 @@ class WaiverController extends Controller ...@@ -294,6 +288,89 @@ class WaiverController extends Controller
return $this->redirect('/waivers')->withSuccess('تم رفض طلب التنازل'); return $this->redirect('/waivers')->withSuccess('تم رفض طلب التنازل');
} }
/**
* Send to cashier — creates payment request in TARGET member's name (buyer pays).
*/
public function sendToCashier(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$waiver = $db->selectOne("SELECT * FROM waiver_requests WHERE id = ? AND status = 'approved'", [(int) $id]);
if (!$waiver) return $this->redirect("/waivers/{$id}")->withError('الطلب غير صالح أو لم يتم اعتماده');
if (!$waiver['target_member_id']) {
return $this->redirect("/waivers/{$id}")->withError('يجب تحديد المتنازل إليه أولاً');
}
// Calculate total fee
$waiverFee = $waiver['waiver_fee_amount'] ?? '0.00';
$spouseFeeTotal = $waiver['spouse_fee_total'] ?? '0.00';
$childFeeTotal = $waiver['child_fee_total'] ?? '0.00';
$tempFeeTotal = $waiver['temporary_fee_total'] ?? '0.00';
$excessTotal = bcadd(bcadd($spouseFeeTotal, $childFeeTotal, 2), $tempFeeTotal, 2);
if (bccomp($excessTotal, '0', 2) <= 0) {
$excessTotal = $waiver['excess_fee_amount'] ?? '0.00';
}
$totalFee = bcadd((string) $waiverFee, $excessTotal, 2);
if (bccomp($totalFee, '0', 2) <= 0) {
return $this->redirect("/waivers/{$id}")->withError('لا توجد رسوم مطلوبة');
}
// Get target member name for the receipt
$targetMember = $db->selectOne("SELECT full_name_ar FROM members WHERE id = ?", [(int) $waiver['target_member_id']]);
$targetName = $targetMember['full_name_ar'] ?? '';
// Build detailed breakdown for receipt
$breakdown = [
'📋 تنازل عن العضوية رقم: ' . ($waiver['membership_number'] ?? ''),
'👤 المتنازل إليه (المشتري): ' . $targetName,
'💰 قيمة العضوية: ' . money($waiver['membership_value_at_waiver'] ?? '0'),
'═══════════════════════════',
'💵 رسوم التنازل: ' . money($waiverFee) . ' (' . ($waiver['waiver_fee_percentage'] ?? '30') . '%)',
];
if (bccomp($spouseFeeTotal, '0', 2) > 0) {
$rateLabel = ($waiver['spouse_fee_type'] ?? '') === 'percentage'
? ($waiver['spouse_fee_rate'] ?? '0') . '% من قيمة العضوية'
: money($waiver['spouse_fee_rate'] ?? '0') . ' ثابت';
$breakdown[] = '👩 رسوم زوجات إضافيات: ' . ($waiver['excess_spouses_count'] ?? 0) . ' × ' . $rateLabel . ' = ' . money($spouseFeeTotal);
}
if (bccomp($childFeeTotal, '0', 2) > 0) {
$rateLabel = ($waiver['child_fee_type'] ?? '') === 'percentage'
? ($waiver['child_fee_rate'] ?? '0') . '% من قيمة العضوية'
: money($waiver['child_fee_rate'] ?? '0') . ' ثابت';
$breakdown[] = '👶 رسوم أبناء إضافيين: ' . ($waiver['excess_children_count'] ?? 0) . ' × ' . $rateLabel . ' = ' . money($childFeeTotal);
}
if (bccomp($tempFeeTotal, '0', 2) > 0) {
$rateLabel = ($waiver['temporary_fee_type'] ?? '') === 'percentage'
? ($waiver['temporary_fee_rate'] ?? '0') . '% من قيمة العضوية'
: money($waiver['temporary_fee_rate'] ?? '0') . ' ثابت';
$breakdown[] = '👤 رسوم أعضاء مؤقتين: ' . ($waiver['excess_temporary_count'] ?? 0) . ' × ' . $rateLabel . ' = ' . money($tempFeeTotal);
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي: ' . money($totalFee);
// Payment request in TARGET member's name (buyer pays)
$result = PaymentRequestService::createRequest([
'member_id' => (int) $waiver['target_member_id'],
'amount' => $totalFee,
'payment_type' => 'waiver_fee',
'related_entity_type' => 'waiver_requests',
'related_entity_id' => (int) $id,
'description_ar' => 'رسوم تنازل عن عضوية #' . ($waiver['membership_number'] ?? '') . ' — ' . $targetName,
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]);
if (!$result['success']) {
return $this->redirect("/waivers/{$id}")->withError('فشل إرسال طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف'));
}
return $this->redirect("/waivers/{$id}")->withSuccess(
'تم إرسال طلب الدفع إلى الخزينة باسم المتنازل إليه (' . $targetName . ') — المبلغ: ' . money($totalFee) . ' — رقم الطلب: ' . $result['request_number']
);
}
public function complete(Request $request, string $id): Response public function complete(Request $request, string $id): Response
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
...@@ -314,46 +391,76 @@ class WaiverController extends Controller ...@@ -314,46 +391,76 @@ class WaiverController extends Controller
return $this->redirect("/waivers/{$id}")->withError('يجب تحديد العضو المتنازل إليه أولاً'); return $this->redirect("/waivers/{$id}")->withError('يجب تحديد العضو المتنازل إليه أولاً');
} }
// VALIDATION 1: All financial obligations must be cleared // BLOCKING RULE 1: Source member debts must be clear
$debtCheck = WaiverProcessor::checkDebts((int) $waiver['source_member_id']); $sourceDebtCheck = WaiverProcessor::checkDebtsComprehensive((int) $waiver['source_member_id']);
if (!$debtCheck['clear']) { if (!$sourceDebtCheck['clear']) {
$debtLines = array_map(fn($d) => $d['type'] . ': ' . money($d['amount']), $debtCheck['debts']);
return $this->redirect("/waivers/{$id}")->withError( return $this->redirect("/waivers/{$id}")->withError(
'لا يمكن إتمام التنازل — توجد مديونيات مستحقة: ' . implode(' | ', $debtLines) . ' — الإجمالي: ' . money($debtCheck['total']) 'لا يمكن إتمام التنازل — مديونيات على المتنازل بمبلغ ' . money($sourceDebtCheck['total'])
); );
} }
// VALIDATION 2: Target dependent count check // BLOCKING RULE 2: Target member debts must be clear
$originalDependentCount = (int) $waiver['original_dependent_count']; $targetDebtCheck = WaiverProcessor::checkDebtsComprehensive($targetMemberId);
if (!$targetDebtCheck['clear']) {
return $this->redirect("/waivers/{$id}")->withError(
'لا يمكن إتمام التنازل — مديونيات على المتنازل إليه بمبلغ ' . money($targetDebtCheck['total'])
);
}
// BLOCKING RULE 3: Per-category dependent check
$originalDeps = [
'spouses' => (int) ($waiver['original_spouses_count'] ?? 0),
'children' => (int) ($waiver['original_children_count'] ?? 0),
'temporary' => (int) ($waiver['original_temporary_count'] ?? 0),
'total' => (int) ($waiver['original_dependent_count'] ?? 0),
];
$targetDeps = WaiverProcessor::countDependents($targetMemberId); $targetDeps = WaiverProcessor::countDependents($targetMemberId);
$comparison = WaiverProcessor::compareDependents($originalDeps, $targetDeps);
if ($targetDeps['total'] > $originalDependentCount) { if ($comparison['has_excess']) {
$excess = $targetDeps['total'] - $originalDependentCount; $totalApprovedExcessFee = bcadd(
// If board already approved excess with fees, check that excess fee was set bcadd($waiver['spouse_fee_total'] ?? '0', $waiver['child_fee_total'] ?? '0', 2),
if (bccomp($waiver['excess_fee_amount'] ?? '0', '0', 2) <= 0) { $waiver['temporary_fee_total'] ?? '0', 2
);
$legacyExcessFee = $waiver['excess_fee_amount'] ?? '0';
if (bccomp($totalApprovedExcessFee, '0', 2) <= 0 && bccomp((string) $legacyExcessFee, '0', 2) <= 0) {
$parts = [];
if ($comparison['excess_spouses'] > 0) $parts[] = $comparison['excess_spouses'] . ' زوجة زائدة';
if ($comparison['excess_children'] > 0) $parts[] = $comparison['excess_children'] . ' ابن زائد';
if ($comparison['excess_temporary'] > 0) $parts[] = $comparison['excess_temporary'] . ' مؤقت زائد';
return $this->redirect("/waivers/{$id}")->withError( return $this->redirect("/waivers/{$id}")->withError(
'عدد التابعين للعضو المتنازل إليه (' . $targetDeps['total'] . ') يتجاوز عدد التابعين الأصليين (' . $originalDependentCount . ') بمقدار ' . $excess . ' — يجب أن يحدد مجلس الأمناء نسبة الرسوم الإضافية أثناء الاعتماد' 'عدد التابعين يتجاوز المسموح: ' . implode(' + ', $parts) . ' — يجب اعتماد الرسوم من مجلس الأمناء'
); );
} }
} }
// VALIDATION 3: Waiver fee must be paid // BLOCKING RULE 4: Status must be fee_paid
if (!\in_array($waiver['status'], ['fee_paid', 'approved'])) { if ($waiver['status'] !== 'fee_paid') {
return $this->redirect("/waivers/{$id}")->withError('يجب سداد رسوم التنازل قبل إتمام العملية'); return $this->redirect("/waivers/{$id}")->withError('يجب سداد جميع الرسوم قبل إتمام التنازل');
}
// BLOCKING RULE 5: Board approval required
if (!$waiver['approved_by']) {
return $this->redirect("/waivers/{$id}")->withError('يجب اعتماد الطلب من مجلس الأمناء أولاً');
} }
// Record final state // Record final state
$db->update('waiver_requests', [ $db->update('waiver_requests', [
'new_dependent_count' => $targetDeps['total'], 'new_dependent_count' => $targetDeps['total'],
'new_spouses_count' => $targetDeps['spouses'],
'new_children_count' => $targetDeps['children'],
'new_temporary_count' => $targetDeps['temporary'],
'annual_renewal_paid' => 1, 'annual_renewal_paid' => 1,
'debts_cleared' => 1, 'debts_cleared' => 1,
'target_debts_cleared' => 1,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
$result = WaiverProcessor::execute((int) $id); $result = WaiverProcessor::execute((int) $id);
if (!$result['success']) return $this->redirect("/waivers/{$id}")->withError($result['error']); if (!$result['success']) return $this->redirect("/waivers/{$id}")->withError($result['error']);
return $this->redirect("/waivers/{$id}")->withSuccess('تم إتمام التنازل — العضوية رقم ' . ($waiver['membership_number'] ?? '') . ' نُقلت بنجاح للعضو المتنازل إليه'); return $this->redirect("/waivers/{$id}")->withSuccess('تم إتمام التنازل — العضوية رقم ' . ($waiver['membership_number'] ?? '') . ' نُقلت بنجاح للمتنازل إليه');
} }
private static function getCurrentMembershipValue(\App\Core\Database $db, array $member): string private static function getCurrentMembershipValue(\App\Core\Database $db, array $member): string
......
...@@ -18,10 +18,18 @@ class WaiverRequest extends Model ...@@ -18,10 +18,18 @@ class WaiverRequest extends Model
protected static array $fillable = [ protected static array $fillable = [
'source_member_id', 'target_member_id', 'membership_number', 'source_member_id', 'target_member_id', 'membership_number',
'membership_value_at_waiver', 'waiver_fee_percentage', 'waiver_fee_amount', 'membership_value_at_waiver', 'waiver_fee_percentage', 'waiver_fee_amount',
'original_dependent_count', 'new_dependent_count', 'original_dependent_count', 'original_spouses_count', 'original_children_count', 'original_temporary_count',
'new_dependent_count', 'new_spouses_count', 'new_children_count', 'new_temporary_count',
'excess_dependent_count', 'excess_spouses_count', 'excess_children_count', 'excess_temporary_count',
'excess_fee_percentage', 'excess_fee_amount',
'spouse_fee_type', 'spouse_fee_rate', 'spouse_fee_total',
'child_fee_type', 'child_fee_rate', 'child_fee_total',
'temporary_fee_type', 'temporary_fee_rate', 'temporary_fee_total',
'board_approval_required', 'board_decision_reference', 'board_approval_required', 'board_decision_reference',
'approved_by', 'approved_at', 'annual_renewal_paid', 'approved_by', 'approved_at', 'annual_renewal_paid',
'debts_cleared', 'target_debts_cleared',
'archive_snapshot_id', 'workflow_instance_id', 'status', 'notes', 'archive_snapshot_id', 'workflow_instance_id', 'status', 'notes',
'waiver_request_doc_path', 'target_form_doc_path',
]; ];
public static function search(array $filters, int $perPage = 25, int $page = 1): array public static function search(array $filters, int $perPage = 25, int $page = 1): array
......
...@@ -7,6 +7,7 @@ return [ ...@@ -7,6 +7,7 @@ return [
['POST', '/waivers/store/{memberId}', 'Waiver\Controllers\WaiverController@store', ['auth', 'csrf'], 'waiver.initiate'], ['POST', '/waivers/store/{memberId}', 'Waiver\Controllers\WaiverController@store', ['auth', 'csrf'], 'waiver.initiate'],
['GET', '/waivers/{id}', 'Waiver\Controllers\WaiverController@show', ['auth'], 'waiver.view'], ['GET', '/waivers/{id}', 'Waiver\Controllers\WaiverController@show', ['auth'], 'waiver.view'],
['POST', '/waivers/{id}/pay', 'Waiver\Controllers\WaiverController@pay', ['auth', 'csrf'], 'payment.collect'], ['POST', '/waivers/{id}/pay', 'Waiver\Controllers\WaiverController@pay', ['auth', 'csrf'], 'payment.collect'],
['POST', '/waivers/{id}/send-to-cashier','Waiver\Controllers\WaiverController@sendToCashier', ['auth', 'csrf'], 'waiver.approve'],
['POST', '/waivers/{id}/approve', 'Waiver\Controllers\WaiverController@approve', ['auth', 'csrf'], 'waiver.approve'], ['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}/reject', 'Waiver\Controllers\WaiverController@reject', ['auth', 'csrf'], 'waiver.approve'],
['POST', '/waivers/{id}/complete', 'Waiver\Controllers\WaiverController@complete',['auth', 'csrf'], 'waiver.approve'], ['POST', '/waivers/{id}/complete', 'Waiver\Controllers\WaiverController@complete',['auth', 'csrf'], 'waiver.approve'],
......
...@@ -34,7 +34,6 @@ final class WaiverProcessor ...@@ -34,7 +34,6 @@ final class WaiverProcessor
try { try {
$snapshotId = ArchiveService::takeSnapshot('members', (int) $sourceMember['id'], 'waiver', 'تنازل — طلب #' . $waiverId); $snapshotId = ArchiveService::takeSnapshot('members', (int) $sourceMember['id'], 'waiver', 'تنازل — طلب #' . $waiverId);
// Archive source member FIRST (release number to avoid unique constraint violation)
$db->update('members', [ $db->update('members', [
'membership_number' => null, 'membership_number' => null,
'status' => 'waived', 'status' => 'waived',
...@@ -44,7 +43,6 @@ final class WaiverProcessor ...@@ -44,7 +43,6 @@ final class WaiverProcessor
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $waiver['source_member_id']]); ], '`id` = ?', [(int) $waiver['source_member_id']]);
// Transfer membership number to target — becomes the new primary member
$db->update('members', [ $db->update('members', [
'membership_number' => $waiver['membership_number'], 'membership_number' => $waiver['membership_number'],
'status' => 'active', 'status' => 'active',
...@@ -80,70 +78,137 @@ final class WaiverProcessor ...@@ -80,70 +78,137 @@ final class WaiverProcessor
} }
/** /**
* Check all financial obligations for a member. * Comprehensive debt check: member + all dependents (spouses, children, temporary).
* Returns ['clear' => bool, 'debts' => [...details...]] * Returns detailed breakdown per person.
*/ */
public static function checkDebts(int $memberId): array public static function checkDebtsComprehensive(int $memberId): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$debts = []; $member = $db->selectOne("SELECT id, full_name_ar, membership_number FROM members WHERE id = ?", [$memberId]);
if (!$member) return ['clear' => true, 'debts' => [], 'total' => '0.00', 'details' => []];
$memberName = $member['full_name_ar'] ?? 'عضو #' . $memberId;
$details = [];
// 1. Check subscriptions for this member (including person_type entries for dependents)
$subs = $db->select(
"SELECT id, person_type, person_id, person_name, financial_year, (total_amount - paid_amount + fine_amount) as due
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue') AND (total_amount - paid_amount + fine_amount) > 0",
[$memberId]
);
foreach ($subs as $sub) {
$personLabel = self::getPersonLabel($sub['person_type'] ?? 'member', $sub['person_name'] ?? $memberName);
$details[] = [
'member_id' => $memberId,
'member_name' => $memberName,
'person_name' => $sub['person_name'] ?? $memberName,
'person_type' => $sub['person_type'] ?? 'member',
'person_label' => $personLabel,
'debt_type' => 'اشتراك سنوي',
'period' => $sub['financial_year'] ?? '',
'amount' => $sub['due'],
'description' => 'اشتراك سنوي ' . ($sub['financial_year'] ?? '') . ' — ' . $personLabel,
];
}
// Unpaid subscriptions // 2. Fines on main member
$unpaidSubs = $db->selectOne( $fines = $db->select(
"SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount - paid_amount + fine_amount), 0) as total "SELECT id, (amount - paid_amount) as due, created_at FROM fines WHERE member_id = ? AND status = 'pending' AND (amount - paid_amount) > 0",
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue')",
[$memberId] [$memberId]
); );
if ((int) ($unpaidSubs['cnt'] ?? 0) > 0) { foreach ($fines as $fine) {
$debts[] = [ $details[] = [
'type' => 'اشتراكات سنوية', 'member_id' => $memberId,
'count' => (int) $unpaidSubs['cnt'], 'member_name' => $memberName,
'amount' => $unpaidSubs['total'] ?? '0.00', 'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي',
'debt_type' => 'غرامة',
'period' => substr($fine['created_at'] ?? '', 0, 10),
'amount' => $fine['due'],
'description' => 'غرامة — العضو الأساسي',
]; ];
} }
// Unpaid fines // 3. Installments on main member
$unpaidFines = $db->selectOne( $installments = $db->select(
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount - paid_amount), 0) as total "SELECT s.id, s.due_date, (s.amount - s.paid_amount) as due
FROM fines WHERE member_id = ? AND status = 'pending'", FROM installment_schedule s JOIN installment_plans p ON p.id = s.installment_plan_id
WHERE p.member_id = ? AND p.status IN ('active','overdue') AND s.status IN ('pending','overdue') AND (s.amount - s.paid_amount) > 0",
[$memberId] [$memberId]
); );
if ((int) ($unpaidFines['cnt'] ?? 0) > 0) { foreach ($installments as $inst) {
$debts[] = [ $details[] = [
'type' => 'غرامات', 'member_id' => $memberId,
'count' => (int) $unpaidFines['cnt'], 'member_name' => $memberName,
'amount' => $unpaidFines['total'] ?? '0.00', 'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي',
'debt_type' => 'قسط عضوية',
'period' => $inst['due_date'] ?? '',
'amount' => $inst['due'],
'description' => 'قسط عضوية مستحق ' . ($inst['due_date'] ?? '') . ' — العضو الأساسي',
]; ];
} }
// Unpaid payment requests (cashier queue) // 4. Pending payment requests
$unpaidRequests = $db->selectOne( $requests = $db->select(
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total "SELECT id, payment_type, amount, description_ar FROM payment_requests
FROM payment_requests WHERE member_id = ? AND status = 'pending' AND is_voided = 0", WHERE member_id = ? AND status = 'pending' AND is_voided = 0",
[$memberId] [$memberId]
); );
if ((int) ($unpaidRequests['cnt'] ?? 0) > 0) { foreach ($requests as $req) {
$debts[] = [ $details[] = [
'type' => 'طلبات دفع معلقة', 'member_id' => $memberId,
'count' => (int) $unpaidRequests['cnt'], 'member_name' => $memberName,
'amount' => $unpaidRequests['total'] ?? '0.00', 'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي',
'debt_type' => 'طلب دفع معلق',
'period' => '',
'amount' => $req['amount'],
'description' => ($req['description_ar'] ?? 'طلب دفع') . ' — العضو الأساسي',
]; ];
} }
$totalDebt = '0.00'; $totalDebt = '0.00';
foreach ($debts as $d) { foreach ($details as $d) {
$totalDebt = bcadd($totalDebt, (string) $d['amount'], 2); $totalDebt = bcadd($totalDebt, (string) $d['amount'], 2);
} }
return [ return [
'clear' => empty($debts), 'clear' => empty($details),
'debts' => $debts, 'debts' => $details,
'total' => $totalDebt, 'total' => $totalDebt,
'member_name' => $memberName,
]; ];
} }
/** /**
* Count dependents for a member (spouses + children + temporary). * Simple debt check (for backward compat and quick validation).
*/
public static function checkDebts(int $memberId): array
{
$result = self::checkDebtsComprehensive($memberId);
// Group by type for legacy format
$grouped = [];
foreach ($result['debts'] as $d) {
$key = $d['debt_type'];
if (!isset($grouped[$key])) {
$grouped[$key] = ['type' => $key, 'count' => 0, 'amount' => '0.00'];
}
$grouped[$key]['count']++;
$grouped[$key]['amount'] = bcadd($grouped[$key]['amount'], (string) $d['amount'], 2);
}
return [
'clear' => $result['clear'],
'debts' => array_values($grouped),
'total' => $result['total'],
];
}
/**
* Count dependents for a member (spouses + children + temporary) — per-category.
*/ */
public static function countDependents(int $memberId): array public static function countDependents(int $memberId): array
{ {
...@@ -159,4 +224,151 @@ final class WaiverProcessor ...@@ -159,4 +224,151 @@ final class WaiverProcessor
'total' => $spouses + $children + $temps, 'total' => $spouses + $children + $temps,
]; ];
} }
/**
* Get detailed dependent data for display (names, ages, DOB, categories).
*/
public static function getDependentDetails(int $memberId): array
{
$db = App::getInstance()->db();
$spouses = $db->select(
"SELECT id, full_name_ar, national_id, status FROM spouses WHERE member_id = ? AND is_archived = 0 ORDER BY id",
[$memberId]
);
$children = $db->select(
"SELECT id, full_name_ar, national_id, date_of_birth, gender, classification, status
FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY date_of_birth",
[$memberId]
);
// Calculate age and category for each child
$today = new \DateTime();
foreach ($children as &$child) {
$dob = !empty($child['date_of_birth']) ? new \DateTime($child['date_of_birth']) : null;
if ($dob) {
$age = $dob->diff($today);
$child['age_years'] = $age->y;
$child['age_months'] = $age->m;
$child['age_display'] = $age->y . ' سنة' . ($age->m > 0 ? ' و ' . $age->m . ' شهر' : '');
if ($age->y < 12) {
$child['age_category'] = 'أقل من 12 سنة';
$child['age_category_code'] = 'under_12';
} elseif ($age->y < 16) {
$child['age_category'] = 'من 12 إلى أقل من 16 سنة';
$child['age_category_code'] = '12_to_16';
} elseif ($age->y < 18) {
$child['age_category'] = 'من 16 إلى أقل من 18 سنة';
$child['age_category_code'] = '16_to_18';
} else {
$child['age_category'] = '18 سنة فأكثر';
$child['age_category_code'] = '18_plus';
}
} else {
$child['age_years'] = null;
$child['age_months'] = null;
$child['age_display'] = '—';
$child['age_category'] = 'غير محدد';
$child['age_category_code'] = 'unknown';
}
}
unset($child);
$temporary = $db->select(
"SELECT id, full_name_ar, national_id, status FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id",
[$memberId]
);
return [
'spouses' => $spouses,
'children' => $children,
'temporary' => $temporary,
];
}
/**
* Compare dependents per-category: source (original) vs target (new).
*/
public static function compareDependents(array $original, array $target): array
{
$excessSpouses = max(0, $target['spouses'] - $original['spouses']);
$excessChildren = max(0, $target['children'] - $original['children']);
$excessTemporary = max(0, $target['temporary'] - $original['temporary']);
return [
'excess_spouses' => $excessSpouses,
'excess_children' => $excessChildren,
'excess_temporary' => $excessTemporary,
'excess_total' => $excessSpouses + $excessChildren + $excessTemporary,
'has_excess' => ($excessSpouses + $excessChildren + $excessTemporary) > 0,
];
}
/**
* Calculate per-category excess fees based on board-set rates.
*/
public static function calculateExcessFees(array $excess, array $feeConfig, string $membershipValue): array
{
$spouseFee = '0.00';
$childFee = '0.00';
$tempFee = '0.00';
if ($excess['excess_spouses'] > 0 && !empty($feeConfig['spouse_fee_type'])) {
$spouseFee = self::calcCategoryFee(
$excess['excess_spouses'],
$feeConfig['spouse_fee_type'],
$feeConfig['spouse_fee_rate'] ?? '0',
$membershipValue
);
}
if ($excess['excess_children'] > 0 && !empty($feeConfig['child_fee_type'])) {
$childFee = self::calcCategoryFee(
$excess['excess_children'],
$feeConfig['child_fee_type'],
$feeConfig['child_fee_rate'] ?? '0',
$membershipValue
);
}
if ($excess['excess_temporary'] > 0 && !empty($feeConfig['temporary_fee_type'])) {
$tempFee = self::calcCategoryFee(
$excess['excess_temporary'],
$feeConfig['temporary_fee_type'],
$feeConfig['temporary_fee_rate'] ?? '0',
$membershipValue
);
}
$total = bcadd(bcadd($spouseFee, $childFee, 2), $tempFee, 2);
return [
'spouse_fee_total' => $spouseFee,
'child_fee_total' => $childFee,
'temporary_fee_total' => $tempFee,
'total_excess_fee' => $total,
];
}
private static function calcCategoryFee(int $excessCount, string $type, string $rate, string $membershipValue): string
{
if ($type === 'percentage') {
$perUnit = bcdiv(bcmul($membershipValue, $rate, 4), '100', 2);
} else {
$perUnit = $rate;
}
return bcmul($perUnit, (string) $excessCount, 2);
}
private static function getPersonLabel(?string $personType, string $personName): string
{
return match ($personType) {
'member' => 'العضو الأساسي (' . $personName . ')',
'spouse' => 'الزوجة (' . $personName . ')',
'child' => 'الابن/ة (' . $personName . ')',
'temporary' => 'العضو المؤقت (' . $personName . ')',
default => $personName,
};
}
} }
...@@ -8,28 +8,52 @@ ...@@ -8,28 +8,52 @@
<li>يتطلب موافقة مجلس الأمناء</li> <li>يتطلب موافقة مجلس الأمناء</li>
<li>العضو المستفيد يحصل على <strong>نفس رقم العضوية</strong></li> <li>العضو المستفيد يحصل على <strong>نفس رقم العضوية</strong></li>
<li>عدد التابعين للمتنازل إليه لا يتجاوز عدد التابعين الأصليين (<?= (int) $total_dependents ?>) — أي زيادة تتطلب رسوم إضافية يحددها مجلس الأمناء</li> <li>عدد التابعين للمتنازل إليه لا يتجاوز عدد التابعين الأصليين (<?= (int) $total_dependents ?>) — أي زيادة تتطلب رسوم إضافية يحددها مجلس الأمناء</li>
<li>يجب سداد <strong>جميع</strong> الالتزامات المالية (اشتراكات + غرامات + طلبات دفع) قبل التنازل</li> <li>يجب سداد <strong>جميع</strong> الالتزامات المالية (اشتراكات + غرامات + أقساط + طلبات دفع) على العضو <strong>وجميع تابعيه</strong> قبل التنازل</li>
<li>يجب تقديم استمارة طلب تنازل + استمارة عضوية جديدة للمتنازل إليه</li> <li>يجب تقديم استمارة طلب تنازل + استمارة عضوية جديدة للمتنازل إليه</li>
<li>الرسوم يدفعها <strong>المتنازل إليه (المشتري)</strong></li>
</ul> </ul>
</div> </div>
<?php if (!$debt_check['clear']): ?> <?php if (!$debt_check['clear']): ?>
<div style="padding:15px;background:#FEF2F2;border:2px solid #DC2626;border-radius:8px;margin-bottom:20px;"> <div style="padding:18px;background:#FEF2F2;border:2px solid #DC2626;border-radius:10px;margin-bottom:20px;">
<strong style="color:#DC2626;">🚫 لا يمكن تقديم طلب التنازل — يوجد مديونية مستحقة:</strong> <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<table style="width:100%;margin-top:10px;font-size:13px;"> <span style="font-size:20px;">🚫</span>
<thead><tr style="background:#FEE2E2;"><th style="padding:6px;text-align:right;">نوع المديونية</th><th style="padding:6px;text-align:center;">العدد</th><th style="padding:6px;text-align:left;">المبلغ</th></tr></thead> <strong style="color:#DC2626;font-size:15px;">لا يمكن تقديم طلب التنازل — يوجد مديونية مستحقة</strong>
</div>
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<thead>
<tr style="background:#FEE2E2;">
<th style="padding:6px 8px;text-align:right;">الشخص</th>
<th style="padding:6px 8px;text-align:right;">النوع</th>
<th style="padding:6px 8px;text-align:right;">نوع المديونية</th>
<th style="padding:6px 8px;text-align:center;">الفترة</th>
<th style="padding:6px 8px;text-align:left;">المبلغ</th>
</tr>
</thead>
<tbody> <tbody>
<?php foreach ($debt_check['debts'] as $debt): ?> <?php foreach ($debt_check['debts'] as $debt): ?>
<tr style="border-bottom:1px solid #FECACA;"> <tr style="border-bottom:1px solid #FECACA;">
<td style="padding:6px;font-weight:600;"><?= e($debt['type']) ?></td> <td style="padding:6px 8px;font-weight:600;"><?= e($debt['person_name'] ?? '') ?></td>
<td style="padding:6px;text-align:center;"><?= (int) $debt['count'] ?></td> <td style="padding:6px 8px;"><span style="background:#FEE2E2;color:#991B1B;padding:1px 6px;border-radius:3px;font-size:11px;"><?= e($debt['person_label'] ?? '') ?></span></td>
<td style="padding:6px;text-align:left;color:#DC2626;font-weight:700;"><?= money($debt['amount']) ?></td> <td style="padding:6px 8px;"><?= e($debt['debt_type'] ?? '') ?></td>
<td style="padding:6px 8px;text-align:center;font-size:12px;color:#6B7280;"><?= e($debt['period'] ?? '') ?></td>
<td style="padding:6px 8px;text-align:left;color:#DC2626;font-weight:700;"><?= money($debt['amount']) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
<tfoot><tr style="border-top:2px solid #DC2626;"><td colspan="2" style="padding:8px;font-weight:700;">إجمالي المديونية</td><td style="padding:8px;font-weight:700;color:#DC2626;"><?= money($debt_check['total']) ?></td></tr></tfoot> <tfoot>
<tr style="border-top:2px solid #DC2626;background:#FEE2E2;">
<td colspan="4" style="padding:8px;font-weight:700;">إجمالي المديونية</td>
<td style="padding:8px;font-weight:700;color:#DC2626;text-align:left;font-size:15px;"><?= money($debt_check['total']) ?></td>
</tr>
</tfoot>
</table> </table>
<p style="margin:10px 0 0;font-size:12px;color:#7F1D1D;">يجب سداد جميع المبالغ المستحقة أعلاه قبل تقديم طلب التنازل.</p> <div style="margin-top:12px;display:flex;align-items:center;gap:12px;">
<a href="/members/<?= (int) $member['id'] ?>/financial" class="btn btn-outline" style="padding:8px 16px;font-size:13px;color:#DC2626;border-color:#DC2626;">
الانتقال إلى السداد ←
</a>
<span style="font-size:12px;color:#7F1D1D;">يجب سداد جميع المبالغ المستحقة (بما فيها مديونيات التابعين) قبل تقديم طلب التنازل.</span>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
...@@ -41,32 +65,73 @@ ...@@ -41,32 +65,73 @@
</table> </table>
<div style="margin-top:15px;padding:12px;background:#F0F9FF;border:1px solid #BAE6FD;border-radius:8px;"> <div style="margin-top:15px;padding:12px;background:#F0F9FF;border:1px solid #BAE6FD;border-radius:8px;">
<strong style="color:#0369A1;">👥 التابعون الحاليون:</strong> <strong style="color:#0369A1;">👥 التابعون الحاليون للمتنازل (الحد المرجعي):</strong>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:8px;"> <table style="width:100%;margin-top:10px;font-size:13px;border-collapse:collapse;">
<div style="text-align:center;padding:8px;background:#fff;border-radius:6px;border:1px solid #E0F2FE;"> <thead>
<div style="font-size:20px;font-weight:700;color:#0D7377;"><?= (int) $dependents['spouses'] ?></div> <tr style="background:#E0F2FE;">
<div style="font-size:11px;color:#6B7280;">زوجات</div> <th style="padding:8px;text-align:right;color:#0369A1;">الفئة</th>
</div> <th style="padding:8px;text-align:center;color:#0369A1;">العدد</th>
<div style="text-align:center;padding:8px;background:#fff;border-radius:6px;border:1px solid #E0F2FE;"> <th style="padding:8px;text-align:right;color:#0369A1;">ملاحظة</th>
<div style="font-size:20px;font-weight:700;color:#0D7377;"><?= (int) $dependents['children'] ?></div> </tr>
<div style="font-size:11px;color:#6B7280;">أبناء</div> </thead>
</div> <tbody>
<div style="text-align:center;padding:8px;background:#fff;border-radius:6px;border:1px solid #E0F2FE;"> <tr style="border-bottom:1px solid #BAE6FD;">
<div style="font-size:20px;font-weight:700;color:#0D7377;"><?= (int) $dependents['temporary'] ?></div> <td style="padding:8px;font-weight:600;">👩 الزوجات</td>
<div style="font-size:11px;color:#6B7280;">مؤقتين</div> <td style="padding:8px;text-align:center;font-weight:700;font-size:16px;color:#0D7377;"><?= (int) $dependents['spouses'] ?></td>
</div> <td style="padding:8px;font-size:12px;color:#6B7280;">أي زيادة عن هذا العدد تتطلب رسوم إضافية</td>
<div style="text-align:center;padding:8px;background:#fff;border-radius:6px;border:1px solid #0D7377;"> </tr>
<div style="font-size:20px;font-weight:700;color:#0D7377;"><?= (int) $dependents['total'] ?></div> <tr style="border-bottom:1px solid #BAE6FD;">
<div style="font-size:11px;color:#6B7280;font-weight:600;">الإجمالي</div> <td style="padding:8px;font-weight:600;">👶 الأبناء</td>
</div> <td style="padding:8px;text-align:center;font-weight:700;font-size:16px;color:#0D7377;"><?= (int) $dependents['children'] ?></td>
<td style="padding:8px;font-size:12px;color:#6B7280;">أي زيادة عن هذا العدد تتطلب رسوم إضافية</td>
</tr>
<tr style="border-bottom:1px solid #BAE6FD;">
<td style="padding:8px;font-weight:600;">👤 الأعضاء المؤقتون</td>
<td style="padding:8px;text-align:center;font-weight:700;font-size:16px;color:#0D7377;"><?= (int) $dependents['temporary'] ?></td>
<td style="padding:8px;font-size:12px;color:#6B7280;">أي زيادة عن هذا العدد تتطلب رسوم إضافية</td>
</tr>
<tr style="border-top:2px solid #0D7377;background:#F0FDFA;">
<td style="padding:8px;font-weight:700;color:#0D7377;">الإجمالي</td>
<td style="padding:8px;text-align:center;font-weight:700;font-size:18px;color:#0D7377;"><?= (int) $dependents['total'] ?></td>
<td style="padding:8px;font-size:12px;color:#0D7377;font-weight:600;">المقارنة تتم لكل فئة على حدة</td>
</tr>
</tbody>
</table>
<!-- Children details if any -->
<?php if (!empty($dependent_details['children'])): ?>
<div style="margin-top:12px;padding:10px;background:#F5F3FF;border:1px solid #DDD6FE;border-radius:6px;">
<strong style="color:#7C3AED;font-size:12px;">👶 تفاصيل الأبناء:</strong>
<table style="width:100%;margin-top:6px;font-size:12px;border-collapse:collapse;">
<thead><tr style="background:#EDE9FE;"><th style="padding:4px 6px;text-align:right;">الاسم</th><th style="padding:4px 6px;text-align:center;">تاريخ الميلاد</th><th style="padding:4px 6px;text-align:center;">السن</th><th style="padding:4px 6px;text-align:center;">الفئة العمرية</th></tr></thead>
<tbody>
<?php foreach ($dependent_details['children'] as $child): ?>
<tr style="border-bottom:1px solid #EDE9FE;">
<td style="padding:4px 6px;font-weight:600;"><?= e($child['full_name_ar'] ?? '') ?></td>
<td style="padding:4px 6px;text-align:center;"><?= e($child['date_of_birth'] ?? '—') ?></td>
<td style="padding:4px 6px;text-align:center;"><?= e($child['age_display'] ?? '—') ?></td>
<td style="padding:4px 6px;text-align:center;">
<?php
$catColor = match($child['age_category_code'] ?? '') {
'under_12' => '#059669', '12_to_16' => '#0284C7', '16_to_18' => '#D97706', '18_plus' => '#DC2626', default => '#6B7280'
};
?>
<span style="background:<?= $catColor ?>15;color:<?= $catColor ?>;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:600;"><?= e($child['age_category'] ?? '') ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div> </div>
<p style="margin:8px 0 0;font-size:12px;color:#0369A1;">هذا العدد هو الحد الأقصى للمتنازل إليه بدون رسوم إضافية. أي زيادة يحدد مجلس الأمناء نسبتها.</p> <?php endif; ?>
<p style="margin:8px 0 0;font-size:12px;color:#0369A1;">⚠ المقارنة تتم بشكل مستقل لكل فئة (الزوجات، الأبناء، المؤقتين) وليس على الإجمالي فقط. أي زيادة في أي فئة يحدد مجلس الأمناء رسومها.</p>
</div> </div>
<div style="margin-top:15px;padding:12px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;"> <div style="margin-top:15px;padding:12px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;">
<table style="width:100%;font-size:14px;"> <table style="width:100%;font-size:14px;">
<tr style="border-bottom:1px dashed #86EFAC;"><td style="padding:6px 0;color:#166534;">رسوم التنازل الأساسية</td><td style="padding:6px 0;font-weight:600;"><?= e($waiver_pct) ?>% × <?= money($membership_value) ?></td><td style="padding:6px 0;font-weight:700;text-align:left;"><?= money($waiver_fee) ?></td></tr> <tr style="border-bottom:1px dashed #86EFAC;"><td style="padding:6px 0;color:#166534;">رسوم التنازل الأساسية</td><td style="padding:6px 0;font-weight:600;"><?= e($waiver_pct) ?>% x <?= money($membership_value) ?></td><td style="padding:6px 0;font-weight:700;text-align:left;"><?= money($waiver_fee) ?></td></tr>
<tr><td style="padding:8px 0;font-weight:700;font-size:16px;color:#0D7377;" colspan="2">الإجمالي المبدئي</td><td style="padding:8px 0;font-weight:700;font-size:18px;color:#0D7377;text-align:left;"><?= money($waiver_fee) ?></td></tr> <tr><td style="padding:8px 0;font-weight:700;font-size:16px;color:#0D7377;" colspan="2">الإجمالي المبدئي (يدفعه المتنازل إليه)</td><td style="padding:8px 0;font-weight:700;font-size:18px;color:#0D7377;text-align:left;"><?= money($waiver_fee) ?></td></tr>
</table> </table>
<p style="margin:8px 0 0;font-size:11px;color:#166534;">* قد تُضاف رسوم إضافية عن التابعين الزائدين بعد اعتماد مجلس الأمناء</p> <p style="margin:8px 0 0;font-size:11px;color:#166534;">* قد تُضاف رسوم إضافية عن التابعين الزائدين بعد اعتماد مجلس الأمناء</p>
</div> </div>
...@@ -95,7 +160,7 @@ ...@@ -95,7 +160,7 @@
<!-- Target Member --> <!-- Target Member -->
<div class="card" style="padding:20px;margin-bottom:15px;background:#ECFDF5;border:2px solid #059669;"> <div class="card" style="padding:20px;margin-bottom:15px;background:#ECFDF5;border:2px solid #059669;">
<h4 style="margin:0 0 15px;color:#059669;">👤 المستفيد (المتنازل إليه)</h4> <h4 style="margin:0 0 15px;color:#059669;">👤 المستفيد (المتنازل إليه / المشتري)</h4>
<div style="position:relative;margin-bottom:10px;"> <div style="position:relative;margin-bottom:10px;">
<input type="text" id="target-search" class="form-input" placeholder="اكتب اسم أو رقم قومي المستفيد..." autocomplete="off" style="padding-left:40px;"> <input type="text" id="target-search" class="form-input" placeholder="اكتب اسم أو رقم قومي المستفيد..." autocomplete="off" style="padding-left:40px;">
<i data-lucide="search" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:#9CA3AF;"></i> <i data-lucide="search" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:#9CA3AF;"></i>
...@@ -118,12 +183,12 @@ ...@@ -118,12 +183,12 @@
<div class="form-group"><label class="form-label">ملاحظات</label><textarea name="notes" class="form-textarea" rows="3"></textarea></div> <div class="form-group"><label class="form-label">ملاحظات</label><textarea name="notes" class="form-textarea" rows="3"></textarea></div>
</div> </div>
<button type="submit" class="btn btn-primary" onclick="return confirm('سيتم تقديم طلب التنازل وإرسال رسوم <?= money($waiver_fee) ?> لطابور الخزينة. متأكد؟')">تقديم الطلب</button> <button type="submit" class="btn btn-primary" onclick="return confirm('سيتم تقديم طلب التنازل. الرسوم ستُرسل لطابور الخزينة باسم المتنازل إليه بعد اعتماد مجلس الأمناء. متأكد؟')">تقديم الطلب</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a> <a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
</form> </form>
<?php else: ?> <?php else: ?>
<div style="padding:20px;text-align:center;"> <div style="padding:20px;text-align:center;">
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-primary">عرض الحساب المالي</a> <a href="/members/<?= (int) $member['id'] ?>/financial" class="btn btn-primary" style="background:#DC2626;">الانتقال إلى السداد</a>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">رجوع</a> <a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">رجوع</a>
</div> </div>
<?php endif; ?> <?php endif; ?>
......
...@@ -4,113 +4,360 @@ ...@@ -4,113 +4,360 @@
<?php <?php
$statusColor = match($waiver['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' }; $statusColor = match($waiver['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' };
$statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في انتظار الاعتماد', 'approved' => 'معتمد — في انتظار الدفع', 'fee_paid' => 'تم الدفع — جاهز للإتمام', 'completed' => 'مكتمل — تم نقل العضوية', 'rejected' => 'مرفوض', default => $waiver['status'] }; $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في انتظار اعتماد مجلس الأمناء', 'approved' => 'معتمد — في انتظار إرسال للخزينة ودفع الرسوم', 'fee_paid' => '🟢 تم الدفع — جاهز لاعتماد طلب التنازل', 'completed' => 'مكتمل — تم نقل العضوية بنجاح', 'rejected' => 'مرفوض', default => $waiver['status'] };
$statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '📋', 'fee_paid' => '🟢', 'completed' => '✅', 'rejected' => '❌', default => '●' };
?> ?>
<!-- Status Banner --> <!-- Status Banner -->
<div style="padding:12px 20px;background:<?= $statusColor ?>15;border:2px solid <?= $statusColor ?>;border-radius:8px;margin-bottom:20px;display:flex;align-items:center;gap:10px;"> <div style="padding:14px 20px;background:<?= $statusColor ?>15;border:2px solid <?= $statusColor ?>;border-radius:10px;margin-bottom:20px;display:flex;align-items:center;gap:12px;">
<span style="font-size:20px;"></span> <span style="font-size:22px;"><?= $statusIcon ?></span>
<div style="flex:1;">
<span style="font-weight:700;font-size:16px;color:<?= $statusColor ?>;"><?= $statusLabel ?></span> <span style="font-weight:700;font-size:16px;color:<?= $statusColor ?>;"><?= $statusLabel ?></span>
<?php if ($waiver['status'] === 'fee_paid'): ?>
<div style="margin-top:4px;font-size:12px;color:#059669;font-weight:600;">جميع الشروط مستوفاة — يمكن الآن إتمام التنازل ونقل العضوية</div>
<?php endif; ?>
</div>
<?php if ($waiver['approved_by_name']): ?> <?php if ($waiver['approved_by_name']): ?>
<span style="margin-right:auto;font-size:12px;color:#6B7280;">بواسطة: <?= e($waiver['approved_by_name']) ?><?= arabic_date($waiver['approved_at'] ?? '') ?></span> <span style="font-size:12px;color:#6B7280;">بواسطة: <?= e($waiver['approved_by_name']) ?><?= arabic_date($waiver['approved_at'] ?? '') ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Main Info --> <!-- SECTION 1: Detailed Debt Status -->
<?php if ($waiver['status'] !== 'completed' && $waiver['status'] !== 'rejected'): ?>
<?php if (!$source_debt_check['clear'] || ($target_debt_check && !$target_debt_check['clear'])): ?>
<div style="padding:18px;background:#FEF2F2;border:2px solid #DC2626;border-radius:10px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span style="font-size:20px;">🚫</span>
<strong style="color:#DC2626;font-size:15px;">يوجد مديونيات تمنع إتمام التنازل</strong>
</div>
<?php if (!$source_debt_check['clear']): ?>
<div style="margin-bottom:15px;padding:12px;background:#fff;border:1px solid #FECACA;border-radius:8px;">
<strong style="color:#7F1D1D;display:block;margin-bottom:8px;">المتنازل: <?= e($waiver['source_name'] ?? '') ?></strong>
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<thead>
<tr style="background:#FEE2E2;">
<th style="padding:6px 8px;text-align:right;">الشخص</th>
<th style="padding:6px 8px;text-align:right;">النوع</th>
<th style="padding:6px 8px;text-align:right;">نوع المديونية</th>
<th style="padding:6px 8px;text-align:center;">الفترة</th>
<th style="padding:6px 8px;text-align:left;">المبلغ</th>
</tr>
</thead>
<tbody>
<?php foreach ($source_debt_check['debts'] as $debt): ?>
<tr style="border-bottom:1px solid #FECACA;">
<td style="padding:6px 8px;font-weight:600;"><?= e($debt['person_name'] ?? '') ?></td>
<td style="padding:6px 8px;"><span style="background:#FEE2E2;color:#991B1B;padding:1px 6px;border-radius:3px;font-size:11px;"><?= e($debt['person_label'] ?? '') ?></span></td>
<td style="padding:6px 8px;"><?= e($debt['debt_type'] ?? '') ?></td>
<td style="padding:6px 8px;text-align:center;font-size:12px;color:#6B7280;"><?= e($debt['period'] ?? '') ?></td>
<td style="padding:6px 8px;text-align:left;color:#DC2626;font-weight:700;"><?= money($debt['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="border-top:2px solid #DC2626;background:#FEE2E2;">
<td colspan="4" style="padding:8px;font-weight:700;">إجمالي مديونية المتنازل</td>
<td style="padding:8px;font-weight:700;color:#DC2626;text-align:left;font-size:15px;"><?= money($source_debt_check['total']) ?></td>
</tr>
</tfoot>
</table>
<div style="margin-top:10px;text-align:left;">
<a href="/members/<?= (int) $waiver['source_member_id'] ?>/financial" class="btn btn-outline" style="padding:6px 14px;font-size:12px;color:#DC2626;border-color:#DC2626;">
الانتقال إلى السداد ←
</a>
</div>
</div>
<?php endif; ?>
<?php if ($target_debt_check && !$target_debt_check['clear']): ?>
<div style="padding:12px;background:#fff;border:1px solid #FECACA;border-radius:8px;">
<strong style="color:#7F1D1D;display:block;margin-bottom:8px;">المتنازل إليه: <?= e($waiver['target_name'] ?? '') ?></strong>
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<thead>
<tr style="background:#FEE2E2;">
<th style="padding:6px 8px;text-align:right;">الشخص</th>
<th style="padding:6px 8px;text-align:right;">النوع</th>
<th style="padding:6px 8px;text-align:right;">نوع المديونية</th>
<th style="padding:6px 8px;text-align:center;">الفترة</th>
<th style="padding:6px 8px;text-align:left;">المبلغ</th>
</tr>
</thead>
<tbody>
<?php foreach ($target_debt_check['debts'] as $debt): ?>
<tr style="border-bottom:1px solid #FECACA;">
<td style="padding:6px 8px;font-weight:600;"><?= e($debt['person_name'] ?? '') ?></td>
<td style="padding:6px 8px;"><span style="background:#FEE2E2;color:#991B1B;padding:1px 6px;border-radius:3px;font-size:11px;"><?= e($debt['person_label'] ?? '') ?></span></td>
<td style="padding:6px 8px;"><?= e($debt['debt_type'] ?? '') ?></td>
<td style="padding:6px 8px;text-align:center;font-size:12px;color:#6B7280;"><?= e($debt['period'] ?? '') ?></td>
<td style="padding:6px 8px;text-align:left;color:#DC2626;font-weight:700;"><?= money($debt['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="border-top:2px solid #DC2626;background:#FEE2E2;">
<td colspan="4" style="padding:8px;font-weight:700;">إجمالي مديونية المتنازل إليه</td>
<td style="padding:8px;font-weight:700;color:#DC2626;text-align:left;font-size:15px;"><?= money($target_debt_check['total']) ?></td>
</tr>
</tfoot>
</table>
<div style="margin-top:10px;text-align:left;">
<a href="/members/<?= (int) $waiver['target_member_id'] ?>/financial" class="btn btn-outline" style="padding:6px 14px;font-size:12px;color:#DC2626;border-color:#DC2626;">
الانتقال إلى السداد ←
</a>
</div>
</div>
<?php endif; ?>
<p style="margin:12px 0 0;font-size:12px;color:#7F1D1D;">يجب سداد جميع المديونيات على الطرفين (بما فيها مديونيات التابعين) قبل إتمام التنازل.</p>
</div>
<?php else: ?>
<div style="padding:10px 15px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;margin-bottom:20px;font-size:13px;color:#166534;display:flex;align-items:center;gap:8px;">
<span style="font-size:16px;"></span>
<span>لا توجد مديونيات على أي من الطرفين (بما فيها التابعين) — الوضع المالي سليم</span>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- SECTION 2: Main Info -->
<div class="card" style="padding:20px;margin-bottom:20px;"> <div class="card" style="padding:20px;margin-bottom:20px;">
<table style="width:100%;max-width:700px;font-size:14px;"> <table style="width:100%;max-width:700px;font-size:14px;">
<tr><td style="padding:8px 0;color:#6B7280;width:30%;">المتنازل</td><td style="padding:8px 0;"><a href="/members/<?= (int) $waiver['source_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['source_name'] ?? '') ?></a></td></tr> <tr><td style="padding:8px 0;color:#6B7280;width:30%;">المتنازل</td><td style="padding:8px 0;"><a href="/members/<?= (int) $waiver['source_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['source_name'] ?? '') ?></a></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">رقم العضوية</td><td style="padding:8px 0;font-weight:700;font-size:16px;"><?= e($waiver['membership_number'] ?? '—') ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">رقم العضوية</td><td style="padding:8px 0;font-weight:700;font-size:16px;"><?= e($waiver['membership_number'] ?? '—') ?></td></tr>
<?php if ($waiver['target_member_id']): ?> <?php if ($waiver['target_member_id']): ?>
<tr><td style="padding:8px 0;color:#6B7280;">المتنازل إليه</td><td style="padding:8px 0;"><a href="/members/<?= (int) $waiver['target_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['target_name'] ?? '') ?></a></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">المتنازل إليه (المشتري)</td><td style="padding:8px 0;"><a href="/members/<?= (int) $waiver['target_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['target_name'] ?? '') ?></a></td></tr>
<?php endif; ?> <?php endif; ?>
<tr><td style="padding:8px 0;color:#6B7280;">تاريخ الطلب</td><td style="padding:8px 0;"><?= arabic_date($waiver['created_at'] ?? '') ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">تاريخ الطلب</td><td style="padding:8px 0;"><?= arabic_date($waiver['created_at'] ?? '') ?></td></tr>
<?php if ($waiver['board_decision_reference']): ?> <?php if ($waiver['board_decision_reference']): ?>
<tr><td style="padding:8px 0;color:#6B7280;">مرجع قرار مجلس الأمناء</td><td style="padding:8px 0;font-weight:600;"><?= e($waiver['board_decision_reference']) ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">مرجع قرار مجلس الأمناء</td><td style="padding:8px 0;font-weight:600;"><?= e($waiver['board_decision_reference']) ?></td></tr>
<?php endif; ?> <?php endif; ?>
<tr><td style="padding:8px 0;color:#6B7280;">المديونيات</td><td style="padding:8px 0;"><?= $waiver['debts_cleared'] ? '<span style="color:#059669;font-weight:600;">✅ تم التحقق — لا توجد مديونيات</span>' : '<span style="color:#D97706;">⏳ لم يتم التحقق</span>' ?></td></tr>
<?php if ($waiver['notes']): ?> <?php if ($waiver['notes']): ?>
<tr><td style="padding:8px 0;color:#6B7280;">ملاحظات</td><td style="padding:8px 0;"><?= e($waiver['notes']) ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">ملاحظات</td><td style="padding:8px 0;"><?= e($waiver['notes']) ?></td></tr>
<?php endif; ?> <?php endif; ?>
</table> </table>
</div> </div>
<!-- Dependents Comparison --> <!-- SECTION 3: Dependents Comparison (counts) -->
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;color:#0369A1;">👥 مقارنة التابعين</h3> <h3 style="margin:0;font-size:15px;color:#0369A1;">👥 مقارنة التابعين (لكل فئة على حدة)</h3>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:15px;align-items:start;"> <table style="width:100%;font-size:14px;border-collapse:collapse;">
<!-- Source dependents --> <thead>
<div style="text-align:center;"> <tr style="background:#F0F9FF;border-bottom:2px solid #BAE6FD;">
<div style="font-size:12px;color:#6B7280;margin-bottom:5px;">تابعين المتنازل (الأصلي)</div> <th style="padding:10px;text-align:right;color:#0369A1;">الفئة</th>
<div style="font-size:28px;font-weight:700;color:#0D7377;"><?= (int) ($waiver['original_dependent_count'] ?? 0) ?></div> <th style="padding:10px;text-align:center;color:#0369A1;">المتنازل (الأصلي)</th>
<?php if ($source_deps): ?> <th style="padding:10px;text-align:center;color:#0369A1;">المتنازل إليه</th>
<div style="font-size:11px;color:#6B7280;margin-top:4px;"> <th style="padding:10px;text-align:center;color:#0369A1;">الزيادة</th>
<?= $source_deps['spouses'] ?> زوجات — <?= $source_deps['children'] ?> أبناء — <?= $source_deps['temporary'] ?> مؤقتين <th style="padding:10px;text-align:center;color:#0369A1;">الحالة</th>
</div> </tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:10px;font-weight:600;">👩 الزوجات</td>
<td style="padding:10px;text-align:center;font-weight:700;"><?= (int) $original_deps['spouses'] ?></td>
<td style="padding:10px;text-align:center;font-weight:700;"><?= $target_deps ? $target_deps['spouses'] : '—' ?></td>
<?php $exSpouses = $target_deps ? max(0, $target_deps['spouses'] - $original_deps['spouses']) : 0; ?>
<td style="padding:10px;text-align:center;font-weight:700;color:<?= $exSpouses > 0 ? '#DC2626' : '#059669' ?>;"><?= $target_deps ? ($exSpouses > 0 ? '+' . $exSpouses : '0') : '—' ?></td>
<td style="padding:10px;text-align:center;"><?= $target_deps ? ($exSpouses > 0 ? '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">زائد</span>' : '<span style="background:#F0FDF4;color:#059669;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">ضمن الحد</span>') : '—' ?></td>
</tr>
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:10px;font-weight:600;">👶 الأبناء</td>
<td style="padding:10px;text-align:center;font-weight:700;"><?= (int) $original_deps['children'] ?></td>
<td style="padding:10px;text-align:center;font-weight:700;"><?= $target_deps ? $target_deps['children'] : '—' ?></td>
<?php $exChildren = $target_deps ? max(0, $target_deps['children'] - $original_deps['children']) : 0; ?>
<td style="padding:10px;text-align:center;font-weight:700;color:<?= $exChildren > 0 ? '#DC2626' : '#059669' ?>;"><?= $target_deps ? ($exChildren > 0 ? '+' . $exChildren : '0') : '—' ?></td>
<td style="padding:10px;text-align:center;"><?= $target_deps ? ($exChildren > 0 ? '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">زائد</span>' : '<span style="background:#F0FDF4;color:#059669;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">ضمن الحد</span>') : '—' ?></td>
</tr>
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:10px;font-weight:600;">👤 الأعضاء المؤقتون</td>
<td style="padding:10px;text-align:center;font-weight:700;"><?= (int) $original_deps['temporary'] ?></td>
<td style="padding:10px;text-align:center;font-weight:700;"><?= $target_deps ? $target_deps['temporary'] : '—' ?></td>
<?php $exTemp = $target_deps ? max(0, $target_deps['temporary'] - $original_deps['temporary']) : 0; ?>
<td style="padding:10px;text-align:center;font-weight:700;color:<?= $exTemp > 0 ? '#DC2626' : '#059669' ?>;"><?= $target_deps ? ($exTemp > 0 ? '+' . $exTemp : '0') : '—' ?></td>
<td style="padding:10px;text-align:center;"><?= $target_deps ? ($exTemp > 0 ? '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">زائد</span>' : '<span style="background:#F0FDF4;color:#059669;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">ضمن الحد</span>') : '—' ?></td>
</tr>
<tr style="border-top:2px solid #0D7377;background:#F0FDFA;">
<td style="padding:10px;font-weight:700;color:#0D7377;">الإجمالي</td>
<td style="padding:10px;text-align:center;font-weight:700;font-size:16px;color:#0D7377;"><?= (int) $original_deps['total'] ?></td>
<td style="padding:10px;text-align:center;font-weight:700;font-size:16px;color:#0D7377;"><?= $target_deps ? $target_deps['total'] : '—' ?></td>
<?php $exTotal = $exSpouses + $exChildren + $exTemp; ?>
<td style="padding:10px;text-align:center;font-weight:700;font-size:16px;color:<?= $exTotal > 0 ? '#DC2626' : '#059669' ?>;"><?= $target_deps ? ($exTotal > 0 ? '+' . $exTotal : '0') : '—' ?></td>
<td style="padding:10px;text-align:center;">
<?php if ($target_deps && $exTotal > 0): ?>
<span style="background:#FEF2F2;color:#DC2626;padding:3px 10px;border-radius:4px;font-size:12px;font-weight:700;">يتطلب رسوم إضافية</span>
<?php elseif ($target_deps): ?>
<span style="background:#F0FDF4;color:#059669;padding:3px 10px;border-radius:4px;font-size:12px;font-weight:700;">ضمن الحد المسموح</span>
<?php endif; ?> <?php endif; ?>
</td>
</tr>
</tbody>
</table>
</div> </div>
<!-- Arrow --> </div>
<div style="text-align:center;padding-top:15px;font-size:24px;color:#6B7280;"></div>
<!-- Target dependents --> <!-- SECTION 4: Children Details (name, age, DOB, category) -->
<div style="text-align:center;"> <?php if (($source_details && !empty($source_details['children'])) || ($target_details && !empty($target_details['children']))): ?>
<div style="font-size:12px;color:#6B7280;margin-bottom:5px;">تابعين المتنازل إليه</div> <div class="card" style="margin-bottom:20px;">
<?php if ($target_deps): ?> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<?php $excess = $target_deps['total'] - (int) ($waiver['original_dependent_count'] ?? 0); ?> <h3 style="margin:0;font-size:15px;color:#7C3AED;">👶 تفاصيل الأبناء (مقارنة بالاسم والسن)</h3>
<div style="font-size:28px;font-weight:700;color:<?= $excess > 0 ? '#DC2626' : '#059669' ?>;"><?= $target_deps['total'] ?></div>
<div style="font-size:11px;color:#6B7280;margin-top:4px;">
<?= $target_deps['spouses'] ?> زوجات — <?= $target_deps['children'] ?> أبناء — <?= $target_deps['temporary'] ?> مؤقتين
</div>
<?php if ($excess > 0): ?>
<div style="margin-top:6px;padding:4px 10px;background:#FEF2F2;border:1px solid #FECACA;border-radius:4px;display:inline-block;">
<span style="color:#DC2626;font-weight:700;font-size:13px;">+<?= $excess ?> تابع زائد</span>
</div>
<?php elseif ($excess <= 0): ?>
<div style="margin-top:6px;padding:4px 10px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:4px;display:inline-block;">
<span style="color:#059669;font-weight:600;font-size:13px;">ضمن الحد المسموح</span>
</div> </div>
<?php endif; ?> <div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<!-- Source children -->
<div>
<h4 style="margin:0 0 10px;font-size:13px;color:#6B7280;">أبناء المتنازل (<?= e($waiver['source_name'] ?? '') ?>)</h4>
<?php if (!empty($source_details['children'])): ?>
<table style="width:100%;font-size:12px;border-collapse:collapse;">
<thead><tr style="background:#F5F3FF;"><th style="padding:6px;text-align:right;">الاسم</th><th style="padding:6px;text-align:center;">تاريخ الميلاد</th><th style="padding:6px;text-align:center;">السن</th><th style="padding:6px;text-align:center;">الفئة العمرية</th></tr></thead>
<tbody>
<?php foreach ($source_details['children'] as $child): ?>
<tr style="border-bottom:1px solid #EDE9FE;">
<td style="padding:6px;font-weight:600;"><?= e($child['full_name_ar'] ?? '') ?></td>
<td style="padding:6px;text-align:center;"><?= e($child['date_of_birth'] ?? '—') ?></td>
<td style="padding:6px;text-align:center;"><?= e($child['age_display'] ?? '—') ?></td>
<td style="padding:6px;text-align:center;">
<?php
$catColor = match($child['age_category_code'] ?? '') {
'under_12' => '#059669', '12_to_16' => '#0284C7', '16_to_18' => '#D97706', '18_plus' => '#DC2626', default => '#6B7280'
};
?>
<span style="background:<?= $catColor ?>15;color:<?= $catColor ?>;padding:2px 6px;border-radius:3px;font-size:11px;font-weight:600;"><?= e($child['age_category'] ?? '') ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?> <?php else: ?>
<div style="font-size:28px;font-weight:700;color:#9CA3AF;"></div> <p style="color:#9CA3AF;font-size:12px;">لا يوجد أبناء</p>
<div style="font-size:11px;color:#9CA3AF;">لم يُحدد المتنازل إليه بعد</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Target children -->
<div>
<h4 style="margin:0 0 10px;font-size:13px;color:#6B7280;">أبناء المتنازل إليه (<?= e($waiver['target_name'] ?? '') ?>)</h4>
<?php if ($target_details && !empty($target_details['children'])): ?>
<table style="width:100%;font-size:12px;border-collapse:collapse;">
<thead><tr style="background:#F5F3FF;"><th style="padding:6px;text-align:right;">الاسم</th><th style="padding:6px;text-align:center;">تاريخ الميلاد</th><th style="padding:6px;text-align:center;">السن</th><th style="padding:6px;text-align:center;">الفئة العمرية</th></tr></thead>
<tbody>
<?php foreach ($target_details['children'] as $child): ?>
<tr style="border-bottom:1px solid #EDE9FE;">
<td style="padding:6px;font-weight:600;"><?= e($child['full_name_ar'] ?? '') ?></td>
<td style="padding:6px;text-align:center;"><?= e($child['date_of_birth'] ?? '—') ?></td>
<td style="padding:6px;text-align:center;"><?= e($child['age_display'] ?? '—') ?></td>
<td style="padding:6px;text-align:center;">
<?php
$catColor = match($child['age_category_code'] ?? '') {
'under_12' => '#059669', '12_to_16' => '#0284C7', '16_to_18' => '#D97706', '18_plus' => '#DC2626', default => '#6B7280'
};
?>
<span style="background:<?= $catColor ?>15;color:<?= $catColor ?>;padding:2px 6px;border-radius:3px;font-size:11px;font-weight:600;"><?= e($child['age_category'] ?? '') ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p style="color:#9CA3AF;font-size:12px;"><?= $target_details ? 'لا يوجد أبناء' : 'لم يُحدد المتنازل إليه بعد' ?></p>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?>
<!-- Fee Breakdown --> <!-- SECTION 5: Fee Breakdown -->
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="receipt" style="width:18px;height:18px;color:#D97706;"></i> <i data-lucide="receipt" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">تفصيل الرسوم</h3> <h3 style="margin:0;color:#D97706;font-size:15px;">تفصيل الرسوم</h3>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<table style="width:100%;max-width:600px;font-size:14px;"> <table style="width:100%;max-width:700px;font-size:14px;">
<tr> <tr>
<td style="padding:8px 0;color:#6B7280;">قيمة العضوية وقت التنازل</td> <td style="padding:8px 0;color:#6B7280;">قيمة العضوية وقت التنازل</td>
<td style="padding:8px 0;font-weight:600;text-align:left;"><?= money($waiver['membership_value_at_waiver'] ?? '0') ?></td> <td style="padding:8px 0;font-weight:600;text-align:left;" colspan="2"><?= money($waiver['membership_value_at_waiver'] ?? '0') ?></td>
</tr> </tr>
<tr> <tr>
<td style="padding:8px 0;color:#6B7280;">نسبة رسوم التنازل</td> <td style="padding:8px 0;color:#6B7280;">نسبة رسوم التنازل</td>
<td style="padding:8px 0;font-weight:600;"><?= e($waiver['waiver_fee_percentage'] ?? '30') ?>%</td> <td style="padding:8px 0;font-weight:600;" colspan="2"><?= e($waiver['waiver_fee_percentage'] ?? '30') ?>%</td>
</tr> </tr>
<tr style="border-bottom:1px dashed #E5E7EB;"> <tr style="border-bottom:1px dashed #E5E7EB;">
<td style="padding:8px 0;color:#374151;">رسوم التنازل الأساسية</td> <td style="padding:8px 0;color:#374151;font-weight:600;">رسوم التنازل الأساسية</td>
<td style="padding:8px 0;font-size:12px;color:#6B7280;"><?= e($waiver['waiver_fee_percentage'] ?? '30') ?>% x <?= money($waiver['membership_value_at_waiver'] ?? '0') ?></td>
<td style="padding:8px 0;font-weight:700;text-align:left;"><?= money($waiver['waiver_fee_amount'] ?? '0') ?></td> <td style="padding:8px 0;font-weight:700;text-align:left;"><?= money($waiver['waiver_fee_amount'] ?? '0') ?></td>
</tr> </tr>
<?php if (bccomp($waiver['excess_fee_amount'] ?? '0', '0', 2) > 0): ?>
<?php
$spouseFeeTotal = $waiver['spouse_fee_total'] ?? '0.00';
$childFeeTotal = $waiver['child_fee_total'] ?? '0.00';
$tempFeeTotal = $waiver['temporary_fee_total'] ?? '0.00';
$hasPerCategoryFees = bccomp(bcadd(bcadd($spouseFeeTotal, $childFeeTotal, 2), $tempFeeTotal, 2), '0', 2) > 0;
?>
<?php if ($hasPerCategoryFees): ?>
<?php if (bccomp($spouseFeeTotal, '0', 2) > 0): ?>
<tr style="background:#FEF2F2;">
<td style="padding:8px 0;color:#DC2626;font-weight:600;">👩 رسوم زوجات إضافيات (<?= (int) ($waiver['excess_spouses_count'] ?? 0) ?>)</td>
<td style="padding:8px 0;font-size:12px;color:#7F1D1D;">
<?= (int) ($waiver['excess_spouses_count'] ?? 0) ?> x
<?php if (($waiver['spouse_fee_type'] ?? '') === 'percentage'): ?>
<?= e($waiver['spouse_fee_rate'] ?? '0') ?>% من <?= money($waiver['membership_value_at_waiver'] ?? '0') ?>
<?php else: ?>
<?= money($waiver['spouse_fee_rate'] ?? '0') ?> (مبلغ ثابت)
<?php endif; ?>
</td>
<td style="padding:8px 0;font-weight:700;color:#DC2626;text-align:left;"><?= money($spouseFeeTotal) ?></td>
</tr>
<?php endif; ?>
<?php if (bccomp($childFeeTotal, '0', 2) > 0): ?>
<tr style="background:#FEF2F2;"> <tr style="background:#FEF2F2;">
<td style="padding:8px 0;color:#DC2626;">رسوم تابعين إضافيين (<?= (int) ($waiver['excess_dependent_count'] ?? 0) ?> عضو × <?= e($waiver['excess_fee_percentage'] ?? '0') ?>%)</td> <td style="padding:8px 0;color:#DC2626;font-weight:600;">👶 رسوم أبناء إضافيين (<?= (int) ($waiver['excess_children_count'] ?? 0) ?>)</td>
<td style="padding:8px 0;font-size:12px;color:#7F1D1D;">
<?= (int) ($waiver['excess_children_count'] ?? 0) ?> x
<?php if (($waiver['child_fee_type'] ?? '') === 'percentage'): ?>
<?= e($waiver['child_fee_rate'] ?? '0') ?>% من <?= money($waiver['membership_value_at_waiver'] ?? '0') ?>
<?php else: ?>
<?= money($waiver['child_fee_rate'] ?? '0') ?> (مبلغ ثابت)
<?php endif; ?>
</td>
<td style="padding:8px 0;font-weight:700;color:#DC2626;text-align:left;"><?= money($childFeeTotal) ?></td>
</tr>
<?php endif; ?>
<?php if (bccomp($tempFeeTotal, '0', 2) > 0): ?>
<tr style="background:#FEF2F2;">
<td style="padding:8px 0;color:#DC2626;font-weight:600;">👤 رسوم أعضاء مؤقتين إضافيين (<?= (int) ($waiver['excess_temporary_count'] ?? 0) ?>)</td>
<td style="padding:8px 0;font-size:12px;color:#7F1D1D;">
<?= (int) ($waiver['excess_temporary_count'] ?? 0) ?> x
<?php if (($waiver['temporary_fee_type'] ?? '') === 'percentage'): ?>
<?= e($waiver['temporary_fee_rate'] ?? '0') ?>% من <?= money($waiver['membership_value_at_waiver'] ?? '0') ?>
<?php else: ?>
<?= money($waiver['temporary_fee_rate'] ?? '0') ?> (مبلغ ثابت)
<?php endif; ?>
</td>
<td style="padding:8px 0;font-weight:700;color:#DC2626;text-align:left;"><?= money($tempFeeTotal) ?></td>
</tr>
<?php endif; ?>
<?php elseif (bccomp($waiver['excess_fee_amount'] ?? '0', '0', 2) > 0): ?>
<tr style="background:#FEF2F2;">
<td style="padding:8px 0;color:#DC2626;">رسوم تابعين إضافيين (<?= (int) ($waiver['excess_dependent_count'] ?? 0) ?> عضو x <?= e($waiver['excess_fee_percentage'] ?? '0') ?>%)</td>
<td style="padding:8px 0;"></td>
<td style="padding:8px 0;font-weight:700;color:#DC2626;text-align:left;"><?= money($waiver['excess_fee_amount']) ?></td> <td style="padding:8px 0;font-weight:700;color:#DC2626;text-align:left;"><?= money($waiver['excess_fee_amount']) ?></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php
$totalExcess = $hasPerCategoryFees
? bcadd(bcadd($spouseFeeTotal, $childFeeTotal, 2), $tempFeeTotal, 2)
: ($waiver['excess_fee_amount'] ?? '0.00');
$grandTotal = bcadd($waiver['waiver_fee_amount'] ?? '0', $totalExcess, 2);
?>
<tr style="border-top:2px solid #0D7377;"> <tr style="border-top:2px solid #0D7377;">
<td style="padding:12px 0;font-weight:700;font-size:16px;">الإجمالي</td> <td style="padding:12px 0;font-weight:700;font-size:16px;" colspan="2">الإجمالي النهائي (يدفعه المتنازل إليه / المشتري)</td>
<td style="padding:12px 0;font-weight:800;font-size:20px;color:#0D7377;text-align:left;"> <td style="padding:12px 0;font-weight:800;font-size:20px;color:#0D7377;text-align:left;">
<?= money(bcadd($waiver['waiver_fee_amount'] ?? '0', $waiver['excess_fee_amount'] ?? '0', 2)) ?> <?= money($grandTotal) ?>
</td> </td>
</tr> </tr>
</table> </table>
...@@ -140,63 +387,142 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان ...@@ -140,63 +387,142 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Payment Section --> <!-- SECTION 6: Send to Cashier (approved status, has target) -->
<?php if (in_array($waiver['status'], ['requested', 'approved']) && bccomp(bcadd($waiver['waiver_fee_amount'] ?? '0', $waiver['excess_fee_amount'] ?? '0', 2), '0', 2) > 0 && can('payment.collect')): ?> <?php if ($waiver['status'] === 'approved' && $waiver['target_member_id'] && bccomp($grandTotal, '0', 2) > 0 && can('waiver.approve')): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;"> <div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;">
<h4 style="margin:0 0 15px;color:#D97706;">💰 دفع الرسوم</h4> <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/pay"> <span style="font-size:20px;">💰</span>
<?= csrf_field() ?> <h4 style="margin:0;color:#D97706;">إرسال طلب الدفع إلى الخزينة</h4>
<div style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div class="form-group">
<label class="form-label">الإجمالي</label>
<input type="text" value="<?= money(bcadd($waiver['waiver_fee_amount'] ?? '0', $waiver['excess_fee_amount'] ?? '0', 2)) ?>" class="form-input" style="background:#F3F4F6;font-weight:700;font-size:18px;min-width:180px;" readonly>
</div> </div>
<div class="form-group"> <p style="font-size:13px;color:#92400E;margin:0 0 12px;">سيتم إنشاء طلب دفع باسم <strong>المتنازل إليه (المشتري): <?= e($waiver['target_name'] ?? '') ?></strong> بإجمالي <strong><?= money($grandTotal) ?></strong></p>
<label class="form-label">طريقة الدفع</label> <div style="padding:10px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;margin-bottom:12px;font-size:12px;color:#92400E;">
<select name="payment_method" class="form-select"><option value="cash">نقدي</option><option value="visa">فيزا</option><option value="bank_transfer">تحويل بنكي</option></select> <strong>سيتضمن الإيصال:</strong> رسوم التنازل<?= $hasPerCategoryFees ? ' + رسوم التابعين الإضافيين لكل فئة' : '' ?> + اسم المتنازل والمتنازل إليه + رقم العضوية
</div>
<button type="submit" class="btn btn-primary" style="padding:10px 25px;" onclick="return confirm('تأكيد دفع <?= money(bcadd($waiver['waiver_fee_amount'] ?? '0', $waiver['excess_fee_amount'] ?? '0', 2)) ?>؟')">💰 دفع</button>
</div> </div>
<form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/send-to-cashier" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" style="background:#D97706;padding:10px 25px;" onclick="return confirm('إرسال طلب دفع <?= money($grandTotal) ?> باسم <?= e($waiver['target_name'] ?? '') ?> إلى الخزينة؟')">
💰 إرسال للخزينة باسم المتنازل إليه
</button>
</form> </form>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Board Approval Section (with excess fee input) --> <!-- SECTION 7: Board Approval (with per-category fee inputs) -->
<?php if ($waiver['status'] === 'requested' && can('waiver.approve')): ?> <?php if ($waiver['status'] === 'requested' && can('waiver.approve')): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#EFF6FF;border:2px solid #3B82F6;"> <div class="card" style="padding:20px;margin-bottom:20px;background:#EFF6FF;border:2px solid #3B82F6;">
<h4 style="margin:0 0 15px;color:#1D4ED8;">🏛 اعتماد مجلس الأمناء</h4> <h4 style="margin:0 0 15px;color:#1D4ED8;">🏛 اعتماد مجلس الأمناء</h4>
<form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/approve" id="approve-form"> <form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/approve" id="approve-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div class="form-group"> <div class="form-group" style="margin-bottom:15px;">
<label class="form-label">مرجع قرار مجلس الأمناء</label> <label class="form-label">مرجع قرار مجلس الأمناء</label>
<input type="text" name="board_decision_reference" class="form-input" placeholder="مثال: قرار جلسة 2026/06/15"> <input type="text" name="board_decision_reference" class="form-input" placeholder="مثال: قرار جلسة 2026/06/15">
</div> </div>
<?php if ($target_deps && $target_deps['total'] > (int) ($waiver['original_dependent_count'] ?? 0)): ?>
<?php if ($target_deps && $comparison && $comparison['has_excess']): ?>
<div style="padding:15px;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;margin-bottom:15px;">
<strong style="color:#DC2626;">⚠ يوجد تابعين زائدين — يجب تحديد الرسوم لكل فئة:</strong>
<p style="font-size:12px;color:#7F1D1D;margin:5px 0 0;">يمكن تحديد رسوم ثابتة (مبلغ لكل عنصر زائد) أو نسبة مئوية (من قيمة العضوية <?= money($waiver['membership_value_at_waiver'] ?? '0') ?> لكل عنصر زائد)</p>
</div>
<!-- Spouse Excess Fee -->
<?php if ($comparison['excess_spouses'] > 0): ?>
<div style="padding:15px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="font-size:18px;">👩</span>
<strong style="color:#374151;">رسوم الزوجات الإضافيات</strong>
<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:700;">+<?= $comparison['excess_spouses'] ?> زوجة زائدة</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group"> <div class="form-group">
<label class="form-label" style="color:#DC2626;">نسبة رسوم التابعين الزائدين (%) <span style="color:#DC2626;">*</span></label> <label class="form-label">نوع الرسوم <span style="color:#DC2626;">*</span></label>
<input type="number" name="excess_fee_percentage" class="form-input" step="0.01" min="0" max="100" placeholder="مثال: 30" style="border-color:#DC2626;"> <select name="spouse_fee_type" class="form-select" required onchange="updateFeePreview('spouse')">
<small style="color:#DC2626;"> <option value="">— اختر —</option>
يوجد <?= $target_deps['total'] - (int) $waiver['original_dependent_count'] ?> تابع زائد — النسبة × قيمة العضوية (<?= money($waiver['membership_value_at_waiver'] ?? '0') ?>) × عدد الزائدين <option value="fixed">مبلغ ثابت لكل زوجة</option>
</small> <option value="percentage">نسبة من قيمة العضوية لكل زوجة</option>
</select>
</div> </div>
<?php else: ?>
<div class="form-group"> <div class="form-group">
<label class="form-label">نسبة رسوم تابعين إضافية (%)</label> <label class="form-label">القيمة <span style="color:#DC2626;">*</span></label>
<input type="number" name="excess_fee_percentage" class="form-input" step="0.01" min="0" max="100" placeholder="0 إذا لم يوجد زيادة"> <input type="number" name="spouse_fee_rate" id="spouse_fee_rate" class="form-input" step="0.01" min="0" placeholder="المبلغ أو النسبة" required oninput="updateFeePreview('spouse')">
<small style="color:#6B7280;">اتركه فارغاً إذا لم يوجد تابعين زائدين</small> <small style="color:#6B7280;" id="spouse_fee_hint"><?= $comparison['excess_spouses'] ?> زوجة x القيمة = ؟</small>
</div>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Child Excess Fee -->
<?php if ($comparison['excess_children'] > 0): ?>
<div style="padding:15px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="font-size:18px;">👶</span>
<strong style="color:#374151;">رسوم الأبناء الإضافيين</strong>
<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:700;">+<?= $comparison['excess_children'] ?> ابن زائد</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label class="form-label">نوع الرسوم <span style="color:#DC2626;">*</span></label>
<select name="child_fee_type" class="form-select" required onchange="updateFeePreview('child')">
<option value="">— اختر —</option>
<option value="fixed">مبلغ ثابت لكل ابن</option>
<option value="percentage">نسبة من قيمة العضوية لكل ابن</option>
</select>
</div>
<div class="form-group">
<label class="form-label">القيمة <span style="color:#DC2626;">*</span></label>
<input type="number" name="child_fee_rate" id="child_fee_rate" class="form-input" step="0.01" min="0" placeholder="المبلغ أو النسبة" required oninput="updateFeePreview('child')">
<small style="color:#6B7280;" id="child_fee_hint"><?= $comparison['excess_children'] ?> ابن x القيمة = ؟</small>
</div>
</div>
</div>
<?php endif; ?>
<!-- Temporary Excess Fee -->
<?php if ($comparison['excess_temporary'] > 0): ?>
<div style="padding:15px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="font-size:18px;">👤</span>
<strong style="color:#374151;">رسوم الأعضاء المؤقتين الإضافيين</strong>
<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:700;">+<?= $comparison['excess_temporary'] ?> مؤقت زائد</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-group">
<label class="form-label">نوع الرسوم <span style="color:#DC2626;">*</span></label>
<select name="temporary_fee_type" class="form-select" required onchange="updateFeePreview('temporary')">
<option value="">— اختر —</option>
<option value="fixed">مبلغ ثابت لكل مؤقت</option>
<option value="percentage">نسبة من قيمة العضوية لكل مؤقت</option>
</select>
</div>
<div class="form-group">
<label class="form-label">القيمة <span style="color:#DC2626;">*</span></label>
<input type="number" name="temporary_fee_rate" id="temporary_fee_rate" class="form-input" step="0.01" min="0" placeholder="المبلغ أو النسبة" required oninput="updateFeePreview('temporary')">
<small style="color:#6B7280;" id="temporary_fee_hint"><?= $comparison['excess_temporary'] ?> مؤقت x القيمة = ؟</small>
</div> </div>
</div>
</div>
<?php endif; ?>
<!-- Fee Preview -->
<div id="fee-preview" style="padding:12px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;margin-bottom:15px;display:none;">
<strong style="color:#166534;">📊 معاينة الرسوم الإضافية:</strong>
<div id="fee-preview-content" style="margin-top:8px;font-size:13px;color:#166534;"></div>
</div>
<?php else: ?>
<div style="padding:12px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;margin-bottom:15px;">
<span style="color:#059669;font-weight:600;">✅ لا يوجد تابعين زائدين — لا تُفرض رسوم إضافية</span>
</div>
<?php endif; ?>
<div style="display:flex;gap:10px;"> <div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary" onclick="return confirm('اعتماد طلب التنازل؟')">✅ اعتماد</button> <button type="submit" class="btn btn-primary" onclick="return confirm('اعتماد طلب التنازل؟')">✅ اعتماد</button>
<button type="button" class="btn btn-outline" style="color:#DC2626;" onclick="document.getElementById('reject-modal').style.display='block'">❌ رفض</button> <button type="button" class="btn btn-outline" style="color:#DC2626;" onclick="document.getElementById('reject-modal').style.display='flex'">❌ رفض</button>
</div> </div>
</form> </form>
</div> </div>
<!-- Reject modal --> <!-- Reject modal -->
<div id="reject-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:none;align-items:center;justify-content:center;"> <div id="reject-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;">
<div style="background:#fff;padding:30px;border-radius:12px;max-width:450px;width:90%;"> <div style="background:#fff;padding:30px;border-radius:12px;max-width:450px;width:90%;">
<h4 style="margin:0 0 15px;color:#DC2626;">❌ رفض طلب التنازل</h4> <h4 style="margin:0 0 15px;color:#DC2626;">❌ رفض طلب التنازل</h4>
<form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/reject"> <form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/reject">
...@@ -212,12 +538,87 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان ...@@ -212,12 +538,87 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان
</form> </form>
</div> </div>
</div> </div>
<?php if ($target_deps && $comparison && $comparison['has_excess']): ?>
<script>
var membershipValue = <?= (float) ($waiver['membership_value_at_waiver'] ?? 0) ?>;
var excessCounts = {spouse: <?= $comparison['excess_spouses'] ?>, child: <?= $comparison['excess_children'] ?>, temporary: <?= $comparison['excess_temporary'] ?>};
function updateFeePreview(category) {
var typeEl = document.querySelector('[name="' + category + '_fee_type"]');
var rateEl = document.getElementById(category + '_fee_rate');
var hintEl = document.getElementById(category + '_fee_hint');
if (!typeEl || !rateEl || !hintEl) return;
var type = typeEl.value;
var rate = parseFloat(rateEl.value) || 0;
var count = excessCounts[category] || 0;
var perUnit = 0;
if (type === 'percentage') {
perUnit = (membershipValue * rate / 100);
hintEl.textContent = count + ' x (' + rate + '% x ' + membershipValue.toLocaleString() + ') = ' + (perUnit * count).toLocaleString() + ' ج.م';
} else if (type === 'fixed') {
perUnit = rate;
hintEl.textContent = count + ' x ' + rate.toLocaleString() + ' = ' + (rate * count).toLocaleString() + ' ج.م';
} else {
hintEl.textContent = count + ' عضو x القيمة = ؟';
}
updateTotalPreview();
}
function updateTotalPreview() {
var preview = document.getElementById('fee-preview');
var content = document.getElementById('fee-preview-content');
var lines = [];
var total = 0;
var categories = [
{key: 'spouse', label: '👩 زوجات', count: excessCounts.spouse},
{key: 'child', label: '👶 أبناء', count: excessCounts.child},
{key: 'temporary', label: '👤 مؤقتين', count: excessCounts.temporary}
];
categories.forEach(function(cat) {
if (cat.count <= 0) return;
var typeEl = document.querySelector('[name="' + cat.key + '_fee_type"]');
var rateEl = document.getElementById(cat.key + '_fee_rate');
if (!typeEl || !rateEl) return;
var type = typeEl.value;
var rate = parseFloat(rateEl.value) || 0;
if (!type || !rate) return;
var perUnit = type === 'percentage' ? (membershipValue * rate / 100) : rate;
var catTotal = perUnit * cat.count;
total += catTotal;
lines.push(cat.label + ': ' + cat.count + ' x ' + perUnit.toLocaleString() + ' = <strong>' + catTotal.toLocaleString() + ' ج.م</strong>');
});
if (lines.length > 0) {
lines.push('<hr style="margin:6px 0;border:none;border-top:1px dashed #BBF7D0;">');
lines.push('<strong>إجمالي الرسوم الإضافية: ' + total.toLocaleString() + ' ج.م</strong>');
lines.push('<strong>+ رسوم التنازل الأساسية: <?= $waiver['waiver_fee_amount'] ?? '0' ?> ج.م</strong>');
lines.push('<strong style="font-size:15px;color:#0D7377;">= الإجمالي النهائي: ' + (total + <?= (float) ($waiver['waiver_fee_amount'] ?? 0) ?>).toLocaleString() + ' ج.م</strong>');
content.innerHTML = lines.join('<br>');
preview.style.display = 'block';
} else {
preview.style.display = 'none';
}
}
</script>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<!-- Complete Section --> <!-- SECTION 8: Complete (fee_paid status) -->
<?php if (in_array($waiver['status'], ['approved', 'fee_paid']) && can('waiver.approve')): ?> <?php if ($waiver['status'] === 'fee_paid' && can('waiver.approve')): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#ECFDF5;border:2px solid #059669;"> <div class="card" style="padding:20px;margin-bottom:20px;background:#ECFDF5;border:2px solid #059669;">
<h4 style="margin:0 0 15px;color:#059669;">✅ إتمام التنازل ونقل العضوية</h4> <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="font-size:22px;">🟢</span>
<div>
<h4 style="margin:0;color:#059669;">جاهز لاعتماد طلب التنازل</h4>
<p style="margin:4px 0 0;font-size:12px;color:#166534;">جميع الشروط مستوفاة: الاعتماد ✓ الدفع ✓ عدم وجود مديونيات ✓</p>
</div>
</div>
<form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/complete" id="complete-form"> <form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/complete" id="complete-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php if (!$waiver['target_member_id']): ?> <?php if (!$waiver['target_member_id']): ?>
...@@ -246,7 +647,7 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان ...@@ -246,7 +647,7 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان
<?php endif; ?> <?php endif; ?>
<input type="hidden" name="target_member_id" id="target-member-id" value="<?= (int) ($waiver['target_member_id'] ?? 0) ?>"> <input type="hidden" name="target_member_id" id="target-member-id" value="<?= (int) ($waiver['target_member_id'] ?? 0) ?>">
<div style="display:flex;gap:10px;align-items:center;"> <div style="display:flex;gap:10px;align-items:center;">
<button type="submit" class="btn btn-primary" onclick="return validateComplete()">✅ إتمام التنازل ونقل العضوية</button> <button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:15px;" onclick="return validateComplete()">✅ إتمام التنازل ونقل العضوية</button>
<?php if (!$waiver['target_member_id']): ?> <?php if (!$waiver['target_member_id']): ?>
<span style="font-size:12px;color:#6B7280;">أو <a href="/members/create" target="_blank" style="color:#0D7377;">أنشئ عضو جديد</a> ثم ارجع هنا</span> <span style="font-size:12px;color:#6B7280;">أو <a href="/members/create" target="_blank" style="color:#0D7377;">أنشئ عضو جديد</a> ثم ارجع هنا</span>
<?php endif; ?> <?php endif; ?>
...@@ -255,11 +656,18 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان ...@@ -255,11 +656,18 @@ $statusLabel = match($waiver['status']) { 'requested' => 'مقدم — في ان
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Complete for approved (still needs payment) -->
<?php if ($waiver['status'] === 'approved' && can('waiver.approve')): ?>
<div style="padding:12px 16px;background:#FEF3C7;border:1px solid #FDE68A;border-radius:8px;margin-bottom:20px;font-size:13px;color:#92400E;">
⏳ لإتمام التنازل: يجب أولاً إرسال طلب الدفع للخزينة وسداد الرسوم، ثم يصبح الطلب جاهزاً لنقل العضوية.
</div>
<?php endif; ?>
<div style="margin-top:20px;"> <div style="margin-top:20px;">
<a href="/waivers" class="btn btn-outline">← رجوع للقائمة</a> <a href="/waivers" class="btn btn-outline">← رجوع للقائمة</a>
</div> </div>
<?php if (in_array($waiver['status'], ['approved', 'fee_paid']) || (!$waiver['target_member_id'] && $waiver['status'] === 'requested')): ?> <?php if ($waiver['status'] === 'fee_paid' || (!$waiver['target_member_id'] && $waiver['status'] === 'requested')): ?>
<script> <script>
(function() { (function() {
var searchInput = document.getElementById('target-search'); var searchInput = document.getElementById('target-search');
......
...@@ -14,6 +14,12 @@ return [ ...@@ -14,6 +14,12 @@ return [
'token' => [ 'token' => [
'ttl_hours' => (int) env('API_TOKEN_TTL_HOURS', 720), 'ttl_hours' => (int) env('API_TOKEN_TTL_HOURS', 720),
'max_per_employee' => 5, 'max_per_employee' => 5,
'player_ttl_hours' => (int) env('API_PLAYER_TOKEN_TTL_HOURS', 2160),
'max_per_player' => 5,
'parent_ttl_hours' => (int) env('API_PARENT_TOKEN_TTL_HOURS', 2160),
'max_per_parent' => 5,
'coach_ttl_hours' => (int) env('API_COACH_TOKEN_TTL_HOURS', 2160),
'max_per_coach' => 5,
], ],
'rate_limit' => [ 'rate_limit' => [
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE waiver_requests
ADD COLUMN original_spouses_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER original_dependent_count,
ADD COLUMN original_children_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER original_spouses_count,
ADD COLUMN original_temporary_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER original_children_count,
ADD COLUMN new_spouses_count INT UNSIGNED NULL AFTER new_dependent_count,
ADD COLUMN new_children_count INT UNSIGNED NULL AFTER new_spouses_count,
ADD COLUMN new_temporary_count INT UNSIGNED NULL AFTER new_children_count,
ADD COLUMN excess_spouses_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER excess_dependent_count,
ADD COLUMN excess_children_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER excess_spouses_count,
ADD COLUMN excess_temporary_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER excess_children_count,
ADD COLUMN spouse_fee_type ENUM('fixed','percentage') NULL AFTER excess_fee_amount,
ADD COLUMN spouse_fee_rate DECIMAL(15,2) NULL AFTER spouse_fee_type,
ADD COLUMN spouse_fee_total DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER spouse_fee_rate,
ADD COLUMN child_fee_type ENUM('fixed','percentage') NULL AFTER spouse_fee_total,
ADD COLUMN child_fee_rate DECIMAL(15,2) NULL AFTER child_fee_type,
ADD COLUMN child_fee_total DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER child_fee_rate,
ADD COLUMN temporary_fee_type ENUM('fixed','percentage') NULL AFTER child_fee_total,
ADD COLUMN temporary_fee_rate DECIMAL(15,2) NULL AFTER temporary_fee_type,
ADD COLUMN temporary_fee_total DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER temporary_fee_rate,
ADD COLUMN target_debts_cleared TINYINT(1) NOT NULL DEFAULT 0 AFTER debts_cleared;
",
'down' => "
ALTER TABLE waiver_requests
DROP COLUMN original_spouses_count,
DROP COLUMN original_children_count,
DROP COLUMN original_temporary_count,
DROP COLUMN new_spouses_count,
DROP COLUMN new_children_count,
DROP COLUMN new_temporary_count,
DROP COLUMN excess_spouses_count,
DROP COLUMN excess_children_count,
DROP COLUMN excess_temporary_count,
DROP COLUMN spouse_fee_type,
DROP COLUMN spouse_fee_rate,
DROP COLUMN spouse_fee_total,
DROP COLUMN child_fee_type,
DROP COLUMN child_fee_rate,
DROP COLUMN child_fee_total,
DROP COLUMN temporary_fee_type,
DROP COLUMN temporary_fee_rate,
DROP COLUMN temporary_fee_total,
DROP COLUMN target_debts_cleared;
",
];
...@@ -876,3 +876,63 @@ WHERE member_id = ? AND payment_type = ? ...@@ -876,3 +876,63 @@ WHERE member_id = ? AND payment_type = ?
| Tables shared across modules | 15+ | | Tables shared across modules | 15+ |
| Direct member-status writers (bypassing Members) | 6 | | Direct member-status writers (bypassing Members) | 6 |
| Missing cron enforcement | 5 | | Missing cron enforcement | 5 |
---
## 10. Mobile API Layer (Added 2026-06-13)
### 10.1 Mobile API Module Dependencies
| Module | Role | Auth Middleware | Endpoints |
|--------|------|----------------|-----------|
| **PlayerAuth** | Player token lifecycle | (none — issues tokens) | 5 |
| **PlayerApi** | Player mobile features | player_auth | 40 |
| **ParentAuth** | Parent token lifecycle | (none — issues tokens) | 5 |
| **ParentApi** | Parent mobile features | parent_auth | 16 |
| **TrainerPortal** | Coach mobile features | coach_auth | 27 |
| **Api** | Health/config (shared) | (none) | 2 |
### 10.2 Shared Mobile Infrastructure
| Component | Path | Used By |
|-----------|------|---------|
| PushNotificationService | app/Shared/Services/ | PlayerApi, ParentApi, TrainerPortal |
| ApiErrorCodes | app/Core/ | All auth middlewares, all API controllers |
| ApiController base | app/Core/ | All API controllers |
| RateLimitMiddleware | app/Middleware/ | Available to all API routes |
### 10.3 Mobile Auth Token Tables
| Table | Entity | Middleware Class |
|-------|--------|-----------------|
| player_tokens | players | PlayerApiAuthMiddleware |
| coach_tokens | coaches | CoachApiAuthMiddleware |
| parent_tokens | parent_accounts | ParentApiAuthMiddleware |
### 10.4 Mobile Event → Push Notification Flow
| Event | Player Notification | Parent Notification | Coach Notification |
|-------|--------------------|--------------------|-------------------|
| player.enrolled | ✓ | ✓ | — |
| player.evaluation_completed | ✓ | ✓ | — |
| player.medical_expiring | ✓ | ✓ | — |
| facility.attendance_absent | ✓ | ✓ | — |
| player.booking_confirmed | ✓ | — | — |
| player.booking_cancelled | ✓ | — | — |
| player.transfer_result | ✓ | — | — |
| player.free_time_entry | ✓ | — | — |
### 10.5 Data Access by Mobile Audience
| Data Domain | Player API | Parent API | Coach API |
|-------------|-----------|------------|-----------|
| Player profile | Own only | Linked children | Group players |
| Attendance | Own history | Child (if can_view_attendance) | Mark + view session |
| Evaluations | Own | Child (if can_view_evaluations) | Submit + view own |
| Medical | Submit own | Child (if can_view_medical) | View group players' status |
| Financials | Own | Child (if can_view_financials) | — |
| Achievements | Own | Child | — |
| Notifications | Own feed | Own feed | Own feed |
| Bookings | CRUD own | — | — |
| Schedule | — | — | Own weekly/sessions |
| Groups | Browse/enroll | View child's | Own groups + roster |
# Subscriptions Module — Architecture Map # Subscriptions Module — Architecture Map
> **Last updated:** 2026-06-10 (updated) > **Last updated:** 2026-06-13 (dev fee included in paid_amount for all payment paths)
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -9,7 +9,8 @@ ...@@ -9,7 +9,8 @@
The Subscriptions module manages **annual membership subscription fees** for the club. It handles: The Subscriptions module manages **annual membership subscription fees** for the club. It handles:
- Batch generation of yearly subscription records for all active members and their dependents - Batch generation of yearly subscription records for all active members and their dependents
- Individual subscription payment collection - Individual subscription payment collection (FIFO: oldest year must be fully paid before newer years)
- On-demand fine recalculation per member (triggered on page load via `OverdueFineApplicator::applyForMember()`)
- Overdue detection and late fine calculation (progressive penalties) - Overdue detection and late fine calculation (progressive penalties)
- Subscription exemptions (with reason tracking) - Subscription exemptions (with reason tracking)
- Membership drop enforcement (5+ consecutive unpaid years) - Membership drop enforcement (5+ consecutive unpaid years)
...@@ -37,11 +38,11 @@ app/Modules/Subscriptions/ ...@@ -37,11 +38,11 @@ app/Modules/Subscriptions/
├── Services/ ├── Services/
│ ├── SubscriptionGenerator.php # Batch generation for a financial year │ ├── SubscriptionGenerator.php # Batch generation for a financial year
│ ├── SubscriptionCalculator.php # Late fine calculation + reinstatement check │ ├── SubscriptionCalculator.php # Late fine calculation + reinstatement check
│ └── OverdueFineApplicator.php # Cron: apply fines, drop members, expire reinstatements │ └── OverdueFineApplicator.php # Cron: apply fines + on-demand per-member fine calc
└── Views/ └── Views/
├── index.php # Paginated list with filters (year, status, person_type) ├── index.php # Paginated list with filters (year, status, person_type)
├── batch-generate.php # Admin form to trigger batch generation ├── batch-generate.php # Admin form to trigger batch generation
└── member-subscriptions.php # Per-member subscription history + unpaid summary └── member-subscriptions.php # Year-grouped display with FIFO, fine breakdowns, totals
``` ```
--- ---
...@@ -61,8 +62,8 @@ app/Modules/Subscriptions/ ...@@ -61,8 +62,8 @@ app/Modules/Subscriptions/
| base_amount | decimal(15,2) | NO | | 0.00 | Rate without dev fee | | base_amount | decimal(15,2) | NO | | 0.00 | Rate without dev fee |
| development_fee | decimal(15,2) | NO | | 0.00 | Only for member type (35 EGP) | | development_fee | decimal(15,2) | NO | | 0.00 | Only for member type (35 EGP) |
| discount_amount | decimal(15,2) | NO | | 0.00 | Any applied discount | | discount_amount | decimal(15,2) | NO | | 0.00 | Any applied discount |
| total_amount | decimal(15,2) | NO | | 0.00 | base + dev_fee - discount | | total_amount | decimal(15,2) | NO | | 0.00 | base - discount (dev fee separate) |
| paid_amount | decimal(15,2) | NO | | 0.00 | Amount paid so far | | paid_amount | decimal(15,2) | NO | | 0.00 | When fully paid: total + fine + dev_fee (member row) |
| fine_amount | decimal(15,2) | NO | | 0.00 | Late payment fine | | fine_amount | decimal(15,2) | NO | | 0.00 | Late payment fine |
| status | varchar(50) | NO | MUL | pending | pending/paid/overdue/exempt | | status | varchar(50) | NO | MUL | pending | pending/paid/overdue/exempt |
| paid_at | timestamp | YES | | | When fully paid | | paid_at | timestamp | YES | | | When fully paid |
...@@ -125,28 +126,53 @@ app/Modules/Subscriptions/ ...@@ -125,28 +126,53 @@ app/Modules/Subscriptions/
guarded). Re-running batch generation on the same FY would create duplicate subscription rows for dependents. guarded). Re-running batch generation on the same FY would create duplicate subscription rows for dependents.
Each dependent now checks for an existing row before inserting. Each dependent now checks for an existing row before inserting.
### 5.2 Subscription Payment ### 5.2 Subscription Payment (with FIFO enforcement)
``` ```
1. POST /subscriptions/{id}/pay 1. POST /subscriptions/{id}/pay
2. Calculate remaining: total_amount + fine_amount - paid_amount 2. FIFO CHECK: query for any subscription with older financial_year still pending/overdue
3. Call PaymentService::processPayment() with: - If found: reject with error "يجب سداد اشتراكات السنة الأقدم أولاً"
- financial_year format 'YYYY/YYYY' sorts lexicographically correctly
3. Calculate remaining: total_amount + fine_amount - paid_amount
4. Call PaymentService::processPayment() with:
- payment_type = 'annual_subscription' - payment_type = 'annual_subscription'
- related_entity_type = 'subscriptions' - related_entity_type = 'subscriptions'
4. Update subscription: status='paid', paid_at, payment_id 5. Update subscription: status='paid', paid_at, payment_id
5. Dispatch: subscription.paid event 6. Dispatch: subscription.paid event
``` ```
**Added 2026-06-12:** FIFO enforcement ensures the oldest unpaid year is paid first.
Business rule: "العضوية بتتجدد بكل محتوياتها" — all people in a year must be cleared before newer years.
**Fixed 2026-06-13:** Dev fee (35 EGP, member row only) is now included in paid_amount when marking as paid.
Formula: `paid_amount = total_amount + fine_amount + development_fee`. The `payYear()` method adds dev_fee
to the member row only (not spouse/child rows). View calculates remaining as
`(total_amount + fine + dev_fee) - paid_amount`, so paid rows correctly show 0 remaining.
### 5.2b On-Demand Fine Recalculation (OverdueFineApplicator::applyForMember)
```
1. Called on page load of /members/{id}/subscriptions (not via cron)
2. SubscriptionCalculator::calculateLateFine() computes fines for this member
3. For each year detail (skipping grace period): UPDATE fine_amount + status='overdue'
4. Returns full calculation array (avoids double-calculating)
5. Idempotent: safe to call multiple times (same inputs = same output)
```
**Added 2026-06-12:** Ensures fines are always current when the page is viewed, regardless of cron timing.
### 5.3 Overdue Fine Application (OverdueFineJob — cron, runs from October) ### 5.3 Overdue Fine Application (OverdueFineJob — cron, runs from October)
``` ```
1. Mark all unpaid subscriptions with paid_amount=0 as status='overdue' 1. Mark unpaid subscriptions (financial_year < current FY) as status='overdue'
2. For each member with overdue subscriptions: (respects grace period — current FY subscriptions stay 'pending')
a. SubscriptionCalculator::calculateLateFine() computes progressive fines 2. Delegates to OverdueFineApplicator::run() which:
b. Update fine_amount on each overdue subscription record a. For each active member with past-due subscriptions:
3. Drop members with 5+ consecutive unpaid years: - SubscriptionCalculator::calculateLateFine() computes progressive fines
- Update member status to 'dropped' - distributeFineForYear() proportionally splits fine across subscription rows
- Dispatch: member.dropped event b. Drops members with 5 CONSECUTIVE unpaid years (not just any 5 years)
3. Calls OverdueFineApplicator::expireReinstatements():
- Members dropped > REINSTATEMENT_WINDOW months ago → status='permanently_dropped'
``` ```
### 5.4 Late Fine Calculation Logic (SubscriptionCalculator) ### 5.4 Late Fine Calculation Logic (SubscriptionCalculator)
...@@ -249,7 +275,7 @@ the correct values idempotently via ON DUPLICATE KEY UPDATE. ...@@ -249,7 +275,7 @@ the correct values idempotently via ON DUPLICATE KEY UPDATE.
| SubscriptionGeneratorJob | cron/jobs/SubscriptionGeneratorJob.php | July 1-7 only | Auto-generates subscriptions for new FY | | SubscriptionGeneratorJob | cron/jobs/SubscriptionGeneratorJob.php | July 1-7 only | Auto-generates subscriptions for new FY |
| OverdueFineJob | cron/jobs/OverdueFineJob.php | October onwards (month >= 10) | Marks overdue, applies fines, drops members | | OverdueFineJob | cron/jobs/OverdueFineJob.php | October onwards (month >= 10) | Marks overdue, applies fines, drops members |
**Note:** Both `OverdueFineJob` (cron) and `OverdueFineApplicator` (service class) exist with overlapping logic. The cron job is the active one; the service class provides the same logic callable from controller context. **Note:** `OverdueFineJob` now delegates entirely to `OverdueFineApplicator::run()` for fine/drop logic. The cron job only handles marking subscriptions as overdue; all calculation and distribution is in the service class. No duplicate logic exists.
--- ---
...@@ -300,24 +326,25 @@ Financial year runs July to June: ...@@ -300,24 +326,25 @@ Financial year runs July to June:
### 13.2 Fine Calculation Consistency ### 13.2 Fine Calculation Consistency
- Fine is calculated on `total_amount` (full subscription), not remaining unpaid - Fine is calculated on `total_amount` (full subscription), not remaining unpaid
- Two implementations exist: `OverdueFineJob` (cron) and `OverdueFineApplicator` (service) - Single implementation: `OverdueFineApplicator` (service) used by both cron and controller
- Both use `SubscriptionCalculator::calculateLateFine()` but the cron job has its own fine update logic - Uses `SubscriptionCalculator::calculateLateFine()` then `distributeFineForYear()` for proportional split
- If rules change mid-year, previously applied fines are not retroactively recalculated - If rules change mid-year, previously applied fines are not retroactively recalculated
### 13.3 Member Drop (Destructive) ### 13.3 Member Drop (Destructive)
- Dropping a member for non-payment is **irreversible after reinstatement window** - Dropping a member for non-payment is **irreversible after reinstatement window**
- Drop event triggers accounting write-offs - Drop event triggers accounting write-offs
- No confirmation UI — happens automatically in cron - No confirmation UI — happens automatically in cron
- The 5-year count uses `COUNT(DISTINCT financial_year)` not consecutive check in cron job - Drop check verifies 5 CONSECUTIVE unpaid years (not just count >= 5)
### 13.4 Payment Amount Calculation ### 13.4 Payment Amount Calculation
- `pay()` calculates remaining as: `total_amount + fine_amount - paid_amount` - `pay()` calculates remaining as: `total_amount + fine_amount - paid_amount`
- If fine_amount changes between page load and payment submission, amount may be stale - If fine_amount changes between page load and payment submission, amount may be stale
- No partial payment support — always pays full remaining - No partial payment support — always pays full remaining
### 13.5 Dual Cron Logic ### 13.5 Cron Job Behavior
- `OverdueFineJob` (active cron) and `OverdueFineApplicator` (service class) have similar but not identical logic - `OverdueFineJob` only marks subscriptions overdue when `financial_year < currentFY` (respects grace)
- The cron job marks ALL unpaid as overdue regardless of grace period - All fine logic is in `OverdueFineApplicator` (single source of truth)
- Reinstatement expiry runs every cron cycle
- The service class respects the grace period via `SubscriptionCalculator` - The service class respects the grace period via `SubscriptionCalculator`
- Risk of inconsistent behavior depending on which path executes - Risk of inconsistent behavior depending on which path executes
......
# Waiver Module — Architecture Map # Waiver Module — Architecture Map
> **Last updated:** 2026-06-22 (comprehensive bylaws compliance rewrite) > **Last updated:** 2026-06-26 (Round 2: comprehensive debt check with per-person details, children age/DOB display, payment in target's name, send-to-cashier flow, status indicators)
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -28,7 +28,7 @@ It does **NOT** directly manage: ...@@ -28,7 +28,7 @@ It does **NOT** directly manage:
``` ```
app/Modules/Waiver/ app/Modules/Waiver/
├── bootstrap.php # Minimal — no permissions (reuses Transfers bootstrap) ├── bootstrap.php # Minimal — no permissions (reuses Transfers bootstrap)
├── Routes.php # Web routes (8 routes) ├── Routes.php # Web routes (9 routes, incl. send-to-cashier)
├── Controllers/ ├── Controllers/
│ └── WaiverController.php # Main controller (index, CRUD, approval, payment, completion) │ └── WaiverController.php # Main controller (index, CRUD, approval, payment, completion)
├── Models/ ├── Models/
...@@ -45,7 +45,7 @@ app/Modules/Waiver/ ...@@ -45,7 +45,7 @@ app/Modules/Waiver/
## 3. Database Schema (Production — Source of Truth) ## 3. Database Schema (Production — Source of Truth)
### 3.1 `waiver_requests` Table (18 rows as of 2026-06-22) ### 3.1 `waiver_requests` Table (18 rows as of 2026-06-23)
| Column | Type | Nullable | Key | Default | Notes | | Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------| |--------|------|----------|-----|---------|-------|
...@@ -57,16 +57,35 @@ app/Modules/Waiver/ ...@@ -57,16 +57,35 @@ app/Modules/Waiver/
| waiver_fee_percentage | decimal(5,2) | NO | | 30.00 | Fee percentage (from WAIVER_FEE rule) | | waiver_fee_percentage | decimal(5,2) | NO | | 30.00 | Fee percentage (from WAIVER_FEE rule) |
| waiver_fee_amount | decimal(15,2) | NO | | | Calculated: percentage * membership_value | | waiver_fee_amount | decimal(15,2) | NO | | | Calculated: percentage * membership_value |
| original_dependent_count | int unsigned | NO | | 0 | Total dependents at time of waiver | | original_dependent_count | int unsigned | NO | | 0 | Total dependents at time of waiver |
| new_dependent_count | int unsigned | YES | | | Target member's dependent count (at completion) | | **original_spouses_count** | int unsigned | NO | | 0 | Spouses at time of waiver (per-category) |
| excess_dependent_count | int unsigned | NO | | 0 | How many dependents exceed the original count | | **original_children_count** | int unsigned | NO | | 0 | Children at time of waiver (per-category) |
| excess_fee_percentage | decimal(5,2) | YES | | NULL | Board-determined % for excess dependents | | **original_temporary_count** | int unsigned | NO | | 0 | Temp members at time of waiver (per-category) |
| excess_fee_amount | decimal(15,2) | NO | | 0.00 | = excess_fee_pct × membership_value × excess_count | | new_dependent_count | int unsigned | YES | | | Target member's total dependent count (at completion) |
| **new_spouses_count** | int unsigned | YES | | | Target spouses at completion |
| **new_children_count** | int unsigned | YES | | | Target children at completion |
| **new_temporary_count** | int unsigned | YES | | | Target temp members at completion |
| excess_dependent_count | int unsigned | NO | | 0 | Total excess dependents |
| **excess_spouses_count** | int unsigned | NO | | 0 | Excess spouses (target - original) |
| **excess_children_count** | int unsigned | NO | | 0 | Excess children (target - original) |
| **excess_temporary_count** | int unsigned | NO | | 0 | Excess temp members (target - original) |
| excess_fee_percentage | decimal(5,2) | YES | | NULL | Legacy: single % (superseded by per-category) |
| excess_fee_amount | decimal(15,2) | NO | | 0.00 | Total excess fee (sum of per-category) |
| **spouse_fee_type** | enum('fixed','percentage') | YES | | NULL | Board-set: fixed amount or % of value |
| **spouse_fee_rate** | decimal(15,2) | YES | | NULL | Rate per excess spouse |
| **spouse_fee_total** | decimal(15,2) | NO | | 0.00 | = rate × excess_spouses_count |
| **child_fee_type** | enum('fixed','percentage') | YES | | NULL | Board-set: fixed amount or % of value |
| **child_fee_rate** | decimal(15,2) | YES | | NULL | Rate per excess child |
| **child_fee_total** | decimal(15,2) | NO | | 0.00 | = rate × excess_children_count |
| **temporary_fee_type** | enum('fixed','percentage') | YES | | NULL | Board-set: fixed amount or % of value |
| **temporary_fee_rate** | decimal(15,2) | YES | | NULL | Rate per excess temp member |
| **temporary_fee_total** | decimal(15,2) | NO | | 0.00 | = rate × excess_temporary_count |
| board_approval_required | tinyint(1) | NO | | 1 | Always requires board approval | | board_approval_required | tinyint(1) | NO | | 1 | Always requires board approval |
| board_decision_reference | varchar(100) | YES | | | Board decision document reference | | board_decision_reference | varchar(100) | YES | | | Board decision document reference |
| approved_by | bigint unsigned | YES | | | FK to employees | | approved_by | bigint unsigned | YES | | | FK to employees |
| approved_at | timestamp | YES | | | | | approved_at | timestamp | YES | | | |
| annual_renewal_paid | tinyint(1) | NO | | 0 | Flag: annual renewal has been paid | | annual_renewal_paid | tinyint(1) | NO | | 0 | Flag: annual renewal has been paid |
| debts_cleared | tinyint(1) | NO | | 0 | Flag: all financial debts verified clear | | debts_cleared | tinyint(1) | NO | | 0 | Flag: source member debts verified clear |
| **target_debts_cleared** | tinyint(1) | NO | | 0 | Flag: target member debts verified clear |
| waiver_request_doc_path | varchar(500) | YES | | NULL | Uploaded waiver request form document | | waiver_request_doc_path | varchar(500) | YES | | NULL | Uploaded waiver request form document |
| target_form_doc_path | varchar(500) | YES | | NULL | Uploaded target member form document | | target_form_doc_path | varchar(500) | YES | | NULL | Uploaded target member form document |
| archive_snapshot_id | bigint unsigned | YES | MUL | | FK to archive_snapshots | | archive_snapshot_id | bigint unsigned | YES | MUL | | FK to archive_snapshots |
...@@ -81,6 +100,8 @@ app/Modules/Waiver/ ...@@ -81,6 +100,8 @@ app/Modules/Waiver/
**Current data distribution (status):** **Current data distribution (status):**
- completed: 2, requested: 7, approved: 1, fee_paid: 8 - completed: 2, requested: 7, approved: 1, fee_paid: 8
**Migration:** Phase_94_003_alter_waiver_requests_add_per_category_fees (batch 62)
--- ---
## 4. Waiver Status Lifecycle ## 4. Waiver Status Lifecycle
...@@ -166,7 +187,7 @@ requested → approved → fee_paid → completed ...@@ -166,7 +187,7 @@ requested → approved → fee_paid → completed
- All historical data preserved (no hard deletes) - All historical data preserved (no hard deletes)
``` ```
### 5.2 Fee Calculation ### 5.2 Fee Calculation (Per-Category — 2026-06-23)
``` ```
BASIC Waiver Fee = WAIVER_FEE percentage × CURRENT membership value (from pricing_configs) BASIC Waiver Fee = WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)
...@@ -174,34 +195,63 @@ BASIC Waiver Fee = WAIVER_FEE percentage × CURRENT membership value (from prici ...@@ -174,34 +195,63 @@ BASIC Waiver Fee = WAIVER_FEE percentage × CURRENT membership value (from prici
Default: 30% × current_price_for(branch_id, qualification_id, membership_type) Default: 30% × current_price_for(branch_id, qualification_id, membership_type)
Example: 30% × 150,000 = 45,000 EGP Example: 30% × 150,000 = 45,000 EGP
EXCESS Dependent Fee (if target has more dependents than source): Falls back to stored membership_value only if no matching pricing_configs row exists.
excess_count = target_dependents - original_dependent_count
excess_fee = (board_percentage / 100) × membership_value × excess_count EXCESS Dependent Fee (PER-CATEGORY — independent comparison for each):
Example: 2 excess × 30% × 150,000 = 90,000 EGP Comparison is done independently for each category:
- excess_spouses = max(0, target_spouses - original_spouses)
- excess_children = max(0, target_children - original_children)
- excess_temporary = max(0, target_temporary - original_temporary)
TOTAL = waiver_fee + excess_fee Each category can have a different fee type and rate (set by Board of Trustees):
Example: 45,000 + 90,000 = 135,000 EGP - Type: 'fixed' = flat amount per excess dependent
- Type: 'percentage' = % of membership_value per excess dependent
Per-Category Fee Formulas:
- spouse_fee_total = excess_spouses × (spouse_fee_rate if fixed, OR spouse_fee_rate% × membership_value)
- child_fee_total = excess_children × (child_fee_rate if fixed, OR child_fee_rate% × membership_value)
- temporary_fee_total = excess_temporary × (temporary_fee_rate if fixed, OR temporary_fee_rate% × membership_value)
Example (mixed fee types):
- 1 excess spouse × 30% × 150,000 = 45,000 EGP (percentage)
- 2 excess children × 10,000 = 20,000 EGP (fixed)
- 1 excess temp × 25% × 150,000 = 37,500 EGP (percentage)
- Total excess: 102,500 EGP
TOTAL = waiver_fee + spouse_fee_total + child_fee_total + temporary_fee_total
Example: 45,000 + 45,000 + 20,000 + 37,500 = 147,500 EGP
Falls back to stored membership_value only if no matching pricing_configs row exists.
No additional form fee or annual subscription (unlike Transfers/Death/Divorce). No additional form fee or annual subscription (unlike Transfers/Death/Divorce).
``` ```
### 5.3 Eligibility & Validation (Bylaws) ### 5.3 Eligibility & Validation (Bylaws — Updated 2026-06-23)
``` ```
PRE-REQUISITES (enforced in create + store): PRE-REQUISITES (enforced in create + store):
1. Member must exist and not be archived 1. Member must exist and not be archived
2. Member must have a membership_number 2. Member must have a membership_number
3. ALL financial debts must be clear: 3. ALL financial debts on SOURCE member must be clear:
- subscriptions (status: pending/overdue) = 0 - subscriptions (status: pending/overdue) = 0
- fines (status: pending) = 0 - fines (status: pending) = 0
- installments (schedule: pending/overdue in active/overdue plans) = 0 ← NEW
- payment_requests (status: pending, not voided) = 0 - payment_requests (status: pending, not voided) = 0
4. Board approval always required 4. ALL financial debts on TARGET member must be clear (if target specified at creation) ← NEW
5. Board approval always required
DEPENDENT RULES (enforced in approve + complete):
- Scenario 1: target dependents ≤ original → OK, no extra fees BLOCKING RULES (enforced at complete — dual debt check):
- Scenario 2: target dependents > original → board sets excess_fee_percentage 1. Source member ALL debts = 0 (subscriptions + fines + installments + payment_requests)
- fee = percentage × membership_value × excess_count per dependent 2. Target member ALL debts = 0 (same checks) ← NEW
3. Per-category dependent excess fees must be set by board (if excess exists)
4. Board approval must exist (approved_by set)
5. Status must be 'approved' or 'fee_paid'
DEPENDENT RULES (PER-CATEGORY — enforced in approve + complete):
- Each category compared independently (NOT total only):
- Scenario: target_spouses > original_spouses → excess spouses → board sets fee
- Scenario: target_children > original_children → excess children → board sets fee
- Scenario: target_temporary > original_temporary → excess temps → board sets fee
- Board sets fee_type (fixed/percentage) and fee_rate for EACH category independently
- If any category has excess without a board-approved fee → operation BLOCKED
REQUIRED DOCUMENTS: REQUIRED DOCUMENTS:
- waiver_request_doc: waiver request form (PDF/JPG/PNG) - waiver_request_doc: waiver request form (PDF/JPG/PNG)
...@@ -306,11 +356,12 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**: ...@@ -306,11 +356,12 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**:
- WaiverProcessor accepts BOTH 'approved' and 'fee_paid' for completion - WaiverProcessor accepts BOTH 'approved' and 'fee_paid' for completion
- This means: payment can be collected even if board eventually rejects (refund needed) - This means: payment can be collected even if board eventually rejects (refund needed)
### 11.6 ~~No Unpaid Subscription Check at Completion~~ FIXED (2026-06-22) ### 11.6 ~~No Unpaid Subscription Check at Completion~~ FIXED (2026-06-22, enhanced 2026-06-23)
- **NOW ENFORCED**: WaiverProcessor::checkDebts() is called at create, store, AND complete - **NOW ENFORCED**: WaiverProcessor::checkDebts() is called at create, store, AND complete
- Checks ALL financial obligations: subscriptions, fines, AND payment_requests - Checks ALL financial obligations: subscriptions, fines, installments, AND payment_requests
- Form is blocked (disabled) if any debts exist — cannot even submit the request - **DUAL CHECK (2026-06-23)**: Both source AND target member debts are validated
- Complete also re-validates debts in case new ones arose between request and completion - Form is blocked (disabled) if any debts exist on either party — cannot even submit the request
- Complete also re-validates debts on BOTH members in case new ones arose between request and completion
--- ---
...@@ -324,13 +375,21 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**: ...@@ -324,13 +375,21 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**:
6. **30% default fee**: Configurable via WAIVER_FEE rule 6. **30% default fee**: Configurable via WAIVER_FEE rule
7. **No form fee or annual sub**: Simpler fee structure than Transfers/Death/Divorce 7. **No form fee or annual sub**: Simpler fee structure than Transfers/Death/Divorce
8. **Payment sent immediately**: Cashier queue payment is created at request time (before approval) 8. **Payment sent immediately**: Cashier queue payment is created at request time (before approval)
9. **Debt gate**: Form is BLOCKED if any debts exist — verified at create, store, and complete 9. **Debt gate (DUAL)**: Form is BLOCKED if any debts exist on source OR target — verified at create, store, and complete
10. **Excess fee board-determined**: Board enters % during approval; formula is pct × value × excess_count 10. **Per-category excess fees (2026-06-23)**: Board enters fee_type (fixed/%) + fee_rate for EACH category (spouses, children, temporary) independently — NOT a single % for all
11. **Document uploads**: Stored in `uploads/waivers/YYYY/MM/` — PDF, JPG, PNG up to 5MB 11. **Document uploads**: Stored in `uploads/waivers/YYYY/MM/` — PDF, JPG, PNG up to 5MB
12. **Two-phase dependent validation**: at approval (board sets fee) + at completion (final check) 12. **Two-phase dependent validation**: at approval (board sets per-category fees) + at completion (final check with blocking)
10. **board_approval_required always 1**: Column exists but is hardcoded to always require board 13. **board_approval_required always 1**: Column exists but is hardcoded to always require board
11. **No soft delete**: WaiverRequest has no is_archived column 14. **No soft delete**: WaiverRequest has no is_archived column
12. **Dual payment paths**: Cashier queue on creation + direct /pay endpoint 15. **Dual payment paths**: Cashier queue on creation + direct /pay endpoint
16. **Installments in debt check (2026-06-23)**: checkDebts() now includes unpaid installment_schedule rows from active/overdue plans
17. **Per-category comparison**: Comparison is INDEPENDENT per category (spouses vs spouses, children vs children, etc.) — NOT total only
18. **Comprehensive debt check (2026-06-26)**: `checkDebtsComprehensive()` returns per-person breakdown: person_name, person_type, person_label, debt_type, period, amount — shows exact who owes what
19. **Children details display (2026-06-26)**: `getDependentDetails()` calculates age_years, age_display, age_category (under_12/12_to_16/16_to_18/18_plus) for each child from date_of_birth
20. **Payment in TARGET's name (2026-06-26)**: `sendToCashier` creates PaymentRequest with target_member_id (buyer pays), includes full receipt breakdown
21. **Send-to-cashier route (2026-06-26)**: New `POST /waivers/{id}/send-to-cashier` replaces old immediate payment. Requires status=approved + target set
22. **Status indicators (2026-06-26)**: fee_paid status shows "🟢 جاهز لاعتماد طلب التنازل" with explicit "all conditions met" message
23. **Go to payment button (2026-06-26)**: Debt display includes direct link to member's financial page for quick payment
--- ---
......
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