# Installments Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Installments module manages **membership fee payment plans** that allow members to pay their membership value in monthly installments with interest. It handles:
- Installment plan creation with interest calculation (diminishing balance method)
- Monthly payment schedule generation
- Individual installment payment collection
- Cheque tracking and upload (required for installment-based activation)
- Member activation after all cheques submitted
- Default detection (3+ overdue items past 90 days = plan defaulted, member dropped)
- Early payoff calculation (principal only, waive future interest)
- Plan completion tracking

It does **NOT** directly manage:
- The initial down payment processing (Payments/Cashier modules handle that)
- Member status lifecycle beyond activation-on-cheques and dropping-on-default
- Subscription-related installment plans (different from membership installments)

---

## 2. Directory & File Structure

```
app/Modules/Installments/
├── bootstrap.php                              # Permission registration only (menu via Members)
├── Routes.php                                 # 7 web routes
├── Controllers/
│   ├── InstallmentController.php             # Plan CRUD + individual payment
│   └── ChequeController.php                  # Cheque upload + member activation trigger
├── Models/
│   ├── InstallmentPlan.php                   # Plan model (no soft delete, with events)
│   └── InstallmentScheduleItem.php           # Schedule item model
├── Services/
│   ├── InstallmentCalculator.php             # Plan calculation (interest, schedule)
│   ├── ChequeService.php                     # Cheque completion check + member activation
│   └── DefaultChecker.php                    # Cron-callable: detect defaults, drop members
└── Views/
    ├── index.php                             # All plans list with overdue indicators
    ├── create.php                            # New plan form (amount, down payment, months)
    ├── show.php                              # Plan detail + payment schedule
    └── cheques.php                           # Cheque upload interface + status
```

---

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

### 3.1 `installment_plans` Table (21 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 |
| related_entity_type | varchar(100) | YES | | | e.g. 'membership_fee' |
| related_entity_id | bigint unsigned | YES | | | FK to related entity |
| total_amount | decimal(15,2) | NO | | | Full membership value |
| down_payment | decimal(15,2) | NO | | | Minimum 25% of total |
| remaining_balance | decimal(15,2) | NO | | | total - down_payment |
| interest_rate | decimal(5,2) | NO | | 22.00 | Annual interest rate % |
| total_interest | decimal(15,2) | NO | | 0.00 | Calculated total interest |
| total_with_interest | decimal(15,2) | NO | | | remaining + total_interest |
| number_of_months | int unsigned | NO | | | Max governed by rule (default 30) |
| monthly_payment | decimal(15,2) | NO | | | Average monthly payment |
| start_date | date | NO | | | Schedule starts from |
| down_payment_receipt | varchar(50) | YES | | | Receipt reference |
| cash_settlement_date | date | YES | | | Deadline for cash settlement |
| is_cash_settled | tinyint(1) | NO | | 0 | If settled in cash early |
| status | varchar(50) | NO | MUL | active | active/completed/defaulted |
| notes | text | YES | | | |
| 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):**
- completed: 16
- active: 5

### 3.2 `installment_schedule` Table (324 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| installment_plan_id | bigint unsigned | NO | MUL | | FK to installment_plans |
| installment_number | int unsigned | NO | | | Sequential number (1, 2, 3...) |
| due_date | date | NO | MUL | | When payment is due |
| amount | decimal(15,2) | NO | | | Total payment (principal + interest) |
| principal | decimal(15,2) | NO | | | Principal portion |
| interest | decimal(15,2) | NO | | 0.00 | Interest portion |
| remaining_after | decimal(15,2) | NO | | 0.00 | Balance after this payment |
| paid_amount | decimal(15,2) | NO | | 0.00 | Amount actually paid |
| payment_id | bigint unsigned | YES | MUL | | FK to payments |
| status | varchar(30) | NO | MUL | pending | pending/paid/overdue |
| paid_at | timestamp | YES | | | |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | CURRENT_TIMESTAMP | on update |

**Current data distribution (status):**
- paid: 202
- pending: 122

