Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/shop/dist/payment/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/ApiClient/PayPlugApiClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $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'] ?? '');
Expand Down
21 changes: 3 additions & 18 deletions src/Command/Handler/NotifyPaymentRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
};
}
}
29 changes: 9 additions & 20 deletions src/Command/Handler/StatusPaymentRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -25,6 +25,7 @@ public function __construct(
private StateMachineInterface $stateMachine,
private PayPlugApiClientFactoryInterface $apiClientFactory,
private PaymentNotificationHandler $paymentNotificationHandler,
private PaymentTransitionApplier $paymentTransitionApplier,
private LoggerInterface $logger,
) {}

Expand All @@ -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;
}
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
};
}
}
69 changes: 69 additions & 0 deletions src/PaymentProcessing/PaymentTransitionApplier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\PaymentProcessing;

use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Payment\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;

class PaymentTransitionApplier
{
public function __construct(
private StateMachineInterface $stateMachine,
private LoggerInterface $logger,
) {
}

public function apply(PaymentInterface $payment): bool
{
$details = $payment->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;
}
}
139 changes: 139 additions & 0 deletions tests/PHPUnit/PaymentProcessing/PaymentTransitionApplierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\PaymentProcessing;

use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Payment\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;

final class PaymentTransitionApplierTest extends TestCase
{
private StateMachineInterface&MockObject $stateMachine;

private LoggerInterface&MockObject $logger;

private PaymentTransitionApplier $applier;

protected function setUp(): void
{
$this->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];
}
}