diff --git a/Config.php b/Config.php index 843e3300fd3..cab021bd7bd 100644 --- a/Config.php +++ b/Config.php @@ -11,12 +11,14 @@ class Config { + const GENERAL_INVOICE_NOTIFY = 'payment/mollie_general/invoice_notify'; const XML_PATH_STATUS_PENDING_BANKTRANSFER = 'payment/mollie_methods_banktransfer/order_status_pending'; const XML_PATH_STATUS_NEW_PAYMENT_LINK = 'payment/mollie_methods_paymentlink/order_status_new'; const PAYMENT_KLARNAPAYLATER_PAYMENT_SURCHARGE = 'payment/mollie_methods_klarnapaylater/payment_surcharge'; const PAYMENT_KLARNAPAYLATER_PAYMENT_SURCHARGE_TAX_CLASS = 'payment/mollie_methods_klarnapaylater/payment_surcharge_tax_class'; const PAYMENT_KLARNASLICEIT_PAYMENT_SURCHARGE = 'payment/mollie_methods_klarnasliceit/payment_surcharge'; const PAYMENT_KLARNASLICEIT_PAYMENT_SURCHARGE_TAX_CLASS = 'payment/mollie_methods_klarnasliceit/payment_surcharge_tax_class'; + const PAYMENT_PAYMENTLINK_ALLOW_MARK_AS_PAID = 'payment/mollie_methods_paymentlink/allow_mark_as_paid'; /** * @var ScopeConfigInterface @@ -39,6 +41,25 @@ private function getPath($path, $storeId) return $this->config->getValue($path, ScopeInterface::SCOPE_STORE, $storeId); } + /** + * @param $path + * @param $storeId + * @return bool + */ + private function getFlag($path, $storeId) + { + return $this->config->isSetFlag($path, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param null $storeId + * @return bool + */ + public function sendInvoiceEmail($storeId = null) + { + return $this->getFlag(static::GENERAL_INVOICE_NOTIFY, $storeId); + } + public function statusPendingBanktransfer($storeId = null) { return $this->config->getValue( @@ -92,4 +113,13 @@ public function klarnaSliceitPaymentSurchargeTaxClass($storeId = null) { return $this->getPath(static::PAYMENT_KLARNASLICEIT_PAYMENT_SURCHARGE_TAX_CLASS, $storeId); } + + /** + * @param null $storeId + * @return bool + */ + public function paymentlinkAllowMarkAsPaid($storeId = null) + { + return $this->getFlag(static::PAYMENT_PAYMENTLINK_ALLOW_MARK_AS_PAID, $storeId); + } } diff --git a/Controller/Adminhtml/Action/MarkAsPaid.php b/Controller/Adminhtml/Action/MarkAsPaid.php new file mode 100644 index 00000000000..5a57316fd3e --- /dev/null +++ b/Controller/Adminhtml/Action/MarkAsPaid.php @@ -0,0 +1,201 @@ +config = $config; + $this->orderRepository = $orderRepository; + $this->orderCreate = $orderCreate; + $this->quoteSession = $quoteSession; + $this->invoiceService = $invoiceService; + $this->invoiceSender = $invoiceSender; + $this->transactionFactory = $transactionFactory; + $this->orderCommentHistory = $orderCommentHistory; + } + + /** + * This controller recreates the selected order with the checkmo payment method and marks it as completed. The + * original order is then canceled. + * + * {@inheritDoc} + */ + public function execute() + { + $orderId = $this->getRequest()->getParam('order_id'); + + $originalOrder = $this->orderRepository->get($orderId); + $originalOrder->setReordered(true); + + $resultRedirect = $this->resultRedirectFactory->create(); + try { + $this->transaction = $this->transactionFactory->create(); + + $order = $this->recreateOrder($originalOrder); + $invoice = $this->createInvoiceFor($order); + $this->cancelOriginalOrder($originalOrder); + $this->transaction->save(); + + $this->addCommentHistoryOriginalOrder($originalOrder, $order->getIncrementId()); + $this->sendInvoice($invoice, $order); + + $this->messageManager->addSuccessMessage( + __( + 'We cancelled order %1, created this order and marked it as complete.', + $originalOrder->getIncrementId() + ) + ); + + return $resultRedirect->setPath('sales/order/view', ['order_id' => $order->getEntityId()]); + } catch (\Exception $exception) { + $this->messageManager->addExceptionMessage($exception); + + return $resultRedirect->setPath('sales/order/view', ['order_id' => $originalOrder->getEntityId()]); + } + } + + /** + * @param OrderInterface $originalOrder + * @return OrderInterface + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function recreateOrder(OrderInterface $originalOrder) + { + $this->quoteSession->setOrderId($originalOrder->getEntityId()); + $this->quoteSession->setUseOldShippingMethod(true); + $this->orderCreate->initFromOrder($originalOrder); + $this->orderCreate->setPaymentMethod('mollie_methods_reorder'); + + $order = $this->orderCreate->createOrder(); + $order->setState(Order::STATE_PROCESSING); + $order->setStatus(Order::STATE_PROCESSING); + + $this->transaction->addObject($order); + $this->transaction->addObject($originalOrder); + + return $order; + } + + /** + * @param OrderInterface $originalOrder + */ + private function cancelOriginalOrder(OrderInterface $originalOrder) + { + $originalOrder->cancel(); + } + + /** + * @param OrderInterface $originalOrder + * @param string $newIncrementId + */ + private function addCommentHistoryOriginalOrder(OrderInterface $originalOrder, $newIncrementId) + { + $comment = __('We created a new order with increment ID: %1', $newIncrementId); + $this->orderCommentHistory->add($originalOrder, $comment, false); + } + + private function createInvoiceFor(OrderInterface $order) + { + $invoice = $this->invoiceService->prepareInvoice($order); + $invoice->setRequestedCaptureCase(Invoice::CAPTURE_OFFLINE); + $invoice->register(); + + $this->transaction->addObject($invoice); + + return $invoice; + } + + private function sendInvoice(InvoiceInterface $invoice, OrderInterface $order) + { + /** @var Order\Invoice $invoice */ + if ($invoice->getEmailSent() || !$this->config->sendInvoiceEmail($invoice->getStoreId())) { + return; + } + + try { + $this->invoiceSender->send($invoice); + $message = __('Notified customer about invoice #%1', $invoice->getIncrementId()); + $this->orderCommentHistory->add($order, $message, true); + } catch (\Throwable $exception) { + $message = __('Unable to send the invoice: %1', $exception->getMessage()); + $this->orderCommentHistory->add($order, $message, false); + } + } +} diff --git a/Model/Methods/Reorder.php b/Model/Methods/Reorder.php new file mode 100644 index 00000000000..c2fb4cad392 --- /dev/null +++ b/Model/Methods/Reorder.php @@ -0,0 +1,112 @@ +request = $request; + + parent::__construct( + $context, + $registry, + $extensionFactory, + $customAttributeFactory, + $paymentData, + $scopeConfig, + $logger, + $resource, + $resourceCollection, + $data + ); + } + + /** + * @param string $paymentAction + * @param object $stateObject + * + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Mollie\Api\Exceptions\ApiException + */ + public function initialize($paymentAction, $stateObject) + { + /** @var \Magento\Sales\Model\Order\Payment $payment */ + $payment = $this->getInfoInstance(); + + /** @var \Magento\Sales\Model\Order $order */ + $order = $payment->getOrder(); + $order->setCanSendNewEmailFlag(false); + $order->save(); + } + + public function isAvailable(\Magento\Quote\Api\Data\CartInterface $quote = null) + { + return $this->request->getModuleName() == 'mollie' && $this->request->getActionName() == 'markAsPaid'; + } +} diff --git a/Model/OrderLines.php b/Model/OrderLines.php index 75fd942164f..e701c875aec 100644 --- a/Model/OrderLines.php +++ b/Model/OrderLines.php @@ -12,6 +12,7 @@ use Magento\Framework\Model\ResourceModel\AbstractResource; use Magento\Framework\Data\Collection\AbstractDb; use Magento\Framework\Model\AbstractModel; +use Magento\Sales\Api\Data\CreditmemoInterface; use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Item; use Mollie\Payment\Model\ResourceModel\OrderLines\Collection as OrderLinesCollection; @@ -416,12 +417,11 @@ public function getOrderLineByItemId($itemId) } /** - * @param Order\Creditmemo $creditmemo - * @param $addShipping - * + * @param CreditmemoInterface $creditmemo + * @param bool $addShipping * @return array */ - public function getCreditmemoOrderLines($creditmemo, $addShipping) + public function getCreditmemoOrderLines(CreditmemoInterface $creditmemo, $addShipping) { $orderLines = []; @@ -429,9 +429,23 @@ public function getCreditmemoOrderLines($creditmemo, $addShipping) foreach ($creditmemo->getAllItems() as $item) { $orderItemId = $item->getOrderItemId(); $lineId = $this->getOrderLineByItemId($orderItemId)->getLineId(); - if ($lineId) { - $orderLines[] = ['id' => $lineId, 'quantity' => round($item->getQty())]; + if (!$lineId) { + continue; } + + $line = [ + 'id' => $lineId, + 'quantity' => round($item->getQty()), + ]; + + if ($item->getBaseDiscountAmount()) { + $line['amount'] = $this->mollieHelper->getAmountArray( + $creditmemo->getBaseCurrencyCode(), + $item->getBaseRowTotalInclTax() - $item->getBaseDiscountAmount() + ); + } + + $orderLines[] = $line; } $orderId = $creditmemo->getOrderId(); diff --git a/Observer/OrderCancelAfter.php b/Observer/OrderCancelAfter.php index 157bffddbbd..dbb11b7559c 100644 --- a/Observer/OrderCancelAfter.php +++ b/Observer/OrderCancelAfter.php @@ -52,6 +52,13 @@ public function execute(Observer $observer) /** @var \Magento\Sales\Model\Order $order */ $order = $observer->getEvent()->getorder(); + /** + * When manually marking an order as paid we don't want to communicate to Mollie as it will throw an exception. + */ + if ($order->getReordered()) { + return; + } + if ($this->mollieHelper->isPaidUsingMollieOrdersApi($order)) { $this->mollieModel->cancelOrder($order); } diff --git a/Plugin/Sales/Block/Adminhtml/Order/View.php b/Plugin/Sales/Block/Adminhtml/Order/View.php new file mode 100644 index 00000000000..5eb64883ae9 --- /dev/null +++ b/Plugin/Sales/Block/Adminhtml/Order/View.php @@ -0,0 +1,82 @@ +config = $config; + $this->url = $url; + $this->orderRepository = $orderRepository; + $this->paymentHelper = $paymentHelper; + } + + public function beforeSetLayout(Subject $subject) + { + $order = $subject->getOrder(); + if (!$this->config->paymentlinkAllowMarkAsPaid($order->getStoreId())) { + return; + } + + $instance = $this->paymentHelper->getMethodInstance(Checkmo::PAYMENT_METHOD_CHECKMO_CODE); + + $isAvailable = !$instance->isAvailable(); + if (!$order->canCancel() || + $order->getPayment()->getMethod() != 'mollie_methods_paymentlink' || + !$instance || + $isAvailable + ) { + return; + } + + $message = __('Are you sure you want to do this? ' . + 'This will cancel the current order and create a new one that is marked as payed.'); + $url = $this->url->getUrl('mollie/action/markAsPaid/'); + + $subject->addButton( + 'mollie_payment_mark_as_payed', + [ + 'label' => __('Mark as paid'), + 'onclick' => 'confirmSetLocation(\'' . $message . '\', \'' . $url . '\', {data: {order_id: ' . $subject->getOrderId() . '}})', + ], + 0, + -10 + ); + } +} diff --git a/Test/Integration/Model/OrderLinesTest.php b/Test/Integration/Model/OrderLinesTest.php index dc2f55a4716..1f7d93e4859 100644 --- a/Test/Integration/Model/OrderLinesTest.php +++ b/Test/Integration/Model/OrderLinesTest.php @@ -3,6 +3,7 @@ namespace Mollie\Payment\Model; use Magento\Sales\Api\Data\CreditmemoInterface; +use Magento\Sales\Api\Data\CreditmemoItemInterface; use Mollie\Payment\Test\Integration\IntegrationTestCase; class OrderLinesTest extends IntegrationTestCase @@ -91,6 +92,37 @@ public function testGetCreditmemoOrderLinesIncludesTheStoreCredit() $this->assertEquals(1, $line['quantity']); } + public function testCreditmemoUsesTheDiscount() + { + /** @var OrderLines $orderLine */ + $orderLine = $this->objectManager->get(\Mollie\Payment\Model\OrderLinesFactory::class)->create(); + $orderLine->setItemId(999); + $orderLine->setLineId('ord_abc123'); + $orderLine->save(); + + /** @var CreditmemoItemInterface $creditmemoItem */ + $creditmemoItem = $this->objectManager->create(CreditmemoItemInterface::class); + $creditmemoItem->setBaseRowTotalInclTax(45); + $creditmemoItem->setBaseDiscountAmount(9); + $creditmemoItem->setQty(1); + $creditmemoItem->setOrderItemId(999); + + /** @var CreditmemoInterface $creditmemo */ + $creditmemo = $this->objectManager->get(CreditmemoInterface::class); + $creditmemo->setOrderId(999); + $creditmemo->setItems([$creditmemoItem]); + + /** @var OrderLines $instance */ + $instance = $this->objectManager->get(OrderLines::class); + $result = $instance->getCreditmemoOrderLines($creditmemo, false); + + $this->assertCount(1, $result['lines']); + + $line = $result['lines'][0]; + $this->assertEquals('36', $line['amount']['value']); + $this->assertEquals(1, $line['quantity']); + } + private function rollbackCreditmemos() { $collection = $this->objectManager->get(\Mollie\Payment\Model\ResourceModel\OrderLines\Collection::class); diff --git a/Test/Integration/Plugin/Sales/Block/Adminhtml/Order/ViewTest.php b/Test/Integration/Plugin/Sales/Block/Adminhtml/Order/ViewTest.php new file mode 100644 index 00000000000..ddd97027e3d --- /dev/null +++ b/Test/Integration/Plugin/Sales/Block/Adminhtml/Order/ViewTest.php @@ -0,0 +1,106 @@ +createMock(Order::class); + + $subjectMock = $this->createMock(Subject::class); + $subjectMock->method('getOrder')->willReturn($orderMock); + + $subjectMock->expects($this->never())->method('addButton'); + + /** @var View $instance */ + $instance = $this->objectManager->create(View::class); + $instance->beforeSetLayout($subjectMock); + } + + /** + * @magentoConfigFixture current_store payment/mollie_methods_paymentlink/allow_mark_as_paid 1 + */ + public function testDoesNotShowsTheButtonWhenWeCantCancel() + { + $orderMock = $this->createMock(Order::class); + $orderMock->method('canCancel')->willReturn(false); + + $subjectMock = $this->createMock(Subject::class); + $subjectMock->method('getOrder')->willReturn($orderMock); + + $subjectMock->expects($this->never())->method('addButton'); + + /** @var View $instance */ + $instance = $this->objectManager->create(View::class); + $instance->beforeSetLayout($subjectMock); + } + + /** + * @magentoConfigFixture current_store payment/mollie_methods_paymentlink/allow_mark_as_paid 1 + * @magentoDataFixture Magento/Sales/_files/order.php + */ + public function testDoesNotShowsTheButtonWhenNotPaymentLink() + { + $order = $this->loadOrderById('100000001'); + $order->getPayment()->setMethod('checkmo'); + + $subjectMock = $this->createMock(Subject::class); + $subjectMock->method('getOrder')->willReturn($order); + + $subjectMock->expects($this->never())->method('addButton'); + + /** @var View $instance */ + $instance = $this->objectManager->create(View::class); + $instance->beforeSetLayout($subjectMock); + } + + /** + * @magentoDataFixture Magento/Sales/_files/order.php + * @magentoConfigFixture current_store payment/mollie_methods_paymentlink/allow_mark_as_paid 0 + */ + public function testDoesNotShowsTheButtonWhenMollieReorderIsNotAvailable() + { + $order = $this->loadOrderById('100000001'); + $order->getPayment()->setMethod('mollie_methods_paymentlink'); + + $subjectMock = $this->createMock(Subject::class); + $subjectMock->method('getOrder')->willReturn($order); + + $subjectMock->expects($this->never())->method('addButton'); + + /** @var View $instance */ + $instance = $this->objectManager->create(View::class); + $instance->beforeSetLayout($subjectMock); + } + + /** + * @magentoDataFixture Magento/Sales/_files/order.php + * @magentoConfigFixture default_store payment/mollie_methods_paymentlink/allow_mark_as_paid 1 + */ + public function testShowTheButtonWhenApplicable() + { + $order = $this->loadOrderById('100000001'); + $order->getPayment()->setMethod('mollie_methods_paymentlink'); + + $subjectMock = $this->createMock(Subject::class); + $subjectMock->method('getOrder')->willReturn($order); + + $subjectMock->expects($this->once())->method('addButton'); + + /** @var View $instance */ + $instance = $this->objectManager->create(View::class); + $instance->beforeSetLayout($subjectMock); + } +} diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml new file mode 100644 index 00000000000..f638006dbb0 --- /dev/null +++ b/etc/adminhtml/di.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/etc/adminhtml/methods/paymentlink.xml b/etc/adminhtml/methods/paymentlink.xml index e00dcc111e1..37ac8427e5f 100644 --- a/etc/adminhtml/methods/paymentlink.xml +++ b/etc/adminhtml/methods/paymentlink.xml @@ -27,7 +27,16 @@ 1 - + + Magento\Config\Model\Config\Source\Yesno + payment/mollie_methods_paymentlink/allow_mark_as_paid + + 1 + + + Magento\Config\Model\Config\Source\Yesno @@ -38,7 +47,7 @@ 1 - + Mollie\Payment\Model\Adminhtml\Source\NewStatus payment/mollie_methods_paymentlink/order_status_new @@ -46,7 +55,7 @@ 1 - Magento\Payment\Model\Config\Source\Allspecificcountries @@ -55,7 +64,7 @@ 1 - Magento\Directory\Model\Config\Source\Country @@ -65,7 +74,7 @@ 1 - payment/mollie_methods_paymentlink/min_order_total @@ -73,7 +82,7 @@ 1 - payment/mollie_methods_paymentlink/max_order_total @@ -81,7 +90,7 @@ 1 - validate-number @@ -90,7 +99,7 @@ 1 - validate-digits-range digits-range-1-100 diff --git a/etc/config.xml b/etc/config.xml index 54afbf0e8f7..20956f31719 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -172,6 +172,15 @@ here to pay for your purchase.]]> + + 1 + Mollie\Payment\Model\Methods\Reorder + Mollie: Reorder + {ordernumber} + order + order + 0 + 1 Mollie\Payment\Model\Methods\Giftcard diff --git a/view/frontend/web/js/view/checkout/save-payment-method.js b/view/frontend/web/js/view/checkout/save-payment-method.js index 2a495f3a58f..ef407c5c5a0 100644 --- a/view/frontend/web/js/view/checkout/save-payment-method.js +++ b/view/frontend/web/js/view/checkout/save-payment-method.js @@ -1,4 +1,5 @@ define([ + 'ko', 'uiComponent', 'mage/storage', 'Magento_Checkout/js/model/quote', @@ -6,6 +7,7 @@ define([ 'Magento_Checkout/js/model/totals', 'Magento_Checkout/js/action/get-totals' ], function ( + ko, Component, storage, quote, @@ -54,11 +56,26 @@ define([ var url = resourceUrlManager.getUrl(urls, params); payload.paymentMethod = { - method: method + method: method, + extension_attributes: {} }; payload.billingAddress = quote.billingAddress(); + /** + * Problem: We need to set the payment method, therefor we created this function. The api call requires + * that the agreements are all agreed by before doing any action. That's why we list all agreement ids + * and sent them with the request. In a later point in the checkout this will also be checked. + */ + var config = window.checkoutConfig.checkoutAgreements; + if (config.isEnabled) { + var ids = config.agreements.map( function (agreement) { + return agreement.agreementId; + }); + + payload.paymentMethod.extension_attributes.agreement_ids = ids; + } + totals.isLoading(true); storage.post( url,