diff --git a/makefile b/makefile index 0a3f0ece1..6a68f6598 100644 --- a/makefile +++ b/makefile @@ -78,7 +78,7 @@ phpcheck: ## Starts the PHP syntax checks @find . -name '*.php' -not -path "./vendor/*" -not -path "./tests/*" | xargs -n 1 -P4 php -l phpmin: ## Starts the PHP compatibility checks - @php vendor/bin/phpcs -p --standard=PHPCompatibility --extensions=php --runtime-set testVersion 7.2 ./src + @php vendor/bin/phpcs -p --standard=PHPCompatibility --extensions=php --runtime-set testVersion 7.4 ./src csfix: ## Starts the PHP CS Fixer @PHP_CS_FIXER_IGNORE_ENV=1 php vendor/bin/php-cs-fixer fix --config=./.php_cs.php --dry-run diff --git a/src/Components/CancelManager/CancelItemFacade.php b/src/Components/CancelManager/CancelItemFacade.php new file mode 100644 index 000000000..76ab9d1ea --- /dev/null +++ b/src/Components/CancelManager/CancelItemFacade.php @@ -0,0 +1,97 @@ +client = $clientFactory->getClient(); + $this->logger = $logger; + $this->orderLineItemRepository = $orderLineItemRepository; + $this->stockManager = $stockManager; + } + + public function cancelItem(string $mollieOrderId, string $mollieLineId, string $shopwareLineId, int $quantity, bool $resetStock, Context $context): CancelItemResponse + { + $response = new CancelItemResponse(); + $logArguments = ['mollieOrderId' => $mollieOrderId, 'mollieLineId' => $mollieLineId, 'shopwareLineId' => $shopwareLineId, 'quantity' => $quantity, 'resetStock' => (string)$resetStock]; + try { + $this->logger->info('Initiated cancelling an item', $logArguments); + + if ($quantity === 0) { + $this->logger->error('Cancelling item failed, quantity is 0', $logArguments); + return $response->failedWithMessage('quantityZero'); + } + + $mollieOrder = $this->client->orders->get($mollieOrderId); + + $orderLine = $mollieOrder->lines()->get($mollieLineId); + + if ($orderLine === null) { + $this->logger->error('Cancelling item failed, lineItem does not exists in order', $logArguments); + return $response->failedWithMessage('invalidLine'); + } + if ($quantity > $orderLine->cancelableQuantity) { + $logArguments['cancelableQuantity'] = $orderLine->cancelableQuantity; + + $this->logger->error('Cancelling item failed, cancelableQuantity is too high', $logArguments); + return $response->failedWithMessage('quantityTooHigh'); + } + + //First we reset the stocks, just in case something went wrong the customer still have the chance to cancel the item on mollie page + if ($resetStock) { + $this->logger->info('Start to reset stocks', $logArguments); + $criteria = new Criteria([$shopwareLineId]); + $searchResult = $this->orderLineItemRepository->search($criteria, $context); + if ($searchResult->count() === 0) { + $this->logger->error('Failed to reset stocks in cancel process, shopware line item not found', $logArguments); + return $response->failedWithMessage('invalidShopwareLineId'); + } + + /** @var OrderLineItemEntity $shopwareLineItem */ + $shopwareLineItem = $searchResult->first(); + + $this->stockManager->increaseStock($shopwareLineItem, $quantity); + + $this->logger->info('Stock rested', $logArguments); + } + + + $lines = [ + 'id' => $orderLine->id, + 'quantity' => $quantity + ]; + + $mollieOrder->cancelLines(['lines' => [$lines]]); + $this->logger->info('Item cancelled successful', ['orderId' => $mollieOrderId, 'mollieLineId' => $mollieLineId, 'quantity' => $quantity]); + + + $response = $response->withData($lines); + } catch (\Throwable $e) { + $response = $response->failedWithMessage($e->getMessage()); + } + + return $response; + } +} diff --git a/src/Components/CancelManager/CancelItemResponse.php b/src/Components/CancelManager/CancelItemResponse.php new file mode 100644 index 000000000..b7b9d0b3d --- /dev/null +++ b/src/Components/CancelManager/CancelItemResponse.php @@ -0,0 +1,70 @@ + + */ + private array $data = []; + + public function isSuccessful(): bool + { + return $this->success === true; + } + + /** + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + public function failedWithMessage(string $message): self + { + $clone = clone $this; + $clone->success = false; + $clone->message = $message; + return $clone; + } + + /** + * @param array $data + * @return $this + */ + public function withData(array $data): self + { + $clone = clone $this; + $clone->data = $data; + return $clone; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'message' => $this->message, + 'success' => $this->success, + 'data' => $this->data + ]; + } +} diff --git a/src/Components/RefundManager/Integrators/StockManagerInterface.php b/src/Components/RefundManager/Integrators/StockManagerInterface.php index 7f84d0e49..0fb80bd90 100644 --- a/src/Components/RefundManager/Integrators/StockManagerInterface.php +++ b/src/Components/RefundManager/Integrators/StockManagerInterface.php @@ -9,8 +9,8 @@ interface StockManagerInterface /** * @param OrderLineItemEntity $lineItem * @param int $quantity - * @param string $mollieRefundID + * * @return void */ - public function increaseStock(OrderLineItemEntity $lineItem, int $quantity, string $mollieRefundID): void; + public function increaseStock(OrderLineItemEntity $lineItem, int $quantity): void; } diff --git a/src/Components/RefundManager/RefundManager.php b/src/Components/RefundManager/RefundManager.php index 4f8d267a9..db0723da9 100644 --- a/src/Components/RefundManager/RefundManager.php +++ b/src/Components/RefundManager/RefundManager.php @@ -286,8 +286,7 @@ public function refund(OrderEntity $order, RefundRequest $request, Context $cont # and now simply call our stock manager $this->stockManager->increaseStock( $orderItem, - $item->getStockIncreaseQty(), - $refund->id + $item->getStockIncreaseQty() ); } } diff --git a/src/Controller/Api/Order/CancelLineController.php b/src/Controller/Api/Order/CancelLineController.php new file mode 100644 index 000000000..3d1a1724f --- /dev/null +++ b/src/Controller/Api/Order/CancelLineController.php @@ -0,0 +1,69 @@ +clientFactory = $clientFactory; + $this->cancelItemFacade = $cancelItemFacade; + } + + public function statusAction(Request $request, Context $context): Response + { + $orderId = $request->get('mollieOrderId'); + $result = []; + $client = $this->clientFactory->getClient(); + $mollieOrder = $client->orders->get($orderId); + + $lines = $mollieOrder->lines(); + if ($lines->count() > 0) { + /** @var OrderLine $line */ + foreach ($lines as $line) { + $metadata = $line->metadata; + if (! property_exists($metadata, 'orderLineItemId')) { + continue; + } + $id = $metadata->orderLineItemId; + + $result[$id] = [ + 'mollieOrderId' => $orderId, + 'mollieId' => $line->id, + 'status' => $line->status, + 'isCancelable' => $line->isCancelable, + 'cancelableQuantity' => $line->cancelableQuantity, + 'quantityCanceled' => $line->quantityCanceled + ]; + } + } + + return new JsonResponse($result); + } + + public function cancelAction(Request $request, Context $context): Response + { + $mollieOrderId = $request->get('mollieOrderId'); + $mollieLineId = $request->get('mollieLineId'); + $quantity = $request->get('canceledQuantity'); + $shopwareOrderLineId = $request->get('shopwareLineId'); + $resetStock = $request->get('resetStock', false); + + $result = $this->cancelItemFacade->cancelItem($mollieOrderId, $mollieLineId, $shopwareOrderLineId, $quantity, $resetStock, $context); + return new JsonResponse($result->toArray()); + } +} diff --git a/src/Repository/OrderLineItem/OrderLineItemRepository.php b/src/Repository/OrderLineItem/OrderLineItemRepository.php index 92fc44640..4a9ea2df1 100644 --- a/src/Repository/OrderLineItem/OrderLineItemRepository.php +++ b/src/Repository/OrderLineItem/OrderLineItemRepository.php @@ -5,6 +5,8 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; class OrderLineItemRepository implements OrderLineItemRepositoryInterface { @@ -31,4 +33,9 @@ public function update(array $data, Context $context): EntityWrittenContainerEve { return $this->repoOrderLineItems->update($data, $context); } + + public function search(Criteria $criteria, Context $context): EntitySearchResult + { + return $this->repoOrderLineItems->search($criteria, $context); + } } diff --git a/src/Repository/OrderLineItem/OrderLineItemRepositoryInterface.php b/src/Repository/OrderLineItem/OrderLineItemRepositoryInterface.php index 0424b0017..a1b510391 100644 --- a/src/Repository/OrderLineItem/OrderLineItemRepositoryInterface.php +++ b/src/Repository/OrderLineItem/OrderLineItemRepositoryInterface.php @@ -4,6 +4,8 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; interface OrderLineItemRepositoryInterface { @@ -13,4 +15,11 @@ interface OrderLineItemRepositoryInterface * @return EntityWrittenContainerEvent */ public function update(array $data, Context $context): EntityWrittenContainerEvent; + + /** + * @param Criteria $criteria + * @param Context $context + * @return EntitySearchResult + */ + public function search(Criteria $criteria, Context $context): EntitySearchResult; } diff --git a/src/Resources/app/administration/src/core/service/api/mollie-payments-item-cancel.service.js b/src/Resources/app/administration/src/core/service/api/mollie-payments-item-cancel.service.js new file mode 100644 index 000000000..73e723e94 --- /dev/null +++ b/src/Resources/app/administration/src/core/service/api/mollie-payments-item-cancel.service.js @@ -0,0 +1,60 @@ +// eslint-disable-next-line no-undef +const ApiService = Shopware.Classes.ApiService; + +export default class MolliePaymentsItemCancelService extends ApiService { + + /** + * + * @param httpClient + * @param loginService + * @param apiEndpoint + */ + constructor(httpClient, loginService, apiEndpoint = 'mollie') { + super(httpClient, loginService, apiEndpoint); + } + + /** + * + * @param data + * @returns {*} + */ + status(data = {mollieOrderId: null}) { + return this.__post('/status', data); + } + + cancel(data = { + mollieOrderId: null, + mollieLineId: null, + shopwareLineId: null, + canceledQuantity: 0, + resetStock: false, + }) { + return this.__post('/cancel', data); + } + + /** + * + * @param endpoint + * @param data + * @param headers + * @returns {*} + * @private + */ + __post(endpoint = '', data = {}, headers = {}) { + return this.httpClient + .post( + `_action/${this.getApiBasePath()}/cancel-item${endpoint}`, + JSON.stringify(data), + { + headers: this.getBasicHeaders(headers), + } + ) + .then((response) => { + return ApiService.handleResponse(response); + }) + .catch((error) => { + return ApiService.handleResponse(error.response); + }); + } + +} diff --git a/src/Resources/app/administration/src/init/api-service.init.js b/src/Resources/app/administration/src/init/api-service.init.js index 811a73f29..830ddd66c 100644 --- a/src/Resources/app/administration/src/init/api-service.init.js +++ b/src/Resources/app/administration/src/init/api-service.init.js @@ -5,11 +5,13 @@ import MolliePaymentsRefundService from '../core/service/api/mollie-payments-ref import MolliePaymentsShippingService from '../core/service/api/mollie-payments-shipping.service'; import MolliePaymentsSupportService from '../core/service/api/mollie-payments-support.service'; import MolliePaymentsSubscriptionService from '../core/service/api/mollie-subscription.service'; +import MolliePaymentsItemCancelService from '../core/service/api/mollie-payments-item-cancel.service'; import '../module/mollie-payments/rules/mollie-lineitem-subscription-rule'; import '../module/mollie-payments/rules/mollie-cart-subscription-rule'; + // eslint-disable-next-line no-undef const {Application} = Shopware; @@ -59,6 +61,12 @@ Application.addServiceProvider('MolliePaymentsSubscriptionService', (container) return new MolliePaymentsSubscriptionService(initContainer.httpClient, container.loginService); }); +Application.addServiceProvider('MolliePaymentsItemCancelService', (container) => { + const initContainer = Application.getContainer('init'); + + return new MolliePaymentsItemCancelService(initContainer.httpClient, container.loginService); +}); + Application.addServiceProviderDecorator('ruleConditionDataProviderService', (ruleConditionService) => { ruleConditionService.addCondition('mollie_lineitem_subscription_rule', { diff --git a/src/Resources/app/administration/src/module/mollie-payments/components/mollie-cancel-item/index.js b/src/Resources/app/administration/src/module/mollie-payments/components/mollie-cancel-item/index.js new file mode 100644 index 000000000..f00dec861 --- /dev/null +++ b/src/Resources/app/administration/src/module/mollie-payments/components/mollie-cancel-item/index.js @@ -0,0 +1,66 @@ +import template from './mollie-cancel-item.html.twig'; + +// eslint-disable-next-line no-undef +const {Component, Mixin} = Shopware; + +Component.register('mollie-cancel-item', { + template, + props: { + item: { + type: Object, + required: true, + }, + }, + data() { + return { + cancelableQuantity: 0, + canceledQuantity: 1, + resetStock: false, + isLoading: false, + }; + }, + mixins: [ + Mixin.getByName('notification'), + ], + + inject: [ + 'MolliePaymentsItemCancelService', + ], + + methods: { + submit() { + this.isLoading = true; + + this.MolliePaymentsItemCancelService.cancel({ + mollieOrderId: this.item.mollieOrderId, + mollieLineId: this.item.mollieId, + shopwareLineId: this.item.shopwareItemId, + canceledQuantity: this.canceledQuantity, + resetStock: this.resetStock, + }).then((response) => { + this.isLoading = false; + + if(response.success){ + this.createNotificationSuccess({ + message: this.$tc('mollie-payments.modals.cancel.item.success'), + }); + }else{ + this.createNotificationError({ + message: this.$tc('mollie-payments.modals.cancel.item.failed.'+response.message), + }); + } + this.$emit('update-cancel-status'); + this.$emit('close'); + }).catch(error => { + this.isLoading = false; + this.createNotificationError({ + message: error.response.data.message, + }); + this.$emit('close'); + }) + }, + close() { + this.$emit('close'); + }, + }, +}) \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/mollie-payments/components/mollie-cancel-item/mollie-cancel-item.html.twig b/src/Resources/app/administration/src/module/mollie-payments/components/mollie-cancel-item/mollie-cancel-item.html.twig new file mode 100644 index 000000000..a1be1a597 --- /dev/null +++ b/src/Resources/app/administration/src/module/mollie-payments/components/mollie-cancel-item/mollie-cancel-item.html.twig @@ -0,0 +1,56 @@ + + + +
+ + +
+
+
+ +
+ {{ item.label }} +
+
+
+
+ + {{ item.label }} + +
+
+
+ + + + +
\ 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/index.js b/src/Resources/app/administration/src/module/mollie-payments/extension/sw-order/component/sw-order-line-items-grid/index.js index 73021408e..b25d5d101 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 @@ -20,6 +20,7 @@ Component.override('sw-order-line-items-grid', { inject: [ 'MolliePaymentsConfigService', 'MolliePaymentsShippingService', + 'MolliePaymentsItemCancelService', 'acl', ], props: { @@ -47,7 +48,9 @@ Component.override('sw-order-line-items-grid', { isShipItemLoading: false, shipQuantity: 0, showShipItemModal: null, + cancelItemModal: null, shippingStatus: null, + cancelStatus:null, tracking: { carrier: '', code: '', @@ -79,6 +82,16 @@ Component.override('sw-order-line-items-grid', { } ); + columnDefinitions.push( + { + property: 'canceledQuantity', + label: this.$tc('sw-order.detailExtended.columnCanceled'), + allowResize: false, + align: 'right', + inlineEdit: false, + width: '100px', + } + ); return columnDefinitions; }, @@ -91,6 +104,12 @@ Component.override('sw-order-line-items-grid', { return attr.isMollieOrder(); }, + mollieId(){ + const attr = new OrderAttributes(this.order); + + return attr.getMollieID(); + }, + /** * * @returns {number} @@ -151,6 +170,7 @@ Component.override('sw-order-line-items-grid', { this.isRefundManagerPossible = this.refundedManagerService.isRefundManagerAvailable(this.order.salesChannelId); await this.loadMollieShippingStatus(); + await this.loadMollieCancelStatus(); }, // ==============================================================================================// @@ -199,6 +219,13 @@ Component.override('sw-order-line-items-grid', { this.updateTrackingPrefilling(); }, + onOpenCancelItemModal(item){ + this.cancelItemModal = item.id; + }, + closeCancelItemModal(){ + this.cancelItemModal = null; + }, + /** * */ @@ -225,6 +252,14 @@ Component.override('sw-order-line-items-grid', { }); }, + async loadMollieCancelStatus(){ + + await this.MolliePaymentsItemCancelService + .status({mollieOrderId: this.mollieId}) + .then((response)=>{ + this.cancelStatus = response + }) + }, /** * * @param item @@ -308,7 +343,32 @@ Component.override('sw-order-line-items-grid', { return itemShippingStatus.quantityShipped; }, + canceledQuantity(item){ + const itemStatus = this.cancelStatus[item.id]; + if(itemStatus === undefined || itemStatus === null){ + return '~'; + } + return itemStatus.quantityCanceled; + }, + isCancelable(item){ + const itemStatus = this.cancelStatus[item.id]; + if(itemStatus === undefined || itemStatus === null){ + return false; + } + + return itemStatus.isCancelable; + }, + getCancelData(item){ + const itemStatus = this.cancelStatus[item.id]; + if(itemStatus === undefined){ + return {}; + } + itemStatus.shopwareItemId = item.id; + itemStatus.label = item.label; + itemStatus.payload = item.payload; + return itemStatus; + }, //==== Tracking =============================================================================================// updateTrackingPrefilling() { 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 ea44f2051..ea88e06bf 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 @@ -115,6 +115,9 @@ SHOPWARE 6.4 + {% endblock %} @@ -198,6 +201,11 @@ SHOPWARE 6.4 {% endblock %} + {% block sw_order_line_items_grid_grid_mollie_cancel_item_modal %} + + + + {% endblock %} {% endblock %} {% block sw_order_line_items_grid_grid_actions_show %} @@ -206,4 +214,9 @@ SHOPWARE 6.4 @click="onOpenShipItemModal(item)"> {{ $tc('mollie-payments.general.shipThroughMollie') }} + + + {{ $tc('mollie-payments.general.cancelMollieItem') }} + {% endblock %} diff --git a/src/Resources/app/administration/src/module/mollie-payments/index.js b/src/Resources/app/administration/src/module/mollie-payments/index.js index 300bfb80d..e1541f66e 100644 --- a/src/Resources/app/administration/src/module/mollie-payments/index.js +++ b/src/Resources/app/administration/src/module/mollie-payments/index.js @@ -15,6 +15,7 @@ import './components/mollie-refund-manager'; import './components/mollie-external-link'; import './components/mollie-internal-link'; import './components/mollie-ship-order'; +import './components/mollie-cancel-item'; import './page/mollie-subscriptions-list'; import './page/mollie-subscriptions-detail'; diff --git a/src/Resources/app/administration/src/snippet/de-DE.json b/src/Resources/app/administration/src/snippet/de-DE.json index 6c9bad760..f6acf5b8b 100644 --- a/src/Resources/app/administration/src/snippet/de-DE.json +++ b/src/Resources/app/administration/src/snippet/de-DE.json @@ -37,7 +37,8 @@ "descriptionTextModule": "Mollie Payments Zahlungen", "btnMollieActions": "Mollie Aktionen", "refundThroughMollie": "Rückerstattung über Mollie", - "shipThroughMollie": "Versand melden" + "shipThroughMollie": "Versand melden", + "cancelMollieItem": "Abbrechen über Mollie" }, "config": { "info": { @@ -280,6 +281,20 @@ "cancelButton": "Abbrechen", "selectAllButton": "Alle auswählen", "resetButton": "Zurücksetzen" + }, + "cancel": { + "title": "Produkt in Mollie abbrechen", + "confirmButton": "Produkt in Mollie abbrechen", + "cancelButton": "Schließen", + "resetStock": "Bestand zurücksetzen", + "item": { + "success": "Produkt erfolgreich abgebrochen", + "failed": { + "quantityZero": "Menge ist 0", + "invalidLine": "Produkt exestiert nicht in der Bestellung", + "quantityTooHigh": "Menge ist zu hoch" + } + } } }, "sw-flow": { @@ -420,6 +435,7 @@ "buttonMolliePaymentLink": "In die Zwischenablage kopieren", "columnRefunded": "Rückerstattet", "columnShipped": "Versandt", + "columnCanceled": "Abgebrochen", "labelMollieOrderId": "Mollie Bestell ID", "labelMollieThirdPartyPaymentId": "Zahlungsreferenz", "labelMolliePaymentLink": "Mollie Checkout URL", diff --git a/src/Resources/app/administration/src/snippet/en-GB.json b/src/Resources/app/administration/src/snippet/en-GB.json index bd12744cf..8ea509084 100644 --- a/src/Resources/app/administration/src/snippet/en-GB.json +++ b/src/Resources/app/administration/src/snippet/en-GB.json @@ -37,7 +37,8 @@ "descriptionTextModule": "Mollie Payments", "btnMollieActions": "Mollie Actions", "refundThroughMollie": "Refund through Mollie", - "shipThroughMollie": "Ship through Mollie" + "shipThroughMollie": "Ship through Mollie", + "cancelMollieItem": "Cancel through Mollie" }, "config": { "info": { @@ -280,6 +281,20 @@ "cancelButton": "Cancel", "selectAllButton": "Select all", "resetButton": "Reset" + }, + "cancel": { + "title": "Cancel item through mollie", + "confirmButton": "Cancel item through mollie", + "cancelButton": "Close modal", + "resetStock": "Reset stocks", + "item": { + "success": "Product canceled successfully", + "failed": { + "quantityZero": "Quantity is 0", + "invalidLine": "Product does not exist in the order", + "quantityTooHigh": "Quantity is too high" + } + } } }, "sw-flow": { @@ -420,6 +435,7 @@ "buttonMolliePaymentLink": "Copy to Clipboard", "columnRefunded": "Refunded", "columnShipped": "Shipped", + "columnCanceled": "Canceled", "labelMollieOrderId": "Mollie Order ID", "labelMollieThirdPartyPaymentId": "Payment Reference", "labelMolliePaymentLink": "Mollie Checkout URL", diff --git a/src/Resources/app/administration/src/snippet/nl-NL.json b/src/Resources/app/administration/src/snippet/nl-NL.json index ea1b1c83c..45055d631 100644 --- a/src/Resources/app/administration/src/snippet/nl-NL.json +++ b/src/Resources/app/administration/src/snippet/nl-NL.json @@ -37,7 +37,8 @@ "descriptionTextModule": "Mollie betalingen", "btnMollieActions": "Mollie acties", "refundThroughMollie": "Terugbetaling via Mollie", - "shipThroughMollie": "Verzending door Mollie" + "shipThroughMollie": "Verzending door Mollie", + "cancelMollieItem": "Opzeggen via Mollie" }, "config": { "info": { @@ -280,6 +281,20 @@ "cancelButton": "Annuleren", "selectAllButton": "Selecteer alles", "resetButton": "Resetten" + }, + "cancel": { + "title": "Product in Mollie afkorting", + "confirmButton": "Product in Mollie afkorting", + "cancelButton": "Annuleren", + "resetStock": "Voorraad resetten", + "item": { + "success": "Product geannuleerd", + "failed": { + "quantityZero": "Aantal is 0", + "invalidLine": "Product bestaat niet in de bestelling", + "quantityTooHigh": "Het aantal is te hoog" + } + } } }, @@ -421,6 +436,7 @@ "buttonMolliePaymentLink": "Kopieer naar Klembord", "columnRefunded": "Terugbetaald", "columnShipped": "Verzonden", + "columnCanceled": "Geannuleerd", "labelMollieOrderId": "Mollie Order ID", "labelMollieThirdPartyPaymentId": "Betalingskenmerk", "labelMolliePaymentLink": "Mollie Checkout URL", diff --git a/src/Resources/config/routes/admin-api/cancel-items.xml b/src/Resources/config/routes/admin-api/cancel-items.xml new file mode 100644 index 000000000..6b4b752cc --- /dev/null +++ b/src/Resources/config/routes/admin-api/cancel-items.xml @@ -0,0 +1,20 @@ + + + + + Kiener\MolliePayments\Controller\Api\Order\CancelLineController::statusAction + api + true + true + + + + Kiener\MolliePayments\Controller\Api\Order\CancelLineController::cancelAction + api + true + true + + diff --git a/src/Resources/config/services/components.xml b/src/Resources/config/services/components.xml index 9e7808e22..2d20b69df 100644 --- a/src/Resources/config/services/components.xml +++ b/src/Resources/config/services/components.xml @@ -58,5 +58,13 @@ + + + + + + + + diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml index 078c98d5d..169f1e679 100644 --- a/src/Resources/config/services/controller.xml +++ b/src/Resources/config/services/controller.xml @@ -101,6 +101,16 @@ + + + + + + + + + + diff --git a/src/Service/Stock/StockManager.php b/src/Service/Stock/StockManager.php index 76e9c2852..3564c8689 100644 --- a/src/Service/Stock/StockManager.php +++ b/src/Service/Stock/StockManager.php @@ -51,11 +51,10 @@ public function __construct(Connection $connection, ContainerInterface $containe /** * @param OrderLineItemEntity $lineItem * @param int $quantity - * @param string $mollieRefundID * @throws \Doctrine\DBAL\Exception * @return void */ - public function increaseStock(OrderLineItemEntity $lineItem, int $quantity, string $mollieRefundID): void + public function increaseStock(OrderLineItemEntity $lineItem, int $quantity): void { if ($this->isEnabled() === false) { return; @@ -74,13 +73,13 @@ public function increaseStock(OrderLineItemEntity $lineItem, int $quantity, stri $productID = (string)$lineItem->getReferencedId(); $update = $this->connection->prepare( - 'UPDATE product SET available_stock = available_stock + :refundQuantity, sales = sales - :refundQuantity, updated_at = :now WHERE id = :id' + 'UPDATE product SET available_stock = available_stock + :quantity, sales = sales - :quantity, updated_at = :now WHERE id = :id' ); $update->execute( [ 'id' => Uuid::fromHexToBytes($productID), - 'refundQuantity' => $quantity, + 'quantity' => $quantity, 'now' => (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT), ] ); diff --git a/tests/Cypress/cypress/e2e/storefront/cancel/cancel-item.cy.js b/tests/Cypress/cypress/e2e/storefront/cancel/cancel-item.cy.js new file mode 100644 index 000000000..36a61e6c1 --- /dev/null +++ b/tests/Cypress/cypress/e2e/storefront/cancel/cancel-item.cy.js @@ -0,0 +1,94 @@ +import DummyBasketScenario from 'Scenarios/DummyBasketScenario'; +import PaymentAction from "Actions/storefront/checkout/PaymentAction"; +import PaymentScreenAction from "cypress-mollie/src/actions/screens/PaymentStatusScreen"; +import CheckoutAction from "Actions/storefront/checkout/CheckoutAction"; +import MollieSandbox from "cypress-mollie/src/actions/MollieSandbox"; +import AdminOrdersAction from "Actions/admin/AdminOrdersAction"; +import AdminLoginAction from "Actions/admin/AdminLoginAction"; +import Shopware from "Services/shopware/Shopware"; +import Devices from "Services/utils/Devices"; +import ShopConfigurationAction from "Actions/admin/ShopConfigurationAction"; +import OrderDetailsRepository from "Repositories/admin/orders/OrderDetailsRepository"; +import CancelItemRepository from "Repositories/admin/cancel-item/CancelItemRepository"; +import Session from "Services/utils/Session"; + + +const devices = new Devices(); +const shopware = new Shopware(); +const configAction = new ShopConfigurationAction(); + +const checkout = new CheckoutAction(); +const paymentAction = new PaymentAction(); +const mollieSandbox = new MollieSandbox(); +const molliePayment = new PaymentScreenAction(); +const adminOrders = new AdminOrdersAction(); +const adminLogin = new AdminLoginAction(); +const scenarioDummyBasket = new DummyBasketScenario(2); +const orderDetailsRepository = new OrderDetailsRepository(); +const cancelItemRepository = new CancelItemRepository(); +const device = devices.getFirstDevice(); +const session = new Session(); + + +context("Cancel Authorized items", () => { + before(function () { + configAction.setupShop(false, false, false); + configAction.updateProducts('', false, 0, ''); + }) + + beforeEach(() => { + session.resetBrowserSession(); + devices.setDevice(device); + }); + + context(devices.getDescription(device), () => { + it('C3259233: Cancel items from order', () => { + createOrderAndOpenAdmin('Pay now'); + + + orderDetailsRepository.getLineItemActionsButton(1).should('be.visible').click({force: true}); + + orderDetailsRepository.getLineItemActionsButtonCancelThroughMollie().should('not.have.class', 'is--disabled'); + orderDetailsRepository.getLineItemActionsButtonCancelThroughMollie().click({force: true}); + cancelItemRepository.getQuantityInput().clear().type(2); + cancelItemRepository.getResetStockToggle().click({force:true}); + cancelItemRepository.getItemLabel().should('not.be.empty'); + cancelItemRepository.getConfirmButton().click({force: true}); + orderDetailsRepository.getLineItemCancelled().should('contain.text', 2); + orderDetailsRepository.getLineItemActionsButton(1).click({force: true}); + orderDetailsRepository.getLineItemActionsButtonCancelThroughMollie().should('have.class', 'is--disabled'); + + }); + + it('C3259299: Check cancel button on non authorized order', () => { + createOrderAndOpenAdmin('PayPal'); + + + orderDetailsRepository.getLineItemActionsButton(1).should('be.visible').click({force: true}); + + orderDetailsRepository.getLineItemActionsButtonCancelThroughMollie().should('have.class', 'is--disabled'); + }); + }); +}); + + +function createOrderAndOpenAdmin(paymentMethod) { + scenarioDummyBasket.execute(); + paymentAction.switchPaymentMethod(paymentMethod); + + shopware.prepareDomainChange(); + checkout.placeOrderOnConfirm(); + + mollieSandbox.initSandboxCookie(); + + if (paymentMethod === 'PayPal') { + molliePayment.selectPaid(); + } else { + molliePayment.selectAuthorized(); + } + + + adminLogin.login(); + adminOrders.openOrders(); + adminOrders.openLastOrder(); +} diff --git a/tests/Cypress/cypress/support/repositories/admin/cancel-item/CancelItemRepository.js b/tests/Cypress/cypress/support/repositories/admin/cancel-item/CancelItemRepository.js new file mode 100644 index 000000000..c500911ce --- /dev/null +++ b/tests/Cypress/cypress/support/repositories/admin/cancel-item/CancelItemRepository.js @@ -0,0 +1,35 @@ + + +export default class CancelItemRepository { + /** + * + * @returns {Cypress.Chainable>} + */ + getQuantityInput(){ + return cy.get('.cy-cancel-item-quantity input'); + } + + /** + * + * @returns {Cypress.Chainable>} + */ + getItemLabel(){ + return cy.get('.cy-cancel-item-label'); + } + + /** + * + * @returns {Cypress.Chainable>} + */ + getResetStockToggle(){ + return cy.get('.cy-cancel-item-stock input') + } + + /** + * + * @returns {Cypress.Chainable>} + */ + getConfirmButton(){ + return cy.get('.cy-cancel-item-confirm .sw-button__content'); + } +} \ No newline at end of file diff --git a/tests/Cypress/cypress/support/repositories/admin/orders/OrderDetailsRepository.js b/tests/Cypress/cypress/support/repositories/admin/orders/OrderDetailsRepository.js index d35cca671..ce9544a54 100644 --- a/tests/Cypress/cypress/support/repositories/admin/orders/OrderDetailsRepository.js +++ b/tests/Cypress/cypress/support/repositories/admin/orders/OrderDetailsRepository.js @@ -31,6 +31,17 @@ export default class OrderDetailsRepository { getMollieActionButtonShipThroughMollie() { return cy.get('.sw-order-line-items-grid__actions-ship-button'); } + /** + * + * @returns {Cypress.Chainable>} + */ + getLineItemActionsButtonCancelThroughMollie() { + return cy.get('.sw-context-button__menu-popover', {timeout: 15000}).contains('Cancel at Mollie'); + } + + getLineItemCancelled(){ + return cy.get('.sw-data-grid__cell--canceledQuantity'); + } /** * diff --git a/tests/PHPUnit/Components/CancelManager/CancelItemFacadeTest.php b/tests/PHPUnit/Components/CancelManager/CancelItemFacadeTest.php new file mode 100644 index 000000000..0d522c5f8 --- /dev/null +++ b/tests/PHPUnit/Components/CancelManager/CancelItemFacadeTest.php @@ -0,0 +1,126 @@ +cancelManagerBuilder = new CancelItemFacadeBuilder($this); + } + + public function testItemQuantityIsZero(): void + { + $cancelManager = $this->cancelManagerBuilder->bild(); + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('test', 'test', 'lineId', 0, false, $context); + + $this->assertFalse($response->isSuccessful()); + $this->assertSame('quantityZero', $response->getMessage()); + } + + public function testLineItemNotExistsInOrder(): void + { + $cancelManagerBuilder = $this->cancelManagerBuilder->withDefaultOrder(); + $cancelManager = $cancelManagerBuilder->bild(); + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('test', 'invalid', 'lineId', 1, false, $context); + + $this->assertFalse($response->isSuccessful()); + $this->assertSame('invalidLine', $response->getMessage()); + } + + public function testQuantityTooHigh(): void + { + $cancelManagerBuilder = $this->cancelManagerBuilder->withDefaultOrder(); + $cancelManager = $cancelManagerBuilder->bild(); + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('test', 'valid', 'lineId', 100, false, $context); + + $this->assertFalse($response->isSuccessful()); + $this->assertSame('quantityTooHigh', $response->getMessage()); + } + + public function testApiExceptionInMessage(): void + { + $cancelManagerBuilder = $this->cancelManagerBuilder->withInvalidOrder(); + $cancelManager = $cancelManagerBuilder->bild(); + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('', 'valid', 'lineId', 100, false, $context); + + $this->assertFalse($response->isSuccessful()); + $this->assertStringContainsString('Invalid order', $response->getMessage()); + } + + public function testCancelSuccessful(): void + { + $cancelManagerBuilder = $this->cancelManagerBuilder->withDefaultOrder(); + $cancelManager = $cancelManagerBuilder->bild(); + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('test', 'valid', 'lineId', 1, false, $context); + + $expectedData = [ + 'id' => 'valid', + 'quantity' => 1 + ]; + + $this->assertTrue($response->isSuccessful()); + $this->assertSame($expectedData, $response->getData()); + } + + public function testOrderLineItemNotFound(): void + { + $cancelManagerBuilder = $this->cancelManagerBuilder->withDefaultOrder(); + + $cancelManager = $cancelManagerBuilder->bild(); + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('test', 'valid', 'invalidLineId', 1, true, $context); + + + $this->assertFalse($response->isSuccessful()); + $this->assertSame('invalidShopwareLineId', $response->getMessage()); + } + + public function testOrderLineStockResetSuccessful(): void + { + $cancelManagerBuilder = $this->cancelManagerBuilder->withDefaultOrder(); + $cancelManagerBuilder = $cancelManagerBuilder->withValidOrderLine(); + + $cancelManager = $cancelManagerBuilder->bild(); + $stockManager = $cancelManagerBuilder->getStockManager(); + + $context = Context::createDefaultContext(); + + $response = $cancelManager->cancelItem('test', 'valid', 'lineId', 1, true, $context); + + $expectedData = [ + 'id' => 'valid', + 'quantity' => 1 + ]; + + $this->assertTrue($response->isSuccessful()); + $this->assertSame($expectedData, $response->getData()); + $this->assertTrue($stockManager->isCalled()); + $this->assertSame($expectedData['quantity'], (int)$stockManager->getQuantity()); + } +} \ No newline at end of file diff --git a/tests/PHPUnit/Components/RefundManager/RefundManagerTest.php b/tests/PHPUnit/Components/RefundManager/RefundManagerTest.php index 0ffa10ab4..08cf3e38c 100644 --- a/tests/PHPUnit/Components/RefundManager/RefundManagerTest.php +++ b/tests/PHPUnit/Components/RefundManager/RefundManagerTest.php @@ -177,7 +177,6 @@ public function testStockReset() $this->assertEquals('Product T-Shirt', $this->fakeStockUpdater->getLineItemLabel()); $this->assertEquals('product-id-1', $this->fakeStockUpdater->getProductID()); $this->assertEquals(1, $this->fakeStockUpdater->getQuantity()); - $this->assertEquals('r-xyz-123', $this->fakeStockUpdater->getMollieRefundID()); } /** diff --git a/tests/PHPUnit/Fakes/CancelItemFacadeBuilder.php b/tests/PHPUnit/Fakes/CancelItemFacadeBuilder.php new file mode 100644 index 000000000..be0eac762 --- /dev/null +++ b/tests/PHPUnit/Fakes/CancelItemFacadeBuilder.php @@ -0,0 +1,110 @@ +testCase = $testCase; + + $this->mollieClient = $testCase->getMockBuilder(MollieApiClient::class)->disableOriginalConstructor()->getMock(); + $this->itemCollection = new OrderLineItemCollection(); + $this->stockManager = new FakeStockManager(); + } + + public function withInvalidOrder(): self + { + + $mockOrderEndpoint = $this->testCase->getMockBuilder(OrderEndpoint::class)->disableOriginalConstructor()->getMock(); + $mockOrderEndpoint->method('get')->willThrowException(new ApiException('Invalid order')); + + $this->mollieClient->orders = $mockOrderEndpoint; + + return $this; + } + + public function withDefaultOrder(): self + { + $mockOrderLine = $this->testCase->getMockBuilder(OrderLine::class)->disableOriginalConstructor()->getMock(); + $mockOrderLine->cancelableQuantity = 2; + $mockOrderLine->id = 'valid'; + + $oderLineCollection = new OrderLineCollection(1, null); + $oderLineCollection[0] = $mockOrderLine; + + $mockOrder = $this->testCase->getMockBuilder(Order::class)->disableOriginalConstructor()->getMock(); + $mockOrder->method('lines')->willReturn($oderLineCollection); + + + $mockOrderEndpoint = $this->testCase->getMockBuilder(OrderEndpoint::class)->disableOriginalConstructor()->getMock(); + $mockOrderEndpoint->method('get')->willReturn($mockOrder); + + $this->mollieClient->orders = $mockOrderEndpoint; + + return $this; + } + + public function withValidOrderLine(): self + { + $fakeShopwareOrderLine = new OrderLineItemEntity(); + $fakeShopwareOrderLine->setId('validLineId'); + $fakeShopwareOrderLine->setLabel('Valid orderline'); + $this->itemCollection->add($fakeShopwareOrderLine); + + return $this; + } + + public function getStockManager(): FakeStockManager + { + return $this->stockManager; + } + + + + + public function bild(): CancelItemFacade + { + /** @var MollieApiFactory $mollieFactory */ + $mollieFactory = $this->testCase->getMockBuilder(MollieApiFactory::class)->disableOriginalConstructor()->getMock(); + $mollieFactory->method('getClient')->willReturn($this->mollieClient); + + $orderLineRepository = new FakeOrderLineItemRepository($this->itemCollection); + + return new CancelItemFacade($mollieFactory, $orderLineRepository, $this->stockManager, new NullLogger()); + } + +} \ No newline at end of file diff --git a/tests/PHPUnit/Fakes/Repositories/FakeOrderLineItemRepository.php b/tests/PHPUnit/Fakes/Repositories/FakeOrderLineItemRepository.php new file mode 100644 index 000000000..cd81009bb --- /dev/null +++ b/tests/PHPUnit/Fakes/Repositories/FakeOrderLineItemRepository.php @@ -0,0 +1,41 @@ +collection = $collection; + } + public function update(array $data, Context $context): EntityWrittenContainerEvent + { + // TODO: Implement update() method. + } + + public function search(Criteria $criteria, Context $context): EntitySearchResult + { + + return new EntitySearchResult( + OrderLineItemEntity::class, + $this->collection->count(), + $this->collection, + null, + $criteria, + $context + ); + } + +} \ No newline at end of file diff --git a/tests/PHPUnit/Fakes/StockUpdater/FakeStockManager.php b/tests/PHPUnit/Fakes/StockUpdater/FakeStockManager.php index fd3d150fe..4cdcd0711 100644 --- a/tests/PHPUnit/Fakes/StockUpdater/FakeStockManager.php +++ b/tests/PHPUnit/Fakes/StockUpdater/FakeStockManager.php @@ -88,13 +88,12 @@ public function getMollieRefundID(): string * @param string $mollieRefundID * @return void */ - public function increaseStock(OrderLineItemEntity $lineItem, int $quantity, string $mollieRefundID): void + public function increaseStock(OrderLineItemEntity $lineItem, int $quantity): void { $this->called = true; $this->lineItemLabel = $lineItem->getLabel(); $this->productID = $lineItem->getReferencedId(); $this->quantity = $quantity; - $this->mollieRefundID = $mollieRefundID; } }