PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / woocommerce-addon / service / product-sync.php

product-sync.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/module/woocommerce-addon/service/product-sync.php

313 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Module\Woocommerce_Addon\Service;
4
5 if ( ! defined( 'ABSPATH' ) ) exit;
6
7 use Booktics\Models\Service_Model;
8
9 /**
10 * Mirrors every Booktics service as a hidden WooCommerce product.
11 *
12 * WooCommerce can only put a real product in its cart, so a booking needs a
13 * product behind it to reach the store's checkout. The mirror exists purely to
14 * carry that line item: it is virtual, hidden from the catalog and search, and
15 * cannot be bought on its own.
16 *
17 * Booktics is always the source of truth. Every service save rewrites the
18 * product, so an edit made inside WooCommerce is replaced on the next save.
19 *
20 * The product price is only a sane default for the record and for percentage
21 * coupon maths. What a customer is actually charged is set per cart item at
22 * hand-off, because additional durations, extra services and group booking all
23 * change the real total.
24 */
25 class Product_Sync {
26
27 /**
28 * Product meta holding the Booktics service id.
29 */
30 const PRODUCT_META = '_booktics_service_id';
31
32 /**
33 * Service post meta holding the WooCommerce product id.
34 */
35 const SERVICE_META = '_booktics_wc_product_id';
36
37 /**
38 * Transient guarding the backfill sweep.
39 */
40 const BACKFILL_KEY = 'booktics_wc_products_synced';
41
42 /**
43 * Return the product id for a service, creating the product if needed.
44 *
45 * @param int $service_id Booktics service id.
46 *
47 * @return int Product id, or 0 when the product could not be created.
48 */
49 public static function ensure( int $service_id ): int {
50 $product_id = self::get_product_id( $service_id );
51
52 if ( $product_id ) {
53 return $product_id;
54 }
55
56 return self::sync( $service_id );
57 }
58
59 /**
60 * Create or update the mirrored product for a service.
61 *
62 * @param int $service_id Booktics service id.
63 *
64 * @return int Product id, or 0 on failure.
65 */
66 public static function sync( int $service_id ): int {
67 if ( ! self::woocommerce_ready() ) {
68 return 0;
69 }
70
71 $service = Service_Model::find( $service_id );
72
73 if ( ! $service ) {
74 return 0;
75 }
76
77 $service = $service->to_array();
78
79 $product_id = self::get_product_id( $service_id );
80 $product = $product_id ? wc_get_product( $product_id ) : null;
81
82 if ( ! $product instanceof \WC_Product ) {
83 $product = new \WC_Product_Simple();
84 }
85
86 $product->set_name( (string) ( $service['title'] ?? '' ) );
87 $product->set_short_description( wp_strip_all_tags( (string) ( $service['description'] ?? '' ) ) );
88
89 // Virtual so WooCommerce never asks for a shipping address, hidden so the
90 // service never turns up in the shop, search or product feeds, and sold
91 // individually because one cart holds one booking.
92 $product->set_virtual( true );
93 $product->set_catalog_visibility( 'hidden' );
94 $product->set_sold_individually( true );
95 $product->set_reviews_allowed( false );
96
97 $price = self::base_price( $service );
98 $product->set_regular_price( (string) $price );
99 $product->set_price( (string) $price );
100
101 // A service that is not live must not be purchasable, so the product
102 // follows it. Booktics services carry custom post statuses ('active',
103 // 'deactive', ...), so 'publish' alone is not the test.
104 $product->set_status( self::is_live_status( (string) ( $service['status'] ?? '' ) ) ? 'publish' : 'draft' );
105
106 $category_id = self::category_id();
107
108 if ( $category_id ) {
109 $product->set_category_ids( array( $category_id ) );
110 }
111
112 $product->update_meta_data( self::PRODUCT_META, $service_id );
113
114 $product_id = $product->save();
115
116 if ( ! $product_id ) {
117 return 0;
118 }
119
120 update_post_meta( $service_id, self::SERVICE_META, $product_id );
121
122 return (int) $product_id;
123 }
124
125 /**
126 * Read the mirrored product id for a service.
127 *
128 * Returns 0 when the product has since been deleted in WooCommerce, so the
129 * caller recreates it rather than adding a phantom id to the cart.
130 *
131 * @param int $service_id Booktics service id.
132 *
133 * @return int
134 */
135 public static function get_product_id( int $service_id ): int {
136 if ( ! self::woocommerce_ready() ) {
137 return 0;
138 }
139
140 $product_id = (int) get_post_meta( $service_id, self::SERVICE_META, true );
141
142 if ( ! $product_id ) {
143 return 0;
144 }
145
146 $product = wc_get_product( $product_id );
147
148 if ( ! $product instanceof \WC_Product ) {
149 delete_post_meta( $service_id, self::SERVICE_META );
150
151 return 0;
152 }
153
154 return $product_id;
155 }
156
157 /**
158 * Whether a product is one of our service mirrors.
159 *
160 * @param int $product_id Product id.
161 *
162 * @return bool
163 */
164 public static function is_booking_product( $product_id ): bool {
165 if ( ! $product_id || ! self::woocommerce_ready() ) {
166 return false;
167 }
168
169 $product = wc_get_product( $product_id );
170
171 return $product instanceof \WC_Product && (int) $product->get_meta( self::PRODUCT_META ) > 0;
172 }
173
174 /**
175 * Trash the mirrored product when its service is deleted.
176 *
177 * The product is trashed rather than force-deleted so past WooCommerce
178 * orders keep resolving their line items.
179 *
180 * @param int $service_id Booktics service id.
181 *
182 * @return void
183 */
184 public static function remove( int $service_id ): void {
185 $product_id = self::get_product_id( $service_id );
186
187 if ( ! $product_id ) {
188 return;
189 }
190
191 wp_trash_post( $product_id );
192 delete_post_meta( $service_id, self::SERVICE_META );
193 }
194
195 /**
196 * Create products for services that do not have one yet.
197 *
198 * Batched, so a site with hundreds of services heals over several admin
199 * page loads instead of timing out on one.
200 *
201 * @param int $limit Maximum services to process in this pass.
202 *
203 * @return int Number of services processed.
204 */
205 public static function backfill( int $limit = 20 ): int {
206 if ( ! self::woocommerce_ready() ) {
207 return 0;
208 }
209
210 $services = get_posts(
211 array(
212 'post_type' => 'booktics-service',
213 'post_status' => self::service_statuses(),
214 'posts_per_page' => $limit,
215 'fields' => 'ids',
216 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Needed to find services without a mirrored product.
217 'meta_query' => array(
218 array(
219 'key' => self::SERVICE_META,
220 'compare' => 'NOT EXISTS',
221 ),
222 ),
223 )
224 );
225
226 foreach ( $services as $service_id ) {
227 self::sync( (int) $service_id );
228 }
229
230 return count( $services );
231 }
232
233 /**
234 * Post statuses a service can be stored under.
235 *
236 * Booktics registers its own statuses (Post_Status_Service), and a service
237 * created through the admin lands on 'active', not 'publish'. A backfill
238 * that only looked for 'publish' would silently mirror nothing.
239 *
240 * @return array
241 */
242 private static function service_statuses(): array {
243 return array( 'active', 'deactive', 'publish', 'draft', 'pending' );
244 }
245
246 /**
247 * Whether a service status means the service is bookable.
248 *
249 * @param string $status Service post status.
250 *
251 * @return bool
252 */
253 private static function is_live_status( string $status ): bool {
254 return in_array( $status, array( 'active', 'publish' ), true );
255 }
256
257 /**
258 * Base price for the product record: the first additional duration's charge.
259 *
260 * @param array $service Service data.
261 *
262 * @return float
263 */
264 private static function base_price( array $service ): float {
265 $durations = $service['additional_durations'] ?? array();
266
267 if ( ! is_array( $durations ) || empty( $durations ) ) {
268 return 0.0;
269 }
270
271 $first = reset( $durations );
272
273 return (float) ( $first['charge_amount'] ?? 0 );
274 }
275
276 /**
277 * Term id of the hidden product category holding the mirrors.
278 *
279 * @return int
280 */
281 private static function category_id(): int {
282 $term = get_term_by( 'slug', 'booktics-service', 'product_cat' );
283
284 if ( $term && ! is_wp_error( $term ) ) {
285 return (int) $term->term_id;
286 }
287
288 $created = wp_insert_term(
289 __( 'Booktics Service', 'booktics' ),
290 'product_cat',
291 array(
292 'slug' => 'booktics-service',
293 'description' => __( 'Hidden products backing Booktics bookings.', 'booktics' ),
294 )
295 );
296
297 if ( is_wp_error( $created ) ) {
298 return 0;
299 }
300
301 return (int) $created['term_id'];
302 }
303
304 /**
305 * Whether WooCommerce is loaded far enough to touch products.
306 *
307 * @return bool
308 */
309 private static function woocommerce_ready(): bool {
310 return class_exists( 'WooCommerce' ) && function_exists( 'wc_get_product' );
311 }
312 }
313