Custom Totals¶
You may find yourself needing to add a custom total to Magentos' total calculation in cart/checkout.
This is quite complicated and has almost no documentation.
Base¶
To define a new total you will need to create a new sales.xml file in etc.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/sales.xsd">
<section name="quote">
<group name="totals">
<item name="surcharge" instance="EdmondsCommerce\Surcharge\Model\Total\Quote\Surcharge" sort_order="360"/>
</group>
</section>
<section name="order_invoice">
<group name="totals">
<item name="surcharge" instance="EdmondsCommerce\Surcharge\Model\Total\Invoice\Surcharge" sort_order="200"/>
</group>
</section>
<section name="order_creditmemo">
<group name="totals">
<item name="surcharge" instance="EdmondsCommerce\Surcharge\Model\Total\Creditmemo\Surcharge"
sort_order="200"/>
</group>
</section>
</config>
looking at the above xml you can see we are adding a new item in each section where totals are displayed; quote, order_invoice, order_creditmemo.
The sales.xml
file includes a sort_order
attribute that needs to be
set. This attribute sets when your new total will be calculated, for example;
if your total is calculated based on shipping value then the sort_order
must be set
to a value greater than that of the shipping total processor. This is set to 350
by Magento, so a value of 360 will make sure that your total always includes the
most up-to-date shipping total.
This is only relevant for the quote
section as after this point the totals are
in effect locked in by the order.
Each item requires a processor that will add the total.
Processor¶
The total processor is a class that extends Magento\Quote\Model\Quote\Address\Total\AbstractTotal
.
This class is required to add the new total to the section you want. The class must have a collect and a fetch method.
public function collect(
Quote $quote,
ShippingAssignmentInterface $shippingAssignment,
Total $total
) {}
public function fetch(Quote $quote, Total $total) {}
These methods used to both update the totals collector with your new total and to fetch it for display.
Display¶
With the above you still haven't added the new total to render.
To do this we need to update the checkout xml; checkout_index_index.xml
and onestepcheckout_index_index
.
checkout_index_index¶
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
layout="1column"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<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="sidebar" xsi:type="array">
<item name="children" xsi:type="array">
<item name="summary" xsi:type="array">
<item name="children" xsi:type="array">
<item name="totals" xsi:type="array">
<item name="children" xsi:type="array">
<item name="edmondscommerce_surcharge" xsi:type="array">
<item name="component" xsi:type="string">
EdmondsCommerce_Surcharge/js/view/cart/totals/surcharge
</item>
<item name="sortOrder" xsi:type="string">20</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
</body>
</page>
onestepcheckout_index_index¶
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<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="totals" xsi:type="array">
<item name="children" xsi:type="array">
<item name="edmondscommerce_surcharge" xsi:type="array">
<item name="component" xsi:type="string">
EdmondsCommerce_Surcharge/js/view/cart/totals/surcharge
</item>
<item name="sortOrder" xsi:type="string">20</item>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
</body>
</page>
In the above we are adding a new js template to the checkout totals render.
JS Template¶
<!-- ko if: isDisplayed() && isEnabled() && isOptedIn() -->
<tr class="totals opc-block-summary surcharge">
<th class="mark" scope="row">
<span class="label" data-bind="text: title"></span>
</th>
<td class="amount">
<span class="price" data-bind="text: getValue(), attr: {'data-th': title}"></span>
</td>
</tr>
<!-- /ko -->
JS Model¶
define([
'Magento_Checkout/js/view/summary/abstract-total',
'Magento_Checkout/js/model/quote',
'Magento_Checkout/js/model/totals'
], function (
Component,
quote,
totals
) {
"use strict";
return Component.extend({
defaults: {
template: 'EdmondsCommerce_Surcharge/cart/totals/surcharge'
},
totals: quote.getTotals(),
is_enabled: window.checkoutConfig.edmondscommerce_surcharge.is_enabled,
opted_in: window.checkoutConfig.edmondscommerce_surcharge.opted_in,
title: window.checkoutConfig.edmondscommerce_surcharge.title,
isDisplayed: function () {
return this.getSurcharge() !== 0;
},
isEnabled: function () {
return this.is_enabled;
},
isOptedIn: function () {
return this.opted_in;
},
getSurcharge: function () {
var price = 0;
if (this.totals() && totals.getSegment('surcharge')) {
price = parseFloat(totals.getSegment('surcharge').value);
}
return price;
},
getValue: function () {
return this.getFormattedPrice(this.getSurcharge());
}
});
});
Config Processor¶
<?php declare(strict_types=1);
namespace EdmondsCommerce\Surcharge\Model;
use EdmondsCommerce\Surcharge\Model\Module\Config;
use Magento\Checkout\Model\ConfigProviderInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
class SurchargeConfigProvider implements ConfigProviderInterface
{
/**
* @var Config
*/
private $moduleConfig;
public function __construct(Config $config)
{
$this->moduleConfig = $config;
}
/**
* @return array[]
* @throws LocalizedException
* @throws NoSuchEntityException
*/
public function getConfig(): array
{
return [
'edmondscommerce_surcharge' => [
'is_enabled' => $this->moduleConfig->isEnabled(),
'title' => $this->moduleConfig->getSurchargeTitle(),
'opted_in' => $this->moduleConfig->isSurchargeOptIn(),
],
];
}
}
You can use something like this to pass your required variables to the JS model.
Remember to define it in xml:
<type name="Magento\Checkout\Model\CompositeConfigProvider">
<arguments>
<argument name="configProviders" xsi:type="array">
<item name="edmondscommerce_surcharge" xsi:type="object">
EdmondsCommerce\Surcharge\Model\SurchargeConfigProvider
</item>
</argument>
</arguments>
</type>
Adding to PDF¶
To add the new total to a PDF you will need to create a new xml for it and a new processor.
PDF XML¶
Create a new xml under etc/
called pdf.xml
.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/pdf_file.xsd">
<totals>
<total name="edmondscommerce_surcharge">
<title translate="true">Surcharge</title>
<model>EdmondsCommerce\Surcharge\Model\Pdf\Surcharge</model>
<sort_order>250</sort_order>
<display_zero>false</display_zero>
<source_field>surcharge</source_field>
</total>
</totals>
</config>
PDF Total Processor¶
The total processor for PDF generation must extend DefaultTotal
and
include a getTotalsForDisplay
method.
The getTotalsForDisplay
method must return a multi-dimensional array.
public function getTotalsForDisplay(): array
{
$totals = [];
$surcharge = $this->getSource()->getSurcharge();
// This check can be float if your custom total
// can be a decimal value below 1
if ((int)$surcharge !== 0) {
$amount = $this->getOrder()->formatPriceTxt($surcharge);
$label = $this->moduleConfig->getSurchargeTitle();
$fontSize = $this->getFontSize() ?: 7;
$totals[] = [
'amount' => $this->getAmountPrefix() . $amount,
'label' => $label . ':',
'font_size' => $fontSize,
];
}
return $totals;
}
This is a standard return value. It includes all details that will need to be rendered for a PDF to display the custom total.
Example¶
The above is a rather small and not well fleshed out version of what the completed module will look like.
The actual module will have to be much more complex and will de dependent on the requirements of each user.
Common Issues¶
Paypal¶
There is a specific error that will occur when trying to complete an order through paypal with a custom total.
This happens because the order totals will not add up because paypal does not see the custom total as an item.
To fix this we will need an observer on payment_cart_collect_items_and_amounts
.
Something like this will solve the issue;
public function execute(Observer $observer) : void
{
/** @var Cart $cart */
$cart = $observer->getEvent()->getCart();
$quote = $this->session->getQuote();
$cart->addCustomItem(__($this->moduleConfig->getSurchargeTitle()), 1, $quote->getBaseSurcharge(), 'surcharge');
}
Total Render¶
If your total relies on shipping calculation you will need to make sure
you are calculating after shipping.
This is done through the sort_order
attribute in your sales.xml.
The standard Magento shipping total is set to sort_order 350, so the
minimum for your total will need to be 360.
Quote Collect Order¶
The collect method for your total processor will need to do things in a specific order. It is not clear why this is and the documentation is spotty.
This is what I had to make sure it is calculated correctly:
public function collect(
Quote $quote,
ShippingAssignmentInterface $shippingAssignment,
Total $total
): Surcharge {
parent::collect($quote, $shippingAssignment, $total);
if (!$shippingAssignment->getItems()) {
return $this;
}
if (!$this->moduleConfig->isEnabled()) {
return $this;
}
if (!$this->moduleConfig->isSurchargeOptIn()) {
return $this;
}
$total->setTotalAmount($this->getCode(), 0);
$total->setBaseTotalAmount($this->getCode(), 0);
$surcharge = $this->surchargeProcessor->getStoreSurchargeAmount($quote);
$baseSurcharge = $this->surchargeProcessor->getBaseSurchargeAmount($quote);
$total->setTotalAmount($this->getCode(), $surcharge);
$total->setBaseTotalAmount($this->getCode(), $baseSurcharge);
$total->setSurcharge($surcharge);
$total->setBaseSurcharge($baseSurcharge);
$quote->setSurcharge($surcharge);
$quote->setBaseSurcharge($baseSurcharge);
return $this;
}
Resources¶
Redacted