PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
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 +528 -269 1.0.15 → 1.0.28 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace Booktics\Order\Controllers;
4 4
5 5 use Booktics\Abstracts\Base_Rest_Controller;
6 +use Booktics\Appointment\Services\Appointment_Validator;
6 7 use Booktics\Abstracts\Booktics_Database;
7 8 use Booktics\Abstracts\User_Model;
8 9 use Booktics\Cart\Cart_Manager;
9 10 use Booktics\Customer\Handlers\Customer_Event_Handler;
@@ -14,8 +15,11 @@
14 15 use Booktics\Models\Payment_Model;
15 16 use Booktics\Models\Service_Model;
16 17 use Booktics\Models\Team_Member_Model;
17 18 use Booktics\Order\Order_Validator;
19 +use Booktics\Order\Services\Order_Key;
20 +use Booktics\Order\Services\Order_Notifier;
21 +use Booktics\Order\Services\Abandoned_Booking_Cleanup;
18 22 use Booktics\Payment\Payment_Method_Manager;
19 23 use Booktics\Payment\Payment_Status;
20 24 use Booktics\Services\Time_Zone_Handler;
21 25 use DateTime;
@@ -109,9 +113,14 @@
109 113 $this,
110 114 'get_item_permission',
111 115 ),
112 116 'args' => array(
113 - 'id' => array( 'required' => true ),
117 + 'id' => array( 'required' => true ),
118 + 'order_key' => array(
119 + 'description' => __( 'Secret key that proves the client may view this order without logging in (returned when the order is created).', 'booktics' ),
120 + 'type' => 'string',
121 + 'required' => false,
122 + ),
114 123 ),
115 124 ),
116 125 array(
117 126 'methods' => WP_REST_Server::DELETABLE,
@@ -149,9 +158,9 @@
149 158 'methods' => WP_REST_Server::EDITABLE,
150 159 'callback' => array( $this, 'refund_payment' ),
151 160 'permission_callback' => array(
152 161 $this,
153 - 'update_payment_status_permission',
162 + 'refund_payment_permission',
154 163 ),
155 164 ),
156 165 )
157 166 );
@@ -209,18 +218,28 @@
209 218 $db->roll_back();
210 219 return $this->error( $e->getMessage() );
211 220 }
212 221
213 - $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
222 + if ( empty( $input_data['pay_now'] ) ) {
223 + $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
224 + }
214 225 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
215 226 do_action('booktics_after_order_created', $order_model, $appointments);
216 -
227 +
217 228 $response_message = 'Order created successfully';
218 229 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
219 230 $response_message = 'Order pending, please proceed payment';
220 231 }
221 232
222 - return $this->response( $response, __( $response_message, 'booktics' ), 201 );
233 + return $this->response(
234 + $response,
235 + sprintf(
236 + /* translators: %s: dynamic response message */
237 + __( 'Response %s', 'booktics' ),
238 + $response_message
239 + ),
240 + 201
241 + );
223 242 }
224 243
225 244 /**
226 245 * Comprehensive validation and preparation of order data
@@ -252,8 +271,22 @@
252 271 );
253 272 if ( is_wp_error( $validate ) ) {
254 273 return $validate;
255 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 + }
256 289 }
257 290
258 291 // Step 4: Process and validate each item
259 292 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
@@ -261,18 +294,32 @@
261 294 }
262 295
263 296 foreach ( $input_data['items'] as $key => $item ) {
264 297 // Validate required fields for each item
265 - $item_validate = booktics_validate(
266 - $item, array(
267 - 'date' => array( 'required' ),
268 - 'start_time' => array( 'required' ),
269 - 'end_time' => array( 'required' ),
270 - 'team_member_id' => array( 'required' ),
271 - 'service_id' => array( 'required' ),
272 - )
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' ),
273 304 );
274 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 +
275 322 if ( is_wp_error( $item_validate ) ) {
276 323 return $item_validate;
277 324 }
278 325
@@ -295,19 +342,18 @@
295 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
296 343 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
297 344 }
298 345
299 - // In validate_and_prepare_order_data(), after converting times:
300 - $item_with_converted_times = array_merge($item, [
301 - 'start_time' => $converted_start_time['time'],
302 - 'end_time' => $converted_end_time['time'],
303 - 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
304 - ]);
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 );
305 350
306 - $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
307 -
308 - if ( is_wp_error( $item_timet_slot_validate ) ) {
309 - 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;
310 356 }
311 357
312 358 // Sanitize and prepare item data
313 359 $input_data['items'][ $key ] = array(
@@ -312,21 +358,36 @@
312 358 // Sanitize and prepare item data
313 359 $input_data['items'][ $key ] = array(
314 360 'start_time' => $converted_start_time['time'],
315 361 'end_time' => $converted_end_time['time'],
316 - 'date' => gmdate( 'Y-m-d', strtotime( $converted_start_time['date'] ) ),
362 + 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
317 363 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
318 364 'team_member_id' => absint( $item['team_member_id'] ),
319 365 'service_id' => absint( $item['service_id'] ),
320 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
321 - 'price' => floatval( $item['price'] ?? 0 ),
322 - 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
366 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
367 + 'price' => $amount['price'],
368 + 'subtotal' => $amount['total'],
369 + 'total' => $amount['total'],
323 370 'quantity' => absint( $item['quantity'] ?? 1 ),
324 - 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
371 + 'total_attendees' => $attendees,
325 372 );
326 373 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
327 374 }
328 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 +
329 390 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
330 391
331 392 return $input_data;
332 393 }
@@ -331,130 +392,120 @@
331 392 return $input_data;
332 393 }
333 394
334 395 /**
335 - * Check if the requested time slot has available capacity for the given team member
336 - * Works by checking all appointments for this team member on this date and calculating remaining capacity
337 - *
338 - * @param array $item Appointment item data
339 - * @return bool|WP_Error True if time slot has capacity, WP_Error if not
396 + * Resolve how many attendees a single booking line is for.
397 + *
398 + * Group booking sends the count inside the item; older payloads put a single
399 + * count at the root. One resolved value is then used for the capacity check,
400 + * the price and the stored appointment.
401 + *
402 + * @param array $item Raw item from the request.
403 + * @param array $input_data Raw order payload.
404 + *
405 + * @return int
340 406 */
341 - private function validate_appointment_time_is_free( $item ) {
342 - try {
343 - // Convert times to DateTime objects
344 - $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
345 - $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
346 -
347 - // Get the requested attendee count (default to 1 for backward compatibility)
348 - $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
349 -
350 - // Get service to check if group booking is enabled and get max capacity
351 - $service = Service_Model::find($item['service_id']);
352 - if ( ! $service ) {
353 - return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
354 - }
355 -
356 - $max_group_size = $this->get_service_max_group_size( $service->id );
407 + private function resolve_item_attendees( $item, $input_data ) {
408 + $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array();
357 409
358 -
359 - $args = [
360 - 'team_member_id' => $item['team_member_id'],
361 - 'date' => $item['date'],
362 - 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
363 - ];
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
364 414
365 - // Get all non-cancelled appointments for this team member on this date
366 - $appointments = Appointment_Model::all( $args );
415 + return max( 1, absint( $attendees ) );
416 + }
367 417
368 - if ( ! is_array( $appointments ) || empty( $appointments ) ) {
369 - // No existing appointments, check if requested attendees fit within max capacity
370 - if ( $requested_attendees > $max_group_size ) {
371 - return new \WP_Error( 'exceeds_max_capacity', sprintf( __( 'Requested attendees (%d) exceeds maximum group size (%d) for this service.', 'booktics' ), $requested_attendees, $max_group_size ) );
372 - }
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
374 - return true;
375 - }
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
432 + }
376 433
377 - // Calculate total attendees for overlapping time slots
378 - $overlapping_attendees = 0;
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
379 435
380 - foreach ( $appointments as $appointment ) {
381 - // Skip if this is the same appointment (for updates)
382 - if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
383 - continue;
384 - }
385 -
386 - $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
387 - $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
436 + if ( empty( $durations ) ) {
437 + return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) );
438 + }
388 439
389 - // Check for time overlap
390 - if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
391 - // Times overlap, add this appointment's attendees to the count
392 - $appointment_attendees = $appointment->total_attendees ?? 1;
393 - $overlapping_attendees += $appointment_attendees;
394 - }
440 + $requested_id = booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' );
441 + $selected = null;
442 +
443 + foreach ( $durations as $duration ) {
444 + if ( (string) ( $duration['id'] ?? '' ) === (string) $requested_id ) {
445 + $selected = $duration;
446 + break;
395 447 }
396 -
397 - // Check if adding the requested attendees would exceed capacity
398 - $total_after_booking = $overlapping_attendees + $requested_attendees;
399 -
400 - if ($total_after_booking > $max_group_size) {
401 - return new \WP_Error( 'insufficient_capacity', __( 'In sufficient capacity' , 'booktics' ) );
448 + }
449 +
450 + // A single-duration service may be booked without naming the duration,
451 + // but an unknown id must never silently fall back to a cheaper one.
452 + if ( null === $selected ) {
453 + if ( '' !== $requested_id ) {
454 + return new WP_Error( 'invalid_duration', __( 'Invalid duration selected for this service.', 'booktics' ) );
402 455 }
456 + $selected = reset( $durations );
457 + }
403 458
404 - return true;
405 - } catch ( \Throwable $e ) {
406 - return new \WP_Error(
407 - 'time_validation_error',
408 - __( 'Error validating appointment time: ', 'booktics' ) . $e->getMessage()
409 - );
410 - }
459 + $price = (float) ( $selected['charge_amount'] ?? 0 );
460 +
461 + // Add-ons priced by booktics-pro (extra services). Runs before the
462 + // attendee multiplier so add-ons scale with the group, matching the
463 + // price the booking form showed.
464 + $total = (float) apply_filters( 'booktics_order_item_amount', $price, $item, $service, $attendees );
465 + $total = $total * $this->get_attendee_multiplier( $service_id, $attendees );
466 +
467 + return array(
468 + 'price' => round( max( 0, $price ), 2 ),
469 + 'total' => round( max( 0, $total ), 2 ),
470 + );
411 471 }
412 472
413 473 /**
414 - * Get max group size for a specific service
415 - *
416 - * @param int $service_id Service ID
417 - *
418 - * @return int Max group size
474 + * How many times a line price is charged for a group booking.
475 + *
476 + * Services that charge per attendee bill the flat price once; the rest
477 + * multiply it by the number of people, which is what the booking form does.
478 + *
479 + * @param int $service_id Service ID.
480 + * @param int $attendees Resolved attendee count.
481 + *
482 + * @return int
419 483 */
420 - private function get_service_max_group_size( $service_id ) {
421 - $default_max_size = 1;
422 - $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
423 -
424 - if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
425 - return $default_max_size;
484 + private function get_attendee_multiplier( $service_id, $attendees ) {
485 + $group_booking = get_post_meta( $service_id, 'group_booking', true );
486 +
487 + if ( ! $this->is_group_booking_enabled( $group_booking ) ) {
488 + return 1;
426 489 }
427 490
428 - 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 );
429 496 }
430 -
497 +
431 498 /**
432 499 * Checks if group booking is enabled for a services group booking meta data
433 - *
500 + *
434 501 * @param array $group_booking_meta Group booking meta data
435 - *
502 + *
436 503 * @return bool True if group booking is enabled, false otherwise
437 504 */
438 505 private function is_group_booking_enabled( $group_booking_meta ) {
439 506 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
440 507 }
441 -
442 - /**
443 - * Extracts the max booking size from the group booking meta data of a service
444 - *
445 - * @param array $group_booking_meta Group booking meta data
446 - * @param int $default_size Default max booking size
447 - *
448 - * @return int Max booking size
449 - */
450 - private function extract_max_booking_size( $group_booking_meta, $default_size ) {
451 - if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
452 - return (int) $group_booking_meta['max_booking'];
453 - }
454 -
455 - return $default_size;
456 - }
457 508
458 509 /**
459 510 * Resolve the customer ID from input data, creating or finding the customer as needed
460 511 *
@@ -517,16 +568,25 @@
517 568 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
518 569 }
519 570 $total_duration_min = $this->calculate_total_duration( $item );
520 571
572 + // Store date/time in a locale-independent format. wp_date() localizes digits and
573 + // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
574 + // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
575 + // the same timezone-aware value but is never localized.
576 + $time_format = get_option( 'time_format' );
577 + $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
578 + $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
579 + $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
580 +
521 581 $appointment_data = array(
522 - 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
523 - 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
524 - 'date' => wp_date( 'Y-m-d', strtotime( $item['date'] ), $input_timezone ),
582 + 'start_time' => $start_local->format( $time_format ),
583 + 'end_time' => $end_local->format( $time_format ),
584 + 'date' => $date_local->format( 'Y-m-d' ),
525 585 'customer_id' => $input_data['customer_id'] ?? '',
526 586 'team_member_id' => $item['team_member_id'] ?? '',
527 587 'service_id' => $item['service_id'] ?? '',
528 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
588 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
529 589 'duration' => $total_duration_min,
530 590 'order_id' => $order_model->id,
531 591 'quantity' => $item['quantity'] ?? 1,
532 592 'price' => $item['price'] ?? 0,
@@ -532,19 +592,24 @@
532 592 'price' => $item['price'] ?? 0,
533 593 'subtotal' => $item['subtotal'] ?? 0,
534 594 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
535 595 'status' => booktics_get_option( 'appointment_status', 'pending' ),
536 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
596 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
537 597 'timezone' => $original_timezone,
538 598 );
539 -
540 -
541 -
599 +
600 +
601 +
542 602 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
543 -
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 +
544 609 $appointment = Appointment_Model::create( $appointment_data );
545 610 if ( is_wp_error( $appointment ) ) {
546 - throw new Exception( $appointment->get_error_message() );
611 + throw new Exception( esc_html( $appointment->get_error_message() ) );
547 612 }
548 613
549 614 do_action( 'booktics_after_appointment_created', $appointment );
550 615 $appointments[] = $appointment;
@@ -570,23 +635,22 @@
570 635 }
571 636
572 637 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
573 638 if ( ! $payment_method ) {
574 - throw new Exception( __( 'Payment method cannot be found!', 'booktics' ) );
639 + throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
575 640 }
576 641
577 642 $payment_intent = $payment_method->process_payment( $order_model );
578 643
579 644 if ( $payment_intent['status'] === 'error' ) {
645 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
580 646 throw new Exception( $payment_intent['message'] );
581 647 }
582 648
583 649 if ( $payment_intent['status'] === 'success' ) {
584 650 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
585 -
586 - // Update orders payment status on successfull payment completion
587 - $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
588 - ( new Order_Model($db) )->update( $order_model_id, [ 'payment_status' => 'fully_paid' ] );
651 + // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
652 + // Status is updated after client-side confirmation via update_payment_status endpoint.
589 653 }
590 654
591 655 return $payment_intent;
592 656 }
@@ -604,9 +668,9 @@
604 668 */
605 669 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
606 670 ( new Order_Model( $db ) )->update(
607 671 $order_model->id,
608 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
672 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
609 673 );
610 674
611 675 ( new Payment_Model( $db ) )->create(
612 676 array(
@@ -613,9 +677,9 @@
613 677 'order_id' => $order_model->id,
614 678 'customer_id' => $input_data['customer_id'],
615 679 'amount' => $input_data['total'] * 100,
616 680 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
617 - 'intent_id' => $payment_intent['payment_intent'],
681 + 'intent_id' => $payment_intent['payment_intent'] ?? null,
618 682 'transaction_id' => null,
619 683 'payment_method' => $input_data['payment_method'],
620 684 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
621 685 'date' => gmdate( 'Y-m-d' ),
@@ -624,8 +688,20 @@
624 688 );
625 689 }
626 690
627 691 /**
692 + * Resolve team member email with WP user fallback.
693 + *
694 + * @param string $primary
695 + * @param mixed $team_member_id
696 + *
697 + * @return string
698 + */
699 + private function resolve_team_member_email( string $primary, $team_member_id ): string {
700 + return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
701 + }
702 +
703 + /**
628 704 * Helper: Format a single appointment for API output
629 705 *
630 706 * @param Appointment_Model|array $appointment
631 707 *
@@ -631,52 +707,9 @@
631 707 *
632 708 * @return array
633 709 */
634 710 private function format_appointment( $appointment ): array {
635 - if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
636 - $appointment = $appointment->to_array();
637 - }
638 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
639 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
640 - $service = $service ? $service->to_array() : array();
641 - $additional_durations = $service['additional_durations'] ?? array();
642 - $additional_durations = ! empty( $additional_durations )
643 - ? array_filter(
644 - $additional_durations, function ( $duration ) use ( $appointment ) {
645 - return $duration['id'] === $appointment['additional_duration_id'];
646 - }
647 - ) : array();
648 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
649 - $formatted_appointment = array(
650 - 'id' => $appointment['id'] ?? 0,
651 - 'date' => $appointment['date'] ?? '',
652 - 'start_time' => $appointment['start_time'] ?? '',
653 - 'end_time' => $appointment['end_time'] ?? '',
654 - 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
655 - 'team' => array(
656 - 'id' => $team_member->id ?? null,
657 - 'display_name' => $team_member->display_name ?? '',
658 - 'image' => $team_member->image ?? '',
659 - 'user_email' => $team_member->user_email ?? '',
660 - ),
661 - 'service' => array(
662 - 'id' => $appointment['service_id'] ?? '',
663 - 'title' => $service['title'] ?? '',
664 - 'additional_duration' => $additional_duration,
665 - 'enable_google_meet' => $service['enable_google_meet'] ?? false,
666 - ),
667 - 'duration' => $appointment['duration'] ?? null,
668 - 'quantity' => $appointment['quantity'] ?? 1,
669 - 'price' => $appointment['price'] ?? 0,
670 - 'subtotal' => $appointment['subtotal'] ?? 0,
671 - 'total' => $appointment['total'] ?? 0,
672 - 'status' => $appointment['status'] ?? '',
673 - 'total_attendees' => $appointment['total_attendees'] ?? 1,
674 - 'appointment_token' => $appointment['security_token'] ?? '',
675 - 'cancel_note' => $appointment['cancel_note'] ?? '',
676 - );
677 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
678 - return $formatted_appointment;
711 + return Order_Notifier::format_appointment( $appointment );
679 712 }
680 713
681 714 /**
682 715 * Helper: Format order for API output
@@ -696,11 +729,14 @@
696 729 foreach ( $appointments as $appointment ) {
697 730 $appointment_details[] = $this->format_appointment( $appointment );
698 731 }
699 732
733 + $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
734 +
700 735 $order_data = array(
701 736 'id' => $order_id,
702 737 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
738 + 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
703 739 'customer' => array(
704 740 'id' => $customer->id ?? null,
705 741 'name' => $customer->name ?? '',
706 742 'phone' => $customer->phone ?? '',
@@ -707,8 +743,14 @@
707 743 'email' => $customer->email ?? '',
708 744 ),
709 745 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
710 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 ),
711 753 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
712 754 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
713 755 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
714 756 'items' => $appointment_details,
@@ -713,8 +755,11 @@
713 755 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
714 756 'items' => $appointment_details,
715 757 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
716 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'] ?? '',
717 762 'total_services' => $input_data['total_services'] ?? 1,
718 763 );
719 764
720 765 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -724,8 +769,12 @@
724 769
725 770 /**
726 771 * Helper: Send notifications for order and appointment creation
727 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
728 777 * @param Order_Model $order_model
729 778 * @param int $customer_id
730 779 * @param array $appointments
731 780 *
@@ -731,54 +780,9 @@
731 780 *
732 781 * @return void
733 782 */
734 783 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
735 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
736 - foreach ( $appointments as $appointment ) {
737 - $formatted_appointment = $this->format_appointment( $appointment );
738 - $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
739 - $dt = new DateTime( $appointment_time, wp_timezone() );
740 - $timestamp = $dt->getTimestamp();
741 - $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
742 - $notification_flow_data_for_appointment = array(
743 - 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
744 - 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
745 - 'start_time' => $formatted_appointment['start_time'],
746 - 'appointment_date' => $formatted_appointment['date'],
747 - 'appointment_time' => $appointment_datetime,
748 - 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
749 - 'customer_name' => $customer->name ?? '',
750 - 'user_email' => $customer->email ?? '',
751 - 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
752 - );
753 - $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
754 -
755 - do_action( 'global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
756 - }
757 -
758 - $appointment = $appointments[0]; // As currently only one appointment is created for each user
759 - $formatted_appointment = $this->format_appointment( $appointment );
760 - $notification_flow_data_for_order = [
761 - 'order_no' => $order_model->order_no ?? '',
762 - 'order_date' => $order_model->created_at ?? '',
763 - 'order_total' => $order_model->total ?? 0,
764 - 'customer_name' => $customer->name ?? '',
765 - 'user_email' => $customer->email ?? '',
766 - 'order_status' => $order_model->status ?? '',
767 - 'service_name' => $formatted_appointment['service']['title'] ?? '',
768 - 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
769 - 'start_time' => $formatted_appointment['start_time'] ?? '',
770 - 'end_time' => $formatted_appointment['end_time'] ?? '',
771 - 'appointment_date' => $formatted_appointment['date'] ?? '',
772 - 'location_address' => $formatted_appointment['location']['address'] ?? '',
773 - 'appointment_status' => $formatted_appointment['status'] ?? '',
774 - 'appointment_cancel_url' => $appointment->get_cancel_link(),
775 - 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
776 - ];
777 -
778 - $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
779 -
780 - do_action( 'global_notification_hook', 'order_created', $notification_flow_data_for_order );
784 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
781 785 }
782 786
783 787 /**
784 788 * Find existing customer by email or create new customer
@@ -843,9 +847,9 @@
843 847 * @return object|null Guest_Model instance or null
844 848 */
845 849 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
846 850 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
847 - if ( $guest ) {
851 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
848 852 ( new Guest_Model( $db ) )->update(
849 853 $guest->id, array(
850 854 'name' => $customer['name'] ?? '',
851 855 'phone' => $customer['phone'] ?? '',
@@ -857,8 +861,40 @@
857 861 return $guest;
858 862 }
859 863
860 864 /**
865 + * Determine whether the current request is authorized to overwrite an
866 + * existing customer's stored contact details (name, phone, wp_user_id).
867 + *
868 + * @param object $guest Existing Guest_Model record.
869 + * @param string $email Submitted customer email.
870 + *
871 + * @return bool True when the caller may overwrite the stored record.
872 + */
873 + private function caller_owns_customer_email( $guest, $email ) {
874 + // Admin or team member managing a booking on the customer's behalf.
875 + if ( booktics_is_admin_or_team_member() ) {
876 + return true;
877 + }
878 +
879 + if ( ! is_user_logged_in() ) {
880 + return false;
881 + }
882 +
883 + // The record is already linked to the logged-in user.
884 + if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
885 + return true;
886 + }
887 +
888 + // The logged-in user's own account email matches the submitted email.
889 + $current_user = wp_get_current_user();
890 +
891 + return $current_user
892 + && ! empty( $current_user->user_email )
893 + && strtolower( $current_user->user_email ) === strtolower( (string) $email );
894 + }
895 +
896 + /**
861 897 * Create a new guest record
862 898 *
863 899 * @param Booktics_Database $db
864 900 * @param array $customer
@@ -879,16 +915,28 @@
879 915 );
880 916 }
881 917
882 918 /**
883 - * Check if user has permission to get orders.
919 + * Check if user has permission to list orders.
884 920 *
921 + * Anonymous access is denied: the collection handler scopes to the current customer or team member;
922 + * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
923 + * guests when {@see get_item_permission()} validates a matching order_key.
924 + *
885 925 * @param WP_REST_Request $request
886 926 *
887 - * @return bool
927 + * @return bool|WP_Error
888 928 */
889 929 public function get_items_permission( $request ) {
890 - return true;
930 + if ( is_user_logged_in() ) {
931 + return true;
932 + }
933 +
934 + return new WP_Error(
935 + 'booktics_forbidden',
936 + __( 'You are not allowed to view orders.', 'booktics' ),
937 + array( 'status' => 403 )
938 + );
891 939 }
892 940
893 941 /**
894 942 * Get paginated list of orders.
@@ -897,16 +945,16 @@
897 945 *
898 946 * @return WP_Error|WP_HTTP_Response
899 947 */
900 948 public function get_items( $request ) {
901 - $page = (int) $request->get_param( 'paged' ) == 0 ? 1 : $request->get_param( 'paged' );
902 - $per_page = (int) $request->get_param( 'per_page' ) == 0 ? 10 : $request->get_param( 'per_page' );
903 - $payment_status = $request->get_param( 'payment_status' );
904 - $payment_method = $request->get_param( 'payment_method' );
905 - $order_date = $request->get_param( 'order_date' );
906 - $team_member = (int) $request->get_param( 'team_member' );
907 - $service = (int) $request->get_param( 'service' );
908 - $search = $request->get_param( 'search' );
949 + $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
950 + $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
951 + $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
952 + $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
953 + $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
954 + $team_member = absint( $request->get_param( 'team_member' ) );
955 + $service = absint( $request->get_param( 'service' ) );
956 + $search = sanitize_text_field( $request->get_param( 'search' ) );
909 957
910 958 $db = new Booktics_Database();
911 959 $order_model = new Order_Model( $db );
912 960 $conditions = array();
@@ -928,8 +976,34 @@
928 976 }
929 977
930 978 $conditions = apply_filters( 'booktics_order_filters', $conditions );
931 979
980 + // Scope to customer's own orders when not admin/team-member
981 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
982 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
983 + if ( ! $guest ) {
984 + return $this->response(
985 + array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
986 + __( 'Orders fetched successfully', 'booktics' )
987 + );
988 + }
989 + $conditions['customer_id'] = $guest->id;
990 + }
991 +
992 + // Scope to team member's assigned orders - only return orders that contain
993 + // at least one appointment where team_member_id equals the current user's team member ID
994 + $is_team_member_scope = false;
995 + $team_member_filter_id = null;
996 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
997 + $current_user_id = get_current_user_id();
998 + $team_member_model = new Team_Member_Model();
999 + $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1000 + if ( $team_member ) {
1001 + $is_team_member_scope = true;
1002 + $team_member_filter_id = $team_member->id;
1003 + }
1004 + }
1005 +
932 1006 $orders = $order_model->get(
933 1007 $conditions, $page, $per_page, array(
934 1008 'created_at',
935 1009 'DESC',
@@ -961,15 +1035,27 @@
961 1035 }
962 1036
963 1037 foreach ( $orders['items'] as &$order ) {
964 1038 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
965 - $order->customer = array(
1039 + $customer_data_for_order = [
966 1040 'id' => $customer ? $customer->id : null,
967 - 'email' => $customer ? $customer->email : '',
968 1041 'name' => $customer ? $customer->name : '',
969 - 'phone' => $customer ? $customer->phone : '',
970 - );
1042 + 'email' => '',
1043 + 'phone' => '',
1044 + ];
1045 + // Admins and team members see email and phone
1046 + if ( booktics_is_admin_or_team_member() ) {
1047 + $customer_data_for_order['email'] = $customer ? $customer->email : '';
1048 + $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1049 + }
1050 + $order->customer = $customer_data_for_order;
971 1051 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1052 +
1053 + // Hide payment intent fields from team members (not admins)
1054 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1055 + $order->payment_intent_id = '';
1056 + $order->client_secret = '';
1057 + }
972 1058 }
973 1059
974 1060 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
975 1061 }
@@ -992,9 +1078,9 @@
992 1078 *
993 1079 * @return WP_Error|WP_HTTP_Response
994 1080 */
995 1081 public function update_item( $request ) {
996 - $order_id = (int) $request->get_param( 'id' );
1082 + $order_id = absint( $request->get_param( 'id' ) );
997 1083 $data = json_decode( $request->get_body(), true );
998 1084 $db = new Booktics_Database();
999 1085 $order_model = new Order_Model( $db );
1000 1086 $order = $order_model->find( array( 'id' => $order_id ) );
@@ -1002,24 +1088,50 @@
1002 1088 if ( ! $order ) {
1003 1089 return $this->error( __( 'Order not found', 'booktics' ) );
1004 1090 }
1005 1091
1092 + $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1093 + $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1094 +
1006 1095 $order_model->update(
1007 1096 $order_id, array(
1008 - 'status' => $data['status'] ?? $order->status,
1009 - 'payment_status' => $data['payment_status'] ?? $order->payment_status,
1097 + 'status' => $status,
1098 + 'payment_status' => $payment_status,
1010 1099 )
1011 1100 );
1012 1101
1013 - if ( isset( $data['customer'] ) && isset( $data['customer']['id']) ) {
1014 - ( new Guest_Model( $db ) )->update( $data['customer']['id'], $data['customer'] );
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 );
1015 1105 }
1016 1106
1107 + if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1108 + $customer_id = absint( $data['customer']['id'] );
1109 + $customer_data = $data['customer'];
1110 + $customer_data['id'] = $customer_id;
1111 + if ( isset( $customer_data['name'] ) ) {
1112 + $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1113 + }
1114 + if ( isset( $customer_data['email'] ) ) {
1115 + $customer_data['email'] = sanitize_email( $customer_data['email'] );
1116 + }
1117 + if ( isset( $customer_data['phone'] ) ) {
1118 + $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1119 + }
1120 + ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1121 + }
1122 +
1017 1123 $order_model = $order_model->find( array( 'id' => $order_id ) );
1018 1124 $data['customer_id'] = $order_model->customer_id;
1019 1125
1020 1126 $response = $this->format_order_response($db, $order_model, $data, null);
1021 1127
1128 + // Hide payment intent fields from team members (not admins)
1129 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1130 + $response['payment_intent_id'] = '';
1131 + $response['client_secret'] = '';
1132 + }
1133 +
1022 1134 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1023 1135 }
1024 1136
1025 1137 /**
@@ -1024,14 +1136,70 @@
1024 1136
1025 1137 /**
1026 1138 * Check if user has permission to get order.
1027 1139 *
1140 + * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1141 + * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1142 + * value returned when the order was created).
1143 + *
1028 1144 * @param WP_REST_Request $request
1029 1145 *
1146 + * @return bool|WP_Error
1147 + */
1148 + public function get_item_permission( $request ) {
1149 + if ( booktics_is_admin_or_team_member() ) {
1150 + return true;
1151 + }
1152 +
1153 + $order_id = absint( $request->get_param( 'id' ) );
1154 + $db = new Booktics_Database();
1155 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1156 +
1157 + if ( ! $order ) {
1158 + return true;
1159 + }
1160 +
1161 + if ( is_user_logged_in() ) {
1162 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1163 +
1164 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1165 + return true;
1166 + }
1167 + }
1168 +
1169 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1170 + if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1171 + return true;
1172 + }
1173 +
1174 + return new WP_Error(
1175 + 'booktics_forbidden',
1176 + __( 'You are not allowed to view this order.', 'booktics' ),
1177 + array( 'status' => 403 )
1178 + );
1179 + }
1180 +
1181 + /**
1182 + * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1183 + *
1184 + * @param int $order_id
1185 + * @param string $order_no
1186 + *
1187 + * @return string
1188 + */
1189 + private function get_order_view_key( $order_id, $order_no ) {
1190 + return Order_Key::make( $order_id, $order_no );
1191 + }
1192 +
1193 + /**
1194 + * @param int $order_id
1195 + * @param string $order_no
1196 + * @param string $provided_key
1197 + *
1030 1198 * @return bool
1031 1199 */
1032 - public function get_item_permission( $request ) {
1033 - return true;
1200 + private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1201 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1034 1202 }
1035 1203
1036 1204 /**
1037 1205 * Get single order.
@@ -1040,9 +1208,9 @@
1040 1208 *
1041 1209 * @return WP_Error|WP_HTTP_Response
1042 1210 */
1043 1211 public function get_item( $request ) {
1044 - $order_id = (int) $request->get_param( 'id' );
1212 + $order_id = absint( $request->get_param( 'id' ) );
1045 1213 $db = new Booktics_Database();
1046 1214 $order_model = new Order_Model( $db );
1047 1215 $order = $order_model->find( array( 'id' => $order_id ) );
1048 1216
@@ -1056,9 +1224,21 @@
1056 1224 // Add extra fields specific to GET if needed
1057 1225 $response['status'] = $order->status;
1058 1226 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1059 1227 $response['payment_gateway'] = $order->payment_method;
1228 + $response['payment_method'] = $order->payment_method;
1060 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 +
1235 + // Hide payment intent fields from team members (not admins)
1236 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1237 + $response['payment_intent_id'] = '';
1238 + $response['client_secret'] = '';
1239 + }
1240 +
1061 1241 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1062 1242 }
1063 1243
1064 1244 /**
@@ -1068,9 +1248,28 @@
1068 1248 *
1069 1249 * @return bool
1070 1250 */
1071 1251 public function update_payment_status_permission( $request ) {
1072 - return current_user_can( 'manage_options' );
1252 + if ( current_user_can( 'manage_options' ) ) {
1253 + return true;
1254 + }
1255 +
1256 + // Allow order owner with a valid order_key (guest checkout support).
1257 + $order_id = absint( $request->get_param( 'id' ) );
1258 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1259 +
1260 + if ( ! $order_id || ! $order_key ) {
1261 + return false;
1262 + }
1263 +
1264 + $db = new Booktics_Database();
1265 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1266 +
1267 + if ( ! $order ) {
1268 + return false;
1269 + }
1270 +
1271 + return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1073 1272 }
1074 1273
1075 1274 /**
1076 1275 * Update payment status for an order.
@@ -1079,13 +1278,16 @@
1079 1278 *
1080 1279 * @return WP_Error|WP_HTTP_Response
1081 1280 */
1082 1281 public function update_payment_status( $request ) {
1083 - $data = json_decode( $request->get_body(), true );
1084 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1085 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1086 - $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();
1087 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 +
1088 1290 $db = new Booktics_Database();
1089 1291 $order_model = new Order_Model( $db );
1090 1292 $order = $order_model->find( array( 'id' => $order_id ) );
1091 1293
@@ -1092,8 +1294,14 @@
1092 1294 if ( ! $order ) {
1093 1295 return $this->error( __( 'Order not found', 'booktics' ) );
1094 1296 }
1095 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 +
1096 1304 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1097 1305 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1098 1306 }
1099 1307
@@ -1110,12 +1318,31 @@
1110 1318
1111 1319 $order_model->update(
1112 1320 $order_id, array(
1113 1321 'payment_status' => $status,
1114 - 'transaction_id' => $data['transaction_id'] ?? null,
1322 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1115 1323 )
1116 1324 );
1117 1325
1326 + if (
1327 + $status === Payment_Status::COMPLETED
1328 + && $order->payment_status !== Payment_Status::COMPLETED
1329 + ) {
1330 + // The payment status is already persisted above. Sending notifications must
1331 + // not fail the request, otherwise a confirmed booking shows an error toast.
1332 + try {
1333 + $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1334 + $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1335 + } catch ( \Throwable $e ) {
1336 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
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 );
1343 + }
1344 +
1118 1345 // Payment model status and transaction id update
1119 1346 return $this->response(
1120 1347 array(
1121 1348 'payment_status' => $status,
@@ -1121,9 +1348,11 @@
1121 1348 'payment_status' => $status,
1122 1349 ), __( 'Payment status updated successfully', 'booktics' )
1123 1350 );
1124 1351 } catch ( \Exception $e ) {
1125 - return $this->error( $e->getMessage() );
1352 +
1353 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1354 + return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1126 1355 }
1127 1356 }
1128 1357
1129 1358 /**
@@ -1144,9 +1373,9 @@
1144 1373 *
1145 1374 * @return WP_Error|WP_HTTP_Response
1146 1375 */
1147 1376 public function delete_item( $request ) {
1148 - $order_id = (int) $request->get_param( 'id' );
1377 + $order_id = absint( $request->get_param( 'id' ) );
1149 1378 $db = new Booktics_Database();
1150 1379 $order_model = new Order_Model( $db );
1151 1380 $order = $order_model->find( array( 'id' => $order_id ) );
1152 1381
@@ -1201,9 +1430,10 @@
1201 1430 *
1202 1431 * @return WP_Error|WP_HTTP_Response
1203 1432 */
1204 1433 public function bulk_delete_items( $request ) {
1205 - $order_ids = array_map( 'intval', $request->get_param( 'ids' ) );
1434 + $ids_param = $request->get_param( 'ids' );
1435 + $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1206 1436 $db = new Booktics_Database();
1207 1437
1208 1438 try {
1209 1439 $db->begin_transaction();
@@ -1244,8 +1474,24 @@
1244 1474 }
1245 1475 }
1246 1476
1247 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 + /**
1248 1494 * Process payment refund for an order.
1249 1495 *
1250 1496 * @param WP_REST_Request $request
1251 1497 *
@@ -1251,22 +1497,34 @@
1251 1497 *
1252 1498 * @return WP_Error|WP_HTTP_Response
1253 1499 */
1254 1500 public function refund_payment( $request ) {
1255 - $order_id = (int) $request->get_param( 'id' );
1501 + $order_id = absint( $request->get_param( 'id' ) );
1256 1502 $db = new Booktics_Database();
1257 1503 $order_model = new Order_Model( $db );
1258 1504 $order = $order_model->find( array( 'id' => $order_id ) );
1259 1505
1506 + if ( ! $order || is_wp_error( $order ) ) {
1507 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1508 + }
1509 +
1260 1510 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1261 1511 if ( ! $payment_method ) {
1262 1512 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1263 1513 }
1264 1514 $response = $payment_method->process_refund( $order );
1265 - if ( $response['status'] === 'error' ) {
1266 - 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() );
1267 1521 }
1268 1522
1523 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1524 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1525 + }
1526 +
1269 1527 $order_model->update(
1270 1528 $order_id, array(
1271 1529 'payment_status' => Payment_Status::REFUNDED,
1272 1530 )
@@ -1318,8 +1576,9 @@
1318 1576 *
1319 1577 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1320 1578 */
1321 1579 private function format_datetime_to_dayjs_format( $datetime ) {
1322 - $dt = new DateTime( $datetime, wp_timezone() );
1323 - return $dt->format( 'c' );
1580 + $dt = new DateTime( $datetime );
1581 + $dt->setTimezone( wp_timezone() );
1582 + return $dt->format( 'Y-m-d H:i:s' );
1324 1583 }
1325 1584 }