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