From 04f1b0270987ba0abf88c1fe484aa9de0a37137a Mon Sep 17 00:00:00 2001 From: Kiener Digital Commerce <39724314+KienerNL@users.noreply.github.com> Date: Thu, 15 Jul 2021 13:22:11 +0200 Subject: [PATCH] MOL-199 Partial Refunds (#144) --- src/Controller/Api/RefundController.php | 498 +++++++--------- .../CouldNotCancelMollieRefundException.php | 22 + .../CouldNotCreateMollieRefundException.php | 20 + .../CouldNotExtractMollieOrderIdException.php | 15 + .../CouldNotFetchMollieOrderException.php | 15 + .../CouldNotFetchMollieRefundsException.php | 20 + .../CouldNotSetRefundAtMollieException.php | 1 - src/Exception/PaymentNotFoundException.php | 15 + src/Facade/SetMollieOrderRefunded.php | 66 +-- src/Hydrator/RefundHydrator.php | 47 ++ .../api/mollie-payments-refund.service.js | 36 +- .../sw-order-line-items-grid/index.js | 193 ++++-- .../sw-order-line-items-grid.html.twig | 199 +++++-- .../view/sw-order-detail-base/index.js | 87 ++- .../sw-order-detail-base.html.twig | 29 +- .../module/mollie-payments/snippet/de-DE.json | 44 +- .../module/mollie-payments/snippet/en-GB.json | 42 +- .../module/mollie-payments/snippet/nl-NL.json | 48 +- src/Resources/config/services.xml | 3 + src/Resources/config/services/controller.xml | 6 +- src/Resources/config/services/facades.xml | 3 +- src/Resources/config/services/hydrators.xml | 11 + src/Resources/config/services/services.xml | 8 +- src/Service/MollieApi/Order.php | 48 +- src/Service/OrderService.php | 40 +- src/Service/RefundService.php | 184 ++++++ src/Service/TransactionService.php | 4 +- .../Facade/SetMollieOrderRefundedTest.php | 164 ++++-- tests/PHPUnit/Hydrator/RefundHydratorTest.php | 146 +++++ tests/PHPUnit/Service/RefundsServiceTest.php | 548 ++++++++++++++++++ 30 files changed, 1925 insertions(+), 637 deletions(-) create mode 100644 src/Exception/CouldNotCancelMollieRefundException.php create mode 100644 src/Exception/CouldNotCreateMollieRefundException.php create mode 100644 src/Exception/CouldNotExtractMollieOrderIdException.php create mode 100644 src/Exception/CouldNotFetchMollieOrderException.php create mode 100644 src/Exception/CouldNotFetchMollieRefundsException.php create mode 100644 src/Exception/PaymentNotFoundException.php create mode 100644 src/Hydrator/RefundHydrator.php create mode 100644 src/Resources/config/services/hydrators.xml create mode 100644 src/Service/RefundService.php create mode 100644 tests/PHPUnit/Hydrator/RefundHydratorTest.php create mode 100644 tests/PHPUnit/Service/RefundsServiceTest.php diff --git a/src/Controller/Api/RefundController.php b/src/Controller/Api/RefundController.php index ce051e802..b7f6b7218 100644 --- a/src/Controller/Api/RefundController.php +++ b/src/Controller/Api/RefundController.php @@ -2,387 +2,291 @@ namespace Kiener\MolliePayments\Controller\Api; -use Exception; -use Kiener\MolliePayments\Factory\MollieApiFactory; -use Kiener\MolliePayments\Service\CustomFieldService; +use Kiener\MolliePayments\Exception\CouldNotCancelMollieRefundException; +use Kiener\MolliePayments\Exception\CouldNotCreateMollieRefundException; +use Kiener\MolliePayments\Exception\CouldNotExtractMollieOrderIdException; +use Kiener\MolliePayments\Exception\CouldNotFetchMollieOrderException; +use Kiener\MolliePayments\Exception\CouldNotFetchMollieRefundsException; +use Kiener\MolliePayments\Exception\PaymentNotFoundException; use Kiener\MolliePayments\Service\OrderService; +use Kiener\MolliePayments\Service\RefundService; use Kiener\MolliePayments\Service\SettingsService; -use Kiener\MolliePayments\Setting\MollieSettingStruct; -use Mollie\Api\Exceptions\ApiException; -use Mollie\Api\Exceptions\IncompatiblePlatform; -use Mollie\Api\MollieApiClient; -use Shopware\Core\Checkout\Order\Aggregate\OrderLineItem\OrderLineItemEntity; +use Psr\Log\LoggerInterface; use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler; use Shopware\Core\Checkout\Order\OrderEntity; +use Shopware\Core\Checkout\Payment\Exception\InvalidOrderException; use Shopware\Core\Framework\Context; -use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; use Shopware\Core\Framework\Routing\Annotation\RouteScope; +use Shopware\Core\Framework\ShopwareHttpException; +use Shopware\Core\Framework\Uuid\Exception\InvalidUuidException; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; class RefundController extends AbstractController { - public const CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY = 'refundedQuantity'; - public const CUSTOM_FIELDS_KEY_CREATE_CREDIT_ITEM = 'createCredit'; - - private const CUSTOM_FIELDS_KEY_ORDER_ID = 'order_id'; - private const CUSTOM_FIELDS_KEY_ORDER_LINE_ID = 'order_line_id'; - private const CUSTOM_FIELDS_KEY_REFUND_ID = 'refund_id'; - private const CUSTOM_FIELDS_KEY_REFUNDS = 'refunds'; - private const CUSTOM_FIELDS_KEY_QUANTITY = 'quantity'; - - private const REFUND_DATA_KEY_ID = 'id'; - private const REFUND_DATA_KEY_LINES = 'lines'; - private const REFUND_DATA_KEY_QUANTITY = self::CUSTOM_FIELDS_KEY_QUANTITY; - private const REFUND_DATA_KEY_TEST_MODE = 'testmode'; - - private const REQUEST_KEY_ORDER_LINE_ITEM_ID = 'itemId'; - private const REQUEST_KEY_ORDER_LINE_QUANTITY = self::CUSTOM_FIELDS_KEY_QUANTITY; - private const REQUEST_KEY_ORDER_LINE_ITEM_VERSION_ID = 'versionId'; - - private const RESPONSE_KEY_AMOUNT = 'amount'; - private const RESPONSE_KEY_ITEMS = 'items'; - private const RESPONSE_KEY_SUCCESS = 'success'; - - /** @var MollieApiFactory */ - private $apiFactory; - - /** @var EntityRepositoryInterface */ - private $orderLineItemRepository; + /** @var LoggerInterface */ + private $logger; /** @var OrderService */ private $orderService; - /** @var OrderTransactionStateHandler */ - private $orderTransactionStateHandler; - - /** @var SettingsService */ - private $settingsService; + /** @var RefundService */ + private $refundService; /** * Creates a new instance of the onboarding controller. * - * @param MollieApiFactory $apiFactory - * @param EntityRepositoryInterface $orderLineItemRepository + * @param LoggerInterface $logger * @param OrderService $orderService - * @param OrderTransactionStateHandler $orderTransactionStateHandler - * @param SettingsService $settingsService + * @param RefundService $refundService */ public function __construct( - MollieApiFactory $apiFactory, - EntityRepositoryInterface $orderLineItemRepository, + LoggerInterface $logger, OrderService $orderService, - OrderTransactionStateHandler $orderTransactionStateHandler, - SettingsService $settingsService + RefundService $refundService ) { - $this->apiFactory = $apiFactory; - $this->orderLineItemRepository = $orderLineItemRepository; + $this->logger = $logger; $this->orderService = $orderService; - $this->orderTransactionStateHandler = $orderTransactionStateHandler; - $this->settingsService = $settingsService; + $this->refundService = $refundService; } /** * @RouteScope(scopes={"api"}) - * @Route("/api/v{version}/_action/mollie/refund", + * @Route("/api/_action/mollie/refund", * defaults={"auth_enabled"=true}, name="api.action.mollie.refund", methods={"POST"}) * - * @param Request $request - * + * @param RequestDataBag $data + * @param Context $context * @return JsonResponse - * @throws ApiException - * @throws IncompatiblePlatform */ - public function refund(Request $request): JsonResponse + public function refund(RequestDataBag $data, Context $context): JsonResponse { - return $this->getRefundResponse($request); + return $this->refundResponse($data->getAlnum('orderId'), $data->get('amount', 0.0), $context); } /** - * refund action for Shopware versions >=6.4 - * * @RouteScope(scopes={"api"}) - * @Route("/api/mollie/refund", - * defaults={"auth_enabled"=true}, name="api.action.mollie.refund-64", methods={"POST"}) - * - * @param Request $request + * @Route("/api/v{version}/_action/mollie/refund", + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.legacy", methods={"POST"}) * + * @param RequestDataBag $data + * @param Context $context * @return JsonResponse - * @throws ApiException - * @throws IncompatiblePlatform */ - public function refund64(Request $request): JsonResponse + public function refundLegacy(RequestDataBag $data, Context $context): JsonResponse { - return $this->getRefundResponse($request); + return $this->refundResponse($data->getAlnum('orderId'), $data->get('amount', 0.0), $context); } - private function getRefundResponse(Request $request): JsonResponse + /** + * @RouteScope(scopes={"api"}) + * @Route("/api/_action/mollie/refund/cancel", + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.cancel", methods={"POST"}) + * + * @param RequestDataBag $data + * @param Context $context + * @return JsonResponse + */ + public function cancel(RequestDataBag $data, Context $context): JsonResponse { - /** @var MollieApiClient|null $apiClient */ - $apiClient = null; - - /** @var array|null $customFields */ - $customFields = null; - - /** @var string|null $orderId */ - $orderId = null; - - /** @var string|null $orderLineId */ - $orderLineId = null; - - /** @var OrderLineItemEntity $orderLineItem */ - $orderLineItem = null; - - /** @var bool $success */ - $success = false; - - /** @var int $quantity */ - $quantity = 0; - - if ( - (string)$request->get(self::REQUEST_KEY_ORDER_LINE_ITEM_ID) !== '' - && (string)$request->get(self::REQUEST_KEY_ORDER_LINE_ITEM_VERSION_ID) !== '' - ) { - $orderLineItem = $this->getOrderLineItemById( - $request->get(self::REQUEST_KEY_ORDER_LINE_ITEM_ID), - $request->get(self::REQUEST_KEY_ORDER_LINE_ITEM_VERSION_ID) - ); - } - - if ((int)$request->get(self::REQUEST_KEY_ORDER_LINE_QUANTITY) > 0) { - $quantity = (int)$request->get(self::REQUEST_KEY_ORDER_LINE_QUANTITY); - } - - if ( - $orderLineItem !== null - && !empty($orderLineItem->getCustomFields()) - ) { - $customFields = $orderLineItem->getCustomFields(); - } - - if ( - $orderLineItem !== null - && !empty($customFields) - && isset($customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_ORDER_LINE_ID]) - ) { - $orderLineId = $customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_ORDER_LINE_ID]; - } - - if ( - $orderLineItem !== null - && $orderLineItem->getOrder() !== null - && !empty($orderLineItem->getOrder()->getCustomFields()) - && isset($orderLineItem->getOrder()->getCustomFields()[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_ORDER_ID]) - ) { - $orderId = $orderLineItem->getOrder()->getCustomFields()[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_ORDER_ID]; - } - - if ($orderLineItem->getOrder() !== null) { - $transactions = $orderLineItem->getOrder()->getTransactions(); - - if ($transactions !== null && $transactions->count()) { - foreach ($transactions as $transaction) { - try { - $this->orderTransactionStateHandler->refundPartially( - $transaction->getId(), - Context::createDefaultContext() - ); - } catch (Exception $e) { - // @todo Maybe handle this exception in debug mode? - } - } - } - } - - if ( - (string)$orderId !== '' - && (string)$orderLineId !== '' - && $quantity > 0 - ) { - $apiClient = $this->apiFactory->createClient( - $orderLineItem->getOrder()->getSalesChannelId() - ); - } - - if ($apiClient !== null) { - /** @var MollieSettingStruct $settings */ - $settings = $this->settingsService->getSettings( - $orderLineItem->getOrder()->getSalesChannelId() - ); - - /** @var array $orderParameters */ - $orderParameters = []; - - if ($settings->isTestMode() && $apiClient->usesOAuth()) { - $orderParameters = [ - self::REFUND_DATA_KEY_TEST_MODE => true - ]; - } - - try { - $order = $apiClient->orders->get($orderId, $orderParameters); - } catch (ApiException $e) { - // - } - - if (isset($order, $order->id)) { - $refundData = [ - self::REFUND_DATA_KEY_LINES => [ - [ - self::REFUND_DATA_KEY_ID => $orderLineId, - self::REFUND_DATA_KEY_QUANTITY => $quantity, - ], - ], - ]; - - if ($settings->isTestMode() && $apiClient->usesOAuth()) { - $refundData[self::REFUND_DATA_KEY_TEST_MODE] = true; - } - - try { - $refund = $apiClient->orderRefunds->createFor($order, $refundData); - } catch (ApiException $e) { - // - } - - if (isset($refund, $refund->id)) { - $success = true; - - if (!isset($customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_REFUNDS])) { - $customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_REFUNDS] = []; - } - - if (!is_array($customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_REFUNDS])) { - $customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_REFUNDS] = []; - } - - $customFields[CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS][self::CUSTOM_FIELDS_KEY_REFUNDS][] = [ - self::CUSTOM_FIELDS_KEY_REFUND_ID => $refund->id, - self::CUSTOM_FIELDS_KEY_QUANTITY => $quantity, - ]; - - if (isset($customFields[self::CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY])) { - $customFields[self::CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY] += $quantity; - } else { - $customFields[self::CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY] = $quantity; - } - } - } - - // Update the custom fields of the order line item - $this->orderLineItemRepository->update([ - [ - self::REFUND_DATA_KEY_ID => $orderLineItem->getId(), - CustomFieldService::CUSTOM_FIELDS_KEY => $customFields, - ] - ], Context::createDefaultContext()); - } - - return new JsonResponse([ - self::RESPONSE_KEY_SUCCESS => $success, - ]); + return $this->cancelResponse($data->getAlnum('orderId'), $data->get('refundId'), $context); } /** * @RouteScope(scopes={"api"}) - * @Route("/api/v{version}/_action/mollie/refund/total", - * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.total", methods={"POST"}) + * @Route("/api/v{version}/_action/mollie/refund/cancel", + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.cancel.legacy", methods={"POST"}) * - * @param Request $request + * @param RequestDataBag $data + * @param Context $context + * @return JsonResponse + */ + public function cancelLegacy(RequestDataBag $data, Context $context): JsonResponse + { + return $this->cancelResponse($data->getAlnum('orderId'), $data->get('refundId'), $context); + } + + /** + * @RouteScope(scopes={"api"}) + * @Route("/api/_action/mollie/refund/list", + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.list", methods={"POST"}) * + * @param RequestDataBag $data + * @param Context $context * @return JsonResponse */ - public function total(Request $request): JsonResponse + public function list(RequestDataBag $data, Context $context): JsonResponse { - /** @var string|null $orderId */ - $orderId = $request->get('orderId'); + return $this->listResponse($data->getAlnum('orderId'), $context); + } - return $this->getTotalResponse($orderId); + /** + * @RouteScope(scopes={"api"}) + * @Route("/api/v{version}/_action/mollie/refund/list", + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.list.legacy", methods={"POST"}) + * + * @param RequestDataBag $data + * @param Context $context + * @return JsonResponse + */ + public function listLegacy(RequestDataBag $data, Context $context): JsonResponse + { + return $this->listResponse($data->getAlnum('orderId'), $context); } /** * @RouteScope(scopes={"api"}) * @Route("/api/_action/mollie/refund/total", - * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.total-64", methods={"POST"}) - * - * @param Request $request + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.total", methods={"POST"}) * + * @param RequestDataBag $data + * @param Context $context * @return JsonResponse */ - public function total64(Request $request): JsonResponse + public function total(RequestDataBag $data, Context $context): JsonResponse { - /** @var string|null $orderId */ - $orderId = $request->get('orderId'); - - return $this->getTotalResponse($orderId); + return $this->totalResponse($data->getAlnum('orderId'), $context); } - private function getTotalResponse(?string $orderId): JsonResponse + /** + * @RouteScope(scopes={"api"}) + * @Route("/api/v{version}/_action/mollie/refund/total", + * defaults={"auth_enabled"=true}, name="api.action.mollie.refund.total.legacy", methods={"POST"}) + * + * @param RequestDataBag $data + * @param Context $context + * @return JsonResponse + */ + public function totalLegacy(RequestDataBag $data, Context $context): JsonResponse { - /** @var float $amount */ - $amount = 0.0; + return $this->totalResponse($data->getAlnum('orderId'), $context); + } - /** @var int $items */ - $items = 0; + /** + * @param string $orderId + * @param float $amount + * @param Context $context + * @return JsonResponse + */ + private function refundResponse(string $orderId, float $amount, Context $context): JsonResponse + { + try { + $order = $this->getValidOrder($orderId, $context); + + $success = $this->refundService->refund($order, $amount, null, $context); + } catch (ShopwareHttpException $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], $e->getStatusCode()); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], 500); + } - /** @var OrderEntity $order */ - $order = null; + return $this->json([ + 'success' => $success + ]); + } - if (!empty($orderId)) { - $order = $this->orderService->getOrder($orderId, Context::createDefaultContext()); + /** + * @param string $orderId + * @param string|null $refundId + * @param Context $context + * @return JsonResponse + */ + private function cancelResponse(string $orderId, ?string $refundId, Context $context): JsonResponse + { + try { + $order = $this->getValidOrder($orderId, $context); + + $success = $this->refundService->cancel($order, $refundId, $context); + } catch (ShopwareHttpException $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], $e->getStatusCode()); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], 500); } - if ($order !== null) { - foreach ($order->getLineItems() as $lineItem) { - if ( - !empty($lineItem->getCustomFields()) - && isset($lineItem->getCustomFields()[self::CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY]) - ) { - $amount += ($lineItem->getUnitPrice() * (int)$lineItem->getCustomFields()[self::CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY]); - $items += (int)$lineItem->getCustomFields()[self::CUSTOM_FIELDS_KEY_REFUNDED_QUANTITY]; - } - } + return $this->json([ + 'success' => $success + ]); + } + + /** + * @param string $orderId + * @param Context $context + * @return JsonResponse + */ + private function listResponse(string $orderId, Context $context): JsonResponse + { + try { + $order = $this->getValidOrder($orderId, $context); + + $refunds = $this->refundService->getRefunds($order, $context); + } catch (ShopwareHttpException $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], $e->getStatusCode()); + } catch (PaymentNotFoundException $e) { + // This indicates there is no completed payment for this order, so there are no refunds yet. + $refunds = []; + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], 500); } - return new JsonResponse([ - self::RESPONSE_KEY_AMOUNT => $amount, - self::RESPONSE_KEY_ITEMS => $items, - ]); + return $this->json($refunds ?? []); } /** - * Returns an order line item by id. - * - * @param $lineItemId - * @param null $versionId - * @param Context|null $context - * - * @return OrderLineItemEntity|null + * @param string $orderId + * @param Context $context + * @return JsonResponse */ - public function getOrderLineItemById( - $lineItemId, - $versionId = null, - Context $context = null - ): ?OrderLineItemEntity + private function totalResponse(string $orderId, Context $context): JsonResponse { - $orderLineCriteria = new Criteria([$lineItemId]); + try { + $order = $this->getValidOrder($orderId, $context); + + $remaining = $this->refundService->getRemainingAmount($order, $context); + $refunded = $this->refundService->getRefundedAmount($order, $context); + } catch (ShopwareHttpException $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], $e->getStatusCode()); + } catch (PaymentNotFoundException $e) { + // This indicates there is no completed payment for this order, so there are no refunds yet. + $remaining = 0; + $refunded = 0; + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + return $this->json(['message' => $e->getMessage()], 500); + } - if ($versionId !== null) { - $orderLineCriteria->addFilter(new EqualsFilter('versionId', $versionId)); + return $this->json(compact('remaining', 'refunded')); + } + + /** + * @param string $orderId + * @param Context $context + * @return OrderEntity + * @throws InvalidUuidException + * @throws InvalidOrderException + */ + private function getValidOrder(string $orderId, Context $context): OrderEntity + { + if (!Uuid::isValid($orderId)) { + throw new InvalidUuidException($orderId); } - $orderLineCriteria->addAssociation('order'); - $orderLineCriteria->addAssociation('order.salesChannel'); - $orderLineCriteria->addAssociation('order.transactions'); + $order = $this->orderService->getOrder($orderId, $context); + + if (!($order instanceof OrderEntity)) { + throw new InvalidOrderException($orderId); + } - return $this->orderLineItemRepository->search( - $orderLineCriteria, - $context ?? Context::createDefaultContext() - )->get($lineItemId); + return $order; } } diff --git a/src/Exception/CouldNotCancelMollieRefundException.php b/src/Exception/CouldNotCancelMollieRefundException.php new file mode 100644 index 000000000..e91017d2f --- /dev/null +++ b/src/Exception/CouldNotCancelMollieRefundException.php @@ -0,0 +1,22 @@ +transactionService = $transactionService; - $this->mollieOrderService = $mollieOrderService; - $this->apiFactory = $apiFactory; + $this->refundService = $refundService; } /** @@ -59,41 +51,21 @@ public function setRefunded(string $orderTransactionId, Context $context): void ); } - $customFields = $order->getCustomFields() ?? []; - - $mollieOrderId = $customFields['mollie_payments']['order_id'] ?? ''; + $refunded = $this->refundService->getRefundedAmount($order, $context); + $toRefund = $order->getAmountTotal() - $refunded; - if (empty($mollieOrderId)) { - throw new CouldNotSetRefundAtMollieException( - sprintf('Could not find a mollie order id in order %s for transaction %s ', - $order->getOrderNumber(), - $transaction->getId() - ) - ); + if ($toRefund <= 0.0) { + return; } - $salesChannel = $order->getSalesChannel(); - - if (!$salesChannel instanceof SalesChannelEntity) { - throw new MissingSalesChannelInOrder($order->getOrderNumber() ?? $order->getId()); - } - - $apiClient = $this->apiFactory->getClient($salesChannel->getId(), $context); - - try { - $mollieOrder = $apiClient->orders->get($mollieOrderId); - $mollieOrder->refundAll(); - } catch (ApiException $e) { - throw new CouldNotSetRefundAtMollieException( - sprintf('Could not refund at mollie for transaction %s with mollieOrderId %s', - $orderTransactionId, - $mollieOrderId - ), - 0, - $e - ); - } + $this->refundService->refund( + $order, + $toRefund, + sprintf( + "Refunded entire order through Shopware Administration. Order number %s", + $order->getOrderNumber() + ), + $context + ); } - - } diff --git a/src/Hydrator/RefundHydrator.php b/src/Hydrator/RefundHydrator.php new file mode 100644 index 000000000..57475feee --- /dev/null +++ b/src/Hydrator/RefundHydrator.php @@ -0,0 +1,47 @@ + + */ + public function hydrate(Refund $refund): array + { + $amount = null; + if ($refund->amount instanceof \stdClass) { + $amount = [ + 'value' => $refund->amount->value, + 'currency' => $refund->amount->currency, + ]; + } + + $settlementAmount = null; + if ($refund->settlementAmount instanceof \stdClass) { + $settlementAmount = [ + 'value' => $refund->settlementAmount->value, + 'currency' => $refund->settlementAmount->currency, + ]; + } + + return [ + 'id' => $refund->id, + 'orderId' => $refund->orderId, + 'paymentId' => $refund->paymentId, + 'amount' => $amount, + 'settlementAmount' => $settlementAmount, + 'description' => $refund->description, + 'createdAt' => $refund->createdAt, + 'status' => $refund->status, + 'isFailed' => $refund->isFailed(), + 'isPending' => $refund->isPending(), + 'isProcessing' => $refund->isProcessing(), + 'isQueued' => $refund->isQueued(), + 'isTransferred' => $refund->isTransferred(), + ]; + } +} diff --git a/src/Resources/app/administration/src/core/service/api/mollie-payments-refund.service.js b/src/Resources/app/administration/src/core/service/api/mollie-payments-refund.service.js index a42651a38..33b404be1 100644 --- a/src/Resources/app/administration/src/core/service/api/mollie-payments-refund.service.js +++ b/src/Resources/app/administration/src/core/service/api/mollie-payments-refund.service.js @@ -5,15 +5,13 @@ class MolliePaymentsRefundService extends ApiService { super(httpClient, loginService, apiEndpoint); } - refund(data = {itemId: null, versionId: null, quantity: null, createCredit: null}) { - const headers = this.getBasicHeaders(); - + __post(endpoint = '', data = {}, headers = {}) { return this.httpClient .post( - `_action/${this.getApiBasePath()}/refund`, + `_action/${this.getApiBasePath()}/refund${endpoint}`, JSON.stringify(data), { - headers: headers + headers: this.getBasicHeaders(headers) } ) .then((response) => { @@ -21,21 +19,21 @@ class MolliePaymentsRefundService extends ApiService { }); } - total(data = {orderId: null}) { - const headers = this.getBasicHeaders(); + refund(data = {orderId: null, amount: null}) { + return this.__post('', data); + } - return this.httpClient - .post( - `_action/${this.getApiBasePath()}/refund/total`, - JSON.stringify(data), - { - headers: headers - } - ) - .then((response) => { - return ApiService.handleResponse(response); - }); + cancel(data = {orderId: null, refundId: null}) { + return this.__post('/cancel', data); + } + + list(data = {orderId: null}) { + return this.__post('/list', data); + } + + total(data = {orderId: null}) { + return this.__post('/total', data); } } -export default MolliePaymentsRefundService; \ No newline at end of file +export default MolliePaymentsRefundService; diff --git a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/index.js b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/index.js index 980a64df8..30756ede0 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/index.js +++ b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/index.js @@ -1,15 +1,34 @@ import template from './sw-order-line-items-grid.html.twig'; -const { Component, Service } = Shopware; +const {Component, Mixin} = Shopware; Component.override('sw-order-line-items-grid', { template, + mixins: [ + Mixin.getByName('notification') + ], + inject: [ 'MolliePaymentsRefundService', 'MolliePaymentsShippingService', ], + props: { + remainingAmount: { + type: Number, + required: true + }, + refundedAmount: { + type: Number, + required: true + }, + refunds: { + type: Array, + required: true + }, + }, + data() { return { isLoading: false, @@ -17,9 +36,8 @@ Component.override('sw-order-line-items-grid', { showRefundModal: false, showShippingModal: false, createCredit: false, - quantityToRefund: 1, quantityToShip: 1, - refundQuantity: 0, + refundAmount: 0.0, shippingQuantity: 0 }; }, @@ -28,17 +46,6 @@ Component.override('sw-order-line-items-grid', { getLineItemColumns() { const columnDefinitions = this.$super('getLineItemColumns'); - columnDefinitions.push( - { - property: 'customFields.refundedQuantity', - label: this.$tc('sw-order.detailExtended.columnRefunded'), - allowResize: false, - align: 'right', - inlineEdit: false, - width: '100px' - } - ); - columnDefinitions.push( { property: 'customFields.shippedQuantity', @@ -51,32 +58,125 @@ Component.override('sw-order-line-items-grid', { ); return columnDefinitions; - } + }, + + getRefundListColumns() { + return [ + { + property: 'amount.value', + label: this.$tc('mollie-payments.modals.refund.list.column.amount') + }, + { + property: 'status', + label: this.$tc('mollie-payments.modals.refund.list.column.status') + }, + { + property: 'createdAt', + label: this.$tc('mollie-payments.modals.refund.list.column.date'), + width: '100px' + }, + ]; + }, + + isMollieOrder() { + return (this.order.customFields !== null && 'mollie_payments' in this.order.customFields); + }, + + canOpenRefundModal() { + return this.remainingAmount > 0 || this.refunds.length > 0; + }, + }, + + created() { + this.createdComponent(); }, methods: { - onRefundItem(item) { - this.showRefundModal = item.id; + createdComponent() { + }, + + onOpenRefundModal() { + this.showRefundModal = true; }, onCloseRefundModal() { this.showRefundModal = false; }, - onConfirmRefund(item) { - this.showRefundModal = false; + onConfirmRefund() { + if(this.refundAmount === 0.0) { + this.createNotificationWarning({ + message: this.$tc('mollie-payments.modals.refund.warning.low-amount') + }); - if (this.quantityToRefund > 0) { - this.MolliePaymentsRefundService.refund({ - itemId: item.id, - versionId: item.versionId, - quantity: this.quantityToRefund, - createCredit: this.createCredit - }) - .then(document.location.reload()); + return; } - this.quantityToRefund = 0; + this.MolliePaymentsRefundService + .refund({ + orderId: this.order.id, + amount: this.refundAmount, + }) + .then((response) => { + if (response.success) { + this.createNotificationSuccess({ + message: this.$tc('mollie-payments.modals.refund.success') + }); + this.showRefundModal = false; + } else { + this.createNotificationError({ + message: this.$tc('mollie-payments.modals.refund.error') + }); + } + }) + .then(() => { + this.$emit('refund-success'); + }) + .catch((response) => { + this.createNotificationError({ + message: response.message + }); + }); + }, + + isRefundCancelable(item) { + return item.isPending || item.isQueued; + }, + + cancelRefund(item) { + this.MolliePaymentsRefundService + .cancel({ + orderId: this.order.id, + refundId: item.id + }) + .then((response) => { + if (response.success) { + this.createNotificationSuccess({ + message: this.$tc('mollie-payments.modals.refund.success') + }); + this.showRefundModal = false; + } else { + this.createNotificationError({ + message: this.$tc('mollie-payments.modals.refund.error') + }); + } + }) + .then(() => { + this.$emit('refund-cancelled'); + }) + .catch((response) => { + this.createNotificationError({ + message: response.message + }); + }); + }, + + getStatus(status) { + return this.$tc('mollie-payments.modals.refund.list.status.' + status); + }, + + getStatusDescription(status) { + return this.$tc('mollie-payments.modals.refund.list.status-description.' + status); }, onShipItem(item) { @@ -102,30 +202,6 @@ Component.override('sw-order-line-items-grid', { this.quantityToShip = 0; }, - isRefundable(item) { - let refundable = false; - - if ( - item.type === 'product' - && ( - item.customFields !== undefined - && item.customFields !== null - && item.customFields.mollie_payments !== undefined - && item.customFields.mollie_payments !== null - && item.customFields.mollie_payments.order_line_id !== undefined - && item.customFields.mollie_payments.order_line_id !== null - ) - && ( - item.customFields.refundedQuantity === undefined - || parseInt(item.customFields.refundedQuantity, 10) < item.quantity - ) - ) { - refundable = true; - } - - return refundable; - }, - isShippable(item) { let shippable = false; @@ -150,17 +226,6 @@ Component.override('sw-order-line-items-grid', { return shippable; }, - refundableQuantity(item) { - if ( - item.customFields !== undefined - && item.customFields.refundedQuantity !== undefined - ) { - return item.quantity - parseInt(item.customFields.refundedQuantity, 10); - } - - return item.quantity; - }, - shippableQuantity(item) { if ( item.customFields !== undefined @@ -181,4 +246,4 @@ Component.override('sw-order-line-items-grid', { return item.quantity; }, } -}); \ No newline at end of file +}); diff --git a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/sw-order-line-items-grid.html.twig b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/sw-order-line-items-grid.html.twig index d7e09cf16..6ddb08d30 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/sw-order-line-items-grid.html.twig +++ b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/sw-order-line-items-grid.html.twig @@ -1,83 +1,166 @@ -{% block sw_order_line_items_grid_grid_actions %} +{% block sw_order_line_items_grid_actions %} {% parent %} - {% endblock %} {% block sw_order_line_items_grid_grid_actions_show %} {% parent %} - - {{ $tc('mollie-payments.general.shipThroughMollie') }} - - - - {{ $tc('mollie-payments.general.refundThroughMollie') }} - -{% endblock %} \ No newline at end of file + + {{ $tc('mollie-payments.general.shipThroughMollie') }} + +{% endblock %} + diff --git a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/index.js b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/index.js index c63b3f95c..cecc43bbc 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/index.js +++ b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/index.js @@ -1,21 +1,20 @@ import template from './sw-order-detail-base.html.twig'; -const { Component } = Shopware; +const {Component, Mixin} = Shopware; Component.override('sw-order-detail-base', { template, - props: { - orderId: { - type: String, - required: true - }, - }, + mixins: [ + Mixin.getByName('notification') + ], data() { return { + remainingAmount: 0.0, refundedAmount: 0.0, - refundedItems: 0, + refundAmountPending: 0.0, + refunds: [], shippedAmount: 0, shippedItems: 0, } @@ -26,23 +25,59 @@ Component.override('sw-order-detail-base', { 'MolliePaymentsShippingService', ], - mounted() { - if (this.orderId !== '') { - this.MolliePaymentsRefundService.total({ - orderId: this.orderId - }) - .then((response) => { - this.refundedAmount = response.amount; - this.refundedItems = response.items; - }); - - this.MolliePaymentsShippingService.total({ - orderId: this.orderId - }) - .then((response) => { - this.shippedAmount = response.amount; - this.shippedItems = response.items; - }); + computed: { + isMollieOrder() { + return (this.order.customFields !== null && 'mollie_payments' in this.order.customFields); + }, + }, + + watch: { + order() { + this.getMollieData(); + } + }, + + methods: { + getMollieData() { + if (this.isMollieOrder) { + this.MolliePaymentsRefundService + .total({orderId: this.order.id}) + .then((response) => { + this.remainingAmount = response.remaining; + this.refundedAmount = response.refunded; + }) + .catch((response) => { + this.createNotificationError({ + message: response.message + }); + }); + + this.MolliePaymentsShippingService + .total({orderId: this.order.id}) + .then((response) => { + this.shippedAmount = response.amount; + this.shippedItems = response.items; + }); + + this.MolliePaymentsRefundService + .list({orderId: this.order.id}) + .then((response) => { + return this.refunds = response; + }) + .then((refunds) => { + this.refundAmountPending = 0.0; + refunds.forEach((refund) => { + if(refund.isPending || refund.isQueued) { + this.refundAmountPending += (refund.amount.value || 0); + } + }); + }) + .catch((response) => { + this.createNotificationError({ + message: response.message + }); + }); + } } } -}); \ No newline at end of file +}); diff --git a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/sw-order-detail-base.html.twig b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/sw-order-detail-base.html.twig index b7e3fc19d..b0678132c 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/sw-order-detail-base.html.twig +++ b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/view/sw-order-detail-base/sw-order-detail-base.html.twig @@ -1,7 +1,30 @@ +{% block sw_order_detail_base_line_items_grid %} + + +{% endblock %} + {% block sw_order_detail_base_line_items_summary_entries %} {% parent %} -
{{ $tc('sw-order.detailExtended.totalRefunds', 0, { quantity: refundedItems }) }}
-
{{ refundedAmount | currency(order.currency.shortName) }}
+
{{ $tc('sw-order.detailExtended.totalRefunds') }}
+
{{ refundedAmount | currency(order.currency.shortName) }}
+ +
{{ $tc('sw-order.detailExtended.totalRefundsPending') }}
+
{{ refundAmountPending | currency(order.currency.shortName) }}
+
{{ $tc('sw-order.detailExtended.totalShipments', 0, { quantity: shippedItems }) }}
{{ shippedAmount | currency(order.currency.shortName) }}
-{% endblock %} \ No newline at end of file +{% endblock %} + diff --git a/src/Resources/app/administration/src/module/mollie-payments/snippet/de-DE.json b/src/Resources/app/administration/src/module/mollie-payments/snippet/de-DE.json index 2927dadd7..3cfc2e6bb 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/snippet/de-DE.json +++ b/src/Resources/app/administration/src/module/mollie-payments/snippet/de-DE.json @@ -8,12 +8,38 @@ }, "modals": { "refund": { - "title": "Bestellposition über Mollie rückerstatten", - "content": "Menge dieser Bestellposition ({refundableQuantity} von {quantity} für Rückerstattung möglich) für die Rückerstattung an den Kunden.", - "quantityPlaceholder": "Menge für Rückerstattung...", - "createCreditText": "Create a credit item for this refund.", + "title": "Rückerstattung über Mollie", + "success": "Es wurde eine Rückerstattung bei Mollie erstellt. Es kann bis zu 2 Stunden dauern, bis die Rückerstattung abgeschlossen ist. Bis dahin können Sie die Rückerstattung stornieren.", + "error": "Beim Erstellen einer Rückerstattung ist etwas schief gelaufen.", + "warning": { + "low-amount": "Bitte geben Sie einen zu erstattenden Betrag ein." + }, "confirmButton": "Rückerstatten", - "cancelButton": "Abbrechen" + "cancelButton": "Abbrechen", + "list": { + "column": { + "amount": "Betrag", + "status": "Status", + "date": "Datum" + }, + "context": { + "cancel": "Diese Rückerstattung stornieren" + }, + "status": { + "queued": "Warteschlange", + "pending": "Ausstehend", + "processing": "Processing", + "refunded": "Erstattet", + "failed": "Gescheitert" + }, + "status-description": { + "queued": "Die Rückerstattung steht in der Warteschlange, bis genügend Guthaben vorhanden ist, um die Rückerstattung zu verarbeiten. Sie können die Rückerstattung noch stornieren.", + "pending": "Die Rückerstattung wird am nächsten Werktag an die Bank gesendet. Sie können die Rückerstattung immer noch stornieren.", + "processing": "Die Rückerstattung wurde an die Bank gesendet. Der Rückerstattungsbetrag wird so schnell wie möglich auf das Kundenkonto überwiesen.", + "refunded": "Der Rückerstattungsbetrag wurde an den Kunden überwiesen.", + "failed": "Die Rückerstattung ist nach der Bearbeitung fehlgeschlagen. Zum Beispiel hat der Kunde sein Bankkonto geschlossen. Das Geld wird auf das Konto zurücküberwiesen." + } + } }, "shipping": { "title": "Versand der Bestellposition an Mollie melden", @@ -31,18 +57,20 @@ "columnShipped": "Versandt", "labelMollieOrderId": "Mollie Bestell ID", "labelMolliePaymentLink": "Mollie Checkout URL", - "totalRefunds": "Rückerstattete Menge ({quantity} Stück)", + "totalRefunds": "Rückerstattete Menge", + "totalRefundsPending": "Warten auf Rückerstattung", + "totalRemaining": "Rückerstattbar", "totalShipments": "Versandte Menge ({quantity} Stück)" } }, "sw-payment": { "apiLinkButton": "Erhalten Sie Ihre API Keys vom Mollie Dashboard", - "testButton": "Prüfe API Keys", + "testButton": "Prüfung", "testApiKeys": { "title": "Mollie Payments", "apiKey": "API Schlüssel", "isValid": "ist gültig", - "isInvalid": "ist gültig" + "isInvalid": "ist ungültig" } }, "sw-customer": { diff --git a/src/Resources/app/administration/src/module/mollie-payments/snippet/en-GB.json b/src/Resources/app/administration/src/module/mollie-payments/snippet/en-GB.json index 685ad1dd4..98f6e3db8 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/snippet/en-GB.json +++ b/src/Resources/app/administration/src/module/mollie-payments/snippet/en-GB.json @@ -8,12 +8,38 @@ }, "modals": { "refund": { - "title": "Refund an order line item through Mollie", - "content": "Fill out the quantity of this item ({refundableQuantity} out of {quantity} left to refund) to be refunded to the customer.", - "quantityPlaceholder": "The quantity to refund...", - "createCreditText": "Create a credit item for this refund.", + "title": "Refund through Mollie", + "success": "A refund has been created in Mollie. It may take 2 hours for the refund to complete. Until this time, you can cancel the refund.", + "error": "Something went wrong creating a refund.", + "warning": { + "low-amount": "Please enter an amount to be refunded." + }, "confirmButton": "Refund", - "cancelButton": "Do not refund" + "cancelButton": "Do not refund", + "list": { + "column": { + "amount": "Amount", + "status": "Status", + "date": "Date" + }, + "context": { + "cancel": "Cancel this refund" + }, + "status": { + "queued": "Queued", + "pending": "Pending", + "processing": "Processing", + "refunded": "Refunded", + "failed": "Failed" + }, + "status-description": { + "queued": "The refund is queued until there is enough balance to process te refund. You can still cancel the refund.", + "pending": "The refund will be sent to the bank on the next business day. You can still cancel the refund.", + "processing": "The refund has been sent to the bank. The refund amount will be transferred to the consumer account as soon as possible.", + "refunded": "The refund amount has been transferred to the consumer.", + "failed": "The refund has failed after processing. For example, the customer has closed his / her bank account. The funds will be returned to your account." + } + } }, "shipping": { "title": "Ship an order line item through Mollie", @@ -31,13 +57,15 @@ "columnShipped": "Shipped", "labelMollieOrderId": "Mollie Order ID", "labelMolliePaymentLink": "Mollie Checkout URL", - "totalRefunds": "Refunded amount ({quantity} items)", + "totalRefunds": "Refunded amount", + "totalRefundsPending": "Waiting to be refunded", + "totalRemaining": "Refundable amount", "totalShipments": "Shipped amount ({quantity} items)" } }, "sw-payment": { "apiLinkButton": "Get your API keys from the Mollie Dashboard", - "testButton": "Test API Keys", + "testButton": "Test", "testApiKeys": { "title": "Mollie Payments", "apiKey": "API key", diff --git a/src/Resources/app/administration/src/module/mollie-payments/snippet/nl-NL.json b/src/Resources/app/administration/src/module/mollie-payments/snippet/nl-NL.json index e561c182b..dfc80c2d3 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/snippet/nl-NL.json +++ b/src/Resources/app/administration/src/module/mollie-payments/snippet/nl-NL.json @@ -8,14 +8,40 @@ }, "modals": { "refund": { - "title": "Terugbetaling van een order line item via Mollie", - "content": "Vul de hoeveelheid van dit item in ({refundableQuantity} van {quantity} over voor terugbetaling) die aan de klant moet worden terugbetaald.", - "quantityPlaceholder": "De terug te betalen hoeveelheid...", - "createCreditText": "Creëer een credit-item voor deze terugbetaling.", - "confirmButton": "Terugbetaling", - "cancelButton": "Niet terugbetalen" + "title": "Terugbetaling via Mollie", + "success": "Er is een terugbetaling aangemaakt bij Mollie. Het kan 2 uur duren voordat de terugbetaling is voltooid. Tot deze tijd kunt u de terugbetaling annuleren.", + "error": "Er is iets mis gegaan bij het aanmaken van een terugbetaling.", + "warning": { + "low-amount": "Voer een terug te betalen bedrag in." + }, + "confirmButton": "Terugbetalen", + "cancelButton": "Niet terugbetalen", + "list": { + "column": { + "amount": "Bedrag", + "status": "Status", + "date": "Datum" + }, + "context": { + "cancel": "Annuleer deze terugbetaling\n" + }, + "status": { + "queued": "In wachtrij", + "pending": "In afwachting", + "processing": "In behandeling", + "refunded": "Terugbetaald", + "failed": "Mislukt" + }, + "status-description": { + "queued": "De terugbetaling wordt in de wachtrij geplaatst totdat er voldoende saldo is om de terugbetaling te verwerken. U kunt de terugbetaling nog steeds annuleren.", + "pending": "De terugbetaling wordt de volgende werkdag naar de bank gestuurd. U kunt de terugbetaling nog annuleren.", + "processing": "De terugbetaling is naar de bank gestuurd. Het bedrag wordt zo spoedig mogelijk overgemaakt op de rekening van de klant.", + "refunded": "Het restitutiebedrag is overgemaakt naar de klant.", + "failed": "De terugbetaling is mislukt na verwerking. De klant heeft bijvoorbeeld zijn/haar bankrekening opgeheven. Het geld zal worden teruggestort op de rekening." + } + } }, - "verzending": { + "shipping": { "title": "Verzend een order line item via Mollie", "content": "Vul de hoeveelheid van dit item in ({shippableQuantity} van {quantity} over voor verzending) om naar de klant te verzenden.", "quantityPlaceholder": "De te verzenden hoeveelheid...", @@ -31,18 +57,20 @@ "columnShipped": "Verzonden", "labelMollieOrderId": "Mollie Order ID", "labelMolliePaymentLink": "Mollie Checkout URL", - "totalRefunds": "Terugbetaald bedrag ({quantity} items)", + "totalRefunds": "Terugbetaald bedrag", + "totalRefundsPending": "Wachtend op terugbetaling", + "totalRemaining": "Terug te betalen", "totalShipments": "Verzonden bedrag ({quantity} items)" } }, "sw-payment": { "apiLinkButton": "Haal uw API sleutels van het Mollie Dashboard", - "testButton": "Test API sleutels", + "testButton": "Controlle", "testApiKeys": { "title": "Mollie Payments", "apiKey": "API Key", "isValid": "is geldig", - "isInvalid": "is geldig" + "isInvalid": "is ongeldig" } }, "sw-customer": { diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index bf1dbaf28..da743f866 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -9,6 +9,7 @@ + @@ -91,6 +92,7 @@ + @@ -156,6 +158,7 @@ + diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml index 91736b4ce..8db18b5d3 100644 --- a/src/Resources/config/services/controller.xml +++ b/src/Resources/config/services/controller.xml @@ -23,11 +23,9 @@ - - + - - + diff --git a/src/Resources/config/services/facades.xml b/src/Resources/config/services/facades.xml index d2bc5ce5f..4120dd49b 100644 --- a/src/Resources/config/services/facades.xml +++ b/src/Resources/config/services/facades.xml @@ -14,8 +14,7 @@ - - + diff --git a/src/Resources/config/services/hydrators.xml b/src/Resources/config/services/hydrators.xml new file mode 100644 index 000000000..9a789c48b --- /dev/null +++ b/src/Resources/config/services/hydrators.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/Resources/config/services/services.xml b/src/Resources/config/services/services.xml index 128ecd6e6..93cc74128 100644 --- a/src/Resources/config/services/services.xml +++ b/src/Resources/config/services/services.xml @@ -35,8 +35,12 @@ - - %kernel.shopware_version% + + + + + + diff --git a/src/Service/MollieApi/Order.php b/src/Service/MollieApi/Order.php index faf8c0aa0..a4070f62c 100644 --- a/src/Service/MollieApi/Order.php +++ b/src/Service/MollieApi/Order.php @@ -2,8 +2,10 @@ namespace Kiener\MolliePayments\Service\MollieApi; +use Kiener\MolliePayments\Exception\CouldNotFetchMollieOrderException; use Kiener\MolliePayments\Exception\MollieOrderCouldNotBeFetched; use Kiener\MolliePayments\Exception\MollieOrderPaymentCouldNotBeCreated; +use Kiener\MolliePayments\Exception\PaymentNotFoundException; use Kiener\MolliePayments\Factory\MollieApiFactory; use Kiener\MolliePayments\Service\LoggerService; use Kiener\MolliePayments\Service\MollieApi\Payment as PaymentApiService; @@ -12,6 +14,7 @@ use Mollie\Api\Resources\OrderLine; use Mollie\Api\Resources\Payment; use Mollie\Api\Resources\PaymentCollection; +use Mollie\Api\Types\PaymentStatus; use Monolog\Logger; use RuntimeException; use Shopware\Core\Framework\Context; @@ -42,16 +45,23 @@ public function __construct(MollieApiFactory $clientFactory, PaymentApiService $ $this->paymentApiService = $paymentApiService; } + /** + * @param string $mollieOrderId + * @param string|null $salesChannelId + * @param array $parameters + * @return MollieOrder + * @throws CouldNotFetchMollieOrderException + */ public function getMollieOrder(string $mollieOrderId, string $salesChannelId, Context $context, array $parameters = []): MollieOrder { - $apiClient = $this->clientFactory->getClient($salesChannelId, $context); - try { + $apiClient = $this->clientFactory->getClient($salesChannelId); + return $apiClient->orders->get($mollieOrderId, $parameters); } catch (ApiException $e) { $this->logger->addEntry( sprintf( - 'API error occured when fetching mollie order %s with message %s', + 'API error occurred when fetching mollie order %s with message %s', $mollieOrderId, $e->getMessage() ), @@ -61,7 +71,7 @@ public function getMollieOrder(string $mollieOrderId, string $salesChannelId, Co Logger::ERROR ); - throw $e; + throw new CouldNotFetchMollieOrderException($mollieOrderId, $e); } } @@ -101,7 +111,7 @@ public function createOrder(array $orderData, string $orderSalesChannelContextId */ public function createOrReusePayment(string $mollieOrderId, string $paymentMethod, SalesChannelContext $salesChannelContext): Payment { - $mollieOrder = $this->getMollieOrder($mollieOrderId, $salesChannelContext->getSalesChannel()->getId(), $salesChannelContext->getContext(), ['embed' => 'payments']); + $mollieOrder = $this->getMollieOrder($mollieOrderId, $salesChannelContext->getSalesChannel()->getId(), ['embed' => 'payments']); if (!$mollieOrder instanceof MollieOrder) { @@ -200,4 +210,32 @@ public function setShipment(string $mollieOrderId, string $salesChannelId, Conte return false; } + + /** + * @param string $mollieOrderId + * @param string|null $salesChannelId + * @return Payment + * @throws CouldNotFetchMollieOrderException + * @throws PaymentNotFoundException + */ + public function getCompletedPayment(string $mollieOrderId, ?string $salesChannelId, Context $context): Payment + { + $mollieOrder = $this->getMollieOrder($mollieOrderId, $salesChannelId, $context, ['embed' => 'payments']); + + if ($mollieOrder->payments()->count() === 0) { + throw new PaymentNotFoundException($mollieOrderId); + } + + /** @var Payment $payment */ + foreach ($mollieOrder->payments()->getArrayCopy() as $payment) { + if (in_array($payment->status, [ + PaymentStatus::STATUS_PAID, + PaymentStatus::STATUS_AUTHORIZED // Klarna + ])) { + return $payment; + } + } + + throw new PaymentNotFoundException($mollieOrderId); + } } diff --git a/src/Service/OrderService.php b/src/Service/OrderService.php index 586d43f8b..d35eecc3c 100644 --- a/src/Service/OrderService.php +++ b/src/Service/OrderService.php @@ -2,7 +2,7 @@ namespace Kiener\MolliePayments\Service; -use Kiener\MolliePayments\Validator\IsOrderTotalRoundingActivated; +use Kiener\MolliePayments\Exception\CouldNotExtractMollieOrderIdException; use Psr\Log\LoggerInterface; use Shopware\Core\Checkout\Cart\Exception\OrderNotFoundException; use Shopware\Core\Checkout\Order\OrderEntity; @@ -12,8 +12,6 @@ class OrderService { - public const ORDER_LINE_ITEM_ID = 'orderLineItemId'; - /** @var EntityRepositoryInterface */ protected $orderRepository; @@ -23,29 +21,15 @@ class OrderService /** @var LoggerInterface */ protected $logger; - /** - * @var IsOrderTotalRoundingActivated - */ - private $validator; - - /** - * @var string - */ - private $shopwareVersion; - public function __construct( EntityRepositoryInterface $orderRepository, EntityRepositoryInterface $orderLineItemRepository, - LoggerInterface $logger, - IsOrderTotalRoundingActivated $validator, - string $shopwareVersion + LoggerInterface $logger ) { $this->orderRepository = $orderRepository; $this->orderLineItemRepository = $orderLineItemRepository; $this->logger = $logger; - $this->validator = $validator; - $this->shopwareVersion = $shopwareVersion; } /** @@ -98,10 +82,26 @@ public function getOrder(string $orderId, Context $context): ?OrderEntity } $this->logger->critical( - sprintf('Could not find an order with id %s. Payment failed', $orderId), - $context + sprintf('Could not find an order with id %s. Payment failed', $orderId) ); throw new OrderNotFoundException($orderId); } + + /** + * @param OrderEntity $order + * @return string + * @throws CouldNotExtractMollieOrderIdException + */ + public function getMollieOrderId(OrderEntity $order): string + { + $mollieOrderId = $order->getCustomFields()['mollie_payments']['order_id'] ?? ''; + + if (empty($mollieOrderId)) { + throw new CouldNotExtractMollieOrderIdException($order->getOrderNumber()); + } + + return $mollieOrderId; + } + } diff --git a/src/Service/RefundService.php b/src/Service/RefundService.php new file mode 100644 index 000000000..4f46d0193 --- /dev/null +++ b/src/Service/RefundService.php @@ -0,0 +1,184 @@ +mollieOrderApi = $mollieOrderApi; + $this->orderService = $orderService; + $this->refundHydrator = $refundHydrator; + } + + /** + * @param OrderEntity $order + * @param float $amount + * @param string|null $description + * @return bool + * @throws CouldNotCreateMollieRefundException + * @throws CouldNotExtractMollieOrderIdException + * @throws CouldNotFetchMollieOrderException + * @throws PaymentNotFoundException + */ + public function refund(OrderEntity $order, float $amount, ?string $description, Context $context): bool + { + $mollieOrderId = $this->orderService->getMollieOrderId($order); + + $payment = $this->mollieOrderApi->getCompletedPayment($mollieOrderId, $order->getSalesChannelId(), $context); + + try { + $refund = $payment->refund([ + 'amount' => [ + 'value' => number_format($amount, 2, '.', ''), + 'currency' => $order->getCurrency()->getIsoCode() + ], + 'description' => $description ?? sprintf("Refunded through Shopware administration. Order number %s", + $order->getOrderNumber()) + ]); + + return $refund instanceof Refund; + } catch (ApiException $e) { + throw new CouldNotCreateMollieRefundException($mollieOrderId, $order->getOrderNumber(), $e); + } + } + + /** + * @param OrderEntity $order + * @param string $refundId + * @return bool + * @throws CouldNotCancelMollieRefundException + * @throws CouldNotExtractMollieOrderIdException + * @throws CouldNotFetchMollieOrderException + * @throws PaymentNotFoundException + */ + public function cancel(OrderEntity $order, string $refundId, Context $context): bool + { + $mollieOrderId = $this->orderService->getMollieOrderId($order); + + $payment = $this->mollieOrderApi->getCompletedPayment($mollieOrderId, $order->getSalesChannelId(), $context); + + try { + // getRefund doesn't contain all necessary @throws tags. + // It is possible for it to throw an ApiException here if $refundId is incorrect. + $refund = $payment->getRefund($refundId); + } catch (ApiException $e) { // Invalid resource id + throw new CouldNotCancelMollieRefundException($mollieOrderId, $order->getOrderNumber(), $refundId, $e); + } + + // This payment does not have a refund with $refundId, so we cannot cancel it. + if (!($refund instanceof Refund)) { + return false; + } + + // Refunds can only be cancelled when they're still queued or pending. + if (!$refund->isQueued() && !$refund->isPending()) { + return false; + } + + try { + $refund->cancel(); + return true; + } catch (ApiException $e) { + throw new CouldNotCancelMollieRefundException($mollieOrderId, $order->getOrderNumber(), $refundId, $e); + } + } + + /** + * @param OrderEntity $order + * @return array + * @throws CouldNotExtractMollieOrderIdException + * @throws CouldNotFetchMollieOrderException + * @throws CouldNotFetchMollieRefundsException + * @throws PaymentNotFoundException + */ + public function getRefunds(OrderEntity $order, Context $context): array + { + $mollieOrderId = $this->orderService->getMollieOrderId($order); + + $payment = $this->mollieOrderApi->getCompletedPayment($mollieOrderId, $order->getSalesChannelId(), $context); + + try { + $refundsArray = []; + + foreach ($payment->refunds()->getArrayCopy() as $refund) { + $refundsArray[] = $this->refundHydrator->hydrate($refund); + } + + return $refundsArray; + } catch (ApiException $e) { + throw new CouldNotFetchMollieRefundsException($mollieOrderId, $order->getOrderNumber(), $e); + } + } + + /** + * @param OrderEntity $order + * @return float + * @throws CouldNotExtractMollieOrderIdException + * @throws CouldNotFetchMollieOrderException + * @throws PaymentNotFoundException + */ + public function getRemainingAmount(OrderEntity $order, Context $context): float + { + $payment = $this->mollieOrderApi->getCompletedPayment( + $this->orderService->getMollieOrderId($order), + $order->getSalesChannelId(), + $context + ); + + return $payment->getAmountRemaining(); + } + + /** + * @param OrderEntity $order + * @return float + * @throws CouldNotExtractMollieOrderIdException + * @throws CouldNotFetchMollieOrderException + * @throws PaymentNotFoundException + */ + public function getRefundedAmount(OrderEntity $order, Context $context): float + { + $payment = $this->mollieOrderApi->getCompletedPayment( + $this->orderService->getMollieOrderId($order), + $order->getSalesChannelId(), + $context + ); + + return $payment->getAmountRefunded(); + } +} diff --git a/src/Service/TransactionService.php b/src/Service/TransactionService.php index 284317c9e..bea8619b1 100644 --- a/src/Service/TransactionService.php +++ b/src/Service/TransactionService.php @@ -60,7 +60,7 @@ public function getTransactionById( $transactionCriteria->addFilter(new EqualsFilter('versionId', $versionId)); } - $transactionCriteria->addAssociation('order'); + $transactionCriteria->addAssociation('order.currency'); /** @var OrderTransactionCollection $transactions */ $transactions = $this->getRepository()->search( @@ -92,4 +92,4 @@ public function updateTransaction( $context ?? Context::createDefaultContext() ); } -} \ No newline at end of file +} diff --git a/tests/PHPUnit/Facade/SetMollieOrderRefundedTest.php b/tests/PHPUnit/Facade/SetMollieOrderRefundedTest.php index 301538a99..16f97ed8e 100644 --- a/tests/PHPUnit/Facade/SetMollieOrderRefundedTest.php +++ b/tests/PHPUnit/Facade/SetMollieOrderRefundedTest.php @@ -2,21 +2,20 @@ namespace MolliePayments\Tests\Facade; - use Kiener\MolliePayments\Exception\CouldNotSetRefundAtMollieException; use Kiener\MolliePayments\Facade\SetMollieOrderRefunded; -use Kiener\MolliePayments\Factory\MollieApiFactory; -use Kiener\MolliePayments\Service\MollieApi\Order; +use Kiener\MolliePayments\Service\RefundService; use Kiener\MolliePayments\Service\TransactionService; use Mollie\Api\Endpoints\OrderEndpoint; -use Mollie\Api\Exceptions\ApiException; use Mollie\Api\MollieApiClient; +use Mollie\Api\Resources\Payment; +use Mollie\Api\Resources\PaymentCollection; +use Mollie\Api\Types\PaymentStatus; use PHPUnit\Framework\TestCase; use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionEntity; use Shopware\Core\Checkout\Order\OrderEntity; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Uuid\Uuid; -use Shopware\Core\System\SalesChannel\SalesChannelEntity; class SetMollieOrderRefundedTest extends TestCase { @@ -24,18 +23,17 @@ class SetMollieOrderRefundedTest extends TestCase * @var TransactionService|\PHPUnit\Framework\MockObject\MockObject */ private $transactionService; + /** - * @var Order|\PHPUnit\Framework\MockObject\MockObject - */ - private $mollieOrderService; - /** - * @var MollieApiFactory|\PHPUnit\Framework\MockObject\MockObject + * @var RefundService|\PHPUnit\Framework\MockObject\MockObject */ - private $apiFactory; + private $refundService; + /** * @var SetMollieOrderRefunded */ private $setMollieOrderService; + /** * @var \PHPUnit\Framework\MockObject\MockObject|Context */ @@ -44,12 +42,10 @@ class SetMollieOrderRefundedTest extends TestCase public function setUp(): void { $this->transactionService = $this->getMockBuilder(TransactionService::class)->disableOriginalConstructor()->getMock(); - $this->mollieOrderService = $this->getMockBuilder(Order::class)->disableOriginalConstructor()->getMock(); - $this->apiFactory = $this->getMockBuilder(MollieApiFactory::class)->disableOriginalConstructor()->getMock(); + $this->refundService = $this->getMockBuilder(RefundService::class)->disableOriginalConstructor()->getMock(); $this->setMollieOrderService = new SetMollieOrderRefunded( $this->transactionService, - $this->mollieOrderService, - $this->apiFactory + $this->refundService ); $this->context = $this->getMockBuilder(Context::class)->disableOriginalConstructor()->getMock(); } @@ -70,68 +66,108 @@ public function testThrowExceptionIfTransactionHasNoOrder(): void $this->setMollieOrderService->setRefunded('foo', $this->context); } - public function testThrowExceptionIfMollieOrderIdCouldNotBeFound(): void - { - $order = $this->getOrder(Uuid::randomHex(), Uuid::randomHex()); - $this->transactionService->method('getTransactionById')->willReturn($this->getTransaction(Uuid::randomHex(), $order)); - - self::expectException(CouldNotSetRefundAtMollieException::class); - $this->setMollieOrderService->setRefunded('foo', $this->context); - } - - public function testThatApiClientIsConstructedWithCorrectSalesChannel(): void + /** + * Test whether the setMollieOrderRefunded facade creates a refund at Mollie, with the given total and refunded amounts + * When refunded is higher than or equal to, it should not create a refund. + * + * @param float $amountTotal + * @param float $amountRefunded + * @param float|null $expectedRefund + * @dataProvider getRefundsTestData + */ + public function testThatRefundIsDone( + float $amountTotal, + float $amountRefunded, + ?float $expectedRefund + ): void { $salesChannelId = Uuid::randomHex(); $mollieOrderId = 'foo'; - $order = $this->getOrder(Uuid::randomHex(), $salesChannelId, $this->getCustomFields($mollieOrderId)); - $this->transactionService->method('getTransactionById')->willReturn($this->getTransaction(Uuid::randomHex(), $order)); - $apiClient = $this->getMockBuilder(MollieApiClient::class)->disableOriginalConstructor()->getMock(); - $orderEndpoint = $this->getMockBuilder(OrderEndpoint::class)->disableOriginalConstructor()->getMock(); - $mollieOrder = $this->getMockBuilder(\Mollie\Api\Resources\Order::class)->disableOriginalConstructor()->getMock(); - $orderEndpoint->method('get')->with($mollieOrderId)->willReturn($mollieOrder); - $apiClient->orders = $orderEndpoint; - - $this->apiFactory->expects($this->once())->method('getClient')->with($salesChannelId, $this->context)->willReturn($apiClient); - $this->setMollieOrderService->setRefunded('foo', $this->context); - } + $order = $this->getOrder(Uuid::randomHex(), $salesChannelId, $amountTotal, $this->getCustomFields($mollieOrderId)); - public function testThatExceptionIsThrownIfMollieOrderCouldNotBeRetrieved(): void - { - $salesChannelId = Uuid::randomHex(); - $mollieOrderId = 'foo'; - $order = $this->getOrder(Uuid::randomHex(), $salesChannelId, $this->getCustomFields($mollieOrderId)); $this->transactionService->method('getTransactionById')->willReturn($this->getTransaction(Uuid::randomHex(), $order)); + $apiClient = $this->getMockBuilder(MollieApiClient::class)->disableOriginalConstructor()->getMock(); $orderEndpoint = $this->getMockBuilder(OrderEndpoint::class)->disableOriginalConstructor()->getMock(); - $orderEndpoint->expects($this->once())->method('get')->willThrowException(new ApiException()); - $apiClient->orders = $orderEndpoint; + $mollieOrder = $this->getMockBuilder(\Mollie\Api\Resources\Order::class)->disableOriginalConstructor()->getMock(); - $this->apiFactory->expects($this->once())->method('getClient')->with($salesChannelId, $this->context)->willReturn($apiClient); + $paymentMock = $this->createConfiguredMock( + Payment::class, + ['getAmountRefunded' => $amountRefunded] + ); + $paymentMock->status = PaymentStatus::STATUS_PAID; - self::expectException(CouldNotSetRefundAtMollieException::class); - $this->setMollieOrderService->setRefunded('foo', $this->context); - } + $paymentCollectionMock = $this->createConfiguredMock( + PaymentCollection::class, + ['getArrayCopy' => [$paymentMock]] + ); - public function testThatRefundIsDone(): void - { - $salesChannelId = Uuid::randomHex(); - $mollieOrderId = 'foo'; - $order = $this->getOrder(Uuid::randomHex(), $salesChannelId, $this->getCustomFields($mollieOrderId)); - $this->transactionService->method('getTransactionById')->willReturn($this->getTransaction(Uuid::randomHex(), $order)); - $apiClient = $this->getMockBuilder(MollieApiClient::class)->disableOriginalConstructor()->getMock(); - $orderEndpoint = $this->getMockBuilder(OrderEndpoint::class)->disableOriginalConstructor()->getMock(); - $mollieOrder = $this->getMockBuilder(\Mollie\Api\Resources\Order::class)->disableOriginalConstructor()->getMock(); + $mollieOrder->method('payments')->willReturn($paymentCollectionMock); $orderEndpoint->method('get')->with($mollieOrderId)->willReturn($mollieOrder); $apiClient->orders = $orderEndpoint; - $this->apiFactory->expects($this->once())->method('getClient')->with($salesChannelId, $this->context)->willReturn($apiClient); + $this->refundService->expects($this->once())->method('getRefundedAmount')->with($order)->willReturn($amountRefunded); - $mollieOrder->expects($this->once())->method('refundAll'); + if (is_null($expectedRefund)) { + $this->refundService->expects($this->never())->method('refund'); + } else { + $this->refundService->expects($this->once())->method('refund')->with($order, $expectedRefund); + } $this->setMollieOrderService->setRefunded('foo', $this->context); } + public function getRefundsTestData(): array + { + return [ + "Do refund: Total 99.99, refunded 0 => 99.99" => [ + 99.99, + 0, + 99.99 + ], + "Do refund: Total 100, refunded 12.34 => 87.66" => [ + 100, + 12.34, + 87.66 + ], + "Do refund: Total 255, refunded 123.45 => 131.55" => [ + 255, + 123.45, + 131.55 + ], + "Do refund: Total 437, refunded 112 => 325" => [ + 437, + 112, + 325 + ], + "Do refund: Total 452.64, refunded 143.84 => 308.80" => [ + 452.64, + 143.84, + 308.80 + ], + "Do refund: Total 845.23, refunded 356.77 => 488.46" => [ + 845.23, + 356.77, + 488.46 + ], + "Don't refund: Total 124.99, refunded 149.99 => no refund" => [ + 124.99, + 149.99, + null + ], + "Don't refund: Total 100, refunded 100 => no refund" => [ + 100, + 100, + null + ], + "Do refund: Total 100, refunded 99.99 => 0.01" => [ + 100, + 99.99, + 0.01 + ], + ]; + } private function getTransaction(string $transactionId, ?OrderEntity $order = null): OrderTransactionEntity { @@ -145,13 +181,17 @@ private function getTransaction(string $transactionId, ?OrderEntity $order = nul return $transaction; } - private function getOrder(string $orderId, string $salesChannelId, array $customFields = []): OrderEntity + private function getOrder( + string $orderId, + string $salesChannelId, + float $amountTotal = 0, + array $customFields = [] + ): OrderEntity { $order = new OrderEntity(); $order->setId($orderId); - $salesChannel = $this->getMockBuilder(SalesChannelEntity::class)->disableOriginalConstructor()->getMock(); - $salesChannel->method('getId')->willReturn($salesChannelId); - $order->setSalesChannel($salesChannel); + $order->setSalesChannelId($salesChannelId); + $order->setAmountTotal($amountTotal); if (!empty($customFields)) { $order->setCustomFields($customFields); diff --git a/tests/PHPUnit/Hydrator/RefundHydratorTest.php b/tests/PHPUnit/Hydrator/RefundHydratorTest.php new file mode 100644 index 000000000..f28a0095b --- /dev/null +++ b/tests/PHPUnit/Hydrator/RefundHydratorTest.php @@ -0,0 +1,146 @@ +refundHydrator = new RefundHydrator(); + } + + /** + * @param array $expected + * @param Refund $refund + * @dataProvider getHydratorTestData + */ + public function testRefundHydrator( + array $expected, + Refund $refund + ) + { + self::assertIsArray($this->refundHydrator->hydrate($refund)); + self::assertEquals($expected, $this->refundHydrator->hydrate($refund)); + } + + public function getHydratorTestData() + { + return [ + 'Refund with amount 12.99, settlementAmount -12.99' => [ + $this->getExpectedData(12.99, -12.99), + $this->getRefund(12.99, -12.99) + ], + 'Refund with amount 12.99, settlementAmount null' => [ + $this->getExpectedData(12.99, null), + $this->getRefund(12.99, null) + ], + 'Refund with amount null, settlementAmount -12.99' => [ + $this->getExpectedData(null, -12.99), + $this->getRefund(null, -12.99) + ], + 'Refund with amount null, settlementAmount null' => [ + $this->getExpectedData(null, null), + $this->getRefund(null, null) + ], + 'Refund with status processing' => [ + $this->getExpectedData(12.99, -12.99, RefundStatus::STATUS_PROCESSING), + $this->getRefund(12.99, -12.99, RefundStatus::STATUS_PROCESSING) + ], + 'Refund with status pending' => [ + $this->getExpectedData(12.99, -12.99, RefundStatus::STATUS_PENDING), + $this->getRefund(12.99, -12.99, RefundStatus::STATUS_PENDING) + ], + 'Refund with status failed' => [ + $this->getExpectedData(12.99, -12.99, RefundStatus::STATUS_FAILED), + $this->getRefund(12.99, -12.99, RefundStatus::STATUS_FAILED) + ], + 'Refund with status refunded' => [ + $this->getExpectedData(12.99, -12.99, RefundStatus::STATUS_REFUNDED), + $this->getRefund(12.99, -12.99, RefundStatus::STATUS_REFUNDED) + ], + ]; + } + + private function getExpectedData(?float $amount, ?float $settlementAmount, string $status = RefundStatus::STATUS_QUEUED): array + { + if (!is_null($amount)) { + $amount = [ + 'value' => $amount, + 'currency' => 'EUR' + ]; + } + + if (!is_null($settlementAmount)) { + $settlementAmount = [ + 'value' => $settlementAmount, + 'currency' => 'EUR' + ]; + } + + return [ + 'id' => 'foo', + 'orderId' => 'bar', + 'paymentId' => 'baz', + 'amount' => $amount, + 'settlementAmount' => $settlementAmount, + 'description' => 'description', + 'createdAt' => '2015-08-01T12:34:56+0100', + 'status' => $status, + 'isFailed' => $status == RefundStatus::STATUS_FAILED, + 'isPending' => $status == RefundStatus::STATUS_PENDING, + 'isProcessing' => $status == RefundStatus::STATUS_PROCESSING, + 'isQueued' => $status == RefundStatus::STATUS_QUEUED, + 'isTransferred' => $status == RefundStatus::STATUS_REFUNDED, + ]; + } + + private function getRefund(?float $amount, ?float $settlementAmount, string $status = RefundStatus::STATUS_QUEUED): Refund + { + if (!is_null($amount)) { + $amount = [ + 'value' => $amount, + 'currency' => 'EUR' + ]; + } + + if (!is_null($settlementAmount)) { + $settlementAmount = [ + 'value' => $settlementAmount, + 'currency' => 'EUR' + ]; + } + + $refundMock = $this->createConfiguredMock( + Refund::class, + [ + 'isQueued' => $status == RefundStatus::STATUS_QUEUED, + 'isPending' => $status == RefundStatus::STATUS_PENDING, + 'isProcessing' => $status == RefundStatus::STATUS_PROCESSING, + 'isTransferred' => $status == RefundStatus::STATUS_REFUNDED, + 'isFailed' => $status == RefundStatus::STATUS_FAILED, + 'cancel' => null + ] + ); + + $refundMock->id = 'foo'; + $refundMock->orderId = 'bar'; + $refundMock->paymentId = 'baz'; + $refundMock->description = 'description'; + $refundMock->createdAt = '2015-08-01T12:34:56+0100'; + $refundMock->status = $status; + $refundMock->_links = (object)[]; + $refundMock->amount = $amount ? (object)$amount : null; + $refundMock->settlementAmount = $settlementAmount ? (object)$settlementAmount : null; + + return $refundMock; + } +} diff --git a/tests/PHPUnit/Service/RefundsServiceTest.php b/tests/PHPUnit/Service/RefundsServiceTest.php new file mode 100644 index 000000000..13f1bab84 --- /dev/null +++ b/tests/PHPUnit/Service/RefundsServiceTest.php @@ -0,0 +1,548 @@ +clientMock = $this->createMock(MollieApiClient::class); + + $this->orderService = new OrderService( + $this->createMock(EntityRepositoryInterface::class), + $this->createMock(EntityRepositoryInterface::class), + $logger + ); + + $apiFactoryMock = $this->createConfiguredMock( + MollieApiFactory::class, + ['createClient' => $this->clientMock, 'getClient' => $this->clientMock] + ); + + $loggerServiceMock = $this->createMock(LoggerService::class); + $paymentApiService = new MolliePaymentApi($apiFactoryMock); + $mollieOrderApiMock = new MollieOrderApi($apiFactoryMock, $paymentApiService, $loggerServiceMock); + + $this->refundService = new RefundService( + $mollieOrderApiMock, + $this->orderService, + new RefundHydrator() + ); + } + + /** + * @param bool $expected + * @param bool $isMollieOrder + * @param string|null $paymentStatus + * @param string|null $exceptionClass + * @dataProvider getRefundTestData + */ + public function testRefunds( + bool $expected, + bool $isMollieOrder, + ?string $paymentStatus, + ?string $exceptionClass + ): void + { + $orderEntityMock = $this->getOrderEntityMock($isMollieOrder); + + if ($isMollieOrder) { + $this->clientMock->orders = $this->getOrderEndpointMock($paymentStatus, 0); + } + + if ($exceptionClass) { + self::expectException($exceptionClass); + } + + static::assertEquals($expected, $this->refundService->refund( + $orderEntityMock, + 24.99, + 'test refund', + Context::createDefaultContext() + )); + } + + /** + * @param bool $expected + * @param bool $isMollieOrder + * @param string|null $paymentStatus + * @param array $refunds + * @param string|null $exceptionClass + * @dataProvider getRefundCancelTestData + */ + public function testCancelRefunds( + bool $expected, + bool $isMollieOrder, + ?string $paymentStatus, + array $refunds, + ?string $exceptionClass + ): void + { + $orderEntityMock = $this->getOrderEntityMock($isMollieOrder); + + if ($isMollieOrder) { + $this->clientMock->orders = $this->getOrderEndpointMock($paymentStatus, 0, $refunds); + } + + if($exceptionClass) { + self::expectException($exceptionClass); + } + + static::assertEquals($expected, $this->refundService->cancel( + $orderEntityMock, + 'foo', + Context::createDefaultContext() + )); + } + + /** + * @param int $expected + * @param bool $isMollieOrder + * @param string|null $paymentStatus + * @param array $refunds + * @param string|null $exceptionClass + * @dataProvider getRefundListTestData + */ + public function testRefundList( + int $expected, + bool $isMollieOrder, + ?string $paymentStatus, + array $refunds, + ?string $exceptionClass + ): void + { + $orderEntityMock = $this->getOrderEntityMock($isMollieOrder); + + if ($isMollieOrder) { + $this->clientMock->orders = $this->getOrderEndpointMock($paymentStatus, 0, $refunds); + } + + if($exceptionClass) { + self::expectException($exceptionClass); + } + + static::assertCount($expected, $this->refundService->getRefunds($orderEntityMock, Context::createDefaultContext())); + } + + /** + * Test getting correct refunded amount from RefundService; + * + * @param float $expected + * @param bool $isMollieOrder + * @param string|null $paymentStatus + * @param float $remainingAmount + * @param string|null $exceptionClass + * @dataProvider getAmountsTestData + */ + public function testRemainingAmount( + float $expected, + bool $isMollieOrder, + ?string $paymentStatus, + float $remainingAmount, + ?string $exceptionClass + ): void + { + $orderEntityMock = $this->getOrderEntityMock($isMollieOrder); + + if ($isMollieOrder) { + $this->clientMock->orders = $this->getOrderEndpointMock($paymentStatus, $remainingAmount); + } + + if($exceptionClass) { + self::expectException($exceptionClass); + } + + static::assertEquals($expected, $this->refundService->getRefundedAmount($orderEntityMock, Context::createDefaultContext())); + } + + /** + * Test getting correct refunded amount from RefundService; + * + * @param float $expected + * @param bool $isMollieOrder + * @param string|null $paymentStatus + * @param float $refundedAmount + * @param string|null $exceptionClass + * @dataProvider getAmountsTestData + */ + public function testRefundedAmount( + float $expected, + bool $isMollieOrder, + ?string $paymentStatus, + float $refundedAmount, + ?string $exceptionClass + ): void + { + $orderEntityMock = $this->getOrderEntityMock($isMollieOrder); + + if ($isMollieOrder) { + $this->clientMock->orders = $this->getOrderEndpointMock($paymentStatus, $refundedAmount); + } + + if($exceptionClass) { + self::expectException($exceptionClass); + } + + static::assertEquals($expected, $this->refundService->getRefundedAmount($orderEntityMock, Context::createDefaultContext())); + } + + public function getRefundTestData(): array + { + return [ + 'Not a Mollie order' => [ + false, + false, + null, + CouldNotExtractMollieOrderIdException::class + ], + 'Mollie order, payment open' => [ + false, + true, + PaymentStatus::STATUS_OPEN, + PaymentNotFoundException::class + ], + 'Mollie order, payment paid' => [ + true, + true, + PaymentStatus::STATUS_PAID, + null + ], + 'Mollie order, payment authorized' => [ + true, + true, + PaymentStatus::STATUS_AUTHORIZED, + null + ] + ]; + } + + public function getRefundCancelTestData(): array + { + return [ + 'Not a Mollie order' => [ + false, + false, + null, + [], + CouldNotExtractMollieOrderIdException::class + ], + 'Mollie order, payment open' => [ + false, + true, + PaymentStatus::STATUS_OPEN, + [], + PaymentNotFoundException::class + ], + 'Mollie order, payment paid, no refund' => [ + false, + true, + PaymentStatus::STATUS_PAID, + [], + null + ], + 'Mollie order, payment paid, refund queued' => [ + true, + true, + PaymentStatus::STATUS_PAID, + [ + [ + 'status' => RefundStatus::STATUS_QUEUED, + 'amount' => 24.99 + ] + ], + null + ], + 'Mollie order, payment paid, refund pending' => [ + true, + true, + PaymentStatus::STATUS_PAID, + [ + [ + 'status' => RefundStatus::STATUS_PENDING, + 'amount' => 24.99 + ] + ], + null + ], + 'Mollie order, payment paid, refund processing' => [ + false, + true, + PaymentStatus::STATUS_PAID, + [ + [ + 'status' => RefundStatus::STATUS_PROCESSING, + 'amount' => 24.99 + ] + ], + null + ], + 'Mollie order, payment paid, refund refunded' => [ + false, + true, + PaymentStatus::STATUS_PAID, + [ + [ + 'status' => RefundStatus::STATUS_REFUNDED, + 'amount' => 24.99 + ] + ], + null + ], + 'Mollie order, payment authorized, refund queued' => [ + true, + true, + PaymentStatus::STATUS_AUTHORIZED, + [ + [ + 'status' => RefundStatus::STATUS_QUEUED, + 'amount' => 24.99 + ] + ], + null + ] + ]; + } + + public function getRefundListTestData(): array + { + return [ + 'Not a Mollie order' => [ + 0, + false, + null, + [], + CouldNotExtractMollieOrderIdException::class + ], + 'Mollie order, payment open' => [ + 0, + true, + PaymentStatus::STATUS_OPEN, + [], + PaymentNotFoundException::class + ], + 'Mollie order, payment paid' => [ + 1, + true, + PaymentStatus::STATUS_PAID, + [ + [ + 'status' => RefundStatus::STATUS_REFUNDED, + 'amount' => 24.99 + ] + ], + null + ], + 'Mollie order, payment authorized' => [ + 1, + true, + PaymentStatus::STATUS_AUTHORIZED, + [ + [ + 'status' => RefundStatus::STATUS_REFUNDED, + 'amount' => 24.99 + ] + ], + null + ] + ]; + } + + public function getAmountsTestData(): array + { + return [ + 'Not a Mollie order' => [ + 0, + false, + null, + 0, + CouldNotExtractMollieOrderIdException::class + ], + 'Mollie order, payment open' => [ + 0, + true, + PaymentStatus::STATUS_OPEN, + 24.99, + PaymentNotFoundException::class + ], + 'Mollie order, payment paid' => [ + 24.99, + true, + PaymentStatus::STATUS_PAID, + 24.99, + null + ], + 'Mollie order, payment authorized' => [ + 24.99, + true, + PaymentStatus::STATUS_AUTHORIZED, + 24.99, + null + ] + ]; + } + + /** + * @param bool $isMollieOrder + * @param string|null $paymentStatus + * @param float $amount + * @param array $refunds + * @return MollieApiFactory + */ + private function getOrderEndpointMock( + ?string $paymentStatus, + float $amount, + array $refunds = [] + ): OrderEndpoint + { + $paymentMock = $this->createConfiguredMock( + Payment::class, + [ + 'getAmountRefunded' => $amount, + 'getAmountRemaining' => $amount, + 'refunds' => $this->getRefundsCollectionMock($refunds), + 'refund' => $this->getRefundMock(), + 'getRefund' => $this->getRefundMock( + $refunds[0]['status'] ?? RefundStatus::STATUS_REFUNDED, + $refunds[0]['amount'] ?? 0 + ) + ] + ); + $paymentMock->status = $paymentStatus; + + $paymentCollectionMock = $this->createConfiguredMock( + PaymentCollection::class, + ['getArrayCopy' => [$paymentMock]] + ); + + $orderMock = $this->createConfiguredMock( + Order::class, + ['payments' => $paymentCollectionMock] + ); + + $orderEndpointMock = $this->createConfiguredMock( + OrderEndpoint::class, + ['get' => $orderMock] + ); + + return $orderEndpointMock; + } + + /** + * @param bool $isMollieOrder + * @return OrderEntity + */ + private function getOrderEntityMock(bool $isMollieOrder): OrderEntity + { + $currencyMock = $this->createConfiguredMock( + CurrencyEntity::class, + ['getIsoCode' => 'EUR'] + ); + + return $this->createConfiguredMock( + OrderEntity::class, + [ + 'getSalesChannelId' => 'foo', + 'getOrderNumber' => 'bar', + 'getCurrency' => $currencyMock, + 'getCustomFields' => $isMollieOrder + ? [ + CustomFieldService::CUSTOM_FIELDS_KEY_MOLLIE_PAYMENTS => [ + 'order_id' => 'baz' + ] + ] + : null + ] + ); + } + + /** + * @param array $refunds + * @return RefundCollection + */ + private function getRefundsCollectionMock(array $refunds = []): RefundCollection + { + $refundMocks = []; + + foreach ($refunds as $refund) { + $refundMocks[] = $this->getRefundMock($refund['status'], $refund['amount']); + } + + return $this->createConfiguredMock( + RefundCollection::class, + [ + 'getArrayCopy' => $refundMocks + ] + ); + } + + /** + * @param string $status + * @param float $amount + * @return Refund + */ + private function getRefundMock(string $status = RefundStatus::STATUS_QUEUED, float $amount = 0.0): Refund + { + $refundMock = $this->createConfiguredMock( + Refund::class, + [ + 'isQueued' => $status == RefundStatus::STATUS_QUEUED, + 'isPending' => $status == RefundStatus::STATUS_PENDING, + 'isProcessing' => $status == RefundStatus::STATUS_PROCESSING, + 'isTransferred' => $status == RefundStatus::STATUS_REFUNDED, + 'isFailed' => $status == RefundStatus::STATUS_FAILED, + 'cancel' => null + ] + ); + + $refundMock->id = 'foo_id'; + $refundMock->amount = (object)[ + 'value' => $amount, + 'currency' => 'EUR' + ]; + $refundMock->createdAt = date(DATE_ISO8601); + $refundMock->description = 'Unit test refund'; + $refundMock->paymentId = 'bar_id'; + $refundMock->settlementAmount = (object)[ + 'value' => -($amount), + 'currency' => 'EUR' + ]; + $refundMock->status = $status; + $refundMock->_links = (object)[]; + + return $refundMock; + } +}