# Pre-Eligibility Flow — Implementation Summary Self-contained reference for the SSE Pre-Eligibility implementation in this Laravel 5.5 codebase. Updated 2026-05-25. ## Purpose Pre-eligibility is a **pre-customer** decisioning flow: a lead provides minimal data (name, mobile, electricity bill amount, monthly income, loan amount, merchant) and the system runs a bureau pull + BRE policy evaluation + Table-of-Offers (TOF) computation to surface eligible loan offers — **without creating a `consumer_application` row**. Conversion to a full `consumer_application` happens later, downstream of this flow. Branch: `CRED-6146-sse-pre-eligibility-check`. JIRA: CRED-6146. ## Entry Point ``` php artisan command:CheckPreEligibility --con=jobs --param='id=' --event= ``` Routes through `app/Console/Commands/CheckPreEligibility.php` → `new App\PreEligibility(Request)` → `init()`. The `id` param is **`pre_eligibility_applications.id`** — NOT a `consumer_application.id`. ## High-Level Sequence ``` PreEligibility::init() └─ runBureauService() ├─ generateApplicantBureau() → PreEligibilityController::generateSoftBureauReport() │ ├─ load PreEligibilityApplication │ ├─ check pre_eligibility_consent_log (status='agree') — gate │ ├─ check pre_eligibility_credit_report (short-circuit if exists) │ ├─ POST EXPERIAN_SOFT_PULL_HOST with pre_eligibility=true payload │ └─ return {status, message} │ └─ if status OK → runMultiApprovalCheck() └─ PreEligibilityController::multiapprovalcheckpreeligibility() ├─ PreEligibilityApplication::with(creditReport, foir, merchant, merchant.grade_v1, │ merchant.custom_grade, merchant.offer, activeOffer) ├─ gradeChecks() ← slim override: bureau+merchant+grade_v1 only ├─ JsonBREController::runJsonBreApproval() with pre_eligibility=true attr │ (loads PreEligibilityApplication, shims approved_amount=loan_amount, │ shims name=customer_name; sends identifier + pre_eligibility flag to BRE service) ├─ filter passed policies (other / power-savings / adjusted-abb) ├─ if TOF gate passes: │ ├─ setTOFAttributes() ← override: pre_eligibility_foir, new self() in offer loop │ │ └─ getFOIRCalculation() ← override: pre_eligibility_credit_report writeback │ └─ saveDecisionInTable() │ ├─ handleRegularAdminCall() │ │ ├─ inline removeRejectedOffers │ │ ├─ if emiData empty → getNextBasedAmountOffer() → stamp eligible_amount │ │ ├─ getEligibleTenureOffer (inherited, uses offer_id) │ │ └─ insert into pre_eligibility_decision_table │ └─ runTOFWithCustomEMI() fallback │ ├─ fetchCustomEMIAmount() ← override: 1.5 × electricity_bill_amount │ ├─ fetchCustomEMIAmountForBanking() ← override: returns null (no pirimid data) │ └─ processCustomEmiFlow() │ └─ saveToLoanDecisionApprovedAmount() ← override: │ pre_eligibility_loan_decision_approved_amount └─ return boolean approval ``` ## Models (all in `App` namespace) | Model | Table | Notes | |---|---|---| | `PreEligibilityApplication` | `pre_eligibility_applications` | Relations: `creditReport`, `foir`, `merchant`, `activeOffer` | | `PreEligibilityCreditReport` | `pre_eligibility_credit_report` | Uses `Encryptable` trait + `MessageScope` global scope (same `$encryptable` as `CreditReport`) | | `PreEligibilityFoir` | `pre_eligibility_foir` | `SoftDeletes` | | `PreEligibilityDecisionTable` | `pre_eligibility_decision_table` | Has `eligibleOffers` relation to `MerchantOffers` | | `PreEligibilityLoanDecisionApprovedAmount` | `pre_eligibility_loan_decision_approved_amount` | enum `policy_type: POWER-SAVING, BANKING-ABB` | ## Database Schema All new tables use `pre_eligibility_application_id` as the FK (no FK constraint, just integer + index). ### `pre_eligibility_applications` — `database/migrations/2026_05_18_233227_create-table-pre-eligibility-applications.php` ``` id, tenure, monthly_income, loan_amount, electricity_bill_amount, result (enum), customer_name, mobile_number, city, merchant_id, offer_id (nullable), softDeletes, timestamps ``` ### `pre_eligibility_credit_report` — `2026_05_24_113000_create-table-pre-eligibility-credit-report.php` Cumulative state of `credit_report` (base + all 9 alters as of 2026-05-25): Score, NoOfActiveAccounts, FOIR fields, bureau, bureau_type, ml_/foir_ amounts, max_emi_serviced, current_emi_sum, encrypted fields, etc. ### `pre_eligibility_foir` — `2026_05_24_113001_create-table-pre-eligibility-foir.php` Cumulative state of `foir` (base + all 7 alters): AccountType, Balance(default '0'), InstallmentAmount, dpd, ReportedDate, reason_info, etc. Three indexes: `pre_elig_foir_ind1/2/3`. ### `pre_eligibility_decision_table` — `2026_05_25_113000_create-table-pre-eligibility-decision-table.php` Mirror of `loan_decision_table` (base + 3 alters): offer_id, cf_foir, foir, tenure, status, reason, emi, payable, interest_rate, processing_fee, up_front_from_customer, eligible_amount. ### `pre_eligibility_loan_decision_approved_amount` — `2026_05_25_113100_create-table-pre-eligibility-loan-decision-approved-amount.php` Mirror of `loan_decision_approved_amount`: offer_id, policy_type enum, emi_amount, calculated_approved_amount. ### `pre_eligibility_consent_log` — `2026_05_24_112851_create-table-pre-eligibility-consent-logs.php` (pre-existing) ips, latitude, longitude, status (`'agree'`/`'deny'`), type, applicant_type. **`status='agree'` is the `is_consent`-equivalent gate** for the bureau pull. ## Code Map ### `app/PreEligibility.php` (service class, `App\PreEligibility`) Orchestrator. Constructor loads `applicationDetails` from `pre_eligibility_applications`. `init()` → `runBureauService()` → `runMultiApprovalCheck()`. Holds both `$applicationController` (for webhook fire) and `$preEligibilityController` (for bureau pull + BRE). ### `app/Http/Controllers/PreEligibilityController.php` (extends `Grade\GradeController`) Public methods: - `multiapprovalcheckpreeligibility($application_id, $req_id='', $tableOfOffers=true)` — adapts `MultiApprovalCheckJSONBRE`. - `generateSoftBureauReport($id, $type='customer', $applicantType=1)` — adapts `ConsumerApplicationController::generateSoftBureauReport`. Consent gate uses `pre_eligibility_consent_log` only (file check dropped). Sends `pre_eligibility => true` in payload. Skips autoBRE/Equifax fallbacks and post-bureau `runAutoDecision`. Overrides (signatures identical to parent for polymorphic dispatch): - `gradeChecks($application, $application_id, $tableOfOffers)` — slim version: only application loaded, bureau pulled, merchant+grade_v1 wired. Drops auto_approve/max_approval/RBI/products/TOF-event checks. - `setTOFAttributes($application, $tenureValue, $foirValue, $cf_foirValue)` — uses `MerchantOffers::where(merchant_id, ...)` directly (no product fallback); instantiates `new self(...)` in the per-offer loop for polymorphic FOIR writeback. - `getFOIRCalculation($application, $InstallmentAmount=0)` — reads CreditFair-EMI from `pre_eligibility_foir`, writes back to `PreEligibilityCreditReport`. Drops co-applicant income / power-saving income contributions. - `saveDecisionInTable($applicationDetails, $approved, $isAdminCall=false, $req_id='')` — slim: delete-prior + build + admin-call + runTOFWithCustomEMI fallback. - `runTOFWithCustomEMI($application)` — same structure as parent (case-corrected: parent defines `runTOFWithCustomEMI` at L2074 but calls it as `runTOFwithCustomEMI` at L3267 — PHP method names are case-insensitive so it resolves). - `fetchCustomEMIAmount($application)` — returns `1.5 * electricity_bill_amount` (parent reads utility_validation + application_element). - `fetchCustomEMIAmountForBanking($application)` — returns null (no pirimid data at this stage). - `processCustomEmiFlow($application, $customEmiAmount, $policyType)` — calls inherited `getNextBestAmountOfferWithCustomEMI`, then `saveToLoanDecisionApprovedAmount` override. Does NOT call `addNegativeResidualLog` / `updateApplicationLogic` / `processOfferChange` / `regenerateEMIAfterDecision`. - `getNextBasedAmountOffer($applicationDetails, $runAgain=false)` — always uses `ScoreCard::getEligibleEmiByResidualFOIR` (no `$application->emi->residual_emi` fallback). EMICalculation request with `purpose=null`. Slack alert on negative residual. - `saveToLoanDecisionApprovedAmount($id, $policy, $values)` — writes to `pre_eligibility_loan_decision_approved_amount` keyed on `pre_eligibility_application_id`. Drops parent's `on('mysql_primary')` connection hint. Private: - `handleRegularAdminCall($approved, &$offers, $req_id)` — inline removeRejectedOffers (parent's is private/uninheritable); empty-emiData branch calls `getNextBasedAmountOffer` and stamps `eligible_amount`; inherits `getEligibleTenureOffer` (public). Does NOT call `processOfferChange` / `updateApplicationLogic` / `regenerateEMIAfterDecision`. - `buildPreEligibilityDecisionTable()`, `processPreEligibilityOffers()`, `reduceOfferItems()` — local copies because parent's `buildDecisionTable` / `processOffers` / `processOfferItems` / `buildOfferRecord` are private. ### `app/Http/Controllers/JsonBREController.php` (modified) `runJsonBreApproval()` branches on `$request->attributes->get('pre_eligibility')`: - When true: loads `PreEligibilityApplication`, shims `$application->approved_amount = $application->loan_amount`, `$application->name = $application->customer_name`, sets `$bre_api_payload['pre_eligibility'] = true`. - `create_rule_list()` — null-tolerant on the `consumer_application` / `consumer_application_element` lookups (`(object) ['employee_status' => null]` defaults) so the inner `continue` guards skip employment-dependent rules cleanly for pre-eligibility. ### `app/Jobs.php` (modified) `preEligibilityConsent` job case now calls a new local `consentDocument($application_id)` method (not `LenderServices::addConstantDocument`). The method: - Loads `id`, `customer_name`, `mobile_number` from `pre_eligibility_applications` via `DB::table()`. - Renders `admin.consumer-application.consent_logs_document` view with `loan_application_no => id`, name, mobile, `status='agree'`, `consent_timestamp=now`. - Generates PDF via `app('snappy.pdf.wrapper')`. - Saves to S3 directly with `Storage::disk('s3')->put('/{id}/consent_logs_{rand}.pdf', $pdf)`. - **No DB inserts/updates** (no `ConsumerApplicationFiles` row, no `is_consent` flip). ## Out-of-Scope (must be wired by other teams) These were explicitly excluded from the current scope but are required for the flow to produce meaningful results end-to-end: ### Bureau response writer (Phase 4) The Experian soft-pull writer (the webhook handler that persists the bureau response) currently inserts into `credit_report` / `foir` keyed on `application_id`. It must branch on the `pre_eligibility=true` flag in the request payload and route inserts to `pre_eligibility_credit_report` / `pre_eligibility_foir` keyed on `pre_eligibility_application_id`. Until this is done, `PreEligibilityApplication->creditReport` / `->foir` will be empty and `gradeChecks` returns `'Bureau not pulled'`. ### External BRE service contract (Phase 5) The Python BRE service receives `identifier` + `pre_eligibility: true` in the payload. The service must branch on that flag and read from `pre_eligibility_credit_report` / `pre_eligibility_foir` keyed on `pre_eligibility_application_id` instead of `credit_report` / `foir`. Until this is done, every bureau-derived rule (`score`, `dpd_rules`, `past_dues_rules`, `account_status_rules`, `bureau_surrogate`, `e_bill_fuzzy_match`, etc.) returns no data and policy outcomes are meaningless. ## Setup ```bash php artisan migrate ``` Runs the 4 new pre-eligibility migrations (consent log, credit_report, foir, decision_table, loan_decision_approved_amount) + the `offer_id` column added to the existing `pre_eligibility_applications` create migration. Seed a `pre_eligibility_applications` row with a real `merchant_id` (must have `grade_v1` and `MerchantOffers` rows) and a `pre_eligibility_consent_log` row with `status='agree'` before running the command. ## Test ```bash php artisan command:CheckPreEligibility --con=jobs --param='id=' ``` Expected end-to-end (once Phases 4/5 are wired): - Bureau pull → row in `pre_eligibility_credit_report` + N rows in `pre_eligibility_foir` - BRE policies evaluated → JSON response stored via `JsonBREController::store_jsonbre_response` - TOF runs → offer matrix in `pre_eligibility_decision_table` - If applicable → custom-EMI policies write to `pre_eligibility_loan_decision_approved_amount` - `PreEligibility::$finalResponse` = boolean approval ## Known Gotchas 1. **`runTOFwithCustomEMI` vs `runTOFWithCustomEMI`** — Parent defines with capital W (L2074), calls with lowercase w (L3267). PHP method names are case-insensitive so it resolves; the override in PreEligibilityController uses the canonical capital W. 2. **Private helpers in `GradeController`** — `buildDecisionTable`, `processOffers`, `processOfferItems`, `buildOfferRecord`, `removeRejectedOffers`, `isOfferRejected`, `selectEligibleTenureOffer`, `processOfferChange`, `updateApplicationLogic`, `handleEmptyEmiData`, `handleRegularAdminCall`, `getApplicationWithRelations` are all `private` — can't be inherited. PreEligibilityController duplicates or works around each. 3. **`$application->purpose` is null for pre-eligibility** — `pre_eligibility_applications` has no `purpose` column. `processCustomEmiFlow` shims `$application->purpose = null` before calling `getNextBestAmountOfferWithCustomEMI` to keep `EMICalculation` from tripping on missing attribute access. 4. **`addNegativeResidualLog` is dropped** — Pre-eligibility doesn't own upstream `ApprovalStatusLog` / `JsonBreApprovalStatusLog` rows to flip to status 0, so the call is intentionally omitted in `processCustomEmiFlow` and `getNextBasedAmountOffer`. 5. **`updateApplicationLogic`, `processOfferChange`, `regenerateEMIAfterDecision` are NOT invoked** — they mutate `consumer_application.emi` / `ConsumerApplicationElement.previous_applied_offer` / re-run EMI generation. None of those artifacts exist at pre-eligibility stage; they'll be created when the lead converts to a full `consumer_application` later. 6. **`GradeController::$request` is private** — child class can't access it. `PreEligibilityController` declares its own `private $request` in the constructor (parent's private property still backs parent's methods). 7. **`generateSoftBureauReport` is now on TWO controllers** — `ConsumerApplicationController::generateSoftBureauReport` (production, untouched) and `PreEligibilityController::generateSoftBureauReport` (new, pre-eligibility-only). Don't unify them. ## Quick File Reference ``` app/PreEligibility.php # service orchestrator app/PreEligibilityApplication.php # model app/PreEligibilityCreditReport.php # model app/PreEligibilityFoir.php # model app/PreEligibilityDecisionTable.php # model app/PreEligibilityLoanDecisionApprovedAmount.php # model app/Http/Controllers/PreEligibilityController.php # all pre-eligibility logic app/Http/Controllers/JsonBREController.php # pre_eligibility flag branch app/Jobs.php # consentDocument() for preEligibilityConsent job app/Console/Commands/CheckPreEligibility.php # CLI entry point database/migrations/2026_05_18_233227_create-table-pre-eligibility-applications.php database/migrations/2026_05_24_112851_create-table-pre-eligibility-consent-logs.php database/migrations/2026_05_24_113000_create-table-pre-eligibility-credit-report.php database/migrations/2026_05_24_113001_create-table-pre-eligibility-foir.php database/migrations/2026_05_25_113000_create-table-pre-eligibility-decision-table.php database/migrations/2026_05_25_113100_create-table-pre-eligibility-loan-decision-approved-amount.php ```