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.
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' )andget_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.