Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 06 / 06 35 MIN

Products and pricing

Pricing Hooks, Tax and Rounding

Follow a price from the stored meta through the filter, the tax conversion and the formatter, and see where a cent goes missing between the cart and the order.

A discount extension filters the product price, the cart shows the discounted amount, the customer pays it, and the next day an order report is a few euros off the cart totals. Nothing threw an exception and the filter was correct. The cart, the order and the invoice each built a total from a different starting number, and the filter reached only one of them.

Price is the most-read value in a store and the most-filtered, and several stages sit between the number a merchant types and the number a customer pays. Each stage has its own hook and its own rounding rule. Knowing their order answers the only question that matters when something is a cent out: which stage did I change, and which stages read the result?

This lesson targets WooCommerce 11.2 on WordPress 7.0, PHP 8.3 or newer. The arithmetic below was produced by scripts that follow the order of operations in includes/wc-core-functions.php, includes/wc-product-functions.php, includes/class-wc-cart-totals.php and includes/traits/trait-wc-item-totals.php on 11.2.1, with woocommerce_price_num_decimals at 2 and woocommerce_tax_round_at_subtotal left at its default of no.

A price is stored as meta and read through a dynamic filter

Three meta keys matter. _regular_price and _sale_price hold what the merchant typed. _price holds the price that is currently active, written by the data store at save time: the sale price when the product is on sale, the regular price otherwise. A scheduled sale updates _price at its boundary through an Action Scheduler job, so between the boundary and the next runner the stored value can be stale. That window is why WooCommerce 11.1 added a read-time filter on the price hook.

$product->get_price() reads the price property, which on a posts-based product store comes from _price. That read passes through a filter whose name WooCommerce builds at runtime. WC_Data::get_prop() applies $this->get_hook_prefix() . $prop when the context is view, and the hook prefix for a product is woocommerce_product_get_. So the price applies woocommerce_product_get_price, the regular price applies woocommerce_product_get_regular_price, and so on for every price property.

<?php
add_filter( 'woocommerce_product_get_price', function ( $price, $product ) {
    if ( 'yes' === $product->get_meta( '_sifero_member_only' ) ) {
        $price = (float) $price * 0.9;
    }
    return $price;
}, 10, 2 );

Two consequences follow. The filter runs on every view-context read, several times per product on a listing page, so anything expensive in the callback is expensive in the page. And 'edit' context skips it entirely: the product edit screen, the CRUD save and the sale-boundary jobs all read prices with get_price( 'edit' ), so a display-time filter cannot corrupt stored data. That is also why WooCommerce 11.1 could add its own ScheduledSalePriceReconciler filter at priority 99 to repair a stale _price at read time.

The cart and the order read the price from different places

Cart totals and order totals both start from $product->get_price(), and they consume it differently. A cart line holds a unit data object and recalculates its price on every totals pass. An order line item is a WC_Order_Item_Product with a stored subtotal and total, written once when checkout copied the cart. The order never asks the product what it costs.

Order line items are built by copying line_subtotal, line_total, line_subtotal_tax and line_tax from the cart item, and the order totals are set from the cart totals. A filter on woocommerce_product_get_price is read by the cart, and its result is frozen into the order. A filter attached only to a presentation hook is not.

<?php
// Attached to the cart template, this changes the price a customer reads on the page.
add_filter( 'woocommerce_cart_item_price', function ( $price_html, $cart_item, $cart_item_key ) {
    return $price_html . ' <em>member price</em>';
}, 10, 3 );

// Attached here, it changes the number the totals are built from.
add_action( 'woocommerce_before_calculate_totals', function ( $cart ) {
    if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
        return;
    }
    foreach ( $cart->get_cart() as $cart_item ) {
        $cart_item['data']->set_price( $cart_item['data']->get_regular_price() * 0.9 );
    }
}, 20, 1 );

Two details in that pair matter. The presentation filter receives $price_html, already the output of wc_price(), so appending a number to it is a string operation and not a discount. And the is_admin() guard matters because the admin order screen recalculates totals too, where the discount must not be applied twice.

Tax is a display decision made at read time

