diff --git a/Block/PartialVoid/Order/Email/Items.php b/Block/PartialVoid/Order/Email/Items.php new file mode 100644 index 00000000..3f24e6de --- /dev/null +++ b/Block/PartialVoid/Order/Email/Items.php @@ -0,0 +1,53 @@ +order) { + $this->order = clone $this->getData('order'); + $items = $this->order->getItems(); + foreach ($items as $key => $item) { + if (!$item->getQtyCanceled()) { + unset($items[$key]); + } + } + $this->order->setItems($items); + } + + return $this->order; + } +} diff --git a/Controller/Payment/ShippingDetails.php b/Controller/Payment/ShippingDetails.php index 08895d4e..468d5006 100644 --- a/Controller/Payment/ShippingDetails.php +++ b/Controller/Payment/ShippingDetails.php @@ -1,6 +1,6 @@ logger = $logger; $this->addressUpdater = $addressUpdater; $this->gdprCompliance = $compliance; + $this->shippingMethodValidator = $shippingMethodValidator; } /** @@ -147,12 +151,18 @@ public function execute() 'shippingDetails' => [] ]; foreach ($shippingMethods as $key => $shippingMethod) { + + $methodFullCode = $shippingMethod->getCarrierCode() . '_' . $shippingMethod->getMethodCode(); + if (!$this->shippingMethodValidator->isValid($methodFullCode)) { + continue; + } + $responseData['shippingDetails'][] = [ 'isDefault' => 'N', 'priority' => $key, 'shippingCost' => $shippingMethod->getAmount(), - 'shippingMethod' => $shippingMethod->getMethodCode(), - 'shippingMethodId' => $shippingMethod->getCarrierCode() . '_' . $shippingMethod->getMethodCode(), + 'shippingMethod' => $shippingMethod->getMethodTitle(), + 'shippingMethodId' => $methodFullCode, ]; } $result->setHttpResponseCode(ZendResponse::STATUS_CODE_200); diff --git a/Gateway/Command/CaptureCommand.php b/Gateway/Command/CaptureCommand.php index acb5bb62..b72be8ef 100644 --- a/Gateway/Command/CaptureCommand.php +++ b/Gateway/Command/CaptureCommand.php @@ -255,7 +255,7 @@ private function captureBasedOnPaymentDetails($commandSubject, Transaction $tran $message = __( 'Captured amount is not the same as you are trying to capture.' . PHP_EOL . ' Payment information was not synced correctly between Magento and Vipps.' - . PHP_EOL . ' It might be happened that previous operation was successfully completed in Vipps' + . PHP_EOL . ' It might be that the previous operation was successfully completed in Vipps' . PHP_EOL . ' but Magento did not receive a response.' . PHP_EOL . ' To be in sync you have to capture the same amount that has been already captured' . PHP_EOL . ' in Vipps: %1', diff --git a/Gateway/Command/RefundCommand.php b/Gateway/Command/RefundCommand.php index 797fc7d2..021bfc03 100644 --- a/Gateway/Command/RefundCommand.php +++ b/Gateway/Command/RefundCommand.php @@ -253,7 +253,7 @@ private function refundBasedOnPaymentDetails($commandSubject, Transaction $trans $message = __( 'Refunded amount is not the same as you are trying to refund.' . PHP_EOL . ' Payment information was not synced correctly between Magento and Vipps.' - . PHP_EOL . ' It might be happened that previous operation was successfully completed in Vipps' + . PHP_EOL . ' It might be that the previous operation was successfully completed in Vipps' . PHP_EOL . ' but Magento did not receive a response.' . PHP_EOL . ' To be in sync you have to refund the same amount that has been already refunded' . PHP_EOL . ' in Vipps: %1', diff --git a/Gateway/Config/CanVoidHandler.php b/Gateway/Config/CanVoidHandler.php new file mode 100644 index 00000000..e69180f3 --- /dev/null +++ b/Gateway/Config/CanVoidHandler.php @@ -0,0 +1,85 @@ +gatewayConfig = $gatewayConfig; + $this->paymentDetailsProvider = $paymentDetailsProvider; + $this->transactionBuilder = $transactionBuilder; + } + + /** + * Disable partial online void + * + * @param array $subject + * @param null $storeId + * + * @return bool + * @throws NoSuchEntityException + * @throws VippsException + */ + public function handle(array $subject, $storeId = null): bool + { + $response = $this->paymentDetailsProvider->get($subject); + $transaction = $this->transactionBuilder->setData($response)->build(); + if ($transaction->getTransactionSummary()->getCapturedAmount() > 0) { + return false; + } + + return (bool)$this->gatewayConfig->getValue(SubjectReader::readField($subject), $storeId); + } +} diff --git a/INSTALL.md b/INSTALL.md index e6333e87..ffbd8da2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,7 +7,7 @@ 1. You must have a Vipps merchant account. See [Vipps på Nett](https://www.vipps.no/bedrift/vipps-pa-nett) 1. As with _all_ Magento extensions, it is highly recommended to backup your site before installation and to install and test on a staging environment prior to production deployments. -# Installation via Composer (recommended) +# Installation via Composer 1. Navigate to your [Magento root directory](https://devdocs.magento.com/guides/v2.2/extension-dev-guide/build/module-file-structure.html). 1. Enter command: `composer require vipps/module-payment` @@ -15,20 +15,6 @@ 1. Enter command: `php bin/magento setup:upgrade` 1. Put your Magento in production mode if it’s required. -# Installation via Marketplace - -**Please note:** _This extension is not yet available on Magento Marketplace. This notice will be removed when it is._ - -Here are steps required to install Payments extension via Component Manager. - -1. Make a purchase for the Vipps extension on [Magento Marketplace](https://marketplace.magento.com). -1. From your `Magento Admin` access `System` -> `Web Setup Wizard` page. -1. Enter Marketplace authentication keys. Please read about authentication keys generation. -1. Navigate to `Component Manager` page. -1. On the `Component Manager` page click the `Sync button to update your new purchased extensions. -1. Click `Install` in the `Action` column for `Realex Payments` component. -1. . Follow Web Setup Wizard instructions. - # Configuration The Vipps Payment module can be easily configured to meet business expectations of your web store. This section will show you how to configure the extension via `Magento Admin`. @@ -38,6 +24,7 @@ From Magento Admin navigate to `Store` -> `Configuration` -> `Sales` -> `Payment By clicking the `Configure` button, all configuration module settings will be shown. Once you have finished with the configuration simply click `Close` and `Save` button for your convenience. ## Add a separate connection for Vipps resources +These settings are required to prevent profiles loss when Magento reverts invoice/refund transactions. * Duplicate 'default' connection in app/etc/env.php and name it 'vipps'. It should look like: ``` 'vipps' => @@ -59,7 +46,6 @@ By clicking the `Configure` button, all configuration module settings will be sh 'connection' => 'vipps', ), ``` -These settings are required to prevent profiles loss when Magento reverts invoice/refund transactions. # Settings @@ -68,7 +54,7 @@ Vipps Payments configuration is divided by sections. It helps to quickly find an 1. Basic Vipps Settings. 1. Express Checkout Settings. -![Screenshot of Vipps Settings](docs/vipps_method.png) +![Screenshot of Vipps Settings](docs/images/vipps_method.png) Please ensure you check all configuration settings prior to using Vipps Payment. Pay attention to the Vipps Basic Settings section, namely `Saleunit Serial Number`, `Client ID`, `Client Secret`, `Subscription Key 1`, `Subscription Key 2`. @@ -76,11 +62,20 @@ For information about how to find the above values, see the [Vipps Developer Por # Basic Vipps Settings -![Screenshot of Basic Vipps Settings](docs/vipps_basic.png) +![Screenshot of Basic Vipps Settings](docs/images/vipps_basic.png) # Express Checkout Settings -![Screenshot of Express Vipps Settings](docs/express_vipps_settings.png) +![Screenshot of Express Vipps Settings](docs/images/express_vipps_settings.png) + +# Quote Monitoring + +Quote it is a cart contents in Magento. Theoretically the quote is an offer and if the user accepts it (by checking out) it converts to order. + +When payment was initiated (customer was redirected to Vipps) Magento creates a new record on `Vipps Quote Monitoring` page and starts tracking an Vipps order. +To do that Magento has a cron job that runs by schedule/each 10 min. + +You can find this page under `System -> Vipps` menu section. Under `Store -> Sales -> Payment Methods -> Vipps -> Cancellation` you can find appropriate configuration settings. # Support diff --git a/Model/Config/Source/Email/Template.php b/Model/Config/Source/Email/Template.php new file mode 100644 index 00000000..bff6c827 --- /dev/null +++ b/Model/Config/Source/Email/Template.php @@ -0,0 +1,69 @@ +config = $config; + } + + /** + * Replace config path for correct rendering default template option + * + * @return array + */ + public function toOptionArray(): array + { + $element = $this->config->getElement($this->getPath()); + $configData = $element->getData(); + $this->setPath($configData['config_path']); + return parent::toOptionArray(); + } +} diff --git a/Model/Order/PartialVoid/Config.php b/Model/Order/PartialVoid/Config.php new file mode 100644 index 00000000..dafe6fd0 --- /dev/null +++ b/Model/Order/PartialVoid/Config.php @@ -0,0 +1,118 @@ +scopeConfig = $scopeConfig; + } + + /** + * @param null $storeId + * + * @return bool + */ + public function isOfflinePartialVoidEnabled($storeId = null): bool + { + return $this->scopeConfig->isSetFlag(self::XML_PATH_IS_ENABLED, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param null $storeId + * + * @return bool + */ + public function isSendMailEnabled($storeId = null): bool + { + return $this->scopeConfig->isSetFlag(self::XML_PATH_IS_SEND_MAIL_ENABLED, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param null $storeId + * + * @return string + */ + public function getEmailTemplate($storeId = null): string + { + return $this->scopeConfig->getValue(self::XML_PATH_EMAIL_TEMPLATE, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param null $storeId + * + * @return string + */ + public function emailSender($storeId = null): string + { + return $this->scopeConfig->getValue(self::XML_PATH_EMAIL_SENDER, ScopeInterface::SCOPE_STORE, $storeId); + } + + /** + * @param null $storeId + * + * @return string + */ + public function getEmailMessage($storeId = null): string + { + return $this->scopeConfig->getValue(self::XML_PATH_EMAIL_MESSAGE, ScopeInterface::SCOPE_STORE, $storeId); + } +} diff --git a/Model/Order/PartialVoid/SendMail.php b/Model/Order/PartialVoid/SendMail.php new file mode 100644 index 00000000..07ef3feb --- /dev/null +++ b/Model/Order/PartialVoid/SendMail.php @@ -0,0 +1,85 @@ +transportBuilder = $transportBuilder; + $this->storeManager = $storeManager; + $this->config = $config; + } + + /** + * @param OrderInterface $order + * + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Magento\Framework\Exception\MailException + */ + public function send(OrderInterface $order) + { + $transport = $this->transportBuilder + ->setTemplateIdentifier($this->config->getEmailTemplate($order->getStoreId())) + ->setFromByScope($this->config->emailSender($order->getStoreId())) + ->addTo($order->getCustomerEmail(), "{$order->getCustomerFirstname()} {$order->getCustomerLastname()}") + ->setTemplateOptions([ + 'area' => Area::AREA_FRONTEND, + 'store' => $order->getStoreId(), + ]) + ->setTemplateVars([ + 'order' => $order, + 'payment_html' => $order->getPayment()->getMethodInstance()->getTitle(), + 'email_message' => $this->config->getEmailMessage($order->getStoreId()), + ]) + ->getTransport(); + + $transport->sendMessage(); + } +} diff --git a/Model/Quote/ShippingMethodValidator.php b/Model/Quote/ShippingMethodValidator.php new file mode 100644 index 00000000..491affea --- /dev/null +++ b/Model/Quote/ShippingMethodValidator.php @@ -0,0 +1,50 @@ +config = $config; + } + + /** + * @param string $methodCode + * @return bool + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function isValid($methodCode) + { + $disabledShippingMethods = explode(',', $this->config->getValue('disallowed_shipping_methods')); + return !in_array($methodCode, $disabledShippingMethods); + } +} diff --git a/Observer/AvailabilityByShippingMethod.php b/Observer/AvailabilityByShippingMethod.php new file mode 100644 index 00000000..d925fceb --- /dev/null +++ b/Observer/AvailabilityByShippingMethod.php @@ -0,0 +1,71 @@ +shippingMethodValidator = $shippingMethodValidator; + } + + /** + * @param Observer $observer + * @return void + * + * 'result' => $checkResult, + * 'method_instance' => $this, + * 'quote' => $quote + * + * @throws NoSuchEntityException + */ + public function execute(Observer $observer) + { + $method = $observer->getData('method_instance'); + $quote = $observer->getData('quote'); + $result = $observer->getData('result'); + + // Ignore not Vipps method and in case no quote here. + if ($method->getCode() !== 'vipps' || !$quote) { + return; + } + + /** @var Quote $quote */ + $quote = $observer->getData('quote'); + if (!$this->shippingMethodValidator->isValid($quote->getShippingAddress()->getShippingMethod())) { + $result->setData('is_available', false); + } + + return; + } +} diff --git a/Observer/SendOfflineVoidEmail.php b/Observer/SendOfflineVoidEmail.php new file mode 100644 index 00000000..851588f9 --- /dev/null +++ b/Observer/SendOfflineVoidEmail.php @@ -0,0 +1,112 @@ +dataObjectFactory = $dataObjectFactory; + $this->paymentDetailsProvider = $paymentDetailsProvider; + $this->transactionBuilder = $transactionBuilder; + $this->sendMail = $sendMail; + $this->config = $config; + } + + /** + * Send email to customer about offline void + * + * @param Observer $observer + * + * @throws LocalizedException + * @throws MailException + * @throws VippsException + */ + public function execute(Observer $observer) + { + /** @var Order $order */ + $order = $observer->getData('order'); + $payment = $order->getPayment(); + $offlineVoidEnabled = $this->config->isOfflinePartialVoidEnabled($order->getStoreId()); + $sendMailEnabled = $this->config->isSendMailEnabled($order->getStoreId()); + + if ($payment->getMethod() === 'vipps' && $offlineVoidEnabled && $sendMailEnabled) { + $paymentDataObject = $this->dataObjectFactory->create($payment); + $response = $this->paymentDetailsProvider->get(['payment' => $paymentDataObject]); + $transaction = $this->transactionBuilder->setData($response)->build(); + if ($transaction->getTransactionSummary()->getRemainingAmountToCapture() > 0) { + $this->sendMail->send($order); + } + } + } +} diff --git a/Plugin/Sales/Model/Order/Payment.php b/Plugin/Sales/Model/Order/Payment.php new file mode 100644 index 00000000..5204185e --- /dev/null +++ b/Plugin/Sales/Model/Order/Payment.php @@ -0,0 +1,94 @@ +dataObjectFactory = $dataObjectFactory; + $this->paymentDetailsProvider = $paymentDetailsProvider; + $this->transactionBuilder = $transactionBuilder; + $this->config = $config; + } + + /** + * Throw exception if offline partial void disabled + * + * @param MagentoPayment $payment + * + * @throws LocalizedException + * @throws VippsException + */ + public function beforeCancel(MagentoPayment $payment) + { + if ($payment->getMethod() === 'vipps') { + $paymentDataObject = $this->dataObjectFactory->create($payment); + $response = $this->paymentDetailsProvider->get(['payment' => $paymentDataObject]); + $transaction = $this->transactionBuilder->setData($response)->build(); + $offlineVoidEnabled = $this->config->isOfflinePartialVoidEnabled(); + if (!$offlineVoidEnabled && $transaction->getTransactionSummary()->getCapturedAmount() > 0) { + throw new LocalizedException(__('Can\'t cancel captured transaction.')); + } + } + } +} diff --git a/README.md b/README.md index e6c52a8e..b85f8444 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,35 @@ Please see: https://github.com/vippsas/vipps-magento-v1 Please follow the instructions in [INSTALL.md](INSTALL.md) + +### Quote Processing Flow + +1. When payment was initiated a new record is created on Vipps Quote Monitoring page with status `New`. +1. Magento polls Vipps for orders to process by cron. +1. When order was accepted on Vipps side, Magento is trying to place an order and marks a record as `Placed` +1. When order was cancelled on Vipps side, Magento marks such record as `Cancelled` +1. If order has not been accepted on Vipps side within some period of time so it marked as expired, Magento marks it as `Expired` +1. If order has not been yet accepted on Vipps side and has not been expired yet, Magento marks it as `Processing`. Appropriate message added on record details page. +1. If order accepted on Vipps side but an error occurred during order placement on Magento side, such record marks as `Processing`. Appropriate message added on record details page. +1. Magento is trying to process the same record `3` times and when it failed after `3` times such record marks as `Place Failed`. +1. It is possible to specify that Magento has to cancel Vipps order automatically when appropriate Magento quote was failed so that client's money released. See `Store -> Sales -> Payment Methods -> Vipps -> Cancellation` +1. If it is specified that Magento has to cancel all failed quotes then Magento fetches all records marked as `Place Failed`, cancel them and marks as `Cancelled` + +Here is a diagram of the process +![Screenshot of Quote Processing Flow](docs/images/quote-monitoring-flow.png) + + +# Requests Profiling + +Requests Profiling is a page in Magento admin panel that helps you to track a communication between Vipps and Magento. +You can find the page under `System -> Vipps` + +On the page you can see the list of all requests for all orders that Magento sends to Vipps. +By clicking on a link `Show` in an `Action` column of grid you can find appropriate response from Vipps. + +Using built-in Magento grid filter you could easily find all requests per order that you are interested in. + + # Magento Magento is an open-source e-commerce platform written in PHP: https://magento.com diff --git a/composer.json b/composer.json index 07152200..c7e4ad6a 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "magento2-module", "description": "Vipps Payment Method", "license": "proprietary", - "version": "1.2.8", + "version": "1.2.9", "require": { "magento/framework": "101.0.*", "magento/module-sales": "101.0.*", diff --git a/docs/express_vipps_settings.png b/docs/images/express_vipps_settings.png similarity index 100% rename from docs/express_vipps_settings.png rename to docs/images/express_vipps_settings.png diff --git a/docs/images/quote-monitoring-flow.png b/docs/images/quote-monitoring-flow.png new file mode 100644 index 00000000..98e61afa Binary files /dev/null and b/docs/images/quote-monitoring-flow.png differ diff --git a/docs/vipps_basic.png b/docs/images/vipps_basic.png similarity index 100% rename from docs/vipps_basic.png rename to docs/images/vipps_basic.png diff --git a/docs/vipps_method.png b/docs/images/vipps_method.png similarity index 100% rename from docs/vipps_method.png rename to docs/images/vipps_method.png diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml index 3df9a8fd..6b5c9489 100644 --- a/etc/adminhtml/di.xml +++ b/etc/adminhtml/di.xml @@ -1,7 +1,7 @@ - + + - + + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 42635134..5b4d5929 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -103,8 +103,10 @@ Magento\Config\Model\Config\Backend\Encrypted + + diff --git a/etc/adminhtml/system/checkout.xml b/etc/adminhtml/system/checkout.xml new file mode 100644 index 00000000..ab3d65f7 --- /dev/null +++ b/etc/adminhtml/system/checkout.xml @@ -0,0 +1,35 @@ + + + + + + + + 1 + Magento\Shipping\Model\Config\Source\Allmethods + payment/vipps/disallowed_shipping_methods + + + + + + + + + diff --git a/etc/adminhtml/system/partial_void.xml b/etc/adminhtml/system/partial_void.xml new file mode 100644 index 00000000..2cdf2b3a --- /dev/null +++ b/etc/adminhtml/system/partial_void.xml @@ -0,0 +1,68 @@ + + + + + + + + + Magento\Config\Model\Config\Source\Yesno + payment/vipps/partial_void_enabled + + + + + Magento\Config\Model\Config\Source\Yesno + payment/vipps/partial_void_send_mail + + 1 + + + + + + + 1 + 1 + + payment/vipps/partial_void_mail_message + This message will be send to customer with voided items by email + + + + + Magento\Config\Model\Config\Source\Email\Identity + payment/vipps/partial_void_sender_email + + 1 + 1 + + + + + Email template chosen based on theme fallback when "Default" option is selected. + Vipps\Payment\Model\Config\Source\Email\Template + payment/vipps/partial_void_email_template + + 1 + 1 + + + + diff --git a/etc/config.xml b/etc/config.xml index 8e560597..e8ef37d4 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -47,6 +47,9 @@ 3 10 60 + sales + payment_vipps_partial_void_email_template + diff --git a/etc/di.xml b/etc/di.xml index 260ff141..a3283534 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -97,6 +97,7 @@ VippsConfigValueHandler + Vipps\Payment\Gateway\Config\CanVoidHandler @@ -431,4 +432,8 @@ vipps + + + + diff --git a/etc/email_templates.xml b/etc/email_templates.xml new file mode 100644 index 00000000..b67421e0 --- /dev/null +++ b/etc/email_templates.xml @@ -0,0 +1,20 @@ + + + +