true` so the downstream bureau writer can * route the response rows to the pre-eligibility tables. * - autoBRE hard-pull fallback, Equifax fallback, log_decision status update, and the * post-bureau runAutoDecision trigger are all omitted: those rely on * consumer_application_element / consumer_application state which doesn't exist here, * and the caller (PreEligibility.php) drives multiapprovalcheckpreeligibility itself. */ public function generateSoftBureauReport($id, $type = 'customer', $applicantType = 1) { $customer = PreEligibilityApplication::with('merchant') ->where('id', $id) ->first(['id', 'merchant_id', 'customer_name', 'mobile_number']); if (!$customer) { return response()->json(['status' => 'FAIL', 'message' => 'Pre-eligibility application not found']); } if (empty($customer->customer_name) || empty($customer->mobile_number)) { return response()->json(['status' => 'FAIL', 'message' => 'Either application name or mobile number is missing']); } $applicant_type = $applicantType; $skip_pan_validation = (bool) (_env('SKIP_PAN_VALIDATION') ?? false); // is_consent equivalent: pre-eligibility logs consent in pre_eligibility_consent_log with // status='agree'. Block the pull if no agree-row exists for this applicant. $consent = DB::table('pre_eligibility_consent_log') ->where('pre_eligibility_application_id', $id) ->where('applicant_type', $applicant_type) ->where('status', 1) ->whereNull('deleted_at') ->first(['id']); if (!$consent) { return response()->json(['status' => 'FAIL', 'message' => 'Please get applicant consent.']); } $credit_report = PreEligibilityCreditReport::where('application_id', $id) ->where('applicant_type', $applicant_type) ->whereNull('deleted_at') ->first(['id']); if (isset($credit_report->id)) { return response()->json(['status' => 'OK', 'message' => 'Bureau Fetched Successfully']); } $name = trim($customer->customer_name); $bureauData = [ 'application_id' => "$customer->id", 'applicant_type' => "$applicant_type", 'request_type' => $type, 'pan_card' => '', 'first_name' => explode(' ', $name)[0], 'last_name' => substr(rtrim($name), strrpos(rtrim($name), ' ') + 1), 'mobile_number' => wall_decrypt($customer->mobile_number), 'document_type' => 'PAN_DETAILED', 'vendor' => 'admin', 'skip_pan_validation' => $skip_pan_validation, 'pre_eligibility' => true, ]; $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => _env('EXPERIAN_SOFT_PULL_HOST'), CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 30, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => json_encode($bureauData), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Client-Id: ' . _env('EXPERIAN_SOFT_PULL_ID'), 'X-Client-Secret: ' . _env('EXPERIAN_SOFT_PULL_SECRET'), ], ]); $res = curl_exec($curl); $data = json_decode($res, 1); $httpcode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $err = curl_error($curl); curl_close($curl); (new Slack(new Request([ 'title' => 'Pre-Eligibility: Soft Bureau Pull', 'fields' => [ 'error' => json_encode($err), 'payload' => json_encode($bureauData), 'response' => json_encode($data), ], 'group' => Slack::$STAGE_TESTING, ])))->send(); $status = 'OK'; $message = isset($data['message']) ? $data['message'] : 'Soft Bureau Pulled Successfully'; if (empty($res) || is_null($data) || $httpcode != 200 || isset($data['error'])) { $status = 'FAIL'; $message = 'Soft bureau pull failed'; } return response()->json(['status' => $status, 'message' => $message]); } /** * Pre-eligibility variant of MultiApprovalCheckJSONBRE. * * Reads the application from `pre_eligibility_applications` (and credit_report/foir from * `pre_eligibility_credit_report` / `pre_eligibility_foir`) and writes the TOF rows to * `pre_eligibility_decision_table`. Rules whose inputs aren't available in pre-eligibility * (application_element, products, emi, purpose) are silently dropped. */ public function multiapprovalcheckpreeligibility($application_id, $req_id = '', $tableOfOffers = true) { $cwFactory = new CloudWatchLoggerFactory(); $jsonBRE = new JsonBREController(); $cwFactory->logger(json_encode(['title' => 'Pre-Eligibility: MultiApprovalCheck', 'application_id' => $application_id])); if ($req_id == '') { $req_id = $this->request->has('request_ap_id') ? $this->request->get('request_ap_id') : 0; } if ($req_id == 0) { $req_id = substr(bin2hex(random_bytes(4)), 0, 8); } $this->appId = $application_id; $application = PreEligibilityApplication::with('creditReport', 'foir', 'merchant', 'merchant.grade_v1', 'merchant.custom_grade', 'merchant.offer', 'activeOffer') ->where('id', $application_id) ->first(); $tableOfOffers = (isset($application->merchant->table_of_offers) && $application->merchant->table_of_offers) ? true : $tableOfOffers; $gradeChecks = $this->gradeChecks($application, $application_id, $tableOfOffers); if ($gradeChecks['status'] == 'failed' && $gradeChecks['reason'] != '') { $this->saveApprovalStatus(0, $application_id, $req_id, $gradeChecks['status'], $gradeChecks); return false; } $grade_rules = ApprovalRule::where('grade_id', $application->merchant->grade_v1->id) ->where('status', 1) ->get(); $this->request->attributes->set('pre_eligibility', true); $jsonBREResponse = $jsonBRE->runJsonBreApproval( $application->id, $req_id, $application->merchant->grade_v1->id, $tableOfOffers, false, $this->request ); $this->jsonBREResponse = $jsonBREResponse; $this->adjustedABBMultiplier = $jsonBRE->adjustedABBMultiplier; $jsonBREResponse = $jsonBREResponse->getData(); $jsonBREresult = isset($jsonBREResponse->res) ? (array) json_decode($jsonBREResponse->res) : false; $allFailed = true; $allSuccess = true; $success = false; if ($jsonBREresult) { $power_saving_policies = array_filter($jsonBREresult, function ($key) { return (str_contains($key, 'power-savings') && !str_contains($key, 'status')); }, ARRAY_FILTER_USE_KEY); $banking_policies = array_filter($jsonBREresult, function ($key) { return (str_contains($key, 'adjusted-abb') && !str_contains($key, 'status')); }, ARRAY_FILTER_USE_KEY); $other_policies = array_filter($jsonBREresult, function ($key) { foreach (['power-savings', 'adjusted-abb', 'status'] as $excluded) { if (str_contains($key, $excluded)) return false; } return true; }, ARRAY_FILTER_USE_KEY); } if (isset($other_policies)) { $passedPolicies = array_reduce(array_keys($other_policies), function ($carry, $key) use ($other_policies) { $item = $other_policies[$key]; if (is_object($item) && $item->policy_status && preg_match('/policy-(\d+)/', $key, $matches)) { $carry[(int) $matches[1]] = $item; } return $carry; }, []); $filteredGradeRules = count($passedPolicies) > 0 ? $grade_rules->whereIn('id', array_keys($passedPolicies)) : collect([]); if (count($passedPolicies) > 0) { $this->policyPassed = $passedPolicies; $allFailed = false; $success = true; } else { $allSuccess = false; } if ($passedPolicies) { $this->bureauSurrogateLimits = $this->extractBureauSurrogateLimits($passedPolicies); } $is_power_saving_grade = isset($power_saving_policies) && count($power_saving_policies) > 0; $is_banking_grade = isset($banking_policies) && count($banking_policies) > 0; if ($is_power_saving_grade) { foreach ($power_saving_policies as $policy_name => $power_saving_rule) { if ($power_saving_rule->policy_status) { $rule_id = array_map(function ($name) { preg_match('/policy-power-savings-(\d+)/', $name, $matches); return isset($matches[1]) ? (int) $matches[1] : null; }, [$policy_name]); $this->powerSavingPolicyPassed[] = $grade_rules->filter(function ($rule) use ($rule_id) { return $rule->id == $rule_id[0]; })->first(); $allFailed = false; $success = true; break; } else { $allSuccess = false; } } } if ($is_banking_grade) { foreach ($banking_policies as $policy_name => $banking_rule) { if ($banking_rule->policy_status) { $rule_id = array_map(function ($name) { preg_match('/policy-adjusted-abb-(\d+)/', $name, $matches); return isset($matches[1]) ? (int) $matches[1] : null; }, [$policy_name]); $this->bankingPolicyPassed[] = $grade_rules->filter(function ($rule) use ($rule_id) { return $rule->id == $rule_id[0]; })->first(); $allFailed = false; $success = true; } else { $allSuccess = false; } } } $cwFactory->logger(json_encode(['title' => 'Pre-Eligibility: TOF prep', 'application_id' => $application_id, 'allFailed' => $allFailed, 'success' => $success])); $status = $success ? true : false; if ($tableOfOffers) { $tenureValues = []; $foirValues = []; $cfFoirValues = []; foreach ($filteredGradeRules as $rule) { $ruleDetails = json_decode($rule->rules, true); if (isset($ruleDetails['max_net_tenure'])) { $tenureValues[] = (int) $ruleDetails['max_net_tenure']; } if (isset($ruleDetails['foir'])) { $foirValues[] = (int) $ruleDetails['foir']; } if (isset($ruleDetails['cf_foir'])) { $cfFoirValues[] = (int) $ruleDetails['cf_foir']; } } $this->setTOFAttributes( $application, !empty($tenureValues) ? max($tenureValues) : 60, !empty($foirValues) ? max($foirValues) : 75, !empty($cfFoirValues) ? max($cfFoirValues) : 50 ); $cwFactory->logger(json_encode(['title' => 'Pre-Eligibility: SavetoDecisionTable', 'application_id' => $application_id])); $decisionTableResult = $this->saveDecisionInTable($application, $status, true, $req_id); $cwFactory->logger(json_encode(['title' => 'Pre-Eligibility: BRE execution complete', 'application_id' => $application_id, 'decisionTableResult' => $decisionTableResult])); return $decisionTableResult; } } return !$allFailed; } /** * Slim gradeChecks for pre-eligibility — only validates the data points we actually have: * application loaded, bureau row present with a Score, merchant + grade_v1 wired. * The consumer-app specific checks (auto-approve, max_approval_value, products, blacklist * RBI lookup, table-of-offers event) are intentionally skipped because pre-eligibility * doesn't carry that context yet. */ public function gradeChecks($application, $application_id, $tableOfOffers) { $status = $reason = $failed_condition = ''; if (!$application || !$application->foir || (isset($application->creditReport[0]) && !$application->creditReport[0])) { $status = 'failed'; $reason = 'Failed to fetch data from pre-eligibility tables and relations'; } if ($status == '' && (!isset($application->creditReport[0]) || !isset($application->creditReport[0]->Score))) { $status = 'failed'; $reason = 'Bureau not pulled'; $failed_condition = json_encode(['score' => 'no_equifax']); } if ($status == '' && (!$application->merchant || !$application->merchant->grade_v1)) { $status = 'failed'; if (!$application->merchant) { $reason = 'Merchant not set'; } elseif (!$application->merchant->grade_v1) { $reason = 'Merchant grade not set'; } } return [ 'status' => $status == '' ? 'success' : $status, 'reason' => $reason, 'failed_condition' => $failed_condition, ]; } /** * Mirrors GradeController::setTOFAttributes but: * - reads foir from $application->foir (which now resolves to PreEligibilityFoir rows) * - instantiates `new self(...)` inside the offer loop so the inner getFOIRCalculation * writes back to pre_eligibility_credit_report. * No application_element->product_name fallback: pre-eligibility always uses MerchantOffers * scoped to merchant_id since there are no selected products yet. */ public function setTOFAttributes($application, $tenureValue, $foirValue, $cf_foirValue) { try { $this->Atenure = []; $this->Afoir = []; $this->Acf_foir = []; $this->tofFoirLimit = (isset($foirValue) && !empty($foirValue)) ? $foirValue : 0; $this->tofCfFoirLimit = (isset($cf_foirValue) && !empty($cf_foirValue)) ? $cf_foirValue : 0; if ($this->foirIncludingCFEMI < 1 && $this->cfFoir < 1) { foreach ($application->foir as $foir) { $this->setFOIRAttributes($foir); } $this->getFOIRCalculation($application); } $offers = MerchantOffers::where('merchant_id', $application->merchant_id) ->whereNull('deleted_at') ->where('status', 1) ->get(); foreach ($offers as $offer) { $this->request->merge([ 'merchant_id' => $application->merchant_id, 'loan_amount' => $application->loan_amount, 'merchant_offer_id' => $offer->id, 'category_id' => null, 'interest_type_id' => $offer->interest_type_id, 'application_id' => $application->id, 'offer_obj' => $offer, 'charge_subvention_remaining_amount' => $application->merchant->charge_subvention_remaining_amount ?? 0, 'pre_eligibility' => true, ]); $_grade_ = new self($this->request); $emi = new EMICalculation($this->request); $res = $emi->prepareCalculation(); $res = $res->getData(); $this->cfFoir = 0; $this->foirIncludingCFEMI = 0; foreach ($application->foir as $foir) { if ($foir->applicant_type != 2) { $_grade_->setFOIRAttributes($foir); } } $_grade_->getFOIRCalculation($application, $res->emi ?? 0); $this->emiData[$offer->id] = [ 'emi' => $res->emi ?? 0, 'payable' => $res->payable ?? 0, 'interest_rate' => $res->flat_annual_interest ?? 0, 'processing_fee' => $res->processing_fee ?? 0, 'upfront_from_customer'=> $res->up_front_from_customer ?? 0, 'tenure' => $res->emi_remaining ?? 0, ]; $_foir = (isset($foirValue) && !empty($foirValue)) ? $foirValue : 0; if ($_grade_->foirIncludingCFEMI > $_foir) { $this->Afoir[] = ['offer_id' => $offer->id, 'foirIncludingCFEMI' => $_grade_->foirIncludingCFEMI, 'status' => 1]; } else { $this->Afoir[] = ['offer_id' => $offer->id, 'foirIncludingCFEMI' => $_grade_->foirIncludingCFEMI, 'status' => 0]; } $_cf_foir = (isset($cf_foirValue) && !empty($cf_foirValue)) ? $cf_foirValue : 0; if ($_grade_->cfFoir > $_cf_foir) { $this->Acf_foir[] = ['offer_id' => $offer->id, 'cfFoir' => $_grade_->cfFoir, 'status' => 1]; } else { $this->Acf_foir[] = ['offer_id' => $offer->id, 'cfFoir' => $_grade_->cfFoir, 'status' => 0]; } $_tenure = $offer->tenure; if ($offer->advance_type == 'emi') { $_tenure = $offer->tenure - $offer->advance_emi; } if ($tenureValue == 0 || empty($tenureValue) || $_tenure > $tenureValue) { $this->Atenure[] = ['tenure' => !$_tenure ? 'no offer found' : $_tenure, 'offer_id' => $offer->id, 'status' => 1]; } else { $this->Atenure[] = ['tenure' => !$_tenure ? 'no offer found' : $_tenure, 'offer_id' => $offer->id, 'status' => 0]; } } } catch (\Exception $e) { (new CloudWatchLoggerFactory())->logger(json_encode([ 'title' => 'Pre-Eligibility: setTOFAttributes Error', 'application_id' => $application->id ?? null, 'error' => $e->getMessage(), 'line' => $e->getLine(), 'file' => $e->getFile(), ])); return false; } } /** * Pre-eligibility variant of getFOIRCalculation — reads CreditFair EMI row from * pre_eligibility_foir and writes the computed FOIR values back to pre_eligibility_credit_report. * Drops the co_applicant_monthly_income / getPowerSavingIncome contributions since neither * exists for pre-eligibility yet. */ public function getFOIRCalculation($application, $InstallmentAmount = 0) { $creditFairEMI = null; if ($InstallmentAmount == 0) { $creditFairEMI = DB::table('pre_eligibility_foir') ->where('application_id', $application->id) ->where('AccountType', 'Credit Fair EMI') ->where('InstallmentAmount', '>', 0) ->whereNull('deleted_at') ->first(['InstallmentAmount']); } $total_credit_card_limit_five = ($this->credit_limit * 5) / 100; $total_monthly_payment = $this->total_existing_emi + $total_credit_card_limit_five; $cfInstallAmount = (isset($creditFairEMI->InstallmentAmount) && $creditFairEMI->InstallmentAmount > 0) ? $creditFairEMI->InstallmentAmount : ($InstallmentAmount > 0 ? $InstallmentAmount : 0); if ($InstallmentAmount || isset($creditFairEMI->InstallmentAmount)) { $total_monthly_payment += $cfInstallAmount; } $monthly_income = $application->monthly_income; $this->foirIncludingCFEMI = ($monthly_income) ? ($total_monthly_payment / $monthly_income) * 100 : 0; if ($InstallmentAmount || isset($creditFairEMI->InstallmentAmount)) { $this->foirNAIncludingCFEMI = ($monthly_income) ? (($total_monthly_payment - $cfInstallAmount) / $monthly_income) * 100 : 0; $this->cfFoir = ($monthly_income) ? ($cfInstallAmount / $monthly_income) * 100 : 0; } $applicant_type = 1; if ($this->applicant_type) { $applicant_type = $this->applicant_type; } PreEligibilityCreditReport::where('application_id', $application->id) ->where('applicant_type', $applicant_type) ->update([ 'foirIncludingCFEMI' => $this->foirIncludingCFEMI, 'foirNAIncludingCFEMI' => $this->foirNAIncludingCFEMI, 'cfFoir' => $this->cfFoir, ]); } /** * Pre-eligibility saveDecisionInTable. * * Mirrors GradeController::saveDecisionInTable — delete prior decision rows, build the * offer matrix, delegate to the admin-call flow, and fall through to runTOFWithCustomEMI * when the regular flow doesn't approve. * * handleSolarFormCall is intentionally omitted: pre-eligibility is always an admin/auto- * decision call from CheckPreEligibility, never a solar-form submission. */ public function saveDecisionInTable($applicationDetails, $approved, $isAdminCall = false, $req_id = '') { $approvalStatus = false; if (!$approved && !$isAdminCall) { return $approved; } try { if (!empty($this->policyPassed)) { PreEligibilityDecisionTable::where('application_id', $this->appId)->delete(); $table = $this->buildPreEligibilityDecisionTable(); $offers = $this->processPreEligibilityOffers($table); if ($isAdminCall) { $approvalStatus = $this->handleRegularAdminCall($approved, $offers, $req_id); } } if (!$approvalStatus) { $approvalStatus = $this->runTOFWithCustomEMI($applicationDetails, $req_id); } return $approvalStatus; } catch (\Exception $e) { (new CloudWatchLoggerFactory())->logger(json_encode([ 'title' => 'Pre-Eligibility: saveDecisionInTable Error', 'application_id' => $this->appId, 'error' => $e->getMessage(), 'line' => $e->getLine(), 'file' => $e->getFile(), ])); return false; } } /** * Pre-eligibility variant of GradeController::handleRegularAdminCall (3392). * * Differences from parent: * - Loads PreEligibilityApplication (no consumer_application row exists yet). * - removeRejectedOffers + isOfferRejected are inlined; the parent versions are private * and not inheritable. * - Empty-emiData branch calls the pre-eligibility getNextBasedAmountOffer override and, * if it returns offer candidates, picks the highest-loan-amount one and stamps * eligible_amount onto the matching $offers row before insert. Parent's processResidualEmi * (updateApplicationLogic + processOfferChange + regenerateEMIAfterDecision) is NOT * invoked — those mutate consumer_application.emi / ConsumerApplicationElement which * don't exist at pre-eligibility stage. * - getEligibleTenureOffer is inherited (public on parent). $application->offer_id is * null on PreEligibilityApplication, so it falls through to the min-tenure picker. * - processOfferChange / updateApplicationLogic / regenerateEMIAfterDecision are NOT called. */ private function handleRegularAdminCall($approved, array &$offers, $req_id) { $application = PreEligibilityApplication::with('merchant', 'activeOffer') ->where('id', $this->appId) ->first(); // Inline removeRejectedOffers — drop emiData entries whose offer was rejected upstream. foreach ($this->emiData as $offerId => $emiDetails) { foreach ($offers as $offer) { if ($offer['offer_id'] == $offerId && $offer['status'] == 1) { unset($this->emiData[$offerId]); break; } } } if (empty($this->emiData)) { $emiDetails = $this->getNextBasedAmountOffer($application, true); if (!empty($emiDetails)) { $highestLoanOffer = collect($emiDetails)->sortByDesc('loan_amount')->first(); if ($highestLoanOffer && !empty($highestLoanOffer->id)) { foreach ($offers as $key => $offer) { if ($offer['offer_id'] == $highestLoanOffer->id) { $offers[$key]['eligible_amount'] = (int) $highestLoanOffer->loan_amount; break; } } } } if (!empty($offers)) { PreEligibilityDecisionTable::insert($offers); } return $approved ? true : false; } // Populates $this->eligibleTenureOffer / $this->eligibleTenure from $this->emiData. // Inherited from GradeController (public). $this->getEligibleTenureOffer($application); // Mirrors GradeController::updateMinimumTenureOffersWithEligibleAmountAndEmi (private on // parent so we reimplement). Stamps eligible_amount + emi onto every status==0 offer // before persisting — matches the minimum-tenure offer-row shape introduced in the // table-of-offers mconnect webhook merge (commits 9d20ee6eee, 22fafd48e2). $this->updateMinimumTenureOffersWithEligibleAmountAndEmi($application, $offers); if (!empty($offers)) { PreEligibilityDecisionTable::insert($offers); } return $approved ? true : false; } /** * Pre-eligibility variant of GradeController::updateMinimumTenureOffersWithEligibleAmountAndEmi (3652). * * For each status==0 (passing) offer in $offers, stamps eligible_amount from the * application's loan_amount and refreshes emi from the per-offer $this->emiData. Same * contract as the parent — the parent's version is private and not inheritable. */ private function updateMinimumTenureOffersWithEligibleAmountAndEmi($application, array &$offers) { foreach ($offers as $key => $offer) { if ($offer['status'] != 0) { continue; } $offers[$key]['eligible_amount'] = $application->loan_amount; if (isset($this->emiData[$offer['offer_id']]['emi'])) { $offers[$key]['emi'] = $this->emiData[$offer['offer_id']]['emi']; } } } /** * Pre-eligibility override of GradeController::runTOFWithCustomEMI (2074). * * Same shape as the parent — re-run setTOFAttributes with zero thresholds, then attempt * the power-saving and banking custom-EMI policies in sequence. The trailing * processOfferChange (which mutates consumer_application.emi / offer_id) is omitted; * decisions are persisted into pre_eligibility_decision_table via processCustomEmiFlow. */ public function runTOFWithCustomEMI($application, $request_id = '') { try { $this->setTOFAttributes($application, 0, 0, 0); $result = false; if (!empty($this->powerSavingPolicyPassed)) { $amount = $this->fetchCustomEMIAmount($application); $this->powerSavingEMI = $amount; if (is_numeric($amount) && $amount == 0) { return true; // auto approve on applied loan amount } if (!is_null($amount) && $amount) { $result = $this->processCustomEmiFlow($application, $amount, 'POWER-SAVING'); } } if (!empty($this->bankingPolicyPassed)) { $amount = $this->fetchCustomEMIAmountForBanking($application); if (is_numeric($amount) && $amount == 0) { return true; } if (!is_null($amount) && $amount) { $result = $this->processCustomEmiFlow($application, $amount, 'BANKING-ABB'); } } return $result; } catch (\Exception $e) { (new CloudWatchLoggerFactory())->logger(json_encode([ 'title' => 'Pre-Eligibility: runTOFWithCustomEMI Error', 'application_id' => $application->id ?? null, 'error' => $e->getMessage(), 'line' => $e->getLine(), 'file' => $e->getFile(), ])); return false; } } /** * Pre-eligibility override of GradeController::fetchCustomEMIAmount (2135). * * Source of truth for the bill amount is `pre_eligibility_applications.electricity_bill_amount` * (set when the lead is captured). The parent's utility_validation + application_element * fallbacks are skipped — neither table is keyed on pre_eligibility_application_id, so * those rows do not exist at this stage. The parent's "emi < customEMI → return 0" early- * approve guard is also dropped because pre-eligibility has no $application->emi to compare. */ public function fetchCustomEMIAmount($application) { try { if (empty($application->electricity_bill_amount)) { return null; } return 1.5 * $application->electricity_bill_amount; } catch (\Exception $e) { (new CloudWatchLoggerFactory())->logger(json_encode([ 'title' => 'Pre-Eligibility: fetchCustomEMIAmount Error', 'application_id' => $application->id ?? null, 'error' => $e->getMessage(), ])); return null; } } /** * Pre-eligibility override of GradeController::fetchCustomEMIAmountForBanking (2159). * * The parent reads `consumer_application_files.pirimid_analysis_json` keyed on * consumer_application_id. That artifact doesn't exist at pre-eligibility stage, so the * banking-ABB custom-EMI policy is unreachable from this flow. Returning null is the * documented "no data" path and causes the caller in runTOFWithCustomEMI to skip the * banking branch. */ public function fetchCustomEMIAmountForBanking($application) { return null; } /** * Pre-eligibility override of GradeController::processCustomEmiFlow (2122). * * Parent flow: * buildDecisionTable -> processOffers -> handleEmptyEmiData($application, $offers, * $approved, $req_id, $customEmiAmount, $policyType, $skipOfferChange=true) * handleEmptyEmiData -> handleEmptyEmiDataWithAmount -> getNextBestAmountOfferWithCustomEMI * -> processResidualEmi -> updateApplicationLogic + saveToLoanDecisionApprovedAmount * + processOfferChange + regenerateEMIAfterDecision * * Pre-eligibility keeps the offer-discovery half but skips the mutation half: * - getNextBestAmountOfferWithCustomEMI is inherited (public on parent) to compute * candidate offers for the custom EMI. * - saveToLoanDecisionApprovedAmount is overridden below to write to * pre_eligibility_loan_decision_approved_amount. * - updateApplicationLogic / processOfferChange / regenerateEMIAfterDecision are NOT * invoked — they mutate consumer_application.emi / ConsumerApplicationElement / EMI, * which do not exist at pre-eligibility stage. * - addNegativeResidualLog is NOT invoked — pre-eligibility does not own the upstream * ApprovalStatusLog / JsonBreApprovalStatusLog rows the parent flips to status 0. */ protected function processCustomEmiFlow($application, $customEmiAmount, $policyType) { if (!isset($application->purpose)) { $application->purpose = null; } $emiDetails = $this->getNextBestAmountOfferWithCustomEMI($application, $customEmiAmount); if (empty($emiDetails)) { return false; } $calculatedLoanAmount = $this->saveToLoanDecisionApprovedAmount($application->id, $policyType, $emiDetails); if (!$calculatedLoanAmount || empty($calculatedLoanAmount->offer_id)) { return false; } return true; } /** * Pre-eligibility override of GradeController::getNextBasedAmountOffer (3759). * * Uses ScoreCard::getEligibleEmiByResidualFOIR to compute the residual EMI for the * application id (same call the parent makes when residual_emi is absent on $application->emi). * Pre-eligibility has no $application->emi->residual_emi at all so we always take that path * — the $runAgain branch and the consumer_application fetch in the parent are dropped as * they don't apply. * * The Slack alert for residual_emi < 0 is kept (operationally useful), but the * addNegativeResidualLog branch is removed per scope. */ public function getNextBasedAmountOffer($applicationDetails, $runAgain = false) { try { $scoreCard = new ScoreCard(new Request()); if (!isset($applicationDetails->id)) { $applicationDetails = PreEligibilityApplication::find($applicationDetails); } $residual_emi = $scoreCard->getEligibleEmiByResidualFOIR($applicationDetails->id); if ($residual_emi < 0) { (new \App\Slack(new Request([ 'title' => 'Pre-Eligibility: next based amount assign error', 'fields' => [ 'error' => 'Residual EMI is less than 0', 'residual_emi' => $residual_emi, 'application_id' => $applicationDetails->id, ], ])))->send(); return []; } $request = new Request(); $request->attributes->add([ 'merchant_id' => $applicationDetails->merchant_id, 'category_id' => $applicationDetails->purpose ?? null, 'emi_amount' => $residual_emi, 'application_id' => $applicationDetails->id, ]); $calculation = new EMICalculation($request); $emi = $calculation->prepareCalculation(); $emiDetails = $emi->getData(); return !empty($emiDetails) ? $emiDetails : []; } catch (\Exception $e) { (new \App\Slack(new Request([ 'title' => 'Pre-Eligibility: next based amount assign error', 'fields' => [ 'error' => $e->getMessage(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ], ])))->send(); return []; } } /** * Pre-eligibility override of GradeController::saveToLoanDecisionApprovedAmount (3603). * * Same insert/upsert semantics — delete prior rows for this application+policy, insert * one row per candidate offer, then return the row with the highest * calculated_approved_amount — but routed to pre_eligibility_loan_decision_approved_amount * keyed on pre_eligibility_application_id. */ public function saveToLoanDecisionApprovedAmount($id, $policy, $values) { PreEligibilityLoanDecisionApprovedAmount::where([ 'application_id' => $id, 'policy_type' => $policy, ])->delete(); $data = array_map(function ($item) use ($id, $policy) { return [ 'application_id' => $id, 'offer_id' => $item->id, 'policy_type' => $policy, 'emi_amount' => $item->emi, 'calculated_approved_amount' => $item->loan_amount, 'created_at' => now(), 'updated_at' => now(), ]; }, $values); PreEligibilityLoanDecisionApprovedAmount::insert($data); return PreEligibilityLoanDecisionApprovedAmount::where('application_id', $id) ->where('policy_type', $policy) ->orderByDesc('calculated_approved_amount') ->first(); } private function buildPreEligibilityDecisionTable() { $table = []; foreach ($this->Acf_foir as $cfF) { $table[$cfF['offer_id']][] = [ 'type' => 'cf_foir', 'value' => $cfF['cfFoir'], 'status' => $cfF['status'], ]; } foreach ($this->Afoir as $cfF) { $table[$cfF['offer_id']][] = [ 'type' => 'foir', 'value' => $cfF['foirIncludingCFEMI'], 'status' => $cfF['status'], ]; } foreach ($this->Atenure as $cfT) { $table[$cfT['offer_id']][] = [ 'type' => 'tenure', 'value' => $cfT['tenure'], 'status' => $cfT['status'], ]; } return $table; } private function processPreEligibilityOffers(array $table) { $offers = []; $date = Carbon::now()->toDateTimeString(); foreach ($table as $offerId => $items) { $offerData = $this->reduceOfferItems($items); if (!isset($this->emiData[$offerId])) { continue; } $offers[] = [ 'application_id' => $this->appId, 'offer_id' => $offerId, 'cf_foir' => $offerData['cf_foir'], 'foir' => $offerData['foir'], 'tenure' => is_string($offerData['tenure']) ? 0 : $offerData['tenure'], 'status' => $offerData['status'], 'payable' => $this->emiData[$offerId]['payable'], 'emi' => $this->emiData[$offerId]['emi'], 'interest_rate' => $this->emiData[$offerId]['interest_rate'], 'processing_fee' => $this->emiData[$offerId]['processing_fee'], 'up_front_from_customer' => $this->emiData[$offerId]['upfront_from_customer'], 'bureau_surrogate_limit_emi' => null, 'bureau_surrogate_limit_amount' => null, 'reason' => $offerData['reason'], 'updated_at' => $date, 'created_at' => $date, ]; } return $offers; } private function reduceOfferItems(array $items) { $cf_foir = 0; $foir = 0; $tenure = 0; $status = 0; $reason = ''; foreach ($items as $item) { switch ($item['type']) { case 'cf_foir': $cf_foir = $item['value']; break; case 'foir': $foir = $item['value']; break; case 'tenure': $tenure = $item['value']; break; } if (!$status) { $status = $item['status']; } if ($item['status']) { $reason .= $item['type'] . ','; } if (is_string($tenure) && $tenure == 'no offer found') { $reason .= 'offer missing,'; $status = 1; } } return [ 'cf_foir' => $cf_foir, 'foir' => $foir, 'tenure' => $tenure, 'status' => $status, 'reason' => rtrim($reason, ','), ]; } public function getPreEligibilityOffers($preEligibilityId) { $application = DB::table('pre_eligibility_applications')->where('id', $preEligibilityId)->first(['loan_amount']); $appLoanAmount = $application->loan_amount ?? 0; $approvedAmounts = PreEligibilityLoanDecisionApprovedAmount::where('application_id', $preEligibilityId) ->get(['offer_id', 'policy_type', 'emi_amount', 'calculated_approved_amount']); if ($approvedAmounts->isNotEmpty()) { $approvedAmounts = $approvedAmounts->filter(function($row) use ($appLoanAmount) { return $row->calculated_approved_amount > 50000 && $row->calculated_approved_amount <= $appLoanAmount; }); if ($approvedAmounts->isEmpty()) { return []; } $offerDetails = $this->getMerchantOfferDetailsForTableOfOffers($approvedAmounts->pluck('offer_id')->all()); return $approvedAmounts->map(function ($approvedAmount) use ($offerDetails) { return $this->formatTableOfOffersPayloadRow( $approvedAmount->offer_id, $approvedAmount->calculated_approved_amount, $approvedAmount->emi_amount, $approvedAmount->policy_type, $offerDetails ); })->values()->all(); } $decisionTableQuery = PreEligibilityDecisionTable::where('application_id', $preEligibilityId); $passedDecisionRows = (clone $decisionTableQuery) ->where('status', 0) ->orderBy('id') ->get(); if ($passedDecisionRows->isNotEmpty()) { $decisionRows = $passedDecisionRows; $offerType = 'minimum_tenure'; } else { $decisionRows = $decisionTableQuery ->orderBy('id') ->get(); $offerType = null; } $decisionRows = $decisionRows->filter(function($row) use ($offerType, $appLoanAmount) { $amt = $offerType === 'bureau_surrogate_limit' ? $row->bureau_surrogate_limit_amount : $row->eligible_amount; return $amt > 50000 && $amt <= $appLoanAmount; }); if ($decisionRows->isEmpty()) { return []; } $offerDetails = $this->getMerchantOfferDetailsForTableOfOffers($decisionRows->pluck('offer_id')->all()); return $decisionRows->map(function ($decisionRow) use ($offerType, $offerDetails) { $emi = $decisionRow->emi; $loanAmount = $decisionRow->eligible_amount; if ($offerType === 'bureau_surrogate_limit') { $emi = $decisionRow->bureau_surrogate_limit_emi; $loanAmount = $decisionRow->bureau_surrogate_limit_amount; } return $this->formatTableOfOffersPayloadRow( $decisionRow->offer_id, $loanAmount, $emi, $offerType, $offerDetails ); })->values()->all(); } private function getMerchantOfferDetailsForTableOfOffers($offerIds) { $offerIds = array_values(array_unique(array_filter($offerIds))); if (empty($offerIds)) { return collect(); } return MerchantOffers::whereIn('id', $offerIds) ->get(['id', 'interest_rate', 'tenure', 'processing_fee']) ->keyBy('id'); } private function formatTableOfOffersPayloadRow($offerId, $loanAmount, $emi, $offerType, $offerDetails) { $offer = $offerDetails->get($offerId); $finalOfferId = $offerId; if ($offerType === 'bureau_surrogate_limit') { $finalOfferId = $offerId . '_b'; } elseif ($offerType === 'residual_emi') { $finalOfferId = $offerId . '_r'; } $payloadOfferType = is_string($offerType) ? strtolower($offerType) : null; return [ 'offer_id' => $finalOfferId, 'eligible_amount' => (int) $loanAmount, 'tenure' => $offer->tenure ?? null, 'emi' => (int) $emi, 'roi' => $offer->interest_rate ?? null, 'processing_fee' => $offer->processing_fee ?? null, 'offer_type' => $payloadOfferType, ]; } public function calculateBureauMetricsFromS3($application_id) { $filePath = "pre_eligibility/{$application_id}/{$application_id}_response_experian_soft_1.json"; try { if (Storage::disk('s3')->exists($filePath)) { $fileContent = \Storage::disk('s3')->get($filePath); $bureauData = json_decode($fileContent, true); $accounts = $bureauData['Body']['ProcessResponse']['Out']['INProfileResponse']['CAIS_Account']['CAIS_Account_DETAILS'] ?? []; $totalOutstanding = 0; $totalOverdue = 0; $totalSanctionAmount = 0; foreach ($accounts as $account) { // Only consider open (non-closed) accounts if (!empty($account['Date_Closed'])) { continue; } $totalOutstanding += (float) ($account['Current_Balance'] ?? 0); $totalOverdue += (float) ($account['Amount_Past_Due'] ?? 0); $totalSanctionAmount += (float) ($account['Highest_Credit_or_Original_Loan_Amount'] ?? 0); } return [ 'total_outstanding' => (int) $totalOutstanding, 'total_sanction_amount' => (int) $totalSanctionAmount, 'total_overdue' => (int) $totalOverdue, ]; } } catch (\Exception $e) { (new CloudWatchLoggerFactory())->logger(json_encode([ 'title' => 'Pre-Eligibility: setTOFAttributes Error', 'application_id' => $application->id ?? null, 'error' => $e->getMessage(), 'line' => $e->getLine(), 'file' => $e->getFile(), ])); } return [ 'total_outstanding' => null, 'total_sanction_amount' => null, 'total_overdue' => null, ]; } /** * Fetch applicant first/last name from the Experian bureau file (S3) or * from the provided $jsonBREResponse. Assumes consistent keys as * `Current_Applicant_Details` -> `First_Name` and `Last_Name`. * * @param int $preEligibilityId * @param mixed $jsonBREResponse * @return array ['first_name' => string, 'last_name' => string] */ private function getBureauApplicantNames($preEligibilityId, $jsonBREResponse) { $filePath = "pre_eligibility/{$preEligibilityId}/{$preEligibilityId}_response_experian_soft_1.json"; $breArr = null; try { if (Storage::disk('s3')->exists($filePath)) { $fileContent = Storage::disk('s3')->get($filePath); $breArr = json_decode($fileContent, true); } } catch (\Exception $e) { $breArr = null; } if (!is_array($breArr)) { return ['first_name' => '', 'last_name' => '']; } // Exact path used in Experian response if (isset($breArr['Body']['ProcessResponse']['Out']['INProfileResponse']['Current_Application']['Current_Application_Details']['Current_Applicant_Details'])) { $cad = $breArr['Body']['ProcessResponse']['Out']['INProfileResponse']['Current_Application']['Current_Application_Details']['Current_Applicant_Details']; $first = isset($cad['First_Name']) ? trim($cad['First_Name']) : (isset($cad['first_name']) ? trim($cad['first_name']) : ''); $last = isset($cad['Last_Name']) ? trim($cad['Last_Name']) : (isset($cad['last_name']) ? trim($cad['last_name']) : ''); return ['first_name' => $first, 'last_name' => $last]; } return ['first_name' => '', 'last_name' => '']; } }