How to Add Extra Fees to Magento 2 Checkout: Custom Code vs Extension
Two ways to add an extra fee to Magento 2 checkout: a custom totals collector (with working code) or an extension. What each handles, what each breaks.

Magento 2 has no built-in way to add an arbitrary fee to checkout. No admin setting, no core feature, nothing to toggle. So every store that needs a handling charge, a payment surcharge, or a small order fee ends up choosing between two paths: write a custom totals collector, or install an extension.
We do both for a living. We run Magento stores for clients, and we also build and sell the OCM Labs Extra Fees module for Magento 2. So read this knowing where our money comes from. We will still show you the working custom code first, because for some stores custom code is genuinely the right answer, and we would rather you know which store you are.
What does it take to add an extra fee to Magento 2 checkout?
You register a custom total in a sales.xml file and write a collector class that adds the amount during totals collection. That gets a fee into the quote's grand total, and it is about 80 lines of code. The other 90 percent of the job is everything after the quote: rendering the fee in the checkout summary, persisting it to the order, carrying it onto invoices, credit memos, emails, and PDFs, taxing it, and refunding it exactly once.
Most tutorials show you the 80 lines and stop. We will show you the 80 lines and then list what they do not do, because that list is what decides custom versus extension.
The custom code path: a working totals collector
Two files. First, etc/sales.xml registers the total in the quote's collector chain:
<?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="acme_handling" instance="Acme\Fee\Model\Total\Quote\Handling" sort_order="420"/>
</group>
</section>
</config>The sort order matters. Core collects the subtotal at 100, shipping at 350, tax at 450, and the grand total at 550. A fee at 420 lands after shipping and before tax and the grand total.
Second, the collector itself:
<?php
declare(strict_types=1);
namespace Acme\Fee\Model\Total\Quote;
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;
class Handling extends AbstractTotal
{
private const CODE = 'acme_handling';
private const BASE_FEE = 10.00;
public function __construct(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 = self::BASE_FEE;
$fee = $this->priceCurrency->convertAndRound($baseFee, $quote->getStore());
$total->setTotalAmount(self::CODE, $fee);
$total->setBaseTotalAmount(self::CODE, $baseFee);
return $this;
}
public function fetch(Quote $quote, Total $total): array
{
return [
'code' => self::CODE,
'title' => __('Handling Fee'),
'value' => $total->getTotalAmount(self::CODE),
];
}
}This adds a flat $10 fee (in base currency, converted to the display currency) to every cart with items. collect() runs during totals collection; fetch() exposes the fee as a totals segment so the frontend and the totals REST API can see it.
One bug to call out because half the tutorials online have it: do not add your fee to the grand total yourself. The grand total collector at sort order 550 sums every registered total amount. If your collector also calls setGrandTotal($total->getGrandTotal() + $fee), the fee gets counted twice. setTotalAmount() and setBaseTotalAmount() are all you need.
What this collector does not handle
Here is the honest list. Every item on it is real work we have either built or debugged on client stores.
Checkout display. fetch() puts the fee in the totals payload, but Luma's cart and checkout render totals with Knockout components declared in jsLayout. An unknown segment does not display. You need a JS component extending Magento_Checkout/js/view/summary/abstract-total plus jsLayout entries in both checkout_cart_index.xml and checkout_index_index.xml before a shopper sees the line.
Conditions. The sample charges everyone. The moment someone says "only for carts under $50" or "only for hazmat SKUs," you are writing condition logic, and product attributes are not free to check: quote items only carry a short whitelist of attributes, so custom attributes mean reloading products.
Persistence to the order. Quote data dies at order placement unless you copy it. That means new columns on sales_order plus a fieldset.xml entry or an observer on sales_model_service_quote_submit_before. Skip this and the fee is in the grand total but invisible on the order view, which confuses everyone who opens the order.
Invoices and credit memos. Order totals do not flow into invoice totals automatically. You need separate collectors for the invoice and credit memo, registered in sales.xml under order_invoice and order_creditmemo, plus a policy for partial invoices: which invoice carries the fee, and how do you stop a second credit memo from refunding it again? Untracked fees get refunded twice. We have seen it in the wild.
Tax. The sample fee bypasses Magento's tax engine entirely. If the fee is taxable where you sell, you have to register it as an associated taxable so core computes the tax, or maintain your own tax math across zones and customer groups. Nobody should maintain their own tax math.
Emails and PDFs. Order, invoice, and credit memo emails render totals from blocks; PDF invoices are drawn by a separate renderer registered through pdf.xml. Each surface needs the fee added or the shopper sees one number at checkout and a different one in their inbox.
Admin order creation. Orders placed by staff in the admin run the same quote collectors, which works in your favor here, but any logic that assumed a frontend session breaks.
We wrote a whole post on the second half of this list, because it is where DIY fees actually fail: what happens to checkout fees on invoices and credit memos.
When is custom code the right call?
Custom code wins when the fee is one, flat, unconditional, and boring. A $2 processing fee on every order, always invoiced in full, never partially refunded, on a store with a developer who can own the code. In that case the collector above plus a day of wiring for display and persistence is cheaper than any extension, and there is no third-party dependency to update at upgrade time.
It is also the right call when your logic is too strange for any rule builder. We have written one-off collectors for client pricing rules no configurable module should try to express. That is normal agency work, and if you need that kind of thing built, that is what our Magento support retainers are for.
When does an extension pay for itself?
The math flips as soon as any of these are true:
- You need more than one fee, or the amounts change often enough that a non-developer should manage them.
- Fees depend on conditions: payment method, shipping country, customer group, product attributes, cart subtotal.
- The fee is taxable.
- Your store does partial invoices or partial refunds.
- You need the fee correct on emails, PDFs, and the admin grid, not just at checkout.
Each of those is days of development and testing, not hours. At $250 for a license, the extension pays for itself the first time you do not have to build invoice-level fee tracking by hand. It keeps paying at every Magento upgrade, because maintaining the compatibility is our problem, not yours.
What does the OCM Labs Extra Fees module do?
The OCM Labs Extra Fees module for Magento 2 turns fees into rules you manage in the admin under Sales > Extra Fees. Each fee is fixed or percentage based, shows as its own labeled line item in cart and checkout, and carries through the entire order lifecycle without template edits. This is our module, so treat this section as the sales pitch it is; everything in it is in the public docs where you can verify it.
The short version of what you get:
- A condition builder. Fees trigger on product attributes and SKUs (six operators) or on eight cart-level conditions: subtotal, item quantity, shipping country, region, postcode, customer group, shipping method, and payment method. Conditions combine with AND or OR. A fee with no conditions applies to every cart.
- Fixed or percentage fees, with caps. Percentage fees compute from the matching items' pre-discount, tax-exclusive row totals and accept optional min and max amounts.
- Optional fees. Mark a fee optional and it becomes an unchecked checkbox in the checkout summary that the shopper opts into, with the same selection exposed over REST and GraphQL for headless builds.
- Tax done by core. Taxable fees run through Magento's tax engine with a per-fee tax class or a global default. There is also a tax-inclusive entry mode for stores that price that way.
- The full lifecycle. Fees appear on orders, invoices, credit memos, all three email types, PDF documents, customer account and guest views, and admin order creation. The fee is captured on the first invoice and refunded on the first credit memo after it, never more than was invoiced. The details are in the order lifecycle docs.
- Admin tooling. Store view scoping with per-store labels, date-range scheduling, a Fee Revenue report showing invoiced and refunded amounts per order, a change log of who edited what, and a view-only ACL role for finance staff.
Version 1.1.0 supports Magento Open Source and Adobe Commerce 2.4.7 to 2.4.8 on PHP 8.3+, Luma and Hyva (via a companion package). The license is $250 for Open Source, $500 for Adobe Commerce.
If your first fee is a payment or shipping surcharge specifically, we walked through those setups in Magento 2 surcharges by payment method, shipping method, and customer group. And if you are still comparing options, our other modules live on the Magento extensions page.
The honest comparison
Custom code: free to write, yours to maintain, and correct exactly as far as you build it. Budget for the invoice, credit memo, email, PDF, and tax work before you decide it was free.
Extension: $250, someone else's maintenance burden, and lifecycle behavior that has already been argued about, decided, and tested. The tradeoff is that you live inside its rule model. Ours is documented in full, so you can check whether the rule model fits before spending anything.
If you already know your fee is one flat amount forever, write the collector. Everyone else, do the arithmetic on your developer's day rate first.
✦ FAQ
Does Magento 2 have a built-in extra fee feature?+–
How do I add a fee to Magento 2 checkout programmatically?+–
Why is my custom fee doubled in the grand total?+–
Why does my custom fee disappear from the invoice?+–
How much does the OCM Labs Extra Fees module cost?+–
Can one store run multiple extra fees at once?+–

Shane Blandford
Founder of Orange Collar Media, a Denver ecommerce agency behind 800+ Magento and Shopify builds. In Magento since version 1.x - building, rescuing, and supporting revenue-critical stores since 2008.
✦ KEEP READING
Related from the Journal.
Magento 2 Surcharges by Payment Method, Shipping Method, and Customer Group
How to add Magento 2 surcharges by payment method, shipping method, or customer group, plus the card-brand and state rules to check before you do.

Charging Handling Fees for Hazmat, Oversized, and Cold-Chain Products in Magento 2
How we set up hazmat, oversized, and cold-chain handling fees in Magento 2 with attribute conditions, plus minimum order and cash on delivery fees.

Magento 2 Checkout Fees Done Right: Tax, Invoices, Credit Memos, Refunds
A checkout fee is the easy part. Here is how Magento 2 fees should behave through tax, partial invoices, credit memos, and refunds, and where DIY breaks.
