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 +546 -272 1.0.12 → 1.0.29 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace Booktics\Order\Controllers;
4 4
5 5 use Booktics\Abstracts\Base_Rest_Controller;
6 +use Booktics\Appointment\Services\Appointment_Validator;
6 7 use Booktics\Abstracts\Booktics_Database;
7 8 use Booktics\Abstracts\User_Model;
8 9 use Booktics\Cart\Cart_Manager;
9 10 use Booktics\Customer\Handlers\Customer_Event_Handler;
@@ -14,8 +15,11 @@
14 15 use Booktics\Models\Payment_Model;
15 16 use Booktics\Models\Service_Model;
16 17 use Booktics\Models\Team_Member_Model;
17 18 use Booktics\Order\Order_Validator;
19 +use Booktics\Order\Services\Order_Key;
20 +use Booktics\Order\Services\Order_Notifier;
21 +use Booktics\Order\Services\Abandoned_Booking_Cleanup;
18 22 use Booktics\Payment\Payment_Method_Manager;
19 23 use Booktics\Payment\Payment_Status;
20 24 use Booktics\Services\Time_Zone_Handler;
21 25 use DateTime;
@@ -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'] ) ),
317 - 'customer_id' => absint( $item['customer_id'] ),
362 + 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
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 *
@@ -481,9 +532,11 @@
481 532 *
482 533 * @return array [Order_Model $order_model, array $appointments]
483 534 */
484 535 private function create_order_and_appointments( $input_data, $db ) {
485 - $input_data['currency'] = $input_data['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();
486 539 $order_model = new Order_Model( $db );
487 540
488 541 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
489 542 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
@@ -499,8 +552,9 @@
499 552 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
500 553 'subtotal' => $input_data['subtotal'],
501 554 'timezone' => $input_timezone,
502 555 'total' => $input_data['total'],
556 + 'currency' => $input_data['currency'],
503 557 'payment_method' => $input_data['payment_method'] ?? null,
504 558 );
505 559
506 560 // This is to only allow setting payment status when creating order from admin panel as admin
@@ -516,16 +570,25 @@
516 570 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
517 571 }
518 572 $total_duration_min = $this->calculate_total_duration( $item );
519 573
574 + // Store date/time in a locale-independent format. wp_date() localizes digits and
575 + // am/pm markers (e.g. Bengali "১১:০০ সকাল"), and such values cannot be parsed back
576 + // by DateTime, breaking slot validation and notifications. DateTime::format() keeps
577 + // the same timezone-aware value but is never localized.
578 + $time_format = get_option( 'time_format' );
579 + $start_local = ( new \DateTime( '@' . strtotime( $item['start_time'] ) ) )->setTimezone( $input_timezone );
580 + $end_local = ( new \DateTime( '@' . strtotime( $item['end_time'] ) ) )->setTimezone( $input_timezone );
581 + $date_local = ( new \DateTime( '@' . strtotime( $item['date'] ) ) )->setTimezone( $input_timezone );
582 +
520 583 $appointment_data = array(
521 - 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
522 - 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
523 - 'date' => wp_date( 'Y-m-d', strtotime( $item['date'] ), $input_timezone ),
584 + 'start_time' => $start_local->format( $time_format ),
585 + 'end_time' => $end_local->format( $time_format ),
586 + 'date' => $date_local->format( 'Y-m-d' ),
524 587 'customer_id' => $input_data['customer_id'] ?? '',
525 588 'team_member_id' => $item['team_member_id'] ?? '',
526 589 'service_id' => $item['service_id'] ?? '',
527 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
590 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
528 591 'duration' => $total_duration_min,
529 592 'order_id' => $order_model->id,
530 593 'quantity' => $item['quantity'] ?? 1,
531 594 'price' => $item['price'] ?? 0,
@@ -531,19 +594,24 @@
531 594 'price' => $item['price'] ?? 0,
532 595 'subtotal' => $item['subtotal'] ?? 0,
533 596 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
534 597 'status' => booktics_get_option( 'appointment_status', 'pending' ),
535 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
536 599 'timezone' => $original_timezone,
537 600 );
538 -
539 -
540 -
601 +
602 +
603 +
541 604 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
542 -
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 +
543 611 $appointment = Appointment_Model::create( $appointment_data );
544 612 if ( is_wp_error( $appointment ) ) {
545 - throw new Exception( $appointment->get_error_message() );
613 + throw new Exception( esc_html( $appointment->get_error_message() ) );
546 614 }
547 615
548 616 do_action( 'booktics_after_appointment_created', $appointment );
549 617 $appointments[] = $appointment;
@@ -569,23 +637,22 @@
569 637 }
570 638
571 639 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
572 640 if ( ! $payment_method ) {
573 - throw new Exception( __( 'Payment method cannot be found!', 'booktics' ) );
641 + throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
574 642 }
575 643
576 644 $payment_intent = $payment_method->process_payment( $order_model );
577 645
578 646 if ( $payment_intent['status'] === 'error' ) {
647 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
579 648 throw new Exception( $payment_intent['message'] );
580 649 }
581 650
582 651 if ( $payment_intent['status'] === 'success' ) {
583 652 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
584 -
585 - // Update orders payment status on successfull payment completion
586 - $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
587 - ( new Order_Model($db) )->update( $order_model_id, [ 'payment_status' => 'fully_paid' ] );
653 + // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
654 + // Status is updated after client-side confirmation via update_payment_status endpoint.
588 655 }
589 656
590 657 return $payment_intent;
591 658 }
@@ -603,9 +670,9 @@
603 670 */
604 671 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
605 672 ( new Order_Model( $db ) )->update(
606 673 $order_model->id,
607 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
674 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
608 675 );
609 676
610 677 ( new Payment_Model( $db ) )->create(
611 678 array(
@@ -610,11 +677,11 @@
610 677 ( new Payment_Model( $db ) )->create(
611 678 array(
612 679 'order_id' => $order_model->id,
613 680 'customer_id' => $input_data['customer_id'],
614 - 'amount' => $input_data['total'] * 100,
615 - 'currency' => $input_data['currency'],
616 - '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,
617 684 'transaction_id' => null,
618 685 'payment_method' => $input_data['payment_method'],
619 686 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
620 687 'date' => gmdate( 'Y-m-d' ),
@@ -623,8 +690,20 @@
623 690 );
624 691 }
625 692
626 693 /**
694 + * Resolve team member email with WP user fallback.
695 + *
696 + * @param string $primary
697 + * @param mixed $team_member_id
698 + *
699 + * @return string
700 + */
701 + private function resolve_team_member_email( string $primary, $team_member_id ): string {
702 + return Order_Notifier::resolve_team_member_email( $primary, $team_member_id );
703 + }
704 +
705 + /**
627 706 * Helper: Format a single appointment for API output
628 707 *
629 708 * @param Appointment_Model|array $appointment
630 709 *
@@ -630,52 +709,9 @@
630 709 *
631 710 * @return array
632 711 */
633 712 private function format_appointment( $appointment ): array {
634 - if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
635 - $appointment = $appointment->to_array();
636 - }
637 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
638 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
639 - $service = $service ? $service->to_array() : array();
640 - $additional_durations = $service['additional_durations'] ?? array();
641 - $additional_durations = ! empty( $additional_durations )
642 - ? array_filter(
643 - $additional_durations, function ( $duration ) use ( $appointment ) {
644 - return $duration['id'] === $appointment['additional_duration_id'];
645 - }
646 - ) : array();
647 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
648 - $formatted_appointment = array(
649 - 'id' => $appointment['id'] ?? 0,
650 - 'date' => $appointment['date'] ?? '',
651 - 'start_time' => $appointment['start_time'] ?? '',
652 - 'end_time' => $appointment['end_time'] ?? '',
653 - 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
654 - 'team' => array(
655 - 'id' => $team_member->id ?? null,
656 - 'display_name' => $team_member->display_name ?? '',
657 - 'image' => $team_member->image ?? '',
658 - 'user_email' => $team_member->user_email ?? '',
659 - ),
660 - 'service' => array(
661 - 'id' => $appointment['service_id'] ?? '',
662 - 'title' => $service['title'] ?? '',
663 - 'additional_duration' => $additional_duration,
664 - 'enable_google_meet' => $service['enable_google_meet'] ?? false,
665 - ),
666 - 'duration' => $appointment['duration'] ?? null,
667 - 'quantity' => $appointment['quantity'] ?? 1,
668 - 'price' => $appointment['price'] ?? 0,
669 - 'subtotal' => $appointment['subtotal'] ?? 0,
670 - 'total' => $appointment['total'] ?? 0,
671 - 'status' => $appointment['status'] ?? '',
672 - 'total_attendees' => $appointment['total_attendees'] ?? 1,
673 - 'appointment_token' => $appointment['security_token'] ?? '',
674 - 'cancel_note' => $appointment['cancel_note'] ?? '',
675 - );
676 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
677 - return $formatted_appointment;
713 + return Order_Notifier::format_appointment( $appointment );
678 714 }
679 715
680 716 /**
681 717 * Helper: Format order for API output
@@ -695,11 +731,14 @@
695 731 foreach ( $appointments as $appointment ) {
696 732 $appointment_details[] = $this->format_appointment( $appointment );
697 733 }
698 734
735 + $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
736 +
699 737 $order_data = array(
700 738 'id' => $order_id,
701 739 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
740 + 'order_key' => $this->get_order_view_key( (int) $order_id, (string) $order_no_for_key ),
702 741 'customer' => array(
703 742 'id' => $customer->id ?? null,
704 743 'name' => $customer->name ?? '',
705 744 'phone' => $customer->phone ?? '',
@@ -706,8 +745,14 @@
706 745 'email' => $customer->email ?? '',
707 746 ),
708 747 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
709 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 ),
710 755 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
711 756 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
712 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
713 758 'items' => $appointment_details,
@@ -712,8 +757,11 @@
712 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
713 758 'items' => $appointment_details,
714 759 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
715 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'] ?? '',
716 764 'total_services' => $input_data['total_services'] ?? 1,
717 765 );
718 766
719 767 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -723,8 +771,12 @@
723 771
724 772 /**
725 773 * Helper: Send notifications for order and appointment creation
726 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
727 779 * @param Order_Model $order_model
728 780 * @param int $customer_id
729 781 * @param array $appointments
730 782 *
@@ -730,54 +782,9 @@
730 782 *
731 783 * @return void
732 784 */
733 785 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
734 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
735 - foreach ( $appointments as $appointment ) {
736 - $formatted_appointment = $this->format_appointment( $appointment );
737 - $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
738 - $dt = new DateTime( $appointment_time, wp_timezone() );
739 - $timestamp = $dt->getTimestamp();
740 - $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
741 - $notification_flow_data_for_appointment = array(
742 - 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
743 - 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
744 - 'start_time' => $formatted_appointment['start_time'],
745 - 'appointment_date' => $formatted_appointment['date'],
746 - 'appointment_time' => $appointment_datetime,
747 - 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
748 - 'customer_name' => $customer->name ?? '',
749 - 'user_email' => $customer->email ?? '',
750 - 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
751 - );
752 - $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
753 -
754 - do_action( 'global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
755 - }
756 -
757 - $appointment = $appointments[0]; // As currently only one appointment is created for each user
758 - $formatted_appointment = $this->format_appointment( $appointment );
759 - $notification_flow_data_for_order = [
760 - 'order_no' => $order_model->order_no ?? '',
761 - 'order_date' => $order_model->created_at ?? '',
762 - 'order_total' => $order_model->total ?? 0,
763 - 'customer_name' => $customer->name ?? '',
764 - 'user_email' => $customer->email ?? '',
765 - 'order_status' => $order_model->status ?? '',
766 - 'service_name' => $formatted_appointment['service']['title'] ?? '',
767 - 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
768 - 'start_time' => $formatted_appointment['start_time'] ?? '',
769 - 'end_time' => $formatted_appointment['end_time'] ?? '',
770 - 'appointment_date' => $formatted_appointment['date'] ?? '',
771 - 'location_address' => $formatted_appointment['location']['address'] ?? '',
772 - 'appointment_status' => $formatted_appointment['status'] ?? '',
773 - 'appointment_cancel_url' => $appointment->get_cancel_link(),
774 - 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
775 - ];
776 -
777 - $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
778 -
779 - do_action( 'global_notification_hook', 'order_created', $notification_flow_data_for_order );
786 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
780 787 }
781 788
782 789 /**
783 790 * Find existing customer by email or create new customer
@@ -842,9 +849,9 @@
842 849 * @return object|null Guest_Model instance or null
843 850 */
844 851 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
845 852 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
846 - if ( $guest ) {
853 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
847 854 ( new Guest_Model( $db ) )->update(
848 855 $guest->id, array(
849 856 'name' => $customer['name'] ?? '',
850 857 'phone' => $customer['phone'] ?? '',
@@ -856,8 +863,40 @@
856 863 return $guest;
857 864 }
858 865
859 866 /**
867 + * Determine whether the current request is authorized to overwrite an
868 + * existing customer's stored contact details (name, phone, wp_user_id).
869 + *
870 + * @param object $guest Existing Guest_Model record.
871 + * @param string $email Submitted customer email.
872 + *
873 + * @return bool True when the caller may overwrite the stored record.
874 + */
875 + private function caller_owns_customer_email( $guest, $email ) {
876 + // Admin or team member managing a booking on the customer's behalf.
877 + if ( booktics_is_admin_or_team_member() ) {
878 + return true;
879 + }
880 +
881 + if ( ! is_user_logged_in() ) {
882 + return false;
883 + }
884 +
885 + // The record is already linked to the logged-in user.
886 + if ( ! empty( $guest->wp_user_id ) && intval( $guest->wp_user_id ) === get_current_user_id() ) {
887 + return true;
888 + }
889 +
890 + // The logged-in user's own account email matches the submitted email.
891 + $current_user = wp_get_current_user();
892 +
893 + return $current_user
894 + && ! empty( $current_user->user_email )
895 + && strtolower( $current_user->user_email ) === strtolower( (string) $email );
896 + }
897 +
898 + /**
860 899 * Create a new guest record
861 900 *
862 901 * @param Booktics_Database $db
863 902 * @param array $customer
@@ -878,16 +917,28 @@
878 917 );
879 918 }
880 919
881 920 /**
882 - * Check if user has permission to get orders.
921 + * Check if user has permission to list orders.
883 922 *
923 + * Anonymous access is denied: the collection handler scopes to the current customer or team member;
924 + * unauthenticated callers would otherwise receive unscoped results. Single-order GET is allowed for
925 + * guests when {@see get_item_permission()} validates a matching order_key.
926 + *
884 927 * @param WP_REST_Request $request
885 928 *
886 - * @return bool
929 + * @return bool|WP_Error
887 930 */
888 931 public function get_items_permission( $request ) {
889 - return true;
932 + if ( is_user_logged_in() ) {
933 + return true;
934 + }
935 +
936 + return new WP_Error(
937 + 'booktics_forbidden',
938 + __( 'You are not allowed to view orders.', 'booktics' ),
939 + array( 'status' => 403 )
940 + );
890 941 }
891 942
892 943 /**
893 944 * Get paginated list of orders.
@@ -896,16 +947,16 @@
896 947 *
897 948 * @return WP_Error|WP_HTTP_Response
898 949 */
899 950 public function get_items( $request ) {
900 - $page = (int) $request->get_param( 'paged' ) == 0 ? 1 : $request->get_param( 'paged' );
901 - $per_page = (int) $request->get_param( 'per_page' ) == 0 ? 10 : $request->get_param( 'per_page' );
902 - $payment_status = $request->get_param( 'payment_status' );
903 - $payment_method = $request->get_param( 'payment_method' );
904 - $order_date = $request->get_param( 'order_date' );
905 - $team_member = (int) $request->get_param( 'team_member' );
906 - $service = (int) $request->get_param( 'service' );
907 - $search = $request->get_param( 'search' );
951 + $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
952 + $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
953 + $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
954 + $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
955 + $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
956 + $team_member = absint( $request->get_param( 'team_member' ) );
957 + $service = absint( $request->get_param( 'service' ) );
958 + $search = sanitize_text_field( $request->get_param( 'search' ) );
908 959
909 960 $db = new Booktics_Database();
910 961 $order_model = new Order_Model( $db );
911 962 $conditions = array();
@@ -927,8 +978,34 @@
927 978 }
928 979
929 980 $conditions = apply_filters( 'booktics_order_filters', $conditions );
930 981
982 + // Scope to customer's own orders when not admin/team-member
983 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
984 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
985 + if ( ! $guest ) {
986 + return $this->response(
987 + array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
988 + __( 'Orders fetched successfully', 'booktics' )
989 + );
990 + }
991 + $conditions['customer_id'] = $guest->id;
992 + }
993 +
994 + // Scope to team member's assigned orders - only return orders that contain
995 + // at least one appointment where team_member_id equals the current user's team member ID
996 + $is_team_member_scope = false;
997 + $team_member_filter_id = null;
998 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
999 + $current_user_id = get_current_user_id();
1000 + $team_member_model = new Team_Member_Model();
1001 + $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1002 + if ( $team_member ) {
1003 + $is_team_member_scope = true;
1004 + $team_member_filter_id = $team_member->id;
1005 + }
1006 + }
1007 +
931 1008 $orders = $order_model->get(
932 1009 $conditions, $page, $per_page, array(
933 1010 'created_at',
934 1011 'DESC',
@@ -960,15 +1037,27 @@
960 1037 }
961 1038
962 1039 foreach ( $orders['items'] as &$order ) {
963 1040 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
964 - $order->customer = array(
1041 + $customer_data_for_order = [
965 1042 'id' => $customer ? $customer->id : null,
966 - 'email' => $customer ? $customer->email : '',
967 1043 'name' => $customer ? $customer->name : '',
968 - 'phone' => $customer ? $customer->phone : '',
969 - );
1044 + 'email' => '',
1045 + 'phone' => '',
1046 + ];
1047 + // Admins and team members see email and phone
1048 + if ( booktics_is_admin_or_team_member() ) {
1049 + $customer_data_for_order['email'] = $customer ? $customer->email : '';
1050 + $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1051 + }
1052 + $order->customer = $customer_data_for_order;
970 1053 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1054 +
1055 + // Hide payment intent fields from team members (not admins)
1056 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1057 + $order->payment_intent_id = '';
1058 + $order->client_secret = '';
1059 + }
971 1060 }
972 1061
973 1062 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
974 1063 }
@@ -991,9 +1080,9 @@
991 1080 *
992 1081 * @return WP_Error|WP_HTTP_Response
993 1082 */
994 1083 public function update_item( $request ) {
995 - $order_id = (int) $request->get_param( 'id' );
1084 + $order_id = absint( $request->get_param( 'id' ) );
996 1085 $data = json_decode( $request->get_body(), true );
997 1086 $db = new Booktics_Database();
998 1087 $order_model = new Order_Model( $db );
999 1088 $order = $order_model->find( array( 'id' => $order_id ) );
@@ -1001,24 +1090,50 @@
1001 1090 if ( ! $order ) {
1002 1091 return $this->error( __( 'Order not found', 'booktics' ) );
1003 1092 }
1004 1093
1094 + $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1095 + $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1096 +
1005 1097 $order_model->update(
1006 1098 $order_id, array(
1007 - 'status' => $data['status'] ?? $order->status,
1008 - 'payment_status' => $data['payment_status'] ?? $order->payment_status,
1099 + 'status' => $status,
1100 + 'payment_status' => $payment_status,
1009 1101 )
1010 1102 );
1011 1103
1012 - if ( isset( $data['customer'] ) && isset( $data['customer']['id']) ) {
1013 - ( new Guest_Model( $db ) )->update( $data['customer']['id'], $data['customer'] );
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 );
1014 1107 }
1015 1108
1109 + if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1110 + $customer_id = absint( $data['customer']['id'] );
1111 + $customer_data = $data['customer'];
1112 + $customer_data['id'] = $customer_id;
1113 + if ( isset( $customer_data['name'] ) ) {
1114 + $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1115 + }
1116 + if ( isset( $customer_data['email'] ) ) {
1117 + $customer_data['email'] = sanitize_email( $customer_data['email'] );
1118 + }
1119 + if ( isset( $customer_data['phone'] ) ) {
1120 + $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1121 + }
1122 + ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1123 + }
1124 +
1016 1125 $order_model = $order_model->find( array( 'id' => $order_id ) );
1017 1126 $data['customer_id'] = $order_model->customer_id;
1018 1127
1019 1128 $response = $this->format_order_response($db, $order_model, $data, null);
1020 1129
1130 + // Hide payment intent fields from team members (not admins)
1131 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1132 + $response['payment_intent_id'] = '';
1133 + $response['client_secret'] = '';
1134 + }
1135 +
1021 1136 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1022 1137 }
1023 1138
1024 1139 /**
@@ -1023,14 +1138,70 @@
1023 1138
1024 1139 /**
1025 1140 * Check if user has permission to get order.
1026 1141 *
1142 + * Staff always allowed. Logged-in customers allowed when the order belongs to their guest record.
1143 + * Anonymous users allowed only when the request includes a valid order_key matching the order (same
1144 + * value returned when the order was created).
1145 + *
1027 1146 * @param WP_REST_Request $request
1028 1147 *
1148 + * @return bool|WP_Error
1149 + */
1150 + public function get_item_permission( $request ) {
1151 + if ( booktics_is_admin_or_team_member() ) {
1152 + return true;
1153 + }
1154 +
1155 + $order_id = absint( $request->get_param( 'id' ) );
1156 + $db = new Booktics_Database();
1157 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1158 +
1159 + if ( ! $order ) {
1160 + return true;
1161 + }
1162 +
1163 + if ( is_user_logged_in() ) {
1164 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1165 +
1166 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1167 + return true;
1168 + }
1169 + }
1170 +
1171 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1172 + if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1173 + return true;
1174 + }
1175 +
1176 + return new WP_Error(
1177 + 'booktics_forbidden',
1178 + __( 'You are not allowed to view this order.', 'booktics' ),
1179 + array( 'status' => 403 )
1180 + );
1181 + }
1182 +
1183 + /**
1184 + * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1185 + *
1186 + * @param int $order_id
1187 + * @param string $order_no
1188 + *
1189 + * @return string
1190 + */
1191 + private function get_order_view_key( $order_id, $order_no ) {
1192 + return Order_Key::make( $order_id, $order_no );
1193 + }
1194 +
1195 + /**
1196 + * @param int $order_id
1197 + * @param string $order_no
1198 + * @param string $provided_key
1199 + *
1029 1200 * @return bool
1030 1201 */
1031 - public function get_item_permission( $request ) {
1032 - return true;
1202 + private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1203 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1033 1204 }
1034 1205
1035 1206 /**
1036 1207 * Get single order.
@@ -1039,9 +1210,9 @@
1039 1210 *
1040 1211 * @return WP_Error|WP_HTTP_Response
1041 1212 */
1042 1213 public function get_item( $request ) {
1043 - $order_id = (int) $request->get_param( 'id' );
1214 + $order_id = absint( $request->get_param( 'id' ) );
1044 1215 $db = new Booktics_Database();
1045 1216 $order_model = new Order_Model( $db );
1046 1217 $order = $order_model->find( array( 'id' => $order_id ) );
1047 1218
@@ -1053,11 +1224,23 @@
1053 1224 $input_data = array( 'customer_id' => $order->customer_id );
1054 1225 $response = $this->format_order_response( $db, $order, $input_data, array() );
1055 1226 // Add extra fields specific to GET if needed
1056 1227 $response['status'] = $order->status;
1057 - $response['created_at'] = $order->created_at;
1228 + $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1058 1229 $response['payment_gateway'] = $order->payment_method;
1230 + $response['payment_method'] = $order->payment_method;
1059 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 +
1237 + // Hide payment intent fields from team members (not admins)
1238 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1239 + $response['payment_intent_id'] = '';
1240 + $response['client_secret'] = '';
1241 + }
1242 +
1060 1243 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1061 1244 }
1062 1245
1063 1246 /**
@@ -1067,9 +1250,28 @@
1067 1250 *
1068 1251 * @return bool
1069 1252 */
1070 1253 public function update_payment_status_permission( $request ) {
1071 - return current_user_can( 'manage_options' );
1254 + if ( current_user_can( 'manage_options' ) ) {
1255 + return true;
1256 + }
1257 +
1258 + // Allow order owner with a valid order_key (guest checkout support).
1259 + $order_id = absint( $request->get_param( 'id' ) );
1260 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1261 +
1262 + if ( ! $order_id || ! $order_key ) {
1263 + return false;
1264 + }
1265 +
1266 + $db = new Booktics_Database();
1267 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1268 +
1269 + if ( ! $order ) {
1270 + return false;
1271 + }
1272 +
1273 + return $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key );
1072 1274 }
1073 1275
1074 1276 /**
1075 1277 * Update payment status for an order.
@@ -1078,13 +1280,16 @@
1078 1280 *
1079 1281 * @return WP_Error|WP_HTTP_Response
1080 1282 */
1081 1283 public function update_payment_status( $request ) {
1082 - $data = json_decode( $request->get_body(), true );
1083 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1084 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1085 - $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();
1086 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 +
1087 1292 $db = new Booktics_Database();
1088 1293 $order_model = new Order_Model( $db );
1089 1294 $order = $order_model->find( array( 'id' => $order_id ) );
1090 1295
@@ -1091,8 +1296,14 @@
1091 1296 if ( ! $order ) {
1092 1297 return $this->error( __( 'Order not found', 'booktics' ) );
1093 1298 }
1094 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 +
1095 1306 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1096 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1097 1308 }
1098 1309
@@ -1109,12 +1320,31 @@
1109 1320
1110 1321 $order_model->update(
1111 1322 $order_id, array(
1112 1323 'payment_status' => $status,
1113 - 'transaction_id' => $data['transaction_id'] ?? null,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1114 1325 )
1115 1326 );
1116 1327
1328 + if (
1329 + $status === Payment_Status::COMPLETED
1330 + && $order->payment_status !== Payment_Status::COMPLETED
1331 + ) {
1332 + // The payment status is already persisted above. Sending notifications must
1333 + // not fail the request, otherwise a confirmed booking shows an error toast.
1334 + try {
1335 + $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1336 + $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1337 + } catch ( \Throwable $e ) {
1338 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1339 + }
1340 + } elseif ( $status === Payment_Status::FAILED ) {
1341 + // Payment was declined/canceled on the processor. Release the held slot
1342 + // immediately by canceling the pending appointments for this order, so the
1343 + // customer does not have to wait for the admin_init sweep.
1344 + ( new Abandoned_Booking_Cleanup() )->release_appointments( $order_id );
1345 + }
1346 +
1117 1347 // Payment model status and transaction id update
1118 1348 return $this->response(
1119 1349 array(
1120 1350 'payment_status' => $status,
@@ -1120,9 +1350,11 @@
1120 1350 'payment_status' => $status,
1121 1351 ), __( 'Payment status updated successfully', 'booktics' )
1122 1352 );
1123 1353 } catch ( \Exception $e ) {
1124 - return $this->error( $e->getMessage() );
1354 +
1355 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1356 + return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1125 1357 }
1126 1358 }
1127 1359
1128 1360 /**
@@ -1143,9 +1375,9 @@
1143 1375 *
1144 1376 * @return WP_Error|WP_HTTP_Response
1145 1377 */
1146 1378 public function delete_item( $request ) {
1147 - $order_id = (int) $request->get_param( 'id' );
1379 + $order_id = absint( $request->get_param( 'id' ) );
1148 1380 $db = new Booktics_Database();
1149 1381 $order_model = new Order_Model( $db );
1150 1382 $order = $order_model->find( array( 'id' => $order_id ) );
1151 1383
@@ -1200,9 +1432,10 @@
1200 1432 *
1201 1433 * @return WP_Error|WP_HTTP_Response
1202 1434 */
1203 1435 public function bulk_delete_items( $request ) {
1204 - $order_ids = array_map( 'intval', $request->get_param( 'ids' ) );
1436 + $ids_param = $request->get_param( 'ids' );
1437 + $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1205 1438 $db = new Booktics_Database();
1206 1439
1207 1440 try {
1208 1441 $db->begin_transaction();
@@ -1243,8 +1476,24 @@
1243 1476 }
1244 1477 }
1245 1478
1246 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 + /**
1247 1496 * Process payment refund for an order.
1248 1497 *
1249 1498 * @param WP_REST_Request $request
1250 1499 *
@@ -1250,22 +1499,34 @@
1250 1499 *
1251 1500 * @return WP_Error|WP_HTTP_Response
1252 1501 */
1253 1502 public function refund_payment( $request ) {
1254 - $order_id = (int) $request->get_param( 'id' );
1503 + $order_id = absint( $request->get_param( 'id' ) );
1255 1504 $db = new Booktics_Database();
1256 1505 $order_model = new Order_Model( $db );
1257 1506 $order = $order_model->find( array( 'id' => $order_id ) );
1258 1507
1508 + if ( ! $order || is_wp_error( $order ) ) {
1509 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1510 + }
1511 +
1259 1512 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1260 1513 if ( ! $payment_method ) {
1261 1514 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1262 1515 }
1263 1516 $response = $payment_method->process_refund( $order );
1264 - if ( $response['status'] === 'error' ) {
1265 - 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() );
1266 1523 }
1267 1524
1525 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1526 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1527 + }
1528 +
1268 1529 $order_model->update(
1269 1530 $order_id, array(
1270 1531 'payment_status' => Payment_Status::REFUNDED,
1271 1532 )
@@ -1307,6 +1568,19 @@
1307 1568
1308 1569 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1309 1570
1310 1571 return $total_duration_min;
1572 + }
1573 +
1574 + /**
1575 + * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1576 + *
1577 + * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1578 + *
1579 + * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1580 + */
1581 + private function format_datetime_to_dayjs_format( $datetime ) {
1582 + $dt = new DateTime( $datetime );
1583 + $dt->setTimezone( wp_timezone() );
1584 + return $dt->format( 'Y-m-d H:i:s' );
1311 1585 }
1312 1586 }