# Transfers Module — Architecture Map

> **Last updated:** 2026-08-06 (store: male under-25 employed+graduated exception, marriage_date vs membership date validation, create.php JS fix)
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Transfers module manages **membership transfers and separations** — the process of converting a dependent (child or spouse) into a full working member, or transferring an entire membership to a different person. It handles:
- Child separation (voluntary at graduation/employment, mandatory at age 25)
- Spouse separation (via divorce — delegates to Divorce module)
- Full membership transfer to another person (e.g., relative inheriting membership)
- Cross-branch transfers
- Sports conversion transfers
- Fee calculation based on years since acquisition (tiered percentage)
- Companion surcharge calculation (when new owner has more dependents than original)
- Board approval workflow
- Cashier queue integration for payment collection

It does **NOT** directly manage:
- Death transfers (Death module)
- Divorce transfers (Divorce module)
- Waiver transfers (Waiver module)
- Payment processing (delegates to Cashier/Payments modules)
- Archive snapshots (delegates to Archive module)

---

## 2. Directory & File Structure

```
app/Modules/Transfers/
├── bootstrap.php                          # Permission registration (9 permissions)
├── Routes.php                             # Web routes (10 routes)
├── Controllers/
│   └── TransferController.php            # Main controller (index, CRUD, approval, payment, completion)
├── Models/
│   └── TransferRequest.php               # Main model (no soft delete)
├── Services/
│   ├── SeparationFeeCalculator.php       # Fee calculation: tiered % by years, companion surcharge
│   ├── TransferEligibility.php           # Eligibility checks: child, spouse, waiver
│   └── TransferProcessor.php             # Execution engine: archive → create → transfer dependents
└── Views/
    ├── index.php                          # Paginated transfer list with filters
    ├── create.php                         # New transfer form (select type, child/spouse, recipient)
    ├── preview.php                        # Fee preview before submission
    └── show.php                           # Transfer detail view with status/actions
```

---

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

### 3.1 `transfer_requests` Table (1 row 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 (original owner) |
| target_member_id | bigint unsigned | YES | MUL | | FK to members (new member created on completion) |
| transfer_type | varchar(50) | NO | MUL | | child_separation/child_mandatory_25/sports_conversion/cross_branch/full_transfer |
| child_id | bigint unsigned | YES | MUL | | FK to children (if child separation) |
| spouse_id | bigint unsigned | YES | MUL | | FK to spouses (if spouse separation) |
| source_membership_number | varchar(20) | YES | | | Original membership number |
| new_membership_number | varchar(20) | YES | | | Assigned on completion (same as source) |
| new_membership_value | decimal(15,2) | YES | | | Current pricing at time of request |
| source_companions_count | int unsigned | YES | | | Original member's dependent count |
| target_companions_count | int unsigned | YES | | | New owner's dependent count |
| companion_surcharge | decimal(15,2) | NO | | 0.00 | Extra fee for additional dependents |
| companion_surcharge_breakdown | text | YES | | | JSON breakdown of surcharge items |
| years_since_acquisition | int unsigned | YES | | | Years since member joined (rounds up) |
| qualification_code | varchar(20) | YES | | | Qualification for pricing lookup |
| fee_percentage | decimal(5,2) | YES | | | Tiered % based on years |
| separation_fee | decimal(15,2) | YES | | | Main fee: percentage * membership value |
| form_fee | decimal(15,2) | YES | | | Transfer form fee (570 EGP) |
| annual_subscription_fee | decimal(15,2) | YES | | | Annual subscription for new member |
| total_fee | decimal(15,2) | YES | | | separation_fee + form_fee + annual_sub + surcharge |
| archive_snapshot_id | bigint unsigned | YES | MUL | | FK to archive_snapshots |
| workflow_instance_id | bigint unsigned | YES | MUL | | FK to workflow_instances |
| board_decision_notes | text | YES | | | Board approval/rejection notes |
| approved_by | bigint unsigned | YES | | | FK to employees |
| approved_at | timestamp | YES | | | |
| completed_at | timestamp | YES | | | |
| status | varchar(50) | NO | MUL | requested | requested/approved/fee_paid/completed/rejected |
| is_employed | tinyint(1) | YES | | | Whether child is employed (child_separation) |
| graduation_date | date | YES | | | Graduation date (required when employed) |
| work_date | date | YES | | | Employment start date (males) |
| marriage_date | date | YES | | | Marriage date (females — used as effective date) |
| date_turned_25 | date | YES | | | Calculated: child's DOB + 25 years |
| effective_transfer_date | date | YES | | | Computed: min(max(work,grad), date25) |
| target_spouses_count | int unsigned | YES | | | Dependents joining new membership (spouses) |
| target_children_count | int unsigned | YES | | | Dependents joining new membership (children) |
| target_temps_count | int unsigned | YES | | | Dependents joining new membership (temps) |
| notes | text | YES | | | JSON: full_transfer stores recipient_data; child_separation stores dependents_data |
| 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: 1

