# Loan Module Rules & Patterns

This document captures every rule, pattern, and convention established in the **MortgageLoan** module. All future loan modules (e.g. Personal Loans, Vehicle Loans, Business Loans) must follow these rules exactly.

---

## 1. Module Structure

Each loan module lives under `app/Modules/<LoanType>/` with the following layout:

```
app/Modules/<LoanType>/
├── Controllers/
│   └── <LoanType>Controller.php
├── Views/
│   ├── index.php   — DataTables list page
│   ├── form.php    — Create / Edit form (6 accordion sections)
│   └── show.php    — Detail page + right-column approval panel (Section 7)
```

Routes are registered in `app/Config/Routes.php` inside the authenticated `filter => auth` group.

---

## 2. Routes

Every loan module exposes this standard route set under its own group prefix:

```php
$routes->group('<loan-prefix>', static function ($routes) {
    $routes->get('/',                        '<Controller>::index');
    $routes->get('datatable',               '<Controller>::ajaxDatatable');
    $routes->get('create',                  '<Controller>::create');
    $routes->post('store',                  '<Controller>::store');
    $routes->get('customer-search',         '<Controller>::customerSearch');
    $routes->get('customer-detail/(:num)',  '<Controller>::customerDetail/$1');
    $routes->post('customer-store',         '<Controller>::customerStore');
    $routes->post('customer-update/(:num)', '<Controller>::customerUpdate/$1');
    $routes->get('(:num)',                  '<Controller>::show/$1');
    $routes->get('(:num)/edit',             '<Controller>::edit/$1');
    $routes->post('(:num)/update',          '<Controller>::update/$1');
    $routes->post('(:num)/submit',          '<Controller>::submit/$1');
    $routes->post('(:num)/approval',        '<Controller>::updateApproval/$1');
});
```

---

## 3. Application Lifecycle / Status

Applications move through these statuses in order:

| Status | Description |
|---|---|
| `draft` | Created but not yet submitted. Only the creator / assigned officer can act. |
| `submitted` | Submitted for review. Approval workflow becomes active. |
| `under_review` | Being reviewed by an approver tier. |
| `approved` | Fully approved through all tiers. |
| `denied` | Denied at any tier. |

**Rule:** An application must be in `submitted` status or later before any approval tier can record a decision. Draft applications cannot be approved.

---

## 4. Approval Workflow — Tiers & Order

All loan modules use the same **six-tier** sequential approval chain:

```
Credit Supervisor  →  Credit Head  →  Risk Management  →  EVP Finance  →  [ EVP Operations | CEO / President ]
```

The final step is **mutually exclusive**: either EVP Operations or CEO / President acts, never both.

### 4.1 Database Columns

Each tier requires two columns in the loans table (ENUM, not VARCHAR):

```sql
credit_supervisor_status    ENUM('Pending','Approved','Denied')  DEFAULT 'Pending'
credit_supervisor_comment   TEXT     NULL
credit_head_status          ENUM('Pending','Approved','Denied')  DEFAULT 'Pending'
credit_head_comment         TEXT     NULL
risk_mgmt_status            ENUM('Pending','Approved','Denied')  DEFAULT 'Pending'
risk_mgmt_comment           TEXT     NULL
evp_finance_status          ENUM('Pending','Approved','Denied')  DEFAULT 'Pending'
evp_finance_comment         TEXT     NULL
evp_operations_status       ENUM('Pending','Approved','Denied')  DEFAULT 'Pending'
evp_operations_comment      TEXT     NULL
ceo_president_status        ENUM('Pending','Approved','Denied')  DEFAULT 'Pending'
ceo_president_comment       TEXT     NULL
```

Allowed status values: `Pending`, `Approved`, `Denied`. These are **title-cased** — not uppercase, not lowercase. The controller enforces this with a strict `in_array` check.

All twelve columns must be in the model's `$allowedFields` array.

### 4.2 Role-to-Tier Mapping

```php
$allowed = [
    'credit_supervisor' => ['credit_supervisor', 'super_admin'],
    'credit_head'       => ['credit_manager', 'super_admin'],
    'risk_mgmt'         => ['risk_officer', 'risk_manager', 'super_admin'],
    'evp_finance'       => ['evp_finance', 'super_admin'],
    'evp_operations'    => ['evp_operations', 'super_admin'],
    'ceo_president'     => ['ceo', 'president', 'super_admin'],
];
```

**Note:** `credit_supervisor` and `credit_head` are distinct tiers with distinct roles. The `credit_supervisor` role does NOT have access to the `credit_head` tier.

### 4.3 Sequential Gating Rules

These rules are enforced **both server-side (controller)** and **client-side (view)**. The controller is authoritative — view gating is for UX only.

| Tier | Pre-condition to act |
|---|---|
| `credit_supervisor` | None — can act on any submitted application |
| `credit_head` | `credit_supervisor_status === 'Approved'` |
| `risk_mgmt` | `credit_head_status === 'Approved'` |
| `evp_finance` | `risk_mgmt_status === 'Approved'` |
| `evp_operations` | `risk_mgmt_status === 'Approved'` AND `ceo_president_status === 'Pending'` |
| `ceo_president` | `risk_mgmt_status === 'Approved'` AND `evp_operations_status === 'Pending'` |

