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
← All changes | core/order/controllers/order-controller.php +488 -253 1.0.16 → 1.0.29 View file →
@@ -2,8 +2,9 @@
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;
9 10 use Booktics\Customer\Handlers\Customer_Event_Handler;
@@ -14,8 +15,11 @@
14 15 use Booktics\Models\Payment_Model;
15 16 use Booktics\Models\Service_Model;
16 17 use Booktics\Models\Team_Member_Model;
17 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;
18 22 use Booktics\Payment\Payment_Method_Manager;
19 23 use Booktics\Payment\Payment_Status;
20 24 use Booktics\Services\Time_Zone_Handler;
21 25 use DateTime;
@@ -109,9 +113,14 @@
109 113 $this,
110 114 'get_item_permission',
111 115 ),
112 116 'args' => array(
113 - '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 + ),
114 123 ),
115 124 ),
116 125 array(
117 126 'methods' => WP_REST_Server::DELETABLE,
@@ -149,9 +158,9 @@
149 158 'methods' => WP_REST_Server::EDITABLE,
150 159 'callback' => array( $this, 'refund_payment' ),
151 160 'permission_callback' => array(
152 161 $this,
153 - 'update_payment_status_permission',
162 + 'refund_payment_permission',
154 163 ),
155 164 ),
156 165 )
157 166 );
@@ -209,12 +218,14 @@
209 218 $db->roll_back();
210 219 return $this->error( $e->getMessage() );
211 220 }
212 221
213 - $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
222 + if ( empty( $input_data['pay_now'] ) ) {
223 + $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
224 + }
214 225 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
215 226 do_action('booktics_after_order_created', $order_model, $appointments);
216 -
227 +
217 228 $response_message = 'Order created successfully';
218 229 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
219 230 $response_message = 'Order pending, please proceed payment';
220 231 }
@@ -260,8 +271,22 @@
260 271 );
261 272 if ( is_wp_error( $validate ) ) {
262 273 return $validate;
263 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 + }
264 289 }
265 290
266 291 // Step 4: Process and validate each item
267 292 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
@@ -269,18 +294,32 @@
269 294 }
270 295
271 296 foreach ( $input_data['items'] as $key => $item ) {
272 297 // Validate required fields for each item
273 - $item_validate = booktics_validate(
274 - $item, array(
275 - 'date' => array( 'required' ),
276 - 'start_time' => array( 'required' ),
277 - 'end_time' => array( 'required' ),
278 - 'team_member_id' => array( 'required' ),
279 - 'service_id' => array( 'required' ),
280 - )
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' ),
281 304 );
282 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 +
283 322 if ( is_wp_error( $item_validate ) ) {
284 323 return $item_validate;
285 324 }
286 325
@@ -303,19 +342,18 @@
303 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
304 343 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
305 344 }
306 345
307 - // In validate_and_prepare_order_data(), after converting times:
308 - $item_with_converted_times = array_merge($item, [
309 - 'start_time' => $converted_start_time['time'],
310 - 'end_time' => $converted_end_time['time'],
311 - 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
312 - ]);
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 );
313 350
314 - $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
315 -
316 - if ( is_wp_error( $item_timet_slot_validate ) ) {
317 - return $item_timet_slot_validate;
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;
318 356 }
319 357
320 358 // Sanitize and prepare item data
321 359 $input_data['items'][ $key ] = array(
@@ -320,21 +358,36 @@
320 358 // Sanitize and prepare item data
321 359 $input_data['items'][ $key ] = array(
322 360 'start_time' => $converted_start_time['time'],
323 361 'end_time' => $converted_end_time['time'],
324 - 'date' => gmdate( 'Y-m-d', strtotime( $converted_start_time['date'] ) ),
362 + 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
325 363 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
326 364 'team_member_id' => absint( $item['team_member_id'] ),
327 365 'service_id' => absint( $item['service_id'] ),
328 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
329 - 'price' => floatval( $item['price'] ?? 0 ),
330 - 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
366 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
367 + 'price' => $amount['price'],
368 + 'subtotal' => $amount['total'],
369 + 'total' => $amount['total'],
331 370 'quantity' => absint( $item['quantity'] ?? 1 ),
332 - 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
371 + 'total_attendees' => $attendees,
333 372 );
334 373 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
335 374 }
336 375
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 +
337 390 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
338 391
339 392 return $input_data;
340 393 }
@@ -339,131 +392,120 @@
339 392 return $input_data;
340 393 }
341 394
342 395 /**
343 - * Check if the requested time slot has available capacity for the given team member
344 - * Works by checking all appointments for this team member on this date and calculating remaining capacity
345 - *
346 - * @param array $item Appointment item data
347 - * @return bool|WP_Error True if time slot has capacity, WP_Error if not
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
348 406 */
349 - private function validate_appointment_time_is_free( $item ) {
350 - try {
351 - // Convert times to DateTime objects
352 - $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
353 - $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
354 -
355 - // Get the requested attendee count (default to 1 for backward compatibility)
356 - $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
357 -
358 - // Get service to check if group booking is enabled and get max capacity
359 - $service = Service_Model::find($item['service_id']);
360 - if ( ! $service ) {
361 - return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
362 - }
363 -
364 - $max_group_size = $this->get_service_max_group_size( $service->id );
407 + private function resolve_item_attendees( $item, $input_data ) {
408 + $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array();
365 409
366 -
367 - $args = [
368 - 'team_member_id' => $item['team_member_id'],
369 - 'date' => $item['date'],
370 - 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
371 - ];
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
372 414
373 - // Get all non-cancelled appointments for this team member on this date
374 - $appointments = Appointment_Model::all( $args );
415 + return max( 1, absint( $attendees ) );
416 + }
375 417
376 - if ( ! is_array( $appointments ) || empty( $appointments ) ) {
377 - // No existing appointments, check if requested attendees fit within max capacity
378 - if ( $requested_attendees > $max_group_size ) {
379 - /* translators: %1$d = requested attendees, %2$d = maximum group size */
380 - return new \WP_Error( 'exceeds_max_capacity', sprintf( __( 'Requested attendees (%1$d) exceeds maximum group size (%2$d) for this service.', 'booktics' ), $requested_attendees, $max_group_size ) );
381 - }
418 + /**
419 + * Calculate what a booking line actually costs, from the service record.
420 + *
421 + * @param array $item Raw item from the request.
422 + * @param int $attendees Resolved attendee count for this line.
423 + *
424 + * @return array|WP_Error array{price: float, total: float}
425 + */
426 + private function calculate_item_amount( $item, $attendees ) {
427 + $service_id = absint( $item['service_id'] ?? 0 );
428 + $service = Service_Model::find( $service_id );
382 429
383 - return true;
384 - }
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
432 + }
385 433
386 - // Calculate total attendees for overlapping time slots
387 - $overlapping_attendees = 0;
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
388 435
389 - foreach ( $appointments as $appointment ) {
390 - // Skip if this is the same appointment (for updates)
391 - if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
392 - continue;
393 - }
394 -
395 - $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
396 - $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
436 + if ( empty( $durations ) ) {
437 + return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) );
438 + }
397 439
398 - // Check for time overlap
399 - if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
400 - // Times overlap, add this appointment's attendees to the count
401 - $appointment_attendees = $appointment->total_attendees ?? 1;
402 - $overlapping_attendees += $appointment_attendees;
403 - }
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;
404 447 }
405 -
406 - // Check if adding the requested attendees would exceed capacity
407 - $total_after_booking = $overlapping_attendees + $requested_attendees;
408 -
409 - if ($total_after_booking > $max_group_size) {
410 - return new \WP_Error( 'insufficient_capacity', __( 'In sufficient capacity' , 'booktics' ) );
448 + }
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' ) );
411 455 }
456 + $selected = reset( $durations );
457 + }
412 458
413 - return true;
414 - } catch ( \Throwable $e ) {
415 - return new \WP_Error(
416 - 'time_validation_error',
417 - __( 'Error validating appointment time: ', 'booktics' ) . $e->getMessage()
418 - );
419 - }
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 + );
420 471 }
421 472
422 473 /**
423 - * Get max group size for a specific service
424 - *
425 - * @param int $service_id Service ID
426 - *
427 - * @return int Max group size
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
428 483 */
429 - private function get_service_max_group_size( $service_id ) {
430 - $default_max_size = 1;
431 - $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
432 -
433 - if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
434 - return $default_max_size;
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;
435 489 }
436 490
437 - return $this->extract_max_booking_size( $group_booking_meta, $default_max_size );
491 + if ( ! empty( $group_booking['charge_per_attendee'] ) ) {
492 + return 1;
493 + }
494 +
495 + return max( 1, (int) $attendees );
438 496 }
439 -
497 +
440 498 /**
441 499 * Checks if group booking is enabled for a services group booking meta data
442 - *
500 + *
443 501 * @param array $group_booking_meta Group booking meta data
444 - *
502 + *
445 503 * @return bool True if group booking is enabled, false otherwise
446 504 */
447 505 private function is_group_booking_enabled( $group_booking_meta ) {
448 506 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
449 507 }
450 -
451 - /**
452 - * Extracts the max booking size from the group booking meta data of a service
453 - *
454 - * @param array $group_booking_meta Group booking meta data
455 - * @param int $default_size Default max booking size
456 - *
457 - * @return int Max booking size
458 - */
459 - private function extract_max_booking_size( $group_booking_meta, $default_size ) {
460 - if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
461 - return (int) $group_booking_meta['max_booking'];
462 - }
463 -
464 - return $default_size;
465 - }
466 508
467 509 /**
468 510 * Resolve the customer ID from input data, creating or finding the customer as needed
469 511 *
@@ -490,9 +532,11 @@
490 532 *
491 533 * @return array [Order_Model $order_model, array $appointments]
492 534 */
493 535 private function create_order_and_appointments( $input_data, $db ) {
494 - $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
536 + // The total is priced server side in the store currency, so the order is
537 + // too. A buyer-sent currency would relabel 100 USD as 100 KRW at Stripe.
538 + $input_data['currency'] = booktics_get_store_currency();
495 539 $order_model = new Order_Model( $db );
496 540
497 541 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
498 542 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
@@ -526,16 +570,25 @@
526 570 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
527 571 }
528 572 $total_duration_min = $this->calculate_total_duration( $item );
529 573
574 + // Store date/time in a locale-independent format. wp_date() localizes digits and
575 + // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
576 + // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
577 + // the same timezone-aware value but is never localized.
578 + $time_format = get_option( 'time_format' );
579 + $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
580 + $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
581 + $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
582 +
530 583 $appointment_data = array(
531 - 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
532 - 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
533 - 'date' => wp_date( 'Y-m-d', strtotime( $item['date'] ), $input_timezone ),
584 + 'start_time' => $start_local->format( $time_format ),
585 + 'end_time' => $end_local->format( $time_format ),
586 + 'date' => $date_local->format( 'Y-m-d' ),
534 587 'customer_id' => $input_data['customer_id'] ?? '',
535 588 'team_member_id' => $item['team_member_id'] ?? '',
536 589 'service_id' => $item['service_id'] ?? '',
537 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
590 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
538 591 'duration' => $total_duration_min,
539 592 'order_id' => $order_model->id,
540 593 'quantity' => $item['quantity'] ?? 1,
541 594 'price' => $item['price'] ?? 0,
@@ -541,16 +594,21 @@
541 594 'price' => $item['price'] ?? 0,
542 595 'subtotal' => $item['subtotal'] ?? 0,
543 596 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
544 597 'status' => booktics_get_option( 'appointment_status', 'pending' ),
545 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
546 599 'timezone' => $original_timezone,
547 600 );
548 -
549 -
550 -
601 +
602 +
603 +
551 604 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
552 -
605 +
606 + $conflict = Appointment_Validator::validate_slot_available( $appointment_data );
607 + if ( $conflict ) {
608 + throw new Exception( esc_html( $conflict->get_error_message() ) );
609 + }
610 +
553 611 $appointment = Appointment_Model::create( $appointment_data );
554 612 if ( is_wp_error( $appointment ) ) {
555 613 throw new Exception( esc_html( $appointment->get_error_message() ) );
556 614 }
@@ -591,12 +649,10 @@
591 649 }
592 650
593 651 if ( $payment_intent['status'] === 'success' ) {
594 652 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
595 -
596 - // Update orders payment status on successfull payment completion
597 - $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
598 - ( new Order_Model($db) )->update( $order_model_id, [ 'payment_status' => 'fully_paid' ] );
653 + // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
654 + // Status is updated after client-side confirmation via update_payment_status endpoint.
599 655 }
600 656
601 657 return $payment_intent;
602 658 }
@@ -614,9 +670,9 @@
614 670 */
615 671 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
616 672 ( new Order_Model( $db ) )->update(
617 673 $order_model->id,
618 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
674 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
619 675 );
620 676
621 677 ( new Payment_Model( $db ) )->create(
622 678 array(
@@ -621,11 +677,11 @@
621 677 ( new Payment_Model( $db ) )->create(
622 678 array(
623 679 'order_id' => $order_model->id,
624 680 'customer_id' => $input_data['customer_id'],
625 - 'amount' => $input_data['total'] * 100,
626 - 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
627 - 'intent_id' => $payment_intent['payment_intent'],
681 + 'amount' => booktics_amount_to_minor_units( $order_model->total, $order_model->currency ),
682 + 'currency' => $order_model->currency,
683 + 'intent_id' => $payment_intent['payment_intent'] ?? null,
628 684 'transaction_id' => null,
629 685 'payment_method' => $input_data['payment_method'],
630 686 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
631 687 'date' => gmdate( 'Y-m-d' ),
@@ -634,8 +690,20 @@
634 690 );
635 691 }
636 692
637 693 /**
694 + * Resolve team member email with WP user fallback.
695 + *
696 + * @param string $primary
697 + * @param mixed $team_member_id
698 + *
699 + * @return string
700 + */
701 + private function resolve_team_member_email( string $primary, $team_member_id ): string {
702 + return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
703 + }
704 +
705 + /**
638 706 * Helper: Format a single appointment for API output
639 707 *
640 708 * @param Appointment_Model|array $appointment
641 709 *
@@ -641,52 +709,9 @@
641 709 *
642 710 * @return array
643 711 */
644 712 private function format_appointment( $appointment ): array {
645 - if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
646 - $appointment = $appointment->to_array();
647 - }
648 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
649 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
650 - $service = $service ? $service->to_array() : array();
651 - $additional_durations = $service['additional_durations'] ?? array();
652 - $additional_durations = ! empty( $additional_durations )
653 - ? array_filter(
654 - $additional_durations, function ( $duration ) use ( $appointment ) {
655 - return $duration['id'] === $appointment['additional_duration_id'];
656 - }
657 - ) : array();
658 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
659 - $formatted_appointment = array(
660 - 'id' => $appointment['id'] ?? 0,
661 - 'date' => $appointment['date'] ?? '',
662 - 'start_time' => $appointment['start_time'] ?? '',
663 - 'end_time' => $appointment['end_time'] ?? '',
664 - 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
665 - 'team' => array(
666 - 'id' => $team_member->id ?? null,
667 - 'display_name' => $team_member->display_name ?? '',
668 - 'image' => $team_member->image ?? '',
669 - 'user_email' => $team_member->user_email ?? '',
670 - ),
671 - 'service' => array(
672 - 'id' => $appointment['service_id'] ?? '',
673 - 'title' => $service['title'] ?? '',
674 - 'additional_duration' => $additional_duration,
675 - 'enable_google_meet' => $service['enable_google_meet'] ?? false,
676 - ),
677 - 'duration' => $appointment['duration'] ?? null,
678 - 'quantity' => $appointment['quantity'] ?? 1,
679 - 'price' => $appointment['price'] ?? 0,
680 - 'subtotal' => $appointment['subtotal'] ?? 0,
681 - 'total' => $appointment['total'] ?? 0,
682 - 'status' => $appointment['status'] ?? '',
683 - 'total_attendees' => $appointment['total_attendees'] ?? 1,
684 - 'appointment_token' => $appointment['security_token'] ?? '',
685 - 'cancel_note' => $appointment['cancel_note'] ?? '',
686 - );
687 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
688 - return $formatted_appointment;
713 + return Order_Notifier::format_appointment( $appointment );
689 714 }
690 715
691 716 /**
692 717 * Helper: Format order for API output
@@ -706,11 +731,14 @@
706 731 foreach ( $appointments as $appointment ) {
707 732 $appointment_details[] = $this->format_appointment( $appointment );
708 733 }
709 734
735 + $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
736 +
710 737 $order_data = array(
711 738 'id' => $order_id,
712 739 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
740 + 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
713 741 'customer' => array(
714 742 'id' => $customer->id ?? null,
715 743 'name' => $customer->name ?? '',
716 744 'phone' => $customer->phone ?? '',
@@ -717,8 +745,14 @@
717 745 'email' => $customer->email ?? '',
718 746 ),
719 747 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
720 748 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
749 + // Tax and coupons are settled at checkout (WooCommerce owns them for
750 + // those bookings), so the invoice has to show them, not just the
751 + // subtotal. Missing here is what left the order looking untaxed.
752 + 'tax_total' => is_object( $order_model ) ? $order_model->tax_total : ( $order_model['tax_total'] ?? null ),
753 + 'coupon_discount' => is_object( $order_model ) ? $order_model->coupon_discount : ( $order_model['coupon_discount'] ?? null ),
754 + 'coupon_code' => is_object( $order_model ) ? $order_model->coupon_code : ( $order_model['coupon_code'] ?? null ),
721 755 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
722 756 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
723 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
724 758 'items' => $appointment_details,
@@ -723,8 +757,11 @@
723 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
724 758 'items' => $appointment_details,
725 759 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
726 760 'client_secret' => $payment_intent['client_secret'] ?? '',
761 + // Gateways that collect payment on their own page (WooCommerce
762 + // checkout) return where to send the customer instead of a secret.
763 + 'redirect_url' => $payment_intent['redirect_url'] ?? '',
727 764 'total_services' => $input_data['total_services'] ?? 1,
728 765 );
729 766
730 767 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -734,8 +771,12 @@
734 771
735 772 /**
736 773 * Helper: Send notifications for order and appointment creation
737 774 *
775 + * Delegates to Order_Notifier so gateways that confirm a booking outside a
776 + * REST request (WooCommerce) fire exactly the same events.
777 + *
778 + * @param Booktics_Database $db
738 779 * @param Order_Model $order_model
739 780 * @param int $customer_id
740 781 * @param array $appointments
741 782 *
@@ -741,54 +782,9 @@
741 782 *
742 783 * @return void
743 784 */
744 785 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
745 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
746 - foreach ( $appointments as $appointment ) {
747 - $formatted_appointment = $this->format_appointment( $appointment );
748 - $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
749 - $dt = new DateTime( $appointment_time, wp_timezone() );
750 - $timestamp = $dt->getTimestamp();
751 - $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
752 - $notification_flow_data_for_appointment = array(
753 - 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
754 - 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
755 - 'start_time' => $formatted_appointment['start_time'],
756 - 'appointment_date' => $formatted_appointment['date'],
757 - 'appointment_time' => $appointment_datetime,
758 - 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
759 - 'customer_name' => $customer->name ?? '',
760 - 'user_email' => $customer->email ?? '',
761 - 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
762 - );
763 - $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
764 -
765 - do_action( 'booktics_global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
766 - }
767 -
768 - $appointment = $appointments[0]; // As currently only one appointment is created for each user
769 - $formatted_appointment = $this->format_appointment( $appointment );
770 - $notification_flow_data_for_order = [
771 - 'order_no' => $order_model->order_no ?? '',
772 - 'order_date' => $order_model->created_at ?? '',
773 - 'order_total' => $order_model->total ?? 0,
774 - 'customer_name' => $customer->name ?? '',
775 - 'user_email' => $customer->email ?? '',
776 - 'order_status' => $order_model->status ?? '',
777 - 'service_name' => $formatted_appointment['service']['title'] ?? '',
778 - 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
779 - 'start_time' => $formatted_appointment['start_time'] ?? '',
780 - 'end_time' => $formatted_appointment['end_time'] ?? '',
781 - 'appointment_date' => $formatted_appointment['date'] ?? '',
782 - 'location_address' => $formatted_appointment['location']['address'] ?? '',
783 - 'appointment_status' => $formatted_appointment['status'] ?? '',
784 - 'appointment_cancel_url' => $appointment->get_cancel_link(),
785 - 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
786 - ];
787 -
788 - $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
789 -
790 - do_action( 'booktics_global_notification_hook', 'order_created', $notification_flow_data_for_order );
786 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
791 787 }
792 788
793 789 /**
794 790 * Find existing customer by email or create new customer
@@ -853,9 +849,9 @@
853 849 * @return object|null Guest_Model instance or null
854 850 */
855 851 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
856 852 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
857 - if ( $guest ) {
853 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
858 854 ( new Guest_Model( $db ) )->update(
859 855 $guest->id, array(
860 856 'name' => $customer['name'] ?? '',
861 857 'phone' => $customer['phone'] ?? '',
@@ -867,8 +863,40 @@
867 863 return $guest;
868 864 }
869 865
870 866 /**
867 + * Determine whether the current request is authorized to overwrite an
868 + * existing customer's stored contact details (name, phone, wp_user_id).
869 + *
870 + * @param object $guest Existing Guest_Model record.
871 + * @param string $email Submitted customer email.
872 + *
873 + * @return bool True when the caller may overwrite the stored record.
874 + */
875 + private function caller_owns_customer_email( $guest, $email ) {
876 + // Admin or team member managing a booking on the customer's behalf.
877 + if ( booktics_is_admin_or_team_member() ) {
878 + return true;
879 + }
880 +
881 + if ( ! is_user_logged_in() ) {
882 + return false;
883 + }
884 +
885 + // The record is already linked to the logged-in user.
886 + if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
887 + return true;
888 + }
889 +
890 + // The logged-in user's own account email matches the submitted email.
891 + $current_user = wp_get_current_user();
892 +
893 + return $current_user
894 + && ! empty( $current_user->user_email )
895 + && strtolower( $current_user->user_email ) === strtolower( (string) $email );
896 + }
897 +
898 + /**
871 899 * Create a new guest record
872 900 *
873 901 * @param Booktics_Database $db
874 902 * @param array $customer
@@ -889,16 +917,28 @@
889 917 );
890 918 }
891 919
892 920 /**
893 - * Check if user has permission to get orders.
921 + * Check if user has permission to list orders.
894 922 *
923 + * Anonymous access is denied: the collection handler scopes to the current customer or team member;
924 + * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
925 + * guests when {@see get_item_permission()} validates a matching order_key.
926 + *
895 927 * @param WP_REST_Request $request
896 928 *
897 - * @return bool
929 + * @return bool|WP_Error
898 930 */
899 931 public function get_items_permission( $request ) {
900 - return true;
932 + if ( is_user_logged_in() ) {
933 + return true;
934 + }
935 +
936 + return new WP_Error(
937 + 'booktics_forbidden',
938 + __( 'You are not allowed to view orders.', 'booktics' ),
939 + array( 'status' => 403 )
940 + );
901 941 }
902 942
903 943 /**
904 944 * Get paginated list of orders.
@@ -938,8 +978,34 @@
938 978 }
939 979
940 980 $conditions = apply_filters( 'booktics_order_filters', $conditions );
941 981
982 + // Scope to customer's own orders when not admin/team-member
983 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
984 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
985 + if ( ! $guest ) {
986 + return $this->response(
987 + array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
988 + __( 'Orders fetched successfully', 'booktics' )
989 + );
990 + }
991 + $conditions['customer_id'] = $guest->id;
992 + }
993 +
994 + // Scope to team member's assigned orders - only return orders that contain
995 + // at least one appointment where team_member_id equals the current user's team member ID
996 + $is_team_member_scope = false;
997 + $team_member_filter_id = null;
998 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
999 + $current_user_id = get_current_user_id();
1000 + $team_member_model = new Team_Member_Model();
1001 + $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1002 + if ( $team_member ) {
1003 + $is_team_member_scope = true;
1004 + $team_member_filter_id = $team_member->id;
1005 + }
1006 + }
1007 +
942 1008 $orders = $order_model->get(
943 1009 $conditions, $page, $per_page, array(
944 1010 'created_at',
945 1011 'DESC',
@@ -971,15 +1037,27 @@
971 1037 }
972 1038
973 1039 foreach ( $orders['items'] as &$order ) {
974 1040 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
975 - $order->customer = array(
1041 + $customer_data_for_order = [
976 1042 'id' => $customer ? $customer->id : null,
977 - 'email' => $customer ? $customer->email : '',
978 1043 'name' => $customer ? $customer->name : '',
979 - 'phone' => $customer ? $customer->phone : '',
980 - );
1044 + 'email' => '',
1045 + 'phone' => '',
1046 + ];
1047 + // Admins and team members see email and phone
1048 + if ( booktics_is_admin_or_team_member() ) {
1049 + $customer_data_for_order['email'] = $customer ? $customer->email : '';
1050 + $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1051 + }
1052 + $order->customer = $customer_data_for_order;
981 1053 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1054 +
1055 + // Hide payment intent fields from team members (not admins)
1056 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1057 + $order->payment_intent_id = '';
1058 + $order->client_secret = '';
1059 + }
982 1060 }
983 1061
984 1062 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
985 1063 }
@@ -1022,8 +1100,13 @@
1022 1100 'payment_status' => $payment_status,
1023 1101 )
1024 1102 );
1025 1103
1104 + if ( $status !== $order->status ) {
1105 + /** This action is documented in core/appointment/services/appointment-service.php */
1106 + do_action( 'booktics_order_status_updated', $order_id, $status, $order->status );
1107 + }
1108 +
1026 1109 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1027 1110 $customer_id = absint( $data['customer']['id'] );
1028 1111 $customer_data = $data['customer'];
1029 1112 $customer_data['id'] = $customer_id;
@@ -1043,8 +1126,14 @@
1043 1126 $data['customer_id'] = $order_model->customer_id;
1044 1127
1045 1128 $response = $this->format_order_response($db, $order_model, $data, null);
1046 1129
1130 + // Hide payment intent fields from team members (not admins)
1131 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1132 + $response['payment_intent_id'] = '';
1133 + $response['client_secret'] = '';
1134 + }
1135 +
1047 1136 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1048 1137 }
1049 1138
1050 1139 /**
@@ -1049,14 +1138,70 @@
1049 1138
1050 1139 /**
1051 1140 * Check if user has permission to get order.
1052 1141 *
1142 + * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1143 + * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1144 + * value returned when the order was created).
1145 + *
1053 1146 * @param WP_REST_Request $request
1054 1147 *
1148 + * @return bool|WP_Error
1149 + */
1150 + public function get_item_permission( $request ) {
1151 + if ( booktics_is_admin_or_team_member() ) {
1152 + return true;
1153 + }
1154 +
1155 + $order_id = absint( $request->get_param( 'id' ) );
1156 + $db = new Booktics_Database();
1157 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1158 +
1159 + if ( ! $order ) {
1160 + return true;
1161 + }
1162 +
1163 + if ( is_user_logged_in() ) {
1164 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1165 +
1166 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1167 + return true;
1168 + }
1169 + }
1170 +
1171 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1172 + if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1173 + return true;
1174 + }
1175 +
1176 + return new WP_Error(
1177 + 'booktics_forbidden',
1178 + __( 'You are not allowed to view this order.', 'booktics' ),
1179 + array( 'status' => 403 )
1180 + );
1181 + }
1182 +
1183 + /**
1184 + * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1185 + *
1186 + * @param int $order_id
1187 + * @param string $order_no
1188 + *
1189 + * @return string
1190 + */
1191 + private function get_order_view_key( $order_id, $order_no ) {
1192 + return Order_Key::make( $order_id, $order_no );
1193 + }
1194 +
1195 + /**
1196 + * @param int $order_id
1197 + * @param string $order_no
1198 + * @param string $provided_key
1199 + *
1055 1200 * @return bool
1056 1201 */
1057 - public function get_item_permission( $request ) {
1058 - return true;
1202 + private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1203 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1059 1204 }
1060 1205
1061 1206 /**
1062 1207 * Get single order.
@@ -1081,9 +1226,21 @@
1081 1226 // Add extra fields specific to GET if needed
1082 1227 $response['status'] = $order->status;
1083 1228 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1084 1229 $response['payment_gateway'] = $order->payment_method;
1230 + $response['payment_method'] = $order->payment_method;
1085 1231
1232 + // format_order_response() builds this from a freshly created payment
1233 + // intent, which a GET does not have, so read the stored one instead.
1234 + // The team-member guard below still strips it for non-admins.
1235 + $response['payment_intent_id'] = $order->payment_intent_id ?? '';
1236 +
1237 + // Hide payment intent fields from team members (not admins)
1238 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1239 + $response['payment_intent_id'] = '';
1240 + $response['client_secret'] = '';
1241 + }
1242 +
1086 1243 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1087 1244 }
1088 1245
1089 1246 /**
@@ -1093,9 +1250,28 @@
1093 1250 *
1094 1251 * @return bool
1095 1252 */
1096 1253 public function update_payment_status_permission( $request ) {
1097 - return current_user_can( 'manage_options' );
1254 + if ( current_user_can( 'manage_options' ) ) {
1255 + return true;
1256 + }
1257 +
1258 + // Allow order owner with a valid order_key (guest checkout support).
1259 + $order_id = absint( $request->get_param( 'id' ) );
1260 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1261 +
1262 + if ( ! $order_id || ! $order_key ) {
1263 + return false;
1264 + }
1265 +
1266 + $db = new Booktics_Database();
1267 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1268 +
1269 + if ( ! $order ) {
1270 + return false;
1271 + }
1272 +
1273 + return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1098 1274 }
1099 1275
1100 1276 /**
1101 1277 * Update payment status for an order.
@@ -1104,13 +1280,16 @@
1104 1280 *
1105 1281 * @return WP_Error|WP_HTTP_Response
1106 1282 */
1107 1283 public function update_payment_status( $request ) {
1108 - $data = json_decode( $request->get_body(), true );
1109 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1110 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1111 - $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1284 + $data = json_decode( $request->get_body(), true );
1285 + $data = is_array( $data ) ? $data : array();
1112 1286
1287 + // Only the order in the route is touched. The permission callback
1288 + // authorized that id against the order_key; an order id in the body
1289 + // would let a valid key for one order act on somebody else's.
1290 + $order_id = absint( $request->get_param( 'id' ) );
1291 +
1113 1292 $db = new Booktics_Database();
1114 1293 $order_model = new Order_Model( $db );
1115 1294 $order = $order_model->find( array( 'id' => $order_id ) );
1116 1295
@@ -1117,8 +1296,14 @@
1117 1296 if ( ! $order ) {
1118 1297 return $this->error( __( 'Order not found', 'booktics' ) );
1119 1298 }
1120 1299
1300 + // The gateway that took the money is the one that may report on it, so
1301 + // prefer what the order was created with over what the client claims.
1302 + $payment_method = ! empty( $order->payment_method )
1303 + ? $order->payment_method
1304 + : ( ! empty( $data['payment_method'] ) ? sanitize_text_field( (string) $data['payment_method'] ) : '' );
1305 +
1121 1306 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1122 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1123 1308 }
1124 1309
@@ -1135,12 +1320,31 @@
1135 1320
1136 1321 $order_model->update(
1137 1322 $order_id, array(
1138 1323 'payment_status' => $status,
1139 - 'transaction_id' => $data['transaction_id'] ?? null,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1140 1325 )
1141 1326 );
1142 1327
1328 + if (
1329 + $status === Payment_Status::COMPLETED
1330 + && $order->payment_status !== Payment_Status::COMPLETED
1331 + ) {
1332 + // The payment status is already persisted above. Sending notifications must
1333 + // not fail the request, otherwise a confirmed booking shows an error toast.
1334 + try {
1335 + $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1336 + $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1337 + } catch ( \Throwable $e ) {
1338 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1339 + }
1340 + } elseif ( $status === Payment_Status::FAILED ) {
1341 + // Payment was declined/canceled on the processor. Release the held slot
1342 + // immediately by canceling the pending appointments for this order, so the
1343 + // customer does not have to wait for the admin_init sweep.
1344 + ( new Abandoned_Booking_Cleanup() )->release_appointments( $order_id );
1345 + }
1346 +
1143 1347 // Payment model status and transaction id update
1144 1348 return $this->response(
1145 1349 array(
1146 1350 'payment_status' => $status,
@@ -1146,9 +1350,11 @@
1146 1350 'payment_status' => $status,
1147 1351 ), __( 'Payment status updated successfully', 'booktics' )
1148 1352 );
1149 1353 } catch ( \Exception $e ) {
1150 - return $this->error( $e->getMessage() );
1354 +
1355 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1356 + return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1151 1357 }
1152 1358 }
1153 1359
1154 1360 /**
@@ -1270,8 +1476,24 @@
1270 1476 }
1271 1477 }
1272 1478
1273 1479 /**
1480 + * Check if the current user may refund an order.
1481 + *
1482 + * Refunding moves money and must never be reachable by the customer. This
1483 + * route previously reused update_payment_status_permission, which also
1484 + * accepts a valid order_key so that a guest can confirm their own payment —
1485 + * meaning any customer holding their own order key could refund themselves.
1486 + *
1487 + * @param WP_REST_Request $request
1488 + *
1489 + * @return bool
1490 + */
1491 + public function refund_payment_permission( $request ) {
1492 + return current_user_can( 'manage_options' );
1493 + }
1494 +
1495 + /**
1274 1496 * Process payment refund for an order.
1275 1497 *
1276 1498 * @param WP_REST_Request $request
1277 1499 *
@@ -1282,17 +1504,29 @@
1282 1504 $db = new Booktics_Database();
1283 1505 $order_model = new Order_Model( $db );
1284 1506 $order = $order_model->find( array( 'id' => $order_id ) );
1285 1507
1508 + if ( ! $order || is_wp_error( $order ) ) {
1509 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1510 + }
1511 +
1286 1512 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1287 1513 if ( ! $payment_method ) {
1288 1514 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1289 1515 }
1290 1516 $response = $payment_method->process_refund( $order );
1291 - if ( $response['status'] === 'error' ) {
1292 - return $this->error( $response['message'] );
1517 +
1518 + // A gateway may refuse outright — WooCommerce-paid orders are refunded
1519 + // from WooCommerce admin — and must not leave the order marked refunded
1520 + // when no money moved.
1521 + if ( is_wp_error( $response ) ) {
1522 + return $this->error( $response->get_error_message() );
1293 1523 }
1294 1524
1525 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1526 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1527 + }
1528 +
1295 1529 $order_model->update(
1296 1530 $order_id, array(
1297 1531 'payment_status' => Payment_Status::REFUNDED,
1298 1532 )
@@ -1344,8 +1578,9 @@
1344 1578 *
1345 1579 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1346 1580 */
1347 1581 private function format_datetime_to_dayjs_format( $datetime ) {
1348 - $dt = new DateTime( $datetime, wp_timezone() );
1349 - return $dt->format( 'c' );
1582 + $dt = new DateTime( $datetime );
1583 + $dt->setTimezone( wp_timezone() );
1584 + return $dt->format( 'Y-m-d H:i:s' );
1350 1585 }
1351 1586 }