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
This diff is collapsed.
...@@ -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],
]; ];
...@@ -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'],
......
This diff is collapsed.
This diff is collapsed.
...@@ -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
......
This diff is collapsed.
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