PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 All 164 releases
← All changes | includes/Integrations/WooCommerce_Tax.php +87 -39 1.10.9 → 1.10.20 View file →
@@ -8,9 +8,9 @@
8 8 namespace WCPOS\WooCommercePOS\Integrations;
9 9
10 10 use WC_Abstract_Order;
11 11 use WC_Order;
12 -use WP_REST_Request;
12 +use WCPOS\WooCommercePOS\Services\Order_Write_Intent;
13 13
14 14 /**
15 15 * Keep WooCommerce Tax from restoring stale tax lines onto open POS orders.
16 16 *
@@ -89,26 +89,14 @@
89 89 */
90 90 private $suspended = array();
91 91
92 92 /**
93 - * The order a WCPOS REST write is about to save, and the status it asked for.
94 - *
95 - * WooCommerce's REST controller recalculates totals before it applies the
96 - * requested status, so a request that reopens a paid order and changes its
97 - * lines recalculates while the persisted status is still paid.
98 - *
99 - * @var array{order: WC_Abstract_Order, status: string}|null
100 - */
101 - private $requested = null;
102 -
103 - /**
104 93 * Constructor.
105 94 *
106 - * Suspend at priority 9, before the plugin's snapshot callback at 10; resume
95 + * Prime at 8, suspend at 9 before the plugin's snapshot at 10, then resume
107 96 * from the last priority so nothing re-added runs in the same pass.
108 97 */
109 98 public function __construct() {
110 - add_filter( 'woocommerce_rest_pre_insert_shop_order_object', array( $this, 'note_requested_status' ), 10, 2 );
111 99 add_action( self::BEFORE_HOOK, array( $this, 'prime_tax_rates' ), 8, 2 );
112 100 add_action( self::BEFORE_HOOK, array( $this, 'suspend_tax_preservation' ), 9, 2 );
113 101 add_action( self::AFTER_HOOK, array( $this, 'resume_tax_preservation' ), self::RESUME_PRIORITY );
114 102 }
@@ -113,28 +101,8 @@
113 101 add_action( self::AFTER_HOOK, array( $this, 'resume_tax_preservation' ), self::RESUME_PRIORITY );
114 102 }
115 103
116 104 /**
117 - * Remember the status a REST write asked for, for the recalculation it triggers.
118 - *
119 - * @param mixed $order The order about to be saved.
120 - * @param WP_REST_Request|null $request The request.
121 - *
122 - * @return mixed The order, unchanged.
123 - */
124 - public function note_requested_status( $order, $request = null ) {
125 - $this->requested = null;
126 - if ( $order instanceof WC_Abstract_Order && $request instanceof WP_REST_Request ) {
127 - $this->requested = array(
128 - 'order' => $order,
129 - 'status' => (string) $request->get_param( 'status' ),
130 - );
131 - }
132 -
133 - return $order;
134 - }
135 -
136 - /**
137 105 * Prime the plugin's rates for the order before WooCommerce matches them.
138 106 *
139 107 * Mirrors the request the plugin's protected get_backend_line_items() builds
140 108 * (woocommerce-services 3.6.14), with one deliberate difference: items
@@ -201,8 +169,9 @@
201 169 // plugin reads it for its VAT-exemption check.
202 170 if ( ! WC()->customer instanceof \WC_Customer ) {
203 171 wc_load_cart();
204 172 }
173 + add_filter( 'woocommerce_services_override_tax_rate', array( $this, 'preserve_tax_rate_order' ), PHP_INT_MAX, 3 );
205 174 if ( false === $taxjar->calculate_tax( $options ) ) {
206 175 \WCPOS\WooCommercePOS\Logger::log( 'WooCommerce Tax returned no rates for the POS order', array( 'order_id' => $order->get_id() ) );
207 176 }
208 177 } catch ( \Throwable $e ) {
@@ -212,12 +181,80 @@
212 181 'order_id' => $order->get_id(),
213 182 'error' => $e->getMessage(),
214 183 )
215 184 );
185 + } finally {
186 + remove_filter( 'woocommerce_services_override_tax_rate', array( $this, 'preserve_tax_rate_order' ), PHP_INT_MAX );
216 187 }
217 188 }
218 189
219 190 /**
191 + * Preserve WooCommerce rate IDs when TaxJar jurisdiction fields change order.
192 + *
193 + * WooCommerce Tax assigns rows by response position, not jurisdiction. Only
194 + * reorder an exact label bijection; new/renamed jurisdictions keep upstream
195 + * behaviour. Values are untouched, including genuine rate changes. This hook
196 + * exposes the mutable response object before the plugin writes its rate rows.
197 + *
198 + * @param mixed $rate Overall rate, returned unchanged.
199 + * @param object $tax TaxJar tax response.
200 + * @param array $body Normalized TaxJar request address.
201 + * @return mixed
202 + */
203 + public function preserve_tax_rate_order( $rate, $tax, $body ) {
204 + $lines = \is_array( $tax->breakdown->line_items ?? null ) ? $tax->breakdown->line_items : array();
205 + if ( isset( $tax->breakdown->shipping ) ) {
206 + $lines[] = $tax->breakdown->shipping;
207 + }
208 + foreach ( $lines as $line ) {
209 + if ( ! \is_object( $line ) ) {
210 + continue;
211 + }
212 + $keys = array();
213 + foreach ( $line as $key => $value ) {
214 + if ( 'combined_tax_rate' === $key || false === strpos( $key, '_tax_rate' ) ) {
215 + continue;
216 + }
217 + // Mirrors the plugin's private generate_itemized_tax_rate_name().
218 + $label = ucwords( str_replace( '_', ' ', str_replace( '_tax_rate', '', $key ) ) ) . ' ' . __( 'Tax', 'woocommerce-services' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- Match the third-party rate labels.
219 + $place = trim( trim( $tax->jurisdictions->county ?? '' ) . ' ' . trim( $tax->jurisdictions->city ?? '' ) );
220 + $label = 'US' === $body['to_country'] ? ( '' === $place ? $label : $place . ' : ' . $label ) : strtoupper( $label );
221 + if ( isset( $keys[ $label ] ) ) {
222 + continue 2;
223 + }
224 + $keys[ $label ] = $key;
225 + }
226 + $product = wc_get_product( (int) ( $line->id ?? 0 ) );
227 + $rates = \WC_Tax::find_rates(
228 + array(
229 + 'country' => $body['to_country'],
230 + 'state' => $body['to_state'],
231 + 'postcode' => $body['to_zip'],
232 + 'city' => $body['to_city'],
233 + 'tax_class' => $product ? $product->get_tax_class() : '',
234 + )
235 + );
236 + if ( \count( $rates ) !== \count( $keys ) ) {
237 + continue;
238 + }
239 + $ordered = array();
240 + foreach ( $rates as $existing ) {
241 + if ( ! isset( $keys[ $existing['label'] ] ) ) {
242 + continue 2;
243 + }
244 + $key = $keys[ $existing['label'] ];
245 + $ordered[ $key ] = $line->$key;
246 + unset( $keys[ $existing['label'] ] );
247 + }
248 + foreach ( $ordered as $key => $value ) {
249 + unset( $line->$key );
250 + $line->$key = $value;
251 + }
252 + }
253 + return $rate;
254 + }
255 +
256 + /**
220 257 * Unhook the plugin's callbacks for an open POS order.
221 258 *
222 259 * @param array $args Args passed to calculate_taxes(). Unused.
223 260 * @param WC_Abstract_Order|null $order The order being recalculated.
@@ -259,8 +296,11 @@
259 296
260 297 /**
261 298 * Whether the order is, or is being put back to, still being built up at the till.
262 299 *
300 + * WooCommerce recalculates totals before applying the requested status, so
301 + * reopening a paid order recalculates while its persisted status is still paid.
302 + *
263 303 * @param WC_Abstract_Order $order The order.
264 304 *
265 305 * @return bool
266 306 */
@@ -268,11 +308,11 @@
268 308 if ( \in_array( $order->get_status(), self::OPEN_STATUSES, true ) ) {
269 309 return true;
270 310 }
271 311
272 - return null !== $this->requested
273 - && $this->requested['order'] === $order
274 - && \in_array( $this->requested['status'], self::OPEN_STATUSES, true );
312 + $intent = Order_Write_Intent::current();
313 + return null !== $intent && $intent->is_subject( $order )
314 + && \in_array( $intent->requested_status(), self::OPEN_STATUSES, true );
275 315 }
276 316
277 317 /**
278 318 * The street line that belongs to the address WooCommerce is taxing.
@@ -278,10 +318,12 @@
278 318 * The street line that belongs to the address WooCommerce is taxing.
279 319 *
280 320 * The declared basis (the POS meta, else WooCommerce's setting) is tried first
281 321 * so two addresses that share a country, state, postcode and city are told
282 - * apart; the tuple check keeps the street consistent with the location that
283 - * was actually resolved, which a filter may have changed.
322 + * apart — including the store's own address, which a local customer's billing
323 + * or shipping address can match exactly; the tuple check keeps the street
324 + * consistent with the location that was actually resolved, which a filter may
325 + * have changed.
284 326 *
285 327 * @param WC_Abstract_Order $order The order.
286 328 * @param array $location Country, state, postcode and city from get_taxable_location().
287 329 *
@@ -292,11 +334,17 @@
292 334 $basis = (string) $order->get_meta( '_woocommerce_pos_tax_based_on' );
293 335 if ( '' === $basis ) {
294 336 $basis = (string) get_option( 'woocommerce_tax_based_on', 'shipping' );
295 337 }
338 + // The store address is a candidate too, but LAST unless it is the
339 + // declared basis: when a filter moves the taxed location to the other
340 + // customer address, that address must win over a store that happens
341 + // to share its country, state, postcode and city.
342 + $countries = WC()->countries;
296 343 $candidates = array(
297 344 'billing' => array( $order->get_billing_address_1(), array( $order->get_billing_country(), $order->get_billing_state(), $order->get_billing_postcode(), $order->get_billing_city() ) ),
298 345 'shipping' => array( $order->get_shipping_address_1(), array( $order->get_shipping_country(), $order->get_shipping_state(), $order->get_shipping_postcode(), $order->get_shipping_city() ) ),
346 + 'base' => array( $countries->get_base_address(), array( $countries->get_base_country(), $countries->get_base_state(), $countries->get_base_postcode(), $countries->get_base_city() ) ),
299 347 );
300 348 if ( isset( $candidates[ $basis ] ) ) {
301 349 $candidates = array( $basis => $candidates[ $basis ] ) + $candidates;
302 350 }