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 / hook / checkout-hook.php

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

386 lines 13.6 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\Abstracts\Booktics_Database;
8 use Booktics\Contracts\Hookable_Service_Contract;
9 use Booktics\Models\Order_Model;
10 use Booktics\Module\Woocommerce_Addon\Service\Cart_Handoff;
11 use Booktics\Module\Woocommerce_Addon\Service\Totals_Writer;
12
13 /**
14 * Carries a booking through the WooCommerce cart and checkout.
15 *
16 * Three jobs: make the cart usable from the REST request that starts the
17 * hand-off, charge the Booktics price rather than the product price, and put
18 * the booking details where the customer, the store owner and any invoice
19 * plugin can see them.
20 */
21 class Checkout_Hook implements Hookable_Service_Contract {
22
23 /**
24 * Register WordPress hooks.
25 *
26 * @return void
27 */
28 public function register() {
29 add_action( 'woocommerce_init', array( $this, 'load_cart_for_rest' ) );
30
31 add_action( 'woocommerce_before_calculate_totals', array( $this, 'apply_booking_price' ), 20 );
32
33 add_filter( 'woocommerce_get_item_data', array( $this, 'cart_item_details' ), 10, 2 );
34 add_filter( 'woocommerce_cart_item_name', array( $this, 'cart_item_name' ), 10, 3 );
35
36 add_action( 'woocommerce_checkout_create_order_line_item', array( $this, 'add_line_item_meta' ), 10, 4 );
37 add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hide_internal_item_meta' ) );
38
39 // Classic checkout fires the first pair; the checkout block goes through
40 // the Store API, which fires none of them and its own instead. Both are
41 // hooked so a booking is linked, assigned and reconciled the same way on
42 // either checkout. update_order_from_request runs before the order is
43 // saved, so set_customer_id there still takes, exactly like create_order.
44 add_action( 'woocommerce_checkout_create_order', array( $this, 'link_order' ), 10, 2 );
45 add_action( 'woocommerce_checkout_order_processed', array( $this, 'store_wc_order_id' ), 10, 3 );
46 add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'link_order' ), 10, 2 );
47 add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'store_wc_order_id_block' ), 10, 1 );
48 }
49
50 /**
51 * Store API counterpart of store_wc_order_id().
52 *
53 * The block's order-processed hook hands over only the order, where the
54 * classic one leads with the order id, so this adapts the arguments and
55 * shares the one implementation.
56 *
57 * @param \WC_Order $order Order just placed through the checkout block.
58 *
59 * @return void
60 */
61 public function store_wc_order_id_block( $order ) {
62 if ( $order instanceof \WC_Order ) {
63 $this->store_wc_order_id( $order->get_id(), array(), $order );
64 }
65 }
66
67 /**
68 * Make the cart available inside REST requests.
69 *
70 * WooCommerce only boots the cart for regular front-end requests, but the
71 * hand-off runs while the booking REST endpoint is being served. Loading it
72 * here also starts the session, and the cookie set on that response is what
73 * carries the cart over to the checkout page.
74 *
75 * @return void
76 */
77 public function load_cart_for_rest() {
78 if ( ! function_exists( 'WC' ) || ! WC()->is_rest_api_request() ) {
79 return;
80 }
81
82 WC()->frontend_includes();
83
84 if ( null === WC()->session && function_exists( 'wc_load_cart' ) ) {
85 wc_load_cart();
86
87 return;
88 }
89
90 if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) {
91 wc_load_cart();
92 }
93 }
94
95 /**
96 * Charge the Booktics total instead of the product price.
97 *
98 * The price is set on the cart item's product clone, never written back to
99 * the product itself: rewriting product meta would let two people booking
100 * the same service at the same moment overwrite each other's price.
101 *
102 * @param \WC_Cart $cart Cart being calculated.
103 *
104 * @return void
105 */
106 public function apply_booking_price( $cart ) {
107 if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
108 return;
109 }
110
111 foreach ( $cart->get_cart() as $cart_item ) {
112 $booking = $cart_item[ Cart_Handoff::CART_KEY ] ?? null;
113
114 if ( ! $booking || ! isset( $booking['price'] ) ) {
115 continue;
116 }
117
118 $cart_item['data']->set_price( (float) $booking['price'] );
119 }
120 }
121
122 /**
123 * Show the booking details under the item in cart and checkout.
124 *
125 * @param array $item_data Existing display rows.
126 * @param array $cart_item Cart item.
127 *
128 * @return array
129 */
130 public function cart_item_details( $item_data, $cart_item ) {
131 $booking = $cart_item[ Cart_Handoff::CART_KEY ] ?? null;
132
133 if ( ! $booking ) {
134 return $item_data;
135 }
136
137 foreach ( $this->display_fields( $booking ) as $label => $value ) {
138 $item_data[] = array(
139 'key' => $label,
140 'value' => $value,
141 );
142 }
143
144 return $item_data;
145 }
146
147 /**
148 * Keep the service name readable even if the product was renamed.
149 *
150 * @param string $name Item name markup.
151 * @param array $cart_item Cart item.
152 * @param string $cart_item_key Cart item key.
153 *
154 * @return string
155 */
156 public function cart_item_name( $name, $cart_item, $cart_item_key ) {
157 $booking = $cart_item[ Cart_Handoff::CART_KEY ] ?? null;
158
159 if ( ! $booking || empty( $booking['service'] ) ) {
160 return $name;
161 }
162
163 return esc_html( $booking['service'] );
164 }
165
166 /**
167 * Persist the booking onto the order line item.
168 *
169 * Hidden keys link the line back to Booktics; the readable ones show up on
170 * the order screen, in WooCommerce emails and in invoice plugins, which is
171 * the detail Timetics' integration never carries across.
172 *
173 * @param \WC_Order_Item_Product $item Line item.
174 * @param string $cart_item_key Cart item key.
175 * @param array $values Cart item values.
176 * @param \WC_Order $order Order being created.
177 *
178 * @return void
179 */
180 public function add_line_item_meta( $item, $cart_item_key, $values, $order ) {
181 $booking = $values[ Cart_Handoff::CART_KEY ] ?? null;
182
183 if ( ! $booking ) {
184 return;
185 }
186
187 $item->add_meta_data( '_booktics_order_id', $booking['order_id'] ?? 0, true );
188 $item->add_meta_data( '_booktics_appointment_id', $booking['appointment_id'] ?? 0, true );
189 $item->add_meta_data( '_booktics_service_id', $booking['service_id'] ?? 0, true );
190
191 foreach ( $this->display_fields( $booking ) as $label => $value ) {
192 $item->add_meta_data( $label, $value, true );
193 }
194 }
195
196 /**
197 * Keep the linking keys out of the order screen's item table.
198 *
199 * They are ours to resolve bookings with, not something a store owner
200 * should have to read past to reach the date and time.
201 *
202 * @param array $hidden Meta keys WooCommerce already hides.
203 *
204 * @return array
205 */
206 public function hide_internal_item_meta( $hidden ) {
207 $hidden[] = '_booktics_order_id';
208 $hidden[] = '_booktics_appointment_id';
209 $hidden[] = '_booktics_service_id';
210
211 return $hidden;
212 }
213
214 /**
215 * Stamp the Booktics order id onto the WooCommerce order as it is created.
216 *
217 * @param \WC_Order $order Order being created.
218 * @param array $data Posted checkout data.
219 *
220 * @return void
221 */
222 public function link_order( $order, $data ) {
223 $booking = $this->booking_in_cart();
224
225 if ( ! $booking ) {
226 return;
227 }
228
229 $order->update_meta_data( '_booktics_order_id', (int) ( $booking['order_id'] ?? 0 ) );
230 $order->update_meta_data( '_booktics_order_no', (string) ( $booking['order_no'] ?? '' ) );
231
232 // Assign the order to the booking's customer, not whoever is logged in.
233 // WooCommerce defaults a new order to the current user, so a store owner
234 // booking for a customer would otherwise own the order while the
235 // customer's email sits in billing. Booktics creates a WP account for
236 // every booking customer, so this is their id; 0 leaves it a guest
237 // order, which is still correct and never the admin.
238 $order->set_customer_id( (int) ( $booking['customer']['wp_user_id'] ?? 0 ) );
239
240 // Kept so the thank-you setting can return the customer to the page they
241 // booked from once the cart no longer exists.
242 $order->update_meta_data( '_booktics_return_url', (string) ( $booking['return_url'] ?? '' ) );
243
244 $this->stamp_billing_contact( $order, $booking );
245 }
246
247 /**
248 * Put the booking's contact details onto the order's billing.
249 *
250 * With the billing fields hidden on the checkout block, the customer never
251 * types a name or phone and the block submits none, so the order would carry
252 * only an email. The booking already holds these, so they are written here,
253 * server side, where it does not matter whether a field was on screen. Only
254 * blanks are filled, so a checkout that did collect the fields keeps whatever
255 * the customer entered.
256 *
257 * @param \WC_Order $order Order being created.
258 * @param array $booking Booking payload from the cart.
259 *
260 * @return void
261 */
262 private function stamp_billing_contact( $order, array $booking ): void {
263 $customer = $booking['customer'] ?? array();
264
265 if ( empty( $customer ) ) {
266 return;
267 }
268
269 if ( ! $order->get_billing_first_name() && ! empty( $customer['first_name'] ) ) {
270 $order->set_billing_first_name( (string) $customer['first_name'] );
271 }
272
273 if ( ! $order->get_billing_last_name() && ! empty( $customer['last_name'] ) ) {
274 $order->set_billing_last_name( (string) $customer['last_name'] );
275 }
276
277 if ( ! $order->get_billing_email() && ! empty( $customer['email'] ) ) {
278 $order->set_billing_email( (string) $customer['email'] );
279 }
280
281 if ( ! $order->get_billing_phone() && ! empty( $customer['phone'] ) ) {
282 $order->set_billing_phone( (string) $customer['phone'] );
283 }
284 }
285
286 /**
287 * Record the WooCommerce order id on the Booktics order.
288 *
289 * Stored in payment_intent_id, which the abandoned-booking sweep already
290 * reads as "an online payment was attempted", so a checkout the customer
291 * walks away from is reconciled rather than left holding a slot forever.
292 *
293 * @param int $wc_order_id WooCommerce order id.
294 * @param array $posted_data Posted checkout data.
295 * @param \WC_Order $order Order object.
296 *
297 * @return void
298 */
299 public function store_wc_order_id( $wc_order_id, $posted_data = array(), $order = null ) {
300 $booktics_order_id = 0;
301
302 if ( $order instanceof \WC_Order ) {
303 $booktics_order_id = (int) $order->get_meta( '_booktics_order_id' );
304 }
305
306 if ( ! $booktics_order_id ) {
307 $booking = $this->booking_in_cart();
308 $booktics_order_id = (int) ( $booking['order_id'] ?? 0 );
309 }
310
311 if ( ! $booktics_order_id ) {
312 return;
313 }
314
315 ( new Order_Model( new Booktics_Database() ) )->update(
316 $booktics_order_id,
317 array( 'payment_intent_id' => (int) $wc_order_id )
318 );
319
320 // WooCommerce owns coupons and tax, so the figures it settled on are the
321 // ones the invoice and the revenue report have to show.
322 if ( $order instanceof \WC_Order ) {
323 Totals_Writer::write( $booktics_order_id, $order );
324 }
325 }
326
327 /**
328 * The booking payload currently sitting in the cart, if any.
329 *
330 * @return array|null
331 */
332 private function booking_in_cart() {
333 if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof \WC_Cart ) {
334 return null;
335 }
336
337 foreach ( WC()->cart->get_cart() as $cart_item ) {
338 if ( ! empty( $cart_item[ Cart_Handoff::CART_KEY ] ) ) {
339 return $cart_item[ Cart_Handoff::CART_KEY ];
340 }
341 }
342
343 return null;
344 }
345
346 /**
347 * Human readable booking rows, shared by the cart display and the line item.
348 *
349 * @param array $booking Booking payload.
350 *
351 * @return array<string, string>
352 */
353 private function display_fields( array $booking ): array {
354 $fields = array();
355
356 if ( ! empty( $booking['date'] ) ) {
357 $timestamp = strtotime( $booking['date'] );
358
359 $fields[ __( 'Date', 'booktics' ) ] = $timestamp
360 ? wp_date( get_option( 'date_format' ), $timestamp )
361 : $booking['date'];
362 }
363
364 if ( ! empty( $booking['start_time'] ) ) {
365 $fields[ __( 'Time', 'booktics' ) ] = empty( $booking['end_time'] )
366 ? $booking['start_time']
367 : $booking['start_time'] . ' - ' . $booking['end_time'];
368 }
369
370 if ( ! empty( $booking['team_member'] ) ) {
371 $fields[ __( 'Team member', 'booktics' ) ] = $booking['team_member'];
372 }
373
374 if ( ! empty( $booking['duration'] ) ) {
375 /* translators: %d: duration in minutes */
376 $fields[ __( 'Duration', 'booktics' ) ] = sprintf( __( '%d min', 'booktics' ), (int) $booking['duration'] );
377 }
378
379 if ( ! empty( $booking['timezone'] ) ) {
380 $fields[ __( 'Timezone', 'booktics' ) ] = $booking['timezone'];
381 }
382
383 return $fields;
384 }
385 }
386