# Waiver Module — Architecture Map

> **Last updated:** 2026-06-26 (Round 2: comprehensive debt check with per-person details, children age/DOB display, payment in target's name, send-to-cashier flow, status indicators)
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Waiver module manages **voluntary membership waiver (transfer to another person)** — when a member chooses to give up their membership in favor of someone else. It handles:
- Recording waiver requests with fee calculation (30% of membership value)
- Board of Trustees approval workflow
- Fee collection (waiver fee = percentage of membership value)
- Transferring the SAME membership number to the target member
- Archiving the source member (status='waived')
- Preserving the complete dependent count at time of waiver for audit

It does **NOT** directly manage:
- Creating the target member (must be created separately first via normal member creation)
- Payment processing (delegates to Cashier/Payments)
- Archive snapshots (delegates to Archive module)
- Eligibility checks beyond basic rules (see TransferEligibility in Transfers module)

---

## 2. Directory & File Structure

```
app/Modules/Waiver/
├── bootstrap.php                          # Minimal — no permissions (reuses Transfers bootstrap)
├── Routes.php                             # Web routes (9 routes, incl. send-to-cashier)
├── Controllers/
│   └── WaiverController.php              # Main controller (index, CRUD, approval, payment, completion)
├── Models/
│   └── WaiverRequest.php                 # Main model (no soft delete)
├── Services/
│   └── WaiverProcessor.php               # Execution engine: snapshot → transfer number → archive source
└── Views/
    ├── index.php                          # Paginated waiver list
    ├── create.php                         # New waiver form (shows fee, dependents)
    └── show.php                           # Waiver detail view with actions
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `waiver_requests` Table (18 rows as of 2026-06-23)

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| source_member_id | bigint unsigned | NO | MUL | | FK to members (giving up membership) |
| target_member_id | bigint unsigned | YES | MUL | | FK to members (receiving membership) |
| membership_number | varchar(20) | NO | MUL | | The number being transferred |
| membership_value_at_waiver | decimal(15,2) | NO | | | Membership value at time of request |
| waiver_fee_percentage | decimal(5,2) | NO | | 30.00 | Fee percentage (from WAIVER_FEE rule) |
| waiver_fee_amount | decimal(15,2) | NO | | | Calculated: percentage * membership_value |
| original_dependent_count | int unsigned | NO | | 0 | Total dependents at time of waiver |
| **original_spouses_count** | int unsigned | NO | | 0 | Spouses at time of waiver (per-category) |
| **original_children_count** | int unsigned | NO | | 0 | Children at time of waiver (per-category) |
| **original_temporary_count** | int unsigned | NO | | 0 | Temp members at time of waiver (per-category) |
| new_dependent_count | int unsigned | YES | | | Target member's total dependent count (at completion) |
| **new_spouses_count** | int unsigned | YES | | | Target spouses at completion |
| **new_children_count** | int unsigned | YES | | | Target children at completion |
| **new_temporary_count** | int unsigned | YES | | | Target temp members at completion |
| excess_dependent_count | int unsigned | NO | | 0 | Total excess dependents |
| **excess_spouses_count** | int unsigned | NO | | 0 | Excess spouses (target - original) |
| **excess_children_count** | int unsigned | NO | | 0 | Excess children (target - original) |
| **excess_temporary_count** | int unsigned | NO | | 0 | Excess temp members (target - original) |
| excess_fee_percentage | decimal(5,2) | YES | | NULL | Legacy: single % (superseded by per-category) |
| excess_fee_amount | decimal(15,2) | NO | | 0.00 | Total excess fee (sum of per-category) |
| **spouse_fee_type** | enum('fixed','percentage') | YES | | NULL | Board-set: fixed amount or % of value |
| **spouse_fee_rate** | decimal(15,2) | YES | | NULL | Rate per excess spouse |
| **spouse_fee_total** | decimal(15,2) | NO | | 0.00 | = rate × excess_spouses_count |
| **child_fee_type** | enum('fixed','percentage') | YES | | NULL | Board-set: fixed amount or % of value |
| **child_fee_rate** | decimal(15,2) | YES | | NULL | Rate per excess child |
| **child_fee_total** | decimal(15,2) | NO | | 0.00 | = rate × excess_children_count |
| **temporary_fee_type** | enum('fixed','percentage') | YES | | NULL | Board-set: fixed amount or % of value |
| **temporary_fee_rate** | decimal(15,2) | YES | | NULL | Rate per excess temp member |
| **temporary_fee_total** | decimal(15,2) | NO | | 0.00 | = rate × excess_temporary_count |
| board_approval_required | tinyint(1) | NO | | 1 | Always requires board approval |
| board_decision_reference | varchar(100) | YES | | | Board decision document reference |
| approved_by | bigint unsigned | YES | | | FK to employees |
| approved_at | timestamp | YES | | | |
| annual_renewal_paid | tinyint(1) | NO | | 0 | Flag: annual renewal has been paid |
| debts_cleared | tinyint(1) | NO | | 0 | Flag: source member debts verified clear |
| **target_debts_cleared** | tinyint(1) | NO | | 0 | Flag: target member debts verified clear |
| waiver_request_doc_path | varchar(500) | YES | | NULL | Uploaded waiver request form document |
| target_form_doc_path | varchar(500) | YES | | NULL | Uploaded target member form document |
| archive_snapshot_id | bigint unsigned | YES | MUL | | FK to archive_snapshots |
| workflow_instance_id | bigint unsigned | YES | MUL | | FK to workflow_instances |
| status | varchar(50) | NO | MUL | requested | requested/approved/fee_paid/completed/rejected |
| notes | text | YES | | | Free text notes |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | auto-update | |
| created_by | bigint unsigned | YES | | | FK to employees |
| updated_by | bigint unsigned | YES | | | FK to employees |

**Current data distribution (status):**
- completed: 2, requested: 7, approved: 1, fee_paid: 8

**Migration:** Phase_94_003_alter_waiver_requests_add_per_category_fees (batch 62)

---

## 4. Waiver Status Lifecycle

```
requested → approved → fee_paid → completed
     ↓
  rejected
```

**Status values:** requested, approved, fee_paid, completed, rejected

**Key invariant:** A waiver can only be completed if status is `approved` or `fee_paid`, AND a `target_member_id` has been set. The target member must exist in the system before completion.

---

## 5. Core Business Flows

### 5.1 Waiver Request Flow (Bylaws-Compliant — 2026-06-22)

```
1. GET /waivers/create/{memberId}
   - Validate member exists and is not archived
   - Run WaiverProcessor::checkDebts(memberId) — checks subscriptions, fines, payment_requests
   - If debts exist: show debt table, BLOCK form submission
   - Calculate waiver fee: WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)
   - Run WaiverProcessor::countDependents(memberId) — counts spouses, children, temporary
   - Display: fee breakdown, dependent grid, debt status, document upload fields