### 3.3 `installment_cheques` Table (1 row as of 2026-06-10)

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| installment_plan_id | bigint unsigned | NO | MUL | | FK to installment_plans |
| cheque_number | varchar(50) | NO | MUL | | Bank cheque reference |
| bank_name | varchar(200) | NO | | | Issuing bank |
| cheque_date | date | NO | | | Date on cheque |
| cheque_amount | decimal(15,2) | NO | | | Amount on cheque |
| scan_path | varchar(500) | NO | | | File path to scanned image |
| scan_original_name | varchar(300) | YES | | | Original filename |
| uploaded_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| uploaded_by | bigint unsigned | YES | | | FK to employees |
| notes | varchar(500) | YES | | | |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | CURRENT_TIMESTAMP | on update |

---

## 4. Routes

| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | /installments | InstallmentController@index | auth | installment.view |
| GET | /installments/create/{memberId} | InstallmentController@create | auth | installment.create |
| POST | /installments/store/{memberId} | InstallmentController@store | auth, csrf | installment.create_plan |
| GET | /installments/{id} | InstallmentController@show | auth | installment.view |
| POST | /installments/{planId}/pay/{scheduleId} | InstallmentController@payInstallment | auth, csrf | installment.pay |
| GET | /installments/{planId}/cheques | ChequeController@index | auth | installment.view |
| POST | /installments/{planId}/cheques | ChequeController@store | auth, csrf | installment.create_plan |

---

## 5. Core Business Flows

### 5.1 Installment Plan Creation

```
1. GET /installments/create/{memberId}
   - Show form with member info
   - Warn if member already has an active plan

2. POST /installments/store/{memberId}
   - Validate: total_amount, down_payment (>= 25%), months (1 to max)
   - InstallmentCalculator::calculate():
     a. Get interest rate from INSTALLMENT_INTEREST_RATE rule (default 22%)
     b. Get min down payment % from INSTALLMENT_MIN_DOWN_PAYMENT (default 25%)
     c. Get max months from INSTALLMENT_MAX_MONTHS (default 30)
     d. Apply BoardOffer overrides if active (may change rate/max months)
     e. Calculate diminishing balance schedule:
        - Monthly principal = remaining / months
        - Monthly interest = running_balance * (annual_rate / 1200)
        - Last month adjusts for rounding
     f. Calculate cash settlement date (CASH_PAYMENT_WINDOW days, default 30)
   - DB transaction:
     a. Insert installment_plans record
     b. Insert N installment_schedule items
   - Dispatch: installment_plan.created event
```

### 5.2 Individual Installment Payment

```
1. POST /installments/{planId}/pay/{scheduleId}
2. Validate: plan exists, schedule item exists and not already paid
3. Call PaymentService::processPayment():
   - payment_type = 'installment'
   - related_entity_type = 'installment_schedule'
4. Update schedule item: paid_amount, payment_id, status='paid', paid_at
5. Check if all items are paid → mark plan as 'completed'
6. Dispatch: installment.paid event
```

### 5.3 Cheque Upload + Member Activation

```
1. POST /installments/{planId}/cheques
   - Validate: plan active, cheque data (number, bank, date, amount, scan file)
   - Validate file: JPEG/PNG/WebP/PDF, max 10MB
   - Store file to storage/uploads/cheques/
   - Insert installment_cheques record
2. After upload, check: ChequeService::allChequesSubmitted()
   - Required count = plan's number_of_months
   - Uploaded count >= required
3. If all submitted → ChequeService::activateMemberAfterCheques():
   - Verify member status is 'pending_cheques'
   - Assign membership number via MemberNumberGenerator
   - Update member: status='active', activated_at
   - Dispatch: member.activated event
```

### 5.4 Default Detection (Cron: InstallmentDefaultJob)

```
1. Find plans where:
   - Plan status = 'active'
   - 3+ schedule items are 'pending' AND due_date < NOW() - 90 days
2. For each defaulted plan:
   a. Mark plan status = 'defaulted'
   b. Mark all pending items as 'overdue'
   c. If member is 'active' → set status = 'dropped'
   d. Dispatch: member.dropped event
```

### 5.5 Early Payoff (InstallmentCalculator::calculateEarlyPayoff)

