/home/alixis5/integrate.sellofinance.com/src/Services
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;
}
}