2. POST /waivers/store/{memberId}
   - VALIDATE: member exists, has membership_number
   - VALIDATE: checkDebts → must be clear (blocks if any unpaid)
   - Calculate fee: WAIVER_FEE (default 30%) × current pricing_configs value
   - Count all active dependents (spouses + children + temp)
   - Handle file uploads: waiver_request_doc, target_form_doc (PDF/JPG/PNG, max 5MB)
   - Optionally accept target_member_id
   - Create WaiverRequest (status='requested', debts_cleared=1)
   - Dispatch: waiver.requested
   - Send payment request to Cashier queue (waiver_fee amount)

3. POST /waivers/{id}/approve
   - Must be status='requested'
   - Board sets: board_decision_reference, excess_fee_percentage (if target has excess dependents)
   - If target exists AND target dependents > original count:
     excess_count = target_deps - original_count
     excess_fee = (excess_fee_percentage × membership_value / 100) × excess_count
   - Set approved_by, approved_at, excess_dependent_count, excess_fee_amount
   - Status → 'approved'
   - Sends combined payment request (waiver_fee + excess_fee) to Cashier

4. POST /waivers/{id}/reject
   - Must be status='requested'
   - Accepts rejection_reason in notes
   - Status → 'rejected'

5. POST /waivers/{id}/pay  (or via Cashier queue)
   - Process payment: waiver_fee + excess_fee combined
   - Status → 'fee_paid'
   - Dispatch: waiver.fee_paid

