param = $param; } /** * Pull the bureau report and run the JSON BRE rejection policy against it. * * One job, two policies, selected by the payload's applicant_type: * * - APPLICANT — AutoRejection@autoRejectJSONBRE against * auto_reject_rule.applicant_type = 1. The decision half mirrors * WhatsappAgentController@autoRejectJSONBRE, driven off the SQS payload * instead of an HTTP request. A true verdict REJECTS the application. * - CO_APPLICANT — AutoRejection@autoRejectCoApplicantJSONBRE against * auto_reject_rule.applicant_type = 2, a genuinely different rule set * (it splits results into signing/income roles). A true verdict is * RECORDED ONLY — see runCoApplicant(). * * The bureau pull is this job's own step for both — see pullBureau(). * * @return void */ public function handle() { $cwFactory = new CloudWatchLoggerFactory(); $param = $this->param; $id = Arr::get($param, 'id', null); $applicantType = (int) Arr::get($param, 'applicant_type', self::APPLICANT); $applicantType = in_array($applicantType, [self::APPLICANT, self::CO_APPLICANT], true) ? $applicantType : self::APPLICANT; $source = Arr::get($param, 'source', 'bot'); $tracking_id = Arr::get($param, 'tracking_id', ''); $account_aggregator_id = Arr::get($param, 'account_aggregator_id', ''); if (!$id) { $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'message' => 'Missing parameter id'])); return; } try { $application = ConsumerApplication::with('merchant.grade_v1')->where('id', $id)->first(); if (!$application) { $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'application_id' => $id, 'applicant_type' => $applicantType, 'message' => 'Application not found'])); return; } // SQS can deliver the same message twice, and the guard expires after // JOB_TTL_SECONDS. Applies to both applicant types: there is nothing // to decide on an application that is already rejected. if ($application->state == 'rejected') { $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'application_id' => $id, 'applicant_type' => $applicantType, 'message' => 'Already rejected'])); return; } // The co-applicant is optional. DatapointHelper::hasCoApplicant() // already gates dispatch on co_applicant_name, so this should not // happen — but the co-applicant could have been removed between // dispatch and delivery, and a co-applicant bureau pull with no // co-applicant would be a pull against empty identity fields. if ($applicantType === self::CO_APPLICANT && empty($application->co_applicant_name)) { $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'application_id' => $id, 'applicant_type' => $applicantType, 'message' => 'No co-applicant on the application'])); return; } // AutoRejection::autoRejectLog() stamps request_ap_id off the request it // was constructed with. The worker has no inbound request, so mint one. $requestID = (new TestImport(new Request()))->random_strings(8); $request = new Request(); $request->merge(['request_ap_id' => $requestID]); \request()->merge(['request_ap_id' => $requestID]); if (!$this->pullBureau($id, $applicantType, $cwFactory)) { return; } $autoRejection = new AutoRejection($request); if ($applicantType === self::CO_APPLICANT) { $this->runCoApplicant($autoRejection, $id, $tracking_id, $cwFactory); // Signal that the co-applicant's rejection check is fully complete. // This fulfills the final requirement for the applicant's approval tier. DatapointHelper::sync($id, ['co_applicant_rejection_executed' => 1]); return; } $grade = $application->merchant ? $application->merchant->grade_v1 : null; $result = $autoRejection->autoRejectJSONBRE($grade, $id, $source, $tracking_id, $account_aggregator_id); $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'application_id' => $id, 'applicant_type' => $applicantType, 'source' => $source, 'rejected' => $result ? 'true' : 'false'])); if (!$result) { return; } $application->state = 'rejected'; $application->save(); StatusLogs::insert([ 'application_id' => $id, 'user_id' => self::SYSTEM_USER_ID, 'status' => 'rejected', 'created_at' => Carbon::now()->toDateTimeString() ]); } catch (\Exception $e) { $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'application_id' => $id, 'message' => $e->getMessage(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString()])); } } /** * Run the co-applicant rejection policy and record the verdict, WITHOUT * changing the application's state. * * That asymmetry with the applicant path is deliberate. A co-applicant * failing the policy is not by itself a decision to reject the loan, so this * job stops at recording it and an operator decides. It matches the only * other place the same policy runs — GradeController@autoApproveInitCoapplicant * calls autoRejectCoApplicantJSONBRE and does nothing with the return value. * * The audit trail is written by the policy itself, not here: * autoRejectCoApplicantJSONBRE() -> autoRejectLog($id, $logs, 2) inserts the * auto_reject_logs row (applicant_type = 2, failure_details split into * signing/income) and adds the operator comment. So a true verdict is * visible on the application even though nothing here writes it. * * If this should ever reject the application instead, the applicant branch in * handle() is the shape to copy — but note it would then need its own * StatusLogs row and the co-applicant equivalent of the rejection * communication, which autoRejectJSONBRE sends and this policy does not. * * @param string $tracking_id Passed through to the BRE for request tracing * @return void */ protected function runCoApplicant(AutoRejection $autoRejection, $id, $tracking_id, CloudWatchLoggerFactory $cwFactory) { // $grade is false to match every existing caller: the method ignores the // argument and re-reads $application->merchant->grade_id itself. $result = $autoRejection->autoRejectCoApplicantJSONBRE(false, $id, $tracking_id); $cwFactory->logger(json_encode([ 'title' => self::LOG_TITLE, 'application_id' => $id, 'applicant_type' => self::CO_APPLICANT, 'rejected' => $result ? 'true' : 'false', 'message' => 'Co-applicant verdict recorded; application state left unchanged', ])); } /** * Make sure a credit report exists before the rejection policy runs. * * The rejection tier does not track the credit report as a data point — the * bureau pull is an output of decisioning, not a precondition for * dispatching (requiring it would deadlock the trigger). But * autoRejectJSONBRE bails out when there is no Score, so the job has to * produce one first. * * generateSoftBureauReport() is reused for the pull, with two arguments * doing the work: * * - $runBre = false — it would otherwise call runAutoDecision(), which * goes on to run the rejection policy itself via autoApproveInit(). * This job owns that step, so the controller must stop after the pull. * - $type = 'autoBRE' — keeps the controller off the 'waiting for * decision' status update and out of the auto-approval status path. * * The controller short-circuits to OK when a credit report row already * exists FOR THAT APPLICANT TYPE, so a re-delivered message does not pull * the bureau twice and the applicant's report never satisfies the * co-applicant's pull. * * The controller also refuses to pull without consent for the applicant type * it is pulling for — 'Please get Co-Applicant consent.' for type 2. That is * why the co-applicant tier is dispatched only from the consent document * choke point: were it dispatched from the observer hot path it could arrive * here before consent, fail, and leave its guard spent for 90 days. See * DatapointHelper::$coApplicantRejectionRequired. * * @param int $applicantType 1 = applicant, 2 = co-applicant * @return bool true when a bureau report is available to decide on */ protected function pullBureau($id, $applicantType, CloudWatchLoggerFactory $cwFactory): bool { // The worker reuses Input across jobs; pin the applicant type the // controller reads instead of inheriting whatever ran before. This is // load-bearing: generateSoftBureauReport() prefers Input over its own // argument ($applicant_type = Input::has(...) ? Input::get(...) : $applicantType), // so a stale value from a previous job on this worker would silently // pull for the wrong applicant. Input::merge(['applicant_type' => $applicantType]); $applicationController = new ConsumerApplicationController(); $applicationController->isRoot = false; $response = $applicationController->generateSoftBureauReport($id, 'autoBRE', $applicantType, false); $responseData = $response->getData(); if (!isset($responseData->status) || $responseData->status != 'OK') { $cwFactory->logger(json_encode(['title' => self::LOG_TITLE, 'application_id' => $id, 'applicant_type' => $applicantType, 'message' => 'Bureau pull failed', 'response' => $responseData->message ?? ''])); return false; } return true; } }