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
← All changes | core/order/controllers/order-controller.php +991 -355 1.0.2 → 1.0.28 View file →
@@ -2,21 +2,27 @@
2 2
3 3 namespace Booktics\Order\Controllers;
4 4
5 5 use Booktics\Abstracts\Base_Rest_Controller;
6 +use Booktics\Appointment\Services\Appointment_Validator;
6 7 use Booktics\Abstracts\Booktics_Database;
7 8 use Booktics\Abstracts\User_Model;
8 9 use Booktics\Cart\Cart_Manager;
10 +use Booktics\Customer\Handlers\Customer_Event_Handler;
9 11 use Booktics\Models\Appointment_Model;
10 12 use Booktics\Models\Customer_Model;
13 +use Booktics\Models\Guest_Model;
11 14 use Booktics\Models\Order_Model;
12 15 use Booktics\Models\Payment_Model;
13 16 use Booktics\Models\Service_Model;
14 17 use Booktics\Models\Team_Member_Model;
15 18 use Booktics\Order\Order_Validator;
19 +use Booktics\Order\Services\Order_Key;
20 +use Booktics\Order\Services\Order_Notifier;
21 +use Booktics\Order\Services\Abandoned_Booking_Cleanup;
16 22 use Booktics\Payment\Payment_Method_Manager;
23 +use Booktics\Payment\Payment_Status;
17 24 use Booktics\Services\Time_Zone_Handler;
18 -use Booktics_Stripe\Admin\Controllers\Booktics_Stripe_Controller;
19 25 use DateTime;
20 26 use Exception;
21 27 use WP_Error;
22 28 use WP_HTTP_Response;
@@ -44,11 +50,11 @@
44 50 */
45 51 protected $base = 'orders';
46 52
47 53 /**
48 - * Register routes
54 + * Register REST API routes for orders.
49 55 *
50 - * @return void
56 + * @return void
51 57 */
52 58 public function register_routes(): void {
53 59 register_rest_route(
54 60 $this->namespace, $this->base, array(
@@ -107,9 +113,14 @@
107 113 $this,
108 114 'get_item_permission',
109 115 ),
110 116 'args' => array(
111 - 'id' => array( 'required' => true ),
117 + 'id' => array( 'required' => true ),
118 + 'order_key' => array(
119 + 'description' => __( 'Secret key that proves the client may view this order without logging in (returned when the order is created).', 'booktics' ),
120 + 'type' => 'string',
121 + 'required' => false,
122 + ),
112 123 ),
113 124 ),
114 125 array(
115 126 'methods' => WP_REST_Server::DELETABLE,
@@ -147,9 +158,9 @@
147 158 'methods' => WP_REST_Server::EDITABLE,
148 159 'callback' => array( $this, 'refund_payment' ),
149 160 'permission_callback' => array(
150 161 $this,
151 - 'update_payment_status_permission',
162 + 'refund_payment_permission',
152 163 ),
153 164 ),
154 165 )
155 166 );
@@ -155,9 +166,9 @@
155 166 );
156 167 }
157 168
158 169 /**
159 - * Check if the current user has permission to create orders
170 + * Check if the current user has permission to create orders.
160 171 *
161 172 * @param WP_REST_Request $request
162 173 *
163 174 * @return bool
@@ -166,339 +177,770 @@
166 177 return true;
167 178 }
168 179
169 180 /**
170 - * Prepare and validate order data before creation
181 + * Create a new order with the provided data
171 182 *
172 - * @param array $data
183 + * @param WP_REST_Request $request
173 184 *
185 + * @return WP_Error|WP_HTTP_Response
186 + * @throws Exception
187 + */
188 + public function create_item( $request ) {
189 + $input_data = json_decode( $request->get_body(), true );
190 +
191 + // Single comprehensive validation and preparation
192 + $validation_result = $this->validate_and_prepare_order_data( $input_data );
193 + if ( is_wp_error( $validation_result ) ) {
194 + $validation_error_msg = $validation_result->get_error_message() ?? __( 'Order validation failed', 'booktics' );
195 +
196 + return $this->error(
197 + $validation_error_msg,
198 + 422,
199 + 'Validation'
200 + );
201 + }
202 +
203 + $db = new Booktics_Database();
204 +
205 + // Resolve customer ID
206 + $customer_id_result = $this->resolve_customer_id( $input_data );
207 + if ( is_wp_error( $customer_id_result ) ) {
208 + return $this->error( $customer_id_result->get_error_message() );
209 + }
210 + $input_data['customer_id'] = $customer_id_result;
211 +
212 + try {
213 + $db->begin_transaction();
214 + list( $order_model, $appointments ) = $this->create_order_and_appointments( $input_data, $db );
215 + $payment_intent = $this->handle_payment_if_needed( $input_data, $order_model, $db );
216 + $db->commit();
217 + } catch ( Exception $e ) {
218 + $db->roll_back();
219 + return $this->error( $e->getMessage() );
220 + }
221 +
222 + if ( empty( $input_data['pay_now'] ) ) {
223 + $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
224 + }
225 + $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
226 + do_action('booktics_after_order_created', $order_model, $appointments);
227 +
228 + $response_message = 'Order created successfully';
229 + if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
230 + $response_message = 'Order pending, please proceed payment';
231 + }
232 +
233 + return $this->response(
234 + $response,
235 + sprintf(
236 + /* translators: %s: dynamic response message */
237 + __( 'Response %s', 'booktics' ),
238 + $response_message
239 + ),
240 + 201
241 + );
242 + }
243 +
244 + /**
245 + * Comprehensive validation and preparation of order data
246 + *
247 + * @param array $input_data
248 + *
174 249 * @return array|WP_Error
175 250 */
176 - private function prepare_item_for_order_create( array $data ) {
177 - if ( ! isset( $data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $data['timezone'] ) ) {
178 - $data['timezone'] = 'UTC';
251 + private function validate_and_prepare_order_data( &$input_data ) {
252 + // Step 1: Basic validation using Order_Validator
253 + $validator = new Order_Validator();
254 + $validation_result = $validator->validate( $input_data );
255 + if ( is_wp_error( $validation_result ) ) {
256 + return $validation_result;
179 257 }
180 258
181 - if ( isset( $data['pay_now'] ) && $data['pay_now'] ) {
259 + // Step 2: Preserve original timezone for appointment creation, then validate and setup working timezone
260 + $input_data['original_timezone'] = $input_data['timezone'] ?? wp_timezone_string();
261 + if ( ! isset( $input_data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $input_data['timezone'] ) ) {
262 + $input_data['timezone'] = 'UTC';
263 + }
264 +
265 + // Step 3: Payment validation if pay_now is true
266 + if ( isset( $input_data['pay_now'] ) && $input_data['pay_now'] ) {
182 267 $validate = booktics_validate(
183 - $data, array(
268 + $input_data, array(
184 269 'payment_method' => array( 'required' ),
185 270 )
186 271 );
187 -
188 272 if ( is_wp_error( $validate ) ) {
189 273 return $validate;
190 274 }
275 +
276 + // WooCommerce checkout carries one booking at a time: the cart is
277 + // emptied and a single line item added, so a multi-item order would
278 + // silently lose everything but the first appointment.
279 + if (
280 + 'woocommerce' === strtolower( (string) $input_data['payment_method'] )
281 + && is_array( $input_data['items'] ?? null )
282 + && count( $input_data['items'] ) > 1
283 + ) {
284 + return new WP_Error(
285 + 'items',
286 + __( 'WooCommerce checkout supports one service at a time. Please book them separately.', 'booktics' )
287 + );
288 + }
191 289 }
192 290
193 - $error = ( new \WP_Error() );
194 - if ( ! is_array( $data['items'] ) || count( $data['items'] ) == 0 ) {
195 - $error->add( 'items', __( 'Items are required', 'booktics' ) );
196 -
197 - return $error;
291 + // Step 4: Process and validate each item
292 + if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
293 + return new WP_Error( 'items', __( 'Items are required', 'booktics' ) );
198 294 }
199 295
200 - foreach ( $data['items'] as $key => $item ) {
201 - $item_validate = booktics_validate(
202 - $item, array(
203 - 'date' => array( 'required' ),
204 - 'start_time' => array( 'required' ),
205 - 'end_time' => array( 'required' ),
206 - 'team_member_id' => array( 'required' ),
207 - 'service_id' => array( 'required' ),
208 - )
296 + foreach ( $input_data['items'] as $key => $item ) {
297 + // Validate required fields for each item
298 + $item_rules = array(
299 + 'date' => array( 'required' ),
300 + 'start_time' => array( 'required' ),
301 + 'end_time' => array( 'required' ),
302 + 'team_member_id' => array( 'required' ),
303 + 'service_id' => array( 'required' ),
209 304 );
210 305
306 + /**
307 + * Filters the validation rules applied to a single order item.
308 + *
309 + * Extensions that assign a team member server-side (Booktics Pro
310 + * round robin) drop the team_member_id requirement here.
311 + *
312 + * @since 1.0.26
313 + *
314 + * @param array $item_rules Validation rules.
315 + * @param array $item The order item being validated.
316 + * @param array $input_data The whole order payload.
317 + */
318 + $item_rules = apply_filters( 'booktics_order_item_validation_rules', $item_rules, $item, $input_data );
319 +
320 + $item_validate = booktics_validate( $item, $item_rules );
321 +
211 322 if ( is_wp_error( $item_validate ) ) {
212 323 return $item_validate;
213 324 }
214 325
326 + // Convert times to UTC
215 327 $converted_start_time = Time_Zone_Handler::convert_to_utc(
216 328 $item['start_time'],
217 329 $item['date'],
218 - $data['timezone']
330 + $input_data['timezone']
219 331 );
220 - $converted_end_time = Time_Zone_Handler::convert_to_utc(
332 + $converted_end_time = Time_Zone_Handler::convert_to_utc(
221 333 $item['end_time'],
222 334 $item['date'],
223 - $data['timezone']
335 + $input_data['timezone']
224 336 );
225 - $item['start_time'] = $converted_start_time['time'];
226 - $item['end_time'] = $converted_end_time['time'];
227 337
228 - $start_time = strtotime( $item['start_time'] );
229 - $end_time = strtotime( $item['end_time'] );
338 + // Validate time range
339 + $start_time = strtotime( $converted_start_time['time'] );
340 + $end_time = strtotime( $converted_end_time['time'] );
230 341
231 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
232 - $error->add( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
343 + return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
344 + }
233 345
234 - return $error;
346 + // Attendee count decides both capacity and price, so resolve it once
347 + // here and reuse the same number everywhere. Reading it separately
348 + // let a client book N seats while paying for one.
349 + $attendees = $this->resolve_item_attendees( $item, $input_data );
350 +
351 + // Money is never taken from the request. Whatever price/subtotal/total
352 + // the client sent is discarded and rebuilt from the service record.
353 + $amount = $this->calculate_item_amount( $item, $attendees );
354 + if ( is_wp_error( $amount ) ) {
355 + return $amount;
235 356 }
236 357
237 - $data['items'][ $key ]['start_time'] = $converted_start_time['time'];
238 - $data['items'][ $key ]['end_time'] = $converted_end_time['time'];
239 - $data['items'][ $key ]['date'] = gmdate( 'Y-m-d', strtotime( $converted_start_time['date'] ) );
240 - $data['items'][ $key ]['customer_id'] = absint( $item['customer_id'] );
241 - $data['items'][ $key ]['team_member_id'] = absint( $item['team_member_id'] );
242 - $data['items'][ $key ]['service_id'] = absint( $item['service_id'] );
243 - $data['items'][ $key ]['additional_duration_id'] = $item['additional_duration_id'];
244 - $data['items'][ $key ]['price'] = floatval( $item['price'] );
245 - $data['items'][ $key ]['subtotal'] = floatval( $item['subtotal'] );
246 - $data['items'][ $key ]['quantity'] = absint( $item['quantity'] ?? 1 );
247 - $data['items'][ $key ]['group_booking'] = $item['group_booking'] ?? array();
358 + // Sanitize and prepare item data
359 + $input_data['items'][ $key ] = array(
360 + 'start_time' => $converted_start_time['time'],
361 + 'end_time' => $converted_end_time['time'],
362 + 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
363 + 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
364 + 'team_member_id' => absint( $item['team_member_id'] ),
365 + 'service_id' => absint( $item['service_id'] ),
366 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
367 + 'price' => $amount['price'],
368 + 'subtotal' => $amount['total'],
369 + 'total' => $amount['total'],
370 + 'quantity' => absint( $item['quantity'] ?? 1 ),
371 + 'total_attendees' => $attendees,
372 + );
373 + $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
248 374 }
249 375
250 - return $data;
376 + // Order totals are the sum of the recalculated lines. Tax and coupons are
377 + // settled by the gateway after checkout (WooCommerce writes the real
378 + // values back), so nothing the client sent for them is trusted here.
379 + $order_total = 0.0;
380 + foreach ( $input_data['items'] as $prepared_item ) {
381 + $order_total += (float) $prepared_item['total'];
382 + }
383 +
384 + $input_data['subtotal'] = round( $order_total, 2 );
385 + $input_data['total'] = round( $order_total, 2 );
386 + $input_data['tax_total'] = 0;
387 + $input_data['coupon_discount'] = 0;
388 + $input_data['coupon_code'] = '';
389 +
390 + $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
391 +
392 + return $input_data;
251 393 }
252 394
395 + /**
396 + * Resolve how many attendees a single booking line is for.
397 + *
398 + * Group booking sends the count inside the item; older payloads put a single
399 + * count at the root. One resolved value is then used for the capacity check,
400 + * the price and the stored appointment.
401 + *
402 + * @param array $item Raw item from the request.
403 + * @param array $input_data Raw order payload.
404 + *
405 + * @return int
406 + */
407 + private function resolve_item_attendees( $item, $input_data ) {
408 + $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array();
253 409
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
414 +
415 + return max( 1, absint( $attendees ) );
416 + }
417 +
254 418 /**
255 - * Create a new order with the provided data
419 + * Calculate what a booking line actually costs, from the service record.
256 420 *
257 - * @param WP_REST_Request $request
421 + * @param array $item Raw item from the request.
422 + * @param int $attendees Resolved attendee count for this line.
258 423 *
259 - * @return WP_Error|WP_HTTP_Response
260 - * @throws Exception
424 + * @return array|WP_Error array{price: float, total: float}
261 425 */
262 - public function create_item( $request ) {
263 - $input_data = json_decode( $request->get_body(), true );
264 - $validator = new Order_Validator();
265 - $validation_result = $validator->validate( $input_data );
426 + private function calculate_item_amount( $item, $attendees ) {
427 + $service_id = absint( $item['service_id'] ?? 0 );
428 + $service = Service_Model::find( $service_id );
266 429
267 - if ( is_wp_error( $validation_result ) ) {
268 - return $this->error(
269 - __( 'Order validation failed', 'booktics' ),
270 - 422,
271 - 'Validation',
272 - $validation_result->get_error_messages()
273 - );
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
274 432 }
275 433
276 - $db = new Booktics_Database();
277 - $validation = $this->prepare_item_for_order_create( $input_data );
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
278 435
279 - if ( is_wp_error( $validation ) ) {
280 - return $this->error( $validation->get_error_message() );
436 + if ( empty( $durations ) ) {
437 + return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) );
281 438 }
282 439
283 - if ( ! isset( $input_data['customer_id'] ) ) {
284 - $customer_id = $this->find_or_create_customer( $input_data );
285 - if ( is_wp_error( $customer_id ) ) {
286 - return $this->error( $customer_id->get_error_message() );
440 + $requested_id = booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' );
441 + $selected = null;
442 +
443 + foreach ( $durations as $duration ) {
444 + if ( (string) ( $duration['id'] ?? '' ) === (string) $requested_id ) {
445 + $selected = $duration;
446 + break;
287 447 }
288 - $input_data['customer_id'] = $customer_id;
289 448 }
290 - try {
291 - $db->begin_transaction();
292 - // Order creation logic
293 - $input_data['currency'] = $input_data['currency'] ?? 'usd';
294 - $order_model = new Order_Model( $db );
295 - $order_model = $order_model->create(
296 - array(
297 - 'customer_id' => $input_data['customer_id'],
298 - 'payment_status' => 'pending',
299 - 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
300 - 'tax_total' => $input_data['tax_total'] ?? 0,
301 - 'coupon_code' => $input_data['coupon_code'] ?? '',
302 - 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
303 - 'subtotal' => $input_data['subtotal'],
304 - 'timezone' => $input_data['timezone'] ?? 'UTC',
305 - 'total' => $input_data['total'],
306 - 'payment_method' => $input_data['payment_method'] ?? null,
307 - )
449 +
450 + // A single-duration service may be booked without naming the duration,
451 + // but an unknown id must never silently fall back to a cheaper one.
452 + if ( null === $selected ) {
453 + if ( '' !== $requested_id ) {
454 + return new WP_Error( 'invalid_duration', __( 'Invalid duration selected for this service.', 'booktics' ) );
455 + }
456 + $selected = reset( $durations );
457 + }
458 +
459 + $price = (float) ( $selected['charge_amount'] ?? 0 );
460 +
461 + // Add-ons priced by booktics-pro (extra services). Runs before the
462 + // attendee multiplier so add-ons scale with the group, matching the
463 + // price the booking form showed.
464 + $total = (float) apply_filters( 'booktics_order_item_amount', $price, $item, $service, $attendees );
465 + $total = $total * $this->get_attendee_multiplier( $service_id, $attendees );
466 +
467 + return array(
468 + 'price' => round( max( 0, $price ), 2 ),
469 + 'total' => round( max( 0, $total ), 2 ),
470 + );
471 + }
472 +
473 + /**
474 + * How many times a line price is charged for a group booking.
475 + *
476 + * Services that charge per attendee bill the flat price once; the rest
477 + * multiply it by the number of people, which is what the booking form does.
478 + *
479 + * @param int $service_id Service ID.
480 + * @param int $attendees Resolved attendee count.
481 + *
482 + * @return int
483 + */
484 + private function get_attendee_multiplier( $service_id, $attendees ) {
485 + $group_booking = get_post_meta( $service_id, 'group_booking', true );
486 +
487 + if ( ! $this->is_group_booking_enabled( $group_booking ) ) {
488 + return 1;
489 + }
490 +
491 + if ( ! empty( $group_booking['charge_per_attendee'] ) ) {
492 + return 1;
493 + }
494 +
495 + return max( 1, (int) $attendees );
496 + }
497 +
498 + /**
499 + * Checks if group booking is enabled for a services group booking meta data
500 + *
501 + * @param array $group_booking_meta Group booking meta data
502 + *
503 + * @return bool True if group booking is enabled, false otherwise
504 + */
505 + private function is_group_booking_enabled( $group_booking_meta ) {
506 + return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
507 + }
508 +
509 + /**
510 + * Resolve the customer ID from input data, creating or finding the customer as needed
511 + *
512 + * @param array $input_data
513 + *
514 + * @return int|WP_Error
515 + */
516 + private function resolve_customer_id( &$input_data ) {
517 + if ( isset( $input_data['customer_id'] ) ) {
518 + return $input_data['customer_id'];
519 + }
520 + if ( ! isset( $input_data['customer'] ) ) {
521 + return new WP_Error( 'missing_customer', __( 'Customer information is required', 'booktics' ) );
522 + }
523 +
524 + return $this->find_or_create_customer( $input_data['customer'] );
525 + }
526 +
527 + /**
528 + * Create the order and its associated appointments
529 + *
530 + * @param array $input_data
531 + * @param Booktics_Database $db
532 + *
533 + * @return array [Order_Model $order_model, array $appointments]
534 + */
535 + private function create_order_and_appointments( $input_data, $db ) {
536 + $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
537 + $order_model = new Order_Model( $db );
538 +
539 + $input_timezone_string = $input_data['timezone'] ?? 'UTC';
540 + $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
541 + $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string;
542 +
543 + $order_data = array(
544 + 'customer_id' => $input_data['customer_id'],
545 + 'payment_status' => Payment_Status::FAILED,
546 + 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
547 + 'status' => 'open',
548 + 'tax_total' => $input_data['tax_total'] ?? 0,
549 + 'coupon_code' => $input_data['coupon_code'] ?? '',
550 + 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
551 + 'subtotal' => $input_data['subtotal'],
552 + 'timezone' => $input_timezone,
553 + 'total' => $input_data['total'],
554 + 'currency' => $input_data['currency'],
555 + 'payment_method' => $input_data['payment_method'] ?? null,
556 + );
557 +
558 + // This is to only allow setting payment status when creating order from admin panel as admin
559 + if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
560 + $order_data['payment_status'] = $input_data['payment_status'];
561 + }
562 +
563 + $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
564 + $order_model = $order_model->create( $order_data );
565 + $appointments = array();
566 + foreach ( $input_data['items'] as $item ) {
567 + if ( isset( $item['uuid'] ) ) {
568 + ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
569 + }
570 + $total_duration_min = $this->calculate_total_duration( $item );
571 +
572 + // Store date/time in a locale-independent format. wp_date() localizes digits and
573 + // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
574 + // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
575 + // the same timezone-aware value but is never localized.
576 + $time_format = get_option( 'time_format' );
577 + $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
578 + $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
579 + $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
580 +
581 + $appointment_data = array(
582 + 'start_time' => $start_local->format( $time_format ),
583 + 'end_time' => $end_local->format( $time_format ),
584 + 'date' => $date_local->format( 'Y-m-d' ),
585 + 'customer_id' => $input_data['customer_id'] ?? '',
586 + 'team_member_id' => $item['team_member_id'] ?? '',
587 + 'service_id' => $item['service_id'] ?? '',
588 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
589 + 'duration' => $total_duration_min,
590 + 'order_id' => $order_model->id,
591 + 'quantity' => $item['quantity'] ?? 1,
592 + 'price' => $item['price'] ?? 0,
593 + 'subtotal' => $item['subtotal'] ?? 0,
594 + 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
595 + 'status' => booktics_get_option( 'appointment_status', 'pending' ),
596 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
597 + 'timezone' => $original_timezone,
308 598 );
309 599
310 - foreach ( $input_data['items'] as $item ) {
311 - if ( isset( $item['uuid'] ) ) {
312 - ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
313 - }
314 - $total_duration_min = $this->calculate_total_duration( $item );
315 - $appointment_data = array(
316 - 'start_time' => $item['start_time'] ?? '',
317 - 'end_time' => $item['end_time'] ?? '',
318 - 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
319 - 'customer_id' => $input_data['customer_id'] ?? '',
320 - 'team_member_id' => $item['team_member_id'] ?? '',
321 - 'service_id' => $item['service_id'] ?? '',
322 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
323 - 'duration' => $total_duration_min,
324 - 'order_id' => $order_model->id,
325 - 'quantity' => $item['quantity'] ?? 1,
326 - 'price' => $item['price'] ?? 0,
327 - 'subtotal' => $item['subtotal'] ?? 0,
328 - 'total' => $item['total'] ?? 0,
329 - 'status' => 'pending',
330 - );
331 - $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
332 - Appointment_Model::create( $appointment_data );
600 +
601 +
602 + $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
603 +
604 + $conflict = Appointment_Validator::validate_slot_available( $appointment_data );
605 + if ( $conflict ) {
606 + throw new Exception( esc_html( $conflict->get_error_message() ) );
333 607 }
334 608
335 - if ( $input_data['pay_now'] ) {
336 - $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
337 - if ( $payment_method ) {
338 - $payment_intent = $payment_method->process_payment( $order_model );
339 - if ( $payment_intent['status'] === 'error' ) {
340 - return $this->error( $payment_intent['message'] );
341 - }
342 - if ( $payment_intent['status'] === 'success' ) {
343 - ( new Order_Model( $db ) )->update(
344 - $order_model->id, array(
345 - 'payment_intent_id' => $payment_intent['payment_intent'],
346 - )
347 - );
609 + $appointment = Appointment_Model::create( $appointment_data );
610 + if ( is_wp_error( $appointment ) ) {
611 + throw new Exception( esc_html( $appointment->get_error_message() ) );
612 + }
348 613
349 - ( new Payment_Model( $db ) )->create(
350 - array(
351 - 'order_id' => $order_model->id,
352 - 'customer_id' => $input_data['customer_id'],
353 - 'amount' => $input_data['total'] * 100,
354 - 'currency' => $input_data['currency'],
355 - 'intent_id' => $payment_intent['payment_intent'],
356 - 'transaction_id' => null,
357 - 'payment_method' => $input_data['payment_method'],
358 - 'status' => 'pending',
359 - 'date' => gmdate( 'Y-m-d' ),
360 - 'date_time' => gmdate( 'Y-m-d H:i:s' ),
361 - )
362 - );
363 - }
364 - } else {
365 - return $this->error( __( 'Payment method can not found!', 'booktics' ) );
366 - }
367 - }
614 + do_action( 'booktics_after_appointment_created', $appointment );
615 + $appointments[] = $appointment;
616 + }
368 617
369 - $db->commit();
370 - } catch ( Exception $e ) {
371 - $db->roll_back();
618 + return array( $order_model, $appointments );
619 + }
372 620
373 - return $this->error( $e->getMessage() );
621 +
622 + /**
623 + * Process payment for an order if immediate payment is requested
624 + *
625 + * @param array $input_data Order input data containing payment details
626 + * @param Order_Model $order_model Order model instance
627 + * @param Booktics_Database $db Database instance
628 + *
629 + * @return array
630 + * @throws Exception
631 + */
632 + private function handle_payment_if_needed( $input_data, $order_model, $db ) {
633 + if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
634 + return array();
374 635 }
375 - do_action( 'booktics_order_created', $order_model );
376 636
377 - $customer = ( new Customer_Model() )->find( $input_data['customer_id'] );
378 - $appointments = ( new Appointment_Model() )->where( 'order_id', $order_model->id );
637 + $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
638 + if ( ! $payment_method ) {
639 + throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
640 + }
641 +
642 + $payment_intent = $payment_method->process_payment( $order_model );
643 +
644 + if ( $payment_intent['status'] === 'error' ) {
645 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
646 + throw new Exception( $payment_intent['message'] );
647 + }
648 +
649 + if ( $payment_intent['status'] === 'success' ) {
650 + $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
651 + // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
652 + // Status is updated after client-side confirmation via update_payment_status endpoint.
653 + }
654 +
655 + return $payment_intent;
656 + }
657 +
658 +
659 + /**
660 + * Update payment records after successful payment processing
661 + *
662 + * @param Booktics_Database $db Database instance
663 + * @param Order_Model $order_model Order model instance
664 + * @param array $input_data Order input data containing payment details
665 + * @param array $payment_intent Payment intent data from payment processor
666 + *
667 + * @return void
668 + */
669 + private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
670 + ( new Order_Model( $db ) )->update(
671 + $order_model->id,
672 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
673 + );
674 +
675 + ( new Payment_Model( $db ) )->create(
676 + array(
677 + 'order_id' => $order_model->id,
678 + 'customer_id' => $input_data['customer_id'],
679 + 'amount' => $input_data['total'] * 100,
680 + 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
681 + 'intent_id' => $payment_intent['payment_intent'] ?? null,
682 + 'transaction_id' => null,
683 + 'payment_method' => $input_data['payment_method'],
684 + 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
685 + 'date' => gmdate( 'Y-m-d' ),
686 + 'date_time' => gmdate( 'Y-m-d H:i:s' ),
687 + )
688 + );
689 + }
690 +
691 + /**
692 + * Resolve team member email with WP user fallback.
693 + *
694 + * @param string $primary
695 + * @param mixed $team_member_id
696 + *
697 + * @return string
698 + */
699 + private function resolve_team_member_email( string $primary, $team_member_id ): string {
700 + return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
701 + }
702 +
703 + /**
704 + * Helper: Format a single appointment for API output
705 + *
706 + * @param Appointment_Model|array $appointment
707 + *
708 + * @return array
709 + */
710 + private function format_appointment( $appointment ): array {
711 + return Order_Notifier::format_appointment( $appointment );
712 + }
713 +
714 + /**
715 + * Helper: Format order for API output
716 + *
717 + * @param Order_Model $order_model
718 + * @param array $input_data
719 + * @param array $payment_intent
720 + *
721 + * @return array
722 + */
723 + private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
724 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
725 + // Ensure $order_model is an object before accessing properties
726 + $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
727 + $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
379 728 $appointment_details = array();
380 729 foreach ( $appointments as $appointment ) {
381 - $appointment = $appointment->to_array();
382 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
383 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
384 - $service = $service ? $service->to_array() : array();
385 - $additional_durations = $service['additional_durations'] ?? array();
386 - $additional_durations = ! empty( $additional_durations )
387 - ? array_filter(
388 - $additional_durations, function ( $duration ) use ( $appointment ) {
389 - return $duration['id'] === $appointment['additional_duration_id'];
390 - }
391 - ) : array();
392 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
393 - $formatted_appointment = array(
394 - 'id' => $appointment['id'],
395 - 'date' => $appointment['date'],
396 - 'start_time' => $appointment['start_time'],
397 - 'end_time' => $appointment['end_time'],
398 - 'additional_duration_id' => $appointment['additional_duration_id'],
399 - 'team' => array(
400 - 'id' => $team_member->id,
401 - 'display_name' => $team_member->display_name ?? '',
402 - 'image' => $team_member->image ?? '',
403 - ),
404 - 'service' => array(
405 - 'id' => $appointment['service_id'],
406 - 'title' => $service['title'] ?? '',
407 - 'additional_duration' => $additional_duration,
408 - ),
409 - 'duration' => $appointment['duration'],
410 - 'quantity' => $appointment['quantity'],
411 - 'price' => $appointment['price'],
412 - 'subtotal' => $appointment['subtotal'],
413 - 'total' => $appointment['total'],
414 - 'status' => $appointment['status'],
415 - );
416 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
417 - $appointment_details[] = $formatted_appointment;
418 - $appointment_time = $appointment['date'] . ' ' . $appointment['start_time'];
419 - $dt = new DateTime( $appointment_time, wp_timezone() );
420 - $timestamp = $dt->getTimestamp();
421 - do_action(
422 - 'global_notification_hook', 'appointment_scheduled', array(
423 - 'appointment_service' => $service['title'] ?? '',
424 - 'appointment_team_member' => $team_member->display_name ?? '',
425 - 'start_time' => $appointment['start_time'],
426 - 'appointment_date' => $appointment['date'],
427 - 'appointment_time' => $timestamp,
428 - 'attendee_count' => $appointment['attendee_count'],
429 - 'customer_name' => $customer->display_name ?? '',
430 - 'user_email' => $customer->user_email ?? '',
431 - )
432 - );
730 + $appointment_details[] = $this->format_appointment( $appointment );
433 731 }
434 732
435 - do_action(
436 - 'global_notification_hook', 'order_created', array(
437 - 'order_no' => $order_model->order_no,
438 - 'order_date' => booktics_datetime( 'M j, Y, g:ia', time(), 'UTC' ),
439 - 'order_total' => $input_data['total'],
440 - 'customer_name' => $customer->display_name ?? '',
441 - 'user_email' => $customer->user_email,
442 - )
443 - );
733 + $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
444 734
445 - $response = array(
446 - 'id' => $order_model->id,
447 - 'order_no' => $order_model->order_no,
448 - 'customer_email' => $customer->user_email,
449 - 'subtotal' => $order_model->subtotal,
450 - 'total' => $order_model->total,
451 - 'payment_status' => $order_model->payment_status,
735 + $order_data = array(
736 + 'id' => $order_id,
737 + 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
738 + 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
739 + 'customer' => array(
740 + 'id' => $customer->id ?? null,
741 + 'name' => $customer->name ?? '',
742 + 'phone' => $customer->phone ?? '',
743 + 'email' => $customer->email ?? '',
744 + ),
745 + 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
746 + 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
747 + // Tax and coupons are settled at checkout (WooCommerce owns them for
748 + // those bookings), so the invoice has to show them, not just the
749 + // subtotal. Missing here is what left the order looking untaxed.
750 + 'tax_total' => is_object( $order_model ) ? $order_model->tax_total : ( $order_model['tax_total'] ?? null ),
751 + 'coupon_discount' => is_object( $order_model ) ? $order_model->coupon_discount : ( $order_model['coupon_discount'] ?? null ),
752 + 'coupon_code' => is_object( $order_model ) ? $order_model->coupon_code : ( $order_model['coupon_code'] ?? null ),
753 + 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
754 + 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
755 + 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
452 756 'items' => $appointment_details,
453 757 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
454 758 'client_secret' => $payment_intent['client_secret'] ?? '',
759 + // Gateways that collect payment on their own page (WooCommerce
760 + // checkout) return where to send the customer instead of a secret.
761 + 'redirect_url' => $payment_intent['redirect_url'] ?? '',
762 + 'total_services' => $input_data['total_services'] ?? 1,
455 763 );
456 764
457 - return $this->response( $response, __( 'Order created successfully', 'booktics' ), 201 );
765 + $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
766 +
767 + return $order_data;
458 768 }
459 769
460 770 /**
771 + * Helper: Send notifications for order and appointment creation
772 + *
773 + * Delegates to Order_Notifier so gateways that confirm a booking outside a
774 + * REST request (WooCommerce) fire exactly the same events.
775 + *
776 + * @param Booktics_Database $db
777 + * @param Order_Model $order_model
778 + * @param int $customer_id
779 + * @param array $appointments
780 + *
781 + * @return void
782 + */
783 + private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
784 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
785 + }
786 +
787 + /**
461 788 * Find existing customer by email or create new customer
462 789 *
463 - * @param array $data
790 + * @param array $customer
464 791 *
465 792 * @return int|WP_Error
466 793 */
467 - private function find_or_create_customer( array $data ) {
468 - $customer = get_user_by( 'email', $data['customer_email'] );
794 + private function find_or_create_customer( array $customer ) {
795 + $db = new Booktics_Database();
796 + if ( ! isset( $customer['email'] ) ) {
797 + return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
798 + }
799 + $user_login = User_Model::generate_username( $customer['email'] );
800 + $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
801 + $wp_user_id = null;
802 + if ( $allow_customer_as_wp_user ) {
803 + $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
804 + }
805 + $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
806 + if ( $guest ) {
807 + return $guest->id;
808 + }
809 + $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
469 810
470 - if ( $customer ) {
471 - return $customer->ID;
811 + return $guest->id;
812 + }
813 +
814 + /**
815 + * Get or create a WordPress user for the customer
816 + *
817 + * @param array $customer
818 + * @param string $user_login
819 + *
820 + * @return int WP user ID
821 + */
822 + private function get_or_create_wp_user( $customer, $user_login ) {
823 + $wp_user = get_user_by( 'email', $customer['email'] );
824 + if ( $wp_user ) {
825 + return $wp_user->ID;
472 826 }
827 + $userdata = array(
828 + 'user_login' => $user_login,
829 + 'user_email' => $customer['email'],
830 + 'first_name' => $customer['name'] ?? '',
831 + 'display_name' => $customer['name'] ?? '',
832 + 'phone' => $customer['phone'] ?? '',
833 + );
834 + $customer_model = new Customer_Model();
835 + $customer_id = $customer_model->save( $userdata );
836 + ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
473 837
474 - $userdata = array(
475 - 'user_login' => User_Model::generate_username( $data['customer_email'] ),
476 - 'user_email' => $data['customer_email'],
477 - 'user_pass' => wp_generate_password(),
478 - 'first_name' => $data['customer_name'] ?? '',
479 - 'display_name' => $data['customer_name'] ?? '',
480 - 'phone' => $data['customer_phone'] ?? '',
481 - );
838 + return $customer_id;
839 + }
482 840
483 - $customer = new Customer_Model();
841 + /**
842 + * Find and update a guest record by email
843 + *
844 + * @param Booktics_Database $db
845 + * @param array $customer
846 + *
847 + * @return object|null Guest_Model instance or null
848 + */
849 + private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
850 + $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
851 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
852 + ( new Guest_Model( $db ) )->update(
853 + $guest->id, array(
854 + 'name' => $customer['name'] ?? '',
855 + 'phone' => $customer['phone'] ?? '',
856 + 'wp_user_id' => $wp_user_id,
857 + )
858 + );
859 + }
484 860
485 - return $customer->save( $userdata );
861 + return $guest;
486 862 }
487 863
488 864 /**
489 - * Check if user has permission to get orders
865 + * Determine whether the current request is authorized to overwrite an
866 + * existing customer's stored contact details (name, phone, wp_user_id).
490 867 *
868 + * @param object $guest Existing Guest_Model record.
869 + * @param string $email Submitted customer email.
870 + *
871 + * @return bool True when the caller may overwrite the stored record.
872 + */
873 + private function caller_owns_customer_email( $guest, $email ) {
874 + // Admin or team member managing a booking on the customer's behalf.
875 + if ( booktics_is_admin_or_team_member() ) {
876 + return true;
877 + }
878 +
879 + if ( ! is_user_logged_in() ) {
880 + return false;
881 + }
882 +
883 + // The record is already linked to the logged-in user.
884 + if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
885 + return true;
886 + }
887 +
888 + // The logged-in user's own account email matches the submitted email.
889 + $current_user = wp_get_current_user();
890 +
891 + return $current_user
892 + && ! empty( $current_user->user_email )
893 + && strtolower( $current_user->user_email ) === strtolower( (string) $email );
894 + }
895 +
896 + /**
897 + * Create a new guest record
898 + *
899 + * @param Booktics_Database $db
900 + * @param array $customer
901 + * @param string $user_login
902 + * @param int|null $wp_user_id
903 + *
904 + * @return object Guest_Model instance
905 + */
906 + private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
907 + return ( new Guest_Model( $db ) )->create(
908 + array(
909 + 'wp_user_id' => $wp_user_id,
910 + 'email' => $customer['email'],
911 + 'name' => $customer['name'] ?? '',
912 + 'user_login' => $user_login,
913 + 'phone' => $customer['phone'] ?? '',
914 + )
915 + );
916 + }
917 +
918 + /**
919 + * Check if user has permission to list orders.
920 + *
921 + * Anonymous access is denied: the collection handler scopes to the current customer or team member;
922 + * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
923 + * guests when {@see get_item_permission()} validates a matching order_key.
924 + *
491 925 * @param WP_REST_Request $request
492 926 *
493 - * @return bool
927 + * @return bool|WP_Error
494 928 */
495 929 public function get_items_permission( $request ) {
496 - return true;
930 + if ( is_user_logged_in() ) {
931 + return true;
932 + }
933 +
934 + return new WP_Error(
935 + 'booktics_forbidden',
936 + __( 'You are not allowed to view orders.', 'booktics' ),
937 + array( 'status' => 403 )
938 + );
497 939 }
498 940
499 941 /**
500 - * Get paginated list of orders
942 + * Get paginated list of orders.
501 943 *
502 944 * @param WP_REST_Request $request
503 945 *
504 946 * @return WP_Error|WP_HTTP_Response
@@ -503,20 +945,20 @@
503 945 *
504 946 * @return WP_Error|WP_HTTP_Response
505 947 */
506 948 public function get_items( $request ) {
507 - $page = (int) $request->get_param( 'paged' ) == 0 ? 1 : $request->get_param( 'paged' );
508 - $per_page = (int) $request->get_param( 'per_page' ) == 0 ? 10 : $request->get_param( 'per_page' );
509 - $payment_status = $request->get_param( 'payment_status' );
510 - $payment_method = $request->get_param( 'payment_method' );
511 - $order_date = $request->get_param( 'order_date' );
512 - $team_member = (int) $request->get_param( 'team_member' );
513 - $service = (int) $request->get_param( 'service' );
514 - $search = $request->get_param( 'search' );
949 + $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
950 + $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
951 + $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
952 + $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
953 + $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
954 + $team_member = absint( $request->get_param( 'team_member' ) );
955 + $service = absint( $request->get_param( 'service' ) );
956 + $search = sanitize_text_field( $request->get_param( 'search' ) );
515 957
516 - $db = new Booktics_Database();
958 + $db = new Booktics_Database();
517 959 $order_model = new Order_Model( $db );
518 - $conditions = array();
960 + $conditions = array();
519 961
520 962 if ( $payment_status ) {
521 963 $conditions['payment_status'] = $payment_status;
522 964 }
@@ -532,8 +974,36 @@
532 974 if ( $search ) {
533 975 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
534 976 }
535 977
978 + $conditions = apply_filters( 'booktics_order_filters', $conditions );
979 +
980 + // Scope to customer's own orders when not admin/team-member
981 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
982 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
983 + if ( ! $guest ) {
984 + return $this->response(
985 + array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
986 + __( 'Orders fetched successfully', 'booktics' )
987 + );
988 + }
989 + $conditions['customer_id'] = $guest->id;
990 + }
991 +
992 + // Scope to team member's assigned orders - only return orders that contain
993 + // at least one appointment where team_member_id equals the current user's team member ID
994 + $is_team_member_scope = false;
995 + $team_member_filter_id = null;
996 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
997 + $current_user_id = get_current_user_id();
998 + $team_member_model = new Team_Member_Model();
999 + $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1000 + if ( $team_member ) {
1001 + $is_team_member_scope = true;
1002 + $team_member_filter_id = $team_member->id;
1003 + }
1004 + }
1005 +
536 1006 $orders = $order_model->get(
537 1007 $conditions, $page, $per_page, array(
538 1008 'created_at',
539 1009 'DESC',
@@ -564,14 +1034,28 @@
564 1034 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
565 1035 }
566 1036
567 1037 foreach ( $orders['items'] as &$order ) {
568 - $customer = ( new Customer_Model() )->find( $order->customer_id );
569 - $order->customer = array(
570 - 'display_name' => $customer ? $customer->display_name : '',
571 - 'email' => $customer ? $customer->user_email : '',
572 - );
1038 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1039 + $customer_data_for_order = [
1040 + 'id' => $customer ? $customer->id : null,
1041 + 'name' => $customer ? $customer->name : '',
1042 + 'email' => '',
1043 + 'phone' => '',
1044 + ];
1045 + // Admins and team members see email and phone
1046 + if ( booktics_is_admin_or_team_member() ) {
1047 + $customer_data_for_order['email'] = $customer ? $customer->email : '';
1048 + $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1049 + }
1050 + $order->customer = $customer_data_for_order;
573 1051 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1052 +
1053 + // Hide payment intent fields from team members (not admins)
1054 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1055 + $order->payment_intent_id = '';
1056 + $order->client_secret = '';
1057 + }
574 1058 }
575 1059
576 1060 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
577 1061 }
@@ -576,9 +1060,9 @@
576 1060 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
577 1061 }
578 1062
579 1063 /**
580 - * Check if user has permission to update order
1064 + * Check if user has permission to update order.
581 1065 *
582 1066 * @param WP_REST_Request $request
583 1067 *
584 1068 * @return bool
@@ -583,13 +1067,13 @@
583 1067 *
584 1068 * @return bool
585 1069 */
586 1070 public function update_item_permission( $request ) {
587 - return true;
1071 + return current_user_can( 'manage_options' );
588 1072 }
589 1073
590 1074 /**
591 - * Update an existing order
1075 + * Update an existing order.
592 1076 *
593 1077 * @param WP_REST_Request $request
594 1078 *
595 1079 * @return WP_Error|WP_HTTP_Response
@@ -594,9 +1078,9 @@
594 1078 *
595 1079 * @return WP_Error|WP_HTTP_Response
596 1080 */
597 1081 public function update_item( $request ) {
598 - $order_id = (int) $request->get_param( 'id' );
1082 + $order_id = absint( $request->get_param( 'id' ) );
599 1083 $data = json_decode( $request->get_body(), true );
600 1084 $db = new Booktics_Database();
601 1085 $order_model = new Order_Model( $db );
602 1086 $order = $order_model->find( array( 'id' => $order_id ) );
@@ -604,31 +1088,122 @@
604 1088 if ( ! $order ) {
605 1089 return $this->error( __( 'Order not found', 'booktics' ) );
606 1090 }
607 1091
1092 + $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1093 + $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1094 +
608 1095 $order_model->update(
609 1096 $order_id, array(
610 - 'status' => $data['status'] ?? $order->status,
611 - 'payment_status' => $data['payment_status'] ?? $order->payment_status,
1097 + 'status' => $status,
1098 + 'payment_status' => $payment_status,
612 1099 )
613 1100 );
614 1101
615 - return $this->response( $order_model->find( array( 'id' => $order_id ) ), __( 'Order updated successfully', 'booktics' ) );
1102 + if ( $status !== $order->status ) {
1103 + /** This action is documented in core/appointment/services/appointment-service.php */
1104 + do_action( 'booktics_order_status_updated', $order_id, $status, $order->status );
1105 + }
1106 +
1107 + if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1108 + $customer_id = absint( $data['customer']['id'] );
1109 + $customer_data = $data['customer'];
1110 + $customer_data['id'] = $customer_id;
1111 + if ( isset( $customer_data['name'] ) ) {
1112 + $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1113 + }
1114 + if ( isset( $customer_data['email'] ) ) {
1115 + $customer_data['email'] = sanitize_email( $customer_data['email'] );
1116 + }
1117 + if ( isset( $customer_data['phone'] ) ) {
1118 + $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1119 + }
1120 + ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1121 + }
1122 +
1123 + $order_model = $order_model->find( array( 'id' => $order_id ) );
1124 + $data['customer_id'] = $order_model->customer_id;
1125 +
1126 + $response = $this->format_order_response($db, $order_model, $data, null);
1127 +
1128 + // Hide payment intent fields from team members (not admins)
1129 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1130 + $response['payment_intent_id'] = '';
1131 + $response['client_secret'] = '';
1132 + }
1133 +
1134 + return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
616 1135 }
617 1136
618 1137 /**
619 - * Check if user has permission to get order
1138 + * Check if user has permission to get order.
620 1139 *
1140 + * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1141 + * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1142 + * value returned when the order was created).
1143 + *
621 1144 * @param WP_REST_Request $request
622 1145 *
1146 + * @return bool|WP_Error
1147 + */
1148 + public function get_item_permission( $request ) {
1149 + if ( booktics_is_admin_or_team_member() ) {
1150 + return true;
1151 + }
1152 +
1153 + $order_id = absint( $request->get_param( 'id' ) );
1154 + $db = new Booktics_Database();
1155 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1156 +
1157 + if ( ! $order ) {
1158 + return true;
1159 + }
1160 +
1161 + if ( is_user_logged_in() ) {
1162 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1163 +
1164 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1165 + return true;
1166 + }
1167 + }
1168 +
1169 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1170 + if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1171 + return true;
1172 + }
1173 +
1174 + return new WP_Error(
1175 + 'booktics_forbidden',
1176 + __( 'You are not allowed to view this order.', 'booktics' ),
1177 + array( 'status' => 403 )
1178 + );
1179 + }
1180 +
1181 + /**
1182 + * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1183 + *
1184 + * @param int $order_id
1185 + * @param string $order_no
1186 + *
1187 + * @return string
1188 + */
1189 + private function get_order_view_key( $order_id, $order_no ) {
1190 + return Order_Key::make( $order_id, $order_no );
1191 + }
1192 +
1193 + /**
1194 + * @param int $order_id
1195 + * @param string $order_no
1196 + * @param string $provided_key
1197 + *
623 1198 * @return bool
624 1199 */
625 - public function get_item_permission( $request ) {
626 - return true;
1200 + private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1201 + return Order_Key::verify( $order_id, $order_no, $provided_key );
627 1202 }
628 1203
629 1204 /**
630 - * Get single order
1205 + * Get single order.
631 1206 *
632 1207 * @param WP_REST_Request $request
633 1208 *
634 1209 * @return WP_Error|WP_HTTP_Response
@@ -633,9 +1208,9 @@
633 1208 *
634 1209 * @return WP_Error|WP_HTTP_Response
635 1210 */
636 1211 public function get_item( $request ) {
637 - $order_id = (int) $request->get_param( 'id' );
1212 + $order_id = absint( $request->get_param( 'id' ) );
638 1213 $db = new Booktics_Database();
639 1214 $order_model = new Order_Model( $db );
640 1215 $order = $order_model->find( array( 'id' => $order_id ) );
641 1216
@@ -642,91 +1217,77 @@
642 1217 if ( ! $order ) {
643 1218 return $this->error( __( 'Order not found', 'booktics' ) );
644 1219 }
645 1220
646 - $customer = ( new Customer_Model() )->find( $order->customer_id );
647 - $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
648 - $appointment_details = array();
1221 + // Use the format_order_response helper for consistency
1222 + $input_data = array( 'customer_id' => $order->customer_id );
1223 + $response = $this->format_order_response( $db, $order, $input_data, array() );
1224 + // Add extra fields specific to GET if needed
1225 + $response['status'] = $order->status;
1226 + $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1227 + $response['payment_gateway'] = $order->payment_method;
1228 + $response['payment_method'] = $order->payment_method;
649 1229
650 - foreach ( $appointments as $appointment ) {
651 - $appointment = $appointment->to_array();
652 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
653 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
654 - $service = $service ? $service->to_array() : array();
655 - $additional_durations = $service['additional_durations'] ?? array();
656 - $additional_durations = ! empty( $additional_durations )
657 - ? array_filter(
658 - $additional_durations, function ( $duration ) use ( $appointment ) {
659 - return $duration['id'] === $appointment['additional_duration_id'];
660 - }
661 - ) : array();
662 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
663 - $formatted_appointment = array(
664 - 'id' => $appointment['id'],
665 - 'date' => $appointment['date'],
666 - 'start_time' => $appointment['start_time'],
667 - 'end_time' => $appointment['end_time'],
668 - 'team' => array(
669 - 'id' => $team_member->id,
670 - 'display_name' => $team_member->display_name ?? '',
671 - 'image' => $team_member->image ?? '',
672 - ),
673 - 'service' => array(
674 - 'id' => $appointment['service_id'],
675 - 'title' => $service['title'] ?? '',
676 - 'additional_duration' => $additional_duration,
677 - ),
678 - 'quantity' => $appointment['quantity'],
679 - 'price' => $appointment['price'],
680 - 'subtotal' => $appointment['subtotal'],
681 - 'total' => $appointment['total'],
682 - 'status' => $appointment['status'],
683 - );
684 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
685 - $appointment_details[] = $formatted_appointment;
1230 + // format_order_response() builds this from a freshly created payment
1231 + // intent, which a GET does not have, so read the stored one instead.
1232 + // The team-member guard below still strips it for non-admins.
1233 + $response['payment_intent_id'] = $order->payment_intent_id ?? '';
1234 +
1235 + // Hide payment intent fields from team members (not admins)
1236 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1237 + $response['payment_intent_id'] = '';
1238 + $response['client_secret'] = '';
686 1239 }
687 1240
688 - $response = array(
689 - 'id' => $order->id,
690 - 'order_no' => $order->order_no,
691 - 'customer' => array(
692 - 'display_name' => $customer->display_name,
693 - 'email' => $customer->user_email,
694 - ),
695 - 'subtotal' => $order->subtotal,
696 - 'total' => $order->total,
697 - 'payment_status' => $order->payment_status,
698 - 'status' => $order->status,
699 - 'items' => $appointment_details,
700 - 'created_at' => $order->created_at,
701 - 'payment_gateway' => $order->payment_method,
702 - );
703 -
704 1241 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
705 1242 }
706 1243
707 1244 /**
708 - * Check if user has permission to update payment status
1245 + * Check if user has permission to update payment status.
709 1246 *
710 - * @param $request
1247 + * @param WP_REST_Request $request
711 1248 *
712 1249 * @return bool
713 1250 */
714 1251 public function update_payment_status_permission( $request ) {
715 - return true;
1252 + if ( current_user_can( 'manage_options' ) ) {
1253 + return true;
1254 + }
1255 +
1256 + // Allow order owner with a valid order_key (guest checkout support).
1257 + $order_id = absint( $request->get_param( 'id' ) );
1258 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1259 +
1260 + if ( ! $order_id || ! $order_key ) {
1261 + return false;
1262 + }
1263 +
1264 + $db = new Booktics_Database();
1265 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1266 +
1267 + if ( ! $order ) {
1268 + return false;
1269 + }
1270 +
1271 + return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
716 1272 }
717 1273
718 1274 /**
719 - * Update payment status for an order
1275 + * Update payment status for an order.
720 1276 *
721 - * @param $request
1277 + * @param WP_REST_Request $request
1278 + *
1279 + * @return WP_Error|WP_HTTP_Response
722 1280 */
723 1281 public function update_payment_status( $request ) {
724 - $data = json_decode( $request->get_body(), true );
725 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
726 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
727 - $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1282 + $data = json_decode( $request->get_body(), true );
1283 + $data = is_array( $data ) ? $data : array();
728 1284
1285 + // Only the order in the route is touched. The permission callback
1286 + // authorized that id against the order_key; an order id in the body
1287 + // would let a valid key for one order act on somebody else's.
1288 + $order_id = absint( $request->get_param( 'id' ) );
1289 +
729 1290 $db = new Booktics_Database();
730 1291 $order_model = new Order_Model( $db );
731 1292 $order = $order_model->find( array( 'id' => $order_id ) );
732 1293
@@ -733,9 +1294,15 @@
733 1294 if ( ! $order ) {
734 1295 return $this->error( __( 'Order not found', 'booktics' ) );
735 1296 }
736 1297
737 - if ( ! in_array( $payment_method, array( 'stripe', 'paypal' ) ) ) {
1298 + // The gateway that took the money is the one that may report on it, so
1299 + // prefer what the order was created with over what the client claims.
1300 + $payment_method = ! empty( $order->payment_method )
1301 + ? $order->payment_method
1302 + : ( ! empty( $data['payment_method'] ) ? sanitize_text_field( (string) $data['payment_method'] ) : '' );
1303 +
1304 + if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
738 1305 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
739 1306 }
740 1307
741 1308 try {
@@ -751,12 +1318,31 @@
751 1318
752 1319 $order_model->update(
753 1320 $order_id, array(
754 1321 'payment_status' => $status,
755 - 'transaction_id' => $data['transaction_id'] ?? null,
1322 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
756 1323 )
757 1324 );
758 1325
1326 + if (
1327 + $status === Payment_Status::COMPLETED
1328 + && $order->payment_status !== Payment_Status::COMPLETED
1329 + ) {
1330 + // The payment status is already persisted above. Sending notifications must
1331 + // not fail the request, otherwise a confirmed booking shows an error toast.
1332 + try {
1333 + $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1334 + $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1335 + } catch ( \Throwable $e ) {
1336 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1337 + }
1338 + } elseif ( $status === Payment_Status::FAILED ) {
1339 + // Payment was declined/canceled on the processor. Release the held slot
1340 + // immediately by canceling the pending appointments for this order, so the
1341 + // customer does not have to wait for the admin_init sweep.
1342 + ( new Abandoned_Booking_Cleanup() )->release_appointments( $order_id );
1343 + }
1344 +
759 1345 // Payment model status and transaction id update
760 1346 return $this->response(
761 1347 array(
762 1348 'payment_status' => $status,
@@ -762,14 +1348,16 @@
762 1348 'payment_status' => $status,
763 1349 ), __( 'Payment status updated successfully', 'booktics' )
764 1350 );
765 1351 } catch ( \Exception $e ) {
766 - return $this->error( $e->getMessage() );
1352 +
1353 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1354 + return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
767 1355 }
768 1356 }
769 1357
770 1358 /**
771 - * Check if user has permission to delete order
1359 + * Check if user has permission to delete order.
772 1360 *
773 1361 * @param WP_REST_Request $request
774 1362 *
775 1363 * @return bool
@@ -774,18 +1362,20 @@
774 1362 *
775 1363 * @return bool
776 1364 */
777 1365 public function delete_item_permission( $request ) {
778 - return true;
1366 + return current_user_can( 'manage_options' );
779 1367 }
780 1368
781 1369 /**
782 - * Delete an order
1370 + * Delete an order.
783 1371 *
784 - * @param $request
1372 + * @param WP_REST_Request $request
1373 + *
1374 + * @return WP_Error|WP_HTTP_Response
785 1375 */
786 1376 public function delete_item( $request ) {
787 - $order_id = (int) $request->get_param( 'id' );
1377 + $order_id = absint( $request->get_param( 'id' ) );
788 1378 $db = new Booktics_Database();
789 1379 $order_model = new Order_Model( $db );
790 1380 $order = $order_model->find( array( 'id' => $order_id ) );
791 1381
@@ -797,9 +1387,9 @@
797 1387 $db->begin_transaction();
798 1388
799 1389 // Delete related appointments
800 1390 $appointment_model = new Appointment_Model();
801 - $appointments = $appointment_model->where( 'order_id', $order_id );
1391 + $appointments = $appointment_model->where( 'order_id', $order_id );
802 1392 foreach ( $appointments as $appointment ) {
803 1393 $appointment->delete();
804 1394 }
805 1395
@@ -804,9 +1394,9 @@
804 1394 }
805 1395
806 1396 // Delete related payments
807 1397 $payment_model = new Payment_Model( $db );
808 - $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1398 + $payments = $payment_model->get( array( 'order_id' => $order_id ) );
809 1399 foreach ( $payments['items'] as $payment ) {
810 1400 ( new Payment_Model( $db ) )->delete( $payment->id );
811 1401 }
812 1402 // Delete the order
@@ -822,9 +1412,9 @@
822 1412 }
823 1413 }
824 1414
825 1415 /**
826 - * Check if user has permission to bulk delete orders
1416 + * Check if user has permission to bulk delete orders.
827 1417 *
828 1418 * @param WP_REST_Request $request
829 1419 *
830 1420 * @return bool
@@ -829,13 +1419,13 @@
829 1419 *
830 1420 * @return bool
831 1421 */
832 1422 public function bulk_delete_items_permission( $request ) {
833 - return true;
1423 + return current_user_can( 'manage_options' );
834 1424 }
835 1425
836 1426 /**
837 - * Bulk delete orders
1427 + * Bulk delete orders.
838 1428 *
839 1429 * @param WP_REST_Request $request
840 1430 *
841 1431 * @return WP_Error|WP_HTTP_Response
@@ -840,9 +1430,10 @@
840 1430 *
841 1431 * @return WP_Error|WP_HTTP_Response
842 1432 */
843 1433 public function bulk_delete_items( $request ) {
844 - $order_ids = array_map( 'intval', $request->get_param( 'ids' ) );
1434 + $ids_param = $request->get_param( 'ids' );
1435 + $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
845 1436 $db = new Booktics_Database();
846 1437
847 1438 try {
848 1439 $db->begin_transaction();
@@ -856,9 +1447,9 @@
856 1447 }
857 1448
858 1449 // Delete related appointments
859 1450 $appointment_model = new Appointment_Model();
860 - $appointments = $appointment_model->where( 'order_id', $order_id );
1451 + $appointments = $appointment_model->where( 'order_id', $order_id );
861 1452 foreach ( $appointments as $appointment ) {
862 1453 $appointment->delete();
863 1454 }
864 1455
@@ -863,9 +1454,9 @@
863 1454 }
864 1455
865 1456 // Delete related payments
866 1457 $payment_model = new Payment_Model( $db );
867 - $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1458 + $payments = $payment_model->get( array( 'order_id' => $order_id ) );
868 1459 foreach ( $payments['items'] as $payment ) {
869 1460 ( new Payment_Model( $db ) )->delete( $payment->id );
870 1461 }
871 1462
@@ -883,30 +1474,60 @@
883 1474 }
884 1475 }
885 1476
886 1477 /**
887 - * Process payment refund
1478 + * Check if the current user may refund an order.
888 1479 *
889 - * @param $request
1480 + * Refunding moves money and must never be reachable by the customer. This
1481 + * route previously reused update_payment_status_permission, which also
1482 + * accepts a valid order_key so that a guest can confirm their own payment —
1483 + * meaning any customer holding their own order key could refund themselves.
1484 + *
1485 + * @param WP_REST_Request $request
1486 + *
1487 + * @return bool
890 1488 */
1489 + public function refund_payment_permission( $request ) {
1490 + return current_user_can( 'manage_options' );
1491 + }
1492 +
1493 + /**
1494 + * Process payment refund for an order.
1495 + *
1496 + * @param WP_REST_Request $request
1497 + *
1498 + * @return WP_Error|WP_HTTP_Response
1499 + */
891 1500 public function refund_payment( $request ) {
892 - $order_id = (int) $request->get_param( 'id' );
1501 + $order_id = absint( $request->get_param( 'id' ) );
893 1502 $db = new Booktics_Database();
894 1503 $order_model = new Order_Model( $db );
895 1504 $order = $order_model->find( array( 'id' => $order_id ) );
896 1505
1506 + if ( ! $order || is_wp_error( $order ) ) {
1507 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1508 + }
1509 +
897 1510 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
898 1511 if ( ! $payment_method ) {
899 1512 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
900 1513 }
901 1514 $response = $payment_method->process_refund( $order );
902 - if ( $response['status'] === 'error' ) {
903 - return $this->error( $response['message'] );
1515 +
1516 + // A gateway may refuse outright — WooCommerce-paid orders are refunded
1517 + // from WooCommerce admin — and must not leave the order marked refunded
1518 + // when no money moved.
1519 + if ( is_wp_error( $response ) ) {
1520 + return $this->error( $response->get_error_message() );
904 1521 }
905 1522
1523 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1524 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1525 + }
1526 +
906 1527 $order_model->update(
907 1528 $order_id, array(
908 - 'payment_status' => 'refunded',
1529 + 'payment_status' => Payment_Status::REFUNDED,
909 1530 )
910 1531 );
911 1532
912 1533 return $this->response(
@@ -915,9 +1536,9 @@
915 1536 );
916 1537 }
917 1538
918 1539 /**
919 - * Calculate total duration in minutes for a service appointment
1540 + * Calculate total duration in minutes for a service appointment.
920 1541 *
921 1542 * @param array $item
922 1543 *
923 1544 * @return int
@@ -942,7 +1563,22 @@
942 1563 if ( ! empty( $item['extra_services'] ) ) {
943 1564 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
944 1565 }
945 1566
1567 + $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1568 +
946 1569 return $total_duration_min;
1570 + }
1571 +
1572 + /**
1573 + * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1574 + *
1575 + * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1576 + *
1577 + * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1578 + */
1579 + private function format_datetime_to_dayjs_format( $datetime ) {
1580 + $dt = new DateTime( $datetime );
1581 + $dt->setTimezone( wp_timezone() );
1582 + return $dt->format( 'Y-m-d H:i:s' );
947 1583 }
948 1584 }