---

## 4. Transfer Status Lifecycle

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

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

**Key invariant:** A transfer can only be completed (`TransferProcessor::execute`) if status is `approved` or `fee_paid`. For non-child_separation types: the source member is archived and a new member record is created with the SAME membership number. For child_separation: parent keeps their membership (no archive), child gets a NEW number + NEW form_number. Auto-completes immediately after cashier payment (no manual "إتمام" button).

---

## 5. Core Business Flows

### 5.1 Child Separation Flow (فصل أبناء)

```
1. GET /transfers/create/{memberId}
   - Show active children (filtered >= 25 for child_separation) and spouses
   - Show employment status, graduation_date, work_date fields
   - Show dependents section (spouses/children/temps joining new membership)
   
2. POST /transfers/store/{memberId}
   - Validate: transfer_type, child_id
   - Gender-based logic:
     - MALE: require is_employed; if employed: graduation_date + work_date required
       Compute effective_transfer_date = min(max(work_date, graduation_date), date_turned_25)
     - FEMALE: require marriage_date only; effective_transfer_date = marriage_date
   - Read dep_spouses_count, dep_children_count, dep_temps_count
   - Calculate fees via SeparationFeeCalculator::calculateForChildSeparation()
     (annual subscription includes member + specified dependents)
   - Collect dependent details (name, national_id) from form arrays
   - Store in notes JSON as dependents_data
   - Create TransferRequest record (status='requested')
   - Submit to FormBridge (TRANSFER_SEPARATION form)
   - Dispatch: transfer.requested
   - Send payment request to Cashier queue

3. POST /transfers/{id}/approve
   - Board approves → status='approved'
   - Dispatch: transfer.approved

4. [Cashier processes payment]
   - PaymentRequestService processes payment (type='separation_fee')
   - Cashier bootstrap listener: Status → 'fee_paid'
   - Dispatch: transfer.fee_paid
   - **AUTO-COMPLETE** (child_separation only): immediately calls TransferProcessor::execute()
     No manual "إتمام التحويل" button needed.

5. TransferProcessor::execute() (auto-triggered for child_separation, manual for others):
   - TransferProcessor::execute() runs in transaction:
     a. Take archive snapshot of source member **(SKIPPED for child_separation)**
     b. child_separation: parent KEEPS membership, child gets NEW membership number
        other types: Archive source member (membership_number=NULL, status='transferred', is_archived=1)
     c. Generate form_number via FormNumberGenerator::next()
     d. Create new member with form_number + membership_number
     d. Mark child as 'separated'
     e. Create dependents from notes.dependents_data (spouses, children, temps)
     f. Record number chain via ArchiveService
     g. Status → 'completed'
   - Dispatch: transfer.completed
```

### 5.2 Full Membership Transfer Flow

Same as above, but:
- Recipient data (name, NID, DOB, etc.) is stored in `notes` JSON field
- On completion: ALL dependents (spouses, children, temp members) are moved to new member
- Source member is fully archived

### 5.3 Fee Calculation Logic (SeparationFeeCalculator)

```
Total Fee = Separation Fee + Form Fee + Annual Subscription + Companion Surcharge

Separation Fee = Current Membership Value × Fee Percentage (by years)
  Year 1: SEPARATION_FEE_YEAR_1 (from RuleEngine)
  Year 2: SEPARATION_FEE_YEAR_2
  Year 3: SEPARATION_FEE_YEAR_3
  Year 4: SEPARATION_FEE_YEAR_4
  Year 5: SEPARATION_FEE_YEAR_5
  Year 6+: SEPARATION_FEE_YEAR_6_PLUS

Form Fee = FORM_TRANSFER_FEE rule (default 570 EGP)
Annual Subscription (child_separation with dependents):
  = SVC_ANNUAL_MEMBER + (spouses × SVC_ANNUAL_SPOUSE) + (children × SVC_ANNUAL_CHILD)
    + (temps × SVC_ANNUAL_TEMP) + DEVELOPMENT_FEE (once per family)
  If activated after July 1 of current FY → 0 (already covered by membership fee)
Annual Subscription (legacy/other types) = SVC_ANNUAL_MEMBER + DEVELOPMENT_FEE (492 + 35 = 527 EGP)

Companion Surcharge: if new owner brings MORE dependents than source had:
  - Extra spouses: tiered (SPOUSE_2ND_FEE, SPOUSE_3RD_FEE, SPOUSE_4TH_FEE)
  - Extra children: age-based via PricingEngine::calculateChildFee
```

---

