*/ private array $customerByHashcodeCache = []; /** * Request-level cache to avoid duplicate CRM calls for the same customer. * * @var array */ private array $customerByEmailCache = []; public function __construct( private readonly CrmApiV4 $crmApi, private readonly CacheRepository $cacheRepository ) { } /** * @throws \App\Exceptions\CrmServiceException In case of an unhandled error * @throws \App\Exceptions\Crm\CrmAuthenticationFailedException * @throws \App\Exceptions\Crm\CrmCustomerNotActivatedException */ public function login( #[\SensitiveParameter] string $login, #[\SensitiveParameter] string $password ): void { try { $result = $this->crmApi->login($login, $password); if ($result !== true) { throw new CrmAuthenticationFailedException(); } } catch (ApiErrorException $e) { switch ($e->getApiError()) { case ApiError::authFailed: throw new CrmAuthenticationFailedException($e->getMessage()); case ApiError::customerNotActivated: throw new CrmCustomerNotActivatedException($e->getMessage()); default: report($e); throw CrmServiceException::of(cause: $e); } } catch (CrmException | \Exception $e) { report($e); throw CrmServiceException::of(cause: $e); } } public function resetPasswordFor(Email $email, string $newPassword): bool | IlluminateValidationException { try { $this->login($email->value, $newPassword); return IlluminateValidationException::withMessages([ trans('Your new password must be different from the old one'), ]); // phpcs:ignore } catch (CrmAuthenticationFailedException | CrmCustomerNotActivatedException) { // Expected: the new password does not match the current one } $crmCustomer = $this->getCustomerFromEmail($email->value); if ($crmCustomer === null) { return IlluminateValidationException::withMessages([ trans('No account was found for this email address'), ]); } if ($crmCustomer instanceof CrmServiceException) { throw $crmCustomer; } $updatedCrmCustomer = $this->newAppCustomerFrom( $crmCustomer, ['password' => $newPassword] ); try { $this->persist($updatedCrmCustomer); event(new CustomerPasswordUpdated($email)); return true; } catch (IlluminateValidationException $e) { return $e; } catch (CrmCustomerExistsException $e) { throw CrmServiceException::of('Unexpected CrmCustomerExistsException', $e); } } public function changePassword(CrmCustomer $crmCustomer, string $oldPassword, string $newPassword): CrmCustomer | IlluminateValidationException { throw_if( $crmCustomer->email === null, new UnexpectedValueException('CRM customer email is required for password change') ); try { $this->login($crmCustomer->email, $oldPassword); } catch (CrmAuthenticationFailedException | CrmCustomerNotActivatedException) { return IlluminateValidationException::withMessages([ 'password_old' => trans('Your old password does not match.'), ]); } $updatedCrmCustomer = $this->newAppCustomerFrom( $crmCustomer, ['password' => $newPassword] ); try { $result = $this->persist($updatedCrmCustomer); throw_if( ! $result->email, new UnexpectedValueException('CRM customer email is required for password change event') ); event(new CustomerPasswordUpdated(new Email($result->email))); return $result; } catch (IlluminateValidationException $e) { return $e; } catch (CrmCustomerExistsException $e) { throw CrmServiceException::of('Unexpected CrmCustomerExistsException during password change', $e); } } /** * @param array{firstname:string,lastname:string,birth_date:string,phone?:string,address?:string,postcode:string,city:string,opt_in?:bool} $data * * @throws \App\Exceptions\CrmServiceException In case of an unhandled error */ public function updateCustomerInfos(ModelsCustomer $customer, array $data): CrmCustomer | IlluminateValidationException { $crmCustomer = $this->getCustomerFromHashCode($customer->hashcode); if (! $crmCustomer instanceof CrmCustomer) { throw $crmCustomer instanceof \Throwable ? $crmCustomer : new UnexpectedValueException('CRM customer not found for hashcode: ' . (string) $customer->hashcode); } $updatedData = [ 'firstname' => $data['firstname'], 'lastname' => $data['lastname'], 'birthDate' => $data['birth_date'], 'address' => $data['address'] ?? null, 'postcode' => $data['postcode'], 'city' => $data['city'], 'phone' => ! empty($data['phone']) ? $data['phone'] : null, 'optIn' => $data['opt_in'] ?? false, ]; try { $newCrmCustomer = $this->newAppCustomerFrom( $crmCustomer, $updatedData ); return $this->persist($newCrmCustomer); } catch (IlluminateValidationException $e) { return $e; } catch (CrmCustomerExistsException $e) { throw CrmServiceException::of('Unexpected CrmCustomerExistsException during customer update', $e); } } public function unsubscribeCustomer(CrmHashcode $hashcode): bool { $crmCustomer = $this->getCustomerFromHashCode($hashcode); if (! $crmCustomer instanceof CrmCustomer) { return false; } $updatedCrmCustomer = $this->newAppCustomerFrom( $crmCustomer, ['optIn' => false] ); try { $this->persist($updatedCrmCustomer); return true; } catch (IlluminateValidationException) { return false; } catch (CrmCustomerExistsException | CrmServiceException $e) { throw $e; } } public function getCustomerFromHashCode(CrmHashcode $hashcode): null | CrmCustomer | CrmServiceException { if (array_key_exists($hashcode->value, $this->customerByHashcodeCache)) { return $this->customerByHashcodeCache[$hashcode->value]; } $cacheKey = $this->getCustomerHashcodeCacheKey($hashcode->value); /** @var null|CrmCustomer|CrmServiceException $result */ $result = $this->cacheRepository->remember( $cacheKey, now()->addMinutes(15), function () use ($hashcode): null | CrmCustomer | CrmServiceException { try { $customer = $this->crmApi->getCustomer($hashcode->value); return $customer ? $this->toAppCrmCustomer($customer) : null; } catch (ApiErrorException $e) { if ($e->getApiError()->equals(ApiError::modelNotFound)) { return null; } return CrmServiceException::of(cause: $e); } } ); $this->customerByHashcodeCache[$hashcode->value] = $result; return $result; } public function getCustomerFromEmail(string $email): null | CrmCustomer | CrmServiceException { $normalizedEmail = strtolower(trim($email)); if (array_key_exists($normalizedEmail, $this->customerByEmailCache)) { return $this->customerByEmailCache[$normalizedEmail]; } $cacheKey = $this->getCustomerEmailCacheKey($normalizedEmail); /** @var null|CrmCustomer|CrmServiceException $result */ $result = $this->cacheRepository->remember( $cacheKey, now()->addMinutes(15), function () use ($normalizedEmail): null | CrmCustomer | CrmServiceException { try { $customer = $this->crmApi->getCustomer($normalizedEmail); return $customer ? $this->toAppCrmCustomer($customer) : null; } catch (ApiErrorException $e) { if ($e->getApiError()->equals(ApiError::modelNotFound)) { return null; } return CrmServiceException::of(cause: $e); } } ); $this->customerByEmailCache[$normalizedEmail] = $result; return $result; } /** * @return object{result:bool,message:string} */ public function activate(CrmCustomer $customer): object { if ($customer->hashcode) { $this->cacheRepository->delete($this->getCustomerHashcodeCacheKey($customer->hashcode)); unset($this->customerByHashcodeCache[$customer->hashcode]); } if ($customer->email) { $this->cacheRepository->delete($this->getCustomerEmailCacheKey($customer->email)); unset($this->customerByEmailCache[strtolower(trim($customer->email))]); } try { $libCustomer = $this->toLibCrmCustomer($customer); return $libCustomer->activate() ? (object) [ 'result' => true, 'message' => trans('Your account has been successfully activated, you can now log in.'), ] : (object) [ 'result' => false, 'message' => trans('This activation link is not valid'), ]; } catch (\Throwable $e) { if ($e instanceof ApiErrorException && $e->getApiError()->equals(ApiError::customerAlreadyActive)) { return (object) [ 'result' => false, 'message' => trans('Your account is already activated, you can log in with it now.'), ]; } report($e); return (object) [ 'result' => false, 'message' => trans('An error has occurred. Please try again later'), ]; } } /** * @throws \Illuminate\Validation\ValidationException * @throws \App\Exceptions\Crm\CrmCustomerExistsException * @throws \App\Exceptions\CrmServiceException */ public function persist(CrmCustomer $customer): CrmCustomer { if ($customer->hashcode) { $this->cacheRepository->delete($this->getCustomerHashcodeCacheKey($customer->hashcode)); unset($this->customerByHashcodeCache[$customer->hashcode]); } if ($customer->email) { $this->cacheRepository->delete($this->getCustomerEmailCacheKey($customer->email)); unset($this->customerByEmailCache[strtolower(trim($customer->email))]); } try { $customer = $this->toLibCrmCustomer($customer); if (! is_null($customer->hashcode)) { $this->forceCrmCustomerNotNew($customer); } if (! $customer->save()) { throw CrmServiceException::of('Failed to save customer into CRM'); } return $this->toAppCrmCustomer($customer); } catch (ApiErrorException $e) { $apiError = $e->getApiError(); if ($apiError->equals(ApiError::validationFailed)) { throw IlluminateValidationException::withMessages(['error' => $e->getMessage()]); } if ($apiError->equals(ApiError::customerExists)) { throw new CrmCustomerExistsException($e->getMessage()); } throw CrmServiceException::of(cause: $e); } catch (ValidationException $e) { throw IlluminateValidationException::withMessages(['error' => $e->getMessage()]); } catch (CrmException $e) { throw CrmServiceException::of(cause: $e); } } /** @param array $andWith */ public function newAppCustomerFrom(CrmCustomer $existing, array $andWith = []): CrmCustomer { $data = array_merge($existing->toArray(), $andWith); return new CrmCustomer( hashcode: $this->stringOrNull($data, 'hashcode'), email: $this->stringOrNull($data, 'email'), password: $this->stringOrNull($data, 'password'), firstname: $this->stringOrNull($data, 'firstname'), lastname: $this->stringOrNull($data, 'lastname'), birthDate: $this->stringOrNull($data, 'birthDate'), phone: $this->stringOrNull($data, 'phone'), address: $this->stringOrNull($data, 'address'), address2: $this->stringOrNull($data, 'address2'), city: $this->stringOrNull($data, 'city'), postcode: $this->stringOrNull($data, 'postcode'), country: $data['country'] instanceof Country ? $data['country'] : Country::from(Arr::string($data, 'country')), optIn: Arr::boolean($data, 'optIn'), status: Arr::boolean($data, 'status'), lastConnection: $existing->lastConnection, ); } /** @param array $data */ private function stringOrNull(array $data, string $key): ?string { $value = Arr::get($data, $key); return is_string($value) ? $value : null; } private function toAppCrmCustomer(Customer $customer): CrmCustomer { return new CrmCustomer( $customer->hashcode, $customer->email, $customer->password, $customer->firstname, $customer->lastname, $customer->birth_date, $customer->phone, $customer->address, $customer->address2, $customer->city, $customer->postcode, $this->toAppCountry($customer->country), $customer->opt_in, $customer->status, $customer->last_connection ? new \DateTimeImmutable($customer->last_connection) : null ); } private function toLibCrmCustomer(CrmCustomer $crmCustomer): Customer { return new Customer(array_filter([ 'hashcode' => $crmCustomer->hashcode, 'email' => $crmCustomer->email, 'password' => $crmCustomer->password, 'firstname' => $crmCustomer->firstname, 'lastname' => $crmCustomer->lastname, 'birth_date' => $crmCustomer->birthDate, 'phone' => $crmCustomer->phone, 'address' => $crmCustomer->address, 'address2' => $crmCustomer->address2, 'city' => $crmCustomer->city, 'postcode' => $crmCustomer->postcode, 'country' => $this->toLibCountry($crmCustomer->country), 'opt_in' => $crmCustomer->optIn, 'status' => $crmCustomer->status, ], fn ($value) => $value !== null)); } private function toAppCountry(EnumsCountry $country): Country { return Country::from($country->value()); } private function toLibCountry(Country $country): EnumsCountry { return EnumsCountry::from($country->value()); } /** * WORKAROUND: Force a Customer model from LDC CRM to not be considered "new" * by modifying its internal state via Reflection. * This is needed because the SDK does not provide an update() method * or a way to mark an entity as existing. * * @see https://github.com/ldc/ldccrm-php-sdk/issues/XX */ private function forceCrmCustomerNotNew(Customer $customer): void { $refClass = new \ReflectionClass($customer::class); $refProp = $refClass->getProperty('isNew'); $refProp->setValue($customer, false); } private function getCustomerHashcodeCacheKey(string $hashcode): string { return "crm_customer_hashcode_{$hashcode}"; } private function getCustomerEmailCacheKey(string $email): string { $hashedEmail = hash('sha256', strtolower(trim($email))); return "crm_customer_email_{$hashedEmail}"; } }