Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 08 / 18 35 MIN

Cart and checkout

Cart Fees, Totals and Calculations, Stage by Stage

The fixed order of the totals pipeline, what add_fee does with your name and amount, and why changing a fee after the customer saw it rewrites the order.

A store adds a 2% payment surcharge as a cart fee. On some orders the fee line appears twice and the customer is charged 4%. The developer's first instinct is a double hook registration, and in WooCommerce 11.2 that instinct is wrong in a way that costs an afternoon: a second fee with the same name is rejected, not summed.

Fees live inside the totals pipeline, so the rules that govern them are the pipeline's rules. The stages run in a fixed order with a full reset at the start of every pass, and "the same fee" means the same name. The amount does not enter into it.

Target is WooCommerce 11.2.1 on WordPress 7.0, PHP 8.3 or newer. The stage order, argument lists, deduplication rule and hashes were read from includes/class-wc-cart.php, includes/class-wc-cart-totals.php, includes/class-wc-cart-fees.php and src/StoreApi/Utilities/CartController.php. The fee counts came from running the real WC_Cart_Fees class against stubs; no booted WordPress install was available here.

The totals pipeline has one fixed order, and shipping runs before fees

WC_Cart::calculate_totals() calls reset_totals(), which resets the totals array and calls remove_all_fees(). Then it fires woocommerce_before_calculate_totals, constructs a WC_Cart_Totals object, and fires woocommerce_after_calculate_totals. The work is in a private calculate() method:

protected function calculate() {
    $this->calculate_item_totals();
    $this->calculate_shipping_totals();
    $this->calculate_fee_totals();
    $this->calculate_totals();
}

Item totals expand into subtotals first and discounts second, so the effective order is line subtotals, coupons, shipping, fees, grand total. Tax is not a stage of its own: it is computed inside each stage with WC_Tax::calc_tax() and merged at the end, which is why a fee with the wrong tax status moves the tax figure and the total together. The final stage reads every figure in cents and adds them once:

$this->set_total( 'total', NumberUtil::round( $this->get_total( 'items_total', true ) + $this->get_total( 'fees_total', true ) + $this->get_total( 'shipping_total', true ) + array_sum( $this->get_merged_taxes( true ) ), 0 ) );

Your callback has already run by then, one stage earlier from calculate_fee_totals(). So inside woocommerce_cart_calculate_fees the cart's total does not include your fee, and after the reset it is the default zero. Price a fee from $cart->get_subtotal() or $cart->get_cart_contents_total(), and note that get_total( $context = 'view' ) defaults to view, which runs the number through wc_price() and returns markup. Multiplying that by 0.02 gives zero and no warning.

add_fee takes four arguments, and the name is the fee's identity

The signature is small:

public function add_fee( $name, $amount, $taxable = false, $tax_class = '' )

Its docblock states the contract: call it from a callback on woocommerce_cart_calculate_fees, avoid negative amounts even though the totals class handles them, and expect fees not to persist. That last sentence is why the pipeline resets them every pass.

Behind the wrapper, WC_Cart_Fees::add_fee() normalises what you passed. The tax class is checked against WC_Tax::get_tax_classes() and WC_Tax::get_tax_class_slugs() and falls back to the standard class on no match. taxable goes through wc_string_to_bool(), so 'yes' works. The amount goes through wc_format_decimal() with its default $dp of false, which does not round to your display decimals: it formats a float to the store's rounding precision, six decimals by default. A fee of 0.475 reaches the totals class as 475000 cents. Rounding to two decimals first is your choice, not a requirement.

The identity of a fee is its name and nothing else, because the id is sanitize_title( $fee->name ). A second fee with the same sanitised name gets a WP_Error with the code fee_exists and is not added, and WC_Cart::add_fee() discards that return value, so the error is invisible unless you call fees_api()->add_fee() and check it yourself. Reading fees back sorts them by amount, not insertion order.

A repeated hook run does not double the fee, but a varying name does

Given that rule, the folklore version of the bug does not reproduce. Here is the real WC_Cart_Fees class driven through a calculation whose callback runs twice on purpose:

A. callback runs twice, fixed name "Handling fee"
  fees in api : 1
  - Handling fee                 5.00 (taxable)
  fee total   : 5.00

B. callback runs twice, name varies per run
  fees in api : 2
  - Handling fee (run 2)         5.00 (taxable)
  - Handling fee (run 1)         5.00 (taxable)
  fee total   : 10.00

C. callback runs twice, guarded by a static flag
  fees in api : 1
  - Handling fee (run 1)         5.00 (taxable)
  fee total   : 5.00

