# Dynamic Loan-to-Value (LTV) Limit --- ## CRED-6160 — 2026-05-09 ### Overview Introduced a **dynamic Loan-to-Value (LTV) limit** at the merchant level and wired it into the consumer application journey. Each application now computes both an **LTV against gross project amount** and an **LTV against net loan amount** at create/update time, persists the snapshot on `consumer_application_additional`, surfaces the values as an application comment, and feeds the merchant-level threshold into the BRE approval rules. --- ### Business Rules - **Configurable per merchant**: `merchant.loan_to_value_limit` stores an integer percentage threshold per merchant. - Editable on the Merchant Edit page (Process tab). - For **solar merchants** the field is only editable when the session permission `Merchant_LTV_alter_Yes|1-0-7-0` is present; otherwise the field is read-only and submitted via a hidden input so the existing value is preserved. - For non-solar merchants the field is always editable. - Marked `required` in the UI; the column itself is nullable in the database. - **Permission**: new "LTV alter" permission added to the Merchant module so the field can be gated per role. - **Calculation** (`GradeController::calculateLTV($application, $offer)`): - `ltv_gross_project_amount = (loan_amount / inverter_gross_project_amount) * 100` (zero if gross project amount is 0). - `ltv_net_loan_amount = (net_loan_amount / loan_amount) * 100` (zero if loan amount is 0). - `net_loan_amount` is derived via `autoBRE::calculateNetLoanAmount($loan_amount, $offer)`. - Returns the two LTV figures along with `loan_amount`, `inverter_gross_project_amount`, and `net_loan_amount` so downstream consumers (comments, logs) have the full context. - **Storage on the application**: the full LTV array is cached on `consumer_application_additional.loan_to_value_limit` (JSON column, model-cast to array) so subsequent reads do not need to recompute. - **BRE rule**: `loan_to_value_limit` is appended as a default rule in every approval policy generated by `JsonBREController`, and the merchant's configured `loan_to_value_limit` is injected into the payload. --- ### Logic Added #### Merchant-level configuration - Migration adds `loan_to_value_limit INT NULL` after `max_approval_value` on `merchant`. - Added `loan_to_value_limit` to `Merchant::$fillable`. - Merchant Edit blade renders an "LTV" numeric input within the Process tab, with the permission gating described above. - `MerchantController::update()` snapshots `$ltv_previous` before the update and, on save, writes a `MerchantLogs` entry `"Merchant LTV changed to by "` whenever the value changes (including to `null`). - New permission entry **"LTV alter"** added to `config/constants.php` under the Merchant module, with the tooltip "Enables modification of maximum loan-to-value ratio." in `config/permission_tooltips.json`. #### LTV calculation engine - `GradeController::calculateLTV($application, $offer)` returns the LTV array described above. The method now returns `loan_amount`, `inverter_gross_project_amount`, and `net_loan_amount` in addition to the two LTV percentages. #### Application create — `ConsumerApplicationController::store()` - After `application_element` and `application_addition` are populated, when the request carries an `offer_id`: - Resolves the `MerchantOffers` record. - Calls `GradeController::calculateLTV($consumer_application, $merchantOffer)`. - Assigns the result to `applicationAdditionObject->loan_to_value_limit` (saved as JSON). - Posts an application comment via `addComment()` titled **"Initial LTV Value Set."** with the gross-project and net-loan LTV percentages plus the supporting loan amount / gross project amount / net loan amount figures. - Inserts a `ContentLogger` row (`model = consumer_application`) capturing `ltv_gross_project_amount` and `ltv_net_loan_amount` with `old = ''` and the rounded new values, so the application data log timeline shows the initial LTV alongside other field changes. #### Application update — `ConsumerApplicationController::getEditPostConsumerApplication()` - After saving the application element, when an offer is resolvable (`$merchantOffer` from the form, otherwise looked up from `consumer_application->offer_id`) **and** the request contains either `gross_project_amount` or `offer_id`: - Recomputes LTV via `GradeController::calculateLTV(...)`. - Decodes the previous JSON snapshot from `applicationAdditionObject->loan_to_value_limit` and compares it to the new value at 2-decimal precision for both LTV fields. - If either LTV percentage has changed: - Posts a comment **"LTV Value Revised."** with old and new percentages plus the latest loan amount / gross project amount / net loan amount. - Inserts a `ContentLogger` row capturing old vs new for `ltv_gross_project_amount` and `ltv_net_loan_amount`. - Writes the new LTV array back to `applicationAdditionObject->loan_to_value_limit` and persists it. Unchanged saves do not generate comment/log noise. #### BRE wiring — `JsonBREController` - The default rule list returned by the controller always appends `$exhaustive_criteria_json['loan_to_value_limit']`, ensuring every generated policy contains the LTV rule. - When building the policy payload, the merchant's configured `loan_to_value_limit` (`$application->merchant->loan_to_value_limit`) is added so the BRE evaluates each application against the merchant-specific threshold. #### Auto-decision pipeline — `AutoDecision::handle()` - Applications submitted via the **MERCHANT API** source (`updated_by == 'MERCHANT API'`) skip the Admin `store()` / `getEditPost` paths, so the LTV snapshot was never being created for them. - Added an LTV bootstrap block before `generateSoftBureauReport()` runs. When `application->updated_by == 'MERCHANT API'`: - Re-fetches the application with `application_element` and `offer` eager-loaded (the upstream query only selected five columns). - If the `offer` relation resolves, calls `GradeController::calculateLTV($ltvApplication, $ltvApplication->offer)`. - Loads `ConsumerApplicationAdditional` for the application (creates the row if it does not yet exist), assigns the LTV array to `loan_to_value_limit`, and saves. - Posts the **"Initial LTV Value Set."** comment via `GradeController::addComment($id, 107, $comment)` (system user 107, matching the SSE flow). - Inserts a `ContentLogger` row (`model = consumer_application`, `user_id = 107`) capturing `old = ''` / `new = ` for both LTV fields, so the application data log timeline records the initial LTV. - Because this runs **before** the BRE call, the saved LTV is available when `loan_to_value_limit` is evaluated as part of the policy rule list — keeping MERCHANT API applications on parity with admin-created ones. - The block is contractually safe: the MERCHANT API source guarantees `offer_id` and `gross_project_amount` are present, so no fallback handling is required. --- ### Impact — Files Changed / Added | File | Type | Change | |------|------|--------| | `database/migrations/2026_05_08_221500_add_ltv_to_merchant_table.php` | Added | Adds nullable `loan_to_value_limit INT` column to `merchant` after `max_approval_value`. | | `database/migrations/2026_05_09_124433_add_loan_to_value_limit_to_consumer_application_additional_table.php` | Added | Adds nullable JSON `loan_to_value_limit` column to `consumer_application_additional`. | | `app/Merchant.php` | Modified | Added `loan_to_value_limit` to `$fillable`. | | `app/ConsumerApplicationAdditional.php` | Modified | Added `$casts = ['loan_to_value_limit' => 'array']` so the JSON snapshot round-trips as an array. | | `app/Http/Controllers/Admin/MerchantController.php` | Modified | Captures `$ltv_previous`, writes a `MerchantLogs` entry on change of `loan_to_value_limit`. | | `app/Http/Controllers/Grade/GradeController.php` | Modified | `calculateLTV()` now returns `loan_amount`, `inverter_gross_project_amount`, and `net_loan_amount` alongside the two LTV percentages. | | `app/Http/Controllers/Admin/ConsumerApplicationController.php` | Modified | `store()`: calculates and saves initial LTV, posts an "Initial LTV Value Set." comment, and inserts a `ContentLogger` entry. `getEditPostConsumerApplication()`: recalculates LTV, compares against cached snapshot, posts a "LTV Value Revised." comment and a `ContentLogger` entry on change. | | `app/Http/Controllers/JsonBREController.php` | Modified | Appends `loan_to_value_limit` to the default rule list; injects the merchant's `loan_to_value_limit` into the BRE policy payload. | | `app/Console/Commands/AutoDecision.php` | Modified | Added an LTV bootstrap block (and `ConsumerApplicationAdditional` / `ContentLogger` / `MerchantOffers` imports). For `updated_by == 'MERCHANT API'` applications, computes the LTV via `GradeController::calculateLTV()`, saves it on `consumer_application_additional`, posts the "Initial LTV Value Set." comment, and writes a `ContentLogger` entry — runs before `generateSoftBureauReport()` so the BRE picks up the saved value. | | `resources/views/admin/merchant/edit.blade.php` | Modified | Adds the LTV numeric input in the Process tab with the solar-merchant permission gating, plus a `$('[data-toggle="tooltip"]').tooltip()` initializer. | | `resources/views/admin/user/user_types.blade.php` | Modified | Tooltip rendering fix (unrelated tooltip-display issue bundled with the LTV blade work). | | `config/constants.php` | Modified | Adds the "LTV alter" permission entry under the Merchant module. | | `config/permission_tooltips.json` | Modified | Adds tooltip text for the new "LTV alter" permission. | --- ### Flows Impacted 1. **Merchant onboarding / edit** — Operators must now set an LTV value on the Merchant Edit page. Changes are recorded in `MerchantLogs`; access is gated by the "LTV alter" permission for solar merchants. 2. **Consumer application creation** — Whenever an application is saved with an offer attached, an initial LTV snapshot is computed, persisted on `consumer_application_additional`, and surfaced both as an application comment and a `ContentLogger` entry. 3. **Consumer application edit** — Updates to `gross_project_amount` or `offer_id` trigger LTV recomputation. If the LTV percentages change (compared at 2-decimal precision), a "LTV Value Revised." comment is added and a `ContentLogger` entry records the delta. Saves that leave LTV unchanged are silent. 4. **MERCHANT API auto-decision pipeline** — Applications coming in via the MERCHANT API source bypass the Admin store/edit endpoints. The `AutoDecision` console command now performs the same LTV bootstrap (calculate → persist → comment → ContentLogger) just before the BRE runs, so MERCHANT API applications carry the LTV snapshot when `loan_to_value_limit` is evaluated by the BRE. 5. **BRE approval** — Every policy generated through `JsonBREController` now carries the `loan_to_value_limit` rule, evaluated against the merchant-configured threshold. Applications can therefore be flagged or rejected when their computed LTV breaches the merchant's limit. 6. **Audit / activity timeline** — `ContentLogger` rows (`model = consumer_application`) for the LTV fields make the LTV history visible alongside other tracked field changes in the application data log views.