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); + } +}