Add a Custom Total in Magento 2

Custom totals can be added to provide a surcharge or discount to customers on your Magento store. To add a custom total in Magento 2, the total must first be defined within a configuration file.

The article will assume that you have correctly created a vanilla module with the registration.php and etc/module.xml files present.

The configuration file used to define the total is sales.xml. Within this file, add a pair of <item> nodes and add a name attribute, whose value will be used as the total code (or identifier).

This example will define a custom_amount total.

// app/code/[Vendor]/[Module]/etc/sales.xml

<?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="custom_amount" instance="[Vendor]\[Module]\Model\Total\Custom" sort_order="150"/>
        </group>
    </section>
</config>

The instance attribute’s value represents the model used for the total. This should be added next and the model should extend Magento\Quote\Model\Quote\Address\Total\AbstractTotal.

There are a few things happening in the code below. Firstly within __constuct(), the total code is set using setCode().

The total class should contain collect() and fetch() methods which Magento calls for each total model. collect(), is responsible for calculating the totals. fetch() returns an array with the total’s code, a display title, and the value to display.

// app/code/[Vendor]/[Module]/Model/Total/Custom.php

<?php
namespace [Vendor]\[Module]\Model\Total;

use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;
use Magento\Quote\Model\Quote;
use Magento\Quote\Api\Data\ShippingAssignmentInterface;
use Magento\Quote\Model\Quote\Address\Total;

class Custom extends AbstractTotal
{
    /**
     * Custom constructor.
     */
    public function __construct()
    {
        $this->setCode('custom_amount');
    }

    /**
     * @param Quote $quote
     * @param ShippingAssignmentInterface $shippingAssignment
     * @param Total $total
     * @return $this
     */
    public function collect(
        Quote $quote,
        ShippingAssignmentInterface $shippingAssignment,
        Total $total
    ) {
        parent::collect($quote, $shippingAssignment, $total);

        $items = $shippingAssignment->getItems();
        if (!count($items)) {
            return $this;
        }
        $amount = 10; // A surcharge of '10' as an example.

        $total->setTotalAmount('custom_amount', $amount);
        $total->setBaseTotalAmount('custom_amount', $amount);
        $total->setCustomAmount($amount);
        $total->setBaseCustomAmount($amount);
        $total->setGrandTotal($total->getGrandTotal() + $amount);
        $total->setBaseGrandTotal($total->getBaseGrandTotal() + $amount);

        return $this;
    }

    /**
     * @param Total $total
     */
    protected function clearValues(Total $total)
    {
        $total->setTotalAmount('subtotal', 0);
        $total->setBaseTotalAmount('subtotal', 0);
        $total->setTotalAmount('tax', 0);
        $total->setBaseTotalAmount('tax', 0);
        $total->setTotalAmount('discount_tax_compensation', 0);
        $total->setBaseTotalAmount('discount_tax_compensation', 0);
        $total->setTotalAmount('shipping_discount_tax_compensation', 0);
        $total->setBaseTotalAmount('shipping_discount_tax_compensation', 0);
        $total->setSubtotalInclTax(0);
        $total->setBaseSubtotalInclTax(0);
    }

    /**
     * @param Quote $quote
     * @param Total $total
     * @return array
     */
    public function fetch(Quote $quote, Total $total)
    {
        return [
            'code' => $this->getCode(),
            'title' => 'Custom Amount',
            'value' => 10
        ];
    }

    /**
     * @return \Magento\Framework\Phrase
     */
    public function getLabel()
    {
        return __('Custom Amount');
    }
}

Totals in Magento 2 are rendered using HTML templates and Knockout.js, so in order to display the custom totals on the cart and checkout pages, a jsLayout component should be added within the checkout_cart_index.xml and checkout_index_index.xml layout files.

// app/code/[Vendor]/[Module]/view/frontend/layout/checkout_cart_index.xml

<?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.cart.totals">
            <arguments>
                <argument name="jsLayout" xsi:type="array">
                    <item name="components" xsi:type="array">
                        <item name="block-totals" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="custom_amount" xsi:type="array">
                                    <item name="component" xsi:type="string">[Vendor]_[Module]/js/view/checkout/cart/totals/custom_amount</item>
                                    <item name="sortOrder" xsi:type="string">20</item>
                                    <item name="config" xsi:type="array">
                                        <item name="template" xsi:type="string">[Vendor]_[Module]/checkout/cart/totals/custom_amount</item>
                                        <item name="title" xsi:type="string">Custom Amount</item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>
// app/code/[Vendor]/[Module]/view/frontend/layout/checkout_index_index.xml

