/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/OnboardingService.php (22947B)
CustomerPreference::TYPE_FINANCE_PREFERENCE, 'primaryProductInterest' => CustomerPreference::TYPE_PRODUCT_INTEREST, 'riskProfile' => CustomerPreference::TYPE_RISK_APPETITE, ]; private const STEP5_ADDRESS_FIELDS = [ 'country', 'city', 'area', 'street', 'postalCode', ]; public function __construct( private readonly CustomerProfileRepository $profileRepo, private readonly CustomerEmploymentRepository $employmentRepo, private readonly CustomerFinancialProfileRepository $financialRepo, private readonly CustomerAddressRepository $addressRepo, private readonly CustomerConsentRepository $consentRepo, private readonly CustomerPreferenceRepository $preferenceRepo, private readonly AppUserRepository $appUserRepo, ) {} /** * Save a single wizard step's data to the appropriate entity/entities. * * @throws \InvalidArgumentException on invalid step number */ /** * Save a single wizard step's data to the appropriate entity/entities. * Returns the updated profileCompletenessPercent so the API response can * send it back to Angular without an extra round-trip. * * @throws \InvalidArgumentException on invalid step number */ public function saveStep(string $profileId, string $appUserId, int $step, array $data): int { match ($step) { 1 => $this->saveStep1($profileId, $data), 2 => $this->saveStep2($profileId, $data), 3 => $this->saveStep3($profileId, $data), 4 => $this->saveStep4($profileId, $appUserId, $data), 5 => $this->saveStep5($profileId, $data), default => throw new \InvalidArgumentException("Invalid onboarding step: {$step}"), }; return $this->recalculateCompleteness($profileId); } /** * Mark onboarding complete after validating all required data exists. * * @throws \RuntimeException (422) if required fields are missing * @throws \RuntimeException (404) if profile not found */ public function complete(string $profileId): CustomerProfile { $profile = $this->profileRepo->findById($profileId); if ($profile === null) { throw new \RuntimeException('Profile not found', 404); } $missing = []; foreach (self::REQUIRED_PROFILE_FIELDS as $field) { $value = $profile->$field ?? null; if ($value === null || $value === '') { $missing[$field] = 'This field is required to complete your profile.'; } } if ($this->employmentRepo->findByProfileId($profileId) === null) { $missing['employment'] = 'Employment information is required.'; } if ($this->financialRepo->findByProfileId($profileId) === null) { $missing['financialProfile'] = 'Financial profile is required.'; } if ($this->addressRepo->findHomeByProfileId($profileId) === null) { $missing['address'] = 'Home address is required.'; } if (!empty($missing)) { throw new \RuntimeException(json_encode($missing), 422); } return $this->profileRepo->update($profileId, [ 'onboardingStatus' => CustomerProfile::ONBOARDING_COMPLETED, 'onboardingStep' => 5, 'status' => CustomerProfile::STATUS_ACTIVE, 'profileCompletenessPercent' => $this->calculateCompleteness($profileId, $profile), ]); } /** * Archive the current active profile and create a fresh empty one. * The old profile is marked inactive so it appears in history. * A new draft profile is returned — the caller must reissue the JWT * with the new profileId so subsequent step saves target the new record. * * @throws \RuntimeException (404) if the current profile cannot be found */ public function archiveAndCreateNew( string $oldProfileId, string $appUserId, string $accountId, string $name, ): CustomerProfile { $old = $this->profileRepo->findById($oldProfileId); if ($old === null) { throw new \RuntimeException('Profile not found', 404); } // Archive the old profile $this->profileRepo->update($oldProfileId, [ 'status' => CustomerProfile::STATUS_INACTIVE, ]); // Create a fresh, empty profile for the new onboarding run return $this->profileRepo->create([ 'name' => $name, 'appUserId' => $appUserId, 'accountId' => $accountId, 'profileType' => CustomerProfile::TYPE_INDIVIDUAL, 'status' => CustomerProfile::STATUS_DRAFT, 'onboardingStatus' => CustomerProfile::ONBOARDING_NOT_STARTED, 'preferredCurrency' => 'AED', 'creditConsentGiven' => false, 'marketingConsentGiven' => false, 'existingBankCustomer' => false, ]); } /** * Returns all inactive (archived) profiles for this AppUser. * Used by the profile page to show KYC update history. * * @return CustomerProfile[] */ public function getProfileHistory(string $appUserId): array { return $this->profileRepo->findHistoryByAppUserId($appUserId); } /** * Returns all saved onboarding data grouped by step for Angular resume hydration. * nationalIdNumber is never returned — the user must re-enter it. */ public function getProfileData(string $profileId, string $appUserId): array { $profile = $this->profileRepo->findById($profileId); $employment = $this->employmentRepo->findByProfileId($profileId); $financial = $this->financialRepo->findByProfileId($profileId); $homeAddr = $this->addressRepo->findHomeByProfileId($profileId); $creditConsent = $this->consentRepo->findByProfileIdAndType($profileId, CustomerConsent::TYPE_CREDIT_BUREAU); $marketingConsent = $this->consentRepo->findByProfileIdAndType($profileId, CustomerConsent::TYPE_MARKETING); $finPref = $this->preferenceRepo->findByProfileIdAndType($profileId, CustomerPreference::TYPE_FINANCE_PREFERENCE); $prodPref = $this->preferenceRepo->findByProfileIdAndType($profileId, CustomerPreference::TYPE_PRODUCT_INTEREST); $riskPref = $this->preferenceRepo->findByProfileIdAndType($profileId, CustomerPreference::TYPE_RISK_APPETITE); $appUser = $this->appUserRepo->findById($appUserId); // `onboardingStep` isn't a column on the CCustomerProfile EspoCRM schema, // so writes to it are silently dropped and reads return null. Derive the // resume step from loaded data instead: the first step missing mandatory // fields wins; if everything is present or status is completed, 5. $computedStep = $this->computeResumeStep( $profile, $employment, $financial, $homeAddr, $creditConsent, $finPref ); return [ 'onboardingStep' => $computedStep, 'onboardingStatus' => $profile?->onboardingStatus ?? CustomerProfile::ONBOARDING_NOT_STARTED, 'step1' => [ 'profileType' => $profile?->profileType, 'nationality' => $profile?->nationality, 'residencyCountry' => $profile?->residencyCountry, 'dateOfBirth' => $profile?->dateOfBirth, 'gender' => $profile?->gender, 'maritalStatus' => $profile?->maritalStatus, 'dependentsCount' => $profile?->dependentsCount, ], 'step2' => [ 'employmentStatus' => $profile?->employmentStatus, 'employmentType' => $employment?->employmentType, 'employerName' => $employment?->employerName, 'jobTitle' => $employment?->jobTitle, 'salaryTransfer' => $employment?->salaryTransfer ?? false, 'employerCategory' => $employment?->employerCategory, 'workSinceDate' => $employment?->workSinceDate, 'employerCountry' => $employment?->employerCountry, ], 'step3' => [ 'declaredMonthlyIncome' => $financial?->declaredMonthlyIncome, 'declaredMonthlyIncomeCurrency' => $financial?->declaredMonthlyIncomeCurrency ?? 'AED', 'otherMonthlyIncome' => $financial?->otherMonthlyIncome, 'otherMonthlyIncomeCurrency' => $financial?->otherMonthlyIncomeCurrency ?? 'AED', 'fixedObligations' => $financial?->fixedObligations, 'fixedObligationsCurrency' => $financial?->fixedObligationsCurrency ?? 'AED', 'existingInstallments' => $financial?->existingInstallments, 'existingInstallmentsCurrency' => $financial?->existingInstallmentsCurrency ?? 'AED', 'existingBankCustomer' => $profile?->existingBankCustomer ?? false, 'creditConsentGiven' => $creditConsent?->status === CustomerConsent::STATUS_GRANTED, ], 'step4' => [ 'financePreference' => $finPref?->valueText ?? $profile?->financePreference, 'primaryProductInterest' => $prodPref?->valueText ?? $profile?->primaryProductInterest, 'riskProfile' => $riskPref?->valueText ?? $profile?->riskProfile, 'preferredCurrency' => $profile?->preferredCurrency ?? 'AED', 'preferredLanguage' => $appUser?->preferredLanguage ?? 'en', 'marketingConsentGiven' => $marketingConsent?->status === CustomerConsent::STATUS_GRANTED, ], 'step5' => [ 'nationalIdType' => $profile?->nationalIdType, 'nationalIdNumber' => null, 'country' => $homeAddr?->country ?? $profile?->residencyCountry, 'area' => $homeAddr?->area ?? $profile?->region, 'city' => $homeAddr?->city ?? $profile?->city, 'street' => $homeAddr?->street, 'postalCode' => $homeAddr?->postalCode, ], ]; } /** * Returns the step number the wizard should resume on (1-5). Ignores the * unreliable `onboardingStep` column (not on the EspoCRM schema) and derives * from data presence. If `onboardingStatus` is completed, returns 5. */ private function computeResumeStep( $profile, $employment, $financial, $homeAddr, $creditConsent, $finPref, ): int { if ($profile === null) return 1; if ($profile->onboardingStatus === CustomerProfile::ONBOARDING_COMPLETED) return 5; // Step 1 — personal identity on CCustomerProfile if (empty($profile->profileType) || empty($profile->nationality) || empty($profile->residencyCountry) || empty($profile->dateOfBirth) || empty($profile->gender) || empty($profile->maritalStatus)) { return 1; } // Step 2 — employment record + employmentStatus mirror if ($employment === null || empty($profile->employmentStatus)) { return 2; } // Step 3 — financial profile + credit consent if ($financial === null || empty($financial->declaredMonthlyIncome) || $creditConsent === null || $creditConsent->status !== CustomerConsent::STATUS_GRANTED) { return 3; } // Step 4 — preferences (fallback to profile mirror) if ($finPref === null && empty($profile->financePreference)) { return 4; } // Step 5 — home address + national ID if ($homeAddr === null || empty($profile->nationalIdType) || empty($profile->nationalIdNumber)) { return 5; } return 5; } // ── Step handlers ────────────────────────────────────────────────── private function saveStep1(string $profileId, array $data): void { $allowed = array_flip(self::STEP1_PROFILE_FIELDS); $patch = array_intersect_key($data, $allowed); $patch['onboardingStatus'] = CustomerProfile::ONBOARDING_IN_PROGRESS; $patch['onboardingStep'] = 1; $this->profileRepo->update($profileId, $patch); } private function saveStep2(string $profileId, array $data): void { $empFields = array_flip(self::STEP2_EMPLOYMENT_FIELDS); $empData = array_intersect_key($data, $empFields); if (!empty($empData)) { $this->employmentRepo->upsertByProfileId($profileId, $empData); } $profilePatch = [ 'onboardingStatus' => CustomerProfile::ONBOARDING_IN_PROGRESS, 'onboardingStep' => 2, ]; if (isset($data['employmentStatus'])) { $profilePatch['employmentStatus'] = $data['employmentStatus']; } $this->profileRepo->update($profileId, $profilePatch); } private function saveStep3(string $profileId, array $data): void { $finFields = array_flip(self::STEP3_FINANCIAL_FIELDS); $finData = array_intersect_key($data, $finFields); if (!empty($finData)) { $this->financialRepo->upsertByProfileId($profileId, $finData); } $consentStatus = ($data['creditConsentGiven'] ?? false) ? CustomerConsent::STATUS_GRANTED : CustomerConsent::STATUS_REVOKED; $this->consentRepo->upsertByProfileIdAndType($profileId, CustomerConsent::TYPE_CREDIT_BUREAU, [ 'status' => $consentStatus, 'grantedAt' => $consentStatus === CustomerConsent::STATUS_GRANTED ? (new \DateTimeImmutable())->format('Y-m-d H:i:s') : null, 'sourceChannel' => 'webPortal', ]); $profilePatch = [ 'onboardingStatus' => CustomerProfile::ONBOARDING_IN_PROGRESS, 'onboardingStep' => 3, 'creditConsentGiven' => $data['creditConsentGiven'] ?? false, ]; if (isset($data['existingBankCustomer'])) { $profilePatch['existingBankCustomer'] = $data['existingBankCustomer']; } if (isset($data['declaredMonthlyIncome'])) { // Mirror income to CCustomerProfile for scoring/completion checks. // Always use AED — the profile currency field only accepts CRM-configured currencies // and this field is used for internal scoring only. // Full currency detail is preserved in CCustomerFinancialProfile. $profilePatch['monthlyIncome'] = $data['declaredMonthlyIncome']; $profilePatch['monthlyIncomeCurrency'] = 'AED'; } $this->profileRepo->update($profileId, $profilePatch); } private function saveStep4(string $profileId, string $appUserId, array $data): void { foreach (self::STEP4_PREFERENCE_TYPES as $dataKey => $preferenceType) { if (isset($data[$dataKey]) && $data[$dataKey] !== '') { $this->preferenceRepo->upsertByProfileIdAndType($profileId, $preferenceType, [ 'valueText' => (string) $data[$dataKey], 'status' => CustomerPreference::STATUS_ACTIVE, ]); } } $marketingStatus = ($data['marketingConsentGiven'] ?? false) ? CustomerConsent::STATUS_GRANTED : CustomerConsent::STATUS_REVOKED; $this->consentRepo->upsertByProfileIdAndType($profileId, CustomerConsent::TYPE_MARKETING, [ 'status' => $marketingStatus, 'grantedAt' => $marketingStatus === CustomerConsent::STATUS_GRANTED ? (new \DateTimeImmutable())->format('Y-m-d H:i:s') : null, 'sourceChannel' => 'webPortal', ]); $profilePatch = [ 'onboardingStatus' => CustomerProfile::ONBOARDING_IN_PROGRESS, 'onboardingStep' => 4, 'marketingConsentGiven' => $data['marketingConsentGiven'] ?? false, ]; if (isset($data['financePreference'])) $profilePatch['financePreference'] = $data['financePreference']; if (isset($data['primaryProductInterest'])) $profilePatch['primaryProductInterest'] = $data['primaryProductInterest']; if (isset($data['riskProfile'])) $profilePatch['riskProfile'] = $data['riskProfile']; if (isset($data['preferredCurrency'])) $profilePatch['preferredCurrency'] = $data['preferredCurrency']; $this->profileRepo->update($profileId, $profilePatch); if (isset($data['preferredLanguage']) && in_array($data['preferredLanguage'], ['en', 'ar'], true)) { $this->appUserRepo->update($appUserId, ['preferredLanguage' => $data['preferredLanguage']]); } } private function saveStep5(string $profileId, array $data): void { $addrFields = array_flip(self::STEP5_ADDRESS_FIELDS); $addrData = array_intersect_key($data, $addrFields); if (!empty($addrData)) { $this->addressRepo->upsertHomeByProfileId($profileId, $addrData); } $profilePatch = [ 'onboardingStatus' => CustomerProfile::ONBOARDING_IN_PROGRESS, 'onboardingStep' => 5, ]; if (isset($data['nationalIdType'])) $profilePatch['nationalIdType'] = $data['nationalIdType']; if (isset($data['nationalIdNumber'])) $profilePatch['nationalIdNumber'] = $data['nationalIdNumber']; if (isset($data['area'])) $profilePatch['region'] = $data['area']; if (isset($data['city'])) $profilePatch['city'] = $data['city']; $this->profileRepo->update($profileId, $profilePatch); } /** * Re-fetch the profile after a step save and write the updated completeness * percentage back to EspoCRM. Called after every saveStep() so the dashboard * always reflects the current fill rate for inProgress users. */ private function recalculateCompleteness(string $profileId): int { $profile = $this->profileRepo->findById($profileId); if ($profile === null) { return 0; } $pct = $this->calculateCompleteness($profileId, $profile); $this->profileRepo->update($profileId, ['profileCompletenessPercent' => $pct]); return $pct; } /** * 20% per completed step × 5 steps = 100%. * Each step is considered complete when its primary data exists. */ private function calculateCompleteness(string $profileId, CustomerProfile $profile): int { $steps = [ // Step 1 — personal identity fields saved $profile->profileType !== null && $profile->nationality !== null && $profile->dateOfBirth !== null, // Step 2 — employment record exists $this->employmentRepo->findByProfileId($profileId) !== null, // Step 3 — financial profile record exists $this->financialRepo->findByProfileId($profileId) !== null, // Step 4 — at least one preference saved $this->preferenceRepo->findByProfileIdAndType( $profileId, CustomerPreference::TYPE_FINANCE_PREFERENCE ) !== null, // Step 5 — home address + national ID type saved $this->addressRepo->findHomeByProfileId($profileId) !== null && $profile->nationalIdType !== null, ]; return array_sum(array_map(fn(bool $done) => $done ? 20 : 0, $steps)); } }