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 +412 -267 1.0.17 → 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 );
@@ -164,11 +173,8 @@
164 173 *
165 174 * @return bool
166 175 */
167 176 public function create_order_permission( $request ) {
168 - // This endpoint intentionally allows unauthenticated (guest) access.
169 - // The plugin supports guest checkout — users do not need to be logged in
170 - // to place a booking. Input is validated inside create_item().
171 177 return true;
172 178 }
173 179
174 180 /**
@@ -212,12 +218,14 @@
212 218 $db->roll_back();
213 219 return $this->error( $e->getMessage() );
214 220 }
215 221
216 - $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 + }
217 225 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
218 226 do_action('booktics_after_order_created', $order_model, $appointments);
219 -
227 +
220 228 $response_message = 'Order created successfully';
221 229 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
222 230 $response_message = 'Order pending, please proceed payment';
223 231 }
@@ -263,8 +271,22 @@
263 271 );
264 272 if ( is_wp_error( $validate ) ) {
265 273 return $validate;
266 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 + }
267 289 }
268 290
269 291 // Step 4: Process and validate each item
270 292 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
@@ -272,18 +294,32 @@
272 294 }
273 295
274 296 foreach ( $input_data['items'] as $key => $item ) {
275 297 // Validate required fields for each item
276 - $item_validate = booktics_validate(
277 - $item, array(
278 - 'date' => array( 'required' ),
279 - 'start_time' => array( 'required' ),
280 - 'end_time' => array( 'required' ),
281 - 'team_member_id' => array( 'required' ),
282 - 'service_id' => array( 'required' ),
283 - )
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' ),
284 304 );
285 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 +
286 322 if ( is_wp_error( $item_validate ) ) {
287 323 return $item_validate;
288 324 }
289 325
@@ -306,19 +342,18 @@
306 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
307 343 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
308 344 }
309 345
310 - // In validate_and_prepare_order_data(), after converting times:
311 - $item_with_converted_times = array_merge($item, [
312 - 'start_time' => $converted_start_time['time'],
313 - 'end_time' => $converted_end_time['time'],
314 - 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
315 - ]);
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 );
316 350
317 - $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
318 -
319 - if ( is_wp_error( $item_timet_slot_validate ) ) {
320 - 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;
321 356 }
322 357
323 358 // Sanitize and prepare item data
324 359 $input_data['items'][ $key ] = array(
@@ -323,21 +358,36 @@
323 358 // Sanitize and prepare item data
324 359 $input_data['items'][ $key ] = array(
325 360 'start_time' => $converted_start_time['time'],
326 361 'end_time' => $converted_end_time['time'],
327 - 'date' => gmdate( 'Y-m-d', strtotime( $converted_start_time['date'] ) ),
362 + 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
328 363 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
329 364 'team_member_id' => absint( $item['team_member_id'] ),
330 365 'service_id' => absint( $item['service_id'] ),
331 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
332 - 'price' => floatval( $item['price'] ?? 0 ),
333 - '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'],
334 370 'quantity' => absint( $item['quantity'] ?? 1 ),
335 - 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
371 + 'total_attendees' => $attendees,
336 372 );
337 373 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
338 374 }
339 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 +
340 390 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
341 391
342 392 return $input_data;
343 393 }
@@ -342,131 +392,120 @@
342 392 return $input_data;
343 393 }
344 394
345 395 /**
346 - * Check if the requested time slot has available capacity for the given team member
347 - * Works by checking all appointments for this team member on this date and calculating remaining capacity
348 - *
349 - * @param array $item Appointment item data
350 - * @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
351 406 */
352 - private function validate_appointment_time_is_free( $item ) {
353 - try {
354 - // Convert times to DateTime objects
355 - $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
356 - $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
357 -
358 - // Get the requested attendee count (default to 1 for backward compatibility)
359 - $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
360 -
361 - // Get service to check if group booking is enabled and get max capacity
362 - $service = Service_Model::find($item['service_id']);
363 - if ( ! $service ) {
364 - return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
365 - }
366 -
367 - $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();
368 409
369 -
370 - $args = [
371 - 'team_member_id' => $item['team_member_id'],
372 - 'date' => $item['date'],
373 - 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
374 - ];
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
375 414
376 - // Get all non-cancelled appointments for this team member on this date
377 - $appointments = Appointment_Model::all( $args );
415 + return max( 1, absint( $attendees ) );
416 + }
378 417
379 - if ( ! is_array( $appointments ) || empty( $appointments ) ) {
380 - // No existing appointments, check if requested attendees fit within max capacity
381 - if ( $requested_attendees > $max_group_size ) {
382 - /* translators: %1$d = requested attendees, %2$d = maximum group size */
383 - 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 ) );
384 - }
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 );
385 429
386 - return true;
387 - }
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
432 + }
388 433
389 - // Calculate total attendees for overlapping time slots
390 - $overlapping_attendees = 0;
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
391 435
392 - foreach ( $appointments as $appointment ) {
393 - // Skip if this is the same appointment (for updates)
394 - if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
395 - continue;
396 - }
397 -
398 - $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
399 - $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 + }
400 439
401 - // Check for time overlap
402 - if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
403 - // Times overlap, add this appointment's attendees to the count
404 - $appointment_attendees = $appointment->total_attendees ?? 1;
405 - $overlapping_attendees += $appointment_attendees;
406 - }
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;
407 447 }
408 -
409 - // Check if adding the requested attendees would exceed capacity
410 - $total_after_booking = $overlapping_attendees + $requested_attendees;
411 -
412 - if ($total_after_booking > $max_group_size) {
413 - 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' ) );
414 455 }
456 + $selected = reset( $durations );
457 + }
415 458
416 - return true;
417 - } catch ( \Throwable $e ) {
418 - return new \WP_Error(
419 - 'time_validation_error',
420 - __( 'Error validating appointment time. Please try another slot.', 'booktics' )
421 - );
422 - }
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 + );
423 471 }
424 472
425 473 /**
426 - * Get max group size for a specific service
427 - *
428 - * @param int $service_id Service ID
429 - *
430 - * @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
431 483 */
432 - private function get_service_max_group_size( $service_id ) {
433 - $default_max_size = 1;
434 - $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
435 -
436 - if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
437 - 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;
438 489 }
439 490
440 - 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 );
441 496 }
442 -
497 +
443 498 /**
444 499 * Checks if group booking is enabled for a services group booking meta data
445 - *
500 + *
446 501 * @param array $group_booking_meta Group booking meta data
447 - *
502 + *
448 503 * @return bool True if group booking is enabled, false otherwise
449 504 */
450 505 private function is_group_booking_enabled( $group_booking_meta ) {
451 506 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
452 507 }
453 -
454 - /**
455 - * Extracts the max booking size from the group booking meta data of a service
456 - *
457 - * @param array $group_booking_meta Group booking meta data
458 - * @param int $default_size Default max booking size
459 - *
460 - * @return int Max booking size
461 - */
462 - private function extract_max_booking_size( $group_booking_meta, $default_size ) {
463 - if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
464 - return (int) $group_booking_meta['max_booking'];
465 - }
466 -
467 - return $default_size;
468 - }
469 508
470 509 /**
471 510 * Resolve the customer ID from input data, creating or finding the customer as needed
472 511 *
@@ -493,9 +532,11 @@
493 532 *
494 533 * @return array [Order_Model $order_model, array $appointments]
495 534 */
496 535 private function create_order_and_appointments( $input_data, $db ) {
497 - $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();
498 539 $order_model = new Order_Model( $db );
499 540
500 541 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
501 542 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
@@ -529,16 +570,25 @@
529 570 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
530 571 }
531 572 $total_duration_min = $this->calculate_total_duration( $item );
532 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 +
533 583 $appointment_data = array(
534 - 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
535 - 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
536 - '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' ),
537 587 'customer_id' => $input_data['customer_id'] ?? '',
538 588 'team_member_id' => $item['team_member_id'] ?? '',
539 589 'service_id' => $item['service_id'] ?? '',
540 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
590 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
541 591 'duration' => $total_duration_min,
542 592 'order_id' => $order_model->id,
543 593 'quantity' => $item['quantity'] ?? 1,
544 594 'price' => $item['price'] ?? 0,
@@ -544,16 +594,21 @@
544 594 'price' => $item['price'] ?? 0,
545 595 'subtotal' => $item['subtotal'] ?? 0,
546 596 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
547 597 'status' => booktics_get_option( 'appointment_status', 'pending' ),
548 - 'total_attendees' => $input_data['total_attendees'] ?? 1,
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
549 599 'timezone' => $original_timezone,
550 600 );
551 -
552 -
553 -
601 +
602 +
603 +
554 604 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
555 -
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 +
556 611 $appointment = Appointment_Model::create( $appointment_data );
557 612 if ( is_wp_error( $appointment ) ) {
558 613 throw new Exception( esc_html( $appointment->get_error_message() ) );
559 614 }
@@ -594,12 +649,10 @@
594 649 }
595 650
596 651 if ( $payment_intent['status'] === 'success' ) {
597 652 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
598 -
599 - // Update orders payment status on successfull payment completion
600 - $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
601 - ( 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.
602 655 }
603 656
604 657 return $payment_intent;
605 658 }
@@ -617,9 +670,9 @@
617 670 */
618 671 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
619 672 ( new Order_Model( $db ) )->update(
620 673 $order_model->id,
621 - array( 'payment_intent_id' => $payment_intent['payment_intent'] )
674 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
622 675 );
623 676
624 677 ( new Payment_Model( $db ) )->create(
625 678 array(
@@ -624,11 +677,11 @@
624 677 ( new Payment_Model( $db ) )->create(
625 678 array(
626 679 'order_id' => $order_model->id,
627 680 'customer_id' => $input_data['customer_id'],
628 - 'amount' => $input_data['total'] * 100,
629 - 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
630 - '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,
631 684 'transaction_id' => null,
632 685 'payment_method' => $input_data['payment_method'],
633 686 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
634 687 'date' => gmdate( 'Y-m-d' ),
@@ -637,8 +690,20 @@
637 690 );
638 691 }
639 692
640 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 + /**
641 706 * Helper: Format a single appointment for API output
642 707 *
643 708 * @param Appointment_Model|array $appointment
644 709 *
@@ -644,52 +709,9 @@
644 709 *
645 710 * @return array
646 711 */
647 712 private function format_appointment( $appointment ): array {
648 - if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
649 - $appointment = $appointment->to_array();
650 - }
651 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
652 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
653 - $service = $service ? $service->to_array() : array();
654 - $additional_durations = $service['additional_durations'] ?? array();
655 - $additional_durations = ! empty( $additional_durations )
656 - ? array_filter(
657 - $additional_durations, function ( $duration ) use ( $appointment ) {
658 - return $duration['id'] === $appointment['additional_duration_id'];
659 - }
660 - ) : array();
661 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
662 - $formatted_appointment = array(
663 - 'id' => $appointment['id'] ?? 0,
664 - 'date' => $appointment['date'] ?? '',
665 - 'start_time' => $appointment['start_time'] ?? '',
666 - 'end_time' => $appointment['end_time'] ?? '',
667 - 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
668 - 'team' => array(
669 - 'id' => $team_member->id ?? null,
670 - 'display_name' => $team_member->display_name ?? '',
671 - 'image' => $team_member->image ?? '',
672 - 'user_email' => $team_member->user_email ?? '',
673 - ),
674 - 'service' => array(
675 - 'id' => $appointment['service_id'] ?? '',
676 - 'title' => $service['title'] ?? '',
677 - 'additional_duration' => $additional_duration,
678 - 'enable_google_meet' => $service['enable_google_meet'] ?? false,
679 - ),
680 - 'duration' => $appointment['duration'] ?? null,
681 - 'quantity' => $appointment['quantity'] ?? 1,
682 - 'price' => $appointment['price'] ?? 0,
683 - 'subtotal' => $appointment['subtotal'] ?? 0,
684 - 'total' => $appointment['total'] ?? 0,
685 - 'status' => $appointment['status'] ?? '',
686 - 'total_attendees' => $appointment['total_attendees'] ?? 1,
687 - 'appointment_token' => $appointment['security_token'] ?? '',
688 - 'cancel_note' => $appointment['cancel_note'] ?? '',
689 - );
690 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
691 - return $formatted_appointment;
713 + return Order_Notifier::format_appointment( $appointment );
692 714 }
693 715
694 716 /**
695 717 * Helper: Format order for API output
@@ -709,11 +731,14 @@
709 731 foreach ( $appointments as $appointment ) {
710 732 $appointment_details[] = $this->format_appointment( $appointment );
711 733 }
712 734
735 + $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
736 +
713 737 $order_data = array(
714 738 'id' => $order_id,
715 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 ),
716 741 'customer' => array(
717 742 'id' => $customer->id ?? null,
718 743 'name' => $customer->name ?? '',
719 744 'phone' => $customer->phone ?? '',
@@ -720,8 +745,14 @@
720 745 'email' => $customer->email ?? '',
721 746 ),
722 747 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
723 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 ),
724 755 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
725 756 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
726 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
727 758 'items' => $appointment_details,
@@ -726,8 +757,11 @@
726 757 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
727 758 'items' => $appointment_details,
728 759 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
729 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'] ?? '',
730 764 'total_services' => $input_data['total_services'] ?? 1,
731 765 );
732 766
733 767 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
@@ -737,8 +771,12 @@
737 771
738 772 /**
739 773 * Helper: Send notifications for order and appointment creation
740 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
741 779 * @param Order_Model $order_model
742 780 * @param int $customer_id
743 781 * @param array $appointments
744 782 *
@@ -744,54 +782,9 @@
744 782 *
745 783 * @return void
746 784 */
747 785 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
748 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
749 - foreach ( $appointments as $appointment ) {
750 - $formatted_appointment = $this->format_appointment( $appointment );
751 - $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
752 - $dt = new DateTime( $appointment_time, wp_timezone() );
753 - $timestamp = $dt->getTimestamp();
754 - $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
755 - $notification_flow_data_for_appointment = array(
756 - 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
757 - 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
758 - 'start_time' => $formatted_appointment['start_time'],
759 - 'appointment_date' => $formatted_appointment['date'],
760 - 'appointment_time' => $appointment_datetime,
761 - 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
762 - 'customer_name' => $customer->name ?? '',
763 - 'user_email' => $customer->email ?? '',
764 - 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
765 - );
766 - $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
767 -
768 - do_action( 'booktics_global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
769 - }
770 -
771 - $appointment = $appointments[0]; // As currently only one appointment is created for each user
772 - $formatted_appointment = $this->format_appointment( $appointment );
773 - $notification_flow_data_for_order = [
774 - 'order_no' => $order_model->order_no ?? '',
775 - 'order_date' => $order_model->created_at ?? '',
776 - 'order_total' => $order_model->total ?? 0,
777 - 'customer_name' => $customer->name ?? '',
778 - 'user_email' => $customer->email ?? '',
779 - 'order_status' => $order_model->status ?? '',
780 - 'service_name' => $formatted_appointment['service']['title'] ?? '',
781 - 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
782 - 'start_time' => $formatted_appointment['start_time'] ?? '',
783 - 'end_time' => $formatted_appointment['end_time'] ?? '',
784 - 'appointment_date' => $formatted_appointment['date'] ?? '',
785 - 'location_address' => $formatted_appointment['location']['address'] ?? '',
786 - 'appointment_status' => $formatted_appointment['status'] ?? '',
787 - 'appointment_cancel_url' => $appointment->get_cancel_link(),
788 - 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
789 - ];
790 -
791 - $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
792 -
793 - do_action( 'booktics_global_notification_hook', 'order_created', $notification_flow_data_for_order );
786 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
794 787 }
795 788
796 789 /**
797 790 * Find existing customer by email or create new customer
@@ -856,9 +849,9 @@
856 849 * @return object|null Guest_Model instance or null
857 850 */
858 851 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
859 852 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
860 - if ( $guest ) {
853 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
861 854 ( new Guest_Model( $db ) )->update(
862 855 $guest->id, array(
863 856 'name' => $customer['name'] ?? '',
864 857 'phone' => $customer['phone'] ?? '',
@@ -870,8 +863,40 @@
870 863 return $guest;
871 864 }
872 865
873 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 + /**
874 899 * Create a new guest record
875 900 *
876 901 * @param Booktics_Database $db
877 902 * @param array $customer
@@ -892,22 +917,19 @@
892 917 );
893 918 }
894 919
895 920 /**
896 - * Check if user has permission to get orders.
921 + * Check if user has permission to list orders.
897 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 + *
898 927 * @param WP_REST_Request $request
899 928 *
900 - * @return bool
929 + * @return bool|WP_Error
901 930 */
902 931 public function get_items_permission( $request ) {
903 - // Admins and team members get the full, unfiltered order list.
904 - if ( booktics_is_admin_or_team_member() ) {
905 - return true;
906 - }
907 -
908 - // Authenticated customers may access this endpoint; get_items() automatically
909 - // scopes the query to only their own orders via customer_id (see lines 950–960).
910 932 if ( is_user_logged_in() ) {
911 933 return true;
912 934 }
913 935
@@ -1028,9 +1050,9 @@
1028 1050 $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1029 1051 }
1030 1052 $order->customer = $customer_data_for_order;
1031 1053 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1032 -
1054 +
1033 1055 // Hide payment intent fields from team members (not admins)
1034 1056 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1035 1057 $order->payment_intent_id = '';
1036 1058 $order->client_secret = '';
@@ -1078,8 +1100,13 @@
1078 1100 'payment_status' => $payment_status,
1079 1101 )
1080 1102 );
1081 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 +
1082 1109 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1083 1110 $customer_id = absint( $data['customer']['id'] );
1084 1111 $customer_data = $data['customer'];
1085 1112 $customer_data['id'] = $customer_id;
@@ -1111,11 +1138,15 @@
1111 1138
1112 1139 /**
1113 1140 * Check if user has permission to get order.
1114 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 + *
1115 1146 * @param WP_REST_Request $request
1116 1147 *
1117 - * @return bool
1148 + * @return bool|WP_Error
1118 1149 */
1119 1150 public function get_item_permission( $request ) {
1120 1151 if ( booktics_is_admin_or_team_member() ) {
1121 1152 return true;
@@ -1120,20 +1151,27 @@
1120 1151 if ( booktics_is_admin_or_team_member() ) {
1121 1152 return true;
1122 1153 }
1123 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 +
1124 1163 if ( is_user_logged_in() ) {
1125 - $order_id = absint( $request->get_param( 'id' ) );
1126 - $db = new Booktics_Database();
1127 - $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1164 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1128 1165
1129 - if ( $order ) {
1130 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1166 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1167 + return true;
1168 + }
1169 + }
1131 1170
1132 - if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1133 - return true;
1134 - }
1135 - }
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;
1136 1174 }
1137 1175
1138 1176 return new WP_Error(
1139 1177 'booktics_forbidden',
@@ -1142,8 +1180,31 @@
1142 1180 );
1143 1181 }
1144 1182
1145 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 + *
1200 + * @return bool
1201 + */
1202 + private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1203 + return Order_Key::verify( $order_id, $order_no, $provided_key );
1204 + }
1205 +
1206 + /**
1146 1207 * Get single order.
1147 1208 *
1148 1209 * @param WP_REST_Request $request
1149 1210 *
@@ -1165,9 +1226,15 @@
1165 1226 // Add extra fields specific to GET if needed
1166 1227 $response['status'] = $order->status;
1167 1228 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1168 1229 $response['payment_gateway'] = $order->payment_method;
1230 + $response['payment_method'] = $order->payment_method;
1169 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 +
1170 1237 // Hide payment intent fields from team members (not admins)
1171 1238 if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1172 1239 $response['payment_intent_id'] = '';
1173 1240 $response['client_secret'] = '';
@@ -1183,9 +1250,28 @@
1183 1250 *
1184 1251 * @return bool
1185 1252 */
1186 1253 public function update_payment_status_permission( $request ) {
1187 - 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 );
1188 1274 }
1189 1275
1190 1276 /**
1191 1277 * Update payment status for an order.
@@ -1194,13 +1280,16 @@
1194 1280 *
1195 1281 * @return WP_Error|WP_HTTP_Response
1196 1282 */
1197 1283 public function update_payment_status( $request ) {
1198 - $data = json_decode( $request->get_body(), true );
1199 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1200 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1201 - $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();
1202 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 +
1203 1292 $db = new Booktics_Database();
1204 1293 $order_model = new Order_Model( $db );
1205 1294 $order = $order_model->find( array( 'id' => $order_id ) );
1206 1295
@@ -1207,8 +1296,14 @@
1207 1296 if ( ! $order ) {
1208 1297 return $this->error( __( 'Order not found', 'booktics' ) );
1209 1298 }
1210 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 +
1211 1306 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1212 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1213 1308 }
1214 1309
@@ -1225,12 +1320,31 @@
1225 1320
1226 1321 $order_model->update(
1227 1322 $order_id, array(
1228 1323 'payment_status' => $status,
1229 - 'transaction_id' => $data['transaction_id'] ?? null,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
1230 1325 )
1231 1326 );
1232 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 +
1233 1347 // Payment model status and transaction id update
1234 1348 return $this->response(
1235 1349 array(
1236 1350 'payment_status' => $status,
@@ -1236,9 +1350,11 @@
1236 1350 'payment_status' => $status,
1237 1351 ), __( 'Payment status updated successfully', 'booktics' )
1238 1352 );
1239 1353 } catch ( \Exception $e ) {
1240 - 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' ) );
1241 1357 }
1242 1358 }
1243 1359
1244 1360 /**
@@ -1360,8 +1476,24 @@
1360 1476 }
1361 1477 }
1362 1478
1363 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 + /**
1364 1496 * Process payment refund for an order.
1365 1497 *
1366 1498 * @param WP_REST_Request $request
1367 1499 *
@@ -1372,17 +1504,29 @@
1372 1504 $db = new Booktics_Database();
1373 1505 $order_model = new Order_Model( $db );
1374 1506 $order = $order_model->find( array( 'id' => $order_id ) );
1375 1507
1508 + if ( ! $order || is_wp_error( $order ) ) {
1509 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1510 + }
1511 +
1376 1512 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1377 1513 if ( ! $payment_method ) {
1378 1514 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1379 1515 }
1380 1516 $response = $payment_method->process_refund( $order );
1381 - if ( $response['status'] === 'error' ) {
1382 - 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() );
1383 1523 }
1384 1524
1525 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1526 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1527 + }
1528 +
1385 1529 $order_model->update(
1386 1530 $order_id, array(
1387 1531 'payment_status' => Payment_Status::REFUNDED,
1388 1532 )
@@ -1434,8 +1578,9 @@
1434 1578 *
1435 1579 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1436 1580 */
1437 1581 private function format_datetime_to_dayjs_format( $datetime ) {
1438 - $dt = new DateTime( $datetime, wp_timezone() );
1439 - return $dt->format( 'c' );
1582 + $dt = new DateTime( $datetime );
1583 + $dt->setTimezone( wp_timezone() );
1584 + return $dt->format( 'Y-m-d H:i:s' );
1440 1585 }
1441 1586 }