Skip to content

Commit 028ceb6

Browse files
authored
Fix - Non-taxable product no longer zeroes shared standard tax rate (WOOTAX-240) (#2979)
* Fix - Non-taxable product no longer zeroes shared standard tax rate (WOOTAX-240) In a cart mixing a taxable product and a non-taxable one (Tax Status = "None", Tax Class = "Standard"), taxes stopped calculating for the whole cart. get_itemized_tax_rates() writes one WooCommerce tax rate row per TaxJar breakdown line item, keyed by the product's Tax Class. The non-taxable product is sent to TaxJar as exempt (code 99999) so its breakdown line comes back at 0%, but its Tax Class is still Standard — so create_or_update_tax_rate() overwrote the shared Standard rate row (already populated by the taxable product) with 0%, zeroing tax for every standard item. The zeroed row persisted in wp_woocommerce_tax_rates, which is why adding another taxable product did not restore it. Skip rate-row writes for line items whose product has Tax Status "None". WooCommerce already applies no tax to such products, and this leaves the shared class rate intact for genuinely taxable items. Zero-rate *class* products are unaffected (they own a separate rate row). Note: the tax status check in get_line_items() is not the cause — a tax_status "none" product is already correctly sent as code 99999. The defect was downstream, in how the itemized rate rows are written. Adds a regression test driving get_itemized_tax_rates() with a mixed taxable/exempt breakdown; it fails on trunk (exempt line writes/zeroes the shared row) and passes with this fix. * Widen non-taxable rate-row guard to cover "Shipping only" status The guard added for the mixed-cart zeroing bug only skipped rate-row writes for Tax Status "None". The backend order path emits the exempt code 99999 for any status other than "taxable", so a "Shipping only" product still returned a 0% breakdown line that overwrote the shared Standard rate row — the same failure, on the admin order screen. Guard on `'taxable' !== $tax_status` instead. ProductTaxStatus defines exactly three statuses and set_tax_status() normalises empty to "taxable", so this is precisely "none or shipping". Skipping the write is safe for both: WC_Cart_Totals gates item tax on is_taxable() and WC_Order_Item::calculate_taxes() on ProductTaxStatus::TAXABLE, so neither status is taxed by core. Shipping tax is unaffected — that rate row is written separately after this loop. Adds coverage for both sides of the case: that the backend path sends a "Shipping only" product as exempt, and that its 0% line leaves the Standard row intact. * Record taxability at request time instead of re-deriving it The response-side guard read the raw tax status while get_line_items() set the exempt code from is_taxable(), which is filtered through woocommerce_product_is_taxable. When the two disagreed, the 0% TaxJar breakdown line still overwrote the shared tax class rate row. Record the decision where it is made and read it back by line item key, so both halves share one predicate. The cart path records is_taxable() and the order path records the raw status, matching how WC_Cart_Totals and WC_Order_Item::calculate_taxes() each gate item tax. Line items sent as exempt for the zero-rate class are not recorded and keep updating their own rate row. Drive the regression tests through the request side rather than fabricating breakdown keys, which could not catch the divergence, and move the "Shipping only" case to the backend order path where that status is actually emitted as exempt. * Fix - Do not record "Shipping only" cart lines as non-taxable. The recording added on the cart path used an unconditional ! is_taxable(), which is false for every Tax Status other than "taxable" -- including "shipping". The exempt branch three lines above deliberately excludes that status, so a "Shipping only" product is sent to TaxJar as genuinely taxable and its breakdown line comes back with a real, non-zero rate. Recording it made get_itemized_tax_rates() skip the write and throw that rate away. This is the one case where skipping is not preventing a 0% clobber but discarding good data. WooCommerce applies no item tax to such a product, so the skip looks harmless, but the row it would have written carries tax_rate_shipping: with the default woocommerce_shipping_tax_class = 'inherit', WC_Cart::get_cart_item_tax_classes_for_shipping() includes the product (its shipping is taxable), WooCommerce resolves the shipping tax class to that product's class, and WC_Tax::get_shipping_tax_rates() looks the rate up there. TaxJar's separate shipping write only ever covers the standard class, so for a "Shipping only" product in a non-standard class nothing kept that row current. The condition now mirrors the exempt branch, so the cart path records exactly what it emitted. The backend order path already did: there the same 'taxable' !== $tax_status test is what sets the 99999 code, so recording on it is correct rather than inconsistent. The divergence was between the two paths' *recording*, not their emission -- the emission difference is pre-existing and deliberate. Two tests, covering both sides of the boundary: - test_get_line_items_does_not_record_shipping_only_status_as_non_taxable -- fails against the previous condition, which recorded '10-<key>' => true. - test_get_line_items_still_records_none_status_as_non_taxable -- passes in both states, pinning that the exclusion narrows by exactly one status and that a line genuinely emitted as exempt stays recorded. The cart path had no coverage for this status, which is why it slipped through; the only "Shipping only" tests were on the order path. Full suite 237 tests / 527 assertions green. * Tweak - Move the stranded docblock onto the method it documents. The docblock for test_get_backend_line_items_sends_shipping_only_status_as_exempt() sat above the cart-path test instead, leaving the backend test with no docblock. PHPCS binds a docblock to the immediately following token, so the orphan became a floating comment and Squiz.Commenting.FunctionComment.Missing fired on the method 60 lines below it. WordPress-Docs loads globally in .phpcs.php.xml and its tests/ exclusions do not cover Squiz.Commenting.FunctionComment, so the sniff does apply to tests/. Measured across both changed PHP files, --report=source: merge-base 590070b : 192 violations / 31 sources before this commit : 193 / 31 (FunctionComment.Missing 3 -> 4) after this commit : 192 / 31, byte-identical to baseline * Tweak - Restore the changelog entry to the 3.6.12 section after rebase. The rebase onto trunk placed the WOOTAX-240 entry into the released 3.6.8 section instead of 3.6.12, in both changelog.txt and readme.txt. The three-way merge anchored on the similar "unbounded growth of WooCommerce tax rate rows" line that 3.6.8 already carried, and the 3.6.12 header the original commit had created now came from trunk. No test covers changelog placement, so this was invisible to CI.
1 parent 1512d21 commit 028ceb6

4 files changed

Lines changed: 471 additions & 5 deletions

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* Fix - Fall back to the store address when a custom nexus address is incomplete instead of abandoning the calculation.
1212
* Fix - Prevent unbounded growth of WooCommerce tax rate rows when the customer state contains a character WooCommerce strips before storing it, such as a period or an accented letter.
1313
* Fix - Restore tax rates on the price display, shipping tax and coupon paths for addresses whose state was stored in a normalized form.
14+
* Fix - Calculate tax correctly for carts mixing taxable and non-taxable products, so a non-taxable product no longer resets the standard tax rate to zero.
1415
* Tweak - Centralize TaxJar address handling in an internal value object. No change to tax calculation.
1516
* Tweak - Store tax rate rows against the postcode the rate was quoted for when the address postcode holds more than one comma-separated value.
1617

classes/class-wc-connect-taxjar-integration.php

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ class WC_Connect_TaxJar_Integration {
8686
*/
8787
private $backend_tax_classes;
8888

89+
/**
90+
* Line items WooCommerce will not apply item tax to.
91+
*
92+
* Recorded while the TaxJar request is built and read back when the response is
93+
* turned into tax rate rows, so both halves share a single taxability decision
94+
* instead of each re-deriving one. Keyed by TaxJar line item id, values unused.
95+
*
96+
* @var array<string, bool>
97+
*/
98+
private $non_taxable_line_items = array();
99+
89100
/**
90101
* Tracks instance.
91102
*
@@ -962,8 +973,9 @@ protected function get_backend_address() {
962973
* @return array
963974
*/
964975
protected function get_line_items( $wc_cart_object ) {
965-
$line_items = array();
966-
$default_location = get_option( 'woocommerce_tax_based_on', 'shipping' );
976+
$line_items = array();
977+
$this->non_taxable_line_items = array();
978+
$default_location = get_option( 'woocommerce_tax_based_on', 'shipping' );
967979

968980
foreach ( $wc_cart_object->get_cart() as $cart_item_key => $cart_item ) {
969981
$product = $cart_item['data'];
@@ -983,6 +995,29 @@ protected function get_line_items( $wc_cart_object ) {
983995
$tax_code = '99999';
984996
}
985997

998+
// WC_Cart_Totals gates item tax on is_taxable(), which is filterable, so a
999+
// product can be untaxed while still reporting Tax Status "taxable". Record
1000+
// the decision here rather than re-deriving it from the response, where the
1001+
// two can disagree and a 0% line ends up overwriting a shared rate row.
1002+
//
1003+
// The 'shipping' exclusion mirrors the exempt branch directly above, and has
1004+
// to: a "Shipping only" product is excluded there, so this path sends it to
1005+
// TaxJar as taxable and its breakdown line comes back with a real, non-zero
1006+
// rate. Recording it would make get_itemized_tax_rates() skip that write --
1007+
// the one case where skipping discards good data instead of preventing a 0%
1008+
// clobber. WooCommerce applies no *item* tax to such a product, but the row
1009+
// still matters: it carries `tax_rate_shipping`, and with the default
1010+
// `woocommerce_shipping_tax_class = 'inherit'` WooCommerce resolves the
1011+
// shipping tax class to this product's class and looks the rate up there.
1012+
// TaxJar's own shipping write only ever covers the standard class.
1013+
//
1014+
// The backend order path records on `'taxable' !== $tax_status` instead, and
1015+
// that is correct rather than inconsistent: there the same condition is what
1016+
// sets the 99999 exempt code, so it too records exactly what it emitted.
1017+
if ( 'shipping' !== $product->get_tax_status() && ! $product->is_taxable() ) {
1018+
$this->non_taxable_line_items[ $id . '-' . $cart_item_key ] = true;
1019+
}
1020+
9861021
// Get WC Subscription sign-up fees for calculations
9871022
if ( class_exists( 'WC_Subscriptions_Cart' ) ) {
9881023
if ( 'none' == WC_Subscriptions_Cart::get_calculation_type() ) {
@@ -1039,9 +1074,10 @@ protected function get_line_items( $wc_cart_object ) {
10391074
* @return array
10401075
*/
10411076
protected function get_backend_line_items( $order ) {
1042-
$line_items = array();
1043-
$this->backend_tax_classes = array();
1044-
$default_location = get_option( 'woocommerce_tax_based_on', 'shipping' );
1077+
$line_items = array();
1078+
$this->backend_tax_classes = array();
1079+
$this->non_taxable_line_items = array();
1080+
$default_location = get_option( 'woocommerce_tax_based_on', 'shipping' );
10451081

10461082
foreach ( $order->get_items() as $item_key => $item ) {
10471083
if ( is_object( $item ) ) { // Woo 3.0+
@@ -1069,6 +1105,11 @@ protected function get_backend_line_items( $order ) {
10691105
}
10701106
if ( 'taxable' !== $tax_status ) {
10711107
$tax_code = '99999';
1108+
1109+
// WC_Order_Item::calculate_taxes() gates item tax on the raw tax status,
1110+
// so the order path records exactly what it emitted as exempt. See the
1111+
// matching note in get_line_items() for why this is recorded, not derived.
1112+
$this->non_taxable_line_items[ $id . '-' . $item_key ] = true;
10721113
}
10731114

10741115
/** This filter is documented in get_line_items() */
@@ -1780,6 +1821,15 @@ private function get_itemized_tax_rates( $taxes, $taxjar_taxes, $options ): arra
17801821

17811822
// Add line item tax rates.
17821823
foreach ( $taxes['line_items'] as $line_item_key => $line_item ) {
1824+
// A line item WooCommerce will not tax was sent to TaxJar as exempt, so its
1825+
// breakdown line comes back at 0% — yet it keeps its Tax Class. Since rate
1826+
// rows are keyed by tax class, writing that 0% overwrites the shared row
1827+
// used by genuinely taxable products in the same class, zeroing tax for the
1828+
// whole cart. Skip the write; WooCommerce applies no item tax to it anyway.
1829+
if ( isset( $this->non_taxable_line_items[ $line_item_key ] ) ) {
1830+
continue;
1831+
}
1832+
17831833
$line_item_key_chunks = explode( '-', $line_item_key );
17841834
$product_id = $line_item_key_chunks[0];
17851835
$product = wc_get_product( $product_id );

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ This plugin relies on the following external services:
8181
* Fix - Fall back to the store address when a custom nexus address is incomplete instead of abandoning the calculation.
8282
* Fix - Prevent unbounded growth of WooCommerce tax rate rows when the customer state contains a character WooCommerce strips before storing it, such as a period or an accented letter.
8383
* Fix - Restore tax rates on the price display, shipping tax and coupon paths for addresses whose state was stored in a normalized form.
84+
* Fix - Calculate tax correctly for carts mixing taxable and non-taxable products, so a non-taxable product no longer resets the standard tax rate to zero.
8485
* Tweak - Centralize TaxJar address handling in an internal value object. No change to tax calculation.
8586
* Tweak - Store tax rate rows against the postcode the rate was quoted for when the address postcode holds more than one comma-separated value.
8687

0 commit comments

Comments
 (0)