```
- Sum remaining principal of all pending/overdue items
- Add only current month's interest (first pending item)
- Waive all future interest
- Return: total_early_payoff, interest_saved
```

---

## 6. Interest Calculation Method

**Diminishing Balance:**
```
monthly_principal = remaining_balance / number_of_months
monthly_rate = annual_rate / 12 / 100

For each month i:
  interest_i = running_balance * monthly_rate
  payment_i = monthly_principal + interest_i
  running_balance -= monthly_principal
  (last month: principal = remaining balance for rounding)
```

**Default parameters:**
- Annual rate: 22% (configurable via rule)
- Min down payment: 25% (configurable via rule)
- Max months: 30 (configurable via rule, BoardOffer can override)
- Cash settlement window: 30 days

---

## 7. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `installment_plan.created` | `{plan_id, member_id}` | InstallmentController::store |
| `installment.paid` | `{plan_id, schedule_id, member_id}` | InstallmentController::payInstallment |
| `member.activated` | `{member_id, membership_number, payment_method:'installment'}` | ChequeService::activateMemberAfterCheques |
| `member.dropped` | `{member_id, reason, installment_plan_id}` | DefaultChecker, InstallmentDefaultJob |

---

## 8. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `installment.plan_created` | Accounting | Create AR (accounts receivable) journal entry |
| `installment.paid` | Accounting | Update AR, recognize revenue |
| `member.dropped` | Accounting | Write off AR balance |