D. return values from WC_Cart_Fees::add_fee
  first  call : stdClass
  second call : WP_Error -> fee_exists
  fee count   : 1

Case A is the surprise: two runs, one fee, because both produced the same sanitised name. Case D is the same thing seen from the return value. A genuinely doubled fee therefore means that your two runs produced different names, which is what case B does with a counter. Anything that varies between passes has that effect when it lands in the name: a formatted total, a timestamp, a quantity, a translated string two code paths render differently, a random token. Fix the name.

When a guard is right, it has to be scoped to one pass, because reset_totals() removes the fee at the start of the next one. A bare static flag inside the callback never resets and would suppress the fee on every later pass in the same request.

Taxable and negative fees are taxed by two different rules

WC_Cart_Totals::get_fees_from_cart() converts each amount with wc_add_number_precision_deep() and branches three ways. A fee whose taxable flag is false gets no tax at all; a positive taxable fee gets rates from its own tax class:

$fee->taxes = WC_Tax::calc_tax( $fee->total, WC_Tax::get_rates( $fee->tax_class, $this->cart->get_customer() ), false );

A negative fee is treated as a discount and apportioned instead. The class takes each tax class's share of the cart cost, skips the non-taxable bucket, and gives each remaining class a slice of the discount proportional to its share of the items:

$proportion               = $tax_class_cost / $total_cost;
$cart_discount_proportion = $fee->total * $proportion;
$fee->taxes               = wc_array_merge_recursive_numeric( $fee->taxes, WC_Tax::calc_tax( $fee->total * $proportion, WC_Tax::get_rates( $tax_class ) ) );

That is the difference between a coupon and a negative fee. A coupon applies against the lines it qualifies for and shows in discount_total. A negative fee spreads across the tax classes in proportion to the whole cart, so a store with a reduced rate on food and a standard rate elsewhere gives up tax at both. If your promotion is product-specific, it belongs in a coupon or a line price.

Two built-in behaviours change the number you get. A negative fee is capped so the order total cannot go below zero, using a $max_discount computed from the items total, the fees already processed and the shipping total. And every tax figure is rounded through round_line_tax(), which honours woocommerce_tax_round_at_subtotal, so that setting applies to fee tax as well as line tax.

The only correct guard is a context guard, and a REST guard removes the fee

WC_Cart::calculate_fees() is two lines: it fires woocommerce_cart_calculate_fees with the cart and checks nothing. Whatever guard exists is one you wrote, and you need one because the admin order editor recalculates totals on an order that already has your fee. The guard that handles that is the one the line-item hooks use, with wp_doing_ajax() in it because admin-ajax is how the checkout refreshes totals:

<?php
add_action( 'woocommerce_cart_calculate_fees', function ( $cart ) {
    if ( is_admin() && ! wp_doing_ajax() ) {
        return;
    }

    $base = (float) $cart->get_cart_contents_total();

    if ( $base <= 0 ) {
        return;
    }

    $cart->add_fee( __( 'Payment surcharge', 'sifero-fees' ), $base * 0.02, true );
}, 20, 1 );

Now the part that catches people who learned to guard on REST. The Store API boots the cart inside the REST request, so woocommerce_cart_calculate_fees fires normally on a blocks checkout and on every Cart and Checkout block API call that recalculates totals. A guard reading wp_doing_rest(), or defined( 'REST_REQUEST' ), or an is_admin() check you extended to REST, protects nothing. It removes your fee from every blocks checkout, silently, on the page where the customer has already been shown a price without it.

Core offers a cleaner discriminator. Every cart carries cart_context, which is 'shortcode' by default and 'store-api' when CartController::load_cart() set it, and core branches on it for its own shipping logic.

A changed fee rewrites the order the customer was quoted

The Checkout block builds a draft order with the checkout-draft status and updates it as the customer types. The Store API decides what to rebuild by hashing each section of the cart separately:

return [
    'line_items' => $cart->get_cart_hash(),
    'shipping'   => md5( wp_json_encode( [ $cart->get_shipping_methods(), wc()->session->get( 'chosen_shipping_methods' ) ] ) ),
    'fees'       => md5( wp_json_encode( $cart->get_fees() ) ),
    'coupons'    => md5( wp_json_encode( $cart->get_applied_coupons() ) ),
    'taxes'      => md5( wp_json_encode( $cart->get_taxes() ) ),
];

