Totals
Cart figures come from an ordered list of totals collectors declared in sales.xml, not from a template.
public function collect(Quote $quote, ShippingAssignmentInterface $shippingAssignment, Total $total)Every figure in the cart summary (subtotal, tax, shipping, discount, grand total) is produced by a totals collector: a class that inspects the quote and registers an amount. Magento has no single place where a cart price is worked out. It has an ordered list of collectors, and the list is configuration.
Declaring one
A collector is one <item> in etc/sales.xml, under section quote and group totals. The name attribute becomes the collector's code (Magento calls setCode() for you), instance is the class, and sort_order fixes when it runs. The core order is subtotal 100, tax_subtotal 200, discount 300, shipping 350, shipping_discount 400, tax 450, grand_total 550.
collect and fetch
The class extends Magento\Quote\Model\Quote\Address\Total\AbstractTotal or Magento throws a LocalizedException. collect() does the arithmetic. fetch() returns an array with code, title and value, which becomes a total segment in the cart totals response. The title must be a Phrase from __(); a plain string arrives at the storefront with an empty label.
Magento\Quote\Model\Quote\Address\Total\Grand sums every registered amount, so a collector with a sort_order below 550 lands in the grand total with no extra work. Above 550 it is silently left out.
Why not in a template
A discount calculated in a .phtml is not on the quote, so it is not on the order, the invoice, the emails or the gateway request. The shopper is charged the undiscounted amount.
After editing sales.xml run bin/magento cache:flush config; in production mode also bin/magento setup:di:compile.
Example
<?php
// app/code/Vendor/Surcharge/Model/Quote/Total/Handling.php
declare(strict_types=1);
namespace Vendor\Surcharge\Model\Quote\Total;
use Magento\Quote\Api\Data\ShippingAssignmentInterface;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;
class Handling extends AbstractTotal
{
private const FEE = 2.50;
public function collect(
Quote $quote,
ShippingAssignmentInterface $shippingAssignment,
Total $total
) {
// Resets this collector's amounts for the address being collected.
parent::collect($quote, $shippingAssignment, $total);
if ($quote->isVirtual() || !$shippingAssignment->getItems()) {
return $this;
}
// Shorthand:
$total->addTotalAmount((string) $this->getCode(), self::FEE);
$total->addBaseTotalAmount((string) $this->getCode(), self::FEE);
// Longhand, the same thing addTotalAmount() does internally:
// $code = (string) $this->getCode();
// $total->setTotalAmount($code, $total->getTotalAmount($code) + self::FEE);
// $total->setBaseTotalAmount($code, $total->getBaseTotalAmount($code) + self::FEE);
return $this;
}
public function fetch(Quote $quote, Total $total): array
{
$value = (float) $total->getTotalAmount((string) $this->getCode());
if ($value === 0.0) {
return [];
}
return ['code' => $this->getCode(), 'title' => __('Handling Fee'), 'value' => $value];
}
}
When to use it
- Add a fixed handling or packaging fee that must appear on the order and the invoice.
- Apply a negative amount for a loyalty credit so the gateway is charged the reduced figure.
- Work out why a fee shows in the cart but not in the grand total.
- Expose a custom amount to a headless client through the cart totals response.
More examples
Declaring the collector
The name attribute is the code the collector reports under, so this one produces a handling_fee total segment. Sort order 460 puts it after tax (450) and before grand_total (550), which is what makes the fee part of the grand total. After adding this file, bin/magento cache:flush config rebuilds the sorted collector list; an existing saved cart shows the fee once something changes on it and totals are recollected.
<!-- app/code/Vendor/Surcharge/etc/sales.xml -->
<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="handling_fee"
instance="Vendor\Surcharge\Model\Quote\Total\Handling"
sort_order="460"/>
</group>
</section>
</config>
Showing the amount on the checkout page
The collector makes the money correct; this makes it visible. The checkout order summary is a tree of JavaScript components, and a new line is a new child under summary.totals. The component reads the handling_fee segment that fetch() produced, so no extra service call is needed. Run bin/magento cache:flush and, in production mode, bin/magento setup:static-content:deploy for the new JavaScript file.
<!-- app/code/Vendor/Surcharge/view/frontend/layout/checkout_index_index.xml -->
<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="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="handling_fee" xsi:type="array">
<item name="component" xsi:type="string">Vendor_Surcharge/js/view/checkout/summary/handling-fee</item>
<item name="sortOrder" xsi:type="string">25</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
</body>
</page>
A collector that reduces the total
A credit or custom discount is a negative amount, not a separate mechanism. Clamping to the current grand total stops the cart going below zero, which would otherwise reach the payment gateway as a negative charge. Declare it with a sort_order after tax and shipping so the amounts it is capped against already exist, and remember a collector runs once per shipping address, so parent::collect() matters on a multi-address quote.
<?php
// app/code/Vendor/Loyalty/Model/Quote/Total/Credit.php
declare(strict_types=1);
namespace Vendor\Loyalty\Model\Quote\Total;
use Magento\Quote\Api\Data\ShippingAssignmentInterface;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;
use Vendor\Loyalty\Model\CreditBalance;
class Credit extends AbstractTotal
{
public function __construct(
private readonly CreditBalance $creditBalance
) {
}
public function collect(
Quote $quote,
ShippingAssignmentInterface $shippingAssignment,
Total $total
) {
parent::collect($quote, $shippingAssignment, $total);
$customerId = (int) $quote->getCustomerId();
if ($customerId === 0) {
return $this;
}
// Only Magento\Quote\Model\Quote\Address\Total\Grand sets the grand total, and it
// runs last at sort_order 550, so $total->getGrandTotal() is still empty here.
// Cap against the amounts the collectors that have already run registered.
$runningTotal = (float) array_sum($total->getAllTotalAmounts());
$available = $this->creditBalance->getFor($customerId);
$applied = min($available, $runningTotal);
if ($applied <= 0.0) {
return $this;
}
$code = (string) $this->getCode();
$total->addTotalAmount($code, -$applied);
$total->addBaseTotalAmount($code, -$applied);
return $this;
}
}
Discussion