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 +240 -251 1.0.22 → 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;
@@ -154,9 +158,9 @@
154 158 'methods' => WP_REST_Server::EDITABLE,
155 159 'callback' => array( $this, 'refund_payment' ),
156 160 'permission_callback' => array(
157 161 $this,
158 - 'update_payment_status_permission',
162 + 'refund_payment_permission',
159 163 ),
160 164 ),
161 165 )
162 166 );
@@ -267,8 +271,22 @@
267 271 );
268 272 if ( is_wp_error( $validate ) ) {
269 273 return $validate;
270 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 + }
271 289 }
272 290
273 291 // Step 4: Process and validate each item
274 292 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
@@ -276,18 +294,32 @@
276 294 }
277 295
278 296 foreach ( $input_data['items'] as $key => $item ) {
279 297 // Validate required fields for each item
280 - $item_validate = booktics_validate(
281 - $item, array(
282 - 'date' => array( 'required' ),
283 - 'start_time' => array( 'required' ),
284 - 'end_time' => array( 'required' ),
285 - 'team_member_id' => array( 'required' ),
286 - 'service_id' => array( 'required' ),
287 - )
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' ),
288 304 );
289 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 +
290 322 if ( is_wp_error( $item_validate ) ) {
291 323 return $item_validate;
292 324 }
293 325
@@ -310,19 +342,18 @@
310 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
311 343 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
312 344 }
313 345
314 - // In validate_and_prepare_order_data(), after converting times:
315 - $item_with_converted_times = array_merge($item, [
316 - 'start_time' => $converted_start_time['time'],
317 - 'end_time' => $converted_end_time['time'],
318 - 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
319 - ]);
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 );
320 350
321 - $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
322 -
323 - if ( is_wp_error( $item_timet_slot_validate ) ) {
324 - 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;
325 356 }
326 357
327 358 // Sanitize and prepare item data
328 359 $input_data['items'][ $key ] = array(
@@ -332,16 +363,31 @@
332 363 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
333 364 'team_member_id' => absint( $item['team_member_id'] ),
334 365 'service_id' => absint( $item['service_id'] ),
335 366 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
336 - 'price' => floatval( $item['price'] ?? 0 ),
337 - 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
367 + 'price' => $amount['price'],
368 + 'subtotal' => $amount['total'],
369 + 'total' => $amount['total'],
338 370 'quantity' => absint( $item['quantity'] ?? 1 ),
339 - 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
371 + 'total_attendees' => $attendees,
340 372 );
341 373 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
342 374 }
343 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 +
344 390 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
345 391
346 392 return $input_data;
347 393 }
@@ -346,112 +392,108 @@
346 392 return $input_data;
347 393 }
348 394
349 395 /**
350 - * Check if the requested time slot has available capacity for the given team member
351 - * Works by checking all appointments for this team member on this date and calculating remaining capacity
396 + * Resolve how many attendees a single booking line is for.
352 397 *
353 - * @param array $item Appointment item data
354 - * @return bool|WP_Error True if time slot has capacity, WP_Error if not
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
355 406 */
356 - private function validate_appointment_time_is_free( $item ) {
357 - try {
358 - // Convert times to DateTime objects
359 - $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
360 - $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
407 + private function resolve_item_attendees( $item, $input_data ) {
408 + $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array();
361 409
362 - // Get the requested attendee count (default to 1 for backward compatibility)
363 - $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
364 414
365 - // Get service to check if group booking is enabled and get max capacity
366 - $service = Service_Model::find($item['service_id']);
367 - if ( ! $service ) {
368 - return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
369 - }
415 + return max( 1, absint( $attendees ) );
416 + }
370 417
371 - $max_group_size = $this->get_service_max_group_size( $service->id );
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 );
372 429
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
432 + }
373 433
374 - $args = [
375 - 'team_member_id' => $item['team_member_id'],
376 - 'date' => $item['date'],
377 - 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
378 - ];
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
379 435
380 - // Get all non-cancelled appointments for this team member on this date
381 - $appointments = Appointment_Model::all( $args );
436 + if ( empty( $durations ) ) {
437 + return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) );
438 + }
382 439
383 - if ( ! is_array( $appointments ) || empty( $appointments ) ) {
384 - // No existing appointments, check if requested attendees fit within max capacity
385 - if ( $requested_attendees > $max_group_size ) {
386 - /* translators: %1$d = requested attendees, %2$d = maximum group size */
387 - 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 ) );
388 - }
440 + $requested_id = booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' );
441 + $selected = null;
389 442
390 - return true;
443 + foreach ( $durations as $duration ) {
444 + if ( (string) ( $duration['id'] ?? '' ) === (string) $requested_id ) {
445 + $selected = $duration;
446 + break;
391 447 }
448 + }
392 449
393 - // Calculate total attendees for overlapping time slots
394 - $overlapping_attendees = 0;
395 -
396 - foreach ( $appointments as $appointment ) {
397 - // Skip if this is the same appointment (for updates)
398 - if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
399 - continue;
400 - }
401 -
402 - // Guard against legacy/localized records whose stored date/time cannot be
403 - // parsed; skipping them keeps a single bad row from blocking every booking.
404 - try {
405 - $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
406 - $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
407 - } catch ( \Throwable $e ) {
408 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
409 - error_log( 'Booktics: skipping appointment ' . ( $appointment->ID ?? '?' ) . ' with unparseable date/time during slot validation: ' . $e->getMessage() );
410 - }
411 - continue;
412 - }
413 -
414 - // Check for time overlap
415 - if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
416 - // Times overlap, add this appointment's attendees to the count
417 - $appointment_attendees = $appointment->total_attendees ?? 1;
418 - $overlapping_attendees += $appointment_attendees;
419 - }
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' ) );
420 455 }
456 + $selected = reset( $durations );
457 + }
421 458
422 - // Check if adding the requested attendees would exceed capacity
423 - $total_after_booking = $overlapping_attendees + $requested_attendees;
459 + $price = (float) ( $selected['charge_amount'] ?? 0 );
424 460
425 - if ($total_after_booking > $max_group_size) {
426 - return new \WP_Error( 'insufficient_capacity', __( 'Insufficient capacity' , 'booktics' ) );
427 - }
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 );
428 466
429 - return true;
430 - } catch ( \Throwable $e ) {
431 - return new \WP_Error(
432 - 'time_validation_error',
433 - __( 'Error validating appointment time. Please try another slot.', 'booktics' )
434 - );
435 - }
467 + return array(
468 + 'price' => round( max( 0, $price ), 2 ),
469 + 'total' => round( max( 0, $total ), 2 ),
470 + );
436 471 }
437 472
438 473 /**
439 - * Get max group size for a specific service
474 + * How many times a line price is charged for a group booking.
440 475 *
441 - * @param int $service_id Service ID
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.
442 478 *
443 - * @return int Max group size
479 + * @param int $service_id Service ID.
480 + * @param int $attendees Resolved attendee count.
481 + *
482 + * @return int
444 483 */
445 - private function get_service_max_group_size( $service_id ) {
446 - $default_max_size = 1;
447 - $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
484 + private function get_attendee_multiplier( $service_id, $attendees ) {
485 + $group_booking = get_post_meta( $service_id, 'group_booking', true );
448 486
449 - if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
450 - return $default_max_size;
487 + if ( ! $this->is_group_booking_enabled( $group_booking ) ) {
488 + return 1;
451 489 }
452 490
453 - 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 );
454 496 }
455 497
456 498 /**
457 499 * Checks if group booking is enabled for a services group booking meta data
@@ -464,24 +506,8 @@
464 506 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
465 507 }
466 508
467 509 /**
468 - * Extracts the max booking size from the group booking meta data of a service
469 - *
470 - * @param array $group_booking_meta Group booking meta data
471 - * @param int $default_size Default max booking size
472 - *
473 - * @return int Max booking size
474 - */
475 - private function extract_max_booking_size( $group_booking_meta, $default_size ) {
476 - if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
477 - return (int) $group_booking_meta['max_booking'];
478 - }
479 -
480 - return $default_size;
481 - }
482 -
483 - /**
484 510 * Resolve the customer ID from input data, creating or finding the customer as needed
485 511 *
486 512 * @param array $input_data
487 513 *
@@ -506,9 +532,11 @@
506 532 *
507 533 * @return array [Order_Model $order_model, array $appointments]
508 534 */
509 535 private function create_order_and_appointments( $input_data, $db ) {
510 - $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();
511 539 $order_model = new Order_Model( $db );
512 540
513 541 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
514 542 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
@@ -566,9 +594,9 @@
566 594 'price' => $item['price'] ?? 0,
567 595 'subtotal' => $item['subtotal'] ?? 0,
568 596 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
569 597 'status' => booktics_get_option( 'appointment_status', 'pending' ),
570 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
571 599 'timezone' => $original_timezone,
572 600 );
573 601
574 602
@@ -574,8 +602,13 @@
574 602
575 603
576 604 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
577 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 +
578 611 $appointment = Appointment_Model::create( $appointment_data );
579 612 if ( is_wp_error( $appointment ) ) {
580 613 throw new Exception( esc_html( $appointment->get_error_message() ) );
581 614 }
@@ -637,9 +670,9 @@
637 670 */
638 671 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
639 672 ( new Order_Model( $db ) )->update(
640 673 $order_model->id,
641 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
674 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
642 675 );
643 676
644 677 ( new Payment_Model( $db ) )->create(
645 678 array(
@@ -644,11 +677,11 @@
644 677 ( new Payment_Model( $db ) )->create(
645 678 array(
646 679 'order_id' => $order_model->id,
647 680 'customer_id' => $input_data['customer_id'],
648 - 'amount' => $input_data['total'] * 100,
649 - 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
650 - '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,
651 684 'transaction_id' => null,
652 685 'payment_method' => $input_data['payment_method'],
653 686 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
654 687 'date' => gmdate( 'Y-m-d' ),
@@ -659,22 +692,15 @@
659 692
660 693 /**
661 694 * Resolve team member email with WP user fallback.
662 695 *
663 - * Model hydration may miss user_email if the team-member WP user row is
664 - * stale or if find() returned null. Fall back to get_userdata so the
665 - * notification SDK does not skip the team email node.
696 + * @param string $primary
697 + * @param mixed $team_member_id
698 + *
699 + * @return string
666 700 */
667 701 private function resolve_team_member_email( string $primary, $team_member_id ): string {
668 - if ( ! empty( $primary ) ) {
669 - return $primary;
670 - }
671 - $team_member_id = absint( $team_member_id );
672 - if ( ! $team_member_id ) {
673 - return '';
674 - }
675 - $user = get_userdata( $team_member_id );
676 - return $user && ! empty( $user->user_email ) ? $user->user_email : '';
702 + return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
677 703 }
678 704
679 705 /**
680 706 * Helper: Format a single appointment for API output
@@ -683,52 +709,9 @@
683 709 *
684 710 * @return array
685 711 */
686 712 private function format_appointment( $appointment ): array {
687 - if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
688 - $appointment = $appointment->to_array();
689 - }
690 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
691 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
692 - $service = $service ? $service->to_array() : array();
693 - $additional_durations = $service['additional_durations'] ?? array();
694 - $additional_durations = ! empty( $additional_durations )
695 - ? array_filter(
696 - $additional_durations, function ( $duration ) use ( $appointment ) {
697 - return $duration['id'] === $appointment['additional_duration_id'];
698 - }
699 - ) : array();
700 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
701 - $formatted_appointment = array(
702 - 'id' => $appointment['id'] ?? 0,
703 - 'date' => $appointment['date'] ?? '',
704 - 'start_time' => $appointment['start_time'] ?? '',
705 - 'end_time' => $appointment['end_time'] ?? '',
706 - 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
707 - 'team' => array(
708 - 'id' => $team_member->id ?? null,
709 - 'display_name' => $team_member->display_name ?? '',
710 - 'image' => $team_member->image ?? '',
711 - 'user_email' => $team_member->user_email ?? '',
712 - ),
713 - 'service' => array(
714 - 'id' => $appointment['service_id'] ?? '',
715 - 'title' => $service['title'] ?? '',
716 - 'additional_duration' => $additional_duration,
717 - 'enable_google_meet' => $service['enable_google_meet'] ?? false,
718 - ),
719 - 'duration' => $appointment['duration'] ?? null,
720 - 'quantity' => $appointment['quantity'] ?? 1,
721 - 'price' => $appointment['price'] ?? 0,
722 - 'subtotal' => $appointment['subtotal'] ?? 0,
723 - 'total' => $appointment['total'] ?? 0,
724 - 'status' => $appointment['status'] ?? '',
725 - 'total_attendees' => $appointment['total_attendees'] ?? 1,
726 - 'appointment_token' => $appointment['security_token'] ?? '',
727 - 'cancel_note' => $appointment['cancel_note'] ?? '',
728 - );
729 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
730 - return $formatted_appointment;
713 + return Order_Notifier::format_appointment( $appointment );
731 714 }
732 715
733 716 /**
734 717 * Helper: Format order for API output
@@ -762,8 +745,14 @@
762 745 'email' => $customer->email ?? '',
763 746 ),
764 747 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
765 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 ),
766 755 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
767 756 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
768 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
769 758 'items' => $appointment_details,
@@ -768,8 +757,11 @@
768 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
769 758 'items' => $appointment_details,
770 759 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
771 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'] ?? '',
772 764 'total_services' => $input_data['total_services'] ?? 1,
773 765 );
774 766
775 767 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -779,8 +771,12 @@
779 771
780 772 /**
781 773 * Helper: Send notifications for order and appointment creation
782 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
783 779 * @param Order_Model $order_model
784 780 * @param int $customer_id
785 781 * @param array $appointments
786 782 *
@@ -786,59 +782,9 @@
786 782 *
787 783 * @return void
788 784 */
789 785 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
790 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
791 - foreach ( $appointments as $appointment ) {
792 - $formatted_appointment = $this->format_appointment( $appointment );
793 - $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
794 - $dt = new DateTime( $appointment_time, wp_timezone() );
795 - $timestamp = $dt->getTimestamp();
796 - $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
797 - $appointment_array = is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ? $appointment->to_array() : (array) $appointment;
798 - $team_member_email = $this->resolve_team_member_email(
799 - $formatted_appointment['team']['user_email'] ?? '',
800 - $appointment_array['team_member_id'] ?? 0
801 - );
802 - $notification_flow_data_for_appointment = array(
803 - 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
804 - 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
805 - 'start_time' => $formatted_appointment['start_time'],
806 - 'appointment_date' => $formatted_appointment['date'],
807 - 'appointment_time' => $appointment_datetime,
808 - 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
809 - 'customer_name' => $customer->name ?? '',
810 - 'user_email' => $customer->email ?? '',
811 - 'appointment_team_member_email' => $team_member_email,
812 - );
813 - $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
814 -
815 - do_action( 'booktics_gln_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
816 - }
817 -
818 - $appointment = $appointments[0]; // As currently only one appointment is created for each user
819 - $formatted_appointment = $this->format_appointment( $appointment );
820 - $notification_flow_data_for_order = [
821 - 'order_no' => $order_model->order_no ?? '',
822 - 'order_date' => $order_model->created_at ?? '',
823 - 'order_total' => $order_model->total ?? 0,
824 - 'customer_name' => $customer->name ?? '',
825 - 'user_email' => $customer->email ?? '',
826 - 'order_status' => $order_model->status ?? '',
827 - 'service_name' => $formatted_appointment['service']['title'] ?? '',
828 - 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
829 - 'start_time' => $formatted_appointment['start_time'] ?? '',
830 - 'end_time' => $formatted_appointment['end_time'] ?? '',
831 - 'appointment_date' => $formatted_appointment['date'] ?? '',
832 - 'location_address' => $formatted_appointment['location']['address'] ?? '',
833 - 'appointment_status' => $formatted_appointment['status'] ?? '',
834 - 'appointment_cancel_url' => $appointment->get_cancel_link(),
835 - 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
836 - ];
837 -
838 - $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
839 -
840 - do_action( 'booktics_gln_hook', 'order_created', $notification_flow_data_for_order );
786 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
841 787 }
842 788
843 789 /**
844 790 * Find existing customer by email or create new customer
@@ -1154,8 +1100,13 @@
1154 1100 'payment_status' => $payment_status,
1155 1101 )
1156 1102 );
1157 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 +
1158 1109 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1159 1110 $customer_id = absint( $data['customer']['id'] );
1160 1111 $customer_data = $data['customer'];
1161 1112 $customer_data['id'] = $customer_id;
@@ -1237,9 +1188,9 @@
1237 1188 *
1238 1189 * @return string
1239 1190 */
1240 1191 private function get_order_view_key( $order_id, $order_no ) {
1241 - return hash_hmac( 'sha256', (string) $order_id . '|' . $order_no, wp_salt( 'booktics_order_view' ) );
1192 + return Order_Key::make( $order_id, $order_no );
1242 1193 }
1243 1194
1244 1195 /**
1245 1196 * @param int $order_id
@@ -1248,15 +1199,9 @@
1248 1199 *
1249 1200 * @return bool
1250 1201 */
1251 1202 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1252 - if ( '' === $provided_key || strlen( $provided_key ) > 128 ) {
1253 - return false;
1254 - }
1255 -
1256 - $expected = $this->get_order_view_key( $order_id, $order_no );
1257 -
1258 - return hash_equals( $expected, $provided_key );
1203 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1259 1204 }
1260 1205
1261 1206 /**
1262 1207 * Get single order.
@@ -1281,9 +1226,15 @@
1281 1226 // Add extra fields specific to GET if needed
1282 1227 $response['status'] = $order->status;
1283 1228 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1284 1229 $response['payment_gateway'] = $order->payment_method;
1230 + $response['payment_method'] = $order->payment_method;
1285 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 +
1286 1237 // Hide payment intent fields from team members (not admins)
1287 1238 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1288 1239 $response['payment_intent_id'] = '';
1289 1240 $response['client_secret'] = '';
@@ -1329,13 +1280,16 @@
1329 1280 *
1330 1281 * @return WP_Error|WP_HTTP_Response
1331 1282 */
1332 1283 public function update_payment_status( $request ) {
1333 - $data = json_decode( $request->get_body(), true );
1334 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1335 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1336 - $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();
1337 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 +
1338 1292 $db = new Booktics_Database();
1339 1293 $order_model = new Order_Model( $db );
1340 1294 $order = $order_model->find( array( 'id' => $order_id ) );
1341 1295
@@ -1342,8 +1296,14 @@
1342 1296 if ( ! $order ) {
1343 1297 return $this->error( __( 'Order not found', 'booktics' ) );
1344 1298 }
1345 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 +
1346 1306 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1347 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1348 1308 }
1349 1309
@@ -1360,9 +1320,9 @@
1360 1320
1361 1321 $order_model->update(
1362 1322 $order_id, array(
1363 1323 'payment_status' => $status,
1364 - 'transaction_id' => $data['transaction_id'] ?? null,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1365 1325 )
1366 1326 );
1367 1327
1368 1328 if (
@@ -1374,12 +1334,15 @@
1374 1334 try {
1375 1335 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1376 1336 $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1377 1337 } catch ( \Throwable $e ) {
1378 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1379 - error_log( 'Booktics: failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1380 - }
1338 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1381 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 );
1382 1345 }
1383 1346
1384 1347 // Payment model status and transaction id update
1385 1348 return $this->response(
@@ -1388,11 +1351,9 @@
1388 1351 ), __( 'Payment status updated successfully', 'booktics' )
1389 1352 );
1390 1353 } catch ( \Exception $e ) {
1391 1354
1392 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1393 - error_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1394 - }
1355 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1395 1356 return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1396 1357 }
1397 1358 }
1398 1359
@@ -1515,8 +1476,24 @@
1515 1476 }
1516 1477 }
1517 1478
1518 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 + /**
1519 1496 * Process payment refund for an order.
1520 1497 *
1521 1498 * @param WP_REST_Request $request
1522 1499 *
@@ -1527,15 +1504,27 @@
1527 1504 $db = new Booktics_Database();
1528 1505 $order_model = new Order_Model( $db );
1529 1506 $order = $order_model->find( array( 'id' => $order_id ) );
1530 1507
1508 + if ( ! $order || is_wp_error( $order ) ) {
1509 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1510 + }
1511 +
1531 1512 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1532 1513 if ( ! $payment_method ) {
1533 1514 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1534 1515 }
1535 1516 $response = $payment_method->process_refund( $order );
1536 - if ( $response['status'] === 'error' ) {
1537 - 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() );
1523 + }
1524 +
1525 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1526 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1538 1527 }
1539 1528
1540 1529 $order_model->update(
1541 1530 $order_id, array(