The stored price is one number. What the customer sees depends on two settings that have nothing to do with it: woocommerce_prices_include_tax, which says whether the merchant typed tax-inclusive prices, and woocommerce_tax_display_shop or woocommerce_tax_display_cart, which decide whether prices are shown with or without tax.

wc_get_price_to_display( $product, $args ) is the read you want in a template. Its defaults are qty of 1, price of $product->get_price(), and display_context of 'shop'. The value 'cart' switches which option it reads; any other value falls back to the shop setting, so there are two contexts, not a general one.

wc_get_price_including_tax( $product, $args ) and wc_get_price_excluding_tax( $product, $args ) are the lower-level calls, and both accept a price and a qty, so you can convert a line total you computed rather than a single unit price. Here is what the settings do on a 20% rate, one unit, two price decimals, to a store that entered 9.99:

entered 9.99, prices_include_tax=no  -> display incl 11.99, excl 9.99
entered 9.99, prices_include_tax=yes -> display incl 9.99,  excl 8.3250
entered 10.00, prices_include_tax=no  -> display incl 12.00, excl 10.00
entered 10.00, prices_include_tax=yes -> display incl 10.00, excl 8.3333

Read the last column carefully. On a tax-inclusive store, the excluding-tax figure is not rounded. wc_get_price_excluding_tax() returns the line price minus the unrounded extracted tax, and the comment in the source says why: it matches the cart totals class, so a shop page and a totals calculation do not disagree. On a tax-exclusive store, including-tax figures are rounded to the store's decimals, because that value is a final display number.

Round with the store's helpers, not your own

wc_get_price_decimals() returns woocommerce_price_num_decimals and applies the wc_get_price_decimals filter, so a plugin can move it. It is the number of decimals displayed. wc_get_rounding_precision() is a different number for internal work: the price decimals plus two, floored at 6. Rounding a total to the display decimals and feeding it back into a calculation is how a cent disappears.

Format with wc_price, never with number_format

wc_price( $price, $args ) is the only function to use when a price becomes text or markup. It reads the store's currency symbol, its thousand and decimal separators and its decimal count, and it runs the result through raw_woocommerce_price, formatted_woocommerce_price and wc_price so other extensions can alter the output. It returns a <span class="woocommerce-Price-amount amount"> wrapping a <bdi>, which is why the markup survives right-to-left currencies.

number_format( $price, 2 ) does none of that. It hardcodes two decimals and a period and a comma, so on a store set to three decimals or a comma separator your output is wrong in a way that looks like a style bug. It also skips the currency symbol and every filter above.

<?php
// Wrong: ignores the store's separators, decimals, symbol and filters.
echo '<span class="price">' . number_format( (float) $product->get_price(), 2 ) . '</span>';

// Right: the same pipeline every template uses.
echo wp_kses_post( wc_price( wc_get_price_to_display( $product ) ) );

Change presentation with woocommerce_get_price_html, not the value

When you want to alter a price's presentation rather than its value, the hook is woocommerce_get_price_html. It receives the already-formatted HTML and the product, and it is what themes and plugins use to add a suffix or wrap the amount:

<?php
add_filter( 'woocommerce_get_price_html', function ( $price_html, $product ) {
    if ( '' === $price_html ) {
        return $price_html;
    }
    return $price_html . ' <small>' . esc_html__( 'excl. VAT', 'sifero-gift-cards' ) . '</small>';
}, 10, 2 );

Two things to check before you attach to it. An empty price becomes an empty string through a different filter, woocommerce_empty_price_html, so appending a suffix to nothing shows up first on a variable product with no purchasable variations. And because the filter runs on the formatted string, it is not the place to change a value: a fixed amount added here changes what a customer reads, not what the cart charges.

Cart totals and order totals are not built the same way

The cart total is not the sum of the prices you see. Each line's price is converted with wc_add_number_precision(), which multiplies by ten to the price decimals and rounds to the difference between the rounding precision and the price decimals. Each line is then rounded again by WC_Item_Totals::round_item_subtotal(), and only then are the lines summed. That is round per line, the opposite of round at the end. Three lines at 0.125, with two price decimals:

line: 0.125 -> 0.13
line: 0.125 -> 0.13
line: 0.125 -> 0.13
sum of rounded lines : 0.39
unrounded sum        : 0.375000
round the sum once   : 0.38

