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 +152 -131 1.0.24 → 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;
@@ -293,18 +294,32 @@
293 294 }
294 295
295 296 foreach ( $input_data['items'] as $key => $item ) {
296 297 // Validate required fields for each item
297 - $item_validate = booktics_validate(
298 - $item, 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' ),
304 - )
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' ),
305 304 );
306 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 +
307 322 if ( is_wp_error( $item_validate ) ) {
308 323 return $item_validate;
309 324 }
310 325
@@ -327,19 +342,18 @@
327 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
328 343 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
329 344 }
330 345
331 - // In validate_and_prepare_order_data(), after converting times:
332 - $item_with_converted_times = array_merge($item, [
333 - 'start_time' => $converted_start_time['time'],
334 - 'end_time' => $converted_end_time['time'],
335 - 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
336 - ]);
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 );
337 350
338 - $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
339 -
340 - if ( is_wp_error( $item_timet_slot_validate ) ) {
341 - 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;
342 356 }
343 357
344 358 // Sanitize and prepare item data
345 359 $input_data['items'][ $key ] = array(
@@ -349,16 +363,31 @@
349 363 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
350 364 'team_member_id' => absint( $item['team_member_id'] ),
351 365 'service_id' => absint( $item['service_id'] ),
352 366 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
353 - 'price' => floatval( $item['price'] ?? 0 ),
354 - 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
367 + 'price' => $amount['price'],
368 + 'subtotal' => $amount['total'],
369 + 'total' => $amount['total'],
355 370 'quantity' => absint( $item['quantity'] ?? 1 ),
356 - 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
371 + 'total_attendees' => $attendees,
357 372 );
358 373 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
359 374 }
360 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 +
361 390 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
362 391
363 392 return $input_data;
364 393 }
@@ -363,112 +392,108 @@
363 392 return $input_data;
364 393 }
365 394
366 395 /**
367 - * Check if the requested time slot has available capacity for the given team member
368 - * Works by checking all appointments for this team member on this date and calculating remaining capacity
396 + * Resolve how many attendees a single booking line is for.
369 397 *
370 - * @param array $item Appointment item data
371 - * @return bool|WP_Error True if time slot has capacity, WP_Error if not
398 + * Group booking sends the count inside the item; older payloads put a single
399 + * count at the root. One resolved value is then used for the capacity check,
400 + * the price and the stored appointment.
401 + *
402 + * @param array $item Raw item from the request.
403 + * @param array $input_data Raw order payload.
404 + *
405 + * @return int
372 406 */
373 - private function validate_appointment_time_is_free( $item ) {
374 - try {
375 - // Convert times to DateTime objects
376 - $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
377 - $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
407 + private function resolve_item_attendees( $item, $input_data ) {
408 + $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array();
378 409
379 - // Get the requested attendee count (default to 1 for backward compatibility)
380 - $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
381 414
382 - // Get service to check if group booking is enabled and get max capacity
383 - $service = Service_Model::find($item['service_id']);
384 - if ( ! $service ) {
385 - return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
386 - }
415 + return max( 1, absint( $attendees ) );
416 + }
387 417
388 - $max_group_size = $this->get_service_max_group_size( $service->id );
418 + /**
419 + * Calculate what a booking line actually costs, from the service record.
420 + *
421 + * @param array $item Raw item from the request.
422 + * @param int $attendees Resolved attendee count for this line.
423 + *
424 + * @return array|WP_Error array{price: float, total: float}
425 + */
426 + private function calculate_item_amount( $item, $attendees ) {
427 + $service_id = absint( $item['service_id'] ?? 0 );
428 + $service = Service_Model::find( $service_id );
389 429
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
432 + }
390 433
391 - $args = [
392 - 'team_member_id' => $item['team_member_id'],
393 - 'date' => $item['date'],
394 - 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
395 - ];
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
396 435
397 - // Get all non-cancelled appointments for this team member on this date
398 - $appointments = Appointment_Model::all( $args );
436 + if ( empty( $durations ) ) {
437 + return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) );
438 + }
399 439
400 - if ( ! is_array( $appointments ) || empty( $appointments ) ) {
401 - // No existing appointments, check if requested attendees fit within max capacity
402 - if ( $requested_attendees > $max_group_size ) {
403 - /* translators: %1$d = requested attendees, %2$d = maximum group size */
404 - 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 ) );
405 - }
440 + $requested_id = booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' );
441 + $selected = null;
406 442
407 - return true;
443 + foreach ( $durations as $duration ) {
444 + if ( (string) ( $duration['id'] ?? '' ) === (string) $requested_id ) {
445 + $selected = $duration;
446 + break;
408 447 }
448 + }
409 449
410 - // Calculate total attendees for overlapping time slots
411 - $overlapping_attendees = 0;
412 -
413 - foreach ( $appointments as $appointment ) {
414 - // Skip if this is the same appointment (for updates)
415 - if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
416 - continue;
417 - }
418 -
419 - // Guard against legacy/localized records whose stored date/time cannot be
420 - // parsed; skipping them keeps a single bad row from blocking every booking.
421 - try {
422 - $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
423 - $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
424 - } catch ( \Throwable $e ) {
425 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
426 - error_log( 'Booktics: skipping appointment ' . ( $appointment->ID ?? '?' ) . ' with unparseable date/time during slot validation: ' . $e->getMessage() );
427 - }
428 - continue;
429 - }
430 -
431 - // Check for time overlap
432 - if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
433 - // Times overlap, add this appointment's attendees to the count
434 - $appointment_attendees = $appointment->total_attendees ?? 1;
435 - $overlapping_attendees += $appointment_attendees;
436 - }
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' ) );
437 455 }
456 + $selected = reset( $durations );
457 + }
438 458
439 - // Check if adding the requested attendees would exceed capacity
440 - $total_after_booking = $overlapping_attendees + $requested_attendees;
459 + $price = (float) ( $selected['charge_amount'] ?? 0 );
441 460
442 - if ($total_after_booking > $max_group_size) {
443 - return new \WP_Error( 'insufficient_capacity', __( 'Insufficient capacity' , 'booktics' ) );
444 - }
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 );
445 466
446 - return true;
447 - } catch ( \Throwable $e ) {
448 - return new \WP_Error(
449 - 'time_validation_error',
450 - __( 'Error validating appointment time. Please try another slot.', 'booktics' )
451 - );
452 - }
467 + return array(
468 + 'price' => round( max( 0, $price ), 2 ),
469 + 'total' => round( max( 0, $total ), 2 ),
470 + );
453 471 }
454 472
455 473 /**
456 - * Get max group size for a specific service
474 + * How many times a line price is charged for a group booking.
457 475 *
458 - * @param int $service_id Service ID
476 + * Services that charge per attendee bill the flat price once; the rest
477 + * multiply it by the number of people, which is what the booking form does.
459 478 *
460 - * @return int Max group size
479 + * @param int $service_id Service ID.
480 + * @param int $attendees Resolved attendee count.
481 + *
482 + * @return int
461 483 */
462 - private function get_service_max_group_size( $service_id ) {
463 - $default_max_size = 1;
464 - $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
484 + private function get_attendee_multiplier( $service_id, $attendees ) {
485 + $group_booking = get_post_meta( $service_id, 'group_booking', true );
465 486
466 - if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
467 - return $default_max_size;
487 + if ( ! $this->is_group_booking_enabled( $group_booking ) ) {
488 + return 1;
468 489 }
469 490
470 - 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 );
471 496 }
472 497
473 498 /**
474 499 * Checks if group booking is enabled for a services group booking meta data
@@ -481,24 +506,8 @@
481 506 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
482 507 }
483 508
484 509 /**
485 - * Extracts the max booking size from the group booking meta data of a service
486 - *
487 - * @param array $group_booking_meta Group booking meta data
488 - * @param int $default_size Default max booking size
489 - *
490 - * @return int Max booking size
491 - */
492 - private function extract_max_booking_size( $group_booking_meta, $default_size ) {
493 - if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
494 - return (int) $group_booking_meta['max_booking'];
495 - }
496 -
497 - return $default_size;
498 - }
499 -
500 - /**
501 510 * Resolve the customer ID from input data, creating or finding the customer as needed
502 511 *
503 512 * @param array $input_data
504 513 *
@@ -523,9 +532,11 @@
523 532 *
524 533 * @return array [Order_Model $order_model, array $appointments]
525 534 */
526 535 private function create_order_and_appointments( $input_data, $db ) {
527 - $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();
528 539 $order_model = new Order_Model( $db );
529 540
530 541 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
531 542 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
@@ -583,9 +594,9 @@
583 594 'price' => $item['price'] ?? 0,
584 595 'subtotal' => $item['subtotal'] ?? 0,
585 596 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
586 597 'status' => booktics_get_option( 'appointment_status', 'pending' ),
587 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
588 599 'timezone' => $original_timezone,
589 600 );
590 601
591 602
@@ -591,8 +602,13 @@
591 602
592 603
593 604 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
594 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 +
595 611 $appointment = Appointment_Model::create( $appointment_data );
596 612 if ( is_wp_error( $appointment ) ) {
597 613 throw new Exception( esc_html( $appointment->get_error_message() ) );
598 614 }
@@ -661,10 +677,10 @@
661 677 ( new Payment_Model( $db ) )->create(
662 678 array(
663 679 'order_id' => $order_model->id,
664 680 'customer_id' => $input_data['customer_id'],
665 - 'amount' => $input_data['total'] * 100,
666 - 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
681 + 'amount' => booktics_amount_to_minor_units( $order_model->total, $order_model->currency ),
682 + 'currency' => $order_model->currency,
667 683 'intent_id' => $payment_intent['payment_intent'] ?? null,
668 684 'transaction_id' => null,
669 685 'payment_method' => $input_data['payment_method'],
670 686 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
@@ -1264,13 +1280,16 @@
1264 1280 *
1265 1281 * @return WP_Error|WP_HTTP_Response
1266 1282 */
1267 1283 public function update_payment_status( $request ) {
1268 - $data = json_decode( $request->get_body(), true );
1269 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1270 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1271 - $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();
1272 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 +
1273 1292 $db = new Booktics_Database();
1274 1293 $order_model = new Order_Model( $db );
1275 1294 $order = $order_model->find( array( 'id' => $order_id ) );
1276 1295
@@ -1277,8 +1296,14 @@
1277 1296 if ( ! $order ) {
1278 1297 return $this->error( __( 'Order not found', 'booktics' ) );
1279 1298 }
1280 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 +
1281 1306 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1282 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1283 1308 }
1284 1309
@@ -1295,9 +1320,9 @@
1295 1320
1296 1321 $order_model->update(
1297 1322 $order_id, array(
1298 1323 'payment_status' => $status,
1299 - 'transaction_id' => $data['transaction_id'] ?? null,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1300 1325 )
1301 1326 );
1302 1327
1303 1328 if (
@@ -1309,11 +1334,9 @@
1309 1334 try {
1310 1335 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
1311 1336 $this->send_notifications( $db, $order, $order->customer_id, $appointments );
1312 1337 } catch ( \Throwable $e ) {
1313 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1314 - error_log( 'Booktics: failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1315 - }
1338 + booktics_log( 'failed to send notifications after payment for order ' . $order_id . ': ' . $e->getMessage() );
1316 1339 }
1317 1340 } elseif ( $status === Payment_Status::FAILED ) {
1318 1341 // Payment was declined/canceled on the processor. Release the held slot
1319 1342 // immediately by canceling the pending appointments for this order, so the
@@ -1328,11 +1351,9 @@
1328 1351 ), __( 'Payment status updated successfully', 'booktics' )
1329 1352 );
1330 1353 } catch ( \Exception $e ) {
1331 1354
1332 - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
1333 - error_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1334 - }
1355 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1335 1356 return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
1336 1357 }
1337 1358 }
1338 1359