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