**EVP Operations and CEO / President are mutually exclusive** — once either one records a decision, the other is permanently locked.

### 4.4 Controller — `updateApproval()` Pattern

```php
public function updateApproval(int $id): ResponseInterface
{
    $record = $this->model->find($id);
    if (!$record) { return $this->error('Record not found.', 404); }

    if ($record['status'] === 'draft') {
        return $this->error('Application must be submitted before approvals can be recorded.', 403);
    }

    $roles   = session()->get('cwas_roles') ?? [];
    $tier    = $this->request->getPost('tier');
    $status  = $this->request->getPost('status');
    $comment = $this->request->getPost('comment') ?? '';

    $allowed = [
        'credit_supervisor' => ['credit_supervisor', 'super_admin'],
        'credit_head'       => ['credit_manager', 'super_admin'],
        'risk_mgmt'         => ['risk_officer', 'risk_manager', 'super_admin'],
        'evp_finance'       => ['evp_finance', 'super_admin'],
        'evp_operations'    => ['evp_operations', 'super_admin'],
        'ceo_president'     => ['ceo', 'president', 'super_admin'],
    ];

    // Role gate
    if (!isset($allowed[$tier])) { return $this->error('Invalid approval tier.'); }
    if (empty(array_intersect($roles, $allowed[$tier]))) {
        return $this->error('You do not have permission to update this approval tier.', 403);
    }
    if (!in_array($status, ['Pending', 'Approved', 'Denied'], true)) {
        return $this->error('Invalid status value.');
    }

    // Sequential gating
    $creditSupervisorStatus = $record['credit_supervisor_status'] ?? 'Pending';
    $creditHeadStatus       = $record['credit_head_status']       ?? 'Pending';
    $riskMgmtStatus         = $record['risk_mgmt_status']         ?? 'Pending';
    $evpOpsStatus           = $record['evp_operations_status']    ?? 'Pending';
    $ceoStatus              = $record['ceo_president_status']     ?? 'Pending';

    if ($tier === 'credit_head' && $creditSupervisorStatus !== 'Approved') {
        return $this->error('Credit Head cannot act until Credit Supervisor has approved.', 403);
    }
    if ($tier === 'risk_mgmt' && $creditHeadStatus !== 'Approved') {
        return $this->error('Risk Management cannot act until Credit Head has approved.', 403);
    }
    if ($tier === 'evp_finance' && $riskMgmtStatus !== 'Approved') {
        return $this->error('EVP Finance cannot act until Risk Management has approved.', 403);
    }
    if ($tier === 'evp_operations') {
        if ($riskMgmtStatus !== 'Approved') {
            return $this->error('EVP Operations cannot act until Risk Management has approved.', 403);
        }
        if ($ceoStatus !== 'Pending') {
            return $this->error('CEO / President has already acted as final approver. EVP Operations cannot also act.', 403);
        }
    }
    if ($tier === 'ceo_president') {
        if ($riskMgmtStatus !== 'Approved') {
            return $this->error('CEO / President cannot act until Risk Management has approved.', 403);
        }
        if ($evpOpsStatus !== 'Pending') {
            return $this->error('EVP Operations has already acted as final approver. CEO / President cannot also act.', 403);
        }
    }

    // Always check the return value — silent DB failures must surface
    if (!$this->model->update($id, [
        "{$tier}_status"  => $status,
        "{$tier}_comment" => $comment,
    ])) {
        return $this->error('Failed to save approval: ' . implode(' ', $this->model->errors()), 422);
    }

    $this->auditService->log('<table_name>', 'approval_updated',
        "Tier [{$tier}] set to [{$status}] on application #{$id}", $id
    );

    return $this->success('Approval status updated.', [
        'tier' => $tier, 'status' => $status, 'comment' => $comment,
    ]);
    // BaseController::success() automatically appends csrf_hash to the response
}
```

### 4.5 `getApprovalSummary()` — Model Helper

Every loan model must expose this method so the controller can pass a structured approval map to the view without the view reaching directly into raw record fields:

```php
public function getApprovalSummary(array $record): array
{
    return [
        'credit_supervisor' => ['status' => $record['credit_supervisor_status'], 'comment' => $record['credit_supervisor_comment']],
        'credit_head'       => ['status' => $record['credit_head_status'],       'comment' => $record['credit_head_comment']],
        'risk_mgmt'         => ['status' => $record['risk_mgmt_status'],         'comment' => $record['risk_mgmt_comment']],
        'evp_finance'       => ['status' => $record['evp_finance_status'],       'comment' => $record['evp_finance_comment']],
        'evp_operations'    => ['status' => $record['evp_operations_status'],    'comment' => $record['evp_operations_comment']],
        'ceo_president'     => ['status' => $record['ceo_president_status'],     'comment' => $record['ceo_president_comment']],
    ];
}
```