## 6. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `transfer.requested` | `{transfer_id, member_id, type}` | TransferController::store |
| `transfer.approved` | `{transfer_id}` | TransferController::approve |
| `transfer.fee_paid` | `{transfer_id, payment_id}` | TransferController::pay |
| `transfer.completed` | `{transfer_id, source_member_id, target_member_id, new_number, transfer_type, fee_amount, old_number}` | TransferProcessor::execute |

---

## 7. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `transfer.completed` | Accounting | `AccountingIntegrationService::onTransferCompleted()` — posts journal entry (AR write-off) |
| `transfer.fee_paid` | Cashier | Updates `transfer_requests.status` to 'fee_paid', then auto-calls TransferProcessor::execute() for separation_fee (via payment_request.completed handler in Cashier/bootstrap.php) |

---

## 8. Cross-Module Dependencies

### 8.1 Transfers module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Members | MemberNumberGenerator (generate next number on conflict), NationalIdParser (parse recipient NID), FormNumberGenerator (generate form_number for new member on completion) |
| Archive | ArchiveService (takeSnapshot, recordNumberTransfer) |
| Payments | PaymentService (processPayment for direct pay) |
| Cashier | PaymentRequestService (queue payment for cashier processing) |
| Forms | FormBridge (submit TRANSFER_SEPARATION form) |
| Rules | RuleEngine (SEPARATION_FEE_YEAR_*, FORM_TRANSFER_FEE, DEVELOPMENT_FEE, CHILD_MANDATORY_SEPARATION_AGE, MALE_CHILD_FREEZE_AGE, DIVORCE_*, SPOUSE_*, WAIVER_FEE) |
| Pricing | PricingEngine (getMembershipPrice, calculateChildFee) |
| ServiceCatalog | ServicePrice (SVC_ANNUAL_MEMBER, SVC_ADDITION_FORM) |

### 8.2 Other modules that IMPORT FROM Transfers:

| Module | What it imports |
|--------|----------------|
| Members | References transfer_requests table in reports |
| Accounting | Listens to transfer.completed event |
| Cashier | Maps separation_fee payment type → transfer_requests table |

---

## 9. Permissions

| Key | Description |
|-----|-------------|
| transfer.initiate | Create/submit transfer requests |
| transfer.approve | Approve/reject/complete transfers |
| transfer.view | View transfer list and details |
| separation.initiate | Initiate separation (redundant with transfer.initiate) |
| separation.approve | Approve separation (redundant with transfer.approve) |
| separation.view | View separations (redundant with transfer.view) |
| waiver.initiate | Initiate waiver (used by Waiver module) |
| waiver.approve | Approve waiver (used by Waiver module) |
| waiver.view | View waivers (used by Waiver module) |

**Note:** The Transfers bootstrap registers permissions for Transfers, Separations, AND Waivers. Death and Divorce reuse `transfer.view` and `transfer.initiate` permissions.

---

## 10. Configuration Dependencies

| Rule Engine Keys | Purpose |
|-----------------|---------|
| SEPARATION_FEE_YEAR_1 through YEAR_5 | Percentage for separation fee by year |
| SEPARATION_FEE_YEAR_6_PLUS | Percentage for 6+ years |
| FORM_TRANSFER_FEE | Form fee amount (default 570) |
| DEVELOPMENT_FEE | Dev fee added to annual subscription |
| CHILD_MANDATORY_SEPARATION_AGE | Age for mandatory child separation (25) |
| MALE_CHILD_FREEZE_AGE | Age limit for male children |
| SPOUSE_2ND_FEE / SPOUSE_3RD_FEE / SPOUSE_4TH_FEE | Companion surcharge rates |
| DIVORCE_REQUEST_WINDOW | Max years after divorce to request |
| DIVORCE_MIN_MEMBERSHIP_YEARS | Min years of membership for divorce transfer |
| DIVORCE_SAME_FORM_FEE | Fee % for same-form spouse divorce |
| DIVORCE_JOINED_AFTER_FEE | Fee % for joined-after spouse divorce |
| DIVORCE_CHILD_UNDER_12 / 12_TO_16 / 16_TO_18 / OVER_18 | Child fees in acquired-member scenario |
| WAIVER_FEE | Waiver fee percentage (30%) |

| Service Catalog Codes | Purpose |
|----------------------|---------|
| SVC_ANNUAL_MEMBER | Annual member subscription base (492) |
| SVC_ANNUAL_SPOUSE | Annual spouse subscription (492) |
| SVC_ANNUAL_CHILD | Annual child subscription (222) |
| SVC_ANNUAL_TEMP | Annual temporary member subscription (222) |
| SVC_ADDITION_FORM | Addition form fee fallback (570) |

---

## 11. High-Risk Areas

