From d7f289338b8a7bda2c31e5eafd6f323235e1b6bd Mon Sep 17 00:00:00 2001 From: hdelaforce-payplug Date: Fri, 7 Aug 2026 09:42:37 +0200 Subject: [PATCH] PRE-3551 feat: Create hosted field payment --- composer.json | 2 +- config/resources.yaml | 5 + config/services.yaml | 20 + config/services/client.xml | 32 ++ config/services/gateway.xml | 8 + migrations/Version20260806120000.php | 38 ++ ruleset/phpstan-baseline.neon | 12 + .../UnifiedApiHostedPaymentServiceFactory.php | 70 ++++ src/Auth/SyliusConfigurationRepository.php | 89 ++++ src/Auth/SyliusUnifiedApiHttpClient.php | 62 +++ .../Handler/CapturePaymentRequestHandler.php | 120 ++++++ .../Handler/NotifyPaymentRequestHandler.php | 96 +++++ .../Handler/StatusPaymentRequestHandler.php | 15 + .../CapturePaymentRequestCommandProvider.php | 4 + .../NotifyPaymentRequestCommandProvider.php | 4 + .../StatusPaymentRequestCommandProvider.php | 4 + src/Controller/SetUhfTestHfTokenAction.php | 61 +++ src/Entity/PayplugOperation.php | 120 ++++++ src/Lock/SyliusLock.php | 43 ++ .../Provider/CaptureHttpResponseProvider.php | 4 + .../Provider/UhfNotifyPaymentProvider.php | 62 +++ .../SyliusOrderStateMutator.php | 74 ++++ src/Provider/Payment/HfTokenProvider.php | 37 ++ src/Repository/PayplugOperationRepository.php | 33 ++ .../PayplugOperationRepositoryInterface.php | 15 + src/Repository/SyliusPaymentRepository.php | 92 ++++ ...fiedApiHostedPaymentServiceFactoryTest.php | 114 +++++ .../SyliusConfigurationRepositoryTest.php | 102 +++++ .../Auth/SyliusUnifiedApiHttpClientTest.php | 103 +++++ .../CapturePaymentRequestHandlerTest.php | 354 ++++++++++++++++ .../NotifyPaymentRequestHandlerTest.php | 395 ++++++++++++++++++ .../StatusPaymentRequestHandlerTest.php | 73 ++++ .../SetUhfTestHfTokenActionTest.php | 49 +++ tests/PHPUnit/Lock/SyliusLockTest.php | 87 ++++ .../Provider/UhfNotifyPaymentProviderTest.php | 102 +++++ .../SyliusOrderStateMutatorTest.php | 115 +++++ .../Provider/Payment/HfTokenProviderTest.php | 70 ++++ .../SyliusPaymentRepositoryTest.php | 110 +++++ 38 files changed, 2795 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260806120000.php create mode 100644 src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php create mode 100644 src/Auth/SyliusConfigurationRepository.php create mode 100644 src/Auth/SyliusUnifiedApiHttpClient.php create mode 100644 src/Controller/SetUhfTestHfTokenAction.php create mode 100644 src/Entity/PayplugOperation.php create mode 100644 src/Lock/SyliusLock.php create mode 100644 src/OrderPay/Provider/UhfNotifyPaymentProvider.php create mode 100644 src/PaymentProcessing/SyliusOrderStateMutator.php create mode 100644 src/Provider/Payment/HfTokenProvider.php create mode 100644 src/Repository/PayplugOperationRepository.php create mode 100644 src/Repository/PayplugOperationRepositoryInterface.php create mode 100644 src/Repository/SyliusPaymentRepository.php create mode 100644 tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php create mode 100644 tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php create mode 100644 tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php create mode 100644 tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php create mode 100644 tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php create mode 100644 tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php create mode 100644 tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php create mode 100644 tests/PHPUnit/Lock/SyliusLockTest.php create mode 100644 tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php create mode 100644 tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php create mode 100644 tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php create mode 100644 tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php diff --git a/composer.json b/composer.json index a7e6c659..d869d56e 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", - "payplug/unified-plugin-core": "0.0.7", + "payplug/unified-plugin-core": "dev-develop", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/config/resources.yaml b/config/resources.yaml index 16265d4d..8cdbd63f 100644 --- a/config/resources.yaml +++ b/config/resources.yaml @@ -9,3 +9,8 @@ sylius_resource: driver: doctrine/orm classes: model: PayPlug\SyliusPayPlugPlugin\Entity\Card + payplug.payplug_operation: + driver: doctrine/orm + classes: + model: PayPlug\SyliusPayPlugPlugin\Entity\PayplugOperation + repository: PayPlug\SyliusPayPlugPlugin\Repository\PayplugOperationRepository diff --git a/config/services.yaml b/config/services.yaml index 2131eff5..634b00cd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -169,6 +169,26 @@ services: - name: sylius.payment_request.provider.http_response gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory::FACTORY_NAME + ## Uhf Payplug Gateway ## + payplug_sylius_payplug_plugin.command_provider.payplug_uhf: + class: Sylius\Bundle\PaymentBundle\CommandProvider\ActionsCommandProvider + arguments: + - !tagged_locator + tag: payplug_sylius_payplug_plugin.command_provider.payplug_uhf + index_by: 'action' + tags: + - name: sylius.payment_request.command_provider + gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory::FACTORY_NAME + payplug_sylius_payplug_plugin.provider.order_pay.http_response.payplug_uhf: + class: Sylius\Bundle\PaymentBundle\Provider\ActionsHttpResponseProvider + arguments: + - !tagged_locator + tag: payplug_sylius_payplug_plugin.http_response_provider.payplug_uhf + index_by: action + tags: + - name: sylius.payment_request.provider.http_response + gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory::FACTORY_NAME + ## Apple Pay Payplug Gateway ## payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay: diff --git a/config/services/client.xml b/config/services/client.xml index 4e2053e8..de9d70c5 100644 --- a/config/services/client.xml +++ b/config/services/client.xml @@ -16,6 +16,31 @@ + + + + + + + + + + + + + + + @@ -29,6 +54,13 @@ + + + %payplug.oauth_base_url% + + + + + + PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory + + diff --git a/migrations/Version20260806120000.php b/migrations/Version20260806120000.php new file mode 100644 index 00000000..b8f24966 --- /dev/null +++ b/migrations/Version20260806120000.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE payplug_operations ( + id INT AUTO_INCREMENT NOT NULL, + operation_id VARCHAR(255) NOT NULL, + order_id VARCHAR(255) NOT NULL, + exec_code VARCHAR(255) NOT NULL, + outcome VARCHAR(255) NOT NULL, + amount INT NOT NULL, + treated TINYINT(1) NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE INDEX UNIQ_PAYPLUG_OPERATIONS_OPERATION_ID (operation_id), + INDEX IDX_PAYPLUG_OPERATIONS_ORDER_ID (order_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE payplug_operations'); + } +} diff --git a/ruleset/phpstan-baseline.neon b/ruleset/phpstan-baseline.neon index abca980f..f8f1121d 100644 --- a/ruleset/phpstan-baseline.neon +++ b/ruleset/phpstan-baseline.neon @@ -1312,6 +1312,18 @@ parameters: count: 1 path: ../src/Repository/PaymentRepository.php + - + message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\PayplugOperationRepository\:\:findOneByOperationId\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\PayplugOperation\|null but returns mixed\.$#' + identifier: return.type + count: 1 + path: ../src/Repository/PayplugOperationRepository.php + + - + message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\PayplugOperationRepository\:\:findOneByOrderId\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\PayplugOperation\|null but returns mixed\.$#' + identifier: return.type + count: 1 + path: ../src/Repository/PayplugOperationRepository.php + - message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\RefundHistoryRepository\:\:findLastProcessedRefundForPayment\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\RefundHistory\|null but returns mixed\.$#' identifier: return.type diff --git a/src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php b/src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php new file mode 100644 index 00000000..3be45d4e --- /dev/null +++ b/src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php @@ -0,0 +1,70 @@ +getGatewayConfig() ?? throw new \LogicException('Gateway config not found'); + $clientConfig = $this->getClientConfig($gatewayConfig); + + $clientId = $clientConfig['client_id'] ?? ''; + $clientSecret = $clientConfig['client_secret'] ?? ''; + $accountId = $clientConfig['account_id'] ?? ''; + + if ('' === $clientId || '' === $clientSecret || '' === $accountId) { + throw new GatewayConfigurationException('No client config found for ' . UhfGatewayFactory::FACTORY_NAME . '. Please renew your credentials in the PayPlug plugin configuration.'); + } + + return new UnifiedApiHostedPaymentService( + $this->httpClient, + $this->tokenManager, + $this->payplugOauthBaseUrl, + $clientId, + $clientSecret, + $accountId, + ); + } + + /** @return array */ + private function getClientConfig(GatewayConfigInterface $gatewayConfig): array + { + $config = $gatewayConfig->getConfig(); + $isLive = true === ($config['live'] ?? false); + $rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null); + if (!\is_array($rawClientConfig)) { + return []; + } + + /** @var array $clientConfig */ + $clientConfig = $rawClientConfig; + + return $clientConfig; + } +} diff --git a/src/Auth/SyliusConfigurationRepository.php b/src/Auth/SyliusConfigurationRepository.php new file mode 100644 index 00000000..242728dc --- /dev/null +++ b/src/Auth/SyliusConfigurationRepository.php @@ -0,0 +1,89 @@ +getConfig()[$key] ?? null; + + return \is_string($value) ? $value : null; + } + + public function set(string $key, string $value): void + { + $gatewayConfig = $this->getGatewayConfig(); + $config = $gatewayConfig->getConfig(); + $config[$key] = $value; + $gatewayConfig->setConfig($config); + $this->gatewayConfigRepository->add($gatewayConfig); + } + + public function getClientId(): string + { + return $this->getClientConfig()['client_id'] ?? ''; + } + + public function getClientSecret(): string + { + return $this->getClientConfig()['client_secret'] ?? ''; + } + + public function getPublicKeyId(): string + { + return $this->getClientConfig()['public_key_id'] ?? ''; + } + + public function getPublicKeyValue(): string + { + return $this->getClientConfig()['public_key_value'] ?? ''; + } + + /** @return array */ + private function getConfig(): array + { + return $this->getGatewayConfig()->getConfig(); + } + + private function getGatewayConfig(): GatewayConfigInterface + { + /** @var GatewayConfigInterface|null $gatewayConfig */ + $gatewayConfig = $this->gatewayConfigRepository->findOneBy(['factoryName' => UhfGatewayFactory::FACTORY_NAME]); + + if (null === $gatewayConfig) { + throw new \LogicException('No gateway config found for ' . UhfGatewayFactory::FACTORY_NAME); + } + + return $gatewayConfig; + } + + /** @return array */ + private function getClientConfig(): array + { + $config = $this->getConfig(); + $isLive = true === ($config['live'] ?? false); + $rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null); + + if (!\is_array($rawClientConfig)) { + return []; + } + + /** @var array $clientConfig */ + $clientConfig = $rawClientConfig; + + return $clientConfig; + } +} diff --git a/src/Auth/SyliusUnifiedApiHttpClient.php b/src/Auth/SyliusUnifiedApiHttpClient.php new file mode 100644 index 00000000..2ab1b278 --- /dev/null +++ b/src/Auth/SyliusUnifiedApiHttpClient.php @@ -0,0 +1,62 @@ + $headers + * + * @return array{status: int, body: string} + */ + public function get(string $url, array $headers = []): array + { + return $this->request('GET', $url, ['headers' => $headers]); + } + + /** + * @param array $body + * @param array $headers + * + * @return array{status: int, body: string} + */ + public function postJson(string $url, array $body, array $headers = []): array + { + return $this->request('POST', $url, ['json' => $body, 'headers' => $headers]); + } + + /** + * @param array $options + * + * @return array{status: int, body: string} + */ + private function request(string $method, string $url, array $options): array + { + try { + $response = $this->httpClient->request($method, $url, $options); + + return [ + 'status' => $response->getStatusCode(), + // false = don't throw on non-2xx; the Unified API caller itself checks the status. + 'body' => $response->getContent(false), + ]; + } catch (TransportExceptionInterface $e) { + // Network-level failure (DNS, timeout, connection reset) — getStatusCode()/getContent() + // throw this regardless of the `false` above, since it only suppresses HTTP status + // exceptions, not transport ones. Status 0 mirrors SyliusOAuthHttpClient's translation + // so callers can treat any UPC HTTP adapter failure uniformly. + return ['status' => 0, 'body' => $e->getMessage()]; + } + } +} diff --git a/src/Command/Handler/CapturePaymentRequestHandler.php b/src/Command/Handler/CapturePaymentRequestHandler.php index cda95705..ef6b30d1 100644 --- a/src/Command/Handler/CapturePaymentRequestHandler.php +++ b/src/Command/Handler/CapturePaymentRequestHandler.php @@ -7,12 +7,19 @@ use Payplug\Exception\HttpException; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; +use PayPlug\SyliusPayPlugPlugin\ApiClient\UnifiedApiHostedPaymentServiceFactory; use PayPlug\SyliusPayPlugPlugin\Command\CapturePaymentRequest; use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator; +use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Provider\Payment\HfTokenProvider; +use PayplugUnifiedCore\Exceptions\ApiException; use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; +use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; +use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -29,6 +36,9 @@ public function __construct( #[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')] private UrlProviderInterface $afterPayUrlProvider, private LoggerInterface $logger, + private UnifiedApiHostedPaymentServiceFactory $hostedPaymentServiceFactory, + private HfTokenProvider $hfTokenProvider, + private UrlGeneratorInterface $urlGenerator, ) {} public function __invoke(CapturePaymentRequest $capturePaymentRequest): void @@ -42,6 +52,12 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void throw new \LogicException('Payment method is not set for the payment.'); } + if (UhfGatewayFactory::FACTORY_NAME === $method->getGatewayConfig()?->getFactoryName()) { + $this->captureViaUnifiedApi($paymentRequest, $payment, $method); + + return; + } + if ( PayPlugApiClientInterface::STATUS_CREATED === ($payment->getDetails()['status'] ?? null) && ($payment->getDetails()['factory_name'] ?? null) === $method->getGatewayConfig()?->getFactoryName() @@ -107,4 +123,108 @@ public function __invoke(CapturePaymentRequest $capturePaymentRequest): void PaymentRequestTransitions::TRANSITION_COMPLETE, ); } + + /** + * Creates/confirms the payment through UPC's Unified API from the shopper's hfToken, instead of + * the legacy PayPlug SDK path above. Never applies an order/payment state transition beyond the + * PaymentRequest's own workflow (COMPLETE/FAIL) — the Unified API's asynchronous webhook is the + * single source of truth for the final PaymentOutcome, handled by a later task's + * NotifyPaymentRequestHandler branch, whether or not a 3DS redirect happened here. + */ + private function captureViaUnifiedApi( + PaymentRequestInterface $paymentRequest, + PaymentInterface $payment, + PaymentMethodInterface $method, + ): void { + if ( + PayPlugApiClientInterface::STATUS_CREATED === ($payment->getDetails()['status'] ?? null) && + UhfGatewayFactory::FACTORY_NAME === ($payment->getDetails()['factory_name'] ?? null) + ) { + $paymentRequest->setResponseData([ + 'retry' => true, + 'message' => 'Payment already created', + 'payment_id' => $payment->getDetails()['payment_id'] ?? 'unknown', + 'redirect_url' => $payment->getDetails()['redirect_url'] ?? null, + ]); + + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + return; + } + + $hfToken = $this->hfTokenProvider->getHfToken($payment); + + if (null === $hfToken) { + $this->logger->error('[PayPlug] UHF capture failed: no hfToken available for this payment.', [ + 'payment_id' => $payment->getId(), + ]); + $paymentRequest->setResponseData(['error' => 'missing_hf_token']); + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + return; + } + + $order = $payment->getOrder(); + $notificationUrl = $this->urlGenerator->generate( + 'sylius_payment_method_notify', + ['code' => $method->getCode()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + try { + $result = $this->hostedPaymentServiceFactory->createForPaymentMethod($method)->createHostedPayment( + $hfToken, + $payment->getAmount() ?? 0, + $payment->getCurrencyCode() ?? '', + (string) $order?->getId(), // @phpstan-ignore-line + null, + null, + null, + null, + null, + $notificationUrl, + ); + } catch (ApiException $exception) { + $this->logger->error('[PayPlug] UHF capture failed', ['error' => $exception->getMessage()]); + $paymentRequest->setResponseData(['error' => $exception->getMessage()]); + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + return; + } + + $responseBody = \json_decode($result->body, true); + $operationId = \is_array($responseBody) && \is_string($responseBody['id'] ?? null) ? $responseBody['id'] : null; + + $payment->setDetails([ + ...$payment->getDetails(), + 'status' => PayPlugApiClientInterface::STATUS_CREATED, + 'factory_name' => UhfGatewayFactory::FACTORY_NAME, + 'payment_id' => $operationId, + 'redirect_url' => $result->redirectUrl, + ]); + + $paymentRequest->setResponseData([ + 'payment_id' => $operationId, + 'redirect_url' => $result->redirectUrl, + 'status' => $result->status, + ]); + + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + } } diff --git a/src/Command/Handler/NotifyPaymentRequestHandler.php b/src/Command/Handler/NotifyPaymentRequestHandler.php index 9ea5147f..de583e17 100644 --- a/src/Command/Handler/NotifyPaymentRequestHandler.php +++ b/src/Command/Handler/NotifyPaymentRequestHandler.php @@ -7,18 +7,29 @@ use Payplug\Resource\Payment; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Command\NotifyPaymentRequest; +use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier; +use PayplugUnifiedCore\Contracts\IConfigurationRepository; +use PayplugUnifiedCore\Contracts\ILock; +use PayplugUnifiedCore\Contracts\IOrderStateMutator; +use PayplugUnifiedCore\Contracts\IPaymentRepository; +use PayplugUnifiedCore\Exceptions\InvalidNotificationException; +use PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper; +use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] class NotifyPaymentRequestHandler { + private const LOCK_TTL_SECONDS = 30; + public function __construct( private PaymentRequestProviderInterface $paymentRequestProvider, private StateMachineInterface $stateMachine, @@ -26,6 +37,11 @@ public function __construct( private PaymentNotificationHandler $paymentNotificationHandler, private RefundNotificationHandler $refundNotificationHandler, private PaymentTransitionApplier $paymentTransitionApplier, + private ILock $lock, + private IPaymentRepository $paymentRepository, + private IOrderStateMutator $orderStateMutator, + private IConfigurationRepository $configurationRepository, + private LoggerInterface $logger, ) {} public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void @@ -34,6 +50,13 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void /** @var PaymentInterface $payment */ $payment = $paymentRequest->getPayment(); + $method = $payment->getMethod(); + if (null !== $method && UhfGatewayFactory::FACTORY_NAME === $method->getGatewayConfig()?->getFactoryName()) { + $this->notifyViaUnifiedApi($paymentRequest); + + return; + } + try { $payload = $paymentRequest->getPayload(); $content = $payload['http_request']['content'] ?? null; // @phpstan-ignore-line @@ -85,4 +108,77 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void ); } } + + /** + * Verifies, persists, and applies a UPC Unified API webhook notification. ILock guards against + * PayPlug retrying the same notification while a first attempt is still in flight; IPaymentRepository's + * isTreated()/markTreated() guard against re-applying an outcome that already succeeded (the lock alone + * only prevents concurrent processing, not a later, sequential retry after the first attempt released it). + */ + private function notifyViaUnifiedApi(PaymentRequestInterface $paymentRequest): void + { + $payload = $paymentRequest->getPayload(); + $rawBody = $payload['http_request']['content'] ?? null; // @phpstan-ignore-line + $rawHeaders = $payload['http_request']['headers'] ?? []; // @phpstan-ignore-line + + if (!\is_string($rawBody) || '' === $rawBody) { + $this->failNotifyRequest($paymentRequest, 'Invalid UHF notification payload: empty body.'); + + return; + } + + $headers = []; + foreach ((array) $rawHeaders as $name => $values) { + $headers[(string) $name] = \is_array($values) ? (string) ($values[0] ?? '') : (string) $values; // @phpstan-ignore-line + } + + $expectedAuthorizationHeader = $this->configurationRepository->get('payplug_webhook_authorization_header') ?? ''; + + try { + $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedAuthorizationHeader); + } catch (InvalidNotificationException $exception) { + $this->logger->error('[PayPlug] UHF webhook notification rejected.', ['error' => $exception->getMessage()]); + $this->failNotifyRequest($paymentRequest, $exception->getMessage()); + + return; + } + + $lockKey = 'payplug_uhf_operation_' . $operationData->operationId; + + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + return; + } + + try { + if (!$this->paymentRepository->isTreated($operationData->operationId)) { + $this->paymentRepository->save($operationData); + $this->orderStateMutator->apply($operationData->orderId, $operationData->outcome); + $this->paymentRepository->markTreated($operationData->operationId); + } + } finally { + $this->lock->release($lockKey); + } + + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + } + + private function failNotifyRequest(PaymentRequestInterface $paymentRequest, string $message): void + { + $paymentRequest->setResponseData(['error' => $message]); + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + } } diff --git a/src/Command/Handler/StatusPaymentRequestHandler.php b/src/Command/Handler/StatusPaymentRequestHandler.php index 9cee5d15..b4a26505 100644 --- a/src/Command/Handler/StatusPaymentRequestHandler.php +++ b/src/Command/Handler/StatusPaymentRequestHandler.php @@ -7,6 +7,7 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Command\StatusPaymentRequest; +use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier; use Psr\Log\LoggerInterface; @@ -44,6 +45,20 @@ public function __invoke(StatusPaymentRequest $statusPaymentRequest): void throw new \LogicException('Payment method is not set for the payment.'); } + if (UhfGatewayFactory::FACTORY_NAME === $method->getGatewayConfig()?->getFactoryName()) { + // The Unified API's webhook (see NotifyPaymentRequestHandler) is the single source of + // truth for the final outcome, whether or not a 3DS challenge happened — nothing to + // synchronously resolve here. Complete the PaymentRequest so the shopper reaches the + // normal "thank you / order in progress" page; the order updates moments later. + $this->stateMachine->apply( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + return; + } + // We don't have a forced status, so we retrieve the payment status from PayPlug $client = $this->apiClientFactory->createForPaymentMethod($method); /** @var string|null $payplugPaymentId */ diff --git a/src/Command/Provider/CapturePaymentRequestCommandProvider.php b/src/Command/Provider/CapturePaymentRequestCommandProvider.php index bb507e7e..0aaa9f31 100644 --- a/src/Command/Provider/CapturePaymentRequestCommandProvider.php +++ b/src/Command/Provider/CapturePaymentRequestCommandProvider.php @@ -39,6 +39,10 @@ 'payplug_sylius_payplug_plugin.command_provider.payplug_wero', ['action' => PaymentRequestInterface::ACTION_CAPTURE], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.command_provider.payplug_uhf', + ['action' => PaymentRequestInterface::ACTION_CAPTURE], +)] final class CapturePaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { public function supports(PaymentRequestInterface $paymentRequest): bool diff --git a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php index 4ef91b6a..bafb7102 100644 --- a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php +++ b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php @@ -37,6 +37,10 @@ 'payplug_sylius_payplug_plugin.command_provider.payplug_wero', ['action' => PaymentRequestInterface::ACTION_NOTIFY], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.command_provider.payplug_uhf', + ['action' => PaymentRequestInterface::ACTION_NOTIFY], +)] final class NotifyPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { public function supports(PaymentRequestInterface $paymentRequest): bool diff --git a/src/Command/Provider/StatusPaymentRequestCommandProvider.php b/src/Command/Provider/StatusPaymentRequestCommandProvider.php index 37676e9b..1649bd9e 100644 --- a/src/Command/Provider/StatusPaymentRequestCommandProvider.php +++ b/src/Command/Provider/StatusPaymentRequestCommandProvider.php @@ -38,6 +38,10 @@ 'payplug_sylius_payplug_plugin.command_provider.payplug_wero', ['action' => PaymentRequestInterface::ACTION_STATUS], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.command_provider.payplug_uhf', + ['action' => PaymentRequestInterface::ACTION_STATUS], +)] final class StatusPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { public function __construct(private RequestStack $requestStack) diff --git a/src/Controller/SetUhfTestHfTokenAction.php b/src/Controller/SetUhfTestHfTokenAction.php new file mode 100644 index 00000000..dc2909e8 --- /dev/null +++ b/src/Controller/SetUhfTestHfTokenAction.php @@ -0,0 +1,61 @@ +query->getString('hfToken'); + + if ('' === $hfToken) { + throw new BadRequestHttpException('Missing "hfToken" query parameter.'); + } + + $this->requestStack->getSession()->set(self::TEST_SESSION_KEY, $hfToken); + + return new Response('Test hfToken stored for this session. You can now pay with Unified Hosted Fields.'); + } +} diff --git a/src/Entity/PayplugOperation.php b/src/Entity/PayplugOperation.php new file mode 100644 index 00000000..db2d506e --- /dev/null +++ b/src/Entity/PayplugOperation.php @@ -0,0 +1,120 @@ +operationId = $operationId; + $this->orderId = $orderId; + $this->execCode = $execCode; + $this->outcome = $outcome; + $this->amount = $amount; + $this->createdAt = new DateTime(); + } + + public function getId(): int + { + return $this->id; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function getExecCode(): string + { + return $this->execCode; + } + + public function setExecCode(string $execCode): void + { + $this->execCode = $execCode; + } + + public function getOutcome(): string + { + return $this->outcome; + } + + public function setOutcome(string $outcome): void + { + $this->outcome = $outcome; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function setAmount(int $amount): void + { + $this->amount = $amount; + } + + public function isTreated(): bool + { + return $this->treated; + } + + public function markTreated(): void + { + $this->treated = true; + } + + public function getCreatedAt(): DateTimeInterface + { + Assert::isInstanceOf($this->createdAt, DateTime::class); + + return DateTimeImmutable::createFromMutable($this->createdAt); + } +} diff --git a/src/Lock/SyliusLock.php b/src/Lock/SyliusLock.php new file mode 100644 index 00000000..82c3e65d --- /dev/null +++ b/src/Lock/SyliusLock.php @@ -0,0 +1,43 @@ + */ + private array $locks = []; + + public function __construct( + private LockFactory $lockFactory, + ) { + } + + public function acquire(string $key, int $ttlSeconds): bool + { + $lock = $this->lockFactory->createLock($key, $ttlSeconds); + + if (!$lock->acquire()) { + return false; + } + + $this->locks[$key] = $lock; + + return true; + } + + public function release(string $key): void + { + if (!isset($this->locks[$key])) { + return; + } + + $this->locks[$key]->release(); + unset($this->locks[$key]); + } +} diff --git a/src/OrderPay/Provider/CaptureHttpResponseProvider.php b/src/OrderPay/Provider/CaptureHttpResponseProvider.php index c5e1b972..bdb2a242 100644 --- a/src/OrderPay/Provider/CaptureHttpResponseProvider.php +++ b/src/OrderPay/Provider/CaptureHttpResponseProvider.php @@ -39,6 +39,10 @@ 'payplug_sylius_payplug_plugin.http_response_provider.payplug_wero', ['action' => PaymentRequestInterface::ACTION_CAPTURE], )] +#[AutoconfigureTag( + 'payplug_sylius_payplug_plugin.http_response_provider.payplug_uhf', + ['action' => PaymentRequestInterface::ACTION_CAPTURE], +)] class CaptureHttpResponseProvider implements HttpResponseProviderInterface { public function supports(RequestConfiguration $requestConfiguration, PaymentRequestInterface $paymentRequest): bool diff --git a/src/OrderPay/Provider/UhfNotifyPaymentProvider.php b/src/OrderPay/Provider/UhfNotifyPaymentProvider.php new file mode 100644 index 00000000..54a83d93 --- /dev/null +++ b/src/OrderPay/Provider/UhfNotifyPaymentProvider.php @@ -0,0 +1,62 @@ +getGatewayConfig()?->getFactoryName() && + $request->getPayload()->has('id') && + $request->getPayload()->has('execCode') && + $request->getPayload()->has('orderId') + ; + } + + public function getPayment(Request $request, PaymentMethodInterface $paymentMethod): PaymentInterface + { + $orderId = $request->getPayload()->getString('orderId'); + $order = $this->orderRepository->findOneBy(['id' => $orderId]); + + if (!$order instanceof OrderInterface) { + throw new \InvalidArgumentException(sprintf('Order with id "%s" not found', $orderId)); + } + + $operationId = $request->getPayload()->getString('id'); + $payment = $order->getPayments()->filter( + static fn (PaymentInterface $payment): bool => $operationId === ($payment->getDetails()['payment_id'] ?? null), + )->first(); + + if (false === $payment) { + throw new \InvalidArgumentException(sprintf('Payment with operation id "%s" not found in order "%s"', $operationId, $orderId)); + } + + return $payment; + } +} diff --git a/src/PaymentProcessing/SyliusOrderStateMutator.php b/src/PaymentProcessing/SyliusOrderStateMutator.php new file mode 100644 index 00000000..ca7b40ba --- /dev/null +++ b/src/PaymentProcessing/SyliusOrderStateMutator.php @@ -0,0 +1,74 @@ +orderRepository->find((int) $orderId); + + if (!$order instanceof OrderInterface) { + throw new \LogicException(sprintf('No Sylius order found for id "%s".', $orderId)); + } + + $payment = $order->getLastPayment(); + + if (null === $payment) { + throw new \LogicException(sprintf('Order "%s" has no payment to apply outcome "%s" to.', $orderId, $outcome)); + } + + $transition = $this->mapOutcomeToTransition($outcome); + + if (null === $transition) { + $this->logger->debug('[PayPlug] Outcome does not map to a Sylius payment transition yet.', [ + 'order_id' => $orderId, + 'outcome' => $outcome, + ]); + + return; + } + + if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [ + 'order_id' => $orderId, + 'payment_id' => $payment->getId(), + 'current_state' => $payment->getState(), + 'transition' => $transition, + 'outcome' => $outcome, + ]); + + return; + } + + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + } + + private function mapOutcomeToTransition(string $outcome): ?string + { + return match ($outcome) { + PaymentOutcome::PAID => PaymentTransitions::TRANSITION_COMPLETE, + PaymentOutcome::AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, + PaymentOutcome::REFUNDED => PaymentTransitions::TRANSITION_REFUND, + PaymentOutcome::FAILED => PaymentTransitions::TRANSITION_FAIL, + default => null, + }; + } +} diff --git a/src/Provider/Payment/HfTokenProvider.php b/src/Provider/Payment/HfTokenProvider.php new file mode 100644 index 00000000..e40481d3 --- /dev/null +++ b/src/Provider/Payment/HfTokenProvider.php @@ -0,0 +1,37 @@ +getDetails(); + $hfToken = $details['hf_token'] ?? null; + + if (\is_string($hfToken) && '' !== $hfToken) { + return $hfToken; + } + + $testToken = $this->requestStack->getSession()->get(self::TEST_SESSION_KEY); + + return \is_string($testToken) && '' !== $testToken ? $testToken : null; + } +} diff --git a/src/Repository/PayplugOperationRepository.php b/src/Repository/PayplugOperationRepository.php new file mode 100644 index 00000000..6656aef4 --- /dev/null +++ b/src/Repository/PayplugOperationRepository.php @@ -0,0 +1,33 @@ +createQueryBuilder('operation') + ->where('operation.operationId = :operationId') + ->setParameter('operationId', $operationId) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function findOneByOrderId(string $orderId): ?PayplugOperation + { + return $this->createQueryBuilder('operation') + ->where('operation.orderId = :orderId') + ->setParameter('orderId', $orderId) + ->orderBy('operation.id', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} diff --git a/src/Repository/PayplugOperationRepositoryInterface.php b/src/Repository/PayplugOperationRepositoryInterface.php new file mode 100644 index 00000000..0565d2b1 --- /dev/null +++ b/src/Repository/PayplugOperationRepositoryInterface.php @@ -0,0 +1,15 @@ +operationRepository->findOneByOrderId($orderId); + if (null === $operation) { + throw new PaymentNotFoundException(sprintf('No operation for order "%s".', $orderId)); + } + + return $this->toOperationData($operation); + } + + public function getByOperationId(string $operationId): OperationData + { + $operation = $this->operationRepository->findOneByOperationId($operationId); + if (null === $operation) { + throw new PaymentNotFoundException(sprintf('No operation for operation id "%s".', $operationId)); + } + + return $this->toOperationData($operation); + } + + public function save(OperationData $operationData): void + { + $operation = $this->operationRepository->findOneByOperationId($operationData->operationId); + + if (null === $operation) { + $operation = new PayplugOperation( + $operationData->operationId, + $operationData->orderId, + $operationData->execCode, + $operationData->outcome, + $operationData->amount, + ); + $this->operationRepository->add($operation); + + return; + } + + $operation->setExecCode($operationData->execCode); + $operation->setOutcome($operationData->outcome); + $operation->setAmount($operationData->amount); + $this->operationRepository->add($operation); + } + + public function markTreated(string $operationId): void + { + $operation = $this->operationRepository->findOneByOperationId($operationId); + if (null === $operation) { + throw new PaymentNotFoundException(sprintf('No operation for operation id "%s".', $operationId)); + } + $operation->markTreated(); + $this->operationRepository->add($operation); + } + + public function isTreated(string $operationId): bool + { + $operation = $this->operationRepository->findOneByOperationId($operationId); + if (null === $operation) { + throw new PaymentNotFoundException(sprintf('No operation for operation id "%s".', $operationId)); + } + + return $operation->isTreated(); + } + + private function toOperationData(PayplugOperation $operation): OperationData + { + return new OperationData( + $operation->getOperationId(), + $operation->getExecCode(), + $operation->getOutcome(), + $operation->getAmount(), + $operation->getOrderId(), + ); + } +} diff --git a/tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php b/tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php new file mode 100644 index 00000000..cec350c8 --- /dev/null +++ b/tests/PHPUnit/ApiClient/UnifiedApiHostedPaymentServiceFactoryTest.php @@ -0,0 +1,114 @@ +httpClient = $this->createMock(IUnifiedApiHttpClient::class); + $oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $tokenCache = $this->createMock(ITokenCache::class); + $oauth2Client = new OAuth2Client($oauthHttpClient, 'https://api-qa.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($tokenCache, $oauth2Client); + + $this->factory = new UnifiedApiHostedPaymentServiceFactory( + $this->httpClient, + $tokenManager, + 'https://api-qa.payplug.com', + ); + } + + public function testCreateForPaymentMethod_withLiveCredentials_buildsAConfiguredService(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret', 'account_id' => 'acc_live'], + 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret', 'account_id' => 'acc_test'], + ]); + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $service = $this->factory->createForPaymentMethod($method); + + self::assertInstanceOf(UnifiedApiHostedPaymentService::class, $service); + } + + public function testCreateForPaymentMethod_whenNotLive_usesTestCredentials(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => false, + 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret', 'account_id' => 'acc_test'], + ]); + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $service = $this->factory->createForPaymentMethod($method); + + self::assertInstanceOf(UnifiedApiHostedPaymentService::class, $service); + } + + public function testCreateForPaymentMethod_whenClientConfigMissing_throwsGatewayConfigurationException(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn(['live' => true]); + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(GatewayConfigurationException::class); + + $this->factory->createForPaymentMethod($method); + } + + public function testCreateForPaymentMethod_whenAccountIdMissing_throwsGatewayConfigurationException(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret'], + ]); + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(GatewayConfigurationException::class); + + $this->factory->createForPaymentMethod($method); + } + + public function testCreateForPaymentMethod_whenNoGatewayConfig_throwsLogicException(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->factory->createForPaymentMethod($method); + } +} diff --git a/tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php b/tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php new file mode 100644 index 00000000..e3984de9 --- /dev/null +++ b/tests/PHPUnit/Auth/SyliusConfigurationRepositoryTest.php @@ -0,0 +1,102 @@ +gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + $this->gatewayConfig = $this->createMock(GatewayConfigInterface::class); + + $this->gatewayConfigRepository->method('findOneBy') + ->with(['factoryName' => UhfGatewayFactory::FACTORY_NAME]) + ->willReturn($this->gatewayConfig) + ; + + $this->repository = new SyliusConfigurationRepository($this->gatewayConfigRepository); + } + + public function testGetClientId_whenLive_readsFromLiveClientConfig(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret'], + 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret'], + ]); + + self::assertSame('live_id', $this->repository->getClientId()); + self::assertSame('live_secret', $this->repository->getClientSecret()); + } + + public function testGetClientId_whenNotLive_readsFromTestClientConfig(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => false, + 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret'], + 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret'], + ]); + + self::assertSame('test_id', $this->repository->getClientId()); + } + + public function testGetClientId_whenNoClientConfig_returnsEmptyString(): void + { + $this->gatewayConfig->method('getConfig')->willReturn(['live' => true]); + self::assertSame('', $this->repository->getClientId()); + } + + public function testGetPublicKeyIdAndValue_readFromTheSameClientConfig(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => ['public_key_id' => 'pk_123', 'public_key_value' => 'pk_value'], + ]); + + self::assertSame('pk_123', $this->repository->getPublicKeyId()); + self::assertSame('pk_value', $this->repository->getPublicKeyValue()); + } + + public function testGet_readsATopLevelConfigKey(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'payplug_webhook_authorization_header' => 'Bearer shared-secret', + ]); + + self::assertSame('Bearer shared-secret', $this->repository->get('payplug_webhook_authorization_header')); + } + + public function testGet_whenKeyIsMissing_returnsNull(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([]); + self::assertNull($this->repository->get('payplug_webhook_authorization_header')); + } + + public function testSet_writesTheKeyBackToTheGatewayConfigAndPersists(): void + { + $this->gatewayConfig->method('getConfig')->willReturn(['live' => true]); + + $this->gatewayConfig->expects(self::once()) + ->method('setConfig') + ->with(['live' => true, 'payplug_webhook_authorization_header' => 'Bearer shared-secret']) + ; + $this->gatewayConfigRepository->expects(self::once())->method('add')->with($this->gatewayConfig); + + $this->repository->set('payplug_webhook_authorization_header', 'Bearer shared-secret'); + } +} diff --git a/tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php b/tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php new file mode 100644 index 00000000..8f7f4258 --- /dev/null +++ b/tests/PHPUnit/Auth/SyliusUnifiedApiHttpClientTest.php @@ -0,0 +1,103 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->adapter = new SyliusUnifiedApiHttpClient($this->httpClient); + } + + public function testGet_sendsRequestWithHeadersAndReturnsStatusAndBody(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}'); + + $this->httpClient->expects(self::once()) + ->method('request') + ->with( + 'GET', + 'https://api.payplug.com/payments/pay_123', + ['headers' => ['Authorization' => 'Bearer jwt']], + ) + ->willReturn($response) + ; + + $result = $this->adapter->get( + 'https://api.payplug.com/payments/pay_123', + ['Authorization' => 'Bearer jwt'], + ); + + self::assertSame(['status' => 200, 'body' => '{"id":"pay_123"}'], $result); + } + + public function testPostJson_sendsJsonBodyAndHeaders(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(201); + $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}'); + + $this->httpClient->expects(self::once()) + ->method('request') + ->with( + 'POST', + 'https://api.payplug.com/payments', + [ + 'json' => ['amount' => 1000, 'currency' => 'EUR'], + 'headers' => ['Authorization' => 'Bearer jwt', 'Content-Type' => 'application/json'], + ], + ) + ->willReturn($response) + ; + + $result = $this->adapter->postJson( + 'https://api.payplug.com/payments', + ['amount' => 1000, 'currency' => 'EUR'], + ['Authorization' => 'Bearer jwt', 'Content-Type' => 'application/json'], + ); + + self::assertSame(['status' => 201, 'body' => '{"id":"pay_123"}'], $result); + } + + public function testPostJson_onNon2xxStatus_returnsStatusAndBodyWithoutThrowing(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(401); + $response->method('getContent')->with(false)->willReturn('{"error":"unauthorized"}'); + + $this->httpClient->method('request')->willReturn($response); + + $result = $this->adapter->postJson('https://api.payplug.com/payments', ['amount' => 1000]); + + self::assertSame(['status' => 401, 'body' => '{"error":"unauthorized"}'], $result); + } + + public function testGet_onTransportFailure_returnsZeroStatusInsteadOfThrowing(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willThrowException(new TransportException('Could not resolve host')); + + $this->httpClient->method('request')->willReturn($response); + + $result = $this->adapter->get('https://api.payplug.com/payments/pay_123'); + + self::assertSame(0, $result['status']); + self::assertSame('Could not resolve host', $result['body']); + } +} diff --git a/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php new file mode 100644 index 00000000..0052d31e --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CapturePaymentRequestHandlerTest.php @@ -0,0 +1,354 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->paymentDataCreator = $this->createMock(PayPlugPaymentDataCreator::class); + $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $tokenCache = $this->createMock(ITokenCache::class); + $tokenCache->method('get')->willReturn('cached-token'); + $oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $oauthHttpClient->expects(self::never())->method('post'); + $oauth2Client = new OAuth2Client($oauthHttpClient, 'https://api-qa.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($tokenCache, $oauth2Client); + $hostedPaymentServiceFactory = new UnifiedApiHostedPaymentServiceFactory( + $this->unifiedApiHttpClient, + $tokenManager, + 'https://api-qa.payplug.com', + ); + + $this->requestStack = $this->createMock(RequestStack::class); + $this->session = $this->createMock(SessionInterface::class); + $this->requestStack->method('getSession')->willReturn($this->session); + $hfTokenProvider = new HfTokenProvider($this->requestStack); + + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->urlGenerator->method('generate')->willReturn('https://shop.example/notify'); + + $this->handler = new CapturePaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->apiClientFactory, + $this->paymentDataCreator, + $this->afterPayUrlProvider, + $this->logger, + $hostedPaymentServiceFactory, + $hfTokenProvider, + $this->urlGenerator, + ); + } + + // ------------------------------------------------------------------------- + // Legacy gateway path (pre-existing behavior, unaffected by the new UHF branch) + // ------------------------------------------------------------------------- + + public function testInvoke_forLegacyGatewayOnSuccess_storesDetailsAndCompletesTheRequest(): void + { + $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, []); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.example/after-pay'); + $this->paymentDataCreator->method('create')->willReturn(new \ArrayObject(['amount' => 1000])); + + $client = $this->createMock(PayPlugApiClientInterface::class); + $payplugPayment = Payment::fromAttributes([ + 'id' => 'pay_legacy_1', + 'hosted_payment' => ['payment_url' => 'https://payplug.example/pay'], + ]); + $client->method('createPayment')->willReturn($payplugPayment); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client); + + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details) => 'pay_legacy_1' === $details['payment_id'] && + PayPlugApiClientInterface::STATUS_CREATED === $details['status'] && + 'https://payplug.example/pay' === $details['redirect_url'], + )); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new CapturePaymentRequest('hash-legacy-1')); + } + + public function testInvoke_forLegacyGatewayAlreadyCreated_doesNotCallTheClientAgain(): void + { + $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [ + 'status' => PayPlugApiClientInterface::STATUS_CREATED, + 'factory_name' => PayPlugGatewayFactory::FACTORY_NAME, + 'payment_id' => 'pay_legacy_2', + 'redirect_url' => 'https://payplug.example/pay-2', + ]); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new CapturePaymentRequest('hash-legacy-2')); + } + + // ------------------------------------------------------------------------- + // UHF branch — captureViaUnifiedApi() + // ------------------------------------------------------------------------- + + public function testInvoke_forUhfWithRedirectRequired_storesRedirectUrlAndCompletesTheRequest(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, ['hf_token' => 'hf_token_1']); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 201, + 'body' => \json_encode([ + 'id' => 'pay_uhf_1', + 'redirect' => ['url' => 'https://3ds.example/redirect'], + ]), + ]); + + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details) => 'pay_uhf_1' === $details['payment_id'] && + 'https://3ds.example/redirect' === $details['redirect_url'] && + PayPlugApiClientInterface::STATUS_CREATED === $details['status'] && + UhfGatewayFactory::FACTORY_NAME === $details['factory_name'], + )); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback( + static fn (array $data) => 'pay_uhf_1' === $data['payment_id'] && + 'https://3ds.example/redirect' === $data['redirect_url'], + )); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new CapturePaymentRequest('hash-uhf-1')); + } + + public function testInvoke_forUhfWithNoRedirect_completesWithoutRedirectUrl(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, ['hf_token' => 'hf_token_2']); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 201, + 'body' => \json_encode(['id' => 'pay_uhf_2']), + ]); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback( + static fn (array $data) => 'pay_uhf_2' === $data['payment_id'] && + null === $data['redirect_url'], + )); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new CapturePaymentRequest('hash-uhf-2')); + } + + public function testInvoke_forUhfWithNoHfToken_failsTheRequestWithoutCallingUnifiedApi(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, []); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->session->method('get')->willReturn(null); + + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + ($this->handler)(new CapturePaymentRequest('hash-uhf-3')); + } + + public function testInvoke_whenUnifiedApiThrows_failsTheRequest(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, ['hf_token' => 'hf_token_4']); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 500, + 'body' => '{"error":"internal"}', + ]); + + $payment->expects(self::never())->method('setDetails'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + ($this->handler)(new CapturePaymentRequest('hash-uhf-4')); + } + + public function testInvoke_whenAlreadyCreatedForUhf_doesNotCallUnifiedApiAgain(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [ + 'status' => PayPlugApiClientInterface::STATUS_CREATED, + 'factory_name' => UhfGatewayFactory::FACTORY_NAME, + 'payment_id' => 'pay_uhf_5', + 'redirect_url' => 'https://3ds.example/redirect-5', + ]); + $paymentRequest = $this->buildPaymentRequest($payment); + + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback( + static fn (array $data) => true === $data['retry'] && + 'pay_uhf_5' === $data['payment_id'] && + 'https://3ds.example/redirect-5' === $data['redirect_url'], + )); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new CapturePaymentRequest('hash-uhf-5')); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildMethod(string $factoryName): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => false, + 'test_client' => [ + 'client_id' => 'test_id', + 'client_secret' => 'test_secret', + 'account_id' => 'acc_test', + ], + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + $method->method('getCode')->willReturn('payplug_method_code'); + + return $method; + } + + /** @param array $details */ + private function buildPayment(PaymentMethodInterface $method, array $details): PaymentInterface&MockObject + { + $order = $this->createMock(OrderInterface::class); + $order->method('getId')->willReturn(42); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + $payment->method('getDetails')->willReturn($details); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + $payment->method('getOrder')->willReturn($order); + $payment->method('getId')->willReturn(7); + + return $payment; + } + + private function buildPaymentRequest(PaymentInterface $payment): PaymentRequestInterface&MockObject + { + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } +} diff --git a/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php new file mode 100644 index 00000000..a428ea31 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php @@ -0,0 +1,395 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class); + $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class); + $this->paymentTransitionApplier = $this->createMock(PaymentTransitionApplier::class); + $this->lock = $this->createMock(ILock::class); + $this->paymentRepository = $this->createMock(IPaymentRepository::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new NotifyPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->apiClientFactory, + $this->paymentNotificationHandler, + $this->refundNotificationHandler, + $this->paymentTransitionApplier, + $this->lock, + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->logger, + ); + } + + // ------------------------------------------------------------------------- + // Legacy gateway path (pre-existing behavior, unaffected by the new UHF branch) + // ------------------------------------------------------------------------- + + public function testInvoke_forLegacyGatewayOnSuccess_treatsNotificationAndCompletesTheRequest(): void + { + $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, ['http_request' => ['content' => '{"id":"pay_1"}']]); + + $payplugPayment = Payment::fromAttributes(['id' => 'pay_1']); + $client = $this->createMock(PayPlugApiClientInterface::class); + $client->method('treat')->willReturn($payplugPayment); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client); + + $this->paymentNotificationHandler->expects(self::once())->method('treat'); + $this->refundNotificationHandler->expects(self::once())->method('treat'); + $this->paymentTransitionApplier->expects(self::once())->method('apply')->with($payment); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-legacy-1')); + } + + public function testInvoke_forLegacyGatewayWithInvalidPayload_failsTheRequest(): void + { + $method = $this->buildMethod(PayPlugGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, ['http_request' => ['content' => '']]); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $paymentRequest->expects(self::once())->method('setResponseData'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-legacy-2')); + } + + // ------------------------------------------------------------------------- + // UHF branch — notifyViaUnifiedApi() + // ------------------------------------------------------------------------- + + public function testInvoke_forUhfWithAValidNotification_locksSavesMutatesAndMarksTreated(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload( + 'op_1', + '0000', + 'order_55', + 1000, + 'expected-secret', + )); + + $this->configurationRepository->method('get') + ->with('payplug_webhook_authorization_header') + ->willReturn('expected-secret'); + + $this->lock->expects(self::once())->method('acquire') + ->with('payplug_uhf_operation_op_1', 30) + ->willReturn(true); + + $this->paymentRepository->expects(self::once())->method('isTreated')->with('op_1')->willReturn(false); + + $callOrder = []; + + $this->paymentRepository->expects(self::once())->method('save') + ->with(self::callback(static function (OperationData $data) use (&$callOrder): bool { + $callOrder[] = 'save'; + + return 'op_1' === $data->operationId && + PaymentOutcome::PAID === $data->outcome && + 'order_55' === $data->orderId && + 1000 === $data->amount; + })); + + $this->orderStateMutator->expects(self::once())->method('apply') + ->with('order_55', PaymentOutcome::PAID) + ->willReturnCallback(static function () use (&$callOrder): void { + $callOrder[] = 'apply'; + }); + + $this->paymentRepository->expects(self::once())->method('markTreated') + ->with('op_1') + ->willReturnCallback(static function () use (&$callOrder): void { + $callOrder[] = 'markTreated'; + }); + + $this->lock->expects(self::once())->method('release')->with('payplug_uhf_operation_op_1'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-uhf-1')); + + self::assertSame(['save', 'apply', 'markTreated'], $callOrder); + } + + public function testInvoke_whenAlreadyTreated_doesNotSaveOrMutateAgain(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload( + 'op_2', + '0000', + 'order_56', + 2000, + 'expected-secret', + )); + + $this->configurationRepository->method('get')->willReturn('expected-secret'); + + $this->lock->expects(self::once())->method('acquire') + ->with('payplug_uhf_operation_op_2', 30) + ->willReturn(true); + + $this->paymentRepository->expects(self::once())->method('isTreated')->with('op_2')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + + $this->lock->expects(self::once())->method('release')->with('payplug_uhf_operation_op_2'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-uhf-2')); + } + + public function testInvoke_whenLockAlreadyHeld_skipsWithoutFailingTheRequest(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload( + 'op_3', + '0000', + 'order_57', + 3000, + 'expected-secret', + )); + + $this->configurationRepository->method('get')->willReturn('expected-secret'); + + $this->lock->expects(self::once())->method('acquire') + ->with('payplug_uhf_operation_op_3', 30) + ->willReturn(false); + + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->lock->expects(self::never())->method('release'); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-uhf-3')); + } + + public function testInvoke_withWrongAuthorizationHeader_failsTheRequestWithoutLocking(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, $this->buildUhfPayload( + 'op_4', + '0000', + 'order_58', + 4000, + 'wrong-secret', + )); + + $this->configurationRepository->method('get') + ->with('payplug_webhook_authorization_header') + ->willReturn('expected-secret'); + + $this->lock->expects(self::never())->method('acquire'); + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback( + static fn (array $data) => isset($data['error']), + )); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-uhf-4')); + } + + public function testInvoke_withMalformedBody_failsTheRequest(): void + { + $method = $this->buildMethod(UhfGatewayFactory::FACTORY_NAME); + $payment = $this->buildPayment($method, [], PaymentInterface::STATE_NEW); + $paymentRequest = $this->buildPaymentRequest($payment, [ + 'http_request' => [ + 'content' => '', + 'headers' => ['Authorization' => ['expected-secret']], + ], + ]); + + $this->lock->expects(self::never())->method('acquire'); + $this->paymentRepository->expects(self::never())->method('isTreated'); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(self::callback( + static fn (array $data) => isset($data['error']), + )); + + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + ($this->handler)(new NotifyPaymentRequest('hash-uhf-5')); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildMethod(string $factoryName): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + $method->method('getCode')->willReturn('payplug_method_code'); + + return $method; + } + + /** @param array $details */ + private function buildPayment( + PaymentMethodInterface $method, + array $details, + string $state, + ): PaymentInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + $payment->method('getDetails')->willReturn($details); + $payment->method('getState')->willReturn($state); + $payment->method('getId')->willReturn(7); + + return $payment; + } + + /** @param mixed $payload */ + private function buildPaymentRequest(PaymentInterface $payment, $payload): PaymentRequestInterface&MockObject + { + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getPayload')->willReturn($payload); + + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } + + /** @return array */ + private function buildUhfPayload( + string $operationId, + string $execCode, + string $orderId, + int $amount, + string $authorizationHeaderValue, + ): array { + return [ + 'http_request' => [ + 'content' => \json_encode([ + 'id' => $operationId, + 'execCode' => $execCode, + 'orderId' => $orderId, + 'amount' => $amount, + ]), + 'headers' => [ + 'Authorization' => [$authorizationHeaderValue], + 'Content-Type' => ['application/json'], + ], + ], + ]; + } +} diff --git a/tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php new file mode 100644 index 00000000..e4dc57e9 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/StatusPaymentRequestHandlerTest.php @@ -0,0 +1,73 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + + $this->handler = new StatusPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->apiClientFactory, + $this->createMock(PaymentNotificationHandler::class), + $this->createMock(PaymentTransitionApplier::class), + $this->createMock(LoggerInterface::class), + ); + } + + public function testInvoke_forUhf_completesTheRequestWithoutCallingTheLegacyApiClient(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(UhfGatewayFactory::FACTORY_NAME); + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + $this->stateMachine->expects(self::once()) + ->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE) + ; + + ($this->handler)(new StatusPaymentRequest($paymentRequest->getId())); + } +} diff --git a/tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php b/tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php new file mode 100644 index 00000000..095eaee4 --- /dev/null +++ b/tests/PHPUnit/Controller/SetUhfTestHfTokenActionTest.php @@ -0,0 +1,49 @@ +requestStack = $this->createMock(RequestStack::class); + $this->session = $this->createMock(SessionInterface::class); + $this->requestStack->method('getSession')->willReturn($this->session); + + $this->action = new SetUhfTestHfTokenAction($this->requestStack); + } + + public function testInvoke_withHfTokenQueryParameter_storesItInTheSession(): void + { + $this->session->expects(self::once())->method('set')->with('payplug_uhf_test_hf_token', 'hf_test_123'); + + $response = ($this->action)(new Request(['hfToken' => 'hf_test_123'])); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_withoutHfTokenQueryParameter_throwsBadRequest(): void + { + $this->session->expects(self::never())->method('set'); + + $this->expectException(BadRequestHttpException::class); + + ($this->action)(new Request()); + } +} diff --git a/tests/PHPUnit/Lock/SyliusLockTest.php b/tests/PHPUnit/Lock/SyliusLockTest.php new file mode 100644 index 00000000..f8fa8e60 --- /dev/null +++ b/tests/PHPUnit/Lock/SyliusLockTest.php @@ -0,0 +1,87 @@ +lockFactory = $this->createMock(LockFactory::class); + $this->lock = new SyliusLock($this->lockFactory); + } + + // ------------------------------------------------------------------------- + // acquire() — lock is free vs already held + // ------------------------------------------------------------------------- + + public function testAcquire_whenLockIsFree_returnsTrue(): void + { + $symfonyLock = $this->createMock(LockInterface::class); + $symfonyLock->expects(self::once())->method('acquire')->willReturn(true); + + $this->lockFactory->expects(self::once()) + ->method('createLock') + ->with('operation_123', 30) + ->willReturn($symfonyLock) + ; + + self::assertTrue($this->lock->acquire('operation_123', 30)); + } + + public function testAcquire_whenLockIsAlreadyHeld_returnsFalse(): void + { + $symfonyLock = $this->createMock(LockInterface::class); + $symfonyLock->method('acquire')->willReturn(false); + + $this->lockFactory->method('createLock')->willReturn($symfonyLock); + + self::assertFalse($this->lock->acquire('operation_123', 30)); + } + + // ------------------------------------------------------------------------- + // release() — only releases a lock this instance actually acquired + // ------------------------------------------------------------------------- + + public function testRelease_afterSuccessfulAcquire_releasesTheUnderlyingLock(): void + { + $symfonyLock = $this->createMock(LockInterface::class); + $symfonyLock->method('acquire')->willReturn(true); + $symfonyLock->expects(self::once())->method('release'); + + $this->lockFactory->method('createLock')->willReturn($symfonyLock); + + $this->lock->acquire('operation_123', 30); + $this->lock->release('operation_123'); + } + + public function testRelease_whenKeyWasNeverAcquired_doesNothing(): void + { + $this->lockFactory->expects(self::never())->method('createLock'); + + $this->lock->release('never_acquired'); + } + + public function testRelease_whenAcquireFailed_doesNotTryToReleaseIt(): void + { + $symfonyLock = $this->createMock(LockInterface::class); + $symfonyLock->method('acquire')->willReturn(false); + $symfonyLock->expects(self::never())->method('release'); + + $this->lockFactory->method('createLock')->willReturn($symfonyLock); + + $this->lock->acquire('operation_123', 30); + $this->lock->release('operation_123'); + } +} diff --git a/tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php b/tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php new file mode 100644 index 00000000..ad21c4f7 --- /dev/null +++ b/tests/PHPUnit/OrderPay/Provider/UhfNotifyPaymentProviderTest.php @@ -0,0 +1,102 @@ +orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->provider = new UhfNotifyPaymentProvider($this->orderRepository); + + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(UhfGatewayFactory::FACTORY_NAME); + $this->uhfMethod = $this->createMock(PaymentMethodInterface::class); + $this->uhfMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + } + + private function requestWithPayload(array $payload): Request + { + return Request::create('/payment-methods/uhf_code', 'POST', [], [], [], [], \json_encode($payload)); + } + + public function testSupports_withUnifiedApiWebhookShapeAndUhfMethod_returnsTrue(): void + { + $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + self::assertTrue($this->provider->supports($request, $this->uhfMethod)); + } + + public function testSupports_withLegacyPayplugWebhookShape_returnsFalse(): void + { + $request = $this->requestWithPayload(['id' => 'pay_1', 'object' => 'payment', 'metadata' => ['order_number' => '42']]); + self::assertFalse($this->provider->supports($request, $this->uhfMethod)); + } + + public function testSupports_forANonUhfPaymentMethod_returnsFalse(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn('payplug_scalapay'); + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + self::assertFalse($this->provider->supports($request, $method)); + } + + public function testGetPayment_findsTheOrderAndThePaymentMatchingTheOperationId(): void + { + $matchingPayment = $this->createMock(PaymentInterface::class); + $matchingPayment->method('getDetails')->willReturn(['payment_id' => 'pay_1']); + $otherPayment = $this->createMock(PaymentInterface::class); + $otherPayment->method('getDetails')->willReturn(['payment_id' => 'pay_0']); + + $order = $this->createMock(OrderInterface::class); + $order->method('getPayments')->willReturn(new ArrayCollection([$otherPayment, $matchingPayment])); + + $this->orderRepository->method('findOneBy')->with(['id' => '42'])->willReturn($order); + + $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + self::assertSame($matchingPayment, $this->provider->getPayment($request, $this->uhfMethod)); + } + + public function testGetPayment_whenOrderNotFound_throwsInvalidArgumentException(): void + { + $this->orderRepository->method('findOneBy')->willReturn(null); + $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '999', 'amount' => 1000]); + $this->expectException(\InvalidArgumentException::class); + $this->provider->getPayment($request, $this->uhfMethod); + } + + public function testGetPayment_whenNoPaymentMatchesTheOperationId_throwsInvalidArgumentException(): void + { + $otherPayment = $this->createMock(PaymentInterface::class); + $otherPayment->method('getDetails')->willReturn(['payment_id' => 'pay_0']); + $order = $this->createMock(OrderInterface::class); + $order->method('getPayments')->willReturn(new ArrayCollection([$otherPayment])); + $this->orderRepository->method('findOneBy')->willReturn($order); + + $request = $this->requestWithPayload(['id' => 'pay_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $this->expectException(\InvalidArgumentException::class); + $this->provider->getPayment($request, $this->uhfMethod); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php b/tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php new file mode 100644 index 00000000..308db06b --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/SyliusOrderStateMutatorTest.php @@ -0,0 +1,115 @@ +orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->mutator = new SyliusOrderStateMutator($this->orderRepository, $this->stateMachine, $this->logger); + } + + public function testApply_withPaidOutcome_appliesCompleteTransitionToTheLastPayment(): void + { + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $this->orderRepository->method('find')->with(42)->willReturn($order); + + $this->stateMachine->method('can') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE) + ->willReturn(true) + ; + $this->stateMachine->expects(self::once()) + ->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE) + ; + + $this->mutator->apply('42', PaymentOutcome::PAID); + } + + public function testApply_withFailedOutcome_appliesFailTransition(): void + { + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $this->orderRepository->method('find')->willReturn($order); + + $this->stateMachine->method('can')->willReturn(true); + $this->stateMachine->expects(self::once()) + ->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL) + ; + + $this->mutator->apply('42', PaymentOutcome::FAILED); + } + + public function testApply_withThreeDsPendingOutcome_doesNotTransitionAndLogs(): void + { + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $this->orderRepository->method('find')->willReturn($order); + + $this->stateMachine->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('debug'); + + $this->mutator->apply('42', PaymentOutcome::THREE_DS_PENDING); + } + + public function testApply_whenTransitionNotAllowed_logsAWarningAndDoesNotThrow(): void + { + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $this->orderRepository->method('find')->willReturn($order); + + $this->stateMachine->method('can')->willReturn(false); + $this->stateMachine->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('warning'); + + $this->mutator->apply('42', PaymentOutcome::PAID); + } + + public function testApply_whenOrderNotFound_throwsLogicException(): void + { + $this->orderRepository->method('find')->willReturn(null); + $this->expectException(\LogicException::class); + $this->mutator->apply('999', PaymentOutcome::PAID); + } + + public function testApply_whenOrderHasNoPayment_throwsLogicException(): void + { + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn(null); + $this->orderRepository->method('find')->willReturn($order); + + $this->expectException(\LogicException::class); + $this->mutator->apply('42', PaymentOutcome::PAID); + } +} diff --git a/tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php b/tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php new file mode 100644 index 00000000..4ad03910 --- /dev/null +++ b/tests/PHPUnit/Provider/Payment/HfTokenProviderTest.php @@ -0,0 +1,70 @@ +requestStack = $this->createMock(RequestStack::class); + $this->session = $this->createMock(SessionInterface::class); + $this->requestStack->method('getSession')->willReturn($this->session); + + $this->provider = new HfTokenProvider($this->requestStack); + } + + public function testGetHfToken_whenStoredOnThePayment_returnsItWithoutTouchingTheSession(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['hf_token' => 'hf_real_token']); + + $this->session->expects(self::never())->method('get'); + + self::assertSame('hf_real_token', $this->provider->getHfToken($payment)); + } + + public function testGetHfToken_whenNotOnThePayment_fallsBackToTheTestSessionToken(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([]); + + $this->session->method('get')->with('payplug_uhf_test_hf_token')->willReturn('hf_test_token'); + + self::assertSame('hf_test_token', $this->provider->getHfToken($payment)); + } + + public function testGetHfToken_whenNeitherIsSet_returnsNull(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn([]); + + $this->session->method('get')->willReturn(null); + + self::assertNull($this->provider->getHfToken($payment)); + } + + public function testGetHfToken_whenPaymentTokenIsAnEmptyString_fallsBackToTheTestSessionToken(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['hf_token' => '']); + + $this->session->method('get')->willReturn('hf_test_token'); + + self::assertSame('hf_test_token', $this->provider->getHfToken($payment)); + } +} diff --git a/tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php b/tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php new file mode 100644 index 00000000..6b7214c3 --- /dev/null +++ b/tests/PHPUnit/Repository/SyliusPaymentRepositoryTest.php @@ -0,0 +1,110 @@ +operationRepository = $this->createMock(PayplugOperationRepositoryInterface::class); + $this->repository = new SyliusPaymentRepository($this->operationRepository); + } + + public function testGetByOrderId_whenFound_returnsOperationData(): void + { + $operation = new PayplugOperation('op_1', 'order_42', '0000', PaymentOutcome::PAID, 1000); + $this->operationRepository->method('findOneByOrderId')->with('order_42')->willReturn($operation); + + $result = $this->repository->getByOrderId('order_42'); + + self::assertSame('op_1', $result->operationId); + self::assertSame('order_42', $result->orderId); + self::assertSame(PaymentOutcome::PAID, $result->outcome); + self::assertSame(1000, $result->amount); + } + + public function testGetByOrderId_whenNotFound_throwsPaymentNotFoundException(): void + { + $this->operationRepository->method('findOneByOrderId')->willReturn(null); + $this->expectException(PaymentNotFoundException::class); + $this->repository->getByOrderId('unknown_order'); + } + + public function testGetByOperationId_whenNotFound_throwsPaymentNotFoundException(): void + { + $this->operationRepository->method('findOneByOperationId')->willReturn(null); + $this->expectException(PaymentNotFoundException::class); + $this->repository->getByOperationId('unknown_op'); + } + + public function testSave_whenOperationDoesNotExistYet_addsANewEntity(): void + { + $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn(null); + + $this->operationRepository->expects(self::once()) + ->method('add') + ->with(self::callback( + static fn (PayplugOperation $operation): bool => 'op_1' === $operation->getOperationId() && + 'order_42' === $operation->getOrderId() && + PaymentOutcome::PAID === $operation->getOutcome(), + )) + ; + + $this->repository->save(new OperationData('op_1', '0000', PaymentOutcome::PAID, 1000, 'order_42')); + } + + public function testSave_whenOperationAlreadyExists_updatesItInPlace(): void + { + $existing = new PayplugOperation('op_1', 'order_42', '0001', PaymentOutcome::FAILED, 1000); + $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn($existing); + + $this->operationRepository->expects(self::once())->method('add')->with($existing); + + $this->repository->save(new OperationData('op_1', '0000', PaymentOutcome::PAID, 1000, 'order_42')); + + self::assertSame(PaymentOutcome::PAID, $existing->getOutcome()); + self::assertSame('0000', $existing->getExecCode()); + } + + public function testMarkTreated_setsTheFlagAndPersists(): void + { + $operation = new PayplugOperation('op_1', 'order_42', '0000', PaymentOutcome::PAID, 1000); + $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn($operation); + $this->operationRepository->expects(self::once())->method('add')->with($operation); + + $this->repository->markTreated('op_1'); + + self::assertTrue($operation->isTreated()); + } + + public function testMarkTreated_whenNotFound_throwsPaymentNotFoundException(): void + { + $this->operationRepository->method('findOneByOperationId')->willReturn(null); + $this->expectException(PaymentNotFoundException::class); + $this->repository->markTreated('unknown_op'); + } + + public function testIsTreated_reflectsTheStoredFlag(): void + { + $operation = new PayplugOperation('op_1', 'order_42', '0000', PaymentOutcome::PAID, 1000); + $operation->markTreated(); + $this->operationRepository->method('findOneByOperationId')->with('op_1')->willReturn($operation); + + self::assertTrue($this->repository->isTreated('op_1')); + } +}