diff --git a/assets/shop/dist/payment/index.css b/assets/shop/dist/payment/index.css index 95e0bf4b..a9b94398 100644 --- a/assets/shop/dist/payment/index.css +++ b/assets/shop/dist/payment/index.css @@ -165,6 +165,9 @@ label { .payment-label-with-image.oney-label::after { width: 130px; } +.payment-label-with-image.scalapay-label::after { + width: 80px; +} @media screen and (max-width: 991px) { .oney-payment-choice__container, .payplug-payment-choice__container { diff --git a/src/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 9b71b41e..7d57cfe9 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -47,13 +47,14 @@ public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): P private function getTokenForGatewayConfig(GatewayConfigInterface $gatewayConfig): string { $config = $gatewayConfig->getConfig(); - $rawClientConfig = true !== $config['live'] ? $config['test_client'] : $config['live_client']; + $isLive = true === ($config['live'] ?? false); + $rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null); if (!\is_array($rawClientConfig)) { throw new GatewayConfigurationException('No client config found for ' . $gatewayConfig->getFactoryName() . '. Please renew your credentials in the PayPlug plugin configuration.'); } /** @var array $clientConfig */ $clientConfig = $rawClientConfig; - $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $config['live'] === true ? 'live' : 'test'); + $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $isLive ? 'live' : 'test'); return $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientConfig) { $response = Authentication::generateJWT($clientConfig['client_id'] ?? '', $clientConfig['client_secret'] ?? ''); diff --git a/src/Command/Handler/NotifyPaymentRequestHandler.php b/src/Command/Handler/NotifyPaymentRequestHandler.php index 441b431a..9ea5147f 100644 --- a/src/Command/Handler/NotifyPaymentRequestHandler.php +++ b/src/Command/Handler/NotifyPaymentRequestHandler.php @@ -6,15 +6,14 @@ use Payplug\Resource\Payment; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Command\NotifyPaymentRequest; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier; 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; #[AsMessageHandler] @@ -26,6 +25,7 @@ public function __construct( private PayPlugApiClientFactoryInterface $apiClientFactory, private PaymentNotificationHandler $paymentNotificationHandler, private RefundNotificationHandler $refundNotificationHandler, + private PaymentTransitionApplier $paymentTransitionApplier, ) {} public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void @@ -66,7 +66,7 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void $payment->setDetails($details->getArrayCopy()); if ($resource instanceof Payment) { - $this->updatePaymentState($payment); + $this->paymentTransitionApplier->apply($payment); } $this->stateMachine->apply( @@ -85,19 +85,4 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void ); } } - - private function updatePaymentState(PaymentInterface $payment): void - { - match ($payment->getDetails()['status'] ?? '') { - PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL), - PayPlugApiClientInterface::STATUS_AUTHORIZED => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_AUTHORIZE), - PayPlugApiClientInterface::STATUS_CAPTURED => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE), - PayPlugApiClientInterface::FAILED => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL), - default => throw new \LogicException(sprintf('Unknown payment status "%s".', $payment->getDetails()['status'] ?? '')), // @phpstan-ignore-line - getDetails() return mixed - }; - } } diff --git a/src/Command/Handler/StatusPaymentRequestHandler.php b/src/Command/Handler/StatusPaymentRequestHandler.php index fcd0f67e..9cee5d15 100644 --- a/src/Command/Handler/StatusPaymentRequestHandler.php +++ b/src/Command/Handler/StatusPaymentRequestHandler.php @@ -8,13 +8,13 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Command\StatusPaymentRequest; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier; use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Payment\Model\PaymentInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\PaymentRequestTransitions; -use Sylius\Component\Payment\PaymentTransitions; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] @@ -25,6 +25,7 @@ public function __construct( private StateMachineInterface $stateMachine, private PayPlugApiClientFactoryInterface $apiClientFactory, private PaymentNotificationHandler $paymentNotificationHandler, + private PaymentTransitionApplier $paymentTransitionApplier, private LoggerInterface $logger, ) {} @@ -50,7 +51,7 @@ public function __invoke(StatusPaymentRequest $statusPaymentRequest): void if (null === $payplugPaymentId) { $this->logger->warning('No PayPlug payment ID found in payment details.', ['payment_id' => $payment->getId(), 'order_id' => $payment->getOrder()?->getId()]); $payment->setDetails(['status' => PayPlugApiClientInterface::FAILED]); - $this->updatePaymentState($payment); + $this->paymentTransitionApplier->apply($payment); return; } @@ -64,7 +65,7 @@ public function __invoke(StatusPaymentRequest $statusPaymentRequest): void $payment->setDetails($details->getArrayCopy()); if ($payment->getState() !== PaymentInterface::STATE_COMPLETED) { // If is already completed, do not try to update it again (updated by notification) - $this->updatePaymentState($payment); + $this->paymentTransitionApplier->apply($payment); } // Mark the PaymentRequest as completed @@ -80,13 +81,16 @@ private function handleForcedStatus( PaymentRequestInterface $paymentRequest, ): void { $payment = $paymentRequest->getPayment(); + $previousDetails = $payment->getDetails(); $payment->setDetails([ - ...$payment->getDetails(), + ...$previousDetails, 'status' => $statusPaymentRequest->getForcedStatus(), ]); - $this->updatePaymentState($payment); + if (!$this->paymentTransitionApplier->apply($payment)) { + $payment->setDetails($previousDetails); + } // Mark the PaymentRequest as completed $this->stateMachine->apply( @@ -95,19 +99,4 @@ private function handleForcedStatus( PaymentRequestTransitions::TRANSITION_COMPLETE, ); } - - private function updatePaymentState(PaymentInterface $payment): void - { - match ($payment->getDetails()['status'] ?? '') { - PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL), - PayPlugApiClientInterface::STATUS_AUTHORIZED => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_AUTHORIZE), - PayPlugApiClientInterface::STATUS_CAPTURED => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE), - PayPlugApiClientInterface::FAILED => $this->stateMachine - ->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL), - default => throw new \LogicException(sprintf('Unknown payment status "%s".', $payment->getDetails()['status'] ?? '')), // @phpstan-ignore-line - getDetails() return mixed - }; - } } diff --git a/src/PaymentProcessing/PaymentTransitionApplier.php b/src/PaymentProcessing/PaymentTransitionApplier.php new file mode 100644 index 00000000..a5af2e8b --- /dev/null +++ b/src/PaymentProcessing/PaymentTransitionApplier.php @@ -0,0 +1,69 @@ +getDetails(); // @phpstan-ignore-line - getDetails() return mixed + $status = $details['status'] ?? ''; + + // These are known PayPlug statuses that do not map to a Sylius payment transition. + if (\in_array($status, [ + PayPlugApiClientInterface::STATUS_CREATED, + PayPlugApiClientInterface::REFUNDED, + PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK, + ], true)) { + return false; + } + + $transition = match ($status) { + PayPlugApiClientInterface::STATUS_ABORTED, PayPlugApiClientInterface::STATUS_CANCELED, PayPlugApiClientInterface::STATUS_CANCELED_BY_ONEY => PaymentTransitions::TRANSITION_CANCEL, + PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, + PayPlugApiClientInterface::STATUS_CAPTURED => PaymentTransitions::TRANSITION_COMPLETE, + PayPlugApiClientInterface::FAILED => PaymentTransitions::TRANSITION_FAIL, + default => null, + }; + + if (null === $transition) { + $this->logger->warning('[PayPlug] Cannot apply payment transition: unknown status.', [ + 'sylius_payment_id' => $payment->getId(), + 'payplug_payment_id' => $details['payment_id'] ?? null, + 'status' => $status, + ]); + + return false; + } + + if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [ + 'sylius_payment_id' => $payment->getId(), + 'payplug_payment_id' => $details['payment_id'] ?? null, + 'current_state' => $payment->getState(), + 'transition' => $transition, + 'status' => $status, + ]); + + return false; + } + + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + + return true; + } +} diff --git a/tests/PHPUnit/PaymentProcessing/PaymentTransitionApplierTest.php b/tests/PHPUnit/PaymentProcessing/PaymentTransitionApplierTest.php new file mode 100644 index 00000000..3f4238c7 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/PaymentTransitionApplierTest.php @@ -0,0 +1,139 @@ +stateMachine = $this->createMock(StateMachineInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->applier = new PaymentTransitionApplier($this->stateMachine, $this->logger); + } + + // ------------------------------------------------------------------------- + // apply() — known status, transition possible → transition applied + // ------------------------------------------------------------------------- + + /** + * The PayPlug status maps to a known transition and the state machine allows it. + * Verifies apply() applies the transition and returns true. + */ + public function testApply_withKnownStatusAndAllowedTransition_appliesTransitionAndReturnsTrue(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['status' => PayPlugApiClientInterface::STATUS_CAPTURED]); + + $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->logger->expects(self::never())->method('warning'); + + self::assertTrue($this->applier->apply($payment)); + } + + // ------------------------------------------------------------------------- + // apply() — unknown status → no transition applied + // ------------------------------------------------------------------------- + + /** + * The PayPlug status does not map to any known transition. + * Verifies apply() logs a warning, never calls the state machine, and returns false. + */ + public function testApply_withUnknownStatus_logsWarningAndReturnsFalse(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['status' => 'some_unhandled_status']); + + $this->stateMachine->expects(self::never())->method('can'); + $this->stateMachine->expects(self::never())->method('apply'); + $this->logger->expects(self::once()) + ->method('warning') + ->with('[PayPlug] Cannot apply payment transition: unknown status.', self::isType('array')) + ; + + self::assertFalse($this->applier->apply($payment)); + } + + // ------------------------------------------------------------------------- + // apply() — known status, transition not allowed → no transition applied + // ------------------------------------------------------------------------- + + /** + * The PayPlug status maps to a known transition but the state machine refuses it + * (e.g. already applied, or incompatible with the payment's current state). + * Verifies apply() logs a warning, never applies the transition, and returns false. + */ + public function testApply_withKnownStatusButDisallowedTransition_logsWarningAndReturnsFalse(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['status' => PayPlugApiClientInterface::FAILED]); + $payment->method('getState')->willReturn('completed'); + + $this->stateMachine->method('can') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_FAIL) + ->willReturn(false) + ; + $this->stateMachine->expects(self::never())->method('apply'); + $this->logger->expects(self::once()) + ->method('warning') + ->with('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', self::isType('array')) + ; + + self::assertFalse($this->applier->apply($payment)); + } + + // ------------------------------------------------------------------------- + // apply() — known-but-inapplicable status → silent no-op + // ------------------------------------------------------------------------- + + /** + * These PayPlug statuses are known but do not map to a Sylius payment transition + * (e.g. the payment session was just created, was refunded outside Sylius, or is + * a failed one-click intermediate state already handled elsewhere). Verifies + * apply() returns false without logging a warning or touching the state machine. + * + * @dataProvider noOpStatusesDataProvider + */ + public function testApply_withKnownNoOpStatus_doesNothingAndReturnsFalse(string $status): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['status' => $status]); + + $this->stateMachine->expects(self::never())->method('can'); + $this->stateMachine->expects(self::never())->method('apply'); + $this->logger->expects(self::never())->method('warning'); + + self::assertFalse($this->applier->apply($payment)); + } + + public function noOpStatusesDataProvider(): \Generator + { + yield 'created' => [PayPlugApiClientInterface::STATUS_CREATED]; + yield 'refunded' => [PayPlugApiClientInterface::REFUNDED]; + yield 'one_click' => [PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK]; + } +}