*/ const PLUGIN_CALLBACKS = array( self::BEFORE_HOOK => 'preserve_order_taxes_on_recalculation', self::AFTER_HOOK => 'restore_order_taxes_after_recalculation', ); /** * Statuses whose tax is not yet a record of what was charged. * * @var string[] */ const OPEN_STATUSES = array( 'pending', 'pos-open', 'pos-partial' ); /** * Plugin callbacks removed for the recalculation in progress. * * @var array */ private $suspended = array(); /** * Constructor. * * Prime at 8, suspend at 9 before the plugin's snapshot at 10, then resume * from the last priority so nothing re-added runs in the same pass. */ public function __construct() { add_action( self::BEFORE_HOOK, array( $this, 'prime_tax_rates' ), 8, 2 ); add_action( self::BEFORE_HOOK, array( $this, 'suspend_tax_preservation' ), 9, 2 ); add_action( self::AFTER_HOOK, array( $this, 'resume_tax_preservation' ), self::RESUME_PRIORITY ); } /** * Prime the plugin's rates for the order before WooCommerce matches them. * * Mirrors the request the plugin's protected get_backend_line_items() builds * (woocommerce-services 3.6.14), with one deliberate difference: items * WooCommerce will not tax are left out. The plugin sends them as exempt and * skips their 0% breakdown line through a private list only its own builders * fill; without that list the 0% would be written over the shared rate row * for the item's tax class. Their rates are never needed here. * * @param array $args Calculation arguments. Unused. * @param WC_Abstract_Order|null $order The order being recalculated. */ public function prime_tax_rates( $args = array(), $order = null ): void { // A leftover suspension would hide the plugin's callbacks from the lookup below. $this->restore_suspended(); if ( ! $order instanceof WC_Abstract_Order || ! \wcpos_request() || ! $this->is_open_pos_order( $order ) ) { return; } $callbacks = $this->find_plugin_callbacks( self::BEFORE_HOOK, self::PLUGIN_CALLBACKS[ self::BEFORE_HOOK ] ); if ( empty( $callbacks ) ) { return; } $taxjar = $callbacks[0]['callback'][0]; // get_taxable_location() is public since WooCommerce 7.6. Older stores keep // today's behaviour rather than a warning on every save. if ( version_compare( WC_VERSION, '7.6.0', '<' ) ) { return; } try { $location = $order->get_taxable_location(); $options = array( 'to_country' => $location['country'] ?? '', 'to_state' => $location['state'] ?? '', 'to_zip' => $location['postcode'] ?? '', 'to_city' => $location['city'] ?? '', 'to_street' => $this->street_for_location( $order, $location ), 'shipping_amount' => $order->get_shipping_total(), 'line_items' => array(), ); foreach ( $order->get_items( 'line_item' ) as $item ) { if ( 'taxable' !== $item->get_tax_status() ) { continue; } $quantity = $item->get_quantity(); $unit_price = empty( $quantity ) ? $item->get_subtotal() : wc_format_decimal( $item->get_subtotal() / $quantity ); if ( empty( $unit_price ) ) { continue; } $tax_class = explode( '-', $item->get_tax_class() ); $options['line_items'][] = array( 'id' => (string) ( $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id() ), 'quantity' => $quantity, 'unit_price' => $unit_price, 'discount' => wc_format_decimal( $item->get_subtotal() - $item->get_total() ), 'product_tax_code' => isset( $tax_class[1] ) && is_numeric( $tax_class[1] ) ? $tax_class[1] : '', ); } if ( empty( $options['line_items'] ) && empty( (float) $options['shipping_amount'] ) ) { return; } // WooCommerce does not initialise the customer on REST requests, and the // plugin reads it for its VAT-exemption check. if ( ! WC()->customer instanceof \WC_Customer ) { wc_load_cart(); } add_filter( 'woocommerce_services_override_tax_rate', array( $this, 'preserve_tax_rate_order' ), PHP_INT_MAX, 3 ); if ( false === $taxjar->calculate_tax( $options ) ) { \WCPOS\WooCommercePOS\Logger::log( 'WooCommerce Tax returned no rates for the POS order', array( 'order_id' => $order->get_id() ) ); } } catch ( \Throwable $e ) { \WCPOS\WooCommercePOS\Logger::warning( 'WooCommerce Tax rate priming failed', array( 'order_id' => $order->get_id(), 'error' => $e->getMessage(), ) ); } finally { remove_filter( 'woocommerce_services_override_tax_rate', array( $this, 'preserve_tax_rate_order' ), PHP_INT_MAX ); } } /** * Preserve WooCommerce rate IDs when TaxJar jurisdiction fields change order. * * WooCommerce Tax assigns rows by response position, not jurisdiction. Only * reorder an exact label bijection; new/renamed jurisdictions keep upstream * behaviour. Values are untouched, including genuine rate changes. This hook * exposes the mutable response object before the plugin writes its rate rows. * * @param mixed $rate Overall rate, returned unchanged. * @param object $tax TaxJar tax response. * @param array $body Normalized TaxJar request address. * @return mixed */ public function preserve_tax_rate_order( $rate, $tax, $body ) { $lines = \is_array( $tax->breakdown->line_items ?? null ) ? $tax->breakdown->line_items : array(); if ( isset( $tax->breakdown->shipping ) ) { $lines[] = $tax->breakdown->shipping; } foreach ( $lines as $line ) { if ( ! \is_object( $line ) ) { continue; } $keys = array(); foreach ( $line as $key => $value ) { if ( 'combined_tax_rate' === $key || false === strpos( $key, '_tax_rate' ) ) { continue; } // Mirrors the plugin's private generate_itemized_tax_rate_name(). $label = ucwords( str_replace( '_', ' ', str_replace( '_tax_rate', '', $key ) ) ) . ' ' . __( 'Tax', 'woocommerce-services' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- Match the third-party rate labels. $place = trim( trim( $tax->jurisdictions->county ?? '' ) . ' ' . trim( $tax->jurisdictions->city ?? '' ) ); $label = 'US' === $body['to_country'] ? ( '' === $place ? $label : $place . ' : ' . $label ) : strtoupper( $label ); if ( isset( $keys[ $label ] ) ) { continue 2; } $keys[ $label ] = $key; } $product = wc_get_product( (int) ( $line->id ?? 0 ) ); $rates = \WC_Tax::find_rates( array( 'country' => $body['to_country'], 'state' => $body['to_state'], 'postcode' => $body['to_zip'], 'city' => $body['to_city'], 'tax_class' => $product ? $product->get_tax_class() : '', ) ); if ( \count( $rates ) !== \count( $keys ) ) { continue; } $ordered = array(); foreach ( $rates as $existing ) { if ( ! isset( $keys[ $existing['label'] ] ) ) { continue 2; } $key = $keys[ $existing['label'] ]; $ordered[ $key ] = $line->$key; unset( $keys[ $existing['label'] ] ); } foreach ( $ordered as $key => $value ) { unset( $line->$key ); $line->$key = $value; } } return $rate; } /** * Unhook the plugin's callbacks for an open POS order. * * @param array $args Args passed to calculate_taxes(). Unused. * @param WC_Abstract_Order|null $order The order being recalculated. */ public function suspend_tax_preservation( $args = array(), $order = null ): void { // A recalculation that never reached AFTER_HOOK (a direct calculate_taxes() // call) leaves the callbacks suspended. Hook them back before deciding // about this order, so a suspension never outlives one recalculation. $this->restore_suspended(); if ( ! $order instanceof WC_Abstract_Order || ! $this->is_open_pos_order( $order ) || ! \wcpos_request() ) { return; } foreach ( self::PLUGIN_CALLBACKS as $hook => $method ) { foreach ( $this->find_plugin_callbacks( $hook, $method ) as $entry ) { remove_action( $hook, $entry['callback'], $entry['priority'] ); $this->suspended[] = $entry; } } } /** * Hook the plugin's callbacks again once the recalculation is done. */ public function resume_tax_preservation(): void { $this->restore_suspended(); } /** * Re-add every suspended callback where it was. */ private function restore_suspended(): void { foreach ( $this->suspended as $entry ) { add_action( $entry['hook'], $entry['callback'], $entry['priority'], $entry['accepted_args'] ); } $this->suspended = array(); } /** * Whether the order is, or is being put back to, still being built up at the till. * * WooCommerce recalculates totals before applying the requested status, so * reopening a paid order recalculates while its persisted status is still paid. * * @param WC_Abstract_Order $order The order. * * @return bool */ private function is_open_pos_order( WC_Abstract_Order $order ): bool { if ( \in_array( $order->get_status(), self::OPEN_STATUSES, true ) ) { return true; } $intent = Order_Write_Intent::current(); return null !== $intent && $intent->is_subject( $order ) && \in_array( $intent->requested_status(), self::OPEN_STATUSES, true ); } /** * The street line that belongs to the address WooCommerce is taxing. * * The declared basis (the POS meta, else WooCommerce's setting) is tried first * so two addresses that share a country, state, postcode and city are told * apart — including the store's own address, which a local customer's billing * or shipping address can match exactly; the tuple check keeps the street * consistent with the location that was actually resolved, which a filter may * have changed. * * @param WC_Abstract_Order $order The order. * @param array $location Country, state, postcode and city from get_taxable_location(). * * @return string */ private function street_for_location( WC_Abstract_Order $order, array $location ): string { if ( $order instanceof WC_Order ) { $basis = (string) $order->get_meta( '_woocommerce_pos_tax_based_on' ); if ( '' === $basis ) { $basis = (string) get_option( 'woocommerce_tax_based_on', 'shipping' ); } // The store address is a candidate too, but LAST unless it is the // declared basis: when a filter moves the taxed location to the other // customer address, that address must win over a store that happens // to share its country, state, postcode and city. $countries = WC()->countries; $candidates = array( 'billing' => array( $order->get_billing_address_1(), array( $order->get_billing_country(), $order->get_billing_state(), $order->get_billing_postcode(), $order->get_billing_city() ) ), 'shipping' => array( $order->get_shipping_address_1(), array( $order->get_shipping_country(), $order->get_shipping_state(), $order->get_shipping_postcode(), $order->get_shipping_city() ) ), 'base' => array( $countries->get_base_address(), array( $countries->get_base_country(), $countries->get_base_state(), $countries->get_base_postcode(), $countries->get_base_city() ) ), ); if ( isset( $candidates[ $basis ] ) ) { $candidates = array( $basis => $candidates[ $basis ] ) + $candidates; } $taxed = array( $location['country'] ?? '', $location['state'] ?? '', $location['postcode'] ?? '', $location['city'] ?? '' ); foreach ( $candidates as $candidate ) { if ( $candidate[1] === $taxed ) { return (string) $candidate[0]; } } } return (string) WC()->countries->get_base_address(); } /** * Locate the plugin's callback for a method on a hook, at whatever priority. * * @param string $hook The hook. * @param string $method The plugin method name. * * @return array */ private function find_plugin_callbacks( string $hook, string $method ): array { global $wp_filter; $found = array(); if ( ! isset( $wp_filter[ $hook ] ) ) { return $found; } foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $entry ) { $callback = $entry['function']; if ( \is_array( $callback ) && isset( $callback[0], $callback[1] ) && \is_object( $callback[0] ) && is_a( $callback[0], self::TAXJAR_CLASS ) && $method === $callback[1] ) { $found[] = array( 'hook' => $hook, 'callback' => $callback, 'priority' => (int) $priority, 'accepted_args' => (int) $entry['accepted_args'], ); } } } return $found; } }