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.
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_priceand 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.