6. POST /waivers/{id}/complete
   - Optionally update target_member_id from POST data
   - PRE-VALIDATION (before processor runs):
     a. Target member must be set
     b. checkDebts(source) — ALL financial obligations must be clear
     c. countDependents(target) check:
        - If target > original AND no excess_fee_amount set → block (board must approve first)
     d. Status must be 'approved' or 'fee_paid'
     e. Records new_dependent_count, debts_cleared=1
   - Execute WaiverProcessor::execute():
     a. Validate: status must be 'approved' or 'fee_paid'
     b. Validate: target_member_id must be set
     c. Transaction:
        - Archive snapshot of source member (full data preserved in archive_snapshots)
        - Archive source member FIRST (number=NULL, status='waived', is_archived=1) — releases unique constraint
        - Transfer membership_number to target member (set number, status='active', membership_type inherited)
        - Record number chain via ArchiveService::recordNumberTransfer
        - Status → 'completed'
     d. Dispatch: waiver.completed
   - POST-STATE:
     - Source member: archived, browsable via Archive module
     - Target member: active, owns the membership number
     - All historical data preserved (no hard deletes)
```

### 5.2 Fee Calculation (Per-Category — 2026-06-23)

```
BASIC Waiver Fee = WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)

Default: 30% × current_price_for(branch_id, qualification_id, membership_type)
Example: 30% × 150,000 = 45,000 EGP

Falls back to stored membership_value only if no matching pricing_configs row exists.

EXCESS Dependent Fee (PER-CATEGORY — independent comparison for each):
  Comparison is done independently for each category:
  - excess_spouses = max(0, target_spouses - original_spouses)
  - excess_children = max(0, target_children - original_children)
  - excess_temporary = max(0, target_temporary - original_temporary)

  Each category can have a different fee type and rate (set by Board of Trustees):
  - Type: 'fixed' = flat amount per excess dependent
  - Type: 'percentage' = % of membership_value per excess dependent

  Per-Category Fee Formulas:
  - spouse_fee_total = excess_spouses × (spouse_fee_rate if fixed, OR spouse_fee_rate% × membership_value)
  - child_fee_total = excess_children × (child_fee_rate if fixed, OR child_fee_rate% × membership_value)
  - temporary_fee_total = excess_temporary × (temporary_fee_rate if fixed, OR temporary_fee_rate% × membership_value)

  Example (mixed fee types):
  - 1 excess spouse × 30% × 150,000 = 45,000 EGP (percentage)
  - 2 excess children × 10,000 = 20,000 EGP (fixed)
  - 1 excess temp × 25% × 150,000 = 37,500 EGP (percentage)
  - Total excess: 102,500 EGP

TOTAL = waiver_fee + spouse_fee_total + child_fee_total + temporary_fee_total
Example: 45,000 + 45,000 + 20,000 + 37,500 = 147,500 EGP

No additional form fee or annual subscription (unlike Transfers/Death/Divorce).
```

### 5.3 Eligibility & Validation (Bylaws — Updated 2026-06-23)

```
PRE-REQUISITES (enforced in create + store):
1. Member must exist and not be archived
2. Member must have a membership_number
3. ALL financial debts on SOURCE member must be clear:
   - subscriptions (status: pending/overdue) = 0
   - fines (status: pending) = 0
   - installments (schedule: pending/overdue in active/overdue plans) = 0  ← NEW
   - payment_requests (status: pending, not voided) = 0
4. ALL financial debts on TARGET member must be clear (if target specified at creation)  ← NEW
5. Board approval always required

BLOCKING RULES (enforced at complete — dual debt check):
1. Source member ALL debts = 0 (subscriptions + fines + installments + payment_requests)
2. Target member ALL debts = 0 (same checks)  ← NEW
3. Per-category dependent excess fees must be set by board (if excess exists)
4. Board approval must exist (approved_by set)
5. Status must be 'approved' or 'fee_paid'

DEPENDENT RULES (PER-CATEGORY — enforced in approve + complete):
- Each category compared independently (NOT total only):
  - Scenario: target_spouses > original_spouses → excess spouses → board sets fee
  - Scenario: target_children > original_children → excess children → board sets fee
  - Scenario: target_temporary > original_temporary → excess temps → board sets fee
- Board sets fee_type (fixed/percentage) and fee_rate for EACH category independently
- If any category has excess without a board-approved fee → operation BLOCKED

