diff --git a/UPGRADE.md b/UPGRADE.md
index 96868b4f..3b1ef5a2 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -36,6 +36,36 @@
)
```
+ `Sylius\PayPalPlugin\Controller\ProcessPayPalOrderAction`:
+ ```diff
+ public function __construct(
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly CustomerRepositoryInterface $customerRepository,
+ private readonly FactoryInterface $customerFactory,
+ private readonly AddressFactoryInterface $addressFactory,
+ private readonly ObjectManager $orderManager,
+ private readonly StateMachineFactoryInterface|StateMachineInterface $stateMachineFactory,
+ private readonly PaymentStateManagerInterface $paymentStateManager,
+ private readonly CacheAuthorizeClientApiInterface $authorizeClientApi,
+ private readonly OrderDetailsApiInterface $orderDetailsApi,
+ private readonly OrderProviderInterface $orderProvider,
+ + private readonly ?PaymentAmountVerifierInterface $paymentAmountVerifier = null,
+ )
+ ```
+
+ `Sylius\PayPalPlugin\Controller\CompletePayPalOrderFromPaymentPageAction`:
+ ```diff
+ public function __construct(
+ private readonly PaymentStateManagerInterface $paymentStateManager,
+ private readonly UrlGeneratorInterface $router,
+ private readonly OrderProviderInterface $orderProvider,
+ private readonly FactoryInterface|StateMachineInterface $stateMachine,
+ private readonly ObjectManager $orderManager,
+ + private readonly ?PaymentAmountVerifierInterface $paymentAmountVerifier = null,
+ + private readonly ?OrderProcessorInterface $orderProcessor = null,
+ )
+ ```
+
### UPGRADE FROM 1.5.1 to 1.6.0
1. Support for Sylius 1.13 has been added, it is now the recommended Sylius version to use.
diff --git a/spec/Payum/Action/CaptureActionSpec.php b/spec/Payum/Action/CaptureActionSpec.php
index 7abe4040..831bd20b 100644
--- a/spec/Payum/Action/CaptureActionSpec.php
+++ b/spec/Payum/Action/CaptureActionSpec.php
@@ -66,6 +66,7 @@ function it_authorizes_seller_send_create_order_request_and_sets_order_response_
'status' => StatusAction::STATUS_CAPTURED,
'paypal_order_id' => '123123',
'reference_id' => 'UUID',
+ 'payment_amount' => 1000,
])->shouldBeCalled();
$this->execute($request);
diff --git a/src/Controller/CompletePayPalOrderFromPaymentPageAction.php b/src/Controller/CompletePayPalOrderFromPaymentPageAction.php
index 693dad1c..7a02a24c 100644
--- a/src/Controller/CompletePayPalOrderFromPaymentPageAction.php
+++ b/src/Controller/CompletePayPalOrderFromPaymentPageAction.php
@@ -19,8 +19,11 @@
use Sylius\Abstraction\StateMachine\WinzouStateMachineAdapter;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\OrderCheckoutTransitions;
+use Sylius\Component\Order\Processor\OrderProcessorInterface;
+use Sylius\PayPalPlugin\Exception\PaymentAmountMismatchException;
use Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface;
use Sylius\PayPalPlugin\Provider\OrderProviderInterface;
+use Sylius\PayPalPlugin\Verifier\PaymentAmountVerifierInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -34,6 +37,8 @@ public function __construct(
private readonly OrderProviderInterface $orderProvider,
private readonly FactoryInterface|StateMachineInterface $stateMachine,
private readonly ObjectManager $orderManager,
+ private readonly ?PaymentAmountVerifierInterface $paymentAmountVerifier = null,
+ private readonly ?OrderProcessorInterface $orderProcessor = null,
) {
if ($this->stateMachine instanceof FactoryInterface) {
trigger_deprecation(
@@ -46,6 +51,22 @@ public function __construct(
),
);
}
+ if (null === $this->paymentAmountVerifier) {
+ trigger_deprecation(
+ 'sylius/paypal-plugin',
+ '1.6',
+ 'Not passing an instance of "%s" as the fifth argument is deprecated and will be prohibited in 3.0.',
+ PaymentAmountVerifierInterface::class,
+ );
+ }
+ if (null === $this->orderProcessor) {
+ trigger_deprecation(
+ 'sylius/paypal-plugin',
+ '1.6',
+ 'Not passing an instance of "%s" as the sixth argument is deprecated and will be prohibited in 3.0.',
+ OrderProcessorInterface::class,
+ );
+ }
}
public function __invoke(Request $request): Response
@@ -56,6 +77,26 @@ public function __invoke(Request $request): Response
/** @var PaymentInterface $payment */
$payment = $order->getLastPayment(PaymentInterface::STATE_PROCESSING);
+ try {
+ if ($this->paymentAmountVerifier !== null) {
+ $this->paymentAmountVerifier->verify($payment);
+ } else {
+ $this->verify($payment);
+ }
+ } catch (PaymentAmountMismatchException) {
+ $this->paymentStateManager->cancel($payment);
+ $order->removePayment($payment);
+
+ if (null === $this->orderProcessor) {
+ throw new \RuntimeException('Order processor is required to process the order.');
+ }
+ $this->orderProcessor->process($order);
+
+ return new JsonResponse([
+ 'return_url' => $this->router->generate('sylius_shop_checkout_complete', [], UrlGeneratorInterface::ABSOLUTE_URL),
+ ]);
+ }
+
$this->paymentStateManager->complete($payment);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_SELECT_PAYMENT);
@@ -78,4 +119,20 @@ private function getStateMachine(): StateMachineInterface
return $this->stateMachine;
}
+
+ private function verify(PaymentInterface $payment): void
+ {
+ $totalAmount = $this->getTotalPaymentAmountFromPaypal($payment);
+
+ if ($payment->getOrder()->getTotal() !== $totalAmount) {
+ throw new PaymentAmountMismatchException();
+ }
+ }
+
+ private function getTotalPaymentAmountFromPaypal(PaymentInterface $payment): int
+ {
+ $details = $payment->getDetails();
+
+ return $details['payment_amount'] ?? 0;
+ }
}
diff --git a/src/Controller/ProcessPayPalOrderAction.php b/src/Controller/ProcessPayPalOrderAction.php
index 1945fa88..47797486 100644
--- a/src/Controller/ProcessPayPalOrderAction.php
+++ b/src/Controller/ProcessPayPalOrderAction.php
@@ -27,8 +27,10 @@
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\PayPalPlugin\Api\CacheAuthorizeClientApiInterface;
use Sylius\PayPalPlugin\Api\OrderDetailsApiInterface;
+use Sylius\PayPalPlugin\Exception\PaymentAmountMismatchException;
use Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface;
use Sylius\PayPalPlugin\Provider\OrderProviderInterface;
+use Sylius\PayPalPlugin\Verifier\PaymentAmountVerifierInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -46,6 +48,7 @@ public function __construct(
private readonly CacheAuthorizeClientApiInterface $authorizeClientApi,
private readonly OrderDetailsApiInterface $orderDetailsApi,
private readonly OrderProviderInterface $orderProvider,
+ private readonly ?PaymentAmountVerifierInterface $paymentAmountVerifier = null,
) {
if ($this->stateMachineFactory instanceof StateMachineFactoryInterface) {
trigger_deprecation(
@@ -58,6 +61,16 @@ public function __construct(
),
);
}
+ if (null === $this->paymentAmountVerifier) {
+ trigger_deprecation(
+ 'sylius/paypal-plugin',
+ '1.6',
+ message: sprintf(
+ 'Not passing $paymentAmountVerifier to "%s" constructor is deprecated and will be prohibited in 3.0',
+ self::class,
+ ),
+ );
+ }
}
public function __invoke(Request $request): Response
@@ -112,6 +125,18 @@ public function __invoke(Request $request): Response
$this->orderManager->flush();
+ try {
+ if ($this->paymentAmountVerifier !== null) {
+ $this->paymentAmountVerifier->verify($payment, $data);
+ } else {
+ $this->verify($payment, $data);
+ }
+ } catch (PaymentAmountMismatchException) {
+ $this->paymentStateManager->cancel($payment);
+
+ return new JsonResponse(['orderID' => $order->getId()]);
+ }
+
$this->paymentStateManager->create($payment);
$this->paymentStateManager->process($payment);
@@ -152,4 +177,30 @@ private function getStateMachine(): StateMachineInterface
return $this->stateMachineFactory;
}
+
+ private function verify(PaymentInterface $payment, array $paypalOrderDetails): void
+ {
+ $totalAmount = $this->getTotalPaymentAmountFromPaypal($paypalOrderDetails);
+
+ if ($payment->getAmount() !== $totalAmount) {
+ throw new PaymentAmountMismatchException();
+ }
+ }
+
+ private function getTotalPaymentAmountFromPaypal(array $paypalOrderDetails): int
+ {
+ if (!isset($paypalOrderDetails['purchase_units']) || !is_array($paypalOrderDetails['purchase_units'])) {
+ return 0;
+ }
+
+ $totalAmount = 0;
+
+ foreach ($paypalOrderDetails['purchase_units'] as $unit) {
+ $stringAmount = $unit['amount']['value'] ?? '0';
+
+ $totalAmount += (int) ($stringAmount * 100);
+ }
+
+ return $totalAmount;
+ }
}
diff --git a/src/Exception/PaymentAmountMismatchException.php b/src/Exception/PaymentAmountMismatchException.php
new file mode 100644
index 00000000..e36f14cb
--- /dev/null
+++ b/src/Exception/PaymentAmountMismatchException.php
@@ -0,0 +1,22 @@
+ StatusAction::STATUS_CAPTURED,
'paypal_order_id' => $content['id'],
'reference_id' => $referenceId,
+ 'payment_amount' => $payment->getAmount(),
]);
}
}
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
index 8385cf0b..b32c7a7d 100644
--- a/src/Resources/config/services.xml
+++ b/src/Resources/config/services.xml
@@ -270,5 +270,8 @@
+
+
+
diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml
index fe4d94ff..9d0c03ac 100644
--- a/src/Resources/config/services/controller.xml
+++ b/src/Resources/config/services/controller.xml
@@ -114,6 +114,7 @@
+
@@ -131,6 +132,8 @@
+
+
diff --git a/src/Verifier/PaymentAmountVerifier.php b/src/Verifier/PaymentAmountVerifier.php
new file mode 100644
index 00000000..1d0795c6
--- /dev/null
+++ b/src/Verifier/PaymentAmountVerifier.php
@@ -0,0 +1,56 @@
+getTotalPaymentAmountFromPaypal($payment, $paypalOrderDetails);
+
+ if ($payment->getOrder()->getTotal() !== $totalAmount) {
+ throw new PaymentAmountMismatchException();
+ }
+ }
+
+ private function getTotalPaymentAmountFromPaypal(PaymentInterface $payment, array $paypalOrderDetails = []): int
+ {
+ if (empty($paypalOrderDetails)) {
+ return $this->getPaymentAmountFromDetails($payment);
+ }
+
+ if (!isset($paypalOrderDetails['purchase_units']) || !is_array($paypalOrderDetails['purchase_units'])) {
+ return 0;
+ }
+
+ $totalAmount = 0;
+
+ foreach ($paypalOrderDetails['purchase_units'] as $unit) {
+ $stringAmount = $unit['amount']['value'] ?? '0';
+ $totalAmount += (int) ($stringAmount * 100);
+ }
+
+ return $totalAmount;
+ }
+
+ private function getPaymentAmountFromDetails(PaymentInterface $payment): int
+ {
+ $details = $payment->getDetails();
+
+ return $details['payment_amount'] ?? 0;
+ }
+}
diff --git a/src/Verifier/PaymentAmountVerifierInterface.php b/src/Verifier/PaymentAmountVerifierInterface.php
new file mode 100644
index 00000000..31327f24
--- /dev/null
+++ b/src/Verifier/PaymentAmountVerifierInterface.php
@@ -0,0 +1,21 @@
+verifier = new PaymentAmountVerifier();
+ }
+
+ public function testVerifySucceedsWhenAmountsMatch(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+ $order->method('getTotal')->willReturn(1500);
+
+ $paypalOrderDetails = [
+ 'purchase_units' => [
+ ['amount' => ['value' => '10.00']],
+ ['amount' => ['value' => '5.00']],
+ ],
+ ];
+
+ $this->verifier->verify($payment, $paypalOrderDetails);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testVerifyThrowsExceptionWhenAmountsDoNotMatch(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+ $order->method('getTotal')->willReturn(1000);
+
+ $paypalOrderDetails = [
+ 'purchase_units' => [
+ ['amount' => ['value' => '10.00']],
+ ['amount' => ['value' => '5.00']],
+ ],
+ ];
+
+ $this->expectException(PaymentAmountMismatchException::class);
+
+ $this->verifier->verify($payment, $paypalOrderDetails);
+ }
+
+ public function testVerifyWithEmptyPurchaseUnitsShouldCompareToZero(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+ $order->method('getTotal')->willReturn(0);
+
+ $paypalOrderDetails = [
+ 'purchase_units' => [],
+ ];
+
+ $this->verifier->verify($payment, $paypalOrderDetails);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testVerifyWithMissingPurchaseUnitsShouldCompareToPaymentDetailsAmount(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+ $order->method('getTotal')->willReturn(2000);
+ $payment->method('getDetails')->willReturn(['payment_amount' => 2000]);
+
+ $paypalOrderDetails = [];
+
+ $this->verifier->verify($payment, $paypalOrderDetails);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testVerifyWithMissingPaymentAmountInDetailsShouldDefaultToZero(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+ $order->method('getTotal')->willReturn(0);
+ $payment->method('getDetails')->willReturn([]);
+
+ $paypalOrderDetails = [];
+
+ $this->verifier->verify($payment, $paypalOrderDetails);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testVerifyWithMissingAmountInPurchaseUnitShouldDefaultToZero(): void
+ {
+ $payment = $this->createMock(PaymentInterface::class);
+ $order = $this->createMock(OrderInterface::class);
+ $payment->method('getOrder')->willReturn($order);
+ $order->method('getTotal')->willReturn(1000);
+
+ $paypalOrderDetails = [
+ 'purchase_units' => [
+ ['amount' => ['value' => '10.00']],
+ ['amount' => []],
+ ],
+ ];
+
+ $this->verifier->verify($payment, $paypalOrderDetails);
+
+ $this->addToAssertionCount(1);
+ }
+}