Pass it to the view as `'approval' => $this->model->getApprovalSummary($record)`. The view reads `$approval[$tierKey]['status']` and `$approval[$tierKey]['comment']`.

---

## 5. Fully-Approved Check

A helper method `isFullyApproved()` determines whether the entire approval chain is complete. Use it to lock editing and display locked state in the UI. It requires **all six tiers** (or the EVP Ops / CEO mutual exclusion) to be resolved.

```php
private function isFullyApproved(array $record): bool
{
    return ($record['credit_supervisor_status'] ?? '') === 'Approved'
        && ($record['credit_head_status']       ?? '') === 'Approved'
        && ($record['risk_mgmt_status']         ?? '') === 'Approved'
        && ($record['evp_finance_status']       ?? '') === 'Approved'
        && (
            ($record['evp_operations_status'] ?? '') === 'Approved'
            || ($record['ceo_president_status'] ?? '') === 'Approved'
        );
}
```

---

## 6. Edit Permission Rules

Editing a loan application is restricted by **both role and approval state**.

### 6.1 Who Can Edit

| Condition | Can Edit? |
|---|---|
| User has role `super_admin` | Yes (unless fully approved) |
| User has role `credit_manager` | Yes (unless fully approved) |
| User has role `credit_supervisor` | Yes (unless fully approved) |
| User's full name matches `assigned_credit_officer` on the record | Yes (unless fully approved) |
| Any other user | No |

### 6.2 When Editing is Blocked

Editing is **blocked for all users** when the application is fully approved (all tiers resolved per section 5). This is enforced in three places:

**1. Index table — JavaScript render** (hides the Edit button per row):
```javascript
var fullyApproved = (
    r.credit_supervisor_status === 'Approved' &&
    r.credit_head_status  === 'Approved' &&
    r.risk_mgmt_status    === 'Approved' &&
    r.evp_finance_status  === 'Approved' &&
    (r.evp_operations_status === 'Approved' || r.ceo_president_status === 'Approved')
);
var isAssigned = (r.assigned_credit_officer && r.assigned_credit_officer === currentUserName);
if (!fullyApproved && (isPrivilegedEditor || isAssigned)) {
    // render Edit button
}
```

**2. Show page — PHP** (hides the Edit button in the header):
```php
$isPrivilegedEditor = in_array('super_admin',        $roles, true)
                   || in_array('credit_manager',     $roles, true)
                   || in_array('credit_supervisor',  $roles, true);
$isAssigned         = !empty($r['assigned_credit_officer']) && $r['assigned_credit_officer'] === $currentUserName;
$fullyApproved      = ($r['credit_supervisor_status'] ?? '') === 'Approved'
                   && ($r['credit_head_status']       ?? '') === 'Approved'
                   && ($r['risk_mgmt_status']         ?? '') === 'Approved'
                   && ($r['evp_finance_status']       ?? '') === 'Approved'
                   && (
                          ($r['evp_operations_status'] ?? '') === 'Approved'
                       || ($r['ceo_president_status']  ?? '') === 'Approved'
                      );
$canEdit = !$fullyApproved && ($isPrivilegedEditor || $isAssigned);
```

**3. Controller — `edit()` and `update()`** (server-side guard, cannot be bypassed):
```php
if ($this->isFullyApproved($record)) {
    return redirect()->to("/<loan-prefix>/{$id}")
        ->with('error', 'This application is fully approved and can no longer be edited.');
}
```

### 6.3 Edit Button Placement on Show Page

The Edit button renders **before** the Back button in the page header action group:

```php
<div class="d-flex gap-2">
    <?php if ($canEdit): ?>
    <a href="<?= base_url('<loan-prefix>/' . $r['id'] . '/edit') ?>" class="btn btn-outline-primary btn-sm">
        <i class="fas fa-pencil me-1"></i> Edit
    </a>
    <?php endif ?>
    <a href="<?= base_url('<loan-prefix>') ?>" class="btn btn-outline-secondary btn-sm">
        <i class="fas fa-arrow-left me-1"></i> Back
    </a>
</div>
```

---

## 7. Index Page — DataTables Rules

### 7.1 Server-Side Processing

All loan index pages use DataTables with **server-side processing** via a dedicated AJAX endpoint (`GET /<loan-prefix>/datatable`).

- `serverSide: true`, `processing: true`
- Page length: `25`, `lengthChange: false`
- Default order: `application_date DESC`

### 7.2 Required Columns Returned by `ajaxDatatable()`

The following fields must always be included in the SELECT so the JS render functions have what they need:

```
id, file_ref_no, borrower_name, <loan_product_type_column>,
<amount_column>, application_date, status,
credit_supervisor_status, credit_head_status, risk_mgmt_status,
evp_finance_status, evp_operations_status, ceo_president_status,
assigned_credit_officer
```

`evp_operations_status` and `credit_supervisor_status` are required even if not displayed as columns — they are used by the fully-approved check and the Edit button gate.

The stat-summary cards on the index page compute per-tier breakdowns using only these four columns:

