# Table of Offers — Decisioning Engine --- ## CRED-6174 — 2026-07-23 *(Table of Offers originally introduced in CRED-4195 / CRED-4265. This document covers the current decisioning engine after the trait separation and the Power Saving / Banking-ABB / Bureau Surrogate enhancements delivered under CRED-6174.)* **Primary code:** - `app/Http/Controllers/Grade/TableOfOffersTrait.php` — the decisioning engine (this document) - `app/Http/Controllers/Grade/GradeController.php` — the caller (BRE driver) that mixes in the trait - Related webhook/payload behaviour is documented separately in `12.table-of-offers-mconnect-webhook.md`. --- # Part 1 — Business Overview ## What is Table of Offers? When an application clears the automated credit rules (JSON-BRE), the customer does not always qualify for the *exact* loan amount and tenure they applied for. **Table of Offers (TOF)** is the engine that answers the question: > *"Given everything we now know about this customer, what is the best offer (amount + EMI + tenure) we can actually approve them for?"* Instead of a simple approve/reject, TOF evaluates **every EMI plan the merchant offers** against the customer's affordability, then automatically selects and applies the single best offer to the application. This lets us approve a *reduced but viable* offer rather than rejecting a customer outright. ## The four offer "logics" TOF can arrive at an eligible offer through four different routes ("logics"). Each route produces a candidate (an approved amount + EMI + offer). All candidates that succeed are collected, and the **best one wins** (see *How the winner is chosen* below). | # | Logic | Plain-language meaning | When it applies | |---|-------|------------------------|-----------------| | 1 | **Minimum Tenure** | Approve the applied amount, on the *shortest* tenure the customer can afford within FOIR limits. | Regular policies pass and EMI data exists. | | 2 | **Residual EMI** | The applied amount is not affordable, so we work backwards from the *residual* (left-over) EMI capacity to find the largest loan amount the customer can support. | Regular policies pass but the applied amount fails affordability (no viable minimum-tenure offer). | | 3 | **Bureau Surrogate Limit** | Use a credit-bureau-derived limit (e.g. existing loan exposure) as the cap and find the best offer within it. | A bureau-surrogate policy passed and a bureau limit is available. | | 4 | **Power Saving / Banking-ABB (custom EMI)** | Affordability is derived from an *external signal* instead of declared income — electricity-bill savings (Power Saving) or average bank balance (Banking-ABB) — and used as a custom EMI to size the loan. | The corresponding surrogate policy passed. | ### Power Saving vs. Banking-ABB (the custom-EMI flows) - **Power Saving** — for solar loans. The affordable EMI is estimated as **1.5 × the customer's monthly electricity bill / inverter power savings** (sourced from the utility-bill validation or the application's solar power-savings figure). The idea: the money the customer saves on electricity can service the EMI. - **Banking-ABB** — the affordable EMI is estimated as **a multiplier × the customer's Average Bank Balance (ABB)**, read from the Pirimid banking-analysis file. The multiplier defaults to 0.5 and is configurable. In both flows, if the customer's *current applied* EMI is already **within** the estimated affordable EMI, TOF auto-approves the applied amount (no downsizing needed). ## How the winner is chosen Once all applicable logics have produced candidates, TOF selects the final offer by: 1. **Highest approved amount wins** — we always prefer to approve the customer for the largest viable loan. 2. **Tie-break: lowest tenure** — if two candidates approve the same amount, the shorter tenure wins (customer repays faster, less risk). The winning offer is then **applied to the application**: the EMI plan is regenerated, the approved amount and residual EMI are saved, and the loan is (re)decisioned. A system comment is added to the application timeline explaining the change (applied amount vs. eligible amount, tenure, and the reason), and an approval log entry records which logic drove the decision. ## Guardrails (business rules) - **Amount band:** an offer is only valid if its amount is **≥ ₹50,000 and ≤ the customer's applied amount**. TOF never approves *more* than the customer asked for, and never below the ₹50k floor. - **Net loan amount < ₹5,00,000:** the JSON-BRE TOF entry point only runs when the net loan amount is below ₹5L. - **Merchant opt-in:** TOF only runs for merchants with the `table_of_offers` flag enabled. - **Solar handling:** for solar applications, TOF runs only when the sub-category and on-grid conditions are met. - **If nothing qualifies:** the application is marked failed ("Table of Offers failed") for the no-touch/solar-form flow. --- # Part 2 — Technical Reference ## Architecture: why a trait? `TableOfOffersTrait` is mixed into `GradeController` (`use TableOfOffersTrait;`, GradeController.php:78). The BRE flow (the *producer*) and the TOF flow (the *consumer*) share a **single `$this`** instance, so TOF can read the state the BRE already populated — `policyPassed`, `emiData`, `Acf_foir`, `Afoir`, `Atenure`, `bureauSurrogateLimits`, `powerSavingPolicyPassed`, `bankingPolicyPassed`, `bureauSurrogatePolicyPassed`, `tofFoirLimit`, `tofCfFoirLimit`, `appId` — without copying it between two objects. ## Entry points (called from GradeController) | Caller | Location | Notes | |--------|----------|-------| | `initiateTableOfOffers(...)` | GradeController.php:1943 | JSON-BRE path. Extracts TOF limits (`max_net_tenure`, `foir`, `cf_foir`) from the passed grade rules, sets attributes, runs the decision flow, then fires the mConnect webhook. Guarded by `netLoanAmount < 500000` + merchant/solar conditions. | | `saveDecisionInTable(...)` | GradeController.php:1770 | Legacy/power-saving path. Called after all rules (incl. power-saving) are evaluated. | Both converge on **`saveDecisionInTable()`**, the orchestrator. ## Core flow — `saveDecisionInTable()` ``` saveDecisionInTable() ├── (if policyPassed or bureauSurrogatePolicyPassed) │ ├── delete existing loan_decision_table + loan_decision_approved_amount rows for the app │ ├── buildDecisionTable() → assemble per-offer cf_foir / foir / tenure rows │ ├── processOffers() → turn the table into offer records (+ pass/fail status & reason) │ └── handleRegularAdminCall() → minimum-tenure / residual-EMI / bureau-surrogate eligibility ├── handleSolarFormCall() → no-touch / solar-form flow (when not an admin call) ├── runTOFWithCustomEMI() → Power Saving + Banking-ABB custom-EMI flows └── setFinalOffer() → pick the best candidate and apply it ``` ### Building the decision table - **`setTOFAttributes($application, $tenure, $foir, $cf_foir)`** — for every eligible merchant offer, runs an EMI calculation and a FOIR calculation, then records per-offer pass/fail against the TOF tenure/FOIR/cf-FOIR limits into `$this->Atenure`, `$this->Afoir`, `$this->Acf_foir`, and caches per-offer EMI details into `$this->emiData`. Limits default to tenure 60 / foir 75 / cf_foir 50 when no policy provided them (hard-coded per credit team). - **`buildDecisionTable()`** — pivots the three attribute arrays into `[offerId => [ {type,value,status}, ... ]]`. - **`processOffers()` / `processOfferItems()` / `buildOfferRecord()`** — collapse each offer's items into a single record with a combined `status` (0 = pass, 1 = fail) and a comma-joined `reason` (which of cf_foir/foir/tenure failed, "offer missing", "Emi data missing"). ### The four logics in code | Logic | Key methods | Eligibility bucket | |-------|-------------|--------------------| | Minimum Tenure | `handleRegularAdminCall()` → `getEligibleTenureOffer()` / `selectEligibleTenureOffer()`, `filterValidMinimumTenureOffers()` | `$this->TOFApprovedEligibility` | | Residual EMI | `handleEmptyEmiData()` → `getNextBasedAmountOffer()` → `processResidualEmi()` | `$this->TOFApprovedEligibility` | | Bureau Surrogate Limit | `filterBureauSurrogateByFOIR()`, `updateOffersWithBureauSurrogateLimit()` (inside `handleRegularAdminCall` / `processResidualEmi`) | `$this->bureauSurrogateEligibility` | | Power Saving / Banking-ABB | `runTOFWithCustomEMI()` → `fetchCustomEMIAmount()` / `fetchCustomEMIAmountForBanking()` → `processCustomEmiFlow()` → `handleEmptyEmiDataWithAmount()` → `processResidualEmi()` (with `$emi` set) | `$this->powerSavingEligibility` / `$this->bankingSurrogateEligibility` | **Custom-EMI amount sourcing:** - `fetchCustomEMIAmount()` (Power Saving) — `1.5 × bill_amount` from `utility_validation.data` (JSON), falling back to `1.5 × inverter_power_savings`. WhatsApp-agent source reads `ebill_ocr_data.bill_amount`. Returns `0` (auto-approve applied amount) when the current EMI is already affordable. - `fetchCustomEMIAmountForBanking()` (Banking-ABB) — reads `adjustedAbb2.averageOfMonthlyAverages` from the S3 `pirimid_analysis_json` file, multiplied by `adjustedABBMultiplier` (default 0.5). Returns `0` when current EMI is already affordable. ### Amount validity — `isValidTableOfOffersAmount()` Central guard used by all the filters: an amount is valid only when `amount >= 50000 && amount <= application->loan_amount`. ### Choosing & applying the winner — `setFinalOffer()` 1. Collects candidates from the four eligibility buckets (minimum-tenure only when `policyPassed` is non-empty). 2. Picks the candidate with the **highest `approved_amount`**; tie-break on **lowest tenure** via `getOfferTenure()` (reads `$this->emiData` first, falls back to `merchant_offers.tenure − advance_emi`). 3. Applies it: - `updateApplicationLogic()` — persists `table_of_offers_logic` and, where relevant, `residual_emi` / `bureau_surrogate_amount` / `approved_amount`. - `processOfferChange()` → `regenerateEMIAfterDecision()` → `EMICalculation::generateEMI()` — regenerates the EMI schedule for the chosen offer and writes the customer/credit comment + approval log. ## Data written TOF is referenced by name only here (columns not enumerated): | Table | Model | Role in TOF | |-------|-------|-------------| | `loan_decision_table` | `App\DecisionTable` | One row per merchant offer evaluated: pass/fail `status`, `reason`, EMI/payable/interest/PF, plus `eligible_amount` and `bureau_surrogate_limit_amount`/`bureau_surrogate_limit_emi`. Cleared and rebuilt on each run. | | `loan_decision_approved_amount` | `App\LoanDecisionApprovedAmount` | Custom-EMI (Power Saving / Banking-ABB) results, keyed by `policy_type`, storing `calculated_approved_amount`, `emi_amount`, `offer_id`. Cleared per policy on each run. | | `emi_calculation` | `App\ApplicationEMICalculation` (`ConsumerApplication::emi()`) | The application's live EMI record. TOF updates `table_of_offers_logic`, and — for the winning logic — `residual_emi`, `bureau_surrogate_amount`, and the regenerated EMI schedule. `approved_amount` is set on `consumer_application`. | ## Logging & error handling - **CloudWatch** (`CloudWatchLoggerFactory`) — progress at each stage: `SavetoDecisionTable`, `BRE execution complete`, `setTOFAttributes Error`, `runCustomTOF Error`, etc. - **Slack** (`App\Slack`) — exceptions in `handleException()`, custom-EMI errors, and negative/zero residual EMI alerts. - **Approval logs** — `generateApprovalLog()` writes to `jsonbre_approval_status_log` (when `JSON_BRE_ACTIVE`) or `approval_status_log`, recording the plan-change reason per logic. - **Negative-residual log** — `addNegativeResidualLog()` records when an eligible net loan amount falls outside the ₹50k–₹5L band for Power Saving / Banking-ABB. ## Helper reference | Method | Purpose | |--------|---------| | `tableOfOffersPassedPolicyIsRegular($appId)` | True when the latest passed policy carries *none* of `power_savings` / `emi_multiplier_on_abb` / `bureau_surrogate` — i.e. a regular policy, so minimum-tenure / residual-EMI offers may be included in the payload. | | `getBureauSurrogateDetails()` / `getNextBasedAmountOffer()` / `getNextBestAmountOfferWithCustomEMI()` | Run EMI calculations for the respective amount/EMI sizing. | | `triggerTableOfOffersWebhook()` | Fires the `table-of-offers-webhook` mConnect event (see doc #12). |