PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
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 +235 -248 1.0.22 → 1.0.27 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 *
@@ -566,9 +592,9 @@
566 592 'price' => $item['price'] ?? 0,
567 593 'subtotal' => $item['subtotal'] ?? 0,
568 594 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
569 595 'status' => booktics_get_option( 'appointment_status', 'pending' ),
570 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
596 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
571 597 'timezone' => $original_timezone,
572 598 );
573 599
574 600
@@ -574,8 +600,13 @@
574 600
575 601
576 602 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
577 603
604 + $conflict = Appointment_Validator::validate_slot_available( $appointment_data );
605 + if ( $conflict ) {
606 + throw new Exception( esc_html( $conflict->get_error_message() ) );
607 + }
608 +
578 609 $appointment = Appointment_Model::create( $appointment_data );
579 610 if ( is_wp_error( $appointment ) ) {
580 611 throw new Exception( esc_html( $appointment->get_error_message() ) );
581 612 }
@@ -637,9 +668,9 @@
637 668 */
638 669 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
639 670 ( new Order_Model( $db ) )->update(
640 671 $order_model->id,
641 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
672 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
642 673 );
643 674
644 675 ( new Payment_Model( $db ) )->create(
645 676 array(
@@ -646,9 +677,9 @@
646 677 'order_id' => $order_model->id,
647 678 'customer_id' => $input_data['customer_id'],
648 679 'amount' => $input_data['total'] * 100,
649 680 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
650 - 'intent_id' => $payment_intent['payment_intent'],
681 + 'intent_id' => $payment_intent['payment_intent'] ?? null,
651 682 'transaction_id' => null,
652 683 'payment_method' => $input_data['payment_method'],
653 684 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
654 685 'date' => gmdate( 'Y-m-d' ),
@@ -659,22 +690,15 @@
659 690
660 691 /**
661 692 * Resolve team member email with WP user fallback.
662 693 *
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.
694 + * @param string $primary
695 + * @param mixed $team_member_id
696 + *
697 + * @return string
666 698 */
667 699 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 : '';
700 + return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
677 701 }
678 702
679 703 /**
680 704 * Helper: Format a single appointment for API output
@@ -683,52 +707,9 @@
683 707 *
684 708 * @return array
685 709 */
686 710 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;
711 + return Order_Notifier::format_appointment( $appointment );
731 712 }
732 713
733 714 /**
734 715 * Helper: Format order for API output
@@ -762,8 +743,14 @@
762 743 'email' => $customer->email ?? '',
763 744 ),
764 745 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
765 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 ),
766 753 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
767 754 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
768 755 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
769 756 'items' => $appointment_details,
@@ -768,8 +755,11 @@
768 755 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
769 756 'items' => $appointment_details,
770 757 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
771 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'] ?? '',
772 762 'total_services' => $input_data['total_services'] ?? 1,
773 763 );
774 764
775 765 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -779,8 +769,12 @@
779 769
780 770 /**
781 771 * Helper: Send notifications for order and appointment creation
782 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
783 777 * @param Order_Model $order_model
784 778 * @param int $customer_id
785 779 * @param array $appointments
786 780 *
@@ -786,59 +780,9 @@
786 780 *
787 781 * @return void
788 782 */
789 783 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 );
784 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
841 785 }
842 786
843 787 /**
844 788 * Find existing customer by email or create new customer
@@ -1154,8 +1098,13 @@
1154 1098 'payment_status' => $payment_status,
1155 1099 )
1156 1100 );
1157 1101
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 +
1158 1107 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1159 1108 $customer_id = absint( $data['customer']['id'] );
1160 1109 $customer_data = $data['customer'];
1161 1110 $customer_data['id'] = $customer_id;
@@ -1237,9 +1186,9 @@
1237 1186 *
1238 1187 * @return string
1239 1188 */
1240 1189 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' ) );
1190 + return Order_Key::make( $order_id, $order_no );
1242 1191 }
1243 1192
1244 1193 /**
1245 1194 * @param int $order_id
@@ -1248,15 +1197,9 @@
1248 1197 *
1249 1198 * @return bool
1250 1199 */
1251 1200 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 );
1201 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1259 1202 }
1260 1203
1261 1204 /**
1262 1205 * Get single order.
@@ -1281,9 +1224,15 @@
1281 1224 // Add extra fields specific to GET if needed
1282 1225 $response['status'] = $order->status;
1283 1226 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1284 1227 $response['payment_gateway'] = $order->payment_method;
1228 + $response['payment_method'] = $order->payment_method;
1285 1229
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 +
1286 1235 // Hide payment intent fields from team members (not admins)
1287 1236 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1288 1237 $response['payment_intent_id'] = '';
1289 1238 $response['client_secret'] = '';
@@ -1329,13 +1278,16 @@
1329 1278 *
1330 1279 * @return WP_Error|WP_HTTP_Response
1331 1280 */
1332 1281 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;
1282 + $data = json_decode( $request->get_body(), true );
1283 + $data = is_array( $data ) ? $data : array();
1337 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 +
1338 1290 $db = new Booktics_Database();
1339 1291 $order_model = new Order_Model( $db );
1340 1292 $order = $order_model->find( array( 'id' => $order_id ) );
1341 1293
@@ -1342,8 +1294,14 @@
1342 1294 if ( ! $order ) {
1343 1295 return $this->error( __( 'Order not found', 'booktics' ) );
1344 1296 }
1345 1297
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 +
1346 1304 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1347 1305 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1348 1306 }
1349 1307
@@ -1360,9 +1318,9 @@
1360 1318
1361 1319 $order_model->update(
1362 1320 $order_id, array(
1363 1321 'payment_status' => $status,
1364 - 'transaction_id' => $data['transaction_id'] ?? null,
1322 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1365 1323 )
1366 1324 );
1367 1325
1368 1326 if (
@@ -1374,12 +1332,15 @@
1374 1332 try {
1375 1333 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1376 1334 $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1377 1335 } 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 - }
1336 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1381 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 );
1382 1343 }
1383 1344
1384 1345 // Payment model status and transaction id update
1385 1346 return $this->response(
@@ -1388,11 +1349,9 @@
1388 1349 ), __( 'Payment status updated successfully', 'booktics' )
1389 1350 );
1390 1351 } catch ( \Exception $e ) {
1391 1352
1392 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1393 - error_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1394 - }
1353 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1395 1354 return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1396 1355 }
1397 1356 }
1398 1357
@@ -1515,8 +1474,24 @@
1515 1474 }
1516 1475 }
1517 1476
1518 1477 /**
1478 + * Check if the current user may refund an order.
1479 + *
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
1488 + */
1489 + public function refund_payment_permission( $request ) {
1490 + return current_user_can( 'manage_options' );
1491 + }
1492 +
1493 + /**
1519 1494 * Process payment refund for an order.
1520 1495 *
1521 1496 * @param WP_REST_Request $request
1522 1497 *
@@ -1527,15 +1502,27 @@
1527 1502 $db = new Booktics_Database();
1528 1503 $order_model = new Order_Model( $db );
1529 1504 $order = $order_model->find( array( 'id' => $order_id ) );
1530 1505
1506 + if ( ! $order || is_wp_error( $order ) ) {
1507 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1508 + }
1509 +
1531 1510 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1532 1511 if ( ! $payment_method ) {
1533 1512 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1534 1513 }
1535 1514 $response = $payment_method->process_refund( $order );
1536 - if ( $response['status'] === 'error' ) {
1537 - 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() );
1521 + }
1522 +
1523 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1524 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1538 1525 }
1539 1526
1540 1527 $order_model->update(
1541 1528 $order_id, array(