Skip to content

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 -->
This template adds the display to order summary in checkout. You can see it is calling ko methods and that is where the js model comes in.

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());
        }
    });
});
Here we define the methods and process the total for display. This requires a new config processor.

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>
The above xml defines a new total processor for pdf generation.

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;
    }
When the totals were shifted or if the parent method was called too late, the total would be calculated twice.

Resources

Redacted