Magento 2 Custom Fee: Write a Totals Collector That Works
A handling fee, a packaging fee, a small order surcharge. They all look like one extra line in the cart totals. In Magento that line is a totals collector, and a fee is only done when it shows correctly in the cart, in checkout, on the order, on the invoice, on the credit memo and in the PDF. Most fee extensions I review get the cart right and one of the others wrong.
This article shows how a quote totals collector works in Magento Open Source and Adobe Commerce 2.4.8, how to write one for a fixed fee, and what you need to carry it through the rest of the order lifecycle.
How quote totals are calculated
When $quote->collectTotals() runs, Magento\Quote\Model\Quote\TotalsCollector goes through each quote address and runs every collector declared in sales.xml files, sorted by sort_order. Subtotal, shipping, discount, tax and grand total are all collectors. Each one receives a Magento\Quote\Model\Quote\Address\Total object and writes amounts into it.
Two details matter for a custom fee:
- Collectors run per address. A normal quote has a billing and a shipping address. If your collector adds the fee for both, the customer pays it twice. Only add it for the address that has items.
- The grand total collector sums the others. When you call
setTotalAmount()andsetBaseTotalAmount()with your code, the grand total collector includes the amount. You do not add it to the grand total yourself.
Declaring the collector in 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="handling_fee" instance="Vendor\Fee\Model\Total\Quote\HandlingFee" sort_order="420"/>
</group>
</section>
</config>
Open Magento_Quote/etc/sales.xml, Magento_SalesRule/etc/sales.xml and Magento_Tax/etc/sales.xml in your installation to see the core sort orders. Your collector must run before grand total. Whether it runs before or after discount and tax depends on the business rule: should a coupon reduce the fee, and is the fee taxable? Decide that with the merchant before picking the number.
The collector
<?php
declare(strict_types=1);
namespace Vendor\Fee\Model\Total\Quote;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Pricing\PriceCurrencyInterface;
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 Magento\Store\Model\ScopeInterface;
class HandlingFee extends AbstractTotal
{
public const CODE = 'handling_fee';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
private readonly PriceCurrencyInterface $priceCurrency
) {
$this->setCode(self::CODE);
}
public function collect(Quote $quote, ShippingAssignmentInterface $shippingAssignment, Total $total)
{
parent::collect($quote, $shippingAssignment, $total);
if (!count($shippingAssignment->getItems())) {
return $this;
}
$baseFee = $this->getBaseFee((int) $quote->getStoreId());
if ($baseFee <= 0) {
return $this;
}
$fee = $this->priceCurrency->convert($baseFee, $quote->getStore());
$total->setTotalAmount(self::CODE, $fee);
$total->setBaseTotalAmount(self::CODE, $baseFee);
return $this;
}
public function fetch(Quote $quote, Total $total)
{
$amount = (float) $total->getTotalAmount(self::CODE);
if ($amount <= 0) {
return [];
}
return [
'code' => self::CODE,
'title' => __('Handling fee'),
'value' => $amount,
];
}
private function getBaseFee(int $storeId): float
{
return (float) $this->scopeConfig->getValue(
'vendor_fee/general/amount',
ScopeInterface::SCOPE_STORE,
$storeId
);
}
}
The config path is your own, defined in your module's system.xml. The fee is configured in base currency and converted for the quote currency, so multi currency stores show the right value.
parent::collect() resets the amounts for this code on the total, so repeated collectTotals() calls do not stack the fee. Never keep the amount in a class property between calls.
Where the amount is stored
setTotalAmount('handling_fee', ...) also sets the data key handling_fee_amount on the total, and the base version sets base_handling_fee_amount. After collection, the total data is added to the address. Add columns with those names to quote_address and they are saved with the address. Add the same columns to sales_order, sales_invoice and sales_creditmemo, and preferably invoiced and refunded columns on the order too, like core does for shipping. Declarative schema handles all of them in one db_schema.xml.
Copying the fee to the order
Use an observer on sales_model_service_quote_submit_before. Take the values from the shipping address, or from the billing address for virtual quotes:
<?php
declare(strict_types=1);
namespace Vendor\Fee\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
class CopyFeeToOrder implements ObserverInterface
{
public function execute(Observer $observer): void
{
$quote = $observer->getEvent()->getQuote();
$order = $observer->getEvent()->getOrder();
$address = $quote->isVirtual() ? $quote->getBillingAddress() : $quote->getShippingAddress();
$order->setData('handling_fee_amount', $address->getData('handling_fee_amount'));
$order->setData('base_handling_fee_amount', $address->getData('base_handling_fee_amount'));
}
}
The grand total on the order already includes the fee, because it was calculated on the quote. The new columns only keep the breakdown.
Showing the fee in cart and checkout
The totals API returns a segment for every collector whose fetch() returned data. The Luma cart and checkout still need a component to render it:
define([
'Magento_Checkout/js/view/summary/abstract-total',
'Magento_Checkout/js/model/totals'
], function (Component, totals) {
'use strict';
return Component.extend({
defaults: {
template: 'Vendor_Fee/summary/handling-fee',
title: 'Handling fee'
},
getSegment: function () {
return totals.getSegment('handling_fee');
},
isDisplayed: function () {
var segment = this.getSegment();
return segment !== null && Number(segment.value) > 0;
},
getValue: function () {
var segment = this.getSegment();
return this.getFormattedPrice(segment ? segment.value : 0);
}
});
});
The template renders one table row with title and getValue(), wrapped in an isDisplayed() check. Register the component under block-totals in checkout_cart_index.xml and under checkout > sidebar > summary > totals in checkout_index_index.xml. Copy the structure from how Magento_Tax adds its rows, it is the most reliable reference.
With GraphQL, the core cart prices type does not know about your fee. You need to extend the schema and add a resolver if a headless frontend has to show it.
Invoices and credit memos
Invoices and credit memos have their own collectors. Declare them in the same sales.xml under the order_invoice and order_creditmemo sections. An invoice collector extends Magento\Sales\Model\Order\Invoice\Total\AbstractTotal and implements collect(Invoice $invoice). It must:
- Add the fee only if it was not invoiced yet. Partial invoices are the classic bug, where every invoice charges the fee again.
- Set the fee on the invoice and add it to the invoice grand total and base grand total. Here, unlike the quote, you add to grand total yourself.
The credit memo collector does the same for refunds. Decide with the merchant if the fee is refundable at all. If you skip these collectors, the order total includes the fee and the invoice total does not. The order then shows a total due that nobody can collect, and paid and refunded amounts in reports stop matching the order totals.
Admin, emails and PDF
Order, invoice and credit memo totals in the admin and in sales emails are rendered by totals blocks. Add a block in layout under order_totals, invoice_totals and creditmemo_totals with an initTotals() method that calls addTotal() on the parent with a DataObject holding code, value, base value and label. Use the same layout handles that sales emails use so the fee appears there too.
The PDF needs a pdf.xml total entry. See invoice PDF customization for that.
Conditional fees
Real fees are rarely flat. A small order surcharge applies below a subtotal, a packaging fee applies to some categories, a payment fee depends on the selected payment method. All of that logic goes inside collect(), and it has access to what it needs: the items from the shipping assignment, the subtotal already collected on the total if your sort order is after subtotal, and the quote with its payment. Keep the rules in a separate service class so the collector stays small and the rules can be tested without building a quote.
A fee that depends on the payment method has one more catch. The collector reads the method saved on the quote, and the Luma checkout does not save it the moment the customer clicks a radio button. If the fee must follow the selected method, save the method on the quote when it changes and then reload totals, for example with Magento_Checkout/js/action/get-totals. If the rule is close to what cart price rules can do, check whether a custom cart rule condition covers it with less code.
Tax on the fee
If the fee is taxable, the simple approach in this article is not enough. The fee needs to be included in the tax calculation, which means working with the tax collectors and quote details items. That is a separate piece of work, and worth confirming with an accountant before writing code.
FAQ
Why is my custom fee added twice?
The collector runs for every quote address. Return early when the shipping assignment has no items, so only the address with items gets the fee.
Should I add the fee to the grand total in the quote collector?
No. Use setTotalAmount and setBaseTotalAmount. The core grand total collector sums all total amounts, so adding it yourself charges it twice.
Why does the order show a total due after a full invoice?
Usually the invoice collector is missing, so the invoiced amount is lower than the order grand total. Add invoice and credit memo collectors for the fee.
Can a coupon discount the fee?
Only if you build it that way. Core discount logic applies to items and optionally shipping. A discount on a custom fee needs custom code.
I am available for freelance Magento and Adobe Commerce work, including custom totals and checkout logic. If you need help with this, get in touch.
Related articles
Magento 2 Custom Payment Method with the Payment Gateway API
Build a custom payment method in Magento 2.4 using the Payment Gateway API: facade virtual type, config, commands and the checkout JS renderer, step by step.
Magento 2 Custom Shipping Method: Build a Carrier Module
How to build a custom shipping method in Magento 2.4: config.xml, system.xml, a carrier model with collectRates, free shipping rules and external rate APIs.
Magento 2 Cart Price Rules: Add a Custom Condition
Add a custom condition to Magento 2 cart price rules: the combine event, a condition model with validate, promo rule attributes and performance traps to avoid.
Magento 2 Guest Checkout and Customer Accounts Explained
How Magento 2 handles guest checkout and customer accounts: configuration, account creation after the order, linking guest orders safely and email checks.