# Waiver Module — Architecture Map

> **Last updated:** 2026-06-19 (fixed duplicate key error + fee uses current pricing)
> **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 (8 routes)
├── 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 (9 rows as of 2026-06-10)

| 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 |
| new_dependent_count | int unsigned | YES | | | Target member's dependent count (future) |
| 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 |
| 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):**
- fee_paid: 9

---

## 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

```
1. GET /waivers/create/{memberId}
   - Validate member exists and is not archived
   - Calculate waiver fee: WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)
   - Count dependents (spouses + children + temporary)
   - Display fee and dependent summary

2. POST /waivers/store/{memberId}
   - Validate: member exists, has a membership_number
   - Calculate fee: WAIVER_FEE (default 30%) × current membership value (from pricing_configs, NOT stored membership_value)
   - Count all active dependents (spouses + children + temp)
   - Optionally accept target_member_id
   - Create WaiverRequest (status='requested')
   - Dispatch: waiver.requested
   - Send payment request to Cashier queue (waiver_fee amount)

3. POST /waivers/{id}/approve
   - Must be status='requested'
   - Set approved_by, approved_at, board_decision_reference
   - Status → 'approved'
   - (No event dispatched for approve)

4. POST /waivers/{id}/reject
   - Must be status='requested'
   - Status → 'rejected'
   - (No event dispatched for reject)

5. POST /waivers/{id}/pay  (or via Cashier queue)
   - Process payment (type='waiver_fee')
   - 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. All annual subscriptions for source member must be paid (no pending/overdue)
     c. Target member's dependent count must NOT exceed source's original_dependent_count
        - If exceeded: blocks with error, requires board approval + extra fees first
     d. Records new_dependent_count and marks annual_renewal_paid = 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')
        - Record number chain via ArchiveService
        - 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

```
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.
No additional form fee or annual subscription (unlike Transfers/Death/Divorce).
```

### 5.3 Eligibility (from TransferEligibility::canWaiver)

```
Requirements:
1. Member must exist and not be archived
2. Member status must be 'active'
3. No unpaid subscriptions (pending/overdue)
4. Board approval always required
```

---

## 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
- Eligibility check (TransferEligibility::canWaiver) verifies no unpaid subscriptions
- BUT this check is in the Transfers module, not called by WaiverController
- WaiverController::store() does NOT call TransferEligibility::canWaiver()
- Gap: member could have unpaid subscriptions and still submit a waiver

---

## 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. **9 fee_paid records exist**: All 9 existing records are in fee_paid status (none completed yet)
10. **board_approval_required always 1**: Column exists but is hardcoded to always require board
11. **No soft delete**: WaiverRequest has no is_archived column
12. **Dual payment paths**: Cashier queue on creation + direct /pay endpoint

---

## 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