```php
$db->table('mortgage_applications')
    ->select('risk_mgmt_status, credit_head_status, evp_finance_status, ceo_president_status')
    ->get()->getResultArray();
```

### 7.3 JS Variables Injected from PHP

```javascript
var baseUrl            = '<?= base_url() ?>';
var currentUserName    = <?= json_encode(session()->get('cwas_user_name') ?? '') ?>;
var isPrivilegedEditor = <?php
    $roles = session()->get('cwas_roles') ?? [];
    echo (in_array('super_admin', $roles, true) || in_array('credit_manager', $roles, true)) ? 'true' : 'false';
?>;
```

### 7.4 Actions Column

The Actions column always renders a **View** button (visible to everyone). The **Edit** button is conditional per section 6.

```javascript
{
    data: null, orderable: false, searchable: false, className: 'text-center',
    render: function (d, t, r) {
        var html = '<div style="display:flex;gap:4px;justify-content:center">'
                 + '<a href="' + baseUrl + '<loan-prefix>/' + r.id + '" class="mtg-action-btn view" title="View">'
                 + '<i class="fas fa-eye"></i></a>';

        var fullyApproved = (
            r.credit_head_status  === 'Approved' &&
            r.risk_mgmt_status    === 'Approved' &&
            r.evp_finance_status  === 'Approved' &&
            (r.evp_operations_status === 'Approved' || r.ceo_president_status === 'Approved')
        );
        var isAssigned = (r.assigned_credit_officer && r.assigned_credit_officer === currentUserName);
        if (!fullyApproved && (isPrivilegedEditor || isAssigned)) {
            html += '<a href="' + baseUrl + '<loan-prefix>/' + r.id + '/edit" class="mtg-action-btn edit" title="Edit">'
                  + '<i class="fas fa-pencil"></i></a>';
        }
        return html + '</div>';
    }
}
```

### 7.5 Status Badge Colors

Use these consistent dot-badge colors across all modules:

| Status key | Background | Text | Border | Dot |
|---|---|---|---|---|
| `Approved` | `#EAF3DE` | `#3B6D11` | `#C0DD97` | `#639922` |
| `Denied` | `#FCEBEB` | `#A32D2D` | `#F7C1C1` | `#E24B4A` |
| `Pending` | `#FAEEDA` | `#854F0B` | `#FAC775` | `#EF9F27` |
| `Draft` | `#F1EFE8` | `#5F5E5A` | `#D3D1C7` | `#888780` |
| `Submitted` | `#E6F1FB` | `#0C447C` | `#B5D4F4` | `#185FA5` |
| `Under Review` | `#FAEEDA` | `#854F0B` | `#FAC775` | `#EF9F27` |

---

## 8. Form Rules (`form.php`)

### 8.1 Form Sections (Accordion)

The create/edit form is organized into **6 accordion sections**. Only Section 1 opens by default; the rest start collapsed. A "Next" / "Back" button pair at the bottom of each section navigates between them.

| Section | Title | Key Fields |
|---|---|---|
| S1 | Administrative & Loan Parameters | File ref, LBDI accounts, product type, amounts, rates, CBL rate |
| S2 | Borrower & Co-Borrower Personal Profiles | Customer search/select, identity, address |
| S3 | Employment & Income Matrix | Employer, job details, income grid, previous employment |
| S4 | Financial Ratios, Debts & Assets | Payments, DTI, disbursement split, assets, liabilities, references |
| S5 | Property Technical Appraisals & Narratives | Legal address, LTV, GPS, occupancy, narrative blocks |
| S6 | Compliance Document Checklist | 16 document checkboxes |

Section 7 (Approval Workflow) only appears on the **show page** as the right-column sticky panel.

### 8.2 Submit Buttons

Every form has two submit buttons that share the same `<form>` action:

```php
<!-- Save as draft -->
<button type="submit" name="_save_draft" value="1" class="btn btn-outline-primary">
    <i class="fas fa-floppy-disk me-1"></i><?= $isEdit ? 'Update Draft' : 'Save as Draft' ?>
</button>

<!-- Save and submit for review -->
<?php if (can('applications.submit') && (!$isEdit || ($r['status'] ?? '') === 'draft')): ?>
<button type="submit" name="_submit_for_review" value="1" class="btn btn-success">
    <i class="fas fa-paper-plane me-1"></i>Save &amp; Submit for Review
</button>
<?php endif ?>
```

**Do not use `onclick` to manipulate `form.action`** — the action attribute is already set correctly for both create and edit modes. Using onclick causes URL doubling in edit mode.

The "Submit for Review" button is also available on the form page header when `$isEdit && status === 'draft'`:

```php
<?php if ($isEdit && ($r['status'] ?? '') === 'draft' && can('applications.submit')): ?>
<form method="POST" action="<?= base_url('mortgage-loans/' . $r['id'] . '/submit') ?>" class="ms-auto">
    <?= csrf_field() ?>
    <button type="submit" class="btn btn-success btn-sm"
            onclick="return confirm('Submit this application for review?')">
        <i class="fas fa-paper-plane me-1"></i>Submit for Review
    </button>
</form>
<?php endif ?>
```

