PluginProbe ʕ •ᴥ•ʔ
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments / 4.5.0
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments v4.5.0
4.5.1 4.5.0 4.4.2 4.4.1 4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.3 4.2.2 4.2.1 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.11.0 1.11.1 1.11.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.9.0 1.9.1 1.9.2 1.9.3 1.9.4 1.9.5 2.0.0 2.0.1 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.10.0 2.10.1 2.11.0 2.11.1 2.11.2 2.11.3 2.11.4 2.12.0 2.13.0 2.14.0 2.14.1 2.15.0 2.15.1 2.16.0 2.16.1 2.16.2 2.16.3 2.17.0 2.17.1 2.17.2 2.18.0 2.19.0 2.19.2 2.19.3 2.19.4 2.2.0 2.2.1 2.20.0 2.20.1 2.20.2 2.20.3 2.20.4 2.20.5 2.20.6 2.21.0 2.22.0 2.22.1 2.23.0 2.24.0 2.25.0 2.25.1 2.25.2 2.26.0 2.27.0 2.27.1 2.28.0 2.29.0 2.29.1 2.29.2 2.29.3 2.29.4 2.3.0 2.3.1 2.30.0 2.31.0 2.31.1 2.31.2 2.31.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.40.0 2.40.1 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.9.0 3.0.0 3.0.0-RC1 3.0.0-RC2 3.0.0-beta1 3.0.0-beta2 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.10.0 3.10.1 3.11.0 3.12.0 3.13.0 3.13.1 3.13.2 3.13.3 3.13.4 3.14.0 3.15.0 3.15.1 3.15.2 3.15.3 3.15.4 3.15.5 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.16.5 3.16.6 3.16.7 3.16.8 3.17.0 3.17.1 3.17.2 3.17.3 3.17.4 3.17.5 3.17.6 3.18.0 3.19.0 3.19.1 3.19.2 3.2.0 3.2.1 3.2.2 3.20.0 3.20.1 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 4.0.0 4.0.1 4.0.2 4.0.3 trunk 4.1.0 0.2.19.1 4.1.1 1.0.0 4.2.0 1.0.1 1.0.2
surecart / app / src / Sync / WooCommerce / WooCommerceProductMapper.php
surecart / app / src / Sync / WooCommerce Last commit date
WooCommerceImportCleanupService.php 2 months ago WooCommerceImportJob.php 2 months ago WooCommerceImportService.php 2 weeks ago WooCommerceImportTask.php 2 months ago WooCommerceProductMapper.php 2 months ago
WooCommerceProductMapper.php
1092 lines
1 <?php
2
3 namespace SureCart\Sync\WooCommerce;
4
5 /**
6 * Maps WooCommerce products to SureCart import data.
7 *
8 * Caching mapper that converts WC product objects into the array format
9 * expected by the SureCart product import API. Caches collections and
10 * currency lookups per batch to avoid duplicate API/DB calls.
11 *
12 * @package SureCart
13 */
14 class WooCommerceProductMapper {
15
16 /**
17 * Collections cache (per batch) to avoid duplicate API calls.
18 *
19 * @var array
20 */
21 private $collections_cache = [];
22
23 /**
24 * Cached WooCommerce currency (per batch).
25 *
26 * @var string|null
27 */
28 private $currency_cache = null;
29
30 /**
31 * Cached WooCommerce weight unit.
32 *
33 * @var string|null
34 */
35 private $weight_unit_cache = null;
36
37 /**
38 * Cached WooCommerce dimension unit.
39 *
40 * @var string|null
41 */
42 private $dimension_unit_cache = null;
43
44 /**
45 * Reset all caches.
46 *
47 * @return void
48 */
49 public function resetCaches() {
50 $this->currency_cache = null;
51 $this->weight_unit_cache = null;
52 $this->dimension_unit_cache = null;
53 $this->collections_cache = [];
54 }
55
56 /**
57 * Map the WooCommerce Product to SureCart.
58 *
59 * @param \WC_Product $product WooCommerce Product.
60 * @return array
61 */
62 public function mapWooCommerceProductToSureCart( $product ) {
63 // Build product data using helper methods.
64 $product_import_data = $this->mapCoreFields( $product );
65
66 // Prices (CRITICAL).
67 $product_import_data['prices'] = $this->mapPrices( $product );
68
69 // Categories to collections (CRITICAL).
70 $product_import_data = array_merge( $product_import_data, $this->mapCategories( $product ) );
71
72 // Variants (for variable products).
73 // Pre-load all variations once to avoid duplicate DB queries across methods.
74 $any_variation_manages_own_stock = false;
75 if ( $product->is_type( 'variable' ) ) {
76 $variations = array_filter( array_map( 'wc_get_product', $product->get_children() ) );
77 $any_variation_manages_own_stock = $this->anyVariationManagesOwnStock( $variations );
78 $product_import_data = array_merge( $product_import_data, $this->mapVariants( $product, $any_variation_manages_own_stock, $variations ) );
79 }
80
81 // Stock, shipping, tax.
82 // When any variation owns its stock, product-level stock is suppressed.
83 $product_import_data = array_merge( $product_import_data, $this->mapStockFields( $product, $any_variation_manages_own_stock ) );
84 $product_import_data = array_merge( $product_import_data, $this->mapShippingFields( $product ) );
85 $product_import_data = array_merge( $product_import_data, $this->mapTaxFields( $product ) );
86
87 // Reviews.
88 $product_import_data = array_merge( $product_import_data, $this->mapReviewsFields( $product ) );
89 $product_import_data['reviews'] = $this->mapReviews( $product );
90
91 // Media.
92 $product_import_data['product_medias'] = $this->mapMedia( $product );
93
94 // Metadata (comprehensive WooCommerce data).
95 $product_import_data['metadata'] = $this->mapMetadata( $product );
96
97 // Allow filtering.
98 $product_import_data = apply_filters( 'surecart/woocommerce_sync/product_data', $product_import_data, $product );
99
100 return $product_import_data;
101 }
102
103 /**
104 * Map core product fields.
105 *
106 * @param \WC_Product $product WooCommerce Product.
107 * @return array
108 */
109 public function mapCoreFields( $product ) {
110 $core_fields = [
111 'name' => $product->get_name(),
112 'slug' => $product->get_slug(),
113 'featured' => $product->is_featured(),
114 'status' => $this->mapStatus( $product ),
115 'description' => wp_kses_post( $product->get_description() ),
116 'sku' => $product->get_sku(),
117 'archived' => $product->get_status() === 'trash',
118 'recurring' => $this->isSubscriptionProduct( $product ),
119 'cataloged_at' => $product->get_date_created() ? $product->get_date_created()->getTimestamp() : null,
120 ];
121
122 // Purchase limit (sold individually).
123 if ( $product->get_sold_individually() ) {
124 $core_fields['purchase_limit'] = 1;
125 }
126
127 // Licensing (if applicable).
128 if ( $this->hasLicensing( $product ) ) {
129 $core_fields['licensing_enabled'] = true;
130 $core_fields['license_activation_limit'] = $this->getLicenseActivationLimit( $product );
131 }
132
133 return $core_fields;
134 }
135
136 /**
137 * Map product status considering catalog visibility.
138 *
139 * @param \WC_Product $product WooCommerce Product.
140 * @return string
141 */
142 public function mapStatus( $product ) {
143 $status = $product->get_status();
144 $catalog_visibility = $product->get_catalog_visibility();
145
146 // Hidden products map to draft.
147 if ( 'hidden' === $catalog_visibility || 'private' === $status ) {
148 return 'draft';
149 }
150
151 return 'publish' === $status ? 'published' : 'draft';
152 }
153
154 /**
155 * Check if product is a subscription.
156 *
157 * @param \WC_Product $product WooCommerce Product.
158 * @return bool
159 */
160 public function isSubscriptionProduct( $product ) {
161 return class_exists( 'WC_Subscriptions_Product' ) && \WC_Subscriptions_Product::is_subscription( $product );
162 }
163
164 /**
165 * Check if product has licensing enabled.
166 *
167 * @param \WC_Product $product WooCommerce Product.
168 * @return bool
169 */
170 public function hasLicensing( $product ) {
171 return 'yes' === get_post_meta( $product->get_id(), '_has_license', true );
172 }
173
174 /**
175 * Get license activation limit.
176 *
177 * @param \WC_Product $product WooCommerce Product.
178 * @return int|null
179 */
180 public function getLicenseActivationLimit( $product ) {
181 $limit = get_post_meta( $product->get_id(), '_license_activation_limit', true );
182 return $limit ? (int) $limit : null;
183 }
184
185 /**
186 * Map prices array.
187 *
188 * @param \WC_Product $product WooCommerce Product.
189 * @return array
190 */
191 public function mapPrices( $product ) {
192 $prices = [];
193
194 // Subscription products.
195 if ( $this->isSubscriptionProduct( $product ) ) {
196 return $this->mapSubscriptionPrices( $product );
197 }
198
199 // Simple products.
200 $price_data = [
201 'amount' => $this->convertPriceToInteger( $product->get_price() ),
202 'currency' => strtolower( $this->getCurrency() ),
203 // translators: %s: Product name.
204 'name' => sprintf( __( '%s Price', 'surecart' ), $product->get_name() ),
205 'position' => 0,
206 'archived' => false,
207 ];
208
209 // Sale price -> scratch_amount.
210 if ( $product->is_on_sale() && $product->get_sale_price() ) {
211 $price_data['scratch_amount'] = $this->convertPriceToInteger( $product->get_regular_price() );
212 $price_data['amount'] = $this->convertPriceToInteger( $product->get_sale_price() );
213 }
214
215 // Metadata.
216 $price_data['metadata'] = [
217 'wc_product_id' => $product->get_id(),
218 'wc_price_type' => 'regular',
219 'wc_tax_class' => $product->get_tax_class(),
220 'wc_regular_price' => $product->get_regular_price(),
221 'wc_sale_price' => $product->get_sale_price(),
222 ];
223
224 // Sale dates.
225 if ( $product->get_date_on_sale_from() ) {
226 $price_data['metadata']['wc_sale_start'] = $product->get_date_on_sale_from()->format( 'c' );
227 }
228 if ( $product->get_date_on_sale_to() ) {
229 $price_data['metadata']['wc_sale_end'] = $product->get_date_on_sale_to()->format( 'c' );
230 }
231
232 $prices[] = apply_filters( 'surecart/woocommerce_sync/price', $price_data, $product );
233
234 return $prices;
235 }
236
237 /**
238 * Map subscription prices.
239 *
240 * @param \WC_Product $product WooCommerce Product.
241 * @return array
242 */
243 public function mapSubscriptionPrices( $product ) {
244 if ( ! class_exists( 'WC_Subscriptions_Product' ) ) {
245 return [];
246 }
247
248 $price_data = [
249 'amount' => $this->convertPriceToInteger( $product->get_price() ),
250 'currency' => strtolower( $this->getCurrency() ),
251 // translators: %s: Product name.
252 'name' => sprintf( __( '%s Subscription', 'surecart' ), $product->get_name() ),
253 'position' => 0,
254 'archived' => false,
255
256 // Recurring fields.
257 'recurring_interval' => \WC_Subscriptions_Product::get_period( $product ),
258 'recurring_interval_count' => (int) \WC_Subscriptions_Product::get_interval( $product ),
259 'recurring_period_count' => ! empty( \WC_Subscriptions_Product::get_length( $product ) ) ? (int) \WC_Subscriptions_Product::get_length( $product ) : null,
260
261 // Trial.
262 'trial_duration_days' => $this->getTrialDays( $product ),
263
264 // Setup fee.
265 'setup_fee_enabled' => \WC_Subscriptions_Product::get_sign_up_fee( $product ) > 0,
266 'setup_fee_amount' => $this->convertPriceToInteger( \WC_Subscriptions_Product::get_sign_up_fee( $product ) ),
267 'setup_fee_name' => __( 'Sign-up Fee', 'surecart' ),
268 'setup_fee_trial_enabled' => false,
269
270 // Portal.
271 'portal_subscription_update_enabled' => true,
272
273 // Metadata.
274 'metadata' => [
275 'wc_subscription_product' => true,
276 'wc_product_id' => $product->get_id(),
277 ],
278 ];
279
280 // Sale price — scratch_amount uses get_regular_price() (full price shown struck-through)
281 // while amount above uses get_price() which WC resolves to the active sale price.
282 if ( $product->is_on_sale() && $product->get_sale_price() ) {
283 $price_data['scratch_amount'] = $this->convertPriceToInteger( $product->get_regular_price() );
284 }
285
286 return [ $price_data ];
287 }
288
289 /**
290 * Calculate trial period in days.
291 *
292 * @param \WC_Product $product WooCommerce Product.
293 * @return int|null
294 */
295 public function getTrialDays( $product ) {
296 if ( ! class_exists( 'WC_Subscriptions_Product' ) ) {
297 return null;
298 }
299
300 $trial_length = \WC_Subscriptions_Product::get_trial_length( $product );
301 $trial_period = \WC_Subscriptions_Product::get_trial_period( $product );
302
303 if ( ! $trial_length || ! $trial_period ) {
304 return null;
305 }
306
307 // Intentional approximations: month=30d, year=365d. SureCart trial_duration_days
308 // only accepts whole days, so exact calendar precision isn't possible.
309 $days_map = [
310 'day' => 1,
311 'week' => 7,
312 'month' => 30,
313 'year' => 365,
314 ];
315
316 return (int) ( $trial_length * ( $days_map[ $trial_period ] ?? 1 ) );
317 }
318
319 /**
320 * Convert price to integer (cents).
321 *
322 * @param string|float $price Price.
323 * @return int
324 */
325 public function convertPriceToInteger( $price ) {
326 if ( empty( $price ) ) {
327 return 0;
328 }
329
330 $currency = $this->getCurrency();
331
332 // Zero-decimal currencies (no cents). This must be a currency-based check,
333 // not wc_get_price_decimals(), because that function reads the store's display
334 // setting (woocommerce_price_num_decimals option) which a store owner can
335 // misconfigure independently of the actual currency.
336 $zero_decimal = in_array(
337 $currency,
338 [ 'BIF', 'CLP', 'DJF', 'GNF', 'HUF', 'ISK', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'TWD', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ],
339 true
340 );
341
342 return $zero_decimal ? (int) $price : (int) round( (float) $price * 100 );
343 }
344
345 /**
346 * Get cached WooCommerce currency.
347 *
348 * @return string
349 */
350 private function getCurrency() {
351 if ( null === $this->currency_cache ) {
352 if ( ! function_exists( 'get_woocommerce_currency' ) ) {
353 return 'USD';
354 }
355 $this->currency_cache = \get_woocommerce_currency();
356 }
357 return $this->currency_cache;
358 }
359
360 /**
361 * Get cached WooCommerce weight unit.
362 *
363 * @return string
364 */
365 private function getWeightUnit() {
366 if ( null === $this->weight_unit_cache ) {
367 $this->weight_unit_cache = get_option( 'woocommerce_weight_unit', 'lbs' );
368 }
369 return $this->weight_unit_cache;
370 }
371
372 /**
373 * Get cached WooCommerce dimension unit.
374 *
375 * @return string
376 */
377 private function getDimensionUnit() {
378 if ( null === $this->dimension_unit_cache ) {
379 $this->dimension_unit_cache = get_option( 'woocommerce_dimension_unit', 'in' );
380 }
381 return $this->dimension_unit_cache;
382 }
383
384 /**
385 * Map WooCommerce weight unit to SureCart accepted value.
386 *
387 * @param string $wc_unit WooCommerce weight unit.
388 * @return string
389 */
390 public function mapWeightUnit( $wc_unit ) {
391 $map = [
392 'lbs' => 'lb',
393 'oz' => 'oz',
394 'kg' => 'kg',
395 'g' => 'g',
396 ];
397 return $map[ $wc_unit ] ?? 'lb';
398 }
399
400 /**
401 * Map WooCommerce dimension unit to SureCart accepted value.
402 *
403 * @param string $wc_unit WooCommerce dimension unit.
404 * @return string
405 */
406 public function mapDimensionUnit( $wc_unit ) {
407 $map = [
408 'cm' => 'cm',
409 'mm' => 'mm',
410 'm' => 'm',
411 'in' => 'in',
412 'ft' => 'ft',
413 'yd' => 'ft', // Intentional: SureCart has no yard unit, so we map to feet and multiply values by 3 (see $dim_multiplier).
414 ];
415 return $map[ $wc_unit ] ?? 'in';
416 }
417
418 /**
419 * Get or create ProductCollections from WooCommerce taxonomy terms using API.
420 *
421 * @param array $terms_data Array of term data with 'term' and 'source' keys.
422 * @return array Array of ProductCollection objects from API.
423 */
424 public function getOrCreateCollections( $terms_data ) {
425 $collections = [];
426 $uncached = [];
427
428 // Step 1: Resolve from cache, collect uncached slugs.
429 foreach ( $terms_data as $slug => $data ) {
430 $cache_key = strtolower( trim( $slug ) );
431
432 if ( isset( $this->collections_cache[ $cache_key ] ) ) {
433 $collections[] = $this->collections_cache[ $cache_key ];
434 } else {
435 $uncached[ $cache_key ] = $data;
436 }
437 }
438
439 if ( empty( $uncached ) ) {
440 return $collections;
441 }
442
443 // Step 2: Batch-fetch existing collections from API (single request).
444 $uncached_slugs = array_keys( $uncached );
445 try {
446 // API doesn't support reliable slug filtering, so check each collection individually
447 foreach ( $uncached as $cache_key => $data ) {
448 $wc_term = $data['term'];
449
450 // Try to find existing collection by name using query search
451 $search_results = \SureCart\Models\ProductCollection::where(
452 [
453 'query' => $wc_term->name,
454 'limit' => 10,
455 ]
456 )->get();
457
458 if ( ! is_wp_error( $search_results ) && is_array( $search_results ) ) {
459 foreach ( $search_results as $result ) {
460 // Check for exact name match (case-insensitive)
461 if ( ! empty( $result->name ) && ! empty( $result->id ) ) {
462 if ( strtolower( trim( $result->name ) ) === strtolower( trim( $wc_term->name ) ) ) {
463 $this->collections_cache[ $cache_key ] = $result;
464 break; // Found exact match, stop looking
465 }
466 }
467 }
468 }
469 }
470 } catch ( \Exception $e ) {
471 error_log( sprintf( 'SureCart WooCommerce Sync: Batch fetch collections failed - %s', $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
472 }
473
474 // Step 3: Resolve newly cached or create missing collections.
475 foreach ( $uncached as $cache_key => $data ) {
476 try {
477 if ( isset( $this->collections_cache[ $cache_key ] ) ) {
478 $collections[] = $this->collections_cache[ $cache_key ];
479 continue;
480 }
481
482 // Collection not found in batch results — create it via API.
483 $wc_term = $data['term'];
484 $collection = \SureCart\Models\ProductCollection::create(
485 [
486 'name' => $wc_term->name,
487 'slug' => $cache_key,
488 'description' => $wc_term->description ?? '',
489 'metadata' => [
490 'wc_source' => $data['source'],
491 'wc_term_id' => $wc_term->term_id,
492 ],
493 ]
494 );
495
496 if ( is_wp_error( $collection ) ) {
497 continue;
498 }
499
500 $this->collections_cache[ $cache_key ] = $collection;
501 $collections[] = $collection;
502 } catch ( \Exception $e ) {
503 error_log( sprintf( 'SureCart WooCommerce Sync: Failed to get/create collection for slug "%s" - %s', $cache_key, $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
504 }
505 }
506
507 return $collections;
508 }
509
510 /**
511 * Map categories to product collections.
512 *
513 * @param \WC_Product $product WooCommerce Product.
514 * @return array
515 */
516 public function mapCategories( $product ) {
517 $category_ids = $product->get_category_ids();
518 $tag_ids = $product->get_tag_ids();
519 $all_terms = [];
520
521 // Batch-fetch categories to avoid N+1 get_term() calls.
522 if ( ! empty( $category_ids ) ) {
523 $categories = get_terms(
524 [
525 'taxonomy' => 'product_cat',
526 'include' => $category_ids,
527 'hide_empty' => false,
528 ]
529 );
530 if ( ! is_wp_error( $categories ) ) {
531 foreach ( $categories as $category ) {
532 $normalized_slug = strtolower( trim( $category->slug ) );
533 $all_terms[ $normalized_slug ] = [
534 'term' => $category,
535 'source' => 'product_cat',
536 ];
537 }
538 }
539 }
540
541 // Batch-fetch tags to avoid N+1 get_term() calls.
542 if ( ! empty( $tag_ids ) ) {
543 $tags = get_terms(
544 [
545 'taxonomy' => 'product_tag',
546 'include' => $tag_ids,
547 'hide_empty' => false,
548 ]
549 );
550 if ( ! is_wp_error( $tags ) ) {
551 foreach ( $tags as $tag ) {
552 // Normalize slug for deduplication - if slug exists from category, keep the first source.
553 $normalized_slug = strtolower( trim( $tag->slug ) );
554 if ( ! isset( $all_terms[ $normalized_slug ] ) ) {
555 $all_terms[ $normalized_slug ] = [
556 'term' => $tag,
557 'source' => 'product_tag',
558 ];
559 }
560 }
561 }
562 }
563
564 // Batch-fetch brands (if WC Brands is active).
565 if ( taxonomy_exists( 'product_brand' ) ) {
566 $brand_ids = wp_get_post_terms( $product->get_id(), 'product_brand', [ 'fields' => 'ids' ] );
567 if ( ! is_wp_error( $brand_ids ) && ! empty( $brand_ids ) ) {
568 $brands = get_terms(
569 [
570 'taxonomy' => 'product_brand',
571 'include' => $brand_ids,
572 'hide_empty' => false,
573 ]
574 );
575 if ( ! is_wp_error( $brands ) ) {
576 foreach ( $brands as $brand ) {
577 $normalized_slug = strtolower( trim( $brand->slug ) );
578 if ( ! isset( $all_terms[ $normalized_slug ] ) ) {
579 $all_terms[ $normalized_slug ] = [
580 'term' => $brand,
581 'source' => 'product_brand',
582 ];
583 }
584 }
585 }
586 }
587 }
588
589 // Get or create collections for all unique terms.
590 $all_collections = $this->getOrCreateCollections( $all_terms );
591
592 // Return just the collection IDs if we have any.
593 if ( empty( $all_collections ) ) {
594 return [];
595 }
596
597 // Extract just the IDs from the ProductCollection objects and deduplicate.
598 // Deduplication is needed because fuzzy API queries can match the same collection
599 // for different WooCommerce terms (e.g., category "Digital Products" and tag "digital").
600 $collection_ids = array_unique(
601 array_map(
602 function ( $collection ) {
603 return $collection->id;
604 },
605 $all_collections
606 )
607 );
608
609 return [
610 'product_collections' => array_values( $collection_ids ),
611 ];
612 }
613
614 /**
615 * Map variants for variable products.
616 *
617 * @param \WC_Product $product WooCommerce Product.
618 * @param bool $any_variation_manages_own_stock Whether any variation manages its own stock.
619 * @param array $variations Pre-loaded variation objects.
620 * @return array
621 */
622 public function mapVariants( $product, $any_variation_manages_own_stock = false, $variations = [] ) {
623 if ( ! $product->is_type( 'variable' ) ) {
624 return [];
625 }
626
627 $variant_options = [];
628 $variants = [];
629
630 // Extract variant_options from attributes and build ordered keys for mapping.
631 $attributes = $product->get_attributes();
632 $option_position = 0;
633 $option_keys = [];
634
635 foreach ( $attributes as $attribute ) {
636 if ( ! $attribute->get_variation() ) {
637 continue;
638 }
639
640 // Track the attribute key for ordered variant mapping.
641 $option_keys[] = 'attribute_' . sanitize_title( $attribute->get_name() );
642
643 // Extract values array (CRITICAL).
644 $values = [];
645 if ( $attribute->is_taxonomy() ) {
646 $term_ids = $attribute->get_options();
647 foreach ( $term_ids as $term_id ) {
648 $term = get_term( $term_id, $attribute->get_taxonomy() );
649 if ( $term && ! is_wp_error( $term ) ) {
650 $values[] = $term->name;
651 }
652 }
653 } else {
654 $values = $attribute->get_options();
655 }
656
657 $variant_options[] = [
658 'name' => wc_attribute_label( $attribute->get_name() ),
659 'values' => $values,
660 'display_type' => 'dropdown',
661 'position' => $option_position++,
662 ];
663 }
664
665 // Map variations to variants.
666 // Use pre-loaded variations if available, otherwise load from get_children().
667 if ( empty( $variations ) ) {
668 $children = $product->get_children();
669 $variations = ! empty( $children ) ? array_filter( array_map( 'wc_get_product', $children ) ) : [];
670 }
671
672 $variant_position = 0;
673
674 // Pre-load taxonomy terms to avoid N+1 get_term_by() calls inside the variation loop.
675 $term_name_cache = [];
676 foreach ( $variations as $variation ) {
677 $attrs = $variation->get_variation_attributes();
678 foreach ( $option_keys as $key ) {
679 if ( isset( $attrs[ $key ] ) && ! empty( $attrs[ $key ] ) ) {
680 $taxonomy = str_replace( 'attribute_', '', $key );
681 if ( taxonomy_exists( $taxonomy ) ) {
682 $term_name_cache[ $taxonomy ][] = $attrs[ $key ];
683 }
684 }
685 }
686 }
687 foreach ( $term_name_cache as $taxonomy => $slugs ) {
688 $terms = get_terms(
689 [
690 'taxonomy' => $taxonomy,
691 'slug' => array_unique( $slugs ),
692 'hide_empty' => false,
693 ]
694 );
695 $resolved = [];
696 if ( ! is_wp_error( $terms ) ) {
697 foreach ( $terms as $t ) {
698 $resolved[ $t->slug ] = $t->name;
699 }
700 }
701 $term_name_cache[ $taxonomy ] = $resolved;
702 }
703
704 // Use cached WC unit options to avoid repeated DB queries.
705 $wc_weight_unit = $this->getWeightUnit();
706 $wc_dim_unit = $this->getDimensionUnit();
707 $dim_multiplier = 'yd' === $wc_dim_unit ? 3 : 1;
708
709 foreach ( $variations as $variation ) {
710
711 // Determine stock mode for this variation.
712 $variation_stock_mode = $variation->managing_stock();
713
714 $variant = [
715 'sku' => $variation->get_sku(),
716 'position' => $variant_position++,
717
718 // Pricing.
719 'amount' => $this->convertPriceToInteger( $variation->get_price() ),
720
721 // Stock -- determined after array init based on variation stock mode.
722
723 // Backorders.
724 'allow_out_of_stock_purchases' => $variation->backorders_allowed(),
725
726 // Shipping.
727 'auto_fulfill_enabled' => $variation->is_virtual(),
728 'shipping_enabled' => ! $variation->is_virtual(),
729
730 // Tax.
731 'tax_enabled' => $variation->is_taxable(),
732 'tax_category' => $this->isSubscriptionProduct( $product ) ? 'digital' : ( $variation->is_virtual() ? 'digital' : 'tangible' ),
733
734 // Metadata.
735 'metadata' => [
736 'wc_variation_id' => $variation->get_id(),
737 'wc_parent_id' => $product->get_id(),
738 'wc_regular_price' => $variation->get_regular_price(),
739 'wc_sale_price' => $variation->get_sale_price(),
740 ],
741 ];
742
743 // Set stock fields based on the variation's stock mode and product-wide context.
744 // SureCart requires stock at either product-level or variant-level, not both.
745 if ( true === $variation_stock_mode ) {
746 // Variation has its own dedicated stock pool.
747 $variant['stock_enabled'] = true;
748 $variant['stock_adjustment'] = (int) $variation->get_stock_quantity();
749 } elseif ( 'parent' === $variation_stock_mode && $any_variation_manages_own_stock ) {
750 // Mixed case: this variant inherits parent stock, but other variants have
751 // own stock, so product-level stock is suppressed. Set as out of stock.
752 $variant['stock_enabled'] = true;
753 $variant['stock_adjustment'] = 0;
754 } else {
755 // No stock management, or pure parent-only (product-level handles it).
756 $variant['stock_enabled'] = false;
757 $variant['stock_adjustment'] = 0;
758 }
759
760 // Only include weight if it's set and greater than 0.
761 $variant_weight = (float) $variation->get_weight();
762 if ( $variant_weight > 0 ) {
763 $variant['weight'] = $variant_weight;
764 $variant['weight_unit'] = $this->mapWeightUnit( $wc_weight_unit );
765 }
766
767 // Only include dimensions if at least one dimension is set and greater than 0.
768 $variant_length = (float) $variation->get_length() * $dim_multiplier;
769 $variant_width = (float) $variation->get_width() * $dim_multiplier;
770 $variant_height = (float) $variation->get_height() * $dim_multiplier;
771
772 if ( $variant_length > 0 || $variant_width > 0 || $variant_height > 0 ) {
773 $variant['dimensions'] = [
774 'length' => $variant_length,
775 'width' => $variant_width,
776 'height' => $variant_height,
777 'unit' => $this->mapDimensionUnit( $wc_dim_unit ),
778 ];
779 }
780
781 // Map attribute values to option_1, option_2, option_3 using parent attribute order.
782 $attributes_map = $variation->get_variation_attributes();
783
784 foreach ( $option_keys as $index => $key ) {
785 $option_num = $index + 1;
786 if ( $option_num > 3 ) {
787 break;
788 }
789 if ( isset( $attributes_map[ $key ] ) && ! empty( $attributes_map[ $key ] ) ) {
790 $value = $attributes_map[ $key ];
791
792 // For taxonomy attributes, WooCommerce stores slugs in variation attributes
793 // but we use term names in variant_options.values -- convert slug to name.
794 // Uses pre-loaded $term_name_cache to avoid N+1 get_term_by() queries.
795 $taxonomy = str_replace( 'attribute_', '', $key );
796 if ( isset( $term_name_cache[ $taxonomy ][ $value ] ) ) {
797 $value = $term_name_cache[ $taxonomy ][ $value ];
798 }
799
800 $variant[ "option_$option_num" ] = $value;
801 }
802 }
803
804 // Variation image.
805 $image_id = $variation->get_image_id();
806 if ( $image_id ) {
807 $variant['metadata']['wp_image_id'] = $image_id;
808 }
809
810 $variants[] = $variant;
811 }
812
813 return [
814 'variant_options' => $variant_options,
815 'variants' => apply_filters( 'surecart/woocommerce_sync/variants', $variants, $product ),
816 ];
817 }
818
819 /**
820 * Determine whether any variation on a variable product manages its own stock.
821 *
822 * Returns true if at least one variation has managing_stock() === true (strict),
823 * meaning it has a dedicated stock quantity separate from the parent product.
824 *
825 * @param \WC_Product_Variation[] $variations Pre-loaded variation objects.
826 * @return bool
827 */
828 private function anyVariationManagesOwnStock( $variations ) {
829 foreach ( $variations as $variation ) {
830 if ( true === $variation->managing_stock() ) {
831 return true;
832 }
833 }
834 return false;
835 }
836
837 /**
838 * Map stock fields.
839 *
840 * @param \WC_Product $product WooCommerce Product.
841 * @param bool $is_variable_with_variant_stock Whether this is a variable product where variants own the stock.
842 * @return array
843 */
844 public function mapStockFields( $product, $is_variable_with_variant_stock = false ) {
845 // For variable products where variants own the stock, enable the product-level
846 // master toggle (required for variant stock tracking to work in SureCart)
847 // but do NOT set stock_adjustment to avoid double-counting.
848 if ( $is_variable_with_variant_stock ) {
849 return [
850 'stock_enabled' => true,
851 ];
852 }
853
854 $managing_stock = $product->managing_stock();
855
856 if ( ! $managing_stock ) {
857 return [];
858 }
859
860 return [
861 'stock_enabled' => true,
862 'allow_out_of_stock_purchases' => $product->backorders_allowed(),
863 'stock_adjustment' => (int) $product->get_stock_quantity(),
864 ];
865 }
866
867 /**
868 * Map shipping fields.
869 *
870 * @param \WC_Product $product WooCommerce Product.
871 * @return array
872 */
873 public function mapShippingFields( $product ) {
874 $is_digital = $product->is_virtual() || $product->is_downloadable();
875
876 if ( $is_digital ) {
877 return [
878 'shipping_enabled' => false,
879 'auto_fulfill_enabled' => true,
880 ];
881 }
882
883 $shipping_fields = [
884 'shipping_enabled' => true,
885 'auto_fulfill_enabled' => false,
886 ];
887
888 // Only include weight if it's set and greater than 0.
889 $weight = (float) $product->get_weight();
890 if ( $weight > 0 ) {
891 $shipping_fields['weight'] = $weight;
892 $shipping_fields['weight_unit'] = $this->mapWeightUnit( $this->getWeightUnit() );
893 }
894
895 // Only include dimensions if at least one dimension is set and greater than 0.
896 $wc_dim_unit = $this->getDimensionUnit();
897 $dim_multiplier = 'yd' === $wc_dim_unit ? 3 : 1;
898 $length = (float) $product->get_length() * $dim_multiplier;
899 $width = (float) $product->get_width() * $dim_multiplier;
900 $height = (float) $product->get_height() * $dim_multiplier;
901
902 if ( $length > 0 || $width > 0 || $height > 0 ) {
903 $shipping_fields['dimensions'] = [
904 'length' => $length,
905 'width' => $width,
906 'height' => $height,
907 'unit' => $this->mapDimensionUnit( $wc_dim_unit ),
908 ];
909 }
910
911 return $shipping_fields;
912 }
913
914 /**
915 * Map tax fields.
916 *
917 * @param \WC_Product $product WooCommerce Product.
918 * @return array
919 */
920 public function mapTaxFields( $product ) {
921 $taxable = $product->is_taxable();
922 $is_digital = $product->is_virtual() || $product->is_downloadable();
923
924 // Determine tax category.
925 $tax_category = $is_digital ? 'digital' : 'tangible';
926 if ( $this->isSubscriptionProduct( $product ) ) {
927 $tax_category = 'digital';
928 }
929
930 return [
931 'tax_enabled' => $taxable,
932 'tax_category' => $tax_category,
933 ];
934 }
935
936 /**
937 * Map reviews fields (settings).
938 *
939 * @param \WC_Product $product WooCommerce Product.
940 * @return array
941 */
942 public function mapReviewsFields( $product ) {
943 $comments_open = comments_open( $product->get_id() );
944 return [
945 'reviews_enabled' => $comments_open,
946 'solicit_reviews' => $comments_open,
947 ];
948 }
949
950 /**
951 * Map individual reviews.
952 *
953 * @param \WC_Product $product WooCommerce Product.
954 * @return array
955 */
956 public function mapReviews( $product ) {
957 $reviews = [];
958
959 // Get product reviews from WordPress comments.
960 $comments = get_comments(
961 [
962 'post_id' => $product->get_id(),
963 'type' => 'review',
964 'status' => 'approve',
965 'orderby' => 'comment_date',
966 'order' => 'DESC',
967 'number' => 100,
968 ]
969 );
970
971 foreach ( $comments as $comment ) {
972 $rating = get_comment_meta( $comment->comment_ID, 'rating', true );
973
974 $reviews[] = [
975 'title' => get_comment_meta( $comment->comment_ID, 'review_title', true ),
976 'body' => wp_kses_post( $comment->comment_content ),
977 'stars' => $rating ? (float) $rating : null,
978 'metadata' => [
979 'wc_comment_id' => (int) $comment->comment_ID,
980 'customer_name' => $comment->comment_author,
981 'customer_email' => $comment->comment_author_email,
982 'review_date' => $comment->comment_date,
983 'verified_purchase' => wc_review_is_from_verified_owner( $comment->comment_ID ),
984 ],
985 ];
986 }
987
988 return $reviews;
989 }
990
991 /**
992 * Map media.
993 *
994 * @param \WC_Product $product WooCommerce Product.
995 * @return array
996 */
997 public function mapMedia( $product ) {
998 $media = [];
999
1000 // Featured image.
1001 $featured_image_id = $product->get_image_id();
1002 if ( $featured_image_id ) {
1003 $image_url = wp_get_attachment_url( $featured_image_id );
1004 if ( $image_url ) {
1005 $media[] = [ 'url' => $image_url ];
1006 }
1007 }
1008
1009 // Gallery images.
1010 foreach ( $product->get_gallery_image_ids() as $id ) {
1011 $image_url = wp_get_attachment_url( $id );
1012 if ( $image_url ) {
1013 $media[] = [ 'url' => $image_url ];
1014 }
1015 }
1016
1017 return $media;
1018 }
1019
1020 /**
1021 * Map comprehensive metadata.
1022 *
1023 * @param \WC_Product $product WooCommerce Product.
1024 * @return array
1025 */
1026 public function mapMetadata( $product ) {
1027 $metadata = [
1028 // Traceability (Critical).
1029 'wc_product_id' => $product->get_id(),
1030 'wc_product_type' => $product->get_type(),
1031 'wc_synced_at' => gmdate( 'c' ),
1032 'wc_permalink' => get_permalink( $product->get_id() ),
1033
1034 // Product Analytics.
1035 'wc_total_sales' => (int) $product->get_total_sales(),
1036 'wc_average_rating' => (float) $product->get_average_rating(),
1037 'wc_rating_counts' => wp_json_encode( $product->get_rating_counts() ),
1038 'wc_review_count' => (int) $product->get_review_count(),
1039
1040 // Products Flags.
1041 'is_virtual' => $product->is_virtual(),
1042 'is_downloadable' => $product->is_downloadable(),
1043 'catalog_visibility' => $product->get_catalog_visibility(),
1044
1045 // Relationships (Marketing Value).
1046 'wc_upsell_ids' => wp_json_encode( $product->get_upsell_ids() ),
1047 'wc_cross_sell_ids' => wp_json_encode( $product->get_cross_sell_ids() ),
1048
1049 // SEO & Content.
1050 'short_description' => wp_kses_post( $product->get_short_description() ),
1051 'purchase_note' => wp_kses_post( $product->get_purchase_note() ),
1052
1053 // Dates.
1054 'wc_date_created' => $product->get_date_created() ? $product->get_date_created()->format( 'c' ) : null,
1055 'wc_date_modified' => $product->get_date_modified() ? $product->get_date_modified()->format( 'c' ) : null,
1056
1057 // Advanced Stock.
1058 'wc_low_stock_threshold' => $product->get_low_stock_amount(),
1059 'wc_stock_status' => $product->get_stock_status(),
1060
1061 // Tax.
1062 'wc_tax_class' => $product->get_tax_class(),
1063 ];
1064
1065 // Downloadable files (Critical for digital products).
1066 if ( $product->is_downloadable() ) {
1067 $downloads = [];
1068 foreach ( $product->get_downloads() as $download ) {
1069 $downloads[] = [
1070 'name' => $download->get_name(),
1071 'file' => esc_url_raw( $download->get_file() ),
1072 'id' => $download->get_id(),
1073 ];
1074 }
1075 $metadata['download_files'] = wp_json_encode( $downloads );
1076 $metadata['download_limit'] = $product->get_download_limit();
1077 $metadata['download_expiry'] = $product->get_download_expiry();
1078 }
1079
1080 // Shipping class.
1081 $shipping_class_id = $product->get_shipping_class_id();
1082 if ( $shipping_class_id ) {
1083 $shipping_class = get_term( $shipping_class_id, 'product_shipping_class' );
1084 if ( $shipping_class && ! is_wp_error( $shipping_class ) ) {
1085 $metadata['wc_shipping_class'] = $shipping_class->slug;
1086 }
1087 }
1088
1089 return apply_filters( 'surecart/woocommerce_sync/product_metadata', $metadata, $product );
1090 }
1091 }
1092