**Note:** The event name dispatched is `installment_plan.created` but Accounting listens for `installment.plan_created` — potential inconsistency (check if both fire or if there's an alias).

---

## 9. Cross-Module Dependencies

### 9.1 Installments module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Payments | PaymentService::processPayment() — actual money processing |
| Rules | RuleEngine — interest rate, min down %, max months, cash window |
| Members | MemberNumberGenerator (for activation after cheques) |
| Members | BoardOfferService (installment overrides for promotions) |

### 9.2 Other modules that IMPORT FROM Installments:

| Module | What it imports |
|--------|----------------|
| Payments | PaymentLifecycleService creates installment plans on installment-method payments |
| Payments | PaymentController creates plans inline for membership payments |
| Payments | BalanceCalculator queries installment_plans/schedule for member balance |
| Members | MemberController queries active plan for member show page |
| Members | MembershipPaymentGuard knows NOT to auto-activate for installment (cheques needed) |
| Members | MembershipRulesService checks overdue installments for eligibility |
| Members | RetroactiveMembershipService creates retroactive plans with cheques |
| Accounting | GLSyncService reads installment_plans/schedule for journal sync |
| Accounting | AccountingIntegrationService::onInstallmentPlanCreated/Paid |
| Carnets | CarnetPrintService checks overdue installments for card eligibility |
| Dashboard | DashboardDataService counts overdue installments for alerts |
| Notifications | NotificationController queries upcoming installments |
| Reports | ReportEngine generates installment status reports |
| Archive | ArchiveService includes installment_plans in member archive |
| Audit | AuditService translates installment table names |

---

## 10. Permissions

| Key | Description |
|-----|-------------|
| installment.view | View installment plans and schedule |
| installment.create | Access create plan form |
| installment.create_plan | Submit new installment plan |
| installment.pay | Process an installment payment |
| installment.record_payment | Record a payment against schedule item |
| installment.modify_plan | Modify an existing plan |

---

## 11. Background Processes (Cron)

| Job | File | Schedule | What it does |
|-----|------|----------|--------------|
| InstallmentDefaultJob | cron/jobs/InstallmentDefaultJob.php | Always (shouldRun: true) | Detect 3+ overdue past 90 days, default plan, drop member |
| InstallmentReminderJob | cron/jobs/InstallmentReminderJob.php | Always (shouldRun: true) | Send SMS 7 days before due date |

---

## 12. Configuration Dependencies

| Config Source | Key | Purpose |
|--------------|-----|---------|
| Rules | INSTALLMENT_INTEREST_RATE | Annual interest rate (default 22%) |
| Rules | INSTALLMENT_MIN_DOWN_PAYMENT | Minimum down payment % (default 25%) |
| Rules | INSTALLMENT_MAX_MONTHS | Maximum installment months (default 30) |
| Rules | CASH_PAYMENT_WINDOW | Days for cash settlement option (default 30) |
| Members | BoardOfferService | Can override max_months and interest_rate during promotions |

---

## 13. Plan Status Lifecycle

```
active → completed   (all schedule items paid)
active → defaulted   (3+ items overdue past 90 days — cron)
```

**Schedule Item Status:**
```
pending → paid       (payment processed)
pending → overdue    (plan defaulted, all pending items become overdue)
```

---

## 14. High-Risk Areas

### 14.1 ChequeService Member Activation (CRITICAL)
- Single path to activate installment-paying members
- Bypasses MembershipPaymentGuard (direct status update + number assignment)
- If cheque count check is wrong: member activated without all cheques
- Status check `pending_cheques` is strict — member MUST be in this state

### 14.2 Multiple Plan Creation Sources
- InstallmentController::store (direct via UI)
- PaymentLifecycleService::createInstallmentPlan (from Payments module)
- PaymentController (inline plan creation during membership payment)
- RetroactiveMembershipService (retroactive entries)
- Each source has slightly different fields/validation — inconsistency risk

### 14.3 Default Detection Threshold
- 90 days grace + 3 items overdue is a hard threshold
- No warning notifications before defaulting (only reminder 7 days before due)
- Dropping is **automatic and irreversible** via cron
- No manual override to prevent auto-default

### 14.4 Interest Calculation Precision
- Uses `bcmul`/`bcdiv` with 2 decimal places for money
- Monthly rate calculated to 10 decimal places
- Last-month rounding adjustment prevents penny drift
- If plan is created with amount=0 or months=0: division by zero risk (validated but double-check)

### 14.5 File Upload Security
- Cheque scans stored in `storage/uploads/cheques/`
- MIME type validated (JPEG, PNG, WebP, PDF)
- File size limit: 10MB
- Filename is randomized (time + random bytes) — no user-controlled names reach disk

### 14.6 Event Name Mismatch
- Controller dispatches `installment_plan.created` (underscore)
- Accounting listens for `installment.plan_created` (dot)
- If these don't match, accounting entries won't be auto-posted for new plans

---

## 15. Known Patterns & Gotchas

1. **No soft delete**: Plans and schedule items are never archived — status tracks lifecycle
2. **One active plan check**: The create form warns but does NOT prevent creating multiple active plans for same member
3. **Cheque activation path**: Member goes potential → payment_pending → pending_cheques → active (skips the normal MembershipPaymentGuard flow)
4. **Down payment is separate**: The down payment is processed by Payments/Cashier before the installment plan is created — plan only tracks the remaining
5. **Menu via Members module**: Installment menu item appears under the Members sidebar group
6. **No partial installment payment**: Each schedule item is paid in full or not at all
7. **Cash settlement date**: Calculated but not enforced — no cron job checks if cash window expired
8. **BoardOffer can change terms**: Active board offers dynamically override interest rate and max months at calculation time

---

## Appendix: Files to Check Before Modifying Installments

1. `app/Modules/Payments/Services/PaymentLifecycleService.php` — Creates plans on installment payments
2. `app/Modules/Payments/Controllers/PaymentController.php` — Inline plan creation
3. `app/Modules/Payments/Services/BalanceCalculator.php` — Queries installment data for balance
4. `app/Modules/Members/Services/MembershipPaymentGuard.php` — Knows about installment activation path
5. `app/Modules/Members/Services/MembershipRulesService.php` — Checks overdue installments
6. `app/Modules/Members/Services/RetroactiveMembershipService.php` — Creates retroactive plans
7. `app/Modules/Accounting/bootstrap.php` — Listens to installment events
8. `app/Modules/Accounting/Services/AccountingIntegrationService.php` — Journal entries
9. `app/Modules/Accounting/Services/GLSyncService.php` — GL sync from installment tables
10. `cron/jobs/InstallmentDefaultJob.php` — Auto-default and member drop
11. `cron/jobs/InstallmentReminderJob.php` — SMS reminders before due date
