PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.16
Booktics – Appointment Booking Calendar for Service Businesses v1.0.16
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
booktics / core / order / controllers / order-controller.php

order-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.16, at core/order/controllers/order-controller.php

1,352 lines 53.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Order\Controllers;
4
5 use Booktics\Abstracts\Base_Rest_Controller;
6 use Booktics\Abstracts\Booktics_Database;
7 use Booktics\Abstracts\User_Model;
8 use Booktics\Cart\Cart_Manager;
9 use Booktics\Customer\Handlers\Customer_Event_Handler;
10 use Booktics\Models\Appointment_Model;
11 use Booktics\Models\Customer_Model;
12 use Booktics\Models\Guest_Model;
13 use Booktics\Models\Order_Model;
14 use Booktics\Models\Payment_Model;
15 use Booktics\Models\Service_Model;
16 use Booktics\Models\Team_Member_Model;
17 use Booktics\Order\Order_Validator;
18 use Booktics\Payment\Payment_Method_Manager;
19 use Booktics\Payment\Payment_Status;
20 use Booktics\Services\Time_Zone_Handler;
21 use DateTime;
22 use Exception;
23 use WP_Error;
24 use WP_HTTP_Response;
25 use WP_REST_Request;
26 use WP_REST_Server;
27
28 /**
29 * Order controller
30 *
31 * @package Booktics/Order
32 */
33 class Order_Controller extends Base_Rest_Controller {
34
35 /**
36 * Endpoint namespace
37 *
38 * @var string
39 */
40 protected $namespace = 'booktics/v1';
41
42 /**
43 * Route name
44 *
45 * @var string
46 */
47 protected $base = 'orders';
48
49 /**
50 * Register REST API routes for orders.
51 *
52 * @return void
53 */
54 public function register_routes(): void {
55 register_rest_route(
56 $this->namespace, $this->base, array(
57 array(
58 'methods' => WP_REST_Server::CREATABLE,
59 'callback' => array( $this, 'create_item' ),
60 'permission_callback' => array(
61 $this,
62 'create_order_permission',
63 ),
64 ),
65 array(
66 'methods' => WP_REST_Server::READABLE,
67 'callback' => array( $this, 'get_items' ),
68 'permission_callback' => array(
69 $this,
70 'get_items_permission',
71 ),
72 'args' => array(
73 'page' => array( 'default' => 1 ),
74 'per_page' => array( 'default' => 10 ),
75 ),
76 ),
77 array(
78 'methods' => WP_REST_Server::DELETABLE,
79 'callback' => array(
80 $this,
81 'bulk_delete_items',
82 ),
83 'permission_callback' => array(
84 $this,
85 'bulk_delete_items_permission',
86 ),
87 ),
88 )
89 );
90
91 register_rest_route(
92 $this->namespace, $this->base . '/(?P<id>[\d]+)', array(
93 array(
94 'methods' => WP_REST_Server::EDITABLE,
95 'callback' => array( $this, 'update_item' ),
96 'permission_callback' => array(
97 $this,
98 'update_item_permission',
99 ),
100 'args' => array(
101 'id' => array( 'required' => true ),
102 'status' => array( 'type' => 'string' ),
103 ),
104 ),
105 array(
106 'methods' => WP_REST_Server::READABLE,
107 'callback' => array( $this, 'get_item' ),
108 'permission_callback' => array(
109 $this,
110 'get_item_permission',
111 ),
112 'args' => array(
113 'id' => array( 'required' => true ),
114 ),
115 ),
116 array(
117 'methods' => WP_REST_Server::DELETABLE,
118 'callback' => array( $this, 'delete_item' ),
119 'permission_callback' => array(
120 $this,
121 'delete_item_permission',
122 ),
123 'args' => array(
124 'id' => array( 'required' => true ),
125 ),
126 ),
127 )
128 );
129
130 register_rest_route(
131 $this->namespace, $this->base . '/(?P<id>[\d]+)/payment', array(
132 array(
133 'methods' => WP_REST_Server::EDITABLE,
134 'callback' => array(
135 $this,
136 'update_payment_status',
137 ),
138 'permission_callback' => array(
139 $this,
140 'update_payment_status_permission',
141 ),
142 ),
143 )
144 );
145
146 register_rest_route(
147 $this->namespace, $this->base . '/(?P<id>[\d]+)/refund', array(
148 array(
149 'methods' => WP_REST_Server::EDITABLE,
150 'callback' => array( $this, 'refund_payment' ),
151 'permission_callback' => array(
152 $this,
153 'update_payment_status_permission',
154 ),
155 ),
156 )
157 );
158 }
159
160 /**
161 * Check if the current user has permission to create orders.
162 *
163 * @param WP_REST_Request $request
164 *
165 * @return bool
166 */
167 public function create_order_permission( $request ) {
168 return true;
169 }
170
171 /**
172 * Create a new order with the provided data
173 *
174 * @param WP_REST_Request $request
175 *
176 * @return WP_Error|WP_HTTP_Response
177 * @throws Exception
178 */
179 public function create_item( $request ) {
180 $input_data = json_decode( $request->get_body(), true );
181
182 // Single comprehensive validation and preparation
183 $validation_result = $this->validate_and_prepare_order_data( $input_data );
184 if ( is_wp_error( $validation_result ) ) {
185 $validation_error_msg = $validation_result->get_error_message() ?? __( 'Order validation failed', 'booktics' );
186
187 return $this->error(
188 $validation_error_msg,
189 422,
190 'Validation'
191 );
192 }
193
194 $db = new Booktics_Database();
195
196 // Resolve customer ID
197 $customer_id_result = $this->resolve_customer_id( $input_data );
198 if ( is_wp_error( $customer_id_result ) ) {
199 return $this->error( $customer_id_result->get_error_message() );
200 }
201 $input_data['customer_id'] = $customer_id_result;
202
203 try {
204 $db->begin_transaction();
205 list( $order_model, $appointments ) = $this->create_order_and_appointments( $input_data, $db );
206 $payment_intent = $this->handle_payment_if_needed( $input_data, $order_model, $db );
207 $db->commit();
208 } catch ( Exception $e ) {
209 $db->roll_back();
210 return $this->error( $e->getMessage() );
211 }
212
213 $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
214 $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
215 do_action('booktics_after_order_created', $order_model, $appointments);
216
217 $response_message = 'Order created successfully';
218 if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
219 $response_message = 'Order pending, please proceed payment';
220 }
221
222 return $this->response(
223 $response,
224 sprintf(
225 /* translators: %s: dynamic response message */
226 __( 'Response %s', 'booktics' ),
227 $response_message
228 ),
229 201
230 );
231 }
232
233 /**
234 * Comprehensive validation and preparation of order data
235 *
236 * @param array $input_data
237 *
238 * @return array|WP_Error
239 */
240 private function validate_and_prepare_order_data( &$input_data ) {
241 // Step 1: Basic validation using Order_Validator
242 $validator = new Order_Validator();
243 $validation_result = $validator->validate( $input_data );
244 if ( is_wp_error( $validation_result ) ) {
245 return $validation_result;
246 }
247
248 // Step 2: Preserve original timezone for appointment creation, then validate and setup working timezone
249 $input_data['original_timezone'] = $input_data['timezone'] ?? wp_timezone_string();
250 if ( ! isset( $input_data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $input_data['timezone'] ) ) {
251 $input_data['timezone'] = 'UTC';
252 }
253
254 // Step 3: Payment validation if pay_now is true
255 if ( isset( $input_data['pay_now'] ) && $input_data['pay_now'] ) {
256 $validate = booktics_validate(
257 $input_data, array(
258 'payment_method' => array( 'required' ),
259 )
260 );
261 if ( is_wp_error( $validate ) ) {
262 return $validate;
263 }
264 }
265
266 // Step 4: Process and validate each item
267 if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
268 return new WP_Error( 'items', __( 'Items are required', 'booktics' ) );
269 }
270
271 foreach ( $input_data['items'] as $key => $item ) {
272 // Validate required fields for each item
273 $item_validate = booktics_validate(
274 $item, array(
275 'date' => array( 'required' ),
276 'start_time' => array( 'required' ),
277 'end_time' => array( 'required' ),
278 'team_member_id' => array( 'required' ),
279 'service_id' => array( 'required' ),
280 )
281 );
282
283 if ( is_wp_error( $item_validate ) ) {
284 return $item_validate;
285 }
286
287 // Convert times to UTC
288 $converted_start_time = Time_Zone_Handler::convert_to_utc(
289 $item['start_time'],
290 $item['date'],
291 $input_data['timezone']
292 );
293 $converted_end_time = Time_Zone_Handler::convert_to_utc(
294 $item['end_time'],
295 $item['date'],
296 $input_data['timezone']
297 );
298
299 // Validate time range
300 $start_time = strtotime( $converted_start_time['time'] );
301 $end_time = strtotime( $converted_end_time['time'] );
302
303 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
304 return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
305 }
306
307 // In validate_and_prepare_order_data(), after converting times:
308 $item_with_converted_times = array_merge($item, [
309 'start_time' => $converted_start_time['time'],
310 'end_time' => $converted_end_time['time'],
311 'date' => gmdate('Y-m-d', strtotime($converted_start_time['date']))
312 ]);
313
314 $item_timet_slot_validate = $this->validate_appointment_time_is_free($item_with_converted_times);
315
316 if ( is_wp_error( $item_timet_slot_validate ) ) {
317 return $item_timet_slot_validate;
318 }
319
320 // Sanitize and prepare item data
321 $input_data['items'][ $key ] = array(
322 'start_time' => $converted_start_time['time'],
323 'end_time' => $converted_end_time['time'],
324 'date' => gmdate( 'Y-m-d', strtotime( $converted_start_time['date'] ) ),
325 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
326 'team_member_id' => absint( $item['team_member_id'] ),
327 'service_id' => absint( $item['service_id'] ),
328 'additional_duration_id' => $item['additional_duration_id'] ?? '',
329 'price' => floatval( $item['price'] ?? 0 ),
330 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
331 'quantity' => absint( $item['quantity'] ?? 1 ),
332 'total_attendees' => absint( $item['total_attendees'] ?? 1 ),
333 );
334 $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
335 }
336
337 $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
338
339 return $input_data;
340 }
341
342 /**
343 * Check if the requested time slot has available capacity for the given team member
344 * Works by checking all appointments for this team member on this date and calculating remaining capacity
345 *
346 * @param array $item Appointment item data
347 * @return bool|WP_Error True if time slot has capacity, WP_Error if not
348 */
349 private function validate_appointment_time_is_free( $item ) {
350 try {
351 // Convert times to DateTime objects
352 $requested_start = new \DateTime( $item['date'] . ' ' . $item['start_time'] );
353 $requested_end = new \DateTime( $item['date'] . ' ' . $item['end_time'] );
354
355 // Get the requested attendee count (default to 1 for backward compatibility)
356 $requested_attendees = isset( $item['total_attendees'] ) ? (int) $item['total_attendees'] : 1;
357
358 // Get service to check if group booking is enabled and get max capacity
359 $service = Service_Model::find($item['service_id']);
360 if ( ! $service ) {
361 return new \WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
362 }
363
364 $max_group_size = $this->get_service_max_group_size( $service->id );
365
366
367 $args = [
368 'team_member_id' => $item['team_member_id'],
369 'date' => $item['date'],
370 'post_status' => booktics_get_option('appointment_statuses_for_blocked_slot', []),
371 ];
372
373 // Get all non-cancelled appointments for this team member on this date
374 $appointments = Appointment_Model::all( $args );
375
376 if ( ! is_array( $appointments ) || empty( $appointments ) ) {
377 // No existing appointments, check if requested attendees fit within max capacity
378 if ( $requested_attendees > $max_group_size ) {
379 /* translators: %1$d = requested attendees, %2$d = maximum group size */
380 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 ) );
381 }
382
383 return true;
384 }
385
386 // Calculate total attendees for overlapping time slots
387 $overlapping_attendees = 0;
388
389 foreach ( $appointments as $appointment ) {
390 // Skip if this is the same appointment (for updates)
391 if ( isset($item['id']) && $appointment->ID == $item['id'] ) {
392 continue;
393 }
394
395 $appt_start = new \DateTime( $appointment->date . ' ' . $appointment->start_time );
396 $appt_end = new \DateTime( $appointment->date . ' ' . $appointment->end_time );
397
398 // Check for time overlap
399 if ( ! ( $requested_end <= $appt_start || $requested_start >= $appt_end ) ) {
400 // Times overlap, add this appointment's attendees to the count
401 $appointment_attendees = $appointment->total_attendees ?? 1;
402 $overlapping_attendees += $appointment_attendees;
403 }
404 }
405
406 // Check if adding the requested attendees would exceed capacity
407 $total_after_booking = $overlapping_attendees + $requested_attendees;
408
409 if ($total_after_booking > $max_group_size) {
410 return new \WP_Error( 'insufficient_capacity', __( 'In sufficient capacity' , 'booktics' ) );
411 }
412
413 return true;
414 } catch ( \Throwable $e ) {
415 return new \WP_Error(
416 'time_validation_error',
417 __( 'Error validating appointment time: ', 'booktics' ) . $e->getMessage()
418 );
419 }
420 }
421
422 /**
423 * Get max group size for a specific service
424 *
425 * @param int $service_id Service ID
426 *
427 * @return int Max group size
428 */
429 private function get_service_max_group_size( $service_id ) {
430 $default_max_size = 1;
431 $group_booking_meta = get_post_meta( $service_id, 'group_booking', true );
432
433 if ( ! $this->is_group_booking_enabled( $group_booking_meta ) ) {
434 return $default_max_size;
435 }
436
437 return $this->extract_max_booking_size( $group_booking_meta, $default_max_size );
438 }
439
440 /**
441 * Checks if group booking is enabled for a services group booking meta data
442 *
443 * @param array $group_booking_meta Group booking meta data
444 *
445 * @return bool True if group booking is enabled, false otherwise
446 */
447 private function is_group_booking_enabled( $group_booking_meta ) {
448 return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
449 }
450
451 /**
452 * Extracts the max booking size from the group booking meta data of a service
453 *
454 * @param array $group_booking_meta Group booking meta data
455 * @param int $default_size Default max booking size
456 *
457 * @return int Max booking size
458 */
459 private function extract_max_booking_size( $group_booking_meta, $default_size ) {
460 if ( isset( $group_booking_meta['max_booking'] ) && is_numeric( $group_booking_meta['max_booking'] ) ) {
461 return (int) $group_booking_meta['max_booking'];
462 }
463
464 return $default_size;
465 }
466
467 /**
468 * Resolve the customer ID from input data, creating or finding the customer as needed
469 *
470 * @param array $input_data
471 *
472 * @return int|WP_Error
473 */
474 private function resolve_customer_id( &$input_data ) {
475 if ( isset( $input_data['customer_id'] ) ) {
476 return $input_data['customer_id'];
477 }
478 if ( ! isset( $input_data['customer'] ) ) {
479 return new WP_Error( 'missing_customer', __( 'Customer information is required', 'booktics' ) );
480 }
481
482 return $this->find_or_create_customer( $input_data['customer'] );
483 }
484
485 /**
486 * Create the order and its associated appointments
487 *
488 * @param array $input_data
489 * @param Booktics_Database $db
490 *
491 * @return array [Order_Model $order_model, array $appointments]
492 */
493 private function create_order_and_appointments( $input_data, $db ) {
494 $input_data['currency'] = ! empty( $input_data['currency'] ) ? strtolower( $input_data['currency'] ) : strtolower( booktics_get_option( 'currency', 'USD' ) );
495 $order_model = new Order_Model( $db );
496
497 $input_timezone_string = $input_data['timezone'] ?? 'UTC';
498 $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
499 $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string;
500
501 $order_data = array(
502 'customer_id' => $input_data['customer_id'],
503 'payment_status' => Payment_Status::FAILED,
504 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
505 'status' => 'open',
506 'tax_total' => $input_data['tax_total'] ?? 0,
507 'coupon_code' => $input_data['coupon_code'] ?? '',
508 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
509 'subtotal' => $input_data['subtotal'],
510 'timezone' => $input_timezone,
511 'total' => $input_data['total'],
512 'currency' => $input_data['currency'],
513 'payment_method' => $input_data['payment_method'] ?? null,
514 );
515
516 // This is to only allow setting payment status when creating order from admin panel as admin
517 if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
518 $order_data['payment_status'] = $input_data['payment_status'];
519 }
520
521 $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
522 $order_model = $order_model->create( $order_data );
523 $appointments = array();
524 foreach ( $input_data['items'] as $item ) {
525 if ( isset( $item['uuid'] ) ) {
526 ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
527 }
528 $total_duration_min = $this->calculate_total_duration( $item );
529
530 $appointment_data = array(
531 'start_time' => wp_date('g:i A', strtotime($item['start_time']), $input_timezone) ?? '',
532 'end_time' => wp_date('g:i A', strtotime($item['end_time']), $input_timezone) ?? '',
533 'date' => wp_date( 'Y-m-d', strtotime( $item['date'] ), $input_timezone ),
534 'customer_id' => $input_data['customer_id'] ?? '',
535 'team_member_id' => $item['team_member_id'] ?? '',
536 'service_id' => $item['service_id'] ?? '',
537 'additional_duration_id' => $item['additional_duration_id'] ?? '',
538 'duration' => $total_duration_min,
539 'order_id' => $order_model->id,
540 'quantity' => $item['quantity'] ?? 1,
541 'price' => $item['price'] ?? 0,
542 'subtotal' => $item['subtotal'] ?? 0,
543 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
544 'status' => booktics_get_option( 'appointment_status', 'pending' ),
545 'total_attendees' => $input_data['total_attendees'] ?? 1,
546 'timezone' => $original_timezone,
547 );
548
549
550
551 $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
552
553 $appointment = Appointment_Model::create( $appointment_data );
554 if ( is_wp_error( $appointment ) ) {
555 throw new Exception( esc_html( $appointment->get_error_message() ) );
556 }
557
558 do_action( 'booktics_after_appointment_created', $appointment );
559 $appointments[] = $appointment;
560 }
561
562 return array( $order_model, $appointments );
563 }
564
565
566 /**
567 * Process payment for an order if immediate payment is requested
568 *
569 * @param array $input_data Order input data containing payment details
570 * @param Order_Model $order_model Order model instance
571 * @param Booktics_Database $db Database instance
572 *
573 * @return array
574 * @throws Exception
575 */
576 private function handle_payment_if_needed( $input_data, $order_model, $db ) {
577 if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
578 return array();
579 }
580
581 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
582 if ( ! $payment_method ) {
583 throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
584 }
585
586 $payment_intent = $payment_method->process_payment( $order_model );
587
588 if ( $payment_intent['status'] === 'error' ) {
589 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
590 throw new Exception( $payment_intent['message'] );
591 }
592
593 if ( $payment_intent['status'] === 'success' ) {
594 $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
595
596 // Update orders payment status on successfull payment completion
597 $order_model_id = is_object( $order_model ) ? $order_model->id : $order_model['id'];
598 ( new Order_Model($db) )->update( $order_model_id, [ 'payment_status' => 'fully_paid' ] );
599 }
600
601 return $payment_intent;
602 }
603
604
605 /**
606 * Update payment records after successful payment processing
607 *
608 * @param Booktics_Database $db Database instance
609 * @param Order_Model $order_model Order model instance
610 * @param array $input_data Order input data containing payment details
611 * @param array $payment_intent Payment intent data from payment processor
612 *
613 * @return void
614 */
615 private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
616 ( new Order_Model( $db ) )->update(
617 $order_model->id,
618 array( 'payment_intent_id' => $payment_intent['payment_intent'] )
619 );
620
621 ( new Payment_Model( $db ) )->create(
622 array(
623 'order_id' => $order_model->id,
624 'customer_id' => $input_data['customer_id'],
625 'amount' => $input_data['total'] * 100,
626 'currency' => $input_data['currency'] ?? booktics_get_option( 'currency', 'USD' ),
627 'intent_id' => $payment_intent['payment_intent'],
628 'transaction_id' => null,
629 'payment_method' => $input_data['payment_method'],
630 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
631 'date' => gmdate( 'Y-m-d' ),
632 'date_time' => gmdate( 'Y-m-d H:i:s' ),
633 )
634 );
635 }
636
637 /**
638 * Helper: Format a single appointment for API output
639 *
640 * @param Appointment_Model|array $appointment
641 *
642 * @return array
643 */
644 private function format_appointment( $appointment ): array {
645 if ( is_object( $appointment ) && method_exists( $appointment, 'to_array' ) ) {
646 $appointment = $appointment->to_array();
647 }
648 $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
649 $service = ( new Service_Model() )->find( $appointment['service_id'] );
650 $service = $service ? $service->to_array() : array();
651 $additional_durations = $service['additional_durations'] ?? array();
652 $additional_durations = ! empty( $additional_durations )
653 ? array_filter(
654 $additional_durations, function ( $duration ) use ( $appointment ) {
655 return $duration['id'] === $appointment['additional_duration_id'];
656 }
657 ) : array();
658 $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
659 $formatted_appointment = array(
660 'id' => $appointment['id'] ?? 0,
661 'date' => $appointment['date'] ?? '',
662 'start_time' => $appointment['start_time'] ?? '',
663 'end_time' => $appointment['end_time'] ?? '',
664 'additional_duration_id' => $appointment['additional_duration_id'] ?? '',
665 'team' => array(
666 'id' => $team_member->id ?? null,
667 'display_name' => $team_member->display_name ?? '',
668 'image' => $team_member->image ?? '',
669 'user_email' => $team_member->user_email ?? '',
670 ),
671 'service' => array(
672 'id' => $appointment['service_id'] ?? '',
673 'title' => $service['title'] ?? '',
674 'additional_duration' => $additional_duration,
675 'enable_google_meet' => $service['enable_google_meet'] ?? false,
676 ),
677 'duration' => $appointment['duration'] ?? null,
678 'quantity' => $appointment['quantity'] ?? 1,
679 'price' => $appointment['price'] ?? 0,
680 'subtotal' => $appointment['subtotal'] ?? 0,
681 'total' => $appointment['total'] ?? 0,
682 'status' => $appointment['status'] ?? '',
683 'total_attendees' => $appointment['total_attendees'] ?? 1,
684 'appointment_token' => $appointment['security_token'] ?? '',
685 'cancel_note' => $appointment['cancel_note'] ?? '',
686 );
687 $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
688 return $formatted_appointment;
689 }
690
691 /**
692 * Helper: Format order for API output
693 *
694 * @param Order_Model $order_model
695 * @param array $input_data
696 * @param array $payment_intent
697 *
698 * @return array
699 */
700 private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
701 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
702 // Ensure $order_model is an object before accessing properties
703 $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
704 $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
705 $appointment_details = array();
706 foreach ( $appointments as $appointment ) {
707 $appointment_details[] = $this->format_appointment( $appointment );
708 }
709
710 $order_data = array(
711 'id' => $order_id,
712 'order_no' => is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? null ),
713 'customer' => array(
714 'id' => $customer->id ?? null,
715 'name' => $customer->name ?? '',
716 'phone' => $customer->phone ?? '',
717 'email' => $customer->email ?? '',
718 ),
719 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
720 'subtotal' => is_object( $order_model ) ? $order_model->subtotal : ( $order_model['subtotal'] ?? null ),
721 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
722 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
723 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
724 'items' => $appointment_details,
725 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
726 'client_secret' => $payment_intent['client_secret'] ?? '',
727 'total_services' => $input_data['total_services'] ?? 1,
728 );
729
730 $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
731
732 return $order_data;
733 }
734
735 /**
736 * Helper: Send notifications for order and appointment creation
737 *
738 * @param Order_Model $order_model
739 * @param int $customer_id
740 * @param array $appointments
741 *
742 * @return void
743 */
744 private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
745 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $customer_id ) );
746 foreach ( $appointments as $appointment ) {
747 $formatted_appointment = $this->format_appointment( $appointment );
748 $appointment_time = $formatted_appointment['date'] . ' ' . $formatted_appointment['start_time'];
749 $dt = new DateTime( $appointment_time, wp_timezone() );
750 $timestamp = $dt->getTimestamp();
751 $appointment_datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp, wp_timezone() );
752 $notification_flow_data_for_appointment = array(
753 'appointment_service' => $formatted_appointment['service']['title'] ?? '',
754 'appointment_team_member' => $formatted_appointment['team']['display_name'] ?? '',
755 'start_time' => $formatted_appointment['start_time'],
756 'appointment_date' => $formatted_appointment['date'],
757 'appointment_time' => $appointment_datetime,
758 'attendee_count' => $formatted_appointment['attendee_count'] ?? null,
759 'customer_name' => $customer->name ?? '',
760 'user_email' => $customer->email ?? '',
761 'appointment_team_member_email' => $formatted_appointment['team']['user_email'] ?? '',
762 );
763 $notification_flow_data_for_appointment = apply_filters( 'booktics_appointment_scheduled_notification_flow_data', $notification_flow_data_for_appointment, $formatted_appointment );
764
765 do_action( 'booktics_global_notification_hook', 'appointment_scheduled', $notification_flow_data_for_appointment );
766 }
767
768 $appointment = $appointments[0]; // As currently only one appointment is created for each user
769 $formatted_appointment = $this->format_appointment( $appointment );
770 $notification_flow_data_for_order = [
771 'order_no' => $order_model->order_no ?? '',
772 'order_date' => $order_model->created_at ?? '',
773 'order_total' => $order_model->total ?? 0,
774 'customer_name' => $customer->name ?? '',
775 'user_email' => $customer->email ?? '',
776 'order_status' => $order_model->status ?? '',
777 'service_name' => $formatted_appointment['service']['title'] ?? '',
778 'team_name' => $formatted_appointment['team']['display_name'] ?? '',
779 'start_time' => $formatted_appointment['start_time'] ?? '',
780 'end_time' => $formatted_appointment['end_time'] ?? '',
781 'appointment_date' => $formatted_appointment['date'] ?? '',
782 'location_address' => $formatted_appointment['location']['address'] ?? '',
783 'appointment_status' => $formatted_appointment['status'] ?? '',
784 'appointment_cancel_url' => $appointment->get_cancel_link(),
785 'appointment_reschedule_url' => $appointment->get_reschedule_link(),
786 ];
787
788 $notification_flow_data_for_order = apply_filters( 'booktics_order_created_notification_flow_data', $notification_flow_data_for_order, $formatted_appointment, $order_model );
789
790 do_action( 'booktics_global_notification_hook', 'order_created', $notification_flow_data_for_order );
791 }
792
793 /**
794 * Find existing customer by email or create new customer
795 *
796 * @param array $customer
797 *
798 * @return int|WP_Error
799 */
800 private function find_or_create_customer( array $customer ) {
801 $db = new Booktics_Database();
802 if ( ! isset( $customer['email'] ) ) {
803 return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
804 }
805 $user_login = User_Model::generate_username( $customer['email'] );
806 $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
807 $wp_user_id = null;
808 if ( $allow_customer_as_wp_user ) {
809 $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
810 }
811 $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
812 if ( $guest ) {
813 return $guest->id;
814 }
815 $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
816
817 return $guest->id;
818 }
819
820 /**
821 * Get or create a WordPress user for the customer
822 *
823 * @param array $customer
824 * @param string $user_login
825 *
826 * @return int WP user ID
827 */
828 private function get_or_create_wp_user( $customer, $user_login ) {
829 $wp_user = get_user_by( 'email', $customer['email'] );
830 if ( $wp_user ) {
831 return $wp_user->ID;
832 }
833 $userdata = array(
834 'user_login' => $user_login,
835 'user_email' => $customer['email'],
836 'first_name' => $customer['name'] ?? '',
837 'display_name' => $customer['name'] ?? '',
838 'phone' => $customer['phone'] ?? '',
839 );
840 $customer_model = new Customer_Model();
841 $customer_id = $customer_model->save( $userdata );
842 ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
843
844 return $customer_id;
845 }
846
847 /**
848 * Find and update a guest record by email
849 *
850 * @param Booktics_Database $db
851 * @param array $customer
852 *
853 * @return object|null Guest_Model instance or null
854 */
855 private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
856 $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
857 if ( $guest ) {
858 ( new Guest_Model( $db ) )->update(
859 $guest->id, array(
860 'name' => $customer['name'] ?? '',
861 'phone' => $customer['phone'] ?? '',
862 'wp_user_id' => $wp_user_id,
863 )
864 );
865 }
866
867 return $guest;
868 }
869
870 /**
871 * Create a new guest record
872 *
873 * @param Booktics_Database $db
874 * @param array $customer
875 * @param string $user_login
876 * @param int|null $wp_user_id
877 *
878 * @return object Guest_Model instance
879 */
880 private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
881 return ( new Guest_Model( $db ) )->create(
882 array(
883 'wp_user_id' => $wp_user_id,
884 'email' => $customer['email'],
885 'name' => $customer['name'] ?? '',
886 'user_login' => $user_login,
887 'phone' => $customer['phone'] ?? '',
888 )
889 );
890 }
891
892 /**
893 * Check if user has permission to get orders.
894 *
895 * @param WP_REST_Request $request
896 *
897 * @return bool
898 */
899 public function get_items_permission( $request ) {
900 return true;
901 }
902
903 /**
904 * Get paginated list of orders.
905 *
906 * @param WP_REST_Request $request
907 *
908 * @return WP_Error|WP_HTTP_Response
909 */
910 public function get_items( $request ) {
911 $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
912 $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
913 $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
914 $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
915 $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
916 $team_member = absint( $request->get_param( 'team_member' ) );
917 $service = absint( $request->get_param( 'service' ) );
918 $search = sanitize_text_field( $request->get_param( 'search' ) );
919
920 $db = new Booktics_Database();
921 $order_model = new Order_Model( $db );
922 $conditions = array();
923
924 if ( $payment_status ) {
925 $conditions['payment_status'] = $payment_status;
926 }
927
928 if ( $payment_method ) {
929 $conditions['payment_method'] = $payment_method;
930 }
931
932 if ( $order_date ) {
933 $conditions['DATE(created_at)'] = array( '=', $order_date );
934 }
935
936 if ( $search ) {
937 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
938 }
939
940 $conditions = apply_filters( 'booktics_order_filters', $conditions );
941
942 $orders = $order_model->get(
943 $conditions, $page, $per_page, array(
944 'created_at',
945 'DESC',
946 )
947 );
948
949 if ( $team_member || $service ) {
950 foreach ( $orders['items'] as $key => $order ) {
951 $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
952 $keep_order = false;
953
954 foreach ( $appointments as $appointment ) {
955 if ( $team_member && $appointment->team_member_id == $team_member ) {
956 $keep_order = true;
957 }
958 if ( $service && $appointment->service_id == $service ) {
959 $keep_order = true;
960 }
961 }
962
963 if ( ! $keep_order && ( $team_member || $service ) ) {
964 unset( $orders['items'][ $key ] );
965 --$orders['total'];
966 }
967 }
968
969 $orders['items'] = array_values( $orders['items'] );
970 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
971 }
972
973 foreach ( $orders['items'] as &$order ) {
974 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
975 $order->customer = array(
976 'id' => $customer ? $customer->id : null,
977 'email' => $customer ? $customer->email : '',
978 'name' => $customer ? $customer->name : '',
979 'phone' => $customer ? $customer->phone : '',
980 );
981 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
982 }
983
984 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
985 }
986
987 /**
988 * Check if user has permission to update order.
989 *
990 * @param WP_REST_Request $request
991 *
992 * @return bool
993 */
994 public function update_item_permission( $request ) {
995 return current_user_can( 'manage_options' );
996 }
997
998 /**
999 * Update an existing order.
1000 *
1001 * @param WP_REST_Request $request
1002 *
1003 * @return WP_Error|WP_HTTP_Response
1004 */
1005 public function update_item( $request ) {
1006 $order_id = absint( $request->get_param( 'id' ) );
1007 $data = json_decode( $request->get_body(), true );
1008 $db = new Booktics_Database();
1009 $order_model = new Order_Model( $db );
1010 $order = $order_model->find( array( 'id' => $order_id ) );
1011
1012 if ( ! $order ) {
1013 return $this->error( __( 'Order not found', 'booktics' ) );
1014 }
1015
1016 $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1017 $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1018
1019 $order_model->update(
1020 $order_id, array(
1021 'status' => $status,
1022 'payment_status' => $payment_status,
1023 )
1024 );
1025
1026 if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1027 $customer_id = absint( $data['customer']['id'] );
1028 $customer_data = $data['customer'];
1029 $customer_data['id'] = $customer_id;
1030 if ( isset( $customer_data['name'] ) ) {
1031 $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1032 }
1033 if ( isset( $customer_data['email'] ) ) {
1034 $customer_data['email'] = sanitize_email( $customer_data['email'] );
1035 }
1036 if ( isset( $customer_data['phone'] ) ) {
1037 $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1038 }
1039 ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1040 }
1041
1042 $order_model = $order_model->find( array( 'id' => $order_id ) );
1043 $data['customer_id'] = $order_model->customer_id;
1044
1045 $response = $this->format_order_response($db, $order_model, $data, null);
1046
1047 return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
1048 }
1049
1050 /**
1051 * Check if user has permission to get order.
1052 *
1053 * @param WP_REST_Request $request
1054 *
1055 * @return bool
1056 */
1057 public function get_item_permission( $request ) {
1058 return true;
1059 }
1060
1061 /**
1062 * Get single order.
1063 *
1064 * @param WP_REST_Request $request
1065 *
1066 * @return WP_Error|WP_HTTP_Response
1067 */
1068 public function get_item( $request ) {
1069 $order_id = absint( $request->get_param( 'id' ) );
1070 $db = new Booktics_Database();
1071 $order_model = new Order_Model( $db );
1072 $order = $order_model->find( array( 'id' => $order_id ) );
1073
1074 if ( ! $order ) {
1075 return $this->error( __( 'Order not found', 'booktics' ) );
1076 }
1077
1078 // Use the format_order_response helper for consistency
1079 $input_data = array( 'customer_id' => $order->customer_id );
1080 $response = $this->format_order_response( $db, $order, $input_data, array() );
1081 // Add extra fields specific to GET if needed
1082 $response['status'] = $order->status;
1083 $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1084 $response['payment_gateway'] = $order->payment_method;
1085
1086 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
1087 }
1088
1089 /**
1090 * Check if user has permission to update payment status.
1091 *
1092 * @param WP_REST_Request $request
1093 *
1094 * @return bool
1095 */
1096 public function update_payment_status_permission( $request ) {
1097 return current_user_can( 'manage_options' );
1098 }
1099
1100 /**
1101 * Update payment status for an order.
1102 *
1103 * @param WP_REST_Request $request
1104 *
1105 * @return WP_Error|WP_HTTP_Response
1106 */
1107 public function update_payment_status( $request ) {
1108 $data = json_decode( $request->get_body(), true );
1109 $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
1110 $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
1111 $payment_method = ! empty( $data['payment_method'] ) ? $data['payment_method'] : null;
1112
1113 $db = new Booktics_Database();
1114 $order_model = new Order_Model( $db );
1115 $order = $order_model->find( array( 'id' => $order_id ) );
1116
1117 if ( ! $order ) {
1118 return $this->error( __( 'Order not found', 'booktics' ) );
1119 }
1120
1121 if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
1122 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
1123 }
1124
1125 try {
1126 $payment_handler = ( new Payment_Method_Manager() )->get_payment_method( $payment_method );
1127 if ( ! $payment_handler ) {
1128 return $this->error( __( 'Payment method not found', 'booktics' ) );
1129 }
1130
1131 $status = $payment_handler->get_payment_status( $order );
1132 if ( is_wp_error( $status ) ) {
1133 return $this->error( $status->get_error_message() );
1134 }
1135
1136 $order_model->update(
1137 $order_id, array(
1138 'payment_status' => $status,
1139 'transaction_id' => $data['transaction_id'] ?? null,
1140 )
1141 );
1142
1143 // Payment model status and transaction id update
1144 return $this->response(
1145 array(
1146 'payment_status' => $status,
1147 ), __( 'Payment status updated successfully', 'booktics' )
1148 );
1149 } catch ( \Exception $e ) {
1150 return $this->error( $e->getMessage() );
1151 }
1152 }
1153
1154 /**
1155 * Check if user has permission to delete order.
1156 *
1157 * @param WP_REST_Request $request
1158 *
1159 * @return bool
1160 */
1161 public function delete_item_permission( $request ) {
1162 return current_user_can( 'manage_options' );
1163 }
1164
1165 /**
1166 * Delete an order.
1167 *
1168 * @param WP_REST_Request $request
1169 *
1170 * @return WP_Error|WP_HTTP_Response
1171 */
1172 public function delete_item( $request ) {
1173 $order_id = absint( $request->get_param( 'id' ) );
1174 $db = new Booktics_Database();
1175 $order_model = new Order_Model( $db );
1176 $order = $order_model->find( array( 'id' => $order_id ) );
1177
1178 if ( ! $order ) {
1179 return $this->error( __( 'Order not found', 'booktics' ) );
1180 }
1181
1182 try {
1183 $db->begin_transaction();
1184
1185 // Delete related appointments
1186 $appointment_model = new Appointment_Model();
1187 $appointments = $appointment_model->where( 'order_id', $order_id );
1188 foreach ( $appointments as $appointment ) {
1189 $appointment->delete();
1190 }
1191
1192 // Delete related payments
1193 $payment_model = new Payment_Model( $db );
1194 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1195 foreach ( $payments['items'] as $payment ) {
1196 ( new Payment_Model( $db ) )->delete( $payment->id );
1197 }
1198 // Delete the order
1199 $order_model->delete( $order_id );
1200
1201 $db->commit();
1202
1203 return $this->response( array(), __( 'Order deleted successfully', 'booktics' ) );
1204 } catch ( Exception $e ) {
1205 $db->roll_back();
1206
1207 return $this->error( $e->getMessage() );
1208 }
1209 }
1210
1211 /**
1212 * Check if user has permission to bulk delete orders.
1213 *
1214 * @param WP_REST_Request $request
1215 *
1216 * @return bool
1217 */
1218 public function bulk_delete_items_permission( $request ) {
1219 return current_user_can( 'manage_options' );
1220 }
1221
1222 /**
1223 * Bulk delete orders.
1224 *
1225 * @param WP_REST_Request $request
1226 *
1227 * @return WP_Error|WP_HTTP_Response
1228 */
1229 public function bulk_delete_items( $request ) {
1230 $ids_param = $request->get_param( 'ids' );
1231 $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
1232 $db = new Booktics_Database();
1233
1234 try {
1235 $db->begin_transaction();
1236
1237 foreach ( $order_ids as $order_id ) {
1238 $order_model = new Order_Model( $db );
1239 $order = $order_model->find( array( 'id' => $order_id ) );
1240
1241 if ( ! $order ) {
1242 continue;
1243 }
1244
1245 // Delete related appointments
1246 $appointment_model = new Appointment_Model();
1247 $appointments = $appointment_model->where( 'order_id', $order_id );
1248 foreach ( $appointments as $appointment ) {
1249 $appointment->delete();
1250 }
1251
1252 // Delete related payments
1253 $payment_model = new Payment_Model( $db );
1254 $payments = $payment_model->get( array( 'order_id' => $order_id ) );
1255 foreach ( $payments['items'] as $payment ) {
1256 ( new Payment_Model( $db ) )->delete( $payment->id );
1257 }
1258
1259 // Delete the order
1260 $order_model->delete( $order_id );
1261 }
1262
1263 $db->commit();
1264
1265 return $this->response( array(), __( 'Orders deleted successfully', 'booktics' ) );
1266 } catch ( Exception $e ) {
1267 $db->roll_back();
1268
1269 return $this->error( $e->getMessage() );
1270 }
1271 }
1272
1273 /**
1274 * Process payment refund for an order.
1275 *
1276 * @param WP_REST_Request $request
1277 *
1278 * @return WP_Error|WP_HTTP_Response
1279 */
1280 public function refund_payment( $request ) {
1281 $order_id = absint( $request->get_param( 'id' ) );
1282 $db = new Booktics_Database();
1283 $order_model = new Order_Model( $db );
1284 $order = $order_model->find( array( 'id' => $order_id ) );
1285
1286 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
1287 if ( ! $payment_method ) {
1288 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
1289 }
1290 $response = $payment_method->process_refund( $order );
1291 if ( $response['status'] === 'error' ) {
1292 return $this->error( $response['message'] );
1293 }
1294
1295 $order_model->update(
1296 $order_id, array(
1297 'payment_status' => Payment_Status::REFUNDED,
1298 )
1299 );
1300
1301 return $this->response(
1302 $order_model->find( array( 'id' => $order_id ) ),
1303 __( 'Refund successful', 'booktics' )
1304 );
1305 }
1306
1307 /**
1308 * Calculate total duration in minutes for a service appointment.
1309 *
1310 * @param array $item
1311 *
1312 * @return int
1313 */
1314 private function calculate_total_duration( $item ) {
1315 $service_model = Service_Model::find( $item['service_id'] );
1316 if ( ! $service_model ) {
1317 return 0;
1318 }
1319 $service_duration = array_filter(
1320 $service_model->additional_durations, function ( $duration ) use ( $item ) {
1321 return $item['additional_duration_id'] === $duration['id'];
1322 }
1323 );
1324 if ( empty( $service_duration ) ) {
1325 return 0;
1326 }
1327 $service_duration = reset( $service_duration );
1328 $total_duration_min = $service_duration['duration_unit'] === 'min'
1329 ? $service_duration['duration_time']
1330 : ( $service_duration['duration_time'] * 60 );
1331 if ( ! empty( $item['extra_services'] ) ) {
1332 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
1333 }
1334
1335 $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1336
1337 return $total_duration_min;
1338 }
1339
1340 /**
1341 * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1342 *
1343 * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1344 *
1345 * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1346 */
1347 private function format_datetime_to_dayjs_format( $datetime ) {
1348 $dt = new DateTime( $datetime, wp_timezone() );
1349 return $dt->format( 'c' );
1350 }
1351 }
1352