diff --git a/Gateway/Client/SofincoPaymentGatewayClient.php b/Gateway/Client/SofincoPaymentGatewayClient.php new file mode 100644 index 0000000..50e99d9 --- /dev/null +++ b/Gateway/Client/SofincoPaymentGatewayClient.php @@ -0,0 +1,944 @@ +twig = $twig; + $this->logger = $logger; + $this->client = new Client(['defaults' => ['verify' => false, 'timeout' => 5]]); + $this->cache = null; + $this->clientId = $clientId; + $this->secretId = $secretId; + $this->serverHostName = $serverHostName; + $this->apiHostName = $apiHostName; + $this->weblongHostName = $weblongHostName; + } + + /** + * Set the cache adapter. + * + * @method setCache + * + * @param AdapterInterface|null $cache + * + * @throws \RuntimeException If the symfony/cache package is not installed + * @throws \UnexpectedValueException If the cache doesn't implement AdapterInterface + */ + public function setCache($cache) + { + if (null !== $cache) { + if (!interface_exists(AdapterInterface::class)) { + throw new \RuntimeException('EurekaPaymentGatewayClient cache requires "symfony/cache" package'); + } + + if (!$cache instanceof AdapterInterface) { + throw new \UnexpectedValueException(sprintf('The client\'s cache must implement %s.', AdapterInterface::class)); + } + + $this->cache = $cache; + } + } + + public function getAccessTokenHash(): string + { + return md5(sprintf('idci_payment.sofinco.access_token.%s', $this->clientId)); + } + + public function getAccessTokenUrl(): string + { + return sprintf('https://%s/token', $this->apiHostName); + } + + public function getAccessTokenResponse(): ?Response + { + if (null === $this->clientId || null === $this->secretId) { + throw new \LogicException('You must define "idci_payment.sofinco.client_id" and "idci_payment.sofinco.secret_id" parameters to use SofincoPaymentGatewayClient'); + } + + try { + return $this->client->request('POST', $this->getAccessTokenUrl(), [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + ], + 'auth' => [ + $this->clientId, + $this->secretId, + ], + ]); + } catch (RequestException $e) { + $this->logger->error($e->hasResponse() ? ((string) $e->getResponse()->getBody()) : $e->getMessage()); + + return null; + } + } + + public function getAccessTokenData(): array + { + if (null !== $this->cache && $this->cache->hasItem($this->getAccessTokenHash())) { + return json_decode($this->cache->getItem($this->getAccessTokenHash())->get(), true); + } + + $tokenResponse = $this->getAccessTokenResponse(); + + if (null === $tokenResponse) { + throw new \UnexpectedValueException('The access token request failed.'); + } + + $tokenData = json_decode($tokenResponse->getBody()->getContents(), true); + + if (!is_array($tokenData)) { + throw new \UnexpectedValueException('The access token response can\'t be parsed.'); + } + + if (null !== $this->cache) { + $item = $this->cache->getItem($this->getAccessTokenHash()); + $item->set(json_encode($tokenData)); + $item->expiresAfter($tokenData['expires_in']); + + $this->cache->save($item); + } + + return $tokenData; + } + + public function getAccessToken(): string + { + return $this->getAccessTokenData()['access_token']; + } + + public function getBusinessTokenUrl(): string + { + return sprintf('https://%s/BusinessDataTransfer/V1/businessDataTransferTokens/', $this->apiHostName); + } + + public function getBusinessTokenResponse(array $options): ?Response + { + try { + return $this->client->request('POST', $this->getBusinessTokenUrl(), [ + 'json' => $this->resolveBusinessTokenOptions($options), + 'headers' => [ + 'Authorization' => sprintf('Bearer %s', $this->getAccessToken()), + ], + ]); + } catch (RequestException $e) { + $this->logger->error($e->hasResponse() ? ((string) $e->getResponse()->getBody()) : $e->getMessage()); + + return null; + } + } + + public function getBusinessTokenData(array $options): array + { + $tokenResponse = $this->getBusinessTokenResponse($options); + + if (null === $tokenResponse) { + throw new \UnexpectedValueException('The business token request failed.'); + } + + $tokenData = json_decode($tokenResponse->getBody()->getContents(), true); + + if (!is_array($tokenData)) { + throw new \UnexpectedValueException('The business token response can\'t be parsed.'); + } + + return $tokenData; + } + + public function getBusinessToken(array $options): string + { + return $this->getBusinessTokenData($options)['token']; + } + + public function getCreditUrl(array $options): string + { + return sprintf( + 'https://%s/creditpartner/?q6=%s&x1=%s&token=%s', + $this->serverHostName, + self::CONTEXT_PARTNER_ID, + self::CONTEXT_SOURCE_ID, + $this->getBusinessToken($options) + ); + } + + public function getLoanSimulationsUrl(): string + { + return sprintf('https://%s/loanSimulation/v1/simulations/', $this->apiHostName); + } + + public function getLoanSimulationsResponse(array $options): ?Response + { + try { + return $this->client->request('POST', $this->getLoanSimulationsUrl(), [ + 'json' => $this->resolveLoanSimulationsOptions($options), + 'headers' => [ + 'Authorization' => sprintf('Bearer %s', $this->getAccessToken()), + 'Content-Type' => 'application/json', + 'Context-Applicationid' => self::CONTEXT_APPLICATION_ID, + 'Context-Partnerid' => self::CONTEXT_PARTNER_ID, + 'Context-Sourceid' => self::CONTEXT_SOURCE_ID, + ], + ]); + } catch (RequestException $e) { + $this->logger->error($e->hasResponse() ? ((string) $e->getResponse()->getBody()) : $e->getMessage()); + + return null; + } + } + + public function getLoanSimulations(array $options): array + { + $loanSimulationResponse = $this->getLoanSimulationsResponse($options); + + if (null === $loanSimulationResponse) { + throw new \UnexpectedValueException('The loan simulations request failed.'); + } + + $loanSimulations = json_decode($loanSimulationResponse->getBody()->getContents(), true); + + if (!is_array($loanSimulations)) { + throw new \UnexpectedValueException('The loanSimulationResponse response can\'t be parsed.'); + } + + return $loanSimulations; + } + + public function getSimulatorUrl(array $options): string + { + $resolvedOptions = $this->resolveSimulatorOptions($options); + + return sprintf( + 'https://%s/creditpartner/?q6=%s&x1=simu_vac&s3=%s&a9=%s&n2=%s', + $this->serverHostName, + self::CONTEXT_PARTNER_ID, + $resolvedOptions['amount'], + $resolvedOptions['businessProviderId'], + $resolvedOptions['equipmentCode'] + ); + } + + public function getDocumentsUrl(): string + { + return sprintf('https://%s/websrv/index.asp', $this->weblongHostName); + } + + public function getDocumentsResponse(array $options): ?Response + { + try { + $resolvedOptions = $this->resolveDocumentsOptions($options); + + $templateName = in_array($resolvedOptions['ServiceName'], [self::DOCUMENT_SERVICE_NAME_A1, self::DOCUMENT_SERVICE_NAME_B]) ? + 'flux_a1_c' : + 'flux_b_d' + ; + + return $this->client->request('POST', $this->getDocumentsUrl(), [ + 'body' => $this->twig->render(sprintf('@IDCIPayment/Gateway/sofinco/%s.html.twig', $templateName), $options), + 'headers' => [ + 'Context-Applicationid' => self::CONTEXT_APPLICATION_ID, + 'Context-Partnerid' => self::CONTEXT_PARTNER_ID, + 'Context-Sourceid' => self::CONTEXT_SOURCE_ID, + ], + ]); + } catch (RequestException $e) { + $this->logger->error($e->hasResponse() ? ((string) $e->getResponse()->getBody()) : $e->getMessage()); + + return null; + } + } + + public function getDocuments(array $options): Crawler + { + $loanSimulationResponse = $this->getDocumentsResponse($options); + + if (null === $loanSimulationResponse) { + throw new \UnexpectedValueException('The loan simulations request failed.'); + } + + return new Crawler($loanSimulationResponse->getBody()->getContents()); + } + + private function resolveBusinessTokenOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setDefault('tokenFormat', self::BUSINESS_TOKEN_FORMAT_OPAQUE)->setAllowedValues('tokenFormat', [ + self::BUSINESS_TOKEN_FORMAT_OPAQUE, + self::BUSINESS_TOKEN_FORMAT_JWS, + self::BUSINESS_TOKEN_FORMAT_JWE, + ]) + ->setDefined('associationKey')->setAllowedTypes('associationKey', ['string', 'null']) + ->setDefault('tokenDuration', self::BUSINESS_TOKEN_DURATION)->setAllowedTypes('tokenDuration', ['int']) + ->setRequired('businessContext')->setAllowedTypes('businessContext', ['string', 'array']) + ->setNormalizer('businessContext', function (Options $options, $value) { + if (is_string($value)) { + $value = json_decode($value, true); + + if (null === $value) { + throw new \InvalidArgumentException('The "businessContext" parameter is not a valid json string'); + } + } + + return json_encode($this->resolveBusinessContextOptions($value)); + }) + ; + + $resolvedOptions = $resolver->resolve($options); + if (empty(array_filter($resolvedOptions, function ($a) { return null !== $a; }))) { + return []; + } + + return $resolvedOptions; + } + + private function resolveBusinessContextOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('providerContext')->setAllowedTypes('providerContext', ['array']) + ->setNormalizer('providerContext', function (Options $options, $value) { + return $this->resolveBusinessProviderContextOptions($value); + }) + ->setDefined('customerContext')->setAllowedTypes('customerContext', ['array']) + ->setNormalizer('customerContext', function (Options $options, $value) { + return $this->resolveBusinessCustomerContextOptions($value); + }) + ->setDefined('coBorrowerContext')->setAllowedTypes('coBorrowerContext', ['array']) + ->setNormalizer('coBorrowerContext', function (Options $options, $value) { + return $this->resolveBusinessCustomerContextOptions($value); + }) + ->setDefined('offerContext')->setAllowedTypes('offerContext', ['array']) + ->setNormalizer('offerContext', function (Options $options, $value) { + return $this->resolveBusinessOfferContextOptions($value); + }) + ; + + return array_filter($resolver->resolve($options), function ($a) { return !empty($a); }); + } + + private function resolveBusinessProviderContextOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setDefined('businessProviderId')->setAllowedTypes('businessProviderId', ['null', 'string']) + ->setNormalizer('businessProviderId', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z0-9]{11}/', $value)) { + throw new \InvalidArgumentException('The "businessProviderId" parameter must be formatted as described in documentation "[A-Z0-9]{11}"'); + } + + return $value; + }) + ->setRequired('returnUrl')->setAllowedTypes('returnUrl', ['string']) + ->setNormalizer('returnUrl', function (Options $options, $value) { + if (!filter_var($value, FILTER_VALIDATE_URL)) { + throw new \InvalidArgumentException('The "returnUrl" parameter is not a valid URL'); + } + + return $value; + }) + ; + + $resolvedOptions = $resolver->resolve($options); + if (empty($resolvedOptions = array_filter($resolvedOptions, function ($a) { return null !== $a; }))) { + return []; + } + + return $resolvedOptions; + } + + private function resolveBusinessCustomerContextOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setDefined('externalCustomerId')->setAllowedTypes('externalCustomerId', ['null', 'string']) + ->setNormalizer('externalCustomerId', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z0-9]{0,16}/', $value)) { + throw new \InvalidArgumentException('The "externalCustomerId" parameter must be formatted as described in documentation "[A-Z0-9]{0,16}"'); + } + + return $value; + }) + ->setDefined('civilityCode')->setAllowedTypes('civilityCode', ['null', 'int', 'string']) + ->setNormalizer('civilityCode', function (Options $options, $value) { + $civilityCodeMapping = [ + 'mr' => self::CUSTOMER_CIVILITY_CODE_MR, + 'mrs' => self::CUSTOMER_CIVILITY_CODE_MRS, + 'ms' => self::CUSTOMER_CIVILITY_CODE_MS, + ]; + + if (isset($civilityCodeMapping[$value])) { + return $civilityCodeMapping[$value]; + } + + if (is_string($value) && 1 !== preg_match('/[123]{1}/', $value)) { + throw new \InvalidArgumentException('The "civilityCode" parameter must be formatted as described in documentation "[123]{1}"'); + } + + return $value !== null ? (int) $value : null; + }) + ->setDefined('firstName')->setAllowedTypes('firstName', ['null', 'string']) + ->setNormalizer('firstName', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,20}/', $value)) { + throw new \InvalidArgumentException('The "firstName" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,20}"'); + } + + return $value; + }) + ->setDefined('lastName')->setAllowedTypes('lastName', ['null', 'string']) + ->setNormalizer('lastName', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,20}/', $value)) { + throw new \InvalidArgumentException('The "lastName" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,20}"'); + } + + return $value; + }) + ->setDefined('birthName')->setAllowedTypes('birthName', ['null', 'string']) + ->setNormalizer('birthName', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,20}/', $value)) { + throw new \InvalidArgumentException('The "birthName" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,20}"'); + } + + return $value; + }) + ->setDefined('birthDate')->setAllowedTypes('birthDate', ['null', 'string']) + ->setNormalizer('birthDate', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[0-9]{4}-[0-9]{2}-[0-9]{2}/', $value)) { + throw new \InvalidArgumentException('The "birthDate" parameter must be formatted as described in documentation "[0-9]{4}-[0-9]{2}-[0-9]{2}"'); + } + + return $value; + }) + ->setDefined('citizenshipCode')->setAllowedTypes('citizenshipCode', ['null', 'string']) + ->setNormalizer('citizenshipCode', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z*]{0,3}/', $value)) { + throw new \InvalidArgumentException('The "citizenshipCode" parameter must be formatted as described in documentation "[A-Z*]{0,3}"'); + } + + return $value; + }) + ->setDefined('birthCountryCode')->setAllowedTypes('birthCountryCode', ['null', 'string']) + ->setNormalizer('birthCountryCode', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z*]{0,3}/', $value)) { + throw new \InvalidArgumentException('The "birthCountryCode" parameter must be formatted as described in documentation "[A-Z*]{0,3}"'); + } + + return $value; + }) + ->setDefined('additionalStreet')->setAllowedTypes('additionalStreet', ['null', 'string']) + ->setNormalizer('additionalStreet', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,32}/', $value)) { + throw new \InvalidArgumentException('The "additionalStreet" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,32}"'); + } + + return $value; + }) + ->setDefined('street')->setAllowedTypes('street', ['null', 'string']) + ->setNormalizer('street', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,32}/', $value)) { + throw new \InvalidArgumentException('The "street" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,32}"'); + } + + return $value; + }) + ->setDefined('city')->setAllowedTypes('city', ['null', 'string']) + ->setNormalizer('city', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,32}/', $value)) { + throw new \InvalidArgumentException('The "city" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,32}"'); + } + + return $value; + }) + ->setDefined('zipCode')->setAllowedTypes('zipCode', ['null', 'int', 'string']) + ->setNormalizer('zipCode', function (Options $options, $value) { + if (is_int($value)) { + $value = (string) $value; + } + + if (is_string($value) && 1 !== preg_match('/[0-9]{5}/', $value)) { + throw new \InvalidArgumentException('The "zipCode" parameter must be formatted as described in documentation "[0-9]{5}"'); + } + + return $value; + }) + ->setDefined('distributerOffice')->setAllowedTypes('distributerOffice', ['null', 'string']) + ->setNormalizer('distributerOffice', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[a-zA-Z \'-]{0,32}/', $value)) { + throw new \InvalidArgumentException('The "distributerOffice" parameter must be formatted as described in documentation "[a-zA-Z \'-]{0,32}"'); + } + + return $value; + }) + ->setDefined('countryCode')->setAllowedTypes('countryCode', ['null', 'string']) + ->setNormalizer('countryCode', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z*]{0,3}/', $value)) { + throw new \InvalidArgumentException('The "countryCode" parameter must be formatted as described in documentation "[A-Z*]{0,3}"'); + } + + return $value; + }) + ->setDefined('phoneNumber')->setAllowedTypes('phoneNumber', ['null', 'string']) + ->setNormalizer('phoneNumber', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/0[1234589]{1}[0-9]{8}/', $value)) { + throw new \InvalidArgumentException('The "phoneNumber" parameter must be formatted as described in documentation "0[1234589]{1}[0-9]{8}"'); + } + + return $value; + }) + ->setDefined('mobileNumber')->setAllowedTypes('mobileNumber', ['null', 'string']) + ->setNormalizer('mobileNumber', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/0[67]{1}[0-9]{8}/', $value)) { + throw new \InvalidArgumentException('The "mobileNumber" parameter must be formatted as described in documentation "0[67]{1}[0-9]{8}"'); + } + + return $value; + }) + ->setDefined('emailAddress')->setAllowedTypes('emailAddress', ['null', 'string']) + ->setNormalizer('emailAddress', function (Options $options, $value) { + if (is_string($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('The "emailAddress" parameter is not a valid email"'); + } + + return $value; + }) + ->setDefined('loyaltyCardId')->setAllowedTypes('loyaltyCardId', ['null', 'string']) + ->setNormalizer('loyaltyCardId', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z0-9]{0,19}/', $value)) { + throw new \InvalidArgumentException('The "loyaltyCardId" parameter must be formatted as described in documentation "[A-Z0-9]{0,19}"'); + } + + return $value; + }) + ; + + $resolvedOptions = $resolver->resolve($options); + if (empty($resolvedOptions = array_filter($resolvedOptions, function ($a) { return null !== $a; }))) { + return []; + } + + return $resolvedOptions; + } + + private function resolveBusinessOfferContextOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setDefined('orderId')->setAllowedTypes('orderId', ['null', 'string']) + ->setNormalizer('orderId', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z0-9]{0,16}/', $value)) { + throw new \InvalidArgumentException('The "orderId" parameter must be formatted as described in documentation "[A-Z0-9]{0,16}"'); + } + + return $value; + }) + ->setDefined('scaleId')->setAllowedTypes('scaleId', ['null', 'string']) + ->setNormalizer('scaleId', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z0-9]{0,16}/', $value)) { + throw new \InvalidArgumentException('The "scaleId" parameter must be formatted as described in documentation "[A-Z0-9]{0,16}"'); + } + + return $value; + }) + ->setDefined('equipmentCode')->setAllowedTypes('equipmentCode', ['null', 'string']) + ->setNormalizer('equipmentCode', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[A-Z0-9]{3}/', $value)) { + throw new \InvalidArgumentException('The "equipmentCode" parameter must be formatted as described in documentation "[A-Z0-9]{3}"'); + } + + return $value; + }) + ->setDefined('amount')->setAllowedTypes('amount', ['null', 'float', 'int', 'string']) + ->setNormalizer('amount', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[0-9]{0,9}/', $value)) { + throw new \InvalidArgumentException('The "amount" parameter must be formatted as described in documentation "[0-9]{0,9}"'); + } + + return $value !== null ? (float) $value : null; + }) + ->setDefined('orderAmount')->setAllowedTypes('orderAmount', ['null', 'float', 'int', 'string']) + ->setNormalizer('orderAmount', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[0-9]{0,9}/', $value)) { + throw new \InvalidArgumentException('The "orderAmount" parameter must be formatted as described in documentation "[0-9]{0,9}"'); + } + + return $value !== null ? (float) $value : null; + }) + ->setDefined('personalContributionAmount')->setAllowedTypes('personalContributionAmount', ['null', 'float', 'int', 'string']) + ->setNormalizer('personalContributionAmount', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[0-9]{0,9}/', $value)) { + throw new \InvalidArgumentException('The "personalContributionAmount" parameter must be formatted as described in documentation "[0-9]{0,9}"'); + } + + return $value !== null ? (float) $value : null; + }) + ->setDefined('duration')->setAllowedTypes('duration', ['null', 'string']) + ->setNormalizer('duration', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[0-9]{0,3}/', $value)) { + throw new \InvalidArgumentException('The "duration" parameter must be formatted as described in documentation "[0-9]{0,3}"'); + } + + return $value; + }) + ->setDefined('preScoringCode')->setAllowedTypes('preScoringCode', ['null', 'string']) + ->setNormalizer('preScoringCode', function (Options $options, $value) { + if (is_string($value) && 1 !== preg_match('/[0-9]{1}/', $value)) { + throw new \InvalidArgumentException('The "preScoringCode" parameter must be formatted as described in documentation "[0-9]{1}"'); + } + + return $value; + }) + ; + + $resolvedOptions = $resolver->resolve($options); + if (empty($resolvedOptions = array_filter($resolvedOptions, function ($a) { return null !== $a; }))) { + return []; + } + + return $resolvedOptions; + } + + private function resolveLoanSimulationsOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('amount')->setAllowedTypes('amount', ['null', 'int', 'float']) + ->setDefined('personalContributionAmount')->setAllowedTypes('personalContributionAmount', ['null', 'int', 'float']) + ->setDefault('dueNumbers', [])->setAllowedTypes('dueNumbers', ['array']) + ->setNormalizer('dueNumbers', function (Options $options, $value) { + foreach ($value as $dueNumber) { + if (!is_int($dueNumber)) { + throw new \InvalidArgumentException('The "dueNumbers" parameter must be an array of integer'); + } + } + + return $value; + }) + ->setDefined('monthlyAmount')->setAllowedTypes('amount', ['int', 'float']) + ->setDefined('hasBorrowerInsurance')->setAllowedTypes('hasBorrowerInsurance', ['bool']) + ->setDefined('hasCoBorrowerInsurance')->setAllowedTypes('hasBorrowerInsurance', ['bool']) + ->setDefined('hasEquipmentInsurance')->setAllowedTypes('hasBorrowerInsurance', ['bool']) + ->setDefined('borrowerBirthDate')->setAllowedTypes('borrowerBirthDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('borrowerBirthDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Y-m-d'); + } + + if (is_string($value) && 1 !== preg_match('/[0-9]{4}-[0-9]{2}-[0-9]{2}/', $value)) { + throw new \InvalidArgumentException('The "borrowerBirthDate" must be formatted as described in documentation "YYYY-MM-DD"'); + } + + return $value; + }) + ->setDefined('coBorrowerBirthDate')->setAllowedTypes('coBorrowerBirthDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('coBorrowerBirthDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Y-m-d'); + } + + if (is_string($value) && 1 !== preg_match('/[0-9]{4}-[0-9]{2}-[0-9]{2}/', $value)) { + throw new \InvalidArgumentException('The "coBorrowerBirthDate" must be formatted as described in documentation "YYYY-MM-DD"'); + } + + return $value; + }) + ->setDefined('scaleCode')->setAllowedTypes('scaleCode', ['string']) + ->setRequired('businessProviderId')->setAllowedTypes('businessProviderId', ['string']) + ->setRequired('equipmentCode')->setAllowedTypes('equipmentCode', ['string']) + ->setDefined('offerDate')->setAllowedTypes('offerDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('offerDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Y-m-d'); + } + + if (is_string($value) && 1 !== preg_match('/[0-9]{4}-[0-9]{2}-[0-9]{2}/', $value)) { + throw new \InvalidArgumentException('The "offerDate" must be formatted as described in documentation "YYYY-MM-DD"'); + } + + return $value; + }) + ; + + $resolvedOptions = $resolver->resolve($options); + if (empty(array_filter($resolvedOptions, function ($a) { return null !== $a; }))) { + return []; + } + + return $resolvedOptions; + } + + private function resolveSimulatorOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('amount')->setAllowedTypes('amount', ['int', 'float']) + ->setRequired('businessProviderId')->setAllowedTypes('businessProviderId', ['string']) + ->setRequired('equipmentCode')->setAllowedTypes('equipmentCode', ['string']) + ; + + return $resolver->resolve($options); + } + + private function resolveDocumentsOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setDefined(array_keys($options)) + ->setRequired('ServiceName')->setAllowedValues('ServiceName', [ + self::DOCUMENT_SERVICE_NAME_A1, + self::DOCUMENT_SERVICE_NAME_B, + self::DOCUMENT_SERVICE_NAME_C, + self::DOCUMENT_SERVICE_NAME_D, + ]) + ; + + $serviceNameResolveOptionsMethodMapping = [ + self::DOCUMENT_SERVICE_NAME_A1 => 'resolveDocumentsServiceNameA1Options', + self::DOCUMENT_SERVICE_NAME_B => 'resolveDocumentsServiceNameBOptions', + self::DOCUMENT_SERVICE_NAME_C => 'resolveDocumentsServiceNameCOptions', + self::DOCUMENT_SERVICE_NAME_D => 'resolveDocumentsServiceNameDOptions', + ]; + + $serviceName = $resolver->resolve($options)['ServiceName']; + + return call_user_func([$this, $serviceNameResolveOptionsMethodMapping[$serviceName]], $options); + } + + private function resolveDocumentsServiceNameA1Options(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('ServiceName')->setAllowedValues('ServiceName', [ + self::DOCUMENT_SERVICE_NAME_A1, + ]) + ->setRequired('StartDate')->setAllowedTypes('StartDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('StartDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Ymd'); + } + + if (1 !== preg_match('/[0-9]{8}/', $value)) { + throw new \InvalidArgumentException('The "StartDate" parameter must be formatted as described in documentation "YYYYMMDD"'); + } + + return $value; + }) + ->setRequired('EndDate')->setAllowedTypes('EndDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('EndDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Ymd'); + } + + if (1 !== preg_match('/[0-9]{8}/', $value)) { + throw new \InvalidArgumentException('The "EndDate" parameter must be formatted as described in documentation "YYYYMMDD"'); + } + + return $value; + }) + ->setRequired('Vendeur')->setAllowedTypes('Vendeur', ['string']) + ->setNormalizer('Vendeur', function (Options $options, $value) { + if (1 !== preg_match('/991[0-9A-Z-a-z]{8}/', $value)) { + throw new \InvalidArgumentException('The "Vendeur" parameter must be formatted as described in documentation "YYYYMMDD"'); + } + + return $value; + }) + ; + + return $resolver->resolve($options); + } + + private function resolveDocumentsServiceNameBOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('ServiceName')->setAllowedValues('ServiceName', [ + self::DOCUMENT_SERVICE_NAME_B, + ]) + ->setDefault('Dossiers', [])->setAllowedTypes('Dossiers', ['array']) + ->setNormalizer('Dossiers', function (Options $options, $value) { + if (empty($value)) { + return []; + } + + foreach ($value as &$dossier) { + $dossier = $this->resolveDocumentsDossiersOptions($dossier); + } + + return $value; + }) + ; + + return $resolver->resolve($options); + } + + private function resolveDocumentsServiceNameCOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('ServiceName')->setAllowedValues('ServiceName', [ + self::DOCUMENT_SERVICE_NAME_C, + ]) + ->setRequired('StartDate')->setAllowedTypes('StartDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('StartDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Ymd'); + } + + if (1 !== preg_match('/[0-9]{8}/', $value)) { + throw new \InvalidArgumentException('The "StartDate" parameter must be formatted as described in documentation "YYYYMMDD"'); + } + + return $value; + }) + ->setRequired('EndDate')->setAllowedTypes('EndDate', [\DateTimeInterface::class, 'string']) + ->setNormalizer('EndDate', function (Options $options, $value) { + if ($value instanceof \DateTime) { + $value = $value->format('Ymd'); + } + + if (1 !== preg_match('/[0-9]{8}/', $value)) { + throw new \InvalidArgumentException('The "EndDate" parameter must be formatted as described in documentation "YYYYMMDD"'); + } + + return $value; + }) + ->setRequired('Vendeur')->setAllowedTypes('Vendeur', ['string']) + ->setNormalizer('Vendeur', function (Options $options, $value) { + if (1 !== preg_match('/991[0-9A-Z-a-z]{8}/', $value)) { + throw new \InvalidArgumentException('The "Vendeur" parameter must be formatted as described in documentation "YYYYMMDD"'); + } + + return $value; + }) + ; + + return $resolver->resolve($options); + } + + private function resolveDocumentsServiceNameDOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('ServiceName')->setAllowedValues('ServiceName', [ + self::DOCUMENT_SERVICE_NAME_D, + ]) + ->setDefault('Dossiers', []) + ->setNormalizer('Dossiers', function (Options $options, $value) { + if (empty($value)) { + return []; + } + + foreach ($value as &$dossier) { + $dossier = $this->resolveDocumentsDossiersOptions($dossier); + } + + return $value; + }) + ; + + return $resolver->resolve($options); + } + + private function resolveDocumentsDossiersOptions(array $options): array + { + $resolver = (new OptionsResolver()) + ->setRequired('DossierNumber')->setAllowedTypes('DossierNumber', ['string']) + ->setNormalizer('DossierNumber', function (Options $options, $value) { + if (11 < strlen($value)) { + return new \InvalidArgumentException( + sprintf('The "DossierNumber" parameter max length is 11, current size given: %s', strlen($value)) + ); + } + }) + ->setDefined('CommandNumber')->setAllowedTypes('CommandNumber', ['string']) + ->setNormalizer('CommandNumber', function (Options $options, $value) { + if (12 < strlen($value)) { + return new \InvalidArgumentException( + sprintf('The "CommandNumber" parameter max length is 12, current size given: %s', strlen($value)) + ); + } + }) + ; + + return $resolver->resolve($options); + } +} diff --git a/Gateway/SofincoPaymentGateway.php b/Gateway/SofincoPaymentGateway.php index 09f5384..ac0edff 100644 --- a/Gateway/SofincoPaymentGateway.php +++ b/Gateway/SofincoPaymentGateway.php @@ -2,13 +2,14 @@ namespace IDCI\Bundle\PaymentBundle\Gateway; -use GuzzleHttp\Client; +use IDCI\Bundle\PaymentBundle\Gateway\Client\SofincoPaymentGatewayClient; use IDCI\Bundle\PaymentBundle\Model\GatewayResponse; use IDCI\Bundle\PaymentBundle\Model\PaymentGatewayConfigurationInterface; use IDCI\Bundle\PaymentBundle\Model\Transaction; use IDCI\Bundle\PaymentBundle\Payment\PaymentStatus; use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Twig\Environment; class SofincoPaymentGateway extends AbstractPaymentGateway { @@ -19,104 +20,89 @@ class SofincoPaymentGateway extends AbstractPaymentGateway const CART_MODULE = 'PANIER'; /** - * @var string + * @var SofincoPaymentGatewayClient */ - private $serverUrl; + private $client; public function __construct( - \Twig_Environment $templating, + Environment $templating, EventDispatcherInterface $dispatcher, - string $serverUrl + SofincoPaymentGatewayClient $client ) { parent::__construct($templating, $dispatcher); - $this->serverUrl = $serverUrl; - } - - /** - * Build options for WSACCROCHE Sofinco module. - * - * @method buildOfferVerifyOptions - * - * @param PaymentGatewayConfigurationInterface $paymentGatewayConfiguration - * @param Transaction $transaction - * - * @return array - */ - private function buildOfferVerifyOptions( - PaymentGatewayConfigurationInterface $paymentGatewayConfiguration, - Transaction $transaction - ): array { - return [ - 'q6' => $paymentGatewayConfiguration->get('site_id'), - 'p0' => self::OFFER_MODULE, - 'p4' => $transaction->getAmount() / 100, - ]; + $this->client = $client; } /** * Build gateway form options. * * @method buildOptions - * - * @param PaymentGatewayConfigurationInterface $paymentGatewayConfiguration - * @param Transaction $transaction - * - * @return array */ private function buildOptions( PaymentGatewayConfigurationInterface $paymentGatewayConfiguration, Transaction $transaction ): array { return [ - 'q6' => $paymentGatewayConfiguration->get('site_id'), - 'p0' => self::CART_MODULE, - 'ti' => $transaction->getId(), - 's3' => $transaction->getAmount() / 100, - 'uret' => $paymentGatewayConfiguration->get('return_url'), - 'p5' => $paymentGatewayConfiguration->get('callback_url'), + 'businessContext' => [ + 'providerContext' => [ + 'businessProviderId' => $paymentGatewayConfiguration->get('business_provider_id'), + 'returnUrl' => $paymentGatewayConfiguration->get('return_url'), + ], + 'customerContext' => [ + 'externalCustomerId' => $transaction->getCustomerId(), + 'civilityCode' => $transaction->getMetadata('customerContext.civilityCode'), + 'firstName' => $transaction->getMetadata('customerContext.firstName'), + 'lastName' => $transaction->getMetadata('customerContext.lastName'), + 'birthName' => $transaction->getMetadata('customerContext.birthName'), + 'birthDate' => $transaction->getMetadata('customerContext.birthDate'), + 'citizenshipCode' => $transaction->getMetadata('customerContext.citizenshipCode'), + 'birthCountryCode' => $transaction->getMetadata('customerContext.birthCountryCode'), + 'additionalStreet' => $transaction->getMetadata('customerContext.additionalStreet'), + 'street' => $transaction->getMetadata('customerContext.street'), + 'city' => $transaction->getMetadata('customerContext.city'), + 'zipCode' => $transaction->getMetadata('customerContext.zipCode'), + 'distributerOffice' => $transaction->getMetadata('customerContext.distributerOffice'), + 'countryCode' => $transaction->getMetadata('customerContext.countryCode'), + 'phoneNumber' => $transaction->getMetadata('customerContext.phoneNumber'), + 'mobileNumber' => $transaction->getMetadata('customerContext.mobileNumber'), + 'emailAddress' => $transaction->getCustomerEmail(), + 'loyaltyCardId' => $transaction->getMetadata('customerContext.loyaltyCardId'), + ], + 'coBorrowerContext' => [ + 'externalCustomerId' => $transaction->getMetadata('coBorrowerContext.externalCustomerId'), + 'civilityCode' => $transaction->getMetadata('coBorrowerContext.civilityCode'), + 'firstName' => $transaction->getMetadata('coBorrowerContext.firstName'), + 'lastName' => $transaction->getMetadata('coBorrowerContext.lastName'), + 'birthName' => $transaction->getMetadata('coBorrowerContext.birthName'), + 'birthDate' => $transaction->getMetadata('coBorrowerContext.birthDate'), + 'citizenshipCode' => $transaction->getMetadata('coBorrowerContext.citizenshipCode'), + 'birthCountryCode' => $transaction->getMetadata('coBorrowerContext.birthCountryCode'), + 'additionalStreet' => $transaction->getMetadata('coBorrowerContext.additionalStreet'), + 'street' => $transaction->getMetadata('coBorrowerContext.street'), + 'city' => $transaction->getMetadata('coBorrowerContext.city'), + 'zipCode' => $transaction->getMetadata('coBorrowerContext.zipCode'), + 'distributerOffice' => $transaction->getMetadata('coBorrowerContext.distributerOffice'), + 'countryCode' => $transaction->getMetadata('coBorrowerContext.countryCode'), + 'phoneNumber' => $transaction->getMetadata('coBorrowerContext.phoneNumber'), + 'mobileNumber' => $transaction->getMetadata('coBorrowerContext.mobileNumber'), + 'emailAddress' => $transaction->getMetadata('coBorrowerContext.emailAddress'), + 'loyaltyCardId' => $transaction->getMetadata('coBorrowerContext.loyaltyCardId'), + ], + 'offerContext' => [ + 'orderId' => $transaction->getId(), + 'scaleId' => $transaction->getMetadata('offerContext.scaleId'), + 'equipmentCode' => $paymentGatewayConfiguration->get('equipment_code'), + 'amount' => $transaction->getAmount(), + 'orderAmount' => $transaction->getMetadata('offerContext.orderAmount'), + 'personalContributionAmount' => $transaction->getMetadata('offerContext.personalContributionAmount'), + 'duration' => $transaction->getMetadata('offerContext.duration'), + 'preScoringCode' => $transaction->getMetadata('offerContext.preScoringCode'), + ], + ], ]; } - /** - * Check if the sofinco offer exists according to transaction amount. - * - * @method verifyIfOfferExist - * - * @param PaymentGatewayConfigurationInterface $paymentGatewayConfiguration - * @param Transaction $transaction - * - * @return bool - * - * @throws \UnexpectedValueException If the offer doesn't exists - */ - private function verifyIfOfferExist( - PaymentGatewayConfigurationInterface $paymentGatewayConfiguration, - Transaction $transaction - ): bool { - $options = $this->buildOfferVerifyOptions($paymentGatewayConfiguration, $transaction); - - $response = (new Client())->request('GET', $this->serverUrl, [ - 'query' => $options, - ]); - - $data = json_decode(json_encode(new \SimpleXMLElement($response->getBody()->getContents())), true); - - if ('00' !== $data['C_RETOUR']) { - throw new \UnexpectedValueException( - sprintf( - 'Error code %s: No offer exists for the contract code "%s" and the amount "%s". Result of the request: %s', - $data['C_RETOUR'], - $options['q6'], - $options['p4'], - json_encode($data) - ) - ); - } - - return true; - } - /** * {@inheritdoc} */ @@ -127,7 +113,7 @@ public function initialize( $options = $this->buildOptions($paymentGatewayConfiguration, $transaction); return [ - 'url' => $this->serverUrl, + 'url' => $this->client->getCreditUrl($options), 'options' => $options, ]; } @@ -155,22 +141,34 @@ public function getResponse( Request $request, PaymentGatewayConfigurationInterface $paymentGatewayConfiguration ): GatewayResponse { - if (!$request->isMethod(Request::METHOD_GET)) { - throw new \UnexpectedValueException('Sofinco : Payment Gateway error (Request method should be GET)'); + if (!$request->isMethod(Request::METHOD_POST)) { + throw new \UnexpectedValueException('Sofinco : Payment Gateway error (Request method should be POST)'); } $gatewayResponse = (new GatewayResponse()) - ->setTransactionUuid($request->query->get('ti')) - ->setAmount($request->query->get('s3')) + ->setTransactionUuid($request->request->get('ORDER_ID')) + ->setAmount($request->request->get('AMOUNT')) ->setDate(new \DateTime()) ->setStatus(PaymentStatus::STATUS_FAILED) - ->setRaw($request->query->all()) + ->setRaw($request->request->all()) ; - if (1 == $request->query->get('c3')) { + if (in_array( + $request->request->get('CONTRACT_STATUS'), + [ + SofincoPaymentGatewayClient::DOCUMENT_STATUS_REFUSED, + SofincoPaymentGatewayClient::DOCUMENT_STATUS_CANCELED, + SofincoPaymentGatewayClient::DOCUMENT_STATUS_NOT_FOUND, + SofincoPaymentGatewayClient::DOCUMENT_STATUS_ERROR, + ] + )) { return $gatewayResponse->setMessage('Transaction unauthorized'); } + if (SofincoPaymentGatewayClient::DOCUMENT_STATUS_FUNDED === $request->request->get('CONTRACT_STATUS')) { + return $gatewayResponse->setStatus(PaymentStatus::STATUS_APPROVED); + } + return $gatewayResponse->setStatus(PaymentStatus::STATUS_UNVERIFIED); } @@ -182,7 +180,8 @@ public static function getParameterNames(): ?array return array_merge( parent::getParameterNames(), [ - 'site_id', + 'business_provider_id', + 'equipment_code', ] ); } diff --git a/Resources/config/config.yml b/Resources/config/config.yml index 0a418ed..a09c796 100644 --- a/Resources/config/config.yml +++ b/Resources/config/config.yml @@ -27,7 +27,11 @@ parameters: idci_payment.systempay.server_url: https://paiement.systempay.fr/vads-payment/ # Sofinco default server url for test purposes - idci_payment.sofinco.server_url: https://re7financement.transcred.com/sofgate.asp # prod: https://financement.transcred.com/sofgate.asp + idci_payment.sofinco.server_host_name: rct.creditpartner.fr + idci_payment.sofinco.api_host_name: rct-api.sofinco.fr + idci_payment.sofinco.weblong_host_name: r7weblong.transcred.com + idci_payment.sofinco.client_id: null + idci_payment.sofinco.secret_id: null # Eureka default server url for test purposes idci_payment.eureka.server_host_name: recette-cb4x.fr # prod: cb4x.fr diff --git a/Resources/config/services.yml b/Resources/config/services.yml index f04a1b3..bcbc3ea 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -131,8 +131,6 @@ services: - { name: idci_payment.gateways, alias: systempay } IDCI\Bundle\PaymentBundle\Gateway\SofincoPaymentGateway: - arguments: - $serverUrl: '%idci_payment.sofinco.server_url%' tags: - { name: idci_payment.gateways, alias: sofinco } @@ -147,3 +145,13 @@ services: $serverHostName: '%idci_payment.eureka.server_host_name%' calls: - [setCache, ['@?cache.idci_payment']] + + IDCI\Bundle\PaymentBundle\Gateway\Client\SofincoPaymentGatewayClient: + arguments: + $clientId: '%idci_payment.sofinco.client_id%' + $secretId: '%idci_payment.sofinco.secret_id%' + $serverHostName: '%idci_payment.sofinco.server_host_name%' + $apiHostName: '%idci_payment.sofinco.api_host_name%' + $weblongHostName: '%idci_payment.sofinco.weblong_host_name%' + calls: + - [setCache, ['@?cache.idci_payment']] diff --git a/Resources/views/Gateway/sofinco.html.twig b/Resources/views/Gateway/sofinco.html.twig index 1f80e4d..8fb477e 100644 --- a/Resources/views/Gateway/sofinco.html.twig +++ b/Resources/views/Gateway/sofinco.html.twig @@ -1,6 +1,3 @@
diff --git a/Resources/views/Gateway/sofinco/flux_a1_c.html.twig b/Resources/views/Gateway/sofinco/flux_a1_c.html.twig new file mode 100644 index 0000000..fa895b5 --- /dev/null +++ b/Resources/views/Gateway/sofinco/flux_a1_c.html.twig @@ -0,0 +1,7 @@ +