### 8.3 Controller Handling

Both `store()` and `update()` check for `_submit_for_review` after saving:

```php
if (!empty($post['_submit_for_review']) && $this->hasPermission('applications.submit')) {
    $this->model->update($id, ['status' => 'submitted']);
    $this->auditService->log('<table>', 'submitted', "Application #{$id} submitted for review", $id);
    return redirect()->to("/<loan-prefix>/{$id}")->with('success', 'Application saved and submitted for review.');
}
```

In `update()`, the submit action also requires `$record['status'] === 'draft'` before flipping to `submitted`.

### 8.4 Approval Fields Are Read-Only on Update

In `update()`, strip all approval fields from the POST data before saving. These fields are only writable via `updateApproval()`:

```php
foreach (['credit_supervisor','risk_mgmt','credit_head','evp_finance','evp_operations','ceo_president'] as $tier) {
    unset($data["{$tier}_status"], $data["{$tier}_comment"]);
}
```

### 8.5 `mapFromPost()` Static Helper

Every loan model must have a `public static function mapFromPost(array $p, int $userId): array` that maps the flat POST array to the exact set of DB columns, handling:

- `$dec` — decimal fields: returns string or null
- `$int` — integer fields: returns int or null
- `$str` — string fields: returns string or null
- `$bool` — tinyint booleans: `1` if truthy, `0` otherwise
- `$jsn` — repeating-row JSON fields (assets, liabilities, references, previous employment): filters empty rows, returns JSON string or null

This keeps both `store()` and `update()` controllers identical and ensures new fields are only added in one place.

Approval fields are **deliberately excluded** from `mapFromPost()` — they must remain writable only via `updateApproval()`.

### 8.6 File Reference Auto-Generation

If `file_ref_no` is blank on create, generate it automatically by passing `approved_amount` and `approved_currency` from the POST data:

```php
if (empty($post['file_ref_no'])) {
    $post['file_ref_no'] = $this->model->generateFileRef(
        (float) ($post['approved_amount']   ?? 0),
        $post['approved_currency'] ?? 'LRD'
    );
}
```

**Format:** `FR/ML-{S3}{S4}/{S5}{globalSeq:04d}/{dailySeq:03d}-{MON}-{DD}-{YY}`

| Section | Default | Condition | Override |
|---|---|---|---|
| S3 (after `ML-`) | `M-` | `approved_amount > 300000` | `BD-` |
| S4 | `MC` | `approved_currency === 'LRD'` | `SM` |
| S5 | `LRD/` | `approved_currency === 'USD'` | `USD/LRD/` |
| globalSeq | total record count + 1 | — | — |
| dailySeq | count of today's records + 1 | — | — |

**Examples:**

```
Standard LRD, amount ≤ $300k:   FR/ML-M-SM/LRD/0001/001-MAY-25-26
Standard USD, amount ≤ $300k:   FR/ML-M-MC/USD/LRD/0001/001-MAY-25-26
Large LRD, amount > $300k:      FR/ML-BD-SM/LRD/0001/001-MAY-25-26
Large USD, amount > $300k:      FR/ML-BD-MC/USD/LRD/0001/001-MAY-25-26
```

`generateFileRef()` uses `\Config\Database::connect()` directly (not `$this->countAllResults()`) so the global and daily queries do not interfere with each other or with the builder chain.

---

## 9. Financial Calculations

### 9.1 Currency Split (USD / LRD Disbursement)

The approved amount is split between USD and LRD disbursement using user-entered percentage rates. The form auto-calculates values via JavaScript; the controller receives already-computed values and stores them.

| Field | Formula |
|---|---|
| `usd_disbursement_value` | `approved_amount × (usd_disbursement_rate / 100)` |
| `lrd_disbursement_value` | `approved_amount × (lrd_disbursement_rate / 100) × cbl_market_current_rate` |

When `usd_disbursement_rate` is set, 70% of the loan is assumed USD and 30% LRD (the standard LBDI split). When 100% is set, the full amount is in the selected currency.

### 9.2 First Month Payments

| Field | Formula |
|---|---|
| `first_month_principal_payment` | `(approved_amount × 0.70) ÷ duration_months` |
| `first_month_interest_payment` | `(approved_amount × 0.70) × (interest_rate / 100) ÷ 12` |
| `monthly_principal_payment_usd` | `first_month_principal_payment + first_month_interest_payment` |
| `first_month_principal_payment_lrd` | `(approved_amount × lrd_pct × cbl_rate) ÷ duration_months` |
| `first_month_interest_payment_lrd` | `(approved_amount × lrd_pct × cbl_rate) × (interest_rate / 100) ÷ 12` |
| `monthly_principal_payment_lrd` | `first_month_principal_payment_lrd + first_month_interest_payment_lrd` |

**DB column name note:** The LRD monthly payment column in the DB and `$allowedFields` is `monthly_principal_payment_ld` (missing the 'r') — this is a known naming inconsistency; do not "fix" it without a migration.

