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
], $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([
'success' => false,
'data' => null,
'meta' => null,
'errors' => [
'code' => $code,
'message' => $message,
'details' => $details ?: null,
],
......
......@@ -14,6 +14,7 @@ final class App
private ?object $currentEmployee = null;
private ?object $currentPlayer = null;
private ?object $currentCoach = null;
private ?object $currentParent = null;
private ?array $currentBranch = null;
private array $bindings = [];
private array $factories = [];
......@@ -384,6 +385,16 @@ final class App
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
{
$this->currentBranch = $branch;
......
......@@ -108,6 +108,7 @@ final class Router
'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'player_auth' => \App\Middleware\PlayerApiAuthMiddleware::class,
'coach_auth' => \App\Middleware\CoachApiAuthMiddleware::class,
'parent_auth' => \App\Middleware\ParentApiAuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class,
......
......@@ -6,6 +6,7 @@ namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\ApiErrorCodes;
final class CoachApiAuthMiddleware
{
......@@ -16,8 +17,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Authentication required'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_REQUIRED, 'message' => 'Authentication required'],
], 401);
}
......@@ -31,8 +32,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Invalid token'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_INVALID, 'message' => 'Invalid token'],
], 401);
}
......@@ -40,8 +41,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Token expired'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_EXPIRED, 'message' => 'Token expired'],
], 401);
}
......@@ -54,8 +55,8 @@ final class CoachApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Account inactive'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_INACTIVE, 'message' => 'Account inactive'],
], 401);
}
......
......@@ -6,6 +6,7 @@ namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\ApiErrorCodes;
final class PlayerApiAuthMiddleware
{
......@@ -16,8 +17,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Authentication required'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_REQUIRED, 'message' => 'Authentication required'],
], 401);
}
......@@ -31,8 +32,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Invalid token'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_INVALID, 'message' => 'Invalid token'],
], 401);
}
......@@ -40,8 +41,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Token expired'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_EXPIRED, 'message' => 'Token expired'],
], 401);
}
......@@ -54,8 +55,8 @@ final class PlayerApiAuthMiddleware
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Account inactive'],
'meta' => null,
'errors' => ['code' => ApiErrorCodes::AUTH_INACTIVE, 'message' => 'Account inactive'],
], 401);
}
......
......@@ -7,6 +7,7 @@ use App\Core\Middleware\MiddlewareInterface;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\ApiErrorCodes;
final class RateLimitMiddleware implements MiddlewareInterface
{
......@@ -16,22 +17,46 @@ final class RateLimitMiddleware implements MiddlewareInterface
public function handle(Request $request, callable $next): Response
{
$ip = $request->ip();
$key = 'rate_limit_' . md5($ip . $request->path());
$routeKey = md5($ip . ':' . $request->method() . ':' . $request->path());
$session = App::getInstance()->session();
$data = $session->get($key, ['count' => 0, 'reset_at' => time() + $this->windowSeconds]);
$db = App::getInstance()->db();
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']) {
$data = ['count' => 0, 'reset_at' => time() + $this->windowSeconds];
if (!$row || $row['window_start'] < $windowStart) {
$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']++;
$session->set($key, $data);
if ((int) $row['hits'] >= $this->maxRequests) {
$retryAfter = $this->windowSeconds - (time() - strtotime($row['window_start']));
if ($data['count'] > $this->maxRequests) {
if ($request->isAjax() || $request->isJson()) {
return (new Response())->json(['error' => 'تم تجاوز الحد الأقصى للطلبات. حاول لاحقاً.'], 429);
if ($request->isAjax() || $request->isJson() || $request->bearerToken()) {
return (new Response())->json([
'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(
'<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>'
......@@ -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);
}
}
......@@ -47,4 +47,23 @@ return [
// Profile
['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],
['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);
namespace App\Modules\PlayerApi\Services;
use App\Core\App;
use App\Shared\Services\PushNotificationService;
final class PlayerNotificationService
{
......@@ -17,6 +18,8 @@ final class PlayerNotificationService
'body_ar' => $body,
'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
......
......@@ -24,4 +24,26 @@ return [
// Replacement
['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],
// 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
protected static array $fillable = [
'source_member_id', 'target_member_id', 'membership_number',
'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',
'approved_by', 'approved_at', 'annual_renewal_paid',
'debts_cleared', 'target_debts_cleared',
'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
......
......@@ -7,6 +7,7 @@ return [
['POST', '/waivers/store/{memberId}', 'Waiver\Controllers\WaiverController@store', ['auth', 'csrf'], 'waiver.initiate'],
['GET', '/waivers/{id}', 'Waiver\Controllers\WaiverController@show', ['auth'], 'waiver.view'],
['POST', '/waivers/{id}/pay', 'Waiver\Controllers\WaiverController@pay', ['auth', '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}/reject', 'Waiver\Controllers\WaiverController@reject', ['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 [
'token' => [
'ttl_hours' => (int) env('API_TOKEN_TTL_HOURS', 720),
'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' => [
......
<?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 = ?
| Tables shared across modules | 15+ |
| Direct member-status writers (bypassing Members) | 6 |
| 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
> **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
---
......@@ -9,7 +9,8 @@
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
- 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)
- Subscription exemptions (with reason tracking)
- Membership drop enforcement (5+ consecutive unpaid years)
......@@ -37,11 +38,11 @@ app/Modules/Subscriptions/
├── Services/
│ ├── SubscriptionGenerator.php # Batch generation for a financial year
│ ├── 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/
├── index.php # Paginated list with filters (year, status, person_type)
├── 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/
| 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) |
| discount_amount | decimal(15,2) | NO | | 0.00 | Any applied discount |
| total_amount | decimal(15,2) | NO | | 0.00 | base + dev_fee - discount |
| paid_amount | decimal(15,2) | NO | | 0.00 | Amount paid so far |
| total_amount | decimal(15,2) | NO | | 0.00 | base - discount (dev fee separate) |
| 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 |
| status | varchar(50) | NO | MUL | pending | pending/paid/overdue/exempt |
| paid_at | timestamp | YES | | | When fully paid |
......@@ -125,28 +126,53 @@ app/Modules/Subscriptions/
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.
### 5.2 Subscription Payment
### 5.2 Subscription Payment (with FIFO enforcement)
```
1. POST /subscriptions/{id}/pay
2. Calculate remaining: total_amount + fine_amount - paid_amount
3. Call PaymentService::processPayment() with:
2. FIFO CHECK: query for any subscription with older financial_year still pending/overdue
- 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'
- related_entity_type = 'subscriptions'
4. Update subscription: status='paid', paid_at, payment_id
5. Dispatch: subscription.paid event
5. Update subscription: status='paid', paid_at, payment_id
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)
```
1. Mark all unpaid subscriptions with paid_amount=0 as status='overdue'
2. For each member with overdue subscriptions:
a. SubscriptionCalculator::calculateLateFine() computes progressive fines
b. Update fine_amount on each overdue subscription record
3. Drop members with 5+ consecutive unpaid years:
- Update member status to 'dropped'
- Dispatch: member.dropped event
1. Mark unpaid subscriptions (financial_year < current FY) as status='overdue'
(respects grace period — current FY subscriptions stay 'pending')
2. Delegates to OverdueFineApplicator::run() which:
a. For each active member with past-due subscriptions:
- SubscriptionCalculator::calculateLateFine() computes progressive fines
- distributeFineForYear() proportionally splits fine across subscription rows
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)
......@@ -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 |
| 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:
### 13.2 Fine Calculation Consistency
- Fine is calculated on `total_amount` (full subscription), not remaining unpaid
- Two implementations exist: `OverdueFineJob` (cron) and `OverdueFineApplicator` (service)
- Both use `SubscriptionCalculator::calculateLateFine()` but the cron job has its own fine update logic
- Single implementation: `OverdueFineApplicator` (service) used by both cron and controller
- Uses `SubscriptionCalculator::calculateLateFine()` then `distributeFineForYear()` for proportional split
- If rules change mid-year, previously applied fines are not retroactively recalculated
### 13.3 Member Drop (Destructive)
- Dropping a member for non-payment is **irreversible after reinstatement window**
- Drop event triggers accounting write-offs
- 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
- `pay()` calculates remaining as: `total_amount + fine_amount - paid_amount`
- If fine_amount changes between page load and payment submission, amount may be stale
- No partial payment support — always pays full remaining
### 13.5 Dual Cron Logic
- `OverdueFineJob` (active cron) and `OverdueFineApplicator` (service class) have similar but not identical logic
- The cron job marks ALL unpaid as overdue regardless of grace period
### 13.5 Cron Job Behavior
- `OverdueFineJob` only marks subscriptions overdue when `financial_year < currentFY` (respects grace)
- 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`
- 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