REQUIRED DOCUMENTS:
- waiver_request_doc: waiver request form (PDF/JPG/PNG)
- target_form_doc: target member's membership form (PDF/JPG/PNG)
```

---

## 6. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `waiver.requested` | `{waiver_id, member_id}` | WaiverController::store |
| `waiver.fee_paid` | `{waiver_id, payment_id}` | WaiverController::pay |
| `waiver.completed` | `{waiver_id, source_member_id, target_member_id, membership_number, fee_amount, fee_percentage}` | WaiverProcessor::execute |

---

## 7. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `waiver.completed` | Accounting | `AccountingIntegrationService::onWaiverCompleted()` — posts journal entry (AR write-off) |
| `waiver.fee_paid` | Cashier | Updates `waiver_requests.status` to 'fee_paid' (via payment_request.completed handler) |

---

## 8. Cross-Module Dependencies

### 8.1 Waiver module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Archive | ArchiveService (takeSnapshot, recordNumberTransfer) |
| Payments | PaymentService (processPayment for direct pay) |
| Cashier | PaymentRequestService (queue payment for cashier) |
| Rules | RuleEngine (WAIVER_FEE percentage) |

### 8.2 Other modules that IMPORT FROM Waiver:

| Module | What it imports |
|--------|----------------|
| Accounting | Listens to waiver.completed event |
| Cashier | Maps waiver_fee payment type → waiver_requests table |
| Transfers | TransferEligibility::canWaiver() provides eligibility check |
| Members | WaiverService (in Members/Services) — legacy reference |

---

## 9. Permissions

The Waiver module uses permissions registered in **Transfers/bootstrap.php**:

| Key | Used For |
|-----|----------|
| waiver.view | View waiver list and details |
| waiver.initiate | Create waiver requests |
| waiver.approve | Approve/reject/complete waivers |
| payment.collect | Process payment |

---

## 10. Configuration Dependencies

| Rule Engine Keys | Purpose |
|-----------------|---------|
| WAIVER_FEE | Percentage of membership value to charge (default 30%) |

---

## 11. High-Risk Areas

### 11.1 Target Member Must Pre-Exist (CRITICAL)
- Unlike Transfers/Death/Divorce which CREATE the new member during completion, Waiver REQUIRES the target member to already exist
- The target member must be created via the normal member registration flow FIRST
- If target_member_id is not set: WaiverProcessor returns error "لم يتم تحديد العضو المستفيد"
- No validation that target member is in a suitable status or branch

### 11.2 Membership Number Transfer
- Source member's `membership_number` is transferred directly to target member
- Source member gets `membership_number = NULL`
- This happens within a transaction, but:
  - Target member's existing number (if any) is OVERWRITTEN without backup
  - No check if target already has a different membership_number

### 11.3 Source Member Archival
- Source member is permanently archived (is_archived=1, status='waived')
- The `archived_by` field is set to the current employee
- All dependents of source member are NOT transferred (stay orphaned on archived member)
- This differs from full_transfer in Transfers module which moves dependents

### 11.4 No Dependent Transfer
- Unlike TransferProcessor which moves spouses/children/temp to new member
- WaiverProcessor ONLY transfers the membership number
- Source member's dependents remain attached to the archived source member
- They effectively become orphaned — no active parent member
- `original_dependent_count` is recorded for audit but dependents are not migrated

### 11.5 Payment Before Approval Race
- Payment request is sent to Cashier queue immediately on creation (before board approval)
- If Cashier processes payment before board approves: status becomes 'fee_paid' (bypasses 'approved')
- WaiverProcessor accepts BOTH 'approved' and 'fee_paid' for completion
- This means: payment can be collected even if board eventually rejects (refund needed)

### 11.6 ~~No Unpaid Subscription Check at Completion~~ FIXED (2026-06-22, enhanced 2026-06-23)
- **NOW ENFORCED**: WaiverProcessor::checkDebts() is called at create, store, AND complete
- Checks ALL financial obligations: subscriptions, fines, installments, AND payment_requests
- **DUAL CHECK (2026-06-23)**: Both source AND target member debts are validated
- Form is blocked (disabled) if any debts exist on either party — cannot even submit the request
- Complete also re-validates debts on BOTH members in case new ones arose between request and completion

---

## 12. Known Patterns & Gotchas

1. **No bootstrap permissions**: Empty bootstrap — permissions registered in Transfers/bootstrap.php
2. **Menu in Members module**: Waiver menu item registered in `Members/bootstrap.php`
3. **Target must pre-exist**: Must create new member FIRST, then link to waiver (unlike other modules)
4. **Same number transfer**: Source's number goes directly to target (like Death primary, unlike Divorce)
5. **Dependents NOT transferred**: Only the number moves, NOT spouses/children/temp members
6. **30% default fee**: Configurable via WAIVER_FEE rule
7. **No form fee or annual sub**: Simpler fee structure than Transfers/Death/Divorce
8. **Payment sent immediately**: Cashier queue payment is created at request time (before approval)
9. **Debt gate (DUAL)**: Form is BLOCKED if any debts exist on source OR target — verified at create, store, and complete
10. **Per-category excess fees (2026-06-23)**: Board enters fee_type (fixed/%) + fee_rate for EACH category (spouses, children, temporary) independently — NOT a single % for all
11. **Document uploads**: Stored in `uploads/waivers/YYYY/MM/` — PDF, JPG, PNG up to 5MB
12. **Two-phase dependent validation**: at approval (board sets per-category fees) + at completion (final check with blocking)
13. **board_approval_required always 1**: Column exists but is hardcoded to always require board
14. **No soft delete**: WaiverRequest has no is_archived column
15. **Dual payment paths**: Cashier queue on creation + direct /pay endpoint
16. **Installments in debt check (2026-06-23)**: checkDebts() now includes unpaid installment_schedule rows from active/overdue plans
17. **Per-category comparison**: Comparison is INDEPENDENT per category (spouses vs spouses, children vs children, etc.) — NOT total only
18. **Comprehensive debt check (2026-06-26)**: `checkDebtsComprehensive()` returns per-person breakdown: person_name, person_type, person_label, debt_type, period, amount — shows exact who owes what
19. **Children details display (2026-06-26)**: `getDependentDetails()` calculates age_years, age_display, age_category (under_12/12_to_16/16_to_18/18_plus) for each child from date_of_birth
20. **Payment in TARGET's name (2026-06-26)**: `sendToCashier` creates PaymentRequest with target_member_id (buyer pays), includes full receipt breakdown
21. **Send-to-cashier route (2026-06-26)**: New `POST /waivers/{id}/send-to-cashier` replaces old immediate payment. Requires status=approved + target set
22. **Status indicators (2026-06-26)**: fee_paid status shows "🟢 جاهز لاعتماد طلب التنازل" with explicit "all conditions met" message
23. **Go to payment button (2026-06-26)**: Debt display includes direct link to member's financial page for quick payment

---

## 13. Comparison with Other Transfer-Type Modules

| Aspect | Transfers | Death | Divorce | Waiver |
|--------|-----------|-------|---------|--------|
| Source member status | 'transferred' | 'deceased' | UNCHANGED | 'waived' |
| Source archived? | Yes | Yes | No | Yes |
| New member created? | Yes (in module) | Yes (in module) | Yes (in module) | No (pre-exists) |
| Number inheritance | Same number | Same number (primary) | NEW number | Same number |
| Dependents moved? | Yes (full_transfer) | Yes (configurable) | No | No |
| Fee basis | Tiered % by years | Fixed (form+sub) | Board sets % | Fixed 30% |
| Board approval | Yes | No | Yes (sets fee) | Yes |
| Form fill step | No | Yes (wife fills) | No | No |
| Certificate required | No | No | Yes (divorce cert) | No |

---

## Appendix: Files to Check Before Modifying Waiver

1. `app/Modules/Archive/Services/ArchiveService.php` — snapshot and number chain logic
2. `app/Modules/Accounting/bootstrap.php` — waiver.completed journal entry
3. `app/Modules/Cashier/bootstrap.php` — payment_request.completed listener (waiver_fee → fee_paid)
4. `app/Modules/Transfers/bootstrap.php` — permission registration (waiver.view/initiate/approve)
5. `app/Modules/Transfers/Services/TransferEligibility.php` — canWaiver() eligibility check
6. `app/Modules/Members/bootstrap.php` — menu registration
7. `app/Modules/Members/Services/WaiverService.php` — legacy waiver service in Members module
8. `app/Modules/Rules/Services/RuleEngine.php` — WAIVER_FEE rule
