PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
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 / hook / product-hook.php

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

221 lines 7.5 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\Hook;
4
5 if ( ! defined( 'ABSPATH' ) ) exit;
6
7 use Booktics\Contracts\Hookable_Service_Contract;
8 use Booktics\Module\Woocommerce_Addon\Service\Product_Sync;
9
10 /**
11 * Keeps the mirrored WooCommerce products in step with Booktics services.
12 *
13 * Products are hidden from the catalog by WooCommerce's own visibility flags,
14 * so no shop query filtering is needed here. What this class does add is a
15 * guard against buying a service straight from a URL: a booking product may
16 * only enter the cart through the Booktics hand-off, which stamps the cart item
17 * with the booking it belongs to.
18 */
19 class Product_Hook implements Hookable_Service_Contract {
20
21 /**
22 * Register WordPress hooks.
23 *
24 * @return void
25 */
26 public function register() {
27 add_action( 'booktics_service_created', array( $this, 'sync_service' ), 10, 2 );
28 add_action( 'booktics_service_updated', array( $this, 'sync_service' ), 10, 2 );
29
30 add_action( 'before_delete_post', array( $this, 'remove_service_product' ), 10, 2 );
31 add_action( 'wp_trash_post', array( $this, 'trash_service_product' ) );
32
33 add_action( 'admin_init', array( $this, 'backfill' ) );
34 add_action( 'booktics_module_status_updated', array( $this, 'reset_backfill_throttle' ), 10, 2 );
35
36 add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'block_direct_add_to_cart' ), 10, 5 );
37 add_filter( 'woocommerce_is_purchasable', array( $this, 'keep_booking_product_purchasable' ), 99, 2 );
38 }
39
40 /**
41 * Mirror a service into its product after a create or update.
42 *
43 * @param mixed $service Service model instance as passed by the controller.
44 * @param mixed $request Unused request object.
45 *
46 * @return void
47 */
48 public function sync_service( $service, $request = null ) {
49 $service_id = $this->resolve_service_id( $service );
50
51 if ( ! $service_id ) {
52 return;
53 }
54
55 Product_Sync::sync( $service_id );
56 }
57
58 /**
59 * Read the id out of whatever the service hooks hand over.
60 *
61 * Post_Model exposes its fields through __get but defines no __isset, so
62 * `$service->id ?? 0` silently evaluates to 0. Going through to_array()
63 * avoids that trap and still tolerates being passed a bare id.
64 *
65 * @param mixed $service Service model instance or id.
66 *
67 * @return int
68 */
69 private function resolve_service_id( $service ): int {
70 if ( is_numeric( $service ) ) {
71 return (int) $service;
72 }
73
74 if ( is_object( $service ) && method_exists( $service, 'to_array' ) ) {
75 $data = $service->to_array();
76
77 return (int) ( $data['id'] ?? 0 );
78 }
79
80 return 0;
81 }
82
83 /**
84 * Drop the mirrored product when a service is permanently deleted.
85 *
86 * @param int $post_id Post being deleted.
87 * @param \WP_Post $post Post object.
88 *
89 * @return void
90 */
91 public function remove_service_product( $post_id, $post = null ) {
92 if ( ! $post || 'booktics-service' !== $post->post_type ) {
93 return;
94 }
95
96 Product_Sync::remove( (int) $post_id );
97 }
98
99 /**
100 * Drop the mirrored product when a service is trashed.
101 *
102 * @param int $post_id Post being trashed.
103 *
104 * @return void
105 */
106 public function trash_service_product( $post_id ) {
107 if ( 'booktics-service' !== get_post_type( $post_id ) ) {
108 return;
109 }
110
111 Product_Sync::remove( (int) $post_id );
112 }
113
114 /**
115 * Create products for services that predate the module being enabled.
116 *
117 * Throttled so it costs one batched query per hour rather than one per
118 * admin page load, and skipped entirely once every service has a product.
119 *
120 * @return void
121 */
122 public function backfill() {
123 if ( get_transient( Product_Sync::BACKFILL_KEY ) ) {
124 return;
125 }
126
127 $processed = Product_Sync::backfill();
128
129 // Nothing left to do: back off for a day. Still work to do: come back
130 // on the next admin load in a few minutes and take the next batch.
131 set_transient(
132 Product_Sync::BACKFILL_KEY,
133 1,
134 $processed ? 5 * MINUTE_IN_SECONDS : DAY_IN_SECONDS
135 );
136 }
137
138 /**
139 * Let the next admin load backfill immediately after the module is enabled.
140 *
141 * Without this, a throttle left over from an earlier session could hold the
142 * first sync back for up to a day, which reads as "the module does nothing".
143 *
144 * @param string $module Module slug.
145 * @param string $status New status.
146 *
147 * @return void
148 */
149 public function reset_backfill_throttle( $module, $status = '' ) {
150 if ( 'woocommerce' !== $module ) {
151 return;
152 }
153
154 delete_transient( Product_Sync::BACKFILL_KEY );
155 }
156
157 /**
158 * Refuse add-to-cart requests for booking products that carry no booking.
159 *
160 * WooCommerce's own add-to-cart endpoints (a ?add-to-cart= URL, a REST call)
161 * would otherwise let anyone put a service in their cart with no date, no
162 * staff member and no reserved slot. The Booktics hand-off passes its cart
163 * item data through, which is what distinguishes a real booking.
164 *
165 * @param bool $passed Whether validation passed so far.
166 * @param int $product_id Product being added.
167 * @param int $quantity Quantity.
168 * @param int $variation_id Variation id, unused.
169 * @param array $cart_item_data Cart item data supplied by the caller.
170 *
171 * @return bool
172 */
173 public function block_direct_add_to_cart( $passed, $product_id, $quantity = 1, $variation_id = 0, $cart_item_data = array() ) {
174 if ( ! $passed ) {
175 return $passed;
176 }
177
178 if ( ! Product_Sync::is_booking_product( $product_id ) ) {
179 return $passed;
180 }
181
182 if ( ! empty( $cart_item_data['booktics'] ) ) {
183 return $passed;
184 }
185
186 wc_add_notice(
187 __( 'This service can only be booked by choosing a date and time.', 'booktics' ),
188 'error'
189 );
190
191 return false;
192 }
193
194 /**
195 * Keep booking mirror products purchasable regardless of marketplace rules.
196 *
197 * On a multivendor store (Dokan and friends) WooCommerce's
198 * `woocommerce_is_purchasable` filter is used to forbid a vendor from buying
199 * their own product. The mirror carries the author of whoever saved the
200 * service, so an admin or vendor testing a booking is treated as buying
201 * their own product and the hand-off fails with "The booking could not be
202 * added to the WooCommerce cart". A booking mirror is never a shelf product,
203 * so the marketplace's own-product rule does not apply: it only reaches the
204 * cart through the Booktics hand-off, and direct add-to-cart is still blocked
205 * by block_direct_add_to_cart. The service id meta is already loaded on the
206 * product object, so this stays a memory read, not a query, on every product.
207 *
208 * @param bool $purchasable Whether WooCommerce considers it purchasable.
209 * @param \WC_Product $product Product being evaluated.
210 *
211 * @return bool
212 */
213 public function keep_booking_product_purchasable( $purchasable, $product ) {
214 if ( $product instanceof \WC_Product && (int) $product->get_meta( Product_Sync::PRODUCT_META ) > 0 ) {
215 return true;
216 }
217
218 return $purchasable;
219 }
220 }
221