OrderController::update_line_items_from_cart() compares each hash with the one on the draft order and rebuilds only the changed section. When the fees hash differs it calls remove_order_items( OrderItemType::FEE ) and recreates every fee line from the cart. A fee that produces a different amount or name on the second pass does not add a line. It discards the fee the customer was quoted and writes a new one. The same hash decides whether an order can be reused: DraftOrderTrait::is_valid_draft_order() treats a pending or failed order as valid when it has_cart_hash( wc()->cart->get_cart_hash() ), which is how a customer who abandons a payment and returns finishes the same order. A fee whose name keeps changing keeps that comparison from settling.

This is why altering a total after the customer has seen it is a correctness problem rather than a cosmetic one. The number the customer agreed to is on a page, and the number the gateway charges comes from a recalculation later in the request. Between those moments your callback runs again, and if it depends on anything other than the cart in front of the customer, the order records a figure the customer never approved. Compute a fee from the cart contents, the chosen shipping method and the customer, all of which the draft order already reflects.

Lab 35 minutes

Build a percentage payment surcharge, then prove what the pipeline does with it.

  • Add the fee at 2% of the cart contents total, taxable, with a fixed name. Log get_total(), get_total( 'edit' ) and get_subtotal() from inside the callback and explain the three values.
  • Put a varying value in the name and watch the fee count on the cart page. Remove it and watch the count return to one.
  • Add a second, negative fee of 10% of the subtotal. Print get_fee_total(), get_fee_tax() and the order of the fee rows.
  • Put the fee behind is_admin() && ! wp_doing_ajax() and confirm the surcharge still appears on a blocks checkout.

Then answer: if you build the fee name with wc_price( $cart->get_total() ), what name does the fee get, and what happens to the order's fee lines on the next pass?

Show the solution and the reasoning
<?php
add_action( 'woocommerce_cart_calculate_fees', function ( $cart ) {
    if ( is_admin() && ! wp_doing_ajax() ) {
        return;
    }

    $base = (float) $cart->get_cart_contents_total();
    if ( $base <= 0 ) {
        return;
    }

    error_log( sprintf(
        'view=%s edit=%s subtotal=%s contents=%s',
        $cart->get_total(),          // formatted markup, from the previous pass
        $cart->get_total( 'edit' ),  // number, still excluding this fee
        $cart->get_subtotal(),
        $base
    ) );

    $cart->add_fee( __( 'Payment surcharge', 'sifero-fees' ), $base * 0.02, true );
}, 20, 1 );

The three values. get_total() returns a formatted price string, because the default context is view and view runs the value through wc_price(). It is also the wrong number: reset_totals() set the totals array back to defaults at the start of this pass, so total is either zero or whatever the previous pass wrote before the reset. get_subtotal() and get_cart_contents_total() are the two that mean something here, because items and coupons are calculated before fees run. The difference between them is the coupon: subtotal is before discounts, cart contents total is after.

The varying name. With a counter or timestamp in the name, each pass produces a new sanitised id, so nothing is rejected and every pass adds another fee. The cart shows two, three or four surcharges depending on how many times totals were recalculated. If you prefer a guard to a constant name, remember that reset_totals() removes all fees at the start of each pass, so the guard has to be scoped to one pass rather than to the request.

The negative fee. get_fee_total() sums the positive and negative amounts, so a 2% surcharge of 4.00 against a 10% discount of 20.00 reports -16.00. get_fee_tax() sums the per-fee tax, and the two fees contribute on different bases: the surcharge is taxed on its own amount at its tax class's rates, while the discount has its tax spread across the cart's tax classes in proportion to their share of the items. Read the fee rows rather than the totals to see that split, and note that fees_api()->get_fees() sorts by amount, not insertion order. The negative fee is also clamped so the order total cannot fall below zero, so a large discount on a small cart is silently smaller than you asked for.

The guard. is_admin() && ! wp_doing_ajax() returns early on the admin order editor and nowhere else, so the surcharge survives admin-ajax recalculations, the shortcode checkout and the blocks checkout. A REST-based guard would return early on every Store API request, which is every Cart and Checkout block API call, and the surcharge would be missing from the checkout the customer is looking at while the server-side order total included it earlier in the session. Nothing errors.

The name built from the total. On the first pass $cart->get_total() returns formatted markup around the value the previous calculation left, so the name is something like Payment surcharge <span class="woocommerce-Price-amount amount">...</span>. sanitize_title() strips the tags, but the digits inside change on the next pass, so the sanitised id changes with them. The Store API hashes the fee list, sees a different hash, deletes the order's fee line items and recreates them, so the amount on the order can differ from the amount on the page.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.