diff --git a/composer.json b/composer.json index 582d6e32..e82ba4bd 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": "^1.0.0", + "payplug/unified-plugin-core": "dev-feature/PRE-3614_handling_unified_notifier", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/config/services.yaml b/config/services.yaml index 49b455ca..01063474 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,13 @@ parameters: payplug.oauth_audience: '%env(default:payplug.oauth_audience.default:PAYPLUG_OAUTH_AUDIENCE)%' payplug.unified_api_base_url.default: 'https://api.payplug.com' payplug.unified_api_base_url: '%env(default:payplug.unified_api_base_url.default:PAYPLUG_UNIFIED_API_BASE_URL)%' + # Separate from payplug.unified_api_base_url on purpose: on at least one QA/staging setup, the + # payment-status GET lives on a different host than the payment-creation POST (an "internal" + # Dalenys/GCP host, distinct from the public-facing payment-gateway proxy the create call goes + # through). Defaults to the same value as payplug.unified_api_base_url, so environments that + # never need the split (production included) are unaffected. + payplug.unified_api_payment_status_base_url.default: '%payplug.unified_api_base_url%' + payplug.unified_api_payment_status_base_url: '%env(default:payplug.unified_api_payment_status_base_url.default:PAYPLUG_UNIFIED_API_PAYMENT_STATUS_BASE_URL)%' services: _defaults: @@ -22,6 +29,7 @@ services: $payplugOauthBaseUrl: '%payplug.oauth_base_url%' $payplugOauthAudience: '%payplug.oauth_audience%' $unifiedApiBaseUrl: '%payplug.unified_api_base_url%' + $unifiedApiPaymentStatusBaseUrl: '%payplug.unified_api_payment_status_base_url%' PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository @@ -64,6 +72,9 @@ services: PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface: alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiHostedPaymentCreator + PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher + payplug_sylius_payplug_plugin.action.capture: class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction diff --git a/src/Command/Handler/StatusHostedPaymentRequestHandler.php b/src/Command/Handler/StatusHostedPaymentRequestHandler.php index abcc8ea4..ecc2a018 100644 --- a/src/Command/Handler/StatusHostedPaymentRequestHandler.php +++ b/src/Command/Handler/StatusHostedPaymentRequestHandler.php @@ -5,8 +5,16 @@ namespace PayPlug\SyliusPayPlugPlugin\Command\Handler; use PayPlug\SyliusPayPlugPlugin\Command\StatusHostedPaymentRequest; +use PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface; +use PayplugUnifiedCore\Contracts\IOrderStateMutator; +use PayplugUnifiedCore\Contracts\IPaymentRepository; +use PayplugUnifiedCore\Exceptions\ApiException; +use PayplugUnifiedCore\Exceptions\OperationNotFoundException; +use PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper; +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\PaymentRequestTransitions; use Sylius\Component\Payment\PaymentTransitions; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -16,26 +24,140 @@ final class StatusHostedPaymentRequestHandler { private const FORCED_STATUS_CANCELED = 'canceled'; + // The Unified API's transient "challenge required" code — never a final outcome. Left + // unmapped by ExecCodeMapper on purpose (it only ever sees final codes from the webhook), so + // it must be special-cased here before reaching it: 0001 is FAILED under that mapper's "only + // 0000 is PAID" rule, which would wrongly fail a payment still awaiting the customer. + private const EXEC_CODE_PENDING = '0001'; + + // States that already carry a final outcome — reached by the webhook, by the synchronous + // branch in CaptureHostedPaymentRequestHandler, or by a previous poll. Anything else + // (observed in practice: the payment stays "new" — Sylius's state machine is never driven + // to "processing" for this flow, despite what SyliusOrderStateMutator's docblock assumes) is + // still awaiting an outcome and is worth polling for. + private const RESOLVED_STATES = [ + PaymentInterface::STATE_COMPLETED, + PaymentInterface::STATE_FAILED, + PaymentInterface::STATE_CANCELLED, + PaymentInterface::STATE_REFUNDED, + PaymentInterface::STATE_AUTHORIZED, + ]; + public function __construct( private PaymentRequestProviderInterface $paymentRequestProvider, private StateMachineInterface $stateMachine, + private OperationStatusFetcherInterface $operationStatusFetcher, + private IPaymentRepository $paymentRepository, + private IOrderStateMutator $orderStateMutator, + private LoggerInterface $logger, ) { } public function __invoke(StatusHostedPaymentRequest $statusHostedPaymentRequest): void { $paymentRequest = $this->paymentRequestProvider->provide($statusHostedPaymentRequest); + /** @var PaymentInterface $payment */ + $payment = $paymentRequest->getPayment(); if (self::FORCED_STATUS_CANCELED === $statusHostedPaymentRequest->getForcedStatus()) { - $payment = $paymentRequest->getPayment(); if ($this->stateMachine->can($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL)) { $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL); } + } else { + $this->pollForOutcomeIfStillPending($payment); } - // No polling against any API: the Payment's current state is whatever the Notify handler - // (Task 10) has already applied from the webhook, or still "processing" if none has - // arrived yet — this handler never queries UPC/PayPlug for a status. $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); } + + /** + * Fallback for when the asynchronous webhook (NotifyHostedPaymentRequestHandler) hasn't + * confirmed the outcome by the time the customer is bounced back from the 3DS challenge page + * — a GET against the Unified API, per PayPlug's own documented recommendation for a delayed + * or lost webhook. Skipped entirely once anything else has already resolved the payment (the + * webhook, or the synchronous branch in CaptureHostedPaymentRequestHandler), so there's + * nothing here to double-apply — and IOrderStateMutator is idempotent besides. + * + * Polls the *operation* (GET /processing-operations/operations/{id}), not the payment: the + * payment representation carries no execCode at all, while the operation's does + * (transaction.status.execCode), in the same vocabulary ExecCodeMapper already maps from the + * webhook and payment-creation flows. isTreated()/markTreated() are keyed by the payment id + * (not the operation id polled here) to match what the webhook path actually dedupes on: + * WebhookNotificationHelper::parse() populates OperationData's operationId from the webhook + * payload's top-level payment id, not from a per-operation id — so whichever of the two + * (webhook or this poll) arrives first is correctly recognized by the other as already done. + */ + private function pollForOutcomeIfStillPending(PaymentInterface $payment): void + { + if (\in_array($payment->getState(), self::RESOLVED_STATES, true)) { + return; + } + + $ids = self::resolvePollingIds($payment->getDetails()); + if (null === $ids) { + return; + } + [$operationId, $paymentId] = $ids; + + try { + $response = $this->operationStatusFetcher->getOperation($operationId); + } catch (OperationNotFoundException | ApiException $e) { + $this->logger->error('[PayPlug][UPC] Hosted payment status poll failed.', [ + 'sylius_payment_id' => $payment->getId(), + 'hosted_fields_operation_id' => $operationId, + 'error' => $e->getMessage(), + ]); + + return; + } + + $execCode = self::extractExecCode($response['body']); + if (!\is_string($execCode) || '' === $execCode || self::EXEC_CODE_PENDING === $execCode) { + // No final code yet (the challenge genuinely hasn't been completed) — leave the + // payment as-is; the webhook or a later poll will resolve it. + return; + } + + if ($this->paymentRepository->isTreated($paymentId)) { + return; + } + + $this->orderStateMutator->apply(self::idToString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode)); + $this->paymentRepository->markTreated($paymentId); + } + + /** + * @param mixed[] $details + * + * @return array{0: string, 1: string}|null + */ + private static function resolvePollingIds(array $details): ?array + { + $operationId = $details['hosted_fields_operation_id'] ?? null; + $paymentId = $details['hosted_fields_payment_id'] ?? null; + if (!\is_string($operationId) || '' === $operationId || !\is_string($paymentId) || '' === $paymentId) { + return null; + } + + return [$operationId, $paymentId]; + } + + private static function extractExecCode(string $operationBody): ?string + { + $body = \json_decode($operationBody, true); + $transaction = \is_array($body) ? ($body['transaction'] ?? null) : null; + $status = \is_array($transaction) ? ($transaction['status'] ?? null) : null; + $execCode = \is_array($status) ? ($status['execCode'] ?? null) : null; + + return \is_string($execCode) ? $execCode : null; + } + + private static function idToString(mixed $id): string + { + if (!\is_int($id) && !\is_string($id)) { + throw new \LogicException('Unexpected non-scalar resource identifier.'); + } + + return (string) $id; + } } diff --git a/src/Controller/IpnAction.php b/src/Controller/IpnAction.php index ee7ca7c5..9c14f7bc 100644 --- a/src/Controller/IpnAction.php +++ b/src/Controller/IpnAction.php @@ -13,9 +13,11 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Handler\HostedFieldsWebhookNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface; +use PayplugUnifiedCore\Exceptions\InvalidNotificationException; use Payum\Core\Bridge\Spl\ArrayObject; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -37,6 +39,7 @@ public function __construct( private LoggerInterface $logger, private PaymentNotificationHandler $paymentNotificationHandler, private RefundNotificationHandler $refundNotificationHandler, + private HostedFieldsWebhookNotificationHandler $hostedFieldsWebhookNotificationHandler, private PayPlugApiClientFactoryInterface $apiClientFactory, private PaymentRepositoryInterface $paymentRepository, private EntityManagerInterface $entityManager, @@ -86,6 +89,21 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse(null, Response::HTTP_UNAUTHORIZED); } + // Hosted Fields notifications are Unified API webhooks, not legacy SDK ones: a different + // signature scheme and payload shape that $this->payPlugApiClient->treat() below cannot + // parse. Branching here — rather than letting it fall through and blow up inside treat() + // — is what lets this same static, already-deployed IPN URL serve as the account-level + // webhook receiver for Hosted Fields too, alongside every other gateway's legacy flow. + if (PayPlugGatewayFactory::isHostedFieldsConfig($gateway)) { + try { + $this->hostedFieldsWebhookNotificationHandler->treat($payment, $input, self::flattenHeaders($request->headers->all())); + } catch (InvalidNotificationException $exception) { + $this->logger->error('[PayPlug][UPC] Rejected webhook notification.', ['error' => $exception->getMessage()]); + } + + return new JsonResponse(); + } + $this->payPlugApiClient = $this->apiClientFactory->create($factoryName); try { @@ -101,4 +119,19 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse(); } + + /** + * @param array> $rawHeaders + * + * @return array + */ + private static function flattenHeaders(array $rawHeaders): array + { + $headers = []; + foreach ($rawHeaders as $name => $values) { + $headers[$name] = $values[0] ?? ''; + } + + return $headers; + } } diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php new file mode 100644 index 00000000..53a5bf30 --- /dev/null +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -0,0 +1,71 @@ + $headers + * + * @throws InvalidNotificationException if the notification fails signature verification or + * parsing — the caller is expected to catch this, same + * as it already does for the legacy SDK's PayplugException. + */ + public function treat(PaymentInterface $payment, string $rawBody, array $headers): void + { + $expectedHeader = $this->configurationRepository->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; + $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader); + + if ($this->paymentRepository->isTreated($operationData->operationId)) { + return; + } + + $this->paymentRepository->save($operationData); + $this->orderStateMutator->apply(self::idToString($payment->getId()), $operationData->outcome); + $this->paymentRepository->markTreated($operationData->operationId); + } + + private static function idToString(mixed $id): string + { + if (!\is_int($id) && !\is_string($id)) { + throw new \LogicException('Unexpected non-scalar resource identifier.'); + } + + return (string) $id; + } +} diff --git a/src/OrderPay/Provider/CaptureHttpResponseProvider.php b/src/OrderPay/Provider/CaptureHttpResponseProvider.php index c5e1b972..4c0de531 100644 --- a/src/OrderPay/Provider/CaptureHttpResponseProvider.php +++ b/src/OrderPay/Provider/CaptureHttpResponseProvider.php @@ -43,8 +43,13 @@ class CaptureHttpResponseProvider implements HttpResponseProviderInterface { public function supports(RequestConfiguration $requestConfiguration, PaymentRequestInterface $paymentRequest): bool { - return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_CAPTURE && - ($paymentRequest->getResponseData()['redirect_url'] ?? null) !== null; + if ($paymentRequest->getAction() !== PaymentRequestInterface::ACTION_CAPTURE) { + return false; + } + + $data = $paymentRequest->getResponseData(); + + return null !== ($data['redirect_url'] ?? null) || null !== ($data['redirect_html'] ?? null); } public function getResponse( @@ -53,6 +58,14 @@ public function getResponse( ): Response { // This is called after the capture payment request has been handled $data = $paymentRequest->getResponseData(); + + // The Unified API's "recommended for web" 3DS shape (Hosted Fields only, see + // CaptureHostedPaymentRequestHandler): a self-submitting HTML form to render as-is, rather + // than a plain redirect target. + if (\is_string($data['redirect_html'] ?? null)) { + return new Response($data['redirect_html']); + } + if (!\is_string($data['redirect_url'] ?? null)) { throw new \LogicException('Redirect URL is not set in the payment request response data.'); } diff --git a/src/Upc/OperationStatusFetcherInterface.php b/src/Upc/OperationStatusFetcherInterface.php new file mode 100644 index 00000000..34519136 --- /dev/null +++ b/src/Upc/OperationStatusFetcherInterface.php @@ -0,0 +1,19 @@ +httpClient, + $this->tokenManager, + $this->unifiedApiPaymentStatusBaseUrl, + $this->configurationRepository->getClientId(), + $this->configurationRepository->getClientSecret(), + ); + + return $service->getOperation($operationId); + } +} diff --git a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php index a4ad07a1..c966947f 100644 --- a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php @@ -9,6 +9,7 @@ use PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface; use PayplugUnifiedCore\Contracts\IOrderStateMutator; use PayplugUnifiedCore\DataValues\PaymentOutcome; +use PayplugUnifiedCore\Dto\HostedFieldDto; use PayplugUnifiedCore\Exceptions\ApiException; use PayplugUnifiedCore\Output\HostedPaymentOutput; use PHPUnit\Framework\MockObject\MockObject; @@ -222,6 +223,26 @@ public function testInvoke_onDirectSuccessWithFailureExecCode_appliesFailedOutco $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } + public function testInvoke_setsSuccessAndCancelUrlsFromTheAfterPayUrlProvider(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->afterPayUrlProvider->method('getUrl') + ->with($paymentRequest, UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('https://shop.example.com/pay/abc'); + + $this->hostedPaymentCreator->expects(self::once())->method('createHostedPayment') + ->with(self::callback(static function (HostedFieldDto $dto): bool { + self::assertSame('https://shop.example.com/pay/abc', $dto->common->successUrl); + self::assertSame('https://shop.example.com/pay/abc?status=canceled', $dto->common->cancelUrl); + + return true; + })) + ->willReturn(new HostedPaymentOutput(201, '{"id":"pay_1"}', null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void { $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); @@ -235,4 +256,48 @@ public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): vo $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); } + + public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + $payment = $paymentRequest->getPayment(); + + $this->hostedPaymentCreator->method('createHostedPayment') + ->willReturn(new HostedPaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds')); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'pay_1' === $details['hosted_fields_payment_id'] && + 'op_1' === $details['hosted_fields_operation_id'])); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenResponseBodyHasNoId_neverStoresAHostedFieldsPaymentOrOperationId(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + $payment = $paymentRequest->getPayment(); + + $this->hostedPaymentCreator->method('createHostedPayment')->willReturn(new HostedPaymentOutput(201, '{}', null)); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => !isset($details['hosted_fields_payment_id']) && + !isset($details['hosted_fields_operation_id']))); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onRedirectHtmlOutcome_setsResponseDataAndNeverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $html = '3DS challenge form'; + $this->hostedPaymentCreator->method('createHostedPayment') + ->willReturn(new HostedPaymentOutput(200, '{"id":"pay_1","execCode":"0001"}', null, $html)); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_html' => $html]); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } } diff --git a/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php index 8161b2bf..05dc3510 100644 --- a/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php +++ b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php @@ -6,8 +6,15 @@ use PayPlug\SyliusPayPlugPlugin\Command\Handler\StatusHostedPaymentRequestHandler; use PayPlug\SyliusPayPlugPlugin\Command\StatusHostedPaymentRequest; +use PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface; +use PayplugUnifiedCore\Contracts\IOrderStateMutator; +use PayplugUnifiedCore\Contracts\IPaymentRepository; +use PayplugUnifiedCore\DataValues\PaymentOutcome; +use PayplugUnifiedCore\Exceptions\ApiException; +use PayplugUnifiedCore\Exceptions\OperationNotFoundException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Core\Model\PaymentInterface; @@ -21,18 +28,48 @@ final class StatusHostedPaymentRequestHandlerTest extends TestCase private StateMachineInterface&MockObject $stateMachine; + private OperationStatusFetcherInterface&MockObject $operationStatusFetcher; + + private IPaymentRepository&MockObject $paymentRepository; + + private IOrderStateMutator&MockObject $orderStateMutator; + + private LoggerInterface&MockObject $logger; + private StatusHostedPaymentRequestHandler $handler; protected function setUp(): void { $this->paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); $this->stateMachine = $this->createMock(StateMachineInterface::class); - $this->handler = new StatusHostedPaymentRequestHandler($this->paymentRequestProvider, $this->stateMachine); + $this->operationStatusFetcher = $this->createMock(OperationStatusFetcherInterface::class); + $this->paymentRepository = $this->createMock(IPaymentRepository::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new StatusHostedPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->operationStatusFetcher, + $this->paymentRepository, + $this->orderStateMutator, + $this->logger, + ); } - private function paymentRequest(): PaymentRequestInterface&MockObject + private function paymentRequest( + // Sylius's state machine is never driven to STATE_PROCESSING for this flow in practice — + // the payment stays STATE_NEW throughout the 3DS-pending window, confirmed against a real + // QA payment row. STATE_NEW is therefore the realistic default here, not STATE_PROCESSING. + string $state = PaymentInterface::STATE_NEW, + array $details = ['hosted_fields_operation_id' => 'op_1', 'hosted_fields_payment_id' => 'pay_1'], + ): PaymentRequestInterface&MockObject { $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getState')->willReturn($state); + $payment->method('getDetails')->willReturn($details); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); $paymentRequest->method('getPayment')->willReturn($payment); $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); @@ -40,9 +77,14 @@ private function paymentRequest(): PaymentRequestInterface&MockObject return $paymentRequest; } - public function testInvoke_withNoForcedStatus_onlyCompletesThePaymentRequest(): void + private static function operationBody(string $execCode): string { - $paymentRequest = $this->paymentRequest(); + return \json_encode(['id' => 'op_1', 'transaction' => ['status' => ['execCode' => $execCode]]]); + } + + public function testInvoke_withNoForcedStatus_completesThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequest(state: PaymentInterface::STATE_COMPLETED); $this->stateMachine->expects(self::once())->method('apply') ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); @@ -52,7 +94,7 @@ public function testInvoke_withNoForcedStatus_onlyCompletesThePaymentRequest(): public function testInvoke_withForcedCanceledStatus_cancelsThePaymentWhenAllowed(): void { - $paymentRequest = $this->paymentRequest(); + $paymentRequest = $this->paymentRequest(state: PaymentInterface::STATE_COMPLETED); $payment = $paymentRequest->getPayment(); $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL)->willReturn(true); @@ -63,7 +105,7 @@ public function testInvoke_withForcedCanceledStatus_cancelsThePaymentWhenAllowed public function testInvoke_withForcedCanceledStatus_whenTransitionNotAllowed_stillCompletesThePaymentRequest(): void { - $paymentRequest = $this->paymentRequest(); + $paymentRequest = $this->paymentRequest(state: PaymentInterface::STATE_COMPLETED); $this->stateMachine->method('can')->willReturn(false); $this->stateMachine->expects(self::once())->method('apply') @@ -71,4 +113,131 @@ public function testInvoke_withForcedCanceledStatus_whenTransitionNotAllowed_sti $this->handler->__invoke(new StatusHostedPaymentRequest(null, 'canceled')); } + + public function testInvoke_withForcedCanceledStatus_neverPolls(): void + { + $this->paymentRequest(state: PaymentInterface::STATE_PROCESSING); + + $this->stateMachine->method('can')->willReturn(false); + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null, 'canceled')); + } + + public function testInvoke_whenPaymentIsAlreadyResolved_neverPolls(): void + { + $this->paymentRequest(state: PaymentInterface::STATE_COMPLETED); + + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenPaymentIsStillProcessing_stillPolls(): void + { + $this->paymentRequest(state: PaymentInterface::STATE_PROCESSING); + + $this->operationStatusFetcher->expects(self::once())->method('getOperation')->with('op_1') + ->willReturn(['status' => 200, 'body' => self::operationBody('0001')]); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenNoHostedFieldsOperationIdStored_neverPolls(): void + { + $this->paymentRequest(details: ['hosted_fields_payment_id' => 'pay_1']); + + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenNoHostedFieldsPaymentIdStored_neverPolls(): void + { + $this->paymentRequest(details: ['hosted_fields_operation_id' => 'op_1']); + + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenPollStillPending_neverAppliesAnOutcome(): void + { + $this->paymentRequest(); + + $this->operationStatusFetcher->method('getOperation')->with('op_1') + ->willReturn(['status' => 200, 'body' => self::operationBody('0001')]); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenPollResolvesToSuccess_appliesPaidOutcomeAndMarksTreated(): void + { + $this->paymentRequest(); + + $this->operationStatusFetcher->method('getOperation')->with('op_1') + ->willReturn(['status' => 200, 'body' => self::operationBody('0000')]); + $this->paymentRepository->method('isTreated')->with('pay_1')->willReturn(false); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('pay_1'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenPollResolvesToFailure_appliesFailedOutcome(): void + { + $this->paymentRequest(); + + $this->operationStatusFetcher->method('getOperation')->with('op_1') + ->willReturn(['status' => 200, 'body' => self::operationBody('9999')]); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::FAILED); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenAlreadyTreatedByTheWebhook_neverAppliesAnOutcomeAgain(): void + { + $this->paymentRequest(); + + $this->operationStatusFetcher->method('getOperation') + ->willReturn(['status' => 200, 'body' => self::operationBody('0000')]); + $this->paymentRepository->method('isTreated')->with('pay_1')->willReturn(true); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenPollThrowsApiException_neverAppliesAnOutcomeAndStillCompletes(): void + { + $paymentRequest = $this->paymentRequest(); + + $this->operationStatusFetcher->method('getOperation')->willThrowException(new ApiException('boom')); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_whenPollThrowsOperationNotFoundException_neverAppliesAnOutcomeAndStillCompletes(): void + { + $paymentRequest = $this->paymentRequest(); + + $this->operationStatusFetcher->method('getOperation')->willThrowException(new OperationNotFoundException('gone')); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } } diff --git a/tests/PHPUnit/Controller/IpnActionTest.php b/tests/PHPUnit/Controller/IpnActionTest.php new file mode 100644 index 00000000..e3781efc --- /dev/null +++ b/tests/PHPUnit/Controller/IpnActionTest.php @@ -0,0 +1,138 @@ +logger = $this->createMock(LoggerInterface::class); + $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class); + $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class); + $this->hostedFieldsWebhookNotificationHandler = $this->createMock(HostedFieldsWebhookNotificationHandler::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->action = new IpnAction( + $this->logger, + $this->paymentNotificationHandler, + $this->refundNotificationHandler, + $this->hostedFieldsWebhookNotificationHandler, + $this->apiClientFactory, + $this->paymentRepository, + $this->entityManager, + ); + } + + private function paymentWithGatewayConfig(bool $hostedFields): PaymentInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => $hostedFields]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + + return $payment; + } + + public function testInvoke_forAHostedFieldsPayment_delegatesToTheWebhookNotificationHandlerAndNeverTouchesTheLegacySdk(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: true); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->with('pay_1')->willReturn($payment); + + $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1', 'execCode' => '0000'])); + $request->headers->set('Authorization', 'Bearer shared-secret'); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::once())->method('treat') + ->with($payment, $request->getContent(), self::callback(static fn (array $headers): bool => 'Bearer shared-secret' === ($headers['authorization'] ?? null))); + $this->apiClientFactory->expects(self::never())->method('create'); + $this->entityManager->expects(self::never())->method('flush'); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_forAHostedFieldsPayment_whenNotificationIsInvalid_logsAndStillReturns200(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: true); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment); + $this->hostedFieldsWebhookNotificationHandler->method('treat')->willThrowException(new InvalidNotificationException('boom')); + + $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->logger->expects(self::once())->method('error'); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_forALegacyPayment_stillGoesThroughTheSdkAndNeverCallsTheWebhookNotificationHandler(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: false); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment); + + $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::never())->method('treat'); + $this->apiClientFactory->expects(self::once())->method('create')->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn($this->createMock(PayPlugApiClientInterface::class)); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_whenPaymentIsNotFound_returnsUnauthorized(): void + { + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn(null); + + $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } +} diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php new file mode 100644 index 00000000..5d7a1c31 --- /dev/null +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -0,0 +1,89 @@ +paymentRepository = $this->createMock(IPaymentRepository::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + ); + } + + private function payment(int $id = 42): PaymentInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn($id); + + return $payment; + } + + public function testTreat_onValidNotification_savesTreatsAndAppliesTheOutcomeAgainstTheResolvedPayment(): void + { + // orderId here ("000000059") is deliberately unrelated to the Sylius payment id (42) — + // unlike NotifyHostedPaymentRequestHandler, this handler never cross-checks it, since the + // caller (IpnAction) already resolved $payment by the webhook's own payment id. + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '000000059', 'amount' => 1000]); + + $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->treat($this->payment(42), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_whenAlreadyTreated_isIdempotentAndDoesNotReapply(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '000000059', 'amount' => 1000]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->treat($this->payment(), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onInvalidSignature_throwsInvalidNotificationExceptionWithoutApplyingTheOutcome(): void + { + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->expectException(InvalidNotificationException::class); + + $this->handler->treat($this->payment(), '{}', ['Authorization' => 'Bearer wrong-secret']); + } +} diff --git a/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php b/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php new file mode 100644 index 00000000..d274d115 --- /dev/null +++ b/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php @@ -0,0 +1,108 @@ +provider = new CaptureHttpResponseProvider(); + $this->requestConfiguration = $this->createMock(RequestConfiguration::class); + } + + private function paymentRequest(string $action, array $responseData): PaymentRequestInterface&MockObject + { + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getAction')->willReturn($action); + $paymentRequest->method('getResponseData')->willReturn($responseData); + + return $paymentRequest; + } + + public function testSupports_whenRedirectUrlIsSetOnCapture_returnsTrue(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_url' => 'https://example.com/3ds']); + + self::assertTrue($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testSupports_whenRedirectHtmlIsSetOnCapture_returnsTrue(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_html' => '']); + + self::assertTrue($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testSupports_whenActionIsNotCapture_returnsFalse(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_NOTIFY, ['redirect_url' => 'https://example.com/3ds']); + + self::assertFalse($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testSupports_whenNeitherRedirectFieldIsSet_returnsFalse(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['status' => 'processing']); + + self::assertFalse($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testGetResponse_whenRedirectUrlIsSet_returnsARedirectResponse(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_url' => 'https://example.com/3ds']); + + $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('https://example.com/3ds', $response->getTargetUrl()); + } + + public function testGetResponse_whenRedirectHtmlIsSet_returnsThatHtmlAsTheResponseContent(): void + { + $html = '3DS challenge form'; + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_html' => $html]); + + $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + + self::assertSame($html, $response->getContent()); + } + + /** + * Not a real-world case (the handler only ever sets one or the other), but proves the + * precedence explicitly rather than leaving it implicit: redirect_html wins if both are set. + */ + public function testGetResponse_whenBothRedirectFieldsAreSet_prefersRedirectHtml(): void + { + $html = '3DS challenge form'; + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, [ + 'redirect_url' => 'https://example.com/3ds', + 'redirect_html' => $html, + ]); + + $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + + self::assertSame($html, $response->getContent()); + } + + public function testGetResponse_whenNeitherRedirectFieldIsSet_throws(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, []); + + $this->expectException(\LogicException::class); + + $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php new file mode 100644 index 00000000..43e2b549 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php @@ -0,0 +1,87 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository->method('getClientId')->willReturn('client_abc'); + $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); + + $this->fetcher = new UnifiedApiOperationStatusFetcher( + $this->unifiedApiHttpClient, + $tokenManager, + $this->configurationRepository, + 'https://api.payplug.com', + ); + } + + public function testGetOperation_withValidCredentials_returnsTheRawResponse(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $body = '{"id":"op_1","transaction":{"status":{"execCode":"0000"}}}'; + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 200, 'body' => $body]); + + $response = $this->fetcher->getOperation('op_1'); + + self::assertSame(['status' => 200, 'body' => $body], $response); + } + + public function testGetOperation_onMissingOperation_throwsOperationNotFoundException(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 404, 'body' => '{}']); + + $this->expectException(OperationNotFoundException::class); + + $this->fetcher->getOperation('op_1'); + } + + public function testGetOperation_onNon2xxResponse_throwsApiException(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 500, 'body' => '{}']); + + $this->expectException(ApiException::class); + + $this->fetcher->getOperation('op_1'); + } +}