Commit 0dd4abfc authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix membership fee calculations, subscription escalation, and over-25 children freeze

- TemporaryFeeCalculator: resolve current pricing_configs price instead of stored
  membership_value, matching SpouseFeeCalculator and ChildFeeCalculator behaviour
- SubscriptionGenerator: add per-dependent dedup guards (spouse/child/temporary)
  to prevent duplicate subscription rows on repeated batch runs
- Phase_89_001 migration: idempotent fix for subscription late-fine escalation rules
  (10/50/100/200/300% over 5 years, correct from the broken seed 100/200/300%)
- Members show view: add warning banner listing active male children aged 25+ with
  direct freeze button, surfacing the existing freeze route that was never linked
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 110d7771
......@@ -820,6 +820,38 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Children -->
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f476; الأبناء (<?= count($children) ?>)</div>
<?php
$childrenOver25Male = array_filter($children, function($c) {
return ($c['gender'] ?? '') === 'male'
&& ($c['status'] ?? '') === 'active'
&& (int) ($c['age_years'] ?? 0) >= 25;
});
?>
<?php if (!empty($childrenOver25Male)): ?>
<div style="margin-bottom:12px;padding:12px 15px;background:#FFF7ED;border:2px solid #F59E0B;border-radius:8px;">
<div style="font-weight:700;color:#D97706;margin-bottom:8px;font-size:13px;">&#x26a0;&#xfe0f; أبناء ذكور بلغوا 25 سنة — يجب تجميد عضوياتهم</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<?php foreach ($childrenOver25Male as $c25): ?>
<div style="display:flex;align-items:center;justify-content:space-between;background:#fff;border:1px solid #FDE68A;border-radius:6px;padding:8px 12px;">
<span style="font-size:13px;color:#374151;">
<strong><?= e($c25['full_name_ar']) ?></strong>
— عمره <strong style="color:#DC2626;"><?= (int) $c25['age_years'] ?> سنة</strong>
</span>
<?php if (can('child.freeze')): ?>
<form method="POST" action="/members/<?= (int) $member->id ?>/children/<?= (int) $c25['id'] ?>/freeze" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="freeze_reason" value="بلوغ سن 25 — تجميد تلقائي">
<button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;border-color:#D97706;font-size:12px;"
onclick="return confirm('تجميد عضوية <?= e($c25['full_name_ar']) ?>؟')">
&#x1f9ca; تجميد العضوية
</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($children as $cIdx => $c): ?>
<?php
......
......@@ -82,6 +82,11 @@ final class SubscriptionGenerator
if ($db->tableExists('spouses')) {
$spouses = $db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$memberId]);
foreach ($spouses as $sp) {
$existingSp = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'spouse' AND person_id = ?",
[$memberId, $financialYear, (int) $sp['id']]
);
if ($existingSp) { $skipped++; continue; }
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -104,6 +109,11 @@ final class SubscriptionGenerator
if ($db->tableExists('children')) {
$children = $db->select("SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$memberId]);
foreach ($children as $ch) {
$existingCh = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'child' AND person_id = ?",
[$memberId, $financialYear, (int) $ch['id']]
);
if ($existingCh) { $skipped++; continue; }
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -126,6 +136,11 @@ final class SubscriptionGenerator
if ($db->tableExists('temporary_members')) {
$temps = $db->select("SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$memberId]);
foreach ($temps as $t) {
$existingTmp = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'temporary' AND person_id = ?",
[$memberId, $financialYear, (int) $t['id']]
);
if ($existingTmp) { $skipped++; continue; }
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......
......@@ -19,6 +19,26 @@ final class TemporaryFeeCalculator
$membershipValue = $member['membership_value'] ?? '0.00';
$isOnInitialForm = FormFeeService::isOnInitialForm($member);
// Always use current pricing_configs price (same logic as SpouseFeeCalculator / ChildFeeCalculator)
$qualId = !empty($member['qualification_id']) ? (int) $member['qualification_id'] : null;
$branchId = (int) ($member['branch_id'] ?? 1);
$mType = $member['membership_type'] ?? 'working';
if ($qualId) {
$currentPricing = $db->selectOne(
"SELECT price FROM pricing_configs WHERE branch_id = ? AND qualification_id = ? AND membership_type = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE()) ORDER BY effective_from DESC LIMIT 1",
[$branchId, $qualId, $mType]
);
} else {
$currentPricing = $db->selectOne(
"SELECT price FROM pricing_configs WHERE branch_id = ? AND membership_type = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE()) ORDER BY price ASC LIMIT 1",
[$branchId, $mType]
);
}
if ($currentPricing && bccomp($currentPricing['price'], '0.01', 2) >= 0) {
$membershipValue = $currentPricing['price'];
}
if (bccomp($membershipValue, '0.00', 2) <= 0 && !$isOnInitialForm) {
return ['fee' => '0.00', 'error' => 'قيمة العضوية غير محددة'];
}
......
<?php
declare(strict_types=1);
/**
* Idempotent migration: ensures subscription late-fine escalation rules exist
* with the correct percentages (10/50/100/200/300) and that the grace period
* rules exist. Safe to run multiple times — uses ON DUPLICATE KEY UPDATE.
*
* Corrects a data issue where the original seed set YEAR_1=100%, YEAR_2=200%,
* YEAR_3=300% (no YEAR_4/YEAR_5), whereas the correct business rule is:
* Year 1 after grace: 10%
* Year 2: 50%
* Year 3: 100%
* Year 4: 200%
* Year 5: 300%
*/
return [
'up' => "
INSERT INTO business_rules
(rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, effective_from, created_at, updated_at)
VALUES
('LATE_SUB_FINE_YEAR_1', 'penalty', 'غرامة تأخير — بعد فترة السماح (10%)', 'Late Fine Year 1 — After Grace (10%)', 'percentage', '{\"percentage_of_subscription\":\"10.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_2', 'penalty', 'غرامة تأخير — سنة متأخرة (50%)', 'Late Fine Year 2 — 1 Year Overdue (50%)', 'percentage', '{\"percentage_of_subscription\":\"50.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_3', 'penalty', 'غرامة تأخير — سنتان متأخرة (100%)', 'Late Fine Year 3 — 2 Years Overdue (100%)', 'percentage', '{\"percentage_of_subscription\":\"100.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_4', 'penalty', 'غرامة تأخير — 3 سنوات متأخرة (200%)', 'Late Fine Year 4 — 3 Years Overdue (200%)', 'percentage', '{\"percentage_of_subscription\":\"200.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_5', 'penalty', 'غرامة تأخير — 4 سنوات متأخرة (300%)', 'Late Fine Year 5 — 4 Years Overdue (300%)', 'percentage', '{\"percentage_of_subscription\":\"300.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('SUB_GRACE_MONTHS', 'penalty', 'فترة السماح للاشتراك السنوي (شهور)', 'Subscription Grace Period (months)', 'integer', '{\"months\":3}', '{\"months\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('SUB_EXTENDED_GRACE_MONTHS', 'penalty', 'فترة السماح الممتدة بقرار مجلس الأمناء', 'Extended Grace Period (Trustees Council)', 'integer', '{\"months\":4}', '{\"months\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_MAX_YEARS', 'penalty', 'الحد الأقصى لسنوات الغرامة', 'Max Late Fine Years', 'integer', '{\"years\":5}', '{\"years\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_DROP_YEARS', 'penalty', 'سنوات التأخر قبل إسقاط العضوية', 'Years Before Membership Drop', 'integer', '{\"years\":5}', '{\"years\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('REINSTATEMENT_WINDOW', 'penalty', 'نافذة إعادة العضوية المسقطة (شهور)', 'Reinstatement Window (months)', 'integer', '{\"months\":12}', '{\"months\":\"integer\"}', 1, '2020-01-01', NOW(), NOW())
ON DUPLICATE KEY UPDATE
current_value_json = VALUES(current_value_json),
name_ar = VALUES(name_ar),
name_en = VALUES(name_en),
updated_at = NOW()
",
'down' => "
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"100.00\"}', name_ar = 'غرامة تأخير سنة أولى' WHERE rule_code = 'LATE_SUB_FINE_YEAR_1';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"200.00\"}', name_ar = 'غرامة تأخير سنة ثانية' WHERE rule_code = 'LATE_SUB_FINE_YEAR_2';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"300.00\"}', name_ar = 'غرامة تأخير سنة ثالثة' WHERE rule_code = 'LATE_SUB_FINE_YEAR_3';
DELETE FROM business_rules WHERE rule_code IN ('LATE_SUB_FINE_YEAR_4', 'LATE_SUB_FINE_YEAR_5', 'SUB_GRACE_MONTHS', 'SUB_EXTENDED_GRACE_MONTHS', 'LATE_SUB_FINE_MAX_YEARS', 'LATE_SUB_DROP_YEARS', 'REINSTATEMENT_WINDOW')
",
];
# Subscriptions Module — Architecture Map
> **Last updated:** 2026-06-10 (updated)
> **Status:** Living document — incrementally updated as new information is discovered
---
## 1. Purpose & Responsibilities
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
- Overdue detection and late fine calculation (progressive penalties)
- Subscription exemptions (with reason tracking)
- Membership drop enforcement (5+ consecutive unpaid years)
- Reinstatement eligibility checking
- Financial year-based tracking (July to June cycle)
It does **NOT** directly manage:
- Payment processing (delegates to Payments module's PaymentService)
- Member status changes beyond dropping for non-payment
- Activity-specific subscriptions (handled by SportsActivity/ActivitySubscriptions modules)
- Installment plans for subscription debts (Installments module)
---
## 2. Directory & File Structure
```
app/Modules/Subscriptions/
├── bootstrap.php # Permission registration only (menu via Members)
├── Routes.php # 6 web routes
├── Controllers/
│ └── SubscriptionController.php # All CRUD + batch operations
├── Models/
│ └── Subscription.php # Query model (no soft delete, with events)
├── 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
└── 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
```
---
## 3. Database Schema (Production — Source of Truth)
### 3.1 `subscriptions` Table (254 rows as of 2026-06-10)
| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| member_id | bigint unsigned | NO | MUL | | FK to members |
| financial_year | varchar(10) | NO | MUL | | Format: "2025/2026" |
| person_type | varchar(50) | NO | MUL | | member/spouse/child/temporary |
| person_id | bigint unsigned | YES | | | FK to respective person table |
| person_name | varchar(200) | YES | | | Denormalized name for display |
| 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 |
| 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 |
| payment_id | bigint unsigned | YES | MUL | | FK to payments |
| exempted_by | bigint unsigned | YES | | | FK to employees |
| exemption_reason | text | YES | | | Required for exemptions |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | CURRENT_TIMESTAMP | on update |
| created_by | bigint unsigned | YES | | | FK to employees |
| updated_by | bigint unsigned | YES | | | FK to employees |
**Current data distribution (status):**
- pending: 201
- paid: 39
- overdue: 14
**Financial years with data:** 2020/2021 through 2026/2027 (7 years)
### 3.2 Key Indexes
- `member_id` — frequent lookups per member
- `financial_year` — filter by year
- `person_type` — filter by dependent type
- `status` — filter active/overdue
- `payment_id` — link to payment record
---
## 4. Routes
| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | /subscriptions | index | auth | subscription.view |
| GET | /subscriptions/batch-generate | batchGenerateForm | auth | subscription.generate_batch |
| POST | /subscriptions/batch-generate | batchGenerate | auth, csrf | subscription.generate_batch |
| GET | /members/{memberId}/subscriptions | memberSubscriptions | auth | subscription.view |
| POST | /subscriptions/{id}/pay | pay | auth, csrf | subscription.collect |
| POST | /subscriptions/{id}/exempt | exempt | auth, csrf | subscription.exempt |
---
## 5. Core Business Flows
### 5.1 Annual Batch Generation (SubscriptionGeneratorJob / SubscriptionGenerator)
```
1. Triggered: Cron (July 1-7 auto) OR manual via /subscriptions/batch-generate
2. For each active member (status='active', not honorary):
a. Skip if subscription already exists for this member + FY (person_type='member') — dedup guard
b. Create member subscription: base_amount + dev_fee (35 EGP)
c. For each active spouse: skip if (member+FY+person_type='spouse'+person_id) already exists, then insert
d. For each active child: skip if (member+FY+person_type='child'+person_id) already exists, then insert
e. For each active temp member: skip if (member+FY+person_type='temporary'+person_id) already exists, then insert
3. Rates loaded from service_catalog (year-specific if available, fallback to generic)
4. Year-specific discount/increase from SUBSCRIPTION_YEAR_ADJUSTMENT_{year} rule
```
**Fix applied 2026-06-10:** Steps c/d/e previously had no dedup guard (only the member row in step a was
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
```
1. POST /subscriptions/{id}/pay
2. Calculate remaining: total_amount + fine_amount - paid_amount
3. 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.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
```
### 5.4 Late Fine Calculation Logic (SubscriptionCalculator)
```
- Grace period: first 3 months of FY (July-September) = no fine (SUB_GRACE_MONTHS rule, default 3)
Extended grace: 4 months if trustees council approves (SUB_EXTENDED_GRACE_MONTHS rule, default 4)
- Fine is % of TOTAL subscription amount (not remaining), keyed by years_overdue + 1:
- years_overdue = 0 (current FY, after grace): LATE_SUB_FINE_YEAR_1 = 10%
- years_overdue = 1 (1 full year late): LATE_SUB_FINE_YEAR_2 = 50%
- years_overdue = 2 (2 full years late): LATE_SUB_FINE_YEAR_3 = 100%
- years_overdue = 3 (3 full years late): LATE_SUB_FINE_YEAR_4 = 200%
- years_overdue = 4 (4 full years late): LATE_SUB_FINE_YEAR_5 = 300%
- capped at LATE_SUB_FINE_MAX_YEARS (default 5)
- After LATE_SUB_DROP_YEARS (default 5) consecutive years overdue: member is dropped
```
**Data fix required — Phase_89_001 migration:** The original seed (Phase_05_001) set
YEAR_1=100%, YEAR_2=200%, YEAR_3=300% with no YEAR_4/YEAR_5. The correct escalation
is 10/50/100/200/300%. Run `php cli.php migrate` to apply Phase_89_001 which sets
the correct values idempotently via ON DUPLICATE KEY UPDATE.
### 5.5 Reinstatement Check (SubscriptionCalculator::canReinstate)
```
- Only for members with status='dropped'
- Window: REINSTATEMENT_WINDOW months (default: 12) from drop date
- Returns: eligibility, total debt (subscriptions + fines), days remaining
```
### 5.6 Permanent Drop (OverdueFineApplicator::expireReinstatements)
```
- Members who remain 'dropped' past the reinstatement window
- Status changes to 'permanently_dropped'
- Dispatches: member.permanently_dropped event
```
---
## 6. Events Dispatched
| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `subscription.paid` | `{subscription_id, member_id, year, amount}` | SubscriptionController::pay |
| `member.dropped` | `{member_id, reason, years_unpaid}` | OverdueFineApplicator, OverdueFineJob |
| `member.permanently_dropped` | `{member_id, reason}` | OverdueFineApplicator::expireReinstatements |
---
## 7. Events Consumed (by other modules)
| Event | Listener Module | Action |
|-------|----------------|--------|
| `subscription.paid` | Accounting | Auto-post journal entry (revenue recognition) |
| `subscription.paid` | Accounting (StatementIntegrationService) | Update member statement |
| `member.dropped` | Accounting | Write off AR for membership |
---
## 8. Cross-Module Dependencies
### 8.1 Subscriptions module IMPORTS FROM:
| Module | What it uses |
|--------|-------------|
| Payments | PaymentService::processPayment() — actual money processing |
| Rules | RuleEngine — fine rates, grace periods, max years, dev fee |
| ServiceCatalog | ServicePrice — annual subscription rates by code |
| Members | BoardOfferService (via SubscriptionGenerator, year adjustments) |
### 8.2 Other modules that IMPORT FROM Subscriptions:
| Module | What it imports |
|--------|----------------|
| Payments | BalanceCalculator queries subscriptions table for unpaid totals |
| Members | RetroactiveMembershipService inserts subscriptions directly into table |
| Members | ReportController queries subscription status for reports |
| Reports | ReportEngine::overdueSubscriptionsReport() |
| Dashboard | DashboardDataService queries overdue subscription counts |
| Carnets | CarnetPrintService checks subscription status for card eligibility |
---
## 9. Permissions
| Key | Description |
|-----|-------------|
| subscription.view | View subscription list and member subscription history |
| subscription.collect | Process a subscription payment |
| subscription.exempt | Exempt a member from a subscription (requires reason) |
| subscription.generate_batch | Trigger batch generation for a financial year |
---
## 10. Background Processes (Cron)
| Job | File | Schedule | What it does |
|-----|------|----------|--------------|
| 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.
---
## 11. Configuration Dependencies
| Config Source | Key | Purpose |
|--------------|-----|---------|
| service_catalog | SVC_ANNUAL_MEMBER | Member base subscription rate (492.00) |
| service_catalog | SVC_ANNUAL_SPOUSE | Spouse subscription rate (492.00) |
| service_catalog | SVC_ANNUAL_CHILD | Child subscription rate (222.00) |
| service_catalog | SVC_ANNUAL_TEMP | Temporary member subscription rate (222.00) |
| service_catalog | SVC_ANNUAL_MEMBER_{YEAR} | Year-specific override (optional) |
| Rules | DEVELOPMENT_FEE | Dev fee amount (35.00 EGP, members only) |
| Rules | LATE_SUB_FINE_YEAR_{N} | Fine percentage for year N overdue |
| Rules | LATE_SUB_FINE_MAX_YEARS | Max years to apply fines (default: 5) |
| Rules | LATE_SUB_DROP_YEARS | Years before dropping membership (default: 5) |
| Rules | SUB_GRACE_MONTHS | Grace period before fines apply (default: 3) |
| Rules | REINSTATEMENT_WINDOW | Months allowed to reinstate after drop (default: 12) |
| Rules | SUBSCRIPTION_YEAR_ADJUSTMENT_{YEAR} | Year-specific discount percentage |
---
## 12. Financial Year Convention
```
Financial year runs July to June:
- If month >= 7: FY = thisYear/(thisYear+1) → e.g. "2025/2026"
- If month < 7: FY = (lastYear)/thisYear → e.g. "2024/2025"
```
**Subscription rates (2025/2026):**
| Person Type | Base Amount | Dev Fee | Total |
|-------------|-------------|---------|-------|
| Member | 492.00 | 35.00 | 527.00 |
| Spouse | 492.00 | 0.00 | 492.00 |
| Child | 222.00 | 0.00 | 222.00 |
| Temporary | 222.00 | 0.00 | 222.00 |
---
## 13. High-Risk Areas
### 13.1 Batch Generation Idempotency
- Checks for existing member subscription before generating — but checks per-member only
- If interrupted mid-batch: some members have subscriptions, others don't
- No rollback mechanism for partial generation
- Multiple years can be generated (retroactive via manual batch-generate form)
### 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
- 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
### 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
- The service class respects the grace period via `SubscriptionCalculator`
- Risk of inconsistent behavior depending on which path executes
---
## 14. Known Patterns & Gotchas
1. **No soft delete**: Subscriptions are never archived — status tracks lifecycle
2. **Denormalized person_name**: Stored at generation time, not updated if member name changes
3. **person_id polymorphism**: `person_id` references different tables based on `person_type` (members, spouses, children, temporary_members)
4. **Development fee only for members**: All dependent types get `development_fee = 0.00`
5. **Retroactive entries bypass generator**: `RetroactiveMembershipService` inserts directly into subscriptions table with backdated timestamps
6. **Menu registered via Members module**: Subscription menu items appear under the Members sidebar group
7. **No API endpoints**: All interaction is web-only (no REST API for external systems)
8. **Exempt status is terminal**: Once exempted, a subscription cannot be un-exempted without direct DB intervention
---
## Appendix: Files to Check Before Modifying Subscriptions
1. `app/Modules/Accounting/bootstrap.php` — Listens to `subscription.paid` for journal entries
2. `app/Modules/Accounting/Services/StatementIntegrationService.php` — Statement integration
3. `app/Modules/Payments/Services/BalanceCalculator.php` — Queries subscriptions for member balance
4. `app/Modules/Members/Services/RetroactiveMembershipService.php` — Direct insert into subscriptions
5. `app/Modules/Members/Controllers/ReportController.php` — Subscription status reports
6. `cron/jobs/OverdueFineJob.php` — Cron-based fine application and member drop
7. `cron/jobs/SubscriptionGeneratorJob.php` — Auto-generation in July
8. `app/Modules/Reports/Services/ReportEngine.php` — Overdue subscriptions report
# Temporary Module — Architecture Map
> **Last updated:** 2026-06-10 (updated)
> **Status:** Living document — incrementally updated as new information is discovered
---
## 1. Purpose & Responsibilities
The Temporary module manages **temporary/auxiliary members** attached to working members. These are non-standard dependents who don't qualify as spouses or children. It handles:
- Adding temporary members across 8 categories (parent, special needs, unmarried daughter, sister, stepchild, orphan, disabled sibling, nanny)
- Category-specific validation (age limits, gender requirements, documentation)
- Fee calculation (10% of membership value, with championship exemption)
- Separation/independence eligibility per category
- Standalone index view (all temporary members across all members)
- Event-based injection into member profile via `member.profile_data` listener
It does **NOT** directly manage:
- Fee collection (Cashier/Payments modules process the payment_request)
- Annual subscription generation (Subscriptions module)
- Auto-expiry cron (Members/AutoFreezeService handles age-limit expiry)
- Member billing aggregation (Members/BillingService aggregates temp fees)
- Carnets/ID cards (Carnets module)
---
## 2. Directory & File Structure
```
app/Modules/Temporary/
├── bootstrap.php # Permission registration (4 permissions) + member.profile_data listener
├── Routes.php # Web routes (5 routes)
├── Controllers/
│ └── TemporaryController.php # Main controller (index, CRUD)
├── Models/
│ └── TemporaryMember.php # Main model (soft delete, events, NID dedup, categories)
├── Services/
│ └── TemporaryFeeCalculator.php # Fee calc + category validation + separation logic
└── Views/
├── index.php # Standalone list of all temporary members (filterable)
├── create.php # Add temporary member form
├── show.php # Temporary member profile view
└── _partials/
└── temporary-table.php # Embedded table in member profile
```
---
## 3. Database Schema (Production — Source of Truth)
### 3.1 `temporary_members` Table (13 rows as of 2026-06-10)
| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| member_id | bigint unsigned | NO | MUL | | FK to members |
| category | varchar(50) | NO | MUL | | One of 8 predefined categories |
| full_name_ar | varchar(200) | NO | | | Arabic name (required) |
| full_name_en | varchar(200) | YES | | | English name |
| national_id | varchar(14) | YES | MUL | | Egyptian NID |
| passport_number | varchar(50) | YES | | | For foreign members |
| date_of_birth | date | NO | | | Required |
| age_years | int unsigned | YES | | | Stored at creation (STATIC) |
| age_months | int unsigned | YES | | | Stored at creation (STATIC) |
| gender | varchar(10) | NO | | | 'male' or 'female' |
| nationality | varchar(100) | YES | | مصري | |
| relationship_to_member | varchar(100) | YES | | | Free-text relationship |
| has_championship | tinyint(1) | NO | | 0 | Championship exemption flag |
| disability_documentation | tinyint(1) | NO | | 0 | Disability docs provided |
| addition_fee | decimal(15,2) | NO | | 0.00 | Calculated fee |
| fee_breakdown_json | text | YES | | | JSON breakdown |
| fee_receipt_number | varchar(50) | YES | | | Receipt after payment |
| can_separate | tinyint(1) | NO | | 0 | Eligible for separation |
| can_get_independent | tinyint(1) | NO | | 0 | Eligible for independence |
| status | varchar(50) | NO | MUL | active | Lifecycle status |
| activated_by_payment_id | bigint unsigned | YES | | | FK to payments |
| join_date | date | YES | | | |
| photo_path | varchar(500) | YES | | | Uploaded photo |
| notes | text | YES | | | Free-text notes |
| is_archived | tinyint(1) | NO | MUL | 0 | Soft delete |
| archived_at | timestamp | YES | | | |
| archived_by | bigint unsigned | YES | | | FK to employees |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | FK to employees |
| updated_by | bigint unsigned | YES | | | FK to employees |
**Current data distribution:**
- active / parent: 7
- active / nanny: 2
- active / unmarried_daughter: 1
- pending_payment / parent: 3
### 3.2 Category Values
| Category | Arabic Label | Notes |
|----------|-------------|-------|
| parent | والدين العضو العامل | No age limit |
| special_needs | ابناء ذوي الاحتياجات الخاصة | Must be 21+, needs disability docs |
| unmarried_daughter | بنات العضو غير المتزوجات | Female only |
| sister | شقيقة العضو العامل | Female only, age < SISTER_MAX_AGE (25) |
| stepchild | ابناء الزوج/الزوجة | Age < STEPCHILD_MAX_AGE (25) |
| orphan | الطفل اليتيم | Age < ORPHAN_MAX_AGE (25) |
| disabled_sibling | شقيق العضو المعاق | Needs disability docs |
| nanny | المربية | No special validation |
### 3.3 Status Values
| Status | Meaning |
|--------|---------|
| active | Currently active temporary member |
| inactive | Archived/removed |
| expired | Aged out (set by AutoFreezeService cron) |
| pending_payment | Awaiting fee payment |
### 3.4 Separation & Independence Flags
| Category | can_separate | can_get_independent |
|----------|-------------|-------------------|
| parent | No | Yes |
| special_needs | Yes | Yes |
| unmarried_daughter | Yes | Yes |
| sister | Yes | Yes |
| stepchild | Yes | Yes |
| orphan | No | No |
| disabled_sibling | No | No |
| nanny | No | No |
---
## 4. Routes
| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | /temporary | TemporaryController@index | auth | temp.view |
| GET | /members/{memberId}/temporary/create | TemporaryController@create | auth | temp.add |
| POST | /members/{memberId}/temporary | TemporaryController@store | auth, csrf | temp.add |
| GET | /members/{memberId}/temporary/{id} | TemporaryController@show | auth | temp.view |
| POST | /members/{memberId}/temporary/{id}/archive | TemporaryController@archive | auth, csrf | temp.remove |
**Note:** No edit route exists. Temporary members cannot be edited after creation.
---
## 5. Core Business Flows
### 5.1 Add Temporary Member Flow
```
1. GET /members/{memberId}/temporary/create
- Validate member exists
- Check lock: if membership_number assigned && not super_admin → blocked
- Show form with category dropdown
2. POST /members/{memberId}/temporary (store)
- Validate: name, category, DOB, gender, photo (all required)
- Parse NID if provided → auto-fill DOB, age, gender
- NID dedup check (members, spouses, children, temporary_members tables)
- Self-check: NID cannot be same as member's NID
- Category-specific validation via TemporaryFeeCalculator::validateCategory():
* special_needs: age >= 21, disability_documentation required
* unmarried_daughter: female only
* sister: female only, age < SISTER_MAX_AGE
* stepchild: age < STEPCHILD_MAX_AGE
* orphan: age < ORPHAN_MAX_AGE
* disabled_sibling: disability_documentation required
- Photo required
- Calculate fee via TemporaryFeeCalculator
- Create temporary_member record
- Set can_separate and can_get_independent based on category
- Upload photo
- Dispatch: temporary.added event
- If fee > 0 AND member activated → payment_request to Cashier
- If fee > 0 AND member NOT activated → included in aggregate bill
- If exempt (championship) → active immediately with fee=0
```
### 5.2 Fee Calculation Logic (TemporaryFeeCalculator)
```
1. Load member record
2. Resolve CURRENT membership value from pricing_configs (same logic as Spouse/Child calculators):
- If qualification_id set: SELECT price FROM pricing_configs WHERE branch_id + qual_id + membership_type + active + date-range
- Else: cheapest active price for branch+type
- Falls back to member.membership_value if no config found
IMPORTANT: always uses CURRENT year pricing — not the stored historical membership_value
3. Check if on initial form (no membership_number = no extra form fee)
4. Check championship exemption:
- If has_championship=true AND TEMP_CHAMPIONSHIP_EXEMPT rule says exempt=true → fee = 0
5. Otherwise:
- TEMP_MEMBER_FEE rule → percentage (default 10%)
- fee = currentMembershipValue * percentage / 100
6. Add form fee via FormFeeService::getFormFee() (0 if initial form, 570+sub if post-activation)
7. total_fee = fee + form_fee
8. Return: fee, percentage, membership_value (resolved current), form_fee, total_fee, breakdown
```
**Fix applied 2026-06-10:** Step 2 was previously missing — the calculator used `member.membership_value`
(historical/stored value) instead of the current pricing_configs price, causing incorrect fee amounts
for old members whose membership was created when the price was lower (e.g. 114,000 instead of 150,000 EGP).
### 5.3 Index View (Standalone)
```
GET /temporary
- Shows ALL temporary members across all members
- Filterable by: category, status, search query (name, NID, member name)
- Joins with members table for member_name and membership_number
- Limit 100 results
```
### 5.4 Archive (Remove) Flow
```
POST /members/{memberId}/temporary/{id}/archive
- Requires reason text
- Check lock (membership_number + super_admin)
- Sets: is_archived=1, archived_at, archived_by, status='inactive'
- Dispatches: temporary.removed event
```
### 5.5 Member Profile Data Injection
The bootstrap.php registers a `member.profile_data` event listener that:
- Queries `temporary_members WHERE member_id = ? AND is_archived = 0`
- Injects results into `$data['temporary']` for display in member profile
---
## 6. Events Dispatched
| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `temporary.added` | `{member_id, temporary_id, category, fee}` | TemporaryController::store |
| `temporary.removed` | `{member_id, temporary_id, reason}` | TemporaryController::archive |
---
## 7. Events Consumed
| Event | Source Module | Action |
|-------|--------------|--------|
| `temporary.fee_paid` | Cashier | Activates temp member (sets status='active', activated_by_payment_id) |
| `member.profile_data` | Members | Injects temporary members data into member profile view |
---
## 8. Cross-Module Dependencies
### 8.1 Temporary module IMPORTS FROM:
| Module | What it uses |
|--------|-------------|
| Members | NationalIdParser (NID parsing), FormFeeService (form fee calculation) |
| Rules | RuleEngine (TEMP_MEMBER_FEE, TEMP_CHAMPIONSHIP_EXEMPT, SISTER_MAX_AGE, STEPCHILD_MAX_AGE, ORPHAN_MAX_AGE, SPOUSE_WORKING_AGE_THRESHOLD) |
| Cashier | PaymentRequestService (creates payment requests) |
| Shared | PhotoUploadService (photo upload/validation) |
### 8.2 Other modules that IMPORT FROM Temporary:
| Module | What it imports / references |
|--------|------------------------------|
| Members/BillingService | Aggregates temp member fees into member bill |
| Members/AutoFreezeService | Cron: expires age-limited categories at 25 |
| Members/MembershipPaymentGuard | Activates temp members on lump-sum payment |
| Members/RetroactiveMembershipService | Creates temp records during retroactive entry |
| Members/MemberController | Loads temp members for profile display, changelog |
| Cashier/bootstrap | Payment processing → temporary.fee_paid event |
| Payments/PaymentLifecycleService | Void/cancel of temp-related payments |
| Subscriptions/SubscriptionGenerator | Generates annual subscriptions for active temps |
| Death/DeathController | Transfers temp members to new primary member on death |
| Transfers/TransferProcessor | Moves temp members to new member on transfer |
| Waiver/WaiverController | Counts temp members for waiver eligibility |
| Archive/ArchiveService | Includes temp members in archive snapshot |
| Carnets/CarnetController | Issues carnets for temp members |
| Reports/ReportEngine | Temporary members report |
| Audit/AuditService | Labels for audit trail display |
---
## 9. Permissions
| Key | Description (AR) |
|-----|-----------------|
| temp.add | اضافة عضو مؤقت |
| temp.edit | تعديل عضو مؤقت |
| temp.remove | ازالة عضو مؤقت |
| temp.view | عرض الاعضاء المؤقتين |
**Note:** `temp.edit` permission is registered but no edit route exists. Reserved for future use.
**Super Admin gates:**
- Add/remove temporary members after member activation (membership_number assigned)
---
## 10. Background Processes (Cron)
Handled by `Members/Services/AutoFreezeService` (NOT in Temporary module):
- **freezeTemporaryAtAgeLimit()**: Temporary members in age-limited categories (sister, stepchild, orphan) who reach 25 → sets `status='expired'`
- Queries `temporary_members WHERE is_archived=0 AND status='active' AND category IN (age-limited categories)`
- Uses `TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) >= 25`
---
## 11. Configuration Dependencies
| Rule Engine Key | Purpose | Default |
|----------------|---------|---------|
| TEMP_MEMBER_FEE | Percentage of membership value | 10% |
| TEMP_CHAMPIONSHIP_EXEMPT | Exempt if has championship | exempt=true |
| SISTER_MAX_AGE | Max age for sister category | 25 |
| STEPCHILD_MAX_AGE | Max age for stepchild category | 25 |
| ORPHAN_MAX_AGE | Max age for orphan category | 25 |
| SPOUSE_WORKING_AGE_THRESHOLD | Min age for special_needs category | 21 |
| Service Catalog | Purpose |
|----------------|---------|
| SVC_ADDITION_FORM | Form fee for addition (570 EGP) |
| SVC_ANNUAL_CHILD% (or temp-specific) | Annual subscription rate (~222 EGP) |
---
## 12. High-Risk Areas
### 12.1 No Edit Capability
- Once created, temporary members CANNOT be edited (no edit route or view)
- If data was entered incorrectly, only option is archive + re-create
- This means fee recalculation is impossible without deletion
### 12.2 Championship Exemption Logic
- `has_championship` flag sets fee to 0 — completely exempt
- No verification of championship claim (just a checkbox)
- If abused: members could avoid 10% fee entirely
- TEMP_CHAMPIONSHIP_EXEMPT rule can be toggled globally
### 12.3 Category Validation Gaps
- `parent` and `nanny` categories have NO validation beyond basic fields
- No max count per member for any category
- A member could theoretically add unlimited nannies or parents
### 12.4 Static Age Storage
- `age_years` stored at creation, never updated
- AutoFreezeService uses `TIMESTAMPDIFF` on `date_of_birth` for live age
- If display logic uses stored age_years → incorrect after years pass
### 12.5 Form Fee Inconsistency
- Uses `FormFeeService::getFormFee()` (includes annual sub) vs Children/Spouses which use `getFormFeeOnly()` + separate annual
- This means the total_fee for temporary members already includes annual subscription within form_fee
- Different decomposition pattern from sibling modules
### 12.6 NID Deduplication (Most Complete)
- Checks ALL 4 entity tables: members, spouses, children, temporary_members
- This is MORE complete than Children module (which doesn't check temporary_members)
- Inconsistency could allow a child NID that already exists as a temporary member
---
## 13. Known Patterns & Gotchas
1. **No edit route**: Only create, show, and archive operations exist
2. **Standalone index**: Only dependent module with its own `/temporary` index listing all records
3. **Profile data injection via EventBus**: Bootstrap listens to `member.profile_data` and injects temp data
4. **can_separate / can_get_independent**: Stored as columns, determined by category at creation time
5. **Category is permanent**: Cannot change category after creation
6. **Photo required**: Cannot add without uploading a photo
7. **Championship = full exemption**: No partial discount — either 10% or 0%
8. **Multiple parents allowed**: No max count for `parent` category (unlike spouses)
9. **Status managed externally**: AutoFreezeService sets 'expired', Cashier sets 'active' via payment
---
## 14. Fee Calculation Reference
| Scenario | Fee |
|----------|-----|
| Standard temp member | 10% of membership value + form fee |
| Championship exempt | 0 (completely free) |
| Form fee (post-activation) | Via FormFeeService::getFormFee() (includes annual sub) |
| On initial form | 10% only (no form fee) |
**Example:** Member with 150,000 EGP qualification:
- Standard: 15,000 + 570 (form) + 222 (annual) = 15,792 EGP
- Championship: 0 EGP
---
## Appendix: Files to Check Before Modifying Temporary
1. `app/Modules/Members/Services/BillingService.php` — Aggregates temp fees into member bill
2. `app/Modules/Members/Services/AutoFreezeService.php` — Cron expiry for age-limited categories
3. `app/Modules/Members/Services/MembershipPaymentGuard.php` — Activates temps on lump-sum payment
4. `app/Modules/Members/Services/FormFeeService.php` — Form fee + annual subscription logic
5. `app/Modules/Cashier/bootstrap.php` — Payment processing → temporary.fee_paid event
6. `app/Modules/Payments/Services/PaymentLifecycleService.php` — Void/cancel for temp payments
7. `app/Modules/Death/Controllers/DeathController.php` — Transfers temps on member death
8. `app/Modules/Transfers/Services/TransferProcessor.php` — Moves temps on member transfer
9. `app/Modules/Subscriptions/Services/SubscriptionGenerator.php` — Annual subscriptions for temps
10. `app/Modules/Reports/Services/ReportEngine.php` — Temporary members report
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