PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
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 / cart-handoff.php

cart-handoff.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.28, at core/module/woocommerce-addon/service/cart-handoff.php

250 lines 9.1 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\Abstracts\Booktics_Database;
8 use Booktics\Models\Appointment_Model;
9 use Booktics\Models\Guest_Model;
10 use Booktics\Models\Service_Model;
11 use Booktics\Models\Team_Member_Model;
12
13 /**
14 * Hands a Booktics order over to the WooCommerce cart.
15 *
16 * The booking and its appointment already exist and the slot is already held by
17 * the time this runs; all that is left is to put something payable in front of
18 * the customer. The cart carries a single line item pointing at the service's
19 * mirrored product, stamped with the booking it belongs to.
20 *
21 * The amount charged comes from the Booktics order, not from the product. The
22 * product price is only a default: additional durations, extra services and
23 * group booking all move the real total, so the authoritative figure travels
24 * with the cart item and is applied in Checkout_Hook.
25 */
26 class Cart_Handoff {
27
28 /**
29 * Cart item data key holding the booking payload.
30 */
31 const CART_KEY = 'booktics';
32
33 /**
34 * Put a Booktics order into the WooCommerce cart.
35 *
36 * @param object $order Booktics order row.
37 *
38 * @return array{status:string, redirect_url?:string, message?:string}
39 */
40 public function handoff( $order ): array {
41 $this->ensure_cart();
42
43 if ( ! $this->cart_available() ) {
44 return array(
45 'status' => 'error',
46 'message' => __( 'WooCommerce checkout is unavailable right now. Please try another payment method.', 'booktics' ),
47 );
48 }
49
50 $order_id = (int) ( $order->id ?? 0 );
51
52 $appointments = $order_id ? ( new Appointment_Model() )->where( 'order_id', $order_id ) : array();
53
54 if ( empty( $appointments ) ) {
55 return array(
56 'status' => 'error',
57 'message' => __( 'This booking has nothing to pay for.', 'booktics' ),
58 );
59 }
60
61 // One booking per WooCommerce checkout: the order endpoint rejects
62 // multi-item orders for this gateway, so the first appointment is the
63 // whole order.
64 $appointment = $appointments[0];
65 $service_id = (int) ( $appointment->service_id ?? 0 );
66 $product_id = Product_Sync::ensure( $service_id );
67
68 if ( ! $product_id ) {
69 return array(
70 'status' => 'error',
71 'message' => __( 'This service is not available for WooCommerce checkout.', 'booktics' ),
72 );
73 }
74
75 // Drop any earlier booking still in the cart so a rebook never charges
76 // for two, but leave ordinary WooCommerce products untouched: a customer
77 // can pay for a booking and a product together in one checkout, which is
78 // WooCommerce's own default behaviour. Only the booking line item's
79 // price is overridden ( apply_booking_price keys off the payload ), so a
80 // mixed cart still totals correctly.
81 $this->remove_existing_bookings();
82
83 $cart_item_data = array(
84 self::CART_KEY => $this->build_payload( $order, $appointment, $service_id ),
85 );
86
87 $added = WC()->cart->add_to_cart( $product_id, 1, 0, array(), $cart_item_data );
88
89 if ( ! $added ) {
90 return array(
91 'status' => 'error',
92 'message' => __( 'The booking could not be added to the WooCommerce cart.', 'booktics' ),
93 );
94 }
95
96 return array(
97 'status' => 'success',
98 'redirect_url' => wc_get_checkout_url(),
99 );
100 }
101
102 /**
103 * Build the booking payload that travels with the cart item.
104 *
105 * Everything the checkout, the order line item and the WooCommerce emails
106 * need to describe the booking is captured here, so none of it has to be
107 * looked up again while the customer is paying.
108 *
109 * @param object $order Booktics order row.
110 * @param object $appointment Appointment row.
111 * @param int $service_id Service id.
112 *
113 * @return array
114 */
115 private function build_payload( $order, $appointment, int $service_id ): array {
116 $team_member = ( new Team_Member_Model() )->find( (int) ( $appointment->team_member_id ?? 0 ) );
117 $service = Service_Model::find( $service_id );
118
119 return array(
120 'order_id' => (int) ( $order->id ?? 0 ),
121 'order_no' => (string) ( $order->order_no ?? '' ),
122 'appointment_id' => (int) ( $appointment->id ?? 0 ),
123 'service_id' => $service_id,
124 'service' => $service ? (string) $service->to_array()['title'] : '',
125 'team_member_id' => (int) ( $appointment->team_member_id ?? 0 ),
126 'team_member' => (string) ( $team_member->display_name ?? '' ),
127 'date' => (string) ( $appointment->date ?? '' ),
128 'start_time' => (string) ( $appointment->start_time ?? '' ),
129 'end_time' => (string) ( $appointment->end_time ?? '' ),
130 'duration' => (int) ( $appointment->duration ?? 0 ),
131 'timezone' => (string) ( $appointment->timezone ?? '' ),
132 'price' => (float) ( $order->total ?? 0 ),
133 'customer' => $this->customer_details( (int) ( $order->customer_id ?? 0 ) ),
134 // Where the customer was booking from, so the thank-you setting can
135 // send them back to that page rather than guessing which one holds
136 // the booking shortcode.
137 'return_url' => $this->booking_page_url(),
138 );
139 }
140
141 /**
142 * Contact details captured by the booking form.
143 *
144 * Used to prefill (or entirely replace) the WooCommerce checkout fields, so
145 * the customer is never asked twice for what they already typed.
146 *
147 * @param int $customer_id Guest id.
148 *
149 * @return array<string, string>
150 */
151 private function customer_details( int $customer_id ): array {
152 if ( ! $customer_id ) {
153 return array();
154 }
155
156 $customer = ( new Guest_Model( new Booktics_Database() ) )->find( array( 'id' => $customer_id ) );
157
158 if ( ! $customer ) {
159 return array();
160 }
161
162 $name = trim( (string) ( $customer->name ?? '' ) );
163 $parts = $name ? preg_split( '/\s+/', $name, 2 ) : array();
164
165 return array(
166 'first_name' => $parts[0] ?? '',
167 'last_name' => $parts[1] ?? '',
168 'email' => (string) ( $customer->email ?? '' ),
169 'phone' => (string) ( $customer->phone ?? '' ),
170 // The WP account behind this booking. The WooCommerce order is
171 // assigned to it at checkout so a logged-in store owner booking on a
172 // customer's behalf does not end up owning the order themselves.
173 'wp_user_id' => (int) ( $customer->wp_user_id ?? 0 ),
174 );
175 }
176
177 /**
178 * The page the booking was made on.
179 *
180 * The hand-off runs inside a REST call made by the booking page, so its
181 * referer is that page. Falls back to the site home when a client withholds
182 * the header.
183 *
184 * @return string
185 */
186 private function booking_page_url(): string {
187 $referer = wp_get_referer();
188
189 if ( ! $referer ) {
190 $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
191 }
192
193 return $referer ? $referer : home_url( '/' );
194 }
195
196 /**
197 * Boot the WooCommerce cart when it is missing.
198 *
199 * The hand-off runs inside the booking REST request, where WooCommerce's
200 * front-end bootstrap never fired, so the cart and session can be absent.
201 * Loading them here is permalink-independent, unlike the woocommerce_init
202 * gate that keys off whether the request URL looks like a REST call —
203 * that check flips with the site's permalink structure and leaves the cart
204 * unloaded under pretty permalinks, which is what makes the hand-off fail.
205 *
206 * @return void
207 */
208 private function ensure_cart(): void {
209 if ( ! function_exists( 'WC' ) ) {
210 return;
211 }
212
213 if ( WC()->cart instanceof \WC_Cart ) {
214 return;
215 }
216
217 WC()->frontend_includes();
218
219 if ( function_exists( 'wc_load_cart' ) ) {
220 wc_load_cart();
221 }
222 }
223
224 /**
225 * Remove any Booktics booking already sitting in the cart.
226 *
227 * Ordinary WooCommerce products are deliberately left in place so a booking
228 * and a product can be paid for together; only a prior booking is cleared,
229 * so returning to the booking page and paying again never stacks two.
230 *
231 * @return void
232 */
233 private function remove_existing_bookings(): void {
234 foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) {
235 if ( isset( $cart_item[ self::CART_KEY ] ) ) {
236 WC()->cart->remove_cart_item( $cart_item_key );
237 }
238 }
239 }
240
241 /**
242 * Whether the WooCommerce cart can be used in this request.
243 *
244 * @return bool
245 */
246 private function cart_available(): bool {
247 return function_exists( 'WC' ) && WC()->cart instanceof \WC_Cart;
248 }
249 }
250