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