### 9.3 DTI Ratios

| Field | Formula |
|---|---|
| `housing_dti_ratio` | `monthly_principal_payment_usd ÷ total_combined_income × 100` |
| `combined_dti_ratio` | `(housing_expenses + sum_of_liability_monthly_payments) ÷ total_combined_income × 100` |
| `commission_fee_3_percent` | `approved_amount × 0.03` |

The `combined_dti_ratio` is computed server-side in `MortgageApplicationModel::calculateCombinedDTI(array $p)`:

```php
public static function calculateCombinedDTI(array $p): float
{
    $income = (float) ($p['total_combined_income'] ?? 0);
    if ($income <= 0) {
        $income = array_sum(array_map('floatval', [
            $p['borrower_net_monthly_income']    ?? 0,
            $p['borrower_cash_incentive']        ?? 0,
            $p['borrower_net_rental_income']     ?? 0,
            $p['borrower_other_income']          ?? 0,
            $p['co_borrower_net_monthly_income'] ?? 0,
            $p['co_borrower_cash_incentive']     ?? 0,
            $p['co_borrower_net_rental_income']  ?? 0,
            $p['co_borrower_other_income']       ?? 0,
        ]));
    }
    if ($income <= 0) return 0.00;

    $housing = (float) ($p['first_month_principal_payment'] ?? 0)
             + (float) ($p['first_month_interest_payment']  ?? 0);

    $liabilityTotal = 0.0;
    foreach (($p['liabilities'] ?? []) as $row) {
        $payment = (float) ($row['monthly_payment'] ?? 0);
        if ($payment > 0) $liabilityTotal += $payment;
    }

    return round(($housing + $liabilityTotal) / $income * 100, 2);
}
```

Call it as `static::calculateCombinedDTI($p)` inside `mapFromPost()` for the `combined_dti_ratio` field.

### 9.4 Liabilities — Unpaid Balance Auto-Calc (JavaScript)

In the form, the unpaid balance for each liability row is auto-calculated client-side:

```
balance = monthly_payment × months_remaining
```

The `balance` field is read-only (stored as computed).

### 9.5 LTV Ratio (JavaScript)

```
loan_to_value_percentage = (approved_amount ÷ property_value_usd) × 100
```

---

## 10. Section 3 — Employment Rules

If `borrower_years_on_job < 2`, a previous employment table is shown and required. Same rule applies to co-borrower. The previous employment rows are submitted as `borrower_prev_emp[n][field]` and `co_borrower_prev_emp[n][field]`, and serialized to `borrower_prev_employment_json` / `co_borrower_prev_employment_json` via `$jsn()` in `mapFromPost()`.

---

## 11. Section 5 — GPS Coordinates

The property location uses two separate decimal fields:

```sql
gps_latitude   DECIMAL(10,7)   NULL    -- range -90 to 90
gps_longitude  DECIMAL(10,7)   NULL    -- range -180 to 180
```

These replace a legacy `gps_reading` text field. Store them separately; display them together as `lat, lng` in the show view.

---

## 12. Section 6 — Document Checklist (16 Items)

| DB column | Label |
|---|---|
| `doc_passport_photos` | Passport-Size Photographs |
| `doc_legal_ids` | Valid Legal IDs |
| `doc_pay_stubs` | Pay Stubs — last 3 months |
| `doc_employment_letter` | Employment / Appointment Letter |
| `doc_business_license` | Business License / Registration |
| `doc_audited_financials` | Audited Financial Statements |
| `doc_cv_resume` | CV / Résumé |
| `doc_drawing_boq` | Architectural Drawing & BOQ |
| `doc_property_picture` | Property Photographs |
| `doc_fire_insurance` | Fire Insurance Policy (LBDI loss payee) |
| `doc_tax_clearance` | Tax Clearance Certificate |
| `doc_bank_statements` | Bank Statements — 6 months |
| `doc_valuation_appraisal` | Independent Valuation / Appraisal Report |
| `doc_irrevocable_assignment` | First Irrevocable Salary Assignment |
| `doc_deed_copy` | Certified Deed / Land Title Copy |
| `doc_mpw_epa_permit` | MPW / EPA Construction Permits |

All stored as `TINYINT(1)` booleans (0/1). Rendered with green border + background when checked.

---

## 13. CBL Reference Pending Flag

```sql
credit_ref_pending_cbl   TINYINT(1)   DEFAULT 0
```

When set, the show page right panel displays a footer warning:

```php
<?php if (!empty($r['credit_ref_pending_cbl'])): ?>
<div class="card-footer bg-warning bg-opacity-10 small">
    <i class="fas fa-clock text-warning me-1"></i> CBL Reference Check Pending
</div>
<?php endif ?>
```

---

## 14. AJAX Patterns & CSRF

### 14.1 CSRF Configuration

`app/Config/Security.php` must have:

```php
public string $csrfProtection = 'cookie';
public bool   $regenerate     = false;   // MUST be false for AJAX apps
```

