| 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 |
|