/home/alixis5/integrate.sellofinance.com/src/Services
NameSizeModeActions
ApplicationService.php290940644editdlrm
AuthService.php127280644editdlrm
CatalogAggregator.php57910644editdlrm
CustomerBundleLoader.php39520644editdlrm
EligibilityEvaluator.php176030644editdlrm
GoogleAuthService.php82850644editdlrm
MeritRanker.php62790644editdlrm
OnboardingService.php229470644editdlrm
ProductPolicyLoader.php38180644editdlrm
VerificationService.php37520644editdlrm
Edit: /home/alixis5/integrate.sellofinance.com/src/Services/ApplicationService.php (29094B)
'draft', Application::STATUS_SUBMITTED => 'received', Application::STATUS_KYC_PENDING, Application::STATUS_KYC_PASSED, Application::STATUS_CREDIT_CHECK, Application::STATUS_UNDERWRITING, Application::STATUS_APPROVED => 'in_review', Application::STATUS_OFFER_SENT => 'offer_ready', Application::STATUS_OFFER_ACCEPTED, Application::STATUS_DISBURSED => 'approved', Application::STATUS_DECLINED, Application::STATUS_KYC_FAILED, Application::STATUS_OFFER_EXPIRED, Application::STATUS_CANCELLED => 'closed', default => 'closed', }; } public static function closedLabelKey(string $financeStatus): string { return match ($financeStatus) { Application::STATUS_DECLINED, Application::STATUS_KYC_FAILED => 'applications.bucket.closed_declined', Application::STATUS_OFFER_EXPIRED => 'applications.bucket.closed_expired', Application::STATUS_CANCELLED => 'applications.bucket.closed_cancelled', default => 'applications.bucket.closed', }; } public static function isCancelable(string $financeStatus): bool { return $financeStatus === Application::STATUS_SUBMITTED; } /** @param array $bundle */ public static function buildSnapshotJson(array $bundle): string { $p = $bundle['CCustomerProfile'] ?? []; $e = $bundle['CCustomerEmployment'] ?? []; $f = $bundle['CCustomerFinancialProfile'] ?? []; $a = $bundle['CCustomerAddress'] ?? []; return json_encode([ 'applicantSnapshot' => [ 'fullName' => $p['name'] ?? '', 'dateOfBirth' => $p['dateOfBirth'] ?? '', 'gender' => $p['gender'] ?? '', 'maritalStatus' => $p['maritalStatus'] ?? '', 'dependentsCount' => $p['dependentsCount'] ?? null, 'nationality' => $p['nationality'] ?? '', 'residencyCountry' => $p['residencyCountry'] ?? '', ], 'employmentSnapshot' => [ 'status' => $p['employmentStatus'] ?? '', 'type' => $e['employmentType'] ?? '', 'employerName' => $e['employerName'] ?? '', 'jobTitle' => $e['jobTitle'] ?? '', 'workSinceDate' => $e['workSinceDate'] ?? '', 'monthlyIncome' => $f['declaredMonthlyIncome'] ?? 0, 'currency' => $f['declaredMonthlyIncomeCurrency'] ?? 'AED', ], 'addressSnapshot' => [ 'country' => $a['country'] ?? '', 'city' => $a['city'] ?? '', 'area' => $a['area'] ?? '', 'street' => $a['street'] ?? '', 'postalCode' => $a['postalCode'] ?? '', ], ], JSON_UNESCAPED_UNICODE); } /** @return array */ public static function timelineFor( string $financeStatus, string $bucket, ?string $submittedAt, ?string $decisionAt, string $modifiedAt, ): array { $steps = ['received', 'in_review', 'offer_ready', 'approved', 'closed']; $currentIdx = array_search($bucket, $steps, true); if ($currentIdx === false) { $currentIdx = 0; } $timeline = []; foreach ($steps as $idx => $step) { if ($idx < $currentIdx) { $state = 'completed'; } elseif ($idx === $currentIdx) { $state = 'current'; } else { $state = 'pending'; } $timeline[] = ['step' => $step, 'state' => $state, 'timestamp' => null]; } if ($bucket === 'closed' && in_array($financeStatus, [ Application::STATUS_DECLINED, Application::STATUS_KYC_FAILED, Application::STATUS_CANCELLED, ], true)) { $timeline[2]['state'] = 'skipped'; $timeline[3]['state'] = 'skipped'; } $fill = function (int $i, ?string $ts) use (&$timeline): void { if ($ts !== null && in_array($timeline[$i]['state'], ['completed', 'current'], true)) { $timeline[$i]['timestamp'] = $ts; } }; $fill(0, $submittedAt); $fill(1, $modifiedAt); $fill(2, $decisionAt); $fill(3, $decisionAt); $fill(4, in_array($financeStatus, [Application::STATUS_DECLINED, Application::STATUS_KYC_FAILED], true) ? $decisionAt : $modifiedAt); return $timeline; } /** * @param string[] $submittedTypeIds * @param string[] $requiredTypeIds * @return string[] */ public static function validateDocumentSet(array $submittedTypeIds, array $requiredTypeIds): array { return array_values(array_diff($requiredTypeIds, array_unique($submittedTypeIds))); } // ── Submit pipeline ───────────────────────────────────────── /** * @param array{productId: string, requestedAmount: float, requestedTenorMonths: int, * purposeOfFinance: string, documentIds: string[]} $input * @return array{status: int, body: array} */ public function submit(string $profileId, string $appUserId, array $input): array { $customer = $this->bundleLoader->loadByProfileId($profileId); if ($customer['CCustomerProfile'] === null) { return ['status' => 404, 'body' => ['code' => 'PROFILE_NOT_FOUND', 'message' => 'Customer profile not found']]; } // Contact-info guard — phone + email are mandatory before applying. // Email is enforced at registration so it's expected to be present; // phone may be missing when the account was created via Google sign-in // (no phone collected) or never updated. Block submit and surface the // missing fields so the client can route the customer to fix it. $appUser = $this->appUserRepo->findById($appUserId); if ($appUser === null) { return ['status' => 404, 'body' => ['code' => 'USER_NOT_FOUND', 'message' => 'User not found']]; } $missingContact = []; if (empty($appUser->emailAddress)) $missingContact[] = 'emailAddress'; if (empty($appUser->phoneNumber)) $missingContact[] = 'phoneNumber'; if (!empty($missingContact)) { return ['status' => 422, 'body' => [ 'code' => 'MISSING_CONTACT_INFO', 'message' => 'Contact information is incomplete. Please add the missing details before applying.', 'missing' => $missingContact, ]]; } try { $product = $this->client->getById('CFinancialProduct', $input['productId']); } catch (EspoApiException $e) { if ($e->getCode() === 404) { return ['status' => 404, 'body' => ['code' => 'PRODUCT_NOT_FOUND', 'message' => 'Product not found']]; } throw $e; } if (($product['status'] ?? '') !== 'active') { return ['status' => 404, 'body' => ['code' => 'PRODUCT_NOT_FOUND', 'message' => 'Product not found']]; } $requiredDocs = $this->client->get('CRequiredDocumentRule', [ 'where' => [ ['type' => 'equals', 'attribute' => 'financialProductId', 'value' => $input['productId']], ['type' => 'equals', 'attribute' => 'isMandatory', 'value' => true], ['type' => 'equals', 'attribute' => 'status', 'value' => 'active'], ], 'maxSize' => 50, ])['list'] ?? []; $requiredTypeIds = array_values(array_filter(array_map(fn($r) => $r['documentTypeId'] ?? '', $requiredDocs))); $policies = $this->policyLoader->loadForProduct($input['productId']); $fields = $this->loadConditionFields(); $verdict = $this->evaluator->evaluate($customer, $policies, $fields); if ($verdict['verdict'] === 'not_eligible' && ($verdict['reasons'][0] ?? '') === 'criteria_not_met') { return ['status' => 403, 'body' => [ 'code' => 'NOT_ELIGIBLE', 'message' => 'Not eligible for this product', 'categoriesToImprove' => $verdict['categoriesToImprove'] ?? [], ]]; } $existing = $this->applicationRepo->findNonTerminalForAppUserProduct($appUserId, $input['productId']); if ($existing !== null) { return ['status' => 409, 'body' => [ 'code' => 'DUPLICATE_APPLICATION', 'message' => 'An active application already exists for this product', 'existingApplicationId' => $existing->id, 'existingRef' => $existing->applicationRef, ]]; } $submittedDocs = $this->documentRepo->findByIdsForProfile($input['documentIds'], $profileId); $submittedTypeIds = array_map(fn(ApplicationDocument $d) => $d->documentTypeId, $submittedDocs); $missing = self::validateDocumentSet($submittedTypeIds, $requiredTypeIds); if (!empty($missing)) { return ['status' => 422, 'body' => [ 'code' => 'INVALID_DOCUMENT_SET', 'message' => 'Required documents are missing', 'missing' => $missing, ]]; } $snapshotJson = self::buildSnapshotJson($customer); $customerName = $customer['CCustomerProfile']['name'] ?? 'Applicant'; $productName = $product['name'] ?? 'Product'; // accountId is the BANK's account (the institution that owns the // product), not the customer's account — that's how the partner-portal // queue scopes its visibility. Customer scoping is via customerProfileId // / appUserId, both already set above. $app = $this->applicationRepo->create([ 'name' => "{$customerName} — {$productName}", 'financeStatus' => Application::STATUS_SUBMITTED, 'submittedAt' => date('Y-m-d H:i:s'), 'customerProfileId' => $profileId, 'appUserId' => $appUserId, 'accountId' => $product['accountId'] ?? null, 'financialProductId' => $input['productId'], 'requestedAmount' => $input['requestedAmount'], 'requestedAmountCurrency' => $product['currency'] ?? 'AED', 'requestedTenorMonths' => $input['requestedTenorMonths'], 'purposeOfFinance' => $input['purposeOfFinance'], 'description' => $snapshotJson, ]); foreach ($input['documentIds'] as $docId) { try { $this->documentRepo->bindToApplication($docId, $app->id); } catch (\Throwable) { error_log("[applications] failed to bind doc {$docId} to app {$app->id}"); } } return ['status' => 201, 'body' => ['application' => $app]]; } // ── Customer-side offer actions ───────────────────────────── /** @return array{status: int, body: array} */ public function acceptOffer(string $appId, string $offerId, string $appUserId): array { $app = $this->applicationRepo->findByIdForAppUser($appId, $appUserId); if ($app === null) { return $this->offerNotFoundApp(); } $offer = $this->offerRepo->findById($offerId); if ($offer === null || $offer->applicationId !== $appId) { return ['status' => 404, 'body' => [ 'code' => 'OFFER_NOT_FOUND', 'message' => 'Offer not found.', ]]; } if ($offer->rescindedAt || $offer->acceptedAt || $offer->declinedAt) { return ['status' => 409, 'body' => [ 'code' => 'OFFER_NOT_ACTIONABLE', 'message' => 'Offer already actioned or rescinded.', ]]; } if ($offer->validUntil !== '' && strtotime($offer->validUntil) !== false && strtotime($offer->validUntil) < time() ) { return ['status' => 409, 'body' => [ 'code' => 'OFFER_EXPIRED', 'message' => 'Offer has expired.', ]]; } $accepted = $this->offerRepo->accept($offerId); $updated = $this->applicationRepo->setStage($appId, Application::STATUS_OFFER_ACCEPTED, 'offerAccepted'); $this->eventRepo->append( $appId, $app->accountId, $appUserId, 'Customer', ApplicationEvent::TYPE_OFFER_ACCEPTED, ['offerId' => $offerId], ); return ['status' => 200, 'body' => ['offer' => $accepted, 'application' => $updated]]; } /** @return array{status: int, body: array} */ public function declineOfferByCustomer( string $appId, string $offerId, string $appUserId, ?string $reason, ): array { $app = $this->applicationRepo->findByIdForAppUser($appId, $appUserId); if ($app === null) { return $this->offerNotFoundApp(); } $offer = $this->offerRepo->findById($offerId); if ($offer === null || $offer->applicationId !== $appId) { return ['status' => 404, 'body' => [ 'code' => 'OFFER_NOT_FOUND', 'message' => 'Offer not found.', ]]; } if ($offer->rescindedAt || $offer->acceptedAt || $offer->declinedAt) { return ['status' => 409, 'body' => [ 'code' => 'OFFER_NOT_ACTIONABLE', 'message' => 'Offer already actioned or rescinded.', ]]; } $declined = $this->offerRepo->decline($offerId); // No counter-offer follows a customer decline — close the application. $updated = $this->applicationRepo->setStage( $appId, Application::STATUS_OFFER_EXPIRED, 'offerExpired', ); $this->eventRepo->append( $appId, $app->accountId, $appUserId, 'Customer', ApplicationEvent::TYPE_OFFER_DECLINED, ['offerId' => $offerId, 'reason' => $reason], ); return ['status' => 200, 'body' => ['offer' => $declined, 'application' => $updated]]; } /** * Standard 404 tuple for application-not-found used by the offer * accept/decline flows (customer scope, mirrors ApplicationReviewService). * * @return array{status: int, body: array} */ private function offerNotFoundApp(): array { return ['status' => 404, 'body' => [ 'code' => 'APPLICATION_NOT_FOUND', 'message' => 'Application not found.', ]]; } /** @return array{status: int, body: array} */ public function cancel(string $appUserId, string $applicationId): array { $app = $this->applicationRepo->findByIdForAppUser($applicationId, $appUserId); if ($app === null) { return ['status' => 404, 'body' => ['code' => 'APPLICATION_NOT_FOUND', 'message' => 'Application not found']]; } if (!self::isCancelable($app->financeStatus)) { return ['status' => 409, 'body' => [ 'code' => 'APPLICATION_NOT_CANCELABLE', 'message' => 'This application can no longer be cancelled', 'currentStatus' => $app->financeStatus, ]]; } $updated = $this->applicationRepo->updateStatus($applicationId, Application::STATUS_CANCELLED); return ['status' => 200, 'body' => ['application' => $updated]]; } // ── Document upload helpers ───────────────────────────────── /** * Create the EspoCRM Attachment record with the file contents. * * @return array raw Attachment row returned by EspoCRM */ public function uploadAttachmentForDocument(string $fileName, string $mimeType, string $contents): array { return $this->client->uploadAttachment($fileName, $mimeType, $contents, 'CCustomerDocument'); } /** * Create a CCustomerDocument row that references a previously-uploaded Attachment. * The row is initially unlinked from any application (`applicationId` left null); * `bindToApplication` attaches it once the customer clicks Submit. */ public function createCustomerDocument( string $profileId, string $documentTypeId, string $attachmentId, string $fileName, string $mimeType, ): ApplicationDocument { return $this->documentRepo->create([ 'name' => $fileName, 'customerProfileId' => $profileId, 'documentTypeId' => $documentTypeId, 'attachmentId' => $attachmentId, 'fileName' => $fileName, 'mimeType' => $mimeType, 'source' => 'upload', 'status' => ApplicationDocument::STATUS_UPLOADED, 'documentScope' => 'application', 'uploadedAt' => date('Y-m-d H:i:s'), ]); } /** * Download the binary contents of a customer-owned document. Returns null * when the document does not exist or does not belong to this profile. * * @return array{contents: string, mimeType: string, fileName: string}|null */ public function downloadDocument(string $documentId, string $profileId): ?array { $doc = $this->documentRepo->findByIdForProfile($documentId, $profileId); if ($doc === null || empty($doc->attachmentId)) { return null; } $bin = $this->client->downloadAttachmentBinary($doc->attachmentId); // Prefer the document's own filename/mime over the raw Attachment's, // since the customer renamed-on-upload semantics live on the doc. $bin['fileName'] = $doc->fileName !== '' ? $doc->fileName : $bin['fileName']; $bin['mimeType'] = $doc->mimeType !== '' ? $doc->mimeType : $bin['mimeType']; return $bin; } /** * Replace the file backing an existing CCustomerDocument. Used when the * bank rejects an upload and asks for a fresh copy. Status is reset to * 'uploaded' so the bank can re-review. */ public function replaceDocument( string $documentId, string $profileId, string $fileName, string $mimeType, string $contents, ): ?ApplicationDocument { $existing = $this->documentRepo->findByIdForProfile($documentId, $profileId); if ($existing === null) { return null; } $attachment = $this->client->uploadAttachment($fileName, $mimeType, $contents, 'CCustomerDocument'); return $this->documentRepo->replaceAttachment( $documentId, (string) ($attachment['id'] ?? ''), $fileName, $mimeType, ); } // ── Projection ────────────────────────────────────────────── /** @return array */ public function projectDetail(Application $app): array { $product = []; try { $product = $this->client->getById('CFinancialProduct', $app->financialProductId); } catch (EspoApiException) { $product = []; } $docs = $this->documentRepo->findByApplicationId($app->id); $offer = null; $bucket = self::bucketFor($app->financeStatus); if (in_array($bucket, ['offer_ready', 'approved'], true)) { $offerList = $this->client->get('COffer', [ 'where' => [ ['type' => 'equals', 'attribute' => 'applicationId', 'value' => $app->id], ], 'maxSize' => 1, 'orderBy' => 'createdAt', 'order' => 'DESC', ])['list'] ?? []; if (!empty($offerList)) { $offer = ApplicationOffer::fromEspo($offerList[0]); } } return [ 'id' => $app->id, 'ref' => $app->applicationRef, 'productId' => $app->financialProductId, 'productName' => $product['name'] ?? '', 'productNameAr' => $product['nameAr'] ?? '', 'institutionName' => $product['accountName'] ?? '', 'institutionSlug' => '', 'financeType' => $product['financeType'] ?? 'conventional', 'currency' => $app->currency, 'requestedAmount' => $app->requestedAmount, 'requestedTenorMonths' => $app->requestedTenorMonths, 'purposeOfFinance' => $app->purposeOfFinance, 'financeStatus' => $app->financeStatus, 'bucket' => $bucket, 'bucketLabelKey' => $bucket === 'closed' ? self::closedLabelKey($app->financeStatus) : "applications.bucket.{$bucket}", 'cancelable' => self::isCancelable($app->financeStatus), 'submittedAt' => $app->submittedAt, 'decisionAt' => $app->decisionAt, 'updatedAt' => $app->modifiedAt, 'applicant' => $app->applicantSnapshot, 'employment' => $app->employmentSnapshot, 'address' => $app->addressSnapshot, 'documents' => array_map(fn(ApplicationDocument $d) => $this->projectDocument($d), $docs), 'offer' => $offer !== null ? $this->projectOffer($offer) : null, 'timeline' => self::timelineFor( $app->financeStatus, $bucket, $app->submittedAt, $app->decisionAt, $app->modifiedAt ), ]; } /** * Light list-item projection — skips documents, offer, and snapshot. * @param Application $app * @param array> $productsById Pre-loaded products keyed by id * @return array */ public function projectListItem(Application $app, array $productsById = []): array { $product = $productsById[$app->financialProductId] ?? null; $bucket = self::bucketFor($app->financeStatus); return [ 'id' => $app->id, 'ref' => $app->applicationRef, 'productName' => $product['name'] ?? '', 'productNameAr' => $product['nameAr'] ?? '', 'institutionName' => $product['accountName'] ?? '', 'institutionSlug' => '', 'currency' => $app->currency, 'requestedAmount' => $app->requestedAmount, 'requestedTenorMonths' => $app->requestedTenorMonths, 'purposeOfFinance' => $app->purposeOfFinance, 'financeStatus' => $app->financeStatus, 'bucket' => $bucket, 'bucketLabelKey' => $bucket === 'closed' ? self::closedLabelKey($app->financeStatus) : "applications.bucket.{$bucket}", 'cancelable' => self::isCancelable($app->financeStatus), 'submittedAt' => $app->submittedAt, 'decisionAt' => $app->decisionAt, 'updatedAt' => $app->modifiedAt, ]; } /** * Project a list of applications into list items with a single batched * product fetch. Called by the controller after listForAppUser returns. * * @param Application[] $apps * @return array> */ public function projectListItems(array $apps): array { if (empty($apps)) { return []; } $productIds = array_values(array_unique(array_map( fn(Application $a) => $a->financialProductId, $apps, ))); $productsById = []; if (!empty($productIds)) { try { $rows = $this->client->get('CFinancialProduct', [ 'where' => [ ['type' => 'in', 'attribute' => 'id', 'value' => $productIds], ], 'maxSize' => count($productIds), ])['list'] ?? []; foreach ($rows as $row) { $productsById[$row['id']] = $row; } } catch (\Sello\Repositories\EspoApiException) { // fallback: empty map; per-item projection shows empty product fields } } return array_map( fn(Application $a) => $this->projectListItem($a, $productsById), $apps, ); } /** @return array */ private function projectDocument(ApplicationDocument $d): array { return [ 'id' => $d->id, 'documentTypeId' => $d->documentTypeId, 'documentTypeName' => $d->documentTypeName, 'documentTypeNameAr' => $d->documentTypeNameAr, 'fileName' => $d->fileName, 'mimeType' => $d->mimeType, 'status' => $d->status, 'rejectionReason' => $d->rejectionReason, 'uploadedAt' => $d->uploadedAt, ]; } /** @return array */ private function projectOffer(ApplicationOffer $o): array { return [ 'id' => $o->id, 'approvedAmount' => $o->approvedAmount, 'profitRate' => $o->profitRate, 'tenorMonths' => $o->tenorMonths, 'monthlyInstallment' => $o->monthlyInstallment, 'validUntil' => $o->validUntil, ]; } /** @return array> keyed by field id */ private function loadConditionFields(): array { $fields = $this->client->get('CConditionField', [ 'select' => 'id,code,dataType,sourceEntity,sourcePath,status', 'maxSize' => 200, 'where' => [['type' => 'equals', 'attribute' => 'status', 'value' => 'active']], ])['list'] ?? []; $keyed = []; foreach ($fields as $f) { $keyed[$f['id']] = $f; } return $keyed; } }