<?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="custom_amount" xsi:type="array">
                                                            <item name="component"  xsi:type="string">[Vendor]_[Module]/js/view/checkout/cart/totals/custom_amount</item>
                                                            <item name="sortOrder" xsi:type="string">20</item>
                                                            <item name="config" xsi:type="array">
                                                                <item name="template" xsi:type="string">[Vendor]_[Module]/checkout/cart/totals/custom_amount</item>
                                                                <item name="title" xsi:type="string">Custom Amount</item>
                                                            </item>
                                                        </item>
                                                    </item>
                                                </item>
                                                <item name="cart_items" xsi:type="array">
                                                    <item name="children" xsi:type="array">
                                                        <item name="details" xsi:type="array">
                                                            <item name="children" xsi:type="array">
                                                                <item name="subtotal" xsi:type="array">
                                                                    <item name="component" xsi:type="string">Magento_Tax/js/view/checkout/summary/item/details/subtotal</item>
                                                                </item>
                                                            </item>
                                                        </item>
                                                    </item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

The key points to take away from the layout configuration above is that a custom component JavaScript file and template HTML file is defined for each layout file.

Let’s start with the Knockout.js and template files defined in checkout_cart_index.xml.

// app/code/[Vendor]/[Module]/view/frontend/web/js/view/checkout/cart/totals/custom_amount.js

define(
    [
        '[Vendor_Module]/js/view/checkout/summary/custom_amount'
    ],
    function (Component) {
        'use strict';

        return Component.extend({

            /**
             * @override
             */
            isDisplayed: function () {
                return this.getPureValue() !== 0;
            }
        });
    }
);
// app/code/[Vendor]/[Module]/view/frontend/web/template/checkout/cart/totals/custom_amount.html

<!-- ko if: isDisplayed() -->
<tr class="totals custom_amount excl">

    <th class="mark" colspan="1" scope="row" data-bind="text: title"></th>
    <td class="amount">
        <span class="price" data-bind="text: getValue()"></span>
    </td>
</tr>
<!-- /ko -->

The HTML template, i.e. the custom total, will only display if the isDisplayed() function from custom_amount.js returns true.

The getPureValue() function is defined within the app/code/[Vendor]/[Module]/view/frontend/web/js/view/checkout/summary/custom_amount.js.

// app/code/[Vendor]/[Module]/view/frontend/web/js/view/checkout/summary/custom_amount.js

define(
    [
        'Magento_Checkout/js/view/summary/abstract-total',
        'Magento_Checkout/js/model/quote',
        'Magento_Catalog/js/price-utils',
        'Magento_Checkout/js/model/totals'
    ],
    function (Component, quote, priceUtils, totals) {
        "use strict";
        return Component.extend({
            defaults: {
                isFullTaxSummaryDisplayed: window.checkoutConfig.isFullTaxSummaryDisplayed || false,
                template: '[Vendor]_[Module]/checkout/summary/custom_amount'
            },
            totals: quote.getTotals(),
            isTaxDisplayedInGrandTotal: window.checkoutConfig.includeTaxInGrandTotal || false,

            isDisplayed: function() {
                return this.isFullMode() && this.getPureValue() !== 0;
            },

            getValue: function() {
                var price = 0;
                if (this.totals()) {
                    price = totals.getSegment('custom_amount').value;
                }
                return this.getFormattedPrice(price);
            },
            getPureValue: function() {
                var price = 0;
                if (this.totals()) {
                    price = totals.getSegment('custom_amount').value;
                }
                return price;
            }
        });
    }
);

Both getValue() and getPureValue() return the amount set for the custom total, however getValue() formats the amount to two decimal places and also adds the current currency symbol.

If the amount set for the custom total is zero, then the HTML templates do not need to be rendered (as per the <!-- ko if: isDisplayed() --> check).

// app/code/[Vendor]_[Module]/view/frontend/web/template/checkout/summary/custom_amount.html

<!-- ko if: isDisplayed() -->
<tr class="totals custom_amount excl">
    <th class="mark" scope="row">
        <span class="label" data-bind="text: title"></span>
        <span class="value" data-bind="text: getValue()"></span>
    </th>
    <td class="amount">
        <span class="price" data-bind="text: getValue(), attr: {'data-th': title}"></span>
    </td>
</tr>
<!-- /ko -->

If all the steps have been followed correctly, you should notice the custom total rendering on the frontend.

Add a Custom Total in Magento 2

It is advised to use the above example as a boilerplate example on how to add a custom total.

Whilst the code correctly calculates the totals on the cart and checkout pages, the custom total data does not get saved within the quote and order tables, hence the totals are not calculated and displayed for invoices, credit memos etc.

Note: This article is based on Magento CE version 2.1.7.