Amasty provides a useful module, Amasty_Rewards, for store loyalty program. This extension lets administrators ability to configure rules for how customers will accrue Reward Points (usually for previously created orders). Customers can use these points to get discounts on future orders.
Let’s say you have a site on Magento® 2 and this module has been working since its first version. After upgrading from Amasty 1.2.2 to 1.5.0, you’re pleased to see a new component for using points on the checkout page. Before, a customer could only set the desired number of points on the cart page, and a custom component was implemented for checkout. A custom component was immediately deleted since it would be no longer needed. However, a bug was identified in the component from Amasty. We describe it below.
Let’s say a customer has 100 points. In the configuration, a Point Rate of 20 is set, meaning 20 points translate to a $1 discount, and 1 point gives the customer a $0.05 discount. After submitting a form (using AJAX), the number of points in the form is changed to 0.05, and the text below the input field incorrectly says, “You have 99.95 points left,” while the discount amount is calculated correctly as $0.05.
Next, we will determine the cause of the bug and show how to fix it.
Investigating the Reason for the Bug at Checkout
The following was established after analyzing the component. The component itself is defined as follows:
<referenceBlock name="checkout.root">
<arguments>
<argument name="jsLayout" xsi:type="array">
<item name="components" xsi:type="array">
<item name="checkout" xsi:type="array">
<item name="children" xsi:type="array">
<item name="steps" xsi:type="array">
<item name="children" xsi:type="array">
<item name="billing-step" xsi:type="array">
<item name="component" xsi:type="string">uiComponent</item>
<item name="children" xsi:type="array">
<item name="payment" xsi:type="array">
<item name="children" xsi:type="array">
<item name="afterMethods" xsi:type="array">
<item name="children" xsi:type="array">
<item name="rewards" xsi:type="array">
<item name="component" xsi:type="string">Amasty_Rewards/js/view/checkout/payment/rewards</item>
<item name="sortOrder" xsi:type="string">10</item>
<item name="children" xsi:type="array">
<item name="errors" xsi:type="array">
<item name="sortOrder" xsi:type="string">0</item>
<item name="component" xsi:type="string">Amasty_Rewards/js/view/checkout/payment/reward-messages</item>
<item name="displayArea" xsi:type="string">messages</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
Its main part is the Javascript file Amasty_Rewards/js/view/checkout/payment/rewards and the knockout template Amasty_Rewards/checkout/payment/rewards
. There is also a child component that displays messages, but we aren’t considering it now. Below is the code for the files (knockout component and knockout template) mentioned above:
define([
'jquery',
'ko',
'uiComponent',
'Magento_Checkout/js/model/quote',
'Amasty_Rewards/js/action/add-reward',
'Amasty_Rewards/js/action/cancel-reward'
], function ($, ko, Component, quote, setRewardPointAction, cancelRewardPointAction) {
'use strict';
var pointsUsed = ko.observable(null),
pointsLeft = ko.observable(null),
isApplied;
isApplied = ko.observable(pointsUsed() != null);
return Component.extend({
defaults: {
template: 'Amasty_Rewards/checkout/payment/rewards'
},
/**
* Applied flag
*/
isApplied: isApplied,
pointsLeft: pointsLeft,
/**
*
* @return {exports}
*/
initialize: function() {
this._super();
pointsUsed(this.pointsUsed);
pointsLeft(this.pointsLeft);
if (pointsUsed() > 0) {
isApplied(true);
}
return this;
},
/**
* @return {*|Boolean}
*/
isDisplayed: function () {
return this.customerId;
},
/**
* Coupon code application procedure
*/
apply: function () {
if (this.validate()) {
pointsUsed(this.pointsUsed);
setRewardPointAction(pointsUsed, isApplied, this.applyUrl, pointsLeft);
}
},
/**
* Cancel using coupon
*/
cancel: function () {
var points = pointsUsed();
pointsUsed(0);
cancelRewardPointAction(isApplied, this.cancelUrl);
pointsLeft(this.pointsLeft Number.parseFloat(points));
},
/**
*
* @return {*}
*/
getRewardsCount: function () {
return pointsLeft;
},
/**
*
* @return {*}
*/
getPointsRate: function () {
return this.pointsRate;
},
/**
*
* @return {*}
*/
getCurrentCurrency: function () {
return this.currentCurrencyCode;
},
/**
*
* @return {*}
*/
getRateForCurrency: function () {
return this.rateForCurrency;
},
/**
* Coupon form validation
*
* @returns {Boolean}
*/
validate: function () {
var form = '#discount-reward-form';
var valueValid = (this.pointsLeft - this.pointsUsed >= 0) && this.pointsUsed > 0;
return $(form).validation() && $(form).validation('isValid') && valueValid;
}
});
});
As you can see, the template has an input field and two buttons: “Apply Reward” and “Cancel Reward.” Only one of them is visible at any given time, depending on whether or not the user has entered a number of points to use. When the button is clicked, the component method applies or cancel is applied accordingly. Then the apply method calls the setRewardPointAction function, which is implemented in Amasty_Rewards/js/action/add-reward
. This function passes the pointsUsed and pointsLeft variables as a knockout parameter. Let’s analyze the code of this function:
/**
* Customer store credit(balance) application
*/
define([
'ko',
'jquery',
'Magento_Checkout/js/model/quote',
'Magento_Checkout/js/model/resource-url-manager',
'Magento_Checkout/js/model/error-processor',
'Amasty_Rewards/js/model/payment/reward-messages',
'mage/storage',
'mage/translate',
'Magento_Checkout/js/action/get-payment-information',
'Magento_Checkout/js/model/totals',
'Magento_Checkout/js/model/full-screen-loader'
], function (ko, $, quote, urlManager, errorProcessor, messageContainer, storage, $t, getPaymentInformationAction,
totals, fullScreenLoader
) {
'use strict';
return function (pointsUsed, isApplied, applyUrl, pointsLeftObs) {
var discountAmount = quote.totals().discount_amount,
url = applyUrl 'amreward_amount/' encodeURIComponent(pointsUsed());
fullScreenLoader.startLoader();
return storage.put(
url,
{},
false
).done(function (response) {
var deferred;
if (response) {
deferred = $.Deferred();
isApplied(true);
totals.isLoading(true);
getPaymentInformationAction(deferred);
$.when(deferred).done(function () {
pointsUsed(Math.abs(totals.totals().discount_amount) - discountAmount);
pointsLeftObs(pointsLeftObs() - pointsUsed());
$('#amreward_amount').val(pointsUsed()).change();
messageContainer.addSuccessMessage({
'message': $t('You used ' pointsUsed() ' point(s)')
});
fullScreenLoader.stopLoader();
totals.isLoading(false);
});
}
}).fail(function (response) {
fullScreenLoader.stopLoader();
totals.isLoading(false);
errorProcessor.process(response, messageContainer);
});
};
});
As you can see, the code sends a request to the server for applying points. Next, according to the response, getPaymentInformation() is called to get updates to totals and methods of payment. Then, for some reason, the discount_amount value received is recorded as the number of points used. This would be accurate if the customer received a $1 discount for 1 point; however, when the customer gets only $0.05 for 1 point (see the previously-configured discount calculation rule) this logic is incorrect. It seems that Amasty did not consider this case. It would be more logically sound to receive the number of used and remaining points from the server, but Amasty decided not to implement a separate controller to do this, which would have returned JSON. In any event, the problem must be solved.
Fixing the Checkout Bug in Amasty_Rewards 1.5.0 and 1.6.0
This fix is made up of two parts: a server part and a browser part. In Magento 2, the totals class implements the Magento\Quote\Api\Data\TotalsInterface interface and supports extension attributes. Since we don’t need to save the extension attributes on the server side and use the built-in attribute joiner, we define a new extension attribute as follows (extension_attributes.xml file).
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
<extension_attributes for="Magento\Quote\Api\Data\TotalsInterface">
<attribute code="reward" type="Web4pro\Ajaxcart\Model\Reward"/>
</extension_attributes>
</config>
Class Web4pro\Ajaxcart\Model\Reward will look as follows:
class Reward extends \Magento\Framework\DataObject
{
/**
* @param int $pointsLeft
* @return $this
*/
public function setPointsLeft($pointsLeft){
return $this->setData('pointsLeft',$pointsLeft);
}
/**
* @return int|null
*/
public function getPointsLeft(){
return $this->getData('pointsLeft');
}
/**
* @param int $pointsUsed
* @return $this
*/
public function setPointsUsed($pointsUsed){
return $this->setData('pointsUsed',$pointsUsed);
}
/**
* @return int|null
*/
public function getPointsUsed(){
return $this->getData('pointsUsed');
}
}
Now we add the information about the client’s points in totals. We can do this using the plugin.
<type name="Magento\Quote\Model\Cart\CartTotalRepository">
<plugin name="provide-reward" type="Web4pro\Ajaxcart\Model\Plugin" sortOrder="20"/>
</type>
class Plugin
{
protected $_helper;
protected $_reward;
protected $_objectManager;
public function __construct(\Amasty\Rewards\Helper\Data $helper,
\Magentice\Ajaxcart\Model\Reward $reward,
\Magento\Framework\ObjectManagerInterface $objectManager){
$this->_helper = $helper;
$this->_reward = $reward;
$this->_objectManager = $objectManager;
}
public function afterGet($model,$quoteTotals){
$rewardsData = $this->_helper->getRewardsData();
if(isset($rewardsData['pointsLeft'])){
$extensionAttributes = $quoteTotals->getExtensionAttributes();
if(!$extensionAttributes){
$extensionAttributes = $this->_objectManager->create('Magento\Quote\Api\Data\TotalsExtension');
}
$this->_reward->setPointsLeft($rewardsData['pointsLeft'])->setPointsUsed($rewardsData['pointsUsed']);
$extensionAttributes->setReward($this->_reward);
$quoteTotals->setExtensionAttributes($extensionAttributes);
}
return $quoteTotals;
}
}
As you can see, the information about points used and remaining was successfully added to the totals extension attribute and is passed to the front end. Now we have to process this information on the front end. This is where we ran into some difficulties. The knockout variables pointsUsed and pointsLeft aren’t visible outside the Amasty component, and as such, it isn’t possible to redefine the method that uses them. It’s also difficult to replace the Amasty_Rewards/js/action/add-reward function.
To fix this problem, we can create event handlers for changing the knockout variables. The totals variable of the componentMagento_Checkout/js/model/totals
is one of these variables.
We replace the component’s Javascript file using the layout file checkout_index_index.xml.
<referenceBlock name="checkout.root">
<arguments>
<argument name="jsLayout" xsi:type="array">
<item name="components" xsi:type="array">
<item name="checkout" xsi:type="array">
<item name="children" xsi:type="array">
<item name="steps" xsi:type="array">
<item name="children" xsi:type="array">
<item name="billing-step" xsi:type="array">
<item name="component" xsi:type="string">uiComponent</item>
<item name="children" xsi:type="array">
<item name="payment" xsi:type="array">
<item name="children" xsi:type="array">
<item name="afterMethods" xsi:type="array">
<item name="children" xsi:type="array">
<item name="rewards" xsi:type="array">
<item name="component" xsi:type="string">Web4pro_Ajaxcart/js/checkout/reward</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
The component's new file will extend the existing file and looks as follows:
define(["jquery",'mage/translate','Amasty_Rewards/js/view/checkout/payment/rewards','Magento_Checkout/js/model/totals'],
function($,$t,Component,totals){
return Component.extend({
initialize:function(){
var self = this;
totals.totals.subscribe(function(data) {
self.reward = data.extension_attributes.reward;
self.pointsUsed = self.reward.points_used;
self.pointsLeft = self.reward.points_left;
var messages = self.regions.messages();
messages[0].messageContainer.successMessages.subscribe(function(data){
if(data.length){
self.pointsUsed = self.reward.points_used;
self.pointsLeft = self.reward.points_left;
if(data[0].search($t('You used'))!=-1&&data[0].search($t('You used ' self.reward.points_used))==-1){
messages[0].messageContainer.clear();
messages[0].messageContainer.addSuccessMessage({
'message': $t('You used ' self.pointsUsed ' point(s)')
});
self.initialize();
$('#amreward_amount').val(self.pointsUsed).change();
}
}
});
});
this._super();
return this;
}
});
});
<!-- ko if: isDisplayed() -->
<div class="payment-option _collapsible opc-payment-additional rewards-add" data-bind="mageInit: {'collapsible':{'openedState': '_active'}}">
<div class="payment-option-title field choice" data-role="title">
<span class="action action-toggle" id="block-reward-heading" role="heading" aria-level="2">
<!-- ko i18n: 'Apply Rewards'--><!-- /ko -->
</span>
</div>
<div class="payment-option-content" data-role="content" aria-labelledby="block-reward-heading">
<!-- ko foreach: getRegion('messages') -->
<!-- ko template: getTemplate() --><!-- /ko -->
<!--/ko-->
<div class="pointsLeft" data-role="title">
<!-- ko i18n: 'You Have '--><!-- /ko -->
<strong data-bind="text: getRewardsCount()"></strong>
<!-- ko i18n: ' points left '--><!-- /ko -->
</div>
<div class="pointsRate" data-role="title">
<span data-bind="text: getPointsRate()"></span>
<!-- ko i18n: ' for every'--><!-- /ko -->
<span data-bind="text: getRateForCurrency()"></span>
<span data-bind="text: getCurrentCurrency()"></span>
</div>
<form class="form form-reward" id="discount-reward-form">
<div class="payment-option-inner">
<input type="hidden" name="remove" id="remove-amreward" value="0" />
<div class="field">
<div class="control">
<input class="input-text"
type="text"
id="amreward_amount"
name="amreward_amount"
data-validate="{'required-entry':true}"
data-bind="value: pointsUsed, attr:{placeholder: $t('Enter reward amount')} " />
</div>
</div>
<div class="actions-toolbar reward-actions">
<div class="primary">
<!-- ko ifnot: isApplied() -->
<button class="action action-apply" type="submit" data-bind="'value': $t('Apply Reward'), click: apply">
<span><!-- ko i18n: 'Apply Reward'--><!-- /ko --></span>
</button>
<!-- /ko -->
<!-- ko if: isApplied() -->
<button class="action action-cancel" type="submit" data-bind="'value': $t('Cancel Reward'), click: cancel">
<span><!-- ko i18n: 'Cancel Reward'--><!-- /ko --></span>
</button>
<!-- /ko -->
</div>
</div>
</div>
</form>
</div>
</div>
<!-- /ko -->
As you can see, we implemented an event handler for changing totals. However, that’s not enough. Immediately after executing the handler for changing totals, the callback from Amasty_Rewards/js/action/add-reward
is executed, and the value of the knockout variables changes. The incorrect values are also displayed in the browser. But since the code above in Amasty_Rewards/js/action/add-reward also initializes the message for the messages child component, we can use its knockout variable to create a handler. This will also allow us to display the correct message about the client’s points. The self.initialize() call sets the required values for the knockout variables from the parent code.
We also must note that this bug in the Amasty_Rewards module wasn’t fixed in Amasty version 1.6.0, but the resolution described above only works for Magento versions 2.2.4 and later. When installing Amasty_Rewards 1.6.0 on Magento 2.2.3, the component installed threw a Javascript error on the checkout page in the browser console: “reward.js:10 Uncaught TypeError: Cannot read property ‘reward’ of undefined”
The cause of this problem is on the back end. What happened is that starting with some version, the Amasty_Rewards module depends on Amasty_Conditions
, but in Amasty_Conditions the plugin was implemented in Magento\Quote\Model\Cart\CartTotalRepository
the following way:
public function aroundGet(\Magento\Quote\Model\Cart\CartTotalRepository $subject, \Closure $proceed, $cartId)
{
if (version_compare($this->productMetadata->getVersion(), '2.2.4', '>=')) {
return $proceed($cartId);
}
/** @var \Magento\Quote\Model\Quote $quote */
$quote = $this->quoteRepository->getActive($cartId);
if ($quote->isVirtual()) {
$addressTotalsData = $quote->getBillingAddress()->getData();
$addressTotals = $quote->getBillingAddress()->getTotals();
} else {
$addressTotalsData = $quote->getShippingAddress()->getData();
$addressTotals = $quote->getShippingAddress()->getTotals();
}
unset($addressTotalsData[\Magento\Framework\Api\ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY]);
/** @var \Magento\Quote\Api\Data\TotalsInterface $quoteTotals */
$quoteTotals = $this->totalsFactory->create();
$this->dataObjectHelper->populateWithArray(
$quoteTotals,
$addressTotalsData,
\Magento\Quote\Api\Data\TotalsInterface::class
);
$items = [];
foreach ($quote->getAllVisibleItems() as $index => $item) {
$items[$index] = $this->itemConverter->modelToDataObject($item);
}
$calculatedTotals = $this->totalsConverter->process($addressTotals);
$quoteTotals->setTotalSegments($calculatedTotals);
$amount = $quoteTotals->getGrandTotal() - $quoteTotals->getTaxAmount();
$amount = $amount > 0 ? $amount : 0;
$quoteTotals->setCouponCode($this->couponService->get($cartId));
$quoteTotals->setGrandTotal($amount);
$quoteTotals->setItems($items);
$quoteTotals->setItemsQty($quote->getItemsQty());
$quoteTotals->setBaseCurrencyCode($quote->getBaseCurrencyCode());
$quoteTotals->setQuoteCurrencyCode($quote->getQuoteCurrencyCode());
if ($this->isEnterprise()) {
$quoteTotals = $this->setExtensionAttributes($quoteTotals, $quote);
}
return $quoteTotals;
}
As you can see, this plugin checks the Magento version, and for versions less than 2.2.4 it implements its own code, blocking the execution of further plugins. As such, the extension attribute implemented in our plugin doesn’t even have the chance to be defined and passed to the front end. We understand why this plugin was implemented. The CartTotalRepository class had a completely different implementation until version 2.2.4, and this could create problems for a full cross-version implementation of the Amasty extension. In any event, we had to correct the above error of passing the extension attribute to the front end in case, for some reason, it isn’t possible to upgrade to newer versions of Magento. This can be done by adding the following plugin:
<type name="Magento\Quote\Model\Cart\Totals">
<plugin name="add-gift-total" type="Web4pro\Ajaxcart\Model\Plugin" sortOrder="20"/>
</type>
public function afterSetItems($quoteTotals){
$rewardsData = $this->_helper->getRewardsData();
if(isset($rewardsData['pointsLeft'])&&version_compare($this->productMetadata->getVersion(), '2.2.4', '<')){
$extensionAttributes = $quoteTotals->getExtensionAttributes();
if(!$extensionAttributes){
$extensionAttributes = $this->_objectManager->create('Magento\Quote\Api\Data\TotalsExtension');
}
$this->_reward->setPointsLeft($rewardsData['pointsLeft'])->setPointsUsed($rewardsData['pointsUsed']);
$extensionAttributes->setReward($this->_reward);
$quoteTotals->setExtensionAttributes($extensionAttributes);
}
return $quoteTotals;
}