`$regenerate = false` is required because `app/Config/Cookie.php` sets `$httponly = true` globally. This makes the CSRF cookie invisible to JavaScript (`document.cookie` cannot read HttpOnly cookies). With `$regenerate = false` the hash baked at page-render time stays valid for the entire session and can be sent with every AJAX call without needing to read it from the cookie.

**Never set `$regenerate = true`** unless you also override the CSRF cookie to be non-HttpOnly.

### 14.2 CSRF Token in AJAX Requests

Bake the token name and hash into a JS variable at the top of the `scripts` section:

```javascript
var csrfHash = '<?= csrf_hash() ?>';
```

Include it in every AJAX POST as both a POST field and a header:

```javascript
$.ajax({
    url      : '...',
    method   : 'POST',
    dataType : 'json',
    headers  : { '<?= csrf_token() ?>': csrfHash },
    data     : { '<?= csrf_token() ?>': csrfHash, /* ... other fields ... */ }
})
```

After each successful response, refresh `csrfHash` from `res.csrf_hash`:

```javascript
.done(function (res) {
    if (res.csrf_hash) { csrfHash = res.csrf_hash; }
    // ...
})
```

### 14.3 `BaseController::success()` Always Includes `csrf_hash`

`BaseController::success()` appends `csrf_hash` to every JSON success response:

```php
protected function success(string $message, array $data = []): ResponseInterface
{
    return $this->json(['success' => true, 'message' => $message, 'csrf_hash' => csrf_hash()] + $data);
}
```

### 14.4 AJAX Response Contract

All AJAX endpoints return JSON with this base shape:

**Success (HTTP 200):**
```json
{ "success": true, "message": "...", "csrf_hash": "...", ...extra fields }
```

**Error (HTTP 400 / 403 / 404 / 422):**
```json
{ "success": false, "error": "Human-readable message" }
```

Always check `res.success === true` in `.done()`. Never check `res.status` — that field is used for tier status values (`'Approved'`, `'Denied'`, `'Pending'`) on approval responses.

### 14.5 `.fail()` Handler Must Show Server Error

Always include a `.fail()` handler that extracts the server's JSON error message:

```javascript
.fail(function (xhr) {
    var msg = 'Request failed (' + xhr.status + ').';
    try { var j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch (e) {}
    alert(msg);
    btn.prop('disabled', false).text('Save');
});
```

---

## 15. View Section Rules

### 15.1 Two Sections: `content` and `scripts`

Every view that contains JavaScript must use **two separate CI4 sections**:

```php
<?= $this->extend('App\Views\layouts\main') ?>

<?= $this->section('content') ?>
<!-- All HTML markup here -->
<?= $this->endSection() ?>

<?= $this->section('scripts') ?>
<script>
// All JavaScript here — jQuery and other libraries are already loaded
</script>
<?= $this->endSection() ?>
```

**Never place `<script>` tags inside `section('content')`.**

The layout loads jQuery at line ~349, after `renderSection('content')` at line ~344. Any script inside the content section runs before jQuery exists and throws `Uncaught ReferenceError: $ is not defined`.

### 15.2 Layout Script Load Order

The `main.php` layout renders sections in this order:

1. `renderSection('content')` — inside `<main>` before any JS files
2. jQuery CDN `<script>` tag
3. Bootstrap, DataTables, other CDN libraries
4. Layout's own inline `<script>` (sidebar, notifications, etc.)
5. `renderSection('scripts')` — **this is where page JS belongs**

### 15.3 CSRF Hash in the `scripts` Section

Because PHP is parsed on the server before the page reaches the browser, `<?= csrf_hash() ?>` works correctly inside `section('scripts')` even though it appears after the HTML. Place the `csrfHash` JS variable at the very top of the script block:

```php
<?= $this->section('scripts') ?>
<script>
var csrfHash = '<?= csrf_hash() ?>';
$(function () {
    // ...
});
</script>
<?= $this->endSection() ?>
```

---

## 16. Audit Logging

Every significant action must be logged via `$this->auditService->log()`:

```php
$this->auditService->log(
    '<table_name>',          // e.g. 'mortgage_applications'
    '<action>',              // e.g. 'created', 'updated', 'submitted', 'approval_updated'
    '<human readable message>',
    $id                      // record ID
);
```

Standard action slugs: `created`, `updated`, `submitted`, `approval_updated`.

---

## 17. CSS / UI Design Tokens

All loan module views share these design tokens and the Sora + DM Mono font stack.

### Fonts

```css
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
```

- **Sora** — all UI text (labels, buttons, table cells)
- **DM Mono** — reference numbers (`file_ref_no`) and monetary amounts

**Important:** Never apply `font-family` via a `*` wildcard — it overrides Font Awesome icon rendering. Apply it explicitly to named element types, excluding `<i>`.

### Design Tokens