A one-cent difference is not a bug in WooCommerce. It is the definition of rounding per line, and the store setting woocommerce_tax_round_at_subtotal changes it. That is the option labelled "Round tax at subtotal level, instead of rounding per line". Off is the default, and every line is rounded. On, lines are summed at full precision and rounded once.

So the failure this lesson opened with becomes explicable. An extension that computes a total and writes it through set_total() is choosing an ordering, and if that ordering differs from the cart's, the totals differ. Clean lines hide it: three units at 9.99 plus one at 0.07 come to 30.04 either way. Dirty lines, the ones a percentage discount or a currency conversion produces, do not.

The rule for extension code is to change inputs, not outputs: set the line price on the product object, let the cart sum its own cents, and let the order copy the cart. When you must compute a total, round with wc_get_rounding_precision() while working and with wc_get_price_decimals() only where a human reads it.

Lab 40 minutes

On a store with two price decimals and one 20% tax rate, take a product priced at 9.99 and make a 10% member discount that survives from the listing page to the paid order.

  • Filter the price with woocommerce_product_get_price and confirm the discounted figure on the shop page, in the cart, and on the order confirmation.
  • Print the value of wc_get_price_to_display() for that product on a tax-inclusive store and on a tax-exclusive store. Explain the difference in the number of decimals.
  • Write a throwaway script that computes line totals in cents the way the cart class does, using a price of 8.325 for three lines. Print the sum of the rounded lines and the rounded sum, each to two decimals.

Then answer: when the two figures differ, which one is on the order, and which single setting would make them agree?

Show the solution and the reasoning

The filter is small, and the second half of the exercise is where the lesson lives.

<?php
add_filter( 'woocommerce_product_get_price', function ( $price, $product ) {
    if ( '' === (string) $price ) {
        return $price;
    }

    $user = wp_get_current_user();
    if ( ! in_array( 'sifero_member', (array) $user->roles, true ) ) {
        return $price;
    }

    return wc_format_decimal( (float) $price * 0.9 );
}, 10, 2 );

Why this filter reaches the order. The cart reads get_price() when it builds totals, so the discounted price is what the cart sums. Checkout copies line_subtotal and line_total into the order item, so the order carries the discounted figure without reading the product again, and the confirmation renders the order's stored lines. One filter, three screens, because the value flowed through the pipeline once instead of being recomputed per screen.

Why wc_format_decimal() and not round(). Prices are stored as strings and run through WooCommerce's decimal normaliser, which handles the store's separator and keeps the internal precision. Returning a raw float works until a store sets a comma separator or three decimals, and then the price round-trips through wc_format_decimal() somewhere else and comes out different.

The tax figures. On the tax-exclusive store, wc_get_price_to_display() returns 11.99, a rounded display number. On the tax-inclusive store it returns 9.99 and the excluding-tax figure is 8.325, unrounded, because that value feeds further calculation. Round it to two decimals before using it and you have added 0.005 of drift that surfaces on a large order.

The two sums. This is exercise arithmetic rather than the shop's, so that the third decimal is visible: three lines at 8.325 with two price decimals. The cart converts the unit price to cents and keeps 832.5, because the internal precision is 6 rather than 2. Each line is rounded to 833 cents by round_item_subtotal(), so the sum of the rounded lines is 2499 cents, or 24.99. The unrounded line total is 24.975, and rounding that once gives 24.98. The order carries 24.99, because that is what the cart computed and checkout copied.

The setting that makes them agree is woocommerce_tax_round_at_subtotal, set to yes. It applies to tax as well as line totals, because the same trait method serves both, and it does not change orders already created under the other setting. The alternative is the one I would take: stop computing the total, set the line price, and read the total the cart computed.

One limit on all of the above. These figures come from scripts that mirror the arithmetic in WooCommerce 11.2.1, not from a running store, because no functioning WordPress and WooCommerce install was available here. The function bodies, option names and order of operations were read from that source tree, and the totals were then computed with PHP 8.5 using the same steps. Reproduce any number you intend to publish on your own store.

Progress

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

Course complete

That is the last lesson of WooCommerce Extension Development. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.