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 +354 -255 1.0.18 → 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;
@@ -154,9 +158,9 @@
154 158 'methods' => WP_REST_Server::EDITABLE,
155 159 'callback' => array( $this, 'refund_payment' ),
156 160 'permission_callback' => array(
157 161 $this,
158 - 'update_payment_status_permission',
162 + 'refund_payment_permission',
159 163 ),
160 164 ),
161 165 )
162 166 );
@@ -214,12 +218,14 @@
214 218 $db->roll_back();
215 219 return $this->error( $e->getMessage() );
216 220 }
217 221
218 - $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 + }
219 225 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
220 226 do_action('booktics_after_order_created', $order_model, $appointments);
221 -
227 +
222 228 $response_message = 'Order created successfully';
223 229 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
224 230 $response_message = 'Order pending, please proceed payment';
225 231 }
@@ -265,8 +271,22 @@
265 271 );
266 272 if ( is_wp_error( $validate ) ) {
267 273 return $validate;
268 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 + }
269 289 }
270 290
271 291 // Step 4: Process and validate each item
272 292 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
@@ -274,18 +294,32 @@
274 294 }
275 295
276 296 foreach ( $input_data['items'] as $key => $item ) {
277 297 // Validate required fields for each item
278 - $item_validate = booktics_validate(
279 - $item, array(
280 - 'date' => array( 'required' ),
281 - 'start_time' => array( 'required' ),
282 - 'end_time' => array( 'required' ),
283 - 'team_member_id' => array( 'required' ),
284 - 'service_id' => array( 'required' ),
285 - )
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' ),
286 304 );
287 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 +
288 322 if ( is_wp_error( $item_validate ) ) {
289 323 return $item_validate;
290 324 }
291 325
@@ -308,19 +342,18 @@
308 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
309 343 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
310 344 }
311 345
312 - // In validate_and_prepare_order_data(), after converting times:
313 - $item_with_converted_times = array_merge($item, [
314 - 'start_time' => $converted_start_time['time'],
315 - 'end_time' => $converted_end_time['time'],
316 - 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
317 - ]);
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 );
318 350
319 - $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
320 -
321 - if ( is_wp_error( $item_timet_slot_validate ) ) {
322 - 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;
323 356 }
324 357
325 358 // Sanitize and prepare item data
326 359 $input_data['items'][ $key ] = array(
@@ -329,17 +362,32 @@
329 362 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
330 363 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
331 364 'team_member_id' => absint( $item['team_member_id'] ),
332 365 'service_id' => absint( $item['service_id'] ),
333 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
334 - 'price' => floatval( $item['price'] ?? 0 ),
335 - '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'],
336 370 'quantity' => absint( $item['quantity'] ?? 1 ),
337 - 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
371 + 'total_attendees' => $attendees,
338 372 );
339 373 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
340 374 }
341 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 +
342 390 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
343 391
344 392 return $input_data;
345 393 }
@@ -344,131 +392,120 @@
344 392 return $input_data;
345 393 }
346 394
347 395 /**
348 - * Check if the requested time slot has available capacity for the given team member
349 - * Works by checking all appointments for this team member on this date and calculating remaining capacity
350 - *
351 - * @param array $item Appointment item data
352 - * @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
353 406 */
354 - private function validate_appointment_time_is_free( $item ) {
355 - try {
356 - // Convert times to DateTime objects
357 - $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
358 - $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
359 -
360 - // Get the requested attendee count (default to 1 for backward compatibility)
361 - $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
362 -
363 - // Get service to check if group booking is enabled and get max capacity
364 - $service = Service_Model::find($item['service_id']);
365 - if ( ! $service ) {
366 - return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
367 - }
368 -
369 - $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();
370 409
371 -
372 - $args = [
373 - 'team_member_id' => $item['team_member_id'],
374 - 'date' => $item['date'],
375 - 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
376 - ];
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
377 414
378 - // Get all non-cancelled appointments for this team member on this date
379 - $appointments = Appointment_Model::all( $args );
415 + return max( 1, absint( $attendees ) );
416 + }
380 417
381 - if ( ! is_array( $appointments ) || empty( $appointments ) ) {
382 - // No existing appointments, check if requested attendees fit within max capacity
383 - if ( $requested_attendees > $max_group_size ) {
384 - /* translators: %1$d = requested attendees, %2$d = maximum group size */
385 - return new \WP_Error( 'exceeds_max_capacity', sprintf( __( 'Requested attendees (%1$d) exceeds maximum group size (%2$d) for this service.', 'booktics' ), $requested_attendees, $max_group_size ) );
386 - }
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 );
387 429
388 - return true;
389 - }
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
432 + }
390 433
391 - // Calculate total attendees for overlapping time slots
392 - $overlapping_attendees = 0;
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
393 435
394 - foreach ( $appointments as $appointment ) {
395 - // Skip if this is the same appointment (for updates)
396 - if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
397 - continue;
398 - }
399 -
400 - $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
401 - $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 + }
402 439
403 - // Check for time overlap
404 - if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
405 - // Times overlap, add this appointment's attendees to the count
406 - $appointment_attendees = $appointment->total_attendees ?? 1;
407 - $overlapping_attendees += $appointment_attendees;
408 - }
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;
409 447 }
410 -
411 - // Check if adding the requested attendees would exceed capacity
412 - $total_after_booking = $overlapping_attendees + $requested_attendees;
413 -
414 - if ($total_after_booking > $max_group_size) {
415 - return new \WP_Error( 'insufficient_capacity', __( 'Insufficient 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' ) );
416 455 }
456 + $selected = reset( $durations );
457 + }
417 458
418 - return true;
419 - } catch ( \Throwable $e ) {
420 - return new \WP_Error(
421 - 'time_validation_error',
422 - __( 'Error validating appointment time. Please try another slot.', 'booktics' )
423 - );
424 - }
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 + );
425 471 }
426 472
427 473 /**
428 - * Get max group size for a specific service
429 - *
430 - * @param int $service_id Service ID
431 - *
432 - * @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
433 483 */
434 - private function get_service_max_group_size( $service_id ) {
435 - $default_max_size = 1;
436 - $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
437 -
438 - if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
439 - 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;
440 489 }
441 490
442 - 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 );
443 496 }
444 -
497 +
445 498 /**
446 499 * Checks if group booking is enabled for a services group booking meta data
447 - *
500 + *
448 501 * @param array $group_booking_meta Group booking meta data
449 - *
502 + *
450 503 * @return bool True if group booking is enabled, false otherwise
451 504 */
452 505 private function is_group_booking_enabled( $group_booking_meta ) {
453 506 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
454 507 }
455 -
456 - /**
457 - * Extracts the max booking size from the group booking meta data of a service
458 - *
459 - * @param array $group_booking_meta Group booking meta data
460 - * @param int $default_size Default max booking size
461 - *
462 - * @return int Max booking size
463 - */
464 - private function extract_max_booking_size( $group_booking_meta, $default_size ) {
465 - if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
466 - return (int) $group_booking_meta['max_booking'];
467 - }
468 -
469 - return $default_size;
470 - }
471 508
472 509 /**
473 510 * Resolve the customer ID from input data, creating or finding the customer as needed
474 511 *
@@ -495,9 +532,11 @@
495 532 *
496 533 * @return array [Order_Model $order_model, array $appointments]
497 534 */
498 535 private function create_order_and_appointments( $input_data, $db ) {
499 - $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
536 + // The total is priced server side in the store currency, so the order is
537 + // too. A buyer-sent currency would relabel 100 USD as 100 KRW at Stripe.
538 + $input_data['currency'] = booktics_get_store_currency();
500 539 $order_model = new Order_Model( $db );
501 540
502 541 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
503 542 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
@@ -531,16 +570,25 @@
531 570 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
532 571 }
533 572 $total_duration_min = $this->calculate_total_duration( $item );
534 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 +
535 583 $appointment_data = array(
536 - 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
537 - 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
538 - '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' ),
539 587 'customer_id' => $input_data['customer_id'] ?? '',
540 588 'team_member_id' => $item['team_member_id'] ?? '',
541 589 'service_id' => $item['service_id'] ?? '',
542 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
590 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
543 591 'duration' => $total_duration_min,
544 592 'order_id' => $order_model->id,
545 593 'quantity' => $item['quantity'] ?? 1,
546 594 'price' => $item['price'] ?? 0,
@@ -546,16 +594,21 @@
546 594 'price' => $item['price'] ?? 0,
547 595 'subtotal' => $item['subtotal'] ?? 0,
548 596 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
549 597 'status' => booktics_get_option( 'appointment_status', 'pending' ),
550 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
551 599 'timezone' => $original_timezone,
552 600 );
553 -
554 -
555 -
601 +
602 +
603 +
556 604 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
557 -
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 +
558 611 $appointment = Appointment_Model::create( $appointment_data );
559 612 if ( is_wp_error( $appointment ) ) {
560 613 throw new Exception( esc_html( $appointment->get_error_message() ) );
561 614 }
@@ -596,12 +649,10 @@
596 649 }
597 650
598 651 if ( $payment_intent['status'] === 'success' ) {
599 652 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
600 -
601 - // Update orders payment status on successfull payment completion
602 - $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
603 - ( 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.
604 655 }
605 656
606 657 return $payment_intent;
607 658 }
@@ -619,9 +670,9 @@
619 670 */
620 671 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
621 672 ( new Order_Model( $db ) )->update(
622 673 $order_model->id,
623 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
674 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
624 675 );
625 676
626 677 ( new Payment_Model( $db ) )->create(
627 678 array(
@@ -626,11 +677,11 @@
626 677 ( new Payment_Model( $db ) )->create(
627 678 array(
628 679 'order_id' => $order_model->id,
629 680 'customer_id' => $input_data['customer_id'],
630 - 'amount' => $input_data['total'] * 100,
631 - 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
632 - '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,
633 684 'transaction_id' => null,
634 685 'payment_method' => $input_data['payment_method'],
635 686 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
636 687 'date' => gmdate( 'Y-m-d' ),
@@ -639,8 +690,20 @@
639 690 );
640 691 }
641 692
642 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 + /**
643 706 * Helper: Format a single appointment for API output
644 707 *
645 708 * @param Appointment_Model|array $appointment
646 709 *
@@ -646,52 +709,9 @@
646 709 *
647 710 * @return array
648 711 */
649 712 private function format_appointment( $appointment ): array {
650 - if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
651 - $appointment = $appointment->to_array();
652 - }
653 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
654 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
655 - $service = $service ? $service->to_array() : array();
656 - $additional_durations = $service['additional_durations'] ?? array();
657 - $additional_durations = ! empty( $additional_durations )
658 - ? array_filter(
659 - $additional_durations, function ( $duration ) use ( $appointment ) {
660 - return $duration['id'] === $appointment['additional_duration_id'];
661 - }
662 - ) : array();
663 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
664 - $formatted_appointment = array(
665 - 'id' => $appointment['id'] ?? 0,
666 - 'date' => $appointment['date'] ?? '',
667 - 'start_time' => $appointment['start_time'] ?? '',
668 - 'end_time' => $appointment['end_time'] ?? '',
669 - 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
670 - 'team' => array(
671 - 'id' => $team_member->id ?? null,
672 - 'display_name' => $team_member->display_name ?? '',
673 - 'image' => $team_member->image ?? '',
674 - 'user_email' => $team_member->user_email ?? '',
675 - ),
676 - 'service' => array(
677 - 'id' => $appointment['service_id'] ?? '',
678 - 'title' => $service['title'] ?? '',
679 - 'additional_duration' => $additional_duration,
680 - 'enable_google_meet' => $service['enable_google_meet'] ?? false,
681 - ),
682 - 'duration' => $appointment['duration'] ?? null,
683 - 'quantity' => $appointment['quantity'] ?? 1,
684 - 'price' => $appointment['price'] ?? 0,
685 - 'subtotal' => $appointment['subtotal'] ?? 0,
686 - 'total' => $appointment['total'] ?? 0,
687 - 'status' => $appointment['status'] ?? '',
688 - 'total_attendees' => $appointment['total_attendees'] ?? 1,
689 - 'appointment_token' => $appointment['security_token'] ?? '',
690 - 'cancel_note' => $appointment['cancel_note'] ?? '',
691 - );
692 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
693 - return $formatted_appointment;
713 + return Order_Notifier::format_appointment( $appointment );
694 714 }
695 715
696 716 /**
697 717 * Helper: Format order for API output
@@ -725,8 +745,14 @@
725 745 'email' => $customer->email ?? '',
726 746 ),
727 747 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
728 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 ),
729 755 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
730 756 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
731 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
732 758 'items' => $appointment_details,
@@ -731,8 +757,11 @@
731 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
732 758 'items' => $appointment_details,
733 759 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
734 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'] ?? '',
735 764 'total_services' => $input_data['total_services'] ?? 1,
736 765 );
737 766
738 767 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -742,8 +771,12 @@
742 771
743 772 /**
744 773 * Helper: Send notifications for order and appointment creation
745 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
746 779 * @param Order_Model $order_model
747 780 * @param int $customer_id
748 781 * @param array $appointments
749 782 *
@@ -749,54 +782,9 @@
749 782 *
750 783 * @return void
751 784 */
752 785 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
753 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
754 - foreach ( $appointments as $appointment ) {
755 - $formatted_appointment = $this->format_appointment( $appointment );
756 - $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
757 - $dt = new DateTime( $appointment_time, wp_timezone() );
758 - $timestamp = $dt->getTimestamp();
759 - $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
760 - $notification_flow_data_for_appointment = array(
761 - 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
762 - 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
763 - 'start_time' => $formatted_appointment['start_time'],
764 - 'appointment_date' => $formatted_appointment['date'],
765 - 'appointment_time' => $appointment_datetime,
766 - 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
767 - 'customer_name' => $customer->name ?? '',
768 - 'user_email' => $customer->email ?? '',
769 - 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
770 - );
771 - $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
772 -
773 - do_action( 'global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
774 - }
775 -
776 - $appointment = $appointments[0]; // As currently only one appointment is created for each user
777 - $formatted_appointment = $this->format_appointment( $appointment );
778 - $notification_flow_data_for_order = [
779 - 'order_no' => $order_model->order_no ?? '',
780 - 'order_date' => $order_model->created_at ?? '',
781 - 'order_total' => $order_model->total ?? 0,
782 - 'customer_name' => $customer->name ?? '',
783 - 'user_email' => $customer->email ?? '',
784 - 'order_status' => $order_model->status ?? '',
785 - 'service_name' => $formatted_appointment['service']['title'] ?? '',
786 - 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
787 - 'start_time' => $formatted_appointment['start_time'] ?? '',
788 - 'end_time' => $formatted_appointment['end_time'] ?? '',
789 - 'appointment_date' => $formatted_appointment['date'] ?? '',
790 - 'location_address' => $formatted_appointment['location']['address'] ?? '',
791 - 'appointment_status' => $formatted_appointment['status'] ?? '',
792 - 'appointment_cancel_url' => $appointment->get_cancel_link(),
793 - 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
794 - ];
795 -
796 - $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
797 -
798 - do_action( 'global_notification_hook', 'order_created', $notification_flow_data_for_order );
786 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
799 787 }
800 788
801 789 /**
802 790 * Find existing customer by email or create new customer
@@ -861,9 +849,9 @@
861 849 * @return object|null Guest_Model instance or null
862 850 */
863 851 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
864 852 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
865 - if ( $guest ) {
853 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
866 854 ( new Guest_Model( $db ) )->update(
867 855 $guest->id, array(
868 856 'name' => $customer['name'] ?? '',
869 857 'phone' => $customer['phone'] ?? '',
@@ -875,8 +863,40 @@
875 863 return $guest;
876 864 }
877 865
878 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 + /**
879 899 * Create a new guest record
880 900 *
881 901 * @param Booktics_Database $db
882 902 * @param array $customer
@@ -1030,9 +1050,9 @@
1030 1050 $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1031 1051 }
1032 1052 $order->customer = $customer_data_for_order;
1033 1053 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1034 -
1054 +
1035 1055 // Hide payment intent fields from team members (not admins)
1036 1056 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1037 1057 $order->payment_intent_id = '';
1038 1058 $order->client_secret = '';
@@ -1080,8 +1100,13 @@
1080 1100 'payment_status' => $payment_status,
1081 1101 )
1082 1102 );
1083 1103
1104 + if ( $status !== $order->status ) {
1105 + /** This action is documented in core/appointment/services/appointment-service.php */
1106 + do_action( 'booktics_order_status_updated', $order_id, $status, $order->status );
1107 + }
1108 +
1084 1109 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1085 1110 $customer_id = absint( $data['customer']['id'] );
1086 1111 $customer_data = $data['customer'];
1087 1112 $customer_data['id'] = $customer_id;
@@ -1163,9 +1188,9 @@
1163 1188 *
1164 1189 * @return string
1165 1190 */
1166 1191 private function get_order_view_key( $order_id, $order_no ) {
1167 - return hash_hmac( 'sha256', (string) $order_id . '|' . $order_no, wp_salt( 'booktics_order_view' ) );
1192 + return Order_Key::make( $order_id, $order_no );
1168 1193 }
1169 1194
1170 1195 /**
1171 1196 * @param int $order_id
@@ -1174,15 +1199,9 @@
1174 1199 *
1175 1200 * @return bool
1176 1201 */
1177 1202 private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1178 - if ( '' === $provided_key || strlen( $provided_key ) > 128 ) {
1179 - return false;
1180 - }
1181 -
1182 - $expected = $this->get_order_view_key( $order_id, $order_no );
1183 -
1184 - return hash_equals( $expected, $provided_key );
1203 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1185 1204 }
1186 1205
1187 1206 /**
1188 1207 * Get single order.
@@ -1207,9 +1226,15 @@
1207 1226 // Add extra fields specific to GET if needed
1208 1227 $response['status'] = $order->status;
1209 1228 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1210 1229 $response['payment_gateway'] = $order->payment_method;
1230 + $response['payment_method'] = $order->payment_method;
1211 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 +
1212 1237 // Hide payment intent fields from team members (not admins)
1213 1238 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1214 1239 $response['payment_intent_id'] = '';
1215 1240 $response['client_secret'] = '';
@@ -1225,9 +1250,28 @@
1225 1250 *
1226 1251 * @return bool
1227 1252 */
1228 1253 public function update_payment_status_permission( $request ) {
1229 - 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 );
1230 1274 }
1231 1275
1232 1276 /**
1233 1277 * Update payment status for an order.
@@ -1236,13 +1280,16 @@
1236 1280 *
1237 1281 * @return WP_Error|WP_HTTP_Response
1238 1282 */
1239 1283 public function update_payment_status( $request ) {
1240 - $data = json_decode( $request->get_body(), true );
1241 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1242 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1243 - $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();
1244 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 +
1245 1292 $db = new Booktics_Database();
1246 1293 $order_model = new Order_Model( $db );
1247 1294 $order = $order_model->find( array( 'id' => $order_id ) );
1248 1295
@@ -1249,8 +1296,14 @@
1249 1296 if ( ! $order ) {
1250 1297 return $this->error( __( 'Order not found', 'booktics' ) );
1251 1298 }
1252 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 +
1253 1306 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1254 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1255 1308 }
1256 1309
@@ -1267,12 +1320,31 @@
1267 1320
1268 1321 $order_model->update(
1269 1322 $order_id, array(
1270 1323 'payment_status' => $status,
1271 - 'transaction_id' => $data['transaction_id'] ?? null,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1272 1325 )
1273 1326 );
1274 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 +
1275 1347 // Payment model status and transaction id update
1276 1348 return $this->response(
1277 1349 array(
1278 1350 'payment_status' => $status,
@@ -1278,13 +1350,11 @@
1278 1350 'payment_status' => $status,
1279 1351 ), __( 'Payment status updated successfully', 'booktics' )
1280 1352 );
1281 1353 } catch ( \Exception $e ) {
1282 -
1283 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1284 - error_log( 'An error occurred while deleting orders:' . $e->getMessage() );
1285 - }
1286 - return $this->error( __( 'An error occurred while deleting orders.', 'booktics' ) );
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' ) );
1287 1357 }
1288 1358 }
1289 1359
1290 1360 /**
@@ -1406,8 +1476,24 @@
1406 1476 }
1407 1477 }
1408 1478
1409 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 + /**
1410 1496 * Process payment refund for an order.
1411 1497 *
1412 1498 * @param WP_REST_Request $request
1413 1499 *
@@ -1418,17 +1504,29 @@
1418 1504 $db = new Booktics_Database();
1419 1505 $order_model = new Order_Model( $db );
1420 1506 $order = $order_model->find( array( 'id' => $order_id ) );
1421 1507
1508 + if ( ! $order || is_wp_error( $order ) ) {
1509 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1510 + }
1511 +
1422 1512 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1423 1513 if ( ! $payment_method ) {
1424 1514 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1425 1515 }
1426 1516 $response = $payment_method->process_refund( $order );
1427 - if ( $response['status'] === 'error' ) {
1428 - 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() );
1429 1523 }
1430 1524
1525 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1526 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1527 + }
1528 +
1431 1529 $order_model->update(
1432 1530 $order_id, array(
1433 1531 'payment_status' => Payment_Status::REFUNDED,
1434 1532 )
@@ -1480,8 +1578,9 @@
1480 1578 *
1481 1579 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1482 1580 */
1483 1581 private function format_datetime_to_dayjs_format( $datetime ) {
1484 - $dt = new DateTime( $datetime, wp_timezone() );
1485 - return $dt->format( 'c' );
1582 + $dt = new DateTime( $datetime );
1583 + $dt->setTimezone( wp_timezone() );
1584 + return $dt->format( 'Y-m-d H:i:s' );
1486 1585 }
1487 1586 }