Skip to content
ThemesIonic — home
WooCommerce

Adding a Fee to the WooCommerce Cart

A fee added here is recalculated on every cart load and never stored, which is why conditions belong inside the callback. A negative fee is a discount that bypasses coupons, and the tax on it does not always behave.

Updated 5 min read intermediate

A fee is a line the cart recalculates from scratch every time totals run. woocommerce_cart_calculate_fees fires during WC_Cart::calculate_totals(), after WooCommerce has called remove_all_fees(), and hands you the cart object. Whatever you add with add_fee() exists until the next calculation clears the list and fires the hook again. Nothing is saved. That makes the hook forgiving — a wrong condition fixes itself on the next page load — and it is also why the condition must live in the callback and nowhere else.

The working shape

add_action( 'woocommerce_cart_calculate_fees', 'ti_small_order_fee' );

function ti_small_order_fee( WC_Cart $cart ) {
	if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
		return;
	}

	if ( $cart->get_subtotal() >= 25 ) {
		return;
	}

	$cart->add_fee( __( 'Small order handling', 'ti' ), 3.00, true );
}

The first guard is in every copied snippet and is mostly historical: the admin recalculates orders through WC_Order, not the cart, and the checkout's own refreshes go through ?wc-ajax= endpoints where is_admin() is false. It costs nothing and protects against the rare admin-side request that does load a cart, so keep it, but do not expect it to be the thing that makes the fee behave.

add_fee() takes a name, an amount, whether it is taxable, and optionally a tax class. The name doubles as the identifier: WooCommerce sanitises it into a key, and a second add_fee() with the same name is rejected with a fee_exists error — the first one stays, the second is dropped. Two callbacks using the same label with different amounts therefore keep whichever ran first. Build the name from a constant string, never from a number that changes.

What the fee can read safely

The callback receives the cart after line items have been calculated, so item totals are reliable:

  • $cart->get_subtotal() is the pre-discount sum of line items, excluding tax.
  • $cart->get_cart_contents_total() is the sum after coupons.
  • $cart->get_cart() gives the line items for per-product rules, such as a deposit on any item in a category.
  • $cart->get_cart_contents_count() is the quantity, not the number of distinct items.

Shipping totals are also available: since WooCommerce 3.2 totals run items, then shipping, then fees, so $cart->get_shipping_total() is final when the hook fires. A rule like "add 10% of shipping" works here, but a surcharge that belongs to one shipping method is cleaner on the rate itself, through woocommerce_package_rates, where it appears as part of the shipping line rather than as a separate fee.

Conditions on the customer are fine: WC()->customer->get_shipping_country(), is_user_logged_in(), or a role check. Conditions on the chosen payment method are available at checkout through WC()->session->get( 'chosen_payment_method' ), but the fee only refreshes when the checkout triggers an update, which it does on payment method change by default and some themes disable.

Negative fees and tax

A negative amount is permitted and shows as a reduction in the totals. Three things differ from a coupon:

  1. Reports do not record it as a discount.
  2. Nothing limits it per customer, per use or per product.
  3. Tax on a negative fee is negative tax on the fee line, not a reduction of the products' tax, which can leave a cart with a different tax figure than a coupon of the same value would.

For a discount a customer can reason about, generate a coupon and apply it with $cart->apply_coupon() — the discount then behaves like every other discount, and the problems covered in coupon not working are the only ones you inherit. Reserve negative fees for internal adjustments nobody expects to see on a coupon report.

The taxable flag

Passing true as the third argument makes the fee taxable at the cart's standard rate; passing a tax class slug as the fourth argument uses that class. Passing false is right for government charges that are themselves tax-like and wrong for a handling charge in a jurisdiction that taxes services. The decision is a tax question, not a code question, and the tax settings are where it should match — a fee taxed differently from the products it accompanies is a frequent audit finding on stores that otherwise handle payments carefully.

Block checkout and the Store API

The hook fires for the block cart and checkout too, because they calculate totals through the same cart object. What differs is the refresh: the block checkout re-requests totals when the shipping address or method changes, so a fee conditional on those values updates without any custom JavaScript. A fee conditional on something the Store API does not resend — a value held only in a classic checkout field — will not update in the block checkout at all.

Verify

  • Add items below and above the threshold and watch the fee appear and disappear on the cart page.
  • Open an existing order in the admin and confirm that recalculating totals does not add the fee.
  • Apply a coupon and check whether the fee should be computed before or after the discount; get_subtotal() and get_cart_contents_total() give different answers.
  • Place a test order and check the fee line, its tax, and the totals in the confirmation email.

If the fee shows on the cart page but not at checkout, the checkout is reading a cached totals fragment; clearing the session by logging out and back in distinguishes a caching problem from a condition problem.

Frequently asked

Because the callback builds a different name each time it runs, or two callbacks add fees whose labels sanitise to different keys. WooCommerce clears all fees before firing the hook on each calculation and rejects a duplicate key within one calculation, so a fixed name can only ever appear once.
You can, and WooCommerce accepts it, but a negative fee is not a coupon: it does not show in coupon reports, it is not limited per customer, and its tax is handled by reducing tax on the fee line rather than on the products. For anything a customer should see as a discount, a programmatic coupon is the cleaner tool.
Fees are calculated during cart totals, and the shipping choice is read from the session. If the callback branches on the chosen shipping method, make sure it reads WC()->session->get('chosen_shipping_methods') rather than a value captured earlier in the request, and remember that the checkout refreshes totals over AJAX when the method changes.

Related guides