### 11.1 TransferProcessor::execute() (CRITICAL)
- Runs inside a DB transaction
- Archives source member by setting `membership_number = NULL` FIRST, then creates new member with same number
- If number conflict is detected (shouldn't happen), falls back to `MemberNumberGenerator::generateNext()`
- If transaction fails mid-way: source member is archived but new member not created = DATA LOSS
- Race condition: if two requests try to complete simultaneously for same member

### 11.2 Membership Number Inheritance
- Source member's `membership_number` is set to NULL before new member gets it
- This is required because `membership_number` has a UNIQUE constraint
- The brief moment between NULL-ing and inserting new member = number is "free" if another process runs

### 11.3 Fee Calculation Complexity
- `SeparationFeeCalculator::calculate()` queries multiple systems: PricingEngine, RuleEngine, pricing_configs
- Years calculation rounds partial years UP (1 day into year 2 = year 2 rate)
- Companion surcharge uses the SAME tiered logic as adding new spouses/children from scratch
- If pricing_configs are missing or expired: falls back to $0.00 membership value

### 11.4 Required NOT NULL Columns (Common Failure Point)
- `members.date_of_birth` is NOT NULL — children may have empty/0000-00-00 DOB; TransferProcessor sanitizes before insert
- `members.phone_mobile` is NOT NULL — children have no phone column; falls through to source member
- `members.gender` is NOT NULL — defaults to 'male' if not derivable from NID
- `spouses.date_of_birth`, `spouses.marriage_date`, `spouses.join_date` are NOT NULL — use '1900-01-01' / today fallbacks
- `children.date_of_birth`, `children.gender` are NOT NULL — use NID parsing or '1900-01-01'/'male' fallbacks
- `temporary_members.date_of_birth`, `temporary_members.gender`, `temporary_members.category` are NOT NULL — defaults applied

### 11.5 Full Transfer Recipient Data
- Stored as JSON in `notes` field — fragile if JSON is malformed
- NID auto-parsing overwrites DOB and gender from form input
- No duplicate NID check against existing members before completion

### 11.6 Dependent Migration on Full Transfer
- ALL non-archived spouses, children, and temporary_members are moved to new member via UPDATE
- No validation that new member's qualifications match dependent requirements
- If transfer fails after dependents are moved but before commit: rollback handles it (within transaction)

---

## 12. Known Patterns & Gotchas

1. **Menu is in Members bootstrap**: Transfer/Divorce/Death/Waiver menu items are registered in `Members/bootstrap.php`, not in their own modules
2. **Permissions registered centrally**: All 9 permissions (transfer, separation, waiver) are in `Transfers/bootstrap.php`
3. **Death and Divorce reuse transfer permissions**: They use `transfer.view` and `transfer.initiate`, not separate permissions
4. **No soft delete**: `TransferRequest` has no `is_archived` column — records are permanent
5. **Form submission optional**: FormBridge is only called if `TRANSFER_SEPARATION` form definition exists
6. **Dual payment paths**: Payment can come via Cashier queue (PaymentRequestService) OR direct (PaymentService::processPayment via /pay endpoint)
7. **TransferEligibility** is a shared service: also used for divorce and waiver eligibility checks
8. **Male under-25 employed+graduated exception (2026-08-06)**: `store()` age validation now allows males under 25 IF both `is_employed=1` AND `graduation_date` is set — checks these fields before rejecting based on age alone.
9. **Marriage date vs membership date validation (2026-08-06)**: `store()` validates that `marriage_date` is not earlier than the membership's `activated_at` or `created_at` — rejects if marriage predates membership acquisition.
10. **create.php JS fix (2026-08-06)**: The child_separation dropdown no longer hides males under 25 from the selection list — server-side validation handles eligibility instead of client-side filtering.

---

## 13. Transfer Type Labels (Arabic)

| Type Code | Arabic Label |
|-----------|-------------|
| child_separation | فصل أبناء |
| child_mandatory_25 | تحويل وجوبي (25 سنة) |
| sports_conversion | تحويل رياضي |
| cross_branch | تحويل بين فروع |
| full_transfer | تحويل كامل |
| divorce | طلاق |
| death | وفاة |
| waiver | تنازل |

---

## Appendix: Files to Check Before Modifying Transfers

1. `app/Modules/Archive/Services/ArchiveService.php` — snapshot/number chain logic
2. `app/Modules/Cashier/bootstrap.php` — payment_request.completed listener (updates status)
3. `app/Modules/Accounting/bootstrap.php` — transfer.completed journal entry
4. `app/Modules/Members/bootstrap.php` — menu registration
5. `app/Modules/Rules/Services/RuleEngine.php` — all fee rule lookups
6. `app/Modules/Pricing/Services/PricingEngine.php` — membership value and child fee calculation
7. `app/Modules/Members/Services/MemberNumberGenerator.php` — number generation on conflict