```css
:root {
    --color-border-tertiary     : #e5e7eb;
    --color-background-primary  : #ffffff;
    --color-background-secondary: #f9fafb;
    --color-text-primary        : #111827;
    --color-text-secondary      : #6b7280;
    --color-text-muted          : #9ca3af;
    --border-radius-md          : 6px;
    --border-radius-lg          : 10px;
}
```

### Outer Card Container

```css
.loan-card {
    border: 0.5px solid var(--color-border-tertiary);
    border-radius: var(--border-radius-lg);
    overflow: hidden;
    background: var(--color-background-primary);
    box-shadow: none;
}
```

No shadows on the card. All borders are `0.5px`.

---

## 18. Customer AJAX Endpoints (Form Modal)

The form page includes a customer lookup/create/edit modal. The controller must expose these three AJAX endpoints:

### `GET customer-search?q=<term>`
Returns up to 15 matching customers as a flat JSON array. Used for typeahead autocomplete. No `success` wrapper — raw array.

### `POST customer-store`
Creates a new customer from the modal. Rules:
- `full_name` required
- Duplicate guard on `id_number` (if provided): returns HTTP 409 with `status: 'duplicate'`, `field: 'id_number'`, `duplicate: <existing customer object>`
- Duplicate guard on `phone` (if provided): same shape with `field: 'phone'`
- On success: returns `$this->success(message, $customerObject)`

### `GET customer-detail/(:num)`
Returns full customer record for auto-fill. Builds `id_details` by joining `id_type` + `id_number`. Returns `$this->success('OK', $customer)`.

### `POST customer-update/(:num)`
Updates an existing customer from the modal. Returns the updated customer object via `$this->success(message, $updatedCustomer)`.

---

## 19. Session Keys Reference

| Key | Type | Description |
|---|---|---|
| `cwas_user_id` | int | Logged-in user's ID |
| `cwas_user_name` | string | Logged-in user's full name (used for assigned officer matching) |
| `cwas_user_email` | string | Logged-in user's email |
| `cwas_roles` | array | Array of role slugs e.g. `['credit_manager']` |
| `cwas_permissions` | array | Array of permission slugs |
| `cwas_branch_id` | int\|null | User's branch |
| `cwas_logged_in` | bool | Authentication flag |

---

## 20. Permission Slugs

| Slug | Used for |
|---|---|
| `applications.view` | Viewing the index and show pages |
| `applications.create` | Accessing the create form and storing |
| `applications.edit` | Accessing the edit form and updating |
| `applications.submit` | Submitting a draft for review |

Call `$this->requirePermission('slug')` at the top of each controller method. Use `$this->hasPermission('slug')` for conditional logic within a method. `super_admin` role bypasses all permission checks automatically (handled in `AuthService::hasPermission()`).

---

## 21. Checklist for a New Loan Module

When building any new loan module, verify all of the following:

- [ ] Routes registered under `filter => auth` group
- [ ] `ajaxDatatable()` SELECT includes all six `*_status` columns plus `evp_operations_status`, `credit_supervisor_status`, and `assigned_credit_officer`
- [ ] All twelve approval columns are in the model's `$allowedFields`
- [ ] `getApprovalSummary()` includes all six tiers starting with `credit_supervisor`
- [ ] `updateApproval()` enforces sequential gating and mutual exclusion server-side, including Credit Supervisor → Credit Head prerequisite
- [ ] `updateApproval()` checks `$this->model->update()` return value and returns 422 on failure
- [ ] `isFullyApproved()` includes `credit_supervisor_status` in the check
- [ ] `isFullyApproved()` called in both `edit()` and `update()`
- [ ] `mapFromPost()` static method handles all form fields with proper type coercion; approval fields excluded
- [ ] Approval fields stripped from `update()` POST data — includes `credit_supervisor` in the list
- [ ] `$canEdit` in show.php includes `credit_supervisor` role as a privileged editor
- [ ] Edit button on show page renders before Back button
- [ ] Edit button on index page hidden via JS `fullyApproved` check (includes `credit_supervisor_status`)
- [ ] Form submit buttons use named submit buttons (`_save_draft`, `_submit_for_review`), no `onclick` action manipulation
- [ ] `calculateCombinedDTI()` static method present in model for server-side DTI calculation
- [ ] `app/Config/Security.php` has `$regenerate = false`
- [ ] All view JavaScript is inside `section('scripts')`, never inside `section('content')`
- [ ] `var csrfHash = '<?= csrf_hash() ?>'` declared at top of scripts section
- [ ] All AJAX POSTs send CSRF as both POST field and header
- [ ] AJAX `.done()` checks `res.success === true`, not `res.status === 'success'`
- [ ] AJAX `.fail()` extracts `xhr.responseText` JSON to show real error message
- [ ] All actions logged via `$this->auditService->log()`
- [ ] CSS uses explicit element selectors for Sora font — no `*` wildcard
- [ ] Status badge colors match the palette in section 7.5
- [ ] `credit_ref_pending_cbl` field present for CBL reference pending state
- [ ] Document checklist has all 16 items with correct DB column names
- [ ] GPS stored as separate `gps_latitude` / `gps_longitude` decimal fields, not a text `gps_reading` field
