diff --git a/README.md b/README.md index 0e10778..2c074f4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ payum: payum.action.status: '@Answear\Payum\PayU\Action\StatusAction' payum.action.convert_payment: '@Answear\Payum\PayU\Action\ConvertPaymentAction' payum.action.sync_payment: '@Answear\Payum\PayU\Action\SyncPaymentAction' + payum.action.cancel: '@Answear\Payum\PayU\Action\CancelAction' ``` Need to provide all `payum.action` as a service. diff --git a/src/Action/CancelAction.php b/src/Action/CancelAction.php new file mode 100644 index 0000000..8a43a10 --- /dev/null +++ b/src/Action/CancelAction.php @@ -0,0 +1,64 @@ +getModel()); + $payment = PaymentHelper::ensurePayment($request->getFirstModel()); + $orderId = PaymentHelper::getOrderId($model, $payment); + Assert::notEmpty($orderId, 'OrderId must be set on cancel action.'); + + if (!$this->canCancelPayment($model, $payment)) { + throw new CannotCancelPaymentException('Order status is final, cannot cancel payment.'); + } + + $this->orderRequestService->cancel($model->orderId(), PaymentHelper::getConfigKey($model, $payment)); + } + + public function supports($request): bool + { + return + $request instanceof Cancel + && $request->getModel() instanceof \ArrayAccess + && $request->getFirstModel() instanceof PaymentInterface; + } + + /** + * @throws PayUException + */ + private function canCancelPayment(Model $model, PaymentInterface $payment): bool + { + $response = $this->orderRequestService->retrieve($model->orderId(), PaymentHelper::getConfigKey($model, $payment)); + + return !$response->orders[0]->status->isFinal(); + } +} diff --git a/src/Enum/OrderStatus.php b/src/Enum/OrderStatus.php index 027880e..3630f05 100644 --- a/src/Enum/OrderStatus.php +++ b/src/Enum/OrderStatus.php @@ -11,4 +11,17 @@ enum OrderStatus: string case WaitingForConfirmation = 'WAITING_FOR_CONFIRMATION'; case Completed = 'COMPLETED'; case Canceled = 'CANCELED'; + + public static function finalStatuses(): array + { + return [ + self::Completed, + self::Canceled, + ]; + } + + public function isFinal(): bool + { + return in_array($this, self::finalStatuses(), true); + } } diff --git a/src/Exception/CannotCancelPaymentException.php b/src/Exception/CannotCancelPaymentException.php new file mode 100644 index 0000000..0f89af7 --- /dev/null +++ b/src/Exception/CannotCancelPaymentException.php @@ -0,0 +1,9 @@ +client->payuRequest( + 'DELETE', + self::ENDPOINT . $orderId, + $this->client->getAuthorizeHeaders( + AuthType::Basic, + $configKey + ) + ); + } catch (\Throwable $throwable) { + throw ExceptionHelper::getPayUException($throwable); + } + + try { + $response = json_decode($result->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); + + return OrderCanceledResponse::fromResponse($response); + } catch (\Throwable $throwable) { + throw new MalformedResponseException($response ?? [], $throwable); + } + } } diff --git a/src/ValueObject/Response/OrderCanceledResponse.php b/src/ValueObject/Response/OrderCanceledResponse.php new file mode 100644 index 0000000..690c8d7 --- /dev/null +++ b/src/ValueObject/Response/OrderCanceledResponse.php @@ -0,0 +1,33 @@ + $this->status->toArray(), + 'orderId' => $this->orderId, + 'extOrderId' => $this->extOrderId, + ]; + } +} diff --git a/tests/Integration/Action/data/retrieveOrderResponse.json b/tests/Integration/Action/data/retrieveOrderResponse.json new file mode 100644 index 0000000..19e813e --- /dev/null +++ b/tests/Integration/Action/data/retrieveOrderResponse.json @@ -0,0 +1,45 @@ +{ + "orders": [ + { + "orderId": "WZHF5FFDRJ140731GUEST000P01", + "extOrderId": "358766", + "orderCreateDate": "2014-10-27T14:58:17.443+01:00", + "notifyUrl": "http://localhost/OrderNotify/", + "customerIp": "127.0.0.1", + "merchantPosId": "145227", + "description": "New order", + "currencyCode": "PLN", + "totalAmount": "3200", + "buyer":{ + "email":"john.doe@example.org", + "phone":"111111111", + "firstName":"John", + "lastName":"Doe", + "language":"pl" + }, + "status": "NEW", + "products": [ + { + "name": "Product1", + "unitPrice": "1000", + "quantity": "1" + }, + { + "name": "Product2", + "unitPrice": "2200", + "quantity": "1" + } + ] + } + ], + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Request processing successful" + }, + "properties": [ + { + "name": "PAYMENT_ID", + "value": "1234567890" + } + ] +} diff --git a/tests/Integration/Request/CancelOrderTest.php b/tests/Integration/Request/CancelOrderTest.php new file mode 100644 index 0000000..7da8722 --- /dev/null +++ b/tests/Integration/Request/CancelOrderTest.php @@ -0,0 +1,96 @@ +mockGuzzleResponse( + new Response(200, [], FileTestUtil::getFileContents(__DIR__ . '/data/orderCanceledResponse.json')) + ); + + $orderId = 'WZHF5FFDRJ140731GUEST000P01'; + $response = $this->getOrderRequestService()->cancel($orderId, null); + + self::assertEquals( + new ResponseStatus( + ResponseStatusCode::Success, + 'Request processing successful' + ), + $response->status + ); + self::assertSame($orderId, $response->orderId); + self::assertSame('extOrderId123', $response->extOrderId); + } + + /** + * @test + */ + public function createUnauthorizedTest(): void + { + $this->mockGuzzleResponse( + new Response(401, [], FileTestUtil::getFileContents(__DIR__ . '/data/orderUnauthorizedResponse.json')) + ); + + $this->expectException(PayUAuthorizationException::class); + $this->expectExceptionCode(401); + $this->getOrderRequestService()->cancel('WZHF5FFDRJ140731GUEST000P01', null); + } + + /** + * @test + */ + public function notFoundTest(): void + { + $this->mockGuzzleResponse( + new Response(404, [], FileTestUtil::getFileContents(__DIR__ . '/data/orderNotFoundForCancelResponse.json')) + ); + + $this->expectException(PayUNetworkException::class); + $this->expectExceptionCode(404); + $this->expectExceptionMessageMatches('/DATA_NOT_FOUND/'); + + $this->getOrderRequestService()->cancel('WZHF5FFDRJ140731GUEST000P01', null); + } + + /** + * @test + */ + public function internalServerErrorTest(): void + { + $this->mockGuzzleResponse( + new Response(500, [], FileTestUtil::getFileContents(__DIR__ . '/data/internalServerErrorResponse.json')) + ); + + $this->expectException(PayUServerErrorException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessageMatches('/OPENPAYU_ERROR_INTERNAL/'); + + $this->getOrderRequestService()->cancel('WZHF5FFDRJ140731GUEST000P01', null); + } + + private function getOrderRequestService(): OrderRequestService + { + return new OrderRequestService( + $this->getConfigProvider(), + $this->getClient(), + new NullLogger() + ); + } +} diff --git a/tests/Integration/Request/data/internalServerErrorResponse.json b/tests/Integration/Request/data/internalServerErrorResponse.json new file mode 100644 index 0000000..27809d4 --- /dev/null +++ b/tests/Integration/Request/data/internalServerErrorResponse.json @@ -0,0 +1,8 @@ +{ + "status": { + "statusCode": "OPENPAYU_ERROR_INTERNAL", + "code": "9111", + "codeLiteral": "UNKNOWN_ERROR", + "statusDesc": "Unknown error." + } +} diff --git a/tests/Integration/Request/data/orderCanceledResponse.json b/tests/Integration/Request/data/orderCanceledResponse.json new file mode 100644 index 0000000..810320a --- /dev/null +++ b/tests/Integration/Request/data/orderCanceledResponse.json @@ -0,0 +1,8 @@ +{ + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Request processing successful" + }, + "orderId": "WZHF5FFDRJ140731GUEST000P01", + "extOrderId": "extOrderId123" +} diff --git a/tests/Integration/Request/data/orderNotFoundForCancelResponse.json b/tests/Integration/Request/data/orderNotFoundForCancelResponse.json new file mode 100644 index 0000000..cd2c75b --- /dev/null +++ b/tests/Integration/Request/data/orderNotFoundForCancelResponse.json @@ -0,0 +1,7 @@ +{ + "status": { + "statusCode": "DATA_NOT_FOUND", + "severity": "INFO", + "statusDesc": "Could not find data for given criteria." + } +} diff --git a/tests/Unit/Action/CancelActionTest.php b/tests/Unit/Action/CancelActionTest.php new file mode 100644 index 0000000..5148c26 --- /dev/null +++ b/tests/Unit/Action/CancelActionTest.php @@ -0,0 +1,92 @@ +getCancelAction( + OrderCanceledResponse::fromResponse( + FileTestUtil::decodeJsonFromFile( + __DIR__ . '/../../Integration/Request/data/orderCanceledResponse.json' + ) + ), + OrderRetrieveResponse::fromResponse( + FileTestUtil::decodeJsonFromFile( + __DIR__ . '/../../Integration/Request/data/retrieveOrderResponse.json' + ) + ) + ); + + $payment = new Payment(); + $payment->setDetails(FileTestUtil::decodeJsonFromFile(__DIR__ . '/../../Integration/Action/data/detailsWithOrderId.json')); + + $request = new Cancel($payment); + $request->setModel($payment->getDetails()); + + $action->execute($request); + } + + /** + * @test + */ + public function orderHasFinalStatusTest(): void + { + $this->expectException(CannotCancelPaymentException::class); + $this->expectExceptionMessage('Order status is final, cannot cancel payment.'); + + $action = $this->getCancelAction( + null, + OrderRetrieveResponse::fromResponse( + FileTestUtil::decodeJsonFromFile( + __DIR__ . '/../../Integration/Request/data/retrieveOrderWithPayMethodResponse.json' + ) + ) + ); + + $payment = new Payment(); + $payment->setDetails(FileTestUtil::decodeJsonFromFile(__DIR__ . '/../../Integration/Action/data/detailsWithOrderId.json')); + + $request = new Cancel($payment); + $request->setModel($payment->getDetails()); + + $action->execute($request); + } + + private function getCancelAction( + ?OrderCanceledResponse $orderCanceledResponse, + OrderRetrieveResponse $retrieveOrderResponse + ): CancelAction { + $orderRequestService = $this->createMock(OrderRequestService::class); + $orderRequestService->expects(self::once()) + ->method('retrieve') + ->willReturn($retrieveOrderResponse); + + if (null === $orderCanceledResponse) { + $orderRequestService->expects(self::never()) + ->method('cancel'); + } else { + $orderRequestService->expects(self::once()) + ->method('cancel') + ->willReturn($orderCanceledResponse); + } + + return new CancelAction($orderRequestService); + } +} diff --git a/tests/Unit/Action/data/orderCanceledResponse.json b/tests/Unit/Action/data/orderCanceledResponse.json new file mode 100644 index 0000000..810320a --- /dev/null +++ b/tests/Unit/Action/data/orderCanceledResponse.json @@ -0,0 +1,8 @@ +{ + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Request processing successful" + }, + "orderId": "WZHF5FFDRJ140731GUEST000P01", + "extOrderId": "extOrderId123" +}