PluginProbe ʕ •ᴥ•ʔ
WooCommerce Square / 5.5.0
WooCommerce Square v5.5.0
5.5.0 5.4.3 5.4.2 5.4.1 5.4.0 trunk 1.0.25 1.0.26 1.0.27 1.0.28 1.0.29 1.0.30 1.0.31 1.0.32 1.0.33 1.0.34 1.0.35 1.0.36 1.0.37 1.0.38 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3.0 2.3.1 2.3.2 2.3.3 2.3.4 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.6.0 2.7.0 2.8.0 2.9.0 2.9.1 3.0.0 3.0.1 3.0.2 3.0.3 3.1.0 3.2.0 3.3.0 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.7.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 4.0.0 4.1.0 4.2.0 4.2.1 4.2.2 4.2.3 4.3.0 4.3.1 4.3.2 4.4.0 4.4.1 4.4.2 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.6.2 4.6.3 4.6.4 4.7.0 4.7.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.1 4.8.2 4.8.3 4.8.4 4.8.5 4.8.6 4.8.7 4.8.8 4.9.0 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 4.9.6 4.9.7 4.9.8 4.9.9 5.0.0 5.0.1 5.1.0 5.1.1 5.1.2 5.2.0 5.3.0 5.3.1 5.3.2 5.3.3
woocommerce-square / includes / Sync / Interval_Polling.php
woocommerce-square / includes / Sync Last commit date
Records 3 months ago Catalog_Item.php 9 months ago Helper.php 3 days ago Interval_Polling.php 3 days ago Job.php 2 years ago Manual_Synchronization.php 3 days ago Order_Importer.php 11 months ago Order_Mapper.php 11 months ago Order_Polling.php 11 months ago Product_Import.php 3 days ago Records.php 2 years ago Stepped_Job.php 3 days ago
Interval_Polling.php
539 lines
1 <?php
2 /**
3 * WooCommerce Square
4 *
5 * This source file is subject to the GNU General Public License v3.0
6 * that is bundled with this package in the file license.txt.
7 * It is also available through the world-wide-web at this URL:
8 * http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
9 * If you did not receive a copy of the license and are unable to
10 * obtain it through the world-wide-web, please send an email
11 * to license@woocommerce.com so we can send you a copy immediately.
12 *
13 * DISCLAIMER
14 *
15 * Do not edit or add to this file if you wish to upgrade WooCommerce Square to newer
16 * versions in the future. If you wish to customize WooCommerce Square for your
17 * needs please refer to https://docs.woocommerce.com/document/woocommerce-square/
18 *
19 * @author WooCommerce
20 * @copyright Copyright: (c) 2019, Automattic, Inc.
21 * @license http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
22 */
23
24 namespace WooCommerce\Square\Sync;
25
26 use Square\Models\SearchCatalogObjectsResponse;
27 use Square\Models\BatchRetrieveInventoryCountsResponse;
28 use WooCommerce\Square\Handlers\Product;
29 use WooCommerce\Square\Handlers\Category;
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Class to represent a synchronization job to poll latest product updates at intervals.
35 *
36 * @since 2.0.0
37 */
38 class Interval_Polling extends Stepped_Job {
39
40 /**
41 * Assigns the next steps needed for this sync job.
42 *
43 * Adds the next steps to the 'next_steps' attribute.
44 *
45 * @since 2.0.0
46 */
47 protected function assign_next_steps() {
48
49 $next_steps = array();
50
51 if ( $this->is_system_of_record_square() ) {
52
53 $next_steps = array(
54 'update_category_data',
55 'update_product_data',
56 );
57 }
58
59 // only pull latest inventory if enabled
60 if ( wc_square()->get_settings_handler()->is_inventory_sync_enabled() ) {
61 $next_steps[] = 'update_inventory_tracking';
62 $next_steps[] = 'update_inventory_counts';
63 }
64
65 $this->set_attr( 'next_steps', $next_steps );
66 }
67
68 /**
69 * Updates categories from Square.
70 *
71 * @since 2.0.8
72 *
73 * @throws \Exception
74 */
75 protected function update_category_data() {
76 $date = new \DateTime();
77 $date->setTimestamp( $this->get_attr( 'catalog_last_synced_at', (int) wc_square()->get_sync_handler()->get_last_synced_at() ) );
78 $date->setTimezone( new \DateTimeZone( 'UTC' ) );
79
80 $count = 0;
81 $response = wc_square()->get_api()->search_catalog_objects(
82 array(
83 'object_types' => array( 'CATEGORY' ),
84 'begin_time' => $date->format( DATE_ATOM ),
85 )
86 );
87
88 if ( $response->get_data() instanceof SearchCatalogObjectsResponse ) {
89 $categories = $response->get_data()->getObjects();
90
91 if ( $categories && is_array( $categories ) ) {
92 foreach ( $categories as $category ) {
93 Category::import_or_update( $category );
94 }
95 $count = count( $categories );
96
97 Records::set_record(
98 array(
99 'type' => 'info',
100 'message' => sprintf(
101 /* translators: Placeholder %d number of categories. */
102 _n( 'Updated data for %d category.', 'Updated data for %d categories.', count( $categories ), 'woocommerce-square' ),
103 count( $categories )
104 ),
105 )
106 );
107 }
108 } else {
109 Records::set_record(
110 array(
111 'type' => 'alert',
112 'message' => esc_html__( 'Product category data could not be updated from Square. Invalid API response.', 'woocommerce-square' ),
113 )
114 );
115 }
116
117 $this->set_attr( 'update_category_data_count', $count );
118 $this->complete_step( 'update_category_data' );
119 }
120
121 /**
122 * Updates products from Square.
123 *
124 * @since 2.0.0
125 *
126 * @throws \Exception
127 */
128 protected function update_product_data() {
129 $date = new \DateTime();
130 $date->setTimestamp( $this->get_attr( 'catalog_last_synced_at', (int) wc_square()->get_sync_handler()->get_last_synced_at() ) );
131 $date->setTimezone( new \DateTimeZone( 'UTC' ) );
132
133 $products_updated = $this->get_attr( 'processed_product_ids', array() );
134 $cursor = $this->get_attr( 'update_product_data_cursor' );
135
136 $response = wc_square()->get_api()->search_catalog_objects(
137 array(
138 'object_types' => array( 'ITEM' ),
139 'include_deleted_objects' => true,
140 'begin_time' => $date->format( DATE_ATOM ),
141 'cursor' => $cursor,
142 )
143 );
144
145 // store the timestamp after this API request was completed
146 // we don't want to set it at the end, as counts may have changed in the time it takes to process the data
147 if ( ! $cursor ) {
148 wc_square()->get_sync_handler()->set_last_synced_at();
149 }
150
151 if ( $response->get_data() instanceof SearchCatalogObjectsResponse && is_array( $response->get_data()->getObjects() ) ) {
152
153 $product_import = new Product_Import();
154
155 foreach ( $response->get_data()->getObjects() as $object ) {
156
157 // filter out objects that aren't at our configured location
158 if ( ! $object->getPresentAtAllLocations() && ( ! is_array( $object->getPresentAtLocationIds() ) || ! in_array( wc_square()->get_settings_handler()->get_location_id(), $object->getPresentAtLocationIds(), true ) ) ) {
159 continue;
160 }
161
162 $product = Product::get_product_by_square_id( $object->getId() );
163
164 if ( $product instanceof \WC_Product ) {
165 if ( ! in_array( $product->get_type(), wc_square()->get_sync_handler()->supported_product_types(), true ) ) {
166 Records::set_record(
167 array(
168 'type' => 'alert',
169 'message' => sprintf(
170 /* translators: %1$s - product edit page URL, %2$s - Product ID, %3$s - Product type. */
171 __( 'Product <a href="%1$s">#%2$s</a> is excluded from sync as the product type "%3$s" is unsupported.', 'woocommerce-square' ),
172 get_edit_post_link( $product->get_id() ),
173 $product->get_id(),
174 $product->get_type()
175 ),
176 )
177 );
178
179 continue;
180 }
181
182 // deleted items won't have any data to set, so don't try and update the product
183 if ( $object->getIsDeleted() ) {
184
185 $record = array(
186 'type' => 'alert',
187 'product_id' => $product->get_id(),
188 );
189
190 // if enabled, hide the product from the catalog
191 if ( wc_square()->get_settings_handler()->hide_missing_square_products() ) {
192
193 try {
194
195 $product->set_catalog_visibility( 'hidden' );
196 $product->save();
197
198 $record['product_hidden'] = true;
199
200 } catch ( \Exception $e ) {
201 /* translators: Placeholder %1$s Product Name, %2$s Exception message */
202 $record['message'] = sprintf( esc_html__( '%1$s was deleted in Square but could not be hidden in WooCommerce. %2$s.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>', $e->getMessage() );
203 }
204 }
205
206 Records::set_record( $record );
207
208 } else {
209
210 try {
211 $data = $product_import->extract_product_data( $object, $product );
212
213 /**
214 * Filters the data that is used to create update a WooCommerce product during import.
215 *
216 * @since 2.0.0
217 *
218 * @param array $data product data
219 * @param \Square\Models\CatalogObject $object the catalog object from the Square API
220 * @param Interval_Polling $this current class instance
221 */
222 $data = apply_filters( 'woocommerce_square_create_product_data', $data, $object, $this );
223
224 // Update the product, this will update/create the variations as well.
225 $product_import->update_product( $product, $data );
226 Product::update_from_square( $product, $object->getItemData(), false );
227
228 $products_updated[] = $product->get_id();
229
230 } catch ( \Exception $exception ) {
231
232 Records::set_record(
233 array(
234 'type' => 'alert',
235 'product_id' => $product->get_id(),
236 /* translators: Placeholder %1$s Product Name, %2$s Exception message */
237 'message' => sprintf( esc_html__( 'Could not sync %1$s data from Square. %2$s.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>', $exception->getMessage() ),
238 )
239 );
240 }
241 }
242 }
243 }
244 }
245
246 $cursor = $response->get_data() instanceof SearchCatalogObjectsResponse ? $response->get_data()->getCursor() : null;
247
248 $this->set_attr( 'update_product_data_cursor', $cursor );
249 $this->set_attr( 'processed_product_ids', array_unique( $products_updated ) );
250 $this->set_attr( 'update_product_data_count', count( array_unique( $products_updated ) ) );
251
252 if ( ! $cursor ) {
253 $this->complete_step( 'update_product_data' );
254 }
255 }
256
257 /**
258 * Updates the inventory tracking value from the latest in Square.
259 *
260 * Helper method, do not open to public.
261 *
262 * @since 3.8.2
263 *
264 * @throws \Exception
265 */
266 protected function update_inventory_tracking() {
267 $products_updated = $this->get_attr( 'processed_product_ids' );
268 $cursor = $this->get_attr( 'update_inventory_tracking_cursor', null );
269 $last_synced_at = $this->get_attr( 'inventory_last_synced_at' );
270 $args = array(
271 'object_types' => array( 'ITEM_VARIATION' ),
272 'limit' => 100,
273 'cursor' => $cursor,
274 );
275
276 if ( $last_synced_at ) {
277 $date = new \DateTime();
278 $date->setTimestamp( $last_synced_at );
279 $date->setTimezone( new \DateTimeZone( 'UTC' ) );
280 $args['begin_time'] = $date->format( DATE_ATOM );
281 }
282
283 $search_result = wc_square()->get_api()->search_catalog_objects( $args );
284
285 if ( ! $search_result->get_data() instanceof SearchCatalogObjectsResponse ) {
286 throw new \Exception( 'API response data is invalid' );
287 }
288
289 $objects = $search_result->get_data()->getObjects() ? $search_result->get_data()->getObjects() : array();
290 $cursor = $search_result->get_data() instanceof SearchCatalogObjectsResponse ? $search_result->get_data()->getCursor() : null;
291
292 $catalog_objects_tracking_stats = Helper::get_catalog_inventory_tracking( $objects );
293 $catalog_objects_to_update = array();
294
295 foreach ( $catalog_objects_tracking_stats as $catalog_object_id => $inventory_data ) {
296 $is_tracking_inventory = $inventory_data['track_inventory'] ?? false;
297 $sold_out = $inventory_data['sold_out'] ?? false;
298 $product = Product::get_product_by_square_variation_id( $catalog_object_id );
299 if ( $product instanceof \WC_Product ) {
300 // Respect the per-product "Sync with Square" setting: the push side already honours
301 // it, and the automatic pull must too, or unticking/unlinking cannot stop Square
302 // from overwriting this product's stock.
303 if ( ! Product::is_synced_with_square( $product ) ) {
304 continue;
305 }
306
307 $manage_stock = $product->get_manage_stock();
308 $stock_status = $product->get_stock_status();
309 $out_of_stock = 'outofstock' === $stock_status;
310
311 // Nothing to do when the product already agrees with Square.
312 //
313 // Tracking is only worth comparing against manage_stock where the poll can still
314 // change it, which is Square SOR. Under WooCommerce SOR the poll leaves manage_stock
315 // alone, so a product tracked off in Square with Manage stock on in WooCommerce would
316 // never satisfy that comparison and would be saved on every poll for ever. There,
317 // availability is the only thing the poll writes, so it is the only thing to compare.
318 $square_owns_stock_setting = wc_square()->get_settings_handler()->is_system_of_record_square();
319 $tracking_already_matches = ! $square_owns_stock_setting || (bool) $is_tracking_inventory === (bool) $manage_stock;
320
321 if ( $tracking_already_matches && (bool) $sold_out === (bool) $out_of_stock ) {
322 continue;
323 }
324 $catalog_objects_to_update[] = $catalog_object_id;
325 }
326 }
327
328 if ( ! empty( $catalog_objects_to_update ) ) {
329 // Catalog Inventory data (IN_STOCK counts only).
330 $inventory_hash = Helper::get_catalog_objects_inventory_stats( $catalog_objects_to_update );
331
332 // A zero IN_STOCK count is ambiguous: a real sellout and a never-counted new item look
333 // identical. Verify zeros against Square's inventory change history so phantom zeros
334 // from uncounted items are never written to WooCommerce (SQUARE-145).
335 $zero_object_ids = Helper::zero_count_object_ids( $inventory_hash );
336 $verified_zero_ids = $this->resolve_zero_count_verification( 'update_inventory_tracking', $zero_object_ids );
337
338 if ( null === $verified_zero_ids ) {
339 // Verification unavailable and retries remain: return WITHOUT advancing any progress
340 // marker so the step runs again, rather than throwing and failing the whole job. Once
341 // the retries are spent this step proceeds writing no zeros, and the counts step keeps
342 // the shared window in place so this window is read again on the next poll.
343 return;
344 }
345
346 foreach ( $catalog_objects_to_update as $catalog_object_id ) {
347 $product = Product::get_product_by_square_variation_id( $catalog_object_id );
348 if ( $product instanceof \WC_Product ) {
349 $inventory_data = $catalog_objects_tracking_stats[ $catalog_object_id ] ?? array();
350 $is_tracking_inventory = $inventory_data['track_inventory'] ?? false;
351 $sold_out = $inventory_data['sold_out'] ?? false;
352
353 if ( $is_tracking_inventory && isset( $inventory_hash[ $catalog_object_id ] ) ) {
354
355 $changed = Helper::apply_square_inventory_count(
356 $product,
357 (float) $inventory_hash[ $catalog_object_id ],
358 (bool) $sold_out,
359 in_array( $catalog_object_id, $verified_zero_ids, true )
360 );
361
362 if ( ! $changed ) {
363 continue;
364 }
365 } elseif ( $is_tracking_inventory ) {
366
367 // Tracked in Square but no IN_STOCK count returned: that is "no information",
368 // never a zero. Leave the product untouched (SQUARE-145).
369 continue;
370 } else {
371
372 // Not tracked in Square: reflect availability. Stock management follows only
373 // when Square owns the setting; under WooCommerce SOR it is merchant intent
374 // and is left alone.
375 $product->set_stock_status( $sold_out ? 'outofstock' : 'instock' );
376
377 if ( wc_square()->get_settings_handler()->is_system_of_record_square() ) {
378 $product->set_manage_stock( false );
379 }
380 }
381
382 $product->save();
383 $products_updated[] = $product->get_id();
384 }
385 }
386 }
387
388 $this->set_attr( 'update_inventory_tracking_cursor', $cursor );
389 $this->set_attr( 'processed_product_ids', array_unique( $products_updated ) );
390
391 if ( ! $cursor ) {
392 $this->complete_step( 'update_inventory_tracking' );
393 }
394 }
395
396 /**
397 * Updates the inventory counts from the latest in Square.
398 *
399 * Helper method, do not open to public.
400 *
401 * @since 2.0.0
402 *
403 * @throws \Exception
404 */
405 protected function update_inventory_counts() {
406 $products_updated = $this->get_attr( 'processed_product_ids' );
407 $cursor = $this->get_attr( 'update_inventory_counts_cursor' );
408 $update_count = $this->get_attr( 'update_inventory_counts_count', 0 );
409
410 $args = array(
411 'location_ids' => array( wc_square()->get_settings_handler()->get_location_id() ),
412 'cursor' => $cursor,
413 );
414
415 $last_synced_at = $this->get_attr( 'inventory_last_synced_at' );
416
417 if ( $last_synced_at ) {
418
419 $date = new \DateTime();
420 $date->setTimestamp( $last_synced_at );
421 $date->setTimezone( new \DateTimeZone( 'UTC' ) );
422
423 $args['updated_after'] = $date->format( DATE_ATOM );
424 }
425
426 $response = wc_square()->get_api()->batch_retrieve_inventory_counts( $args );
427 $cursor = $response->get_data() instanceof BatchRetrieveInventoryCountsResponse ? $response->get_data()->getCursor() : null;
428
429 // store the start timestamp after the first API request was completed but do not save it now
430 // if cursor is present, then it is not the last page. So, use the inventory_last_synced_at time
431 // else use the current time
432 $last_sync_timestamp = $cursor ? $last_synced_at : current_time( 'timestamp', true ); // phpcs:disable WordPress.DateTime.CurrentTimeTimestamp.RequestedUTC
433
434 $catalog_objects_inventory_stats = array();
435
436 foreach ( $response->get_counts() as $count ) {
437 // Only explicit IN_STOCK counts are usable data. Other states (SOLD, WASTE, ORDERED,
438 // RECEIVED_FROM_VENDOR, RESERVED, ...) are movements or purchase-order stages, not the
439 // available quantity - coercing them to zero is what wiped stock for purchase-order
440 // users (SQUARE-7) and for uncounted items (SQUARE-145). Ignore them.
441 if ( 'IN_STOCK' !== $count->getState() ) {
442 continue;
443 }
444
445 $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] = array(
446 'IN_STOCK' => true,
447 'quantity' => $count->getQuantity(),
448 );
449 }
450
451 // Get the inventory tracking for catalog objects.
452 $catalog_objects_tracking_stats = Helper::get_catalog_objects_tracking_stats(
453 array_keys( $catalog_objects_inventory_stats )
454 );
455
456 // Verify zero counts against Square's inventory change history: a never-counted item
457 // reports IN_STOCK 0 exactly like a real sellout, and only real zeros may be written.
458 $zero_object_ids = Helper::zero_count_object_ids( $catalog_objects_inventory_stats, 'quantity' );
459 $verified_zero_ids = $this->resolve_zero_count_verification( 'update_inventory_counts', $zero_object_ids );
460
461 if ( null === $verified_zero_ids ) {
462 // Verification unavailable and retries remain: return WITHOUT advancing the cursor or the
463 // inventory watermark so the step runs again, rather than throwing and failing the job.
464 return;
465 }
466
467 foreach ( $catalog_objects_inventory_stats as $catalog_object_id => $stats ) {
468
469 $product = Product::get_product_by_square_variation_id( $catalog_object_id );
470
471 // Square can return multiple "types" of counts, WooCommerce only distinguishes whether a product is in stock or not
472 if ( $product instanceof \WC_Product ) {
473 // Respect the per-product "Sync with Square" setting on the pull side as well.
474 if ( ! Product::is_synced_with_square( $product ) ) {
475 continue;
476 }
477
478 $inventory_data = $catalog_objects_tracking_stats[ $catalog_object_id ] ?? array();
479 $is_tracking_inventory = $inventory_data['track_inventory'] ?? false;
480 $sold_out = $inventory_data['sold_out'] ?? false;
481
482 if ( $is_tracking_inventory ) {
483 $changed = Helper::apply_square_inventory_count(
484 $product,
485 (float) $stats['quantity'],
486 (bool) $sold_out,
487 in_array( $catalog_object_id, $verified_zero_ids, true )
488 );
489
490 if ( ! $changed ) {
491 continue;
492 }
493 } else {
494 // Not tracked in Square: reflect availability. Stock management follows only when
495 // Square owns the setting; under WooCommerce SOR it is merchant intent and is
496 // left alone, so a Square side toggle cannot silently invert the system of record.
497 $product->set_stock_status( $sold_out ? 'outofstock' : 'instock' );
498
499 if ( wc_square()->get_settings_handler()->is_system_of_record_square() ) {
500 $product->set_manage_stock( false );
501 }
502 }
503
504 $product->save();
505
506 $products_updated[] = $product->get_id();
507 }
508 }
509
510 $this->set_attr( 'update_inventory_counts_cursor', $cursor );
511 $this->set_attr( 'processed_product_ids', array_unique( $products_updated ) );
512 $this->set_attr( 'update_inventory_counts_count', $update_count + count( $catalog_objects_inventory_stats ) );
513
514 if ( ! $cursor ) {
515
516 // All counts in this window are processed, so the watermark may move to the start time
517 // stored above, with one exception: if a zero count in this window could not be verified
518 // after the retries, the window is left in place. Counts are read by updated_after, so the
519 // same window is read again on the next poll and those exact objects come back with it.
520 // Advancing here instead would skip a genuine sellout for good, because Square would not
521 // report that object again until something else changed it.
522 // Both inventory steps read this one watermark and only this step advances it, so a window
523 // either step could not verify has to stay put. Checking only this step's flag would let it
524 // move the window over objects the tracking step had to leave unverified.
525 if ( $this->zero_verification_exhausted( 'update_inventory_counts' )
526 || $this->zero_verification_exhausted( 'update_inventory_tracking' ) ) {
527
528 wc_square()->log( 'Zero inventory counts in this window could not be verified against Square history; keeping the inventory sync window so the next poll reads it again.' );
529
530 } else {
531
532 wc_square()->get_sync_handler()->set_inventory_last_synced_at( $last_sync_timestamp );
533 }
534
535 $this->complete_step( 'update_inventory_counts' );
536 }
537 }
538 }
539