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 +1087 -414 1.0.0 → 1.0.29 View file →
@@ -2,21 +2,28 @@
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;
10 +use Booktics\Customer\Handlers\Customer_Event_Handler;
9 11 use Booktics\Models\Appointment_Model;
10 12 use Booktics\Models\Customer_Model;
13 +use Booktics\Models\Guest_Model;
11 14 use Booktics\Models\Order_Model;
12 15 use Booktics\Models\Payment_Model;
13 16 use Booktics\Models\Service_Model;
14 17 use Booktics\Models\Team_Member_Model;
15 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;
16 22 use Booktics\Payment\Payment_Method_Manager;
23 +use Booktics\Payment\Payment_Status;
17 24 use Booktics\Services\Time_Zone_Handler;
18 -use Booktics_Stripe\Admin\Controllers\Booktics_Stripe_Controller;
25 +use DateTime;
19 26 use Exception;
20 27 use WP_Error;
21 28 use WP_HTTP_Response;
22 29 use WP_REST_Request;
@@ -43,102 +50,125 @@
43 50 */
44 51 protected $base = 'orders';
45 52
46 53 /**
47 - * Register routes
54 + * Register REST API routes for orders.
48 55 *
49 - * @return void
56 + * @return void
50 57 */
51 58 public function register_routes(): void {
52 59 register_rest_route(
53 60 $this->namespace, $this->base, array(
54 - array(
55 - 'methods' => WP_REST_Server::CREATABLE,
56 - 'callback' => array( $this, 'create_item' ),
57 - 'permission_callback' => array(
58 - $this,
59 - 'create_order_permission',
60 - ),
61 - ),
62 - array(
63 - 'methods' => WP_REST_Server::READABLE,
64 - 'callback' => array( $this, 'get_items' ),
65 - 'permission_callback' => array( $this, 'get_items_permission' ),
66 - 'args' => array(
67 - 'page' => array( 'default' => 1 ),
68 - 'per_page' => array( 'default' => 10 ),
69 - ),
70 - ),
71 - array(
72 - 'methods' => WP_REST_Server::DELETABLE,
73 - 'callback' => array( $this, 'bulk_delete_items' ),
74 - 'permission_callback' => array(
75 - $this,
76 - 'bulk_delete_items_permission',
77 - ),
78 - ),
61 + array(
62 + 'methods' => WP_REST_Server::CREATABLE,
63 + 'callback' => array( $this, 'create_item' ),
64 + 'permission_callback' => array(
65 + $this,
66 + 'create_order_permission',
67 + ),
68 + ),
69 + array(
70 + 'methods' => WP_REST_Server::READABLE,
71 + 'callback' => array( $this, 'get_items' ),
72 + 'permission_callback' => array(
73 + $this,
74 + 'get_items_permission',
75 + ),
76 + 'args' => array(
77 + 'page' => array( 'default' => 1 ),
78 + 'per_page' => array( 'default' => 10 ),
79 + ),
80 + ),
81 + array(
82 + 'methods' => WP_REST_Server::DELETABLE,
83 + 'callback' => array(
84 + $this,
85 + 'bulk_delete_items',
86 + ),
87 + 'permission_callback' => array(
88 + $this,
89 + 'bulk_delete_items_permission',
90 + ),
91 + ),
79 92 )
80 93 );
81 94
82 95 register_rest_route(
83 96 $this->namespace, $this->base . '/(?P<id>[\d]+)', array(
84 - array(
85 - 'methods' => WP_REST_Server::EDITABLE,
86 - 'callback' => array( $this, 'update_item' ),
87 - 'permission_callback' => array( $this, 'update_item_permission' ),
88 - 'args' => array(
89 - 'id' => array( 'required' => true ),
90 - 'status' => array( 'type' => 'string' ),
91 - ),
92 - ),
93 - array(
94 - 'methods' => WP_REST_Server::READABLE,
95 - 'callback' => array( $this, 'get_item' ),
96 - 'permission_callback' => array( $this, 'get_item_permission' ),
97 - 'args' => array(
98 - 'id' => array( 'required' => true ),
99 - ),
100 - ),
101 - array(
102 - 'methods' => WP_REST_Server::DELETABLE,
103 - 'callback' => array( $this, 'delete_item' ),
104 - 'permission_callback' => array( $this, 'delete_item_permission' ),
105 - 'args' => array(
106 - 'id' => array( 'required' => true ),
107 - ),
108 - ),
97 + array(
98 + 'methods' => WP_REST_Server::EDITABLE,
99 + 'callback' => array( $this, 'update_item' ),
100 + 'permission_callback' => array(
101 + $this,
102 + 'update_item_permission',
103 + ),
104 + 'args' => array(
105 + 'id' => array( 'required' => true ),
106 + 'status' => array( 'type' => 'string' ),
107 + ),
108 + ),
109 + array(
110 + 'methods' => WP_REST_Server::READABLE,
111 + 'callback' => array( $this, 'get_item' ),
112 + 'permission_callback' => array(
113 + $this,
114 + 'get_item_permission',
115 + ),
116 + 'args' => array(
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 + ),
123 + ),
124 + ),
125 + array(
126 + 'methods' => WP_REST_Server::DELETABLE,
127 + 'callback' => array( $this, 'delete_item' ),
128 + 'permission_callback' => array(
129 + $this,
130 + 'delete_item_permission',
131 + ),
132 + 'args' => array(
133 + 'id' => array( 'required' => true ),
134 + ),
135 + ),
109 136 )
110 137 );
111 138
112 139 register_rest_route(
113 140 $this->namespace, $this->base . '/(?P<id>[\d]+)/payment', array(
114 - array(
115 - 'methods' => WP_REST_Server::EDITABLE,
116 - 'callback' => array( $this, 'update_payment_status' ),
117 - 'permission_callback' => array(
118 - $this,
119 - 'update_payment_status_permission',
120 - ),
121 - ),
141 + array(
142 + 'methods' => WP_REST_Server::EDITABLE,
143 + 'callback' => array(
144 + $this,
145 + 'update_payment_status',
146 + ),
147 + 'permission_callback' => array(
148 + $this,
149 + 'update_payment_status_permission',
150 + ),
151 + ),
122 152 )
123 153 );
124 154
125 155 register_rest_route(
126 156 $this->namespace, $this->base . '/(?P<id>[\d]+)/refund', array(
127 - array(
128 - 'methods' => WP_REST_Server::EDITABLE,
129 - 'callback' => array( $this, 'refund_payment' ),
130 - 'permission_callback' => array(
131 - $this,
132 - 'update_payment_status_permission',
133 - ),
134 - ),
157 + array(
158 + 'methods' => WP_REST_Server::EDITABLE,
159 + 'callback' => array( $this, 'refund_payment' ),
160 + 'permission_callback' => array(
161 + $this,
162 + 'refund_payment_permission',
163 + ),
164 + ),
135 165 )
136 166 );
137 167 }
138 168
139 169 /**
140 - * Check if the current user has permission to create orders
170 + * Check if the current user has permission to create orders.
141 171 *
142 172 * @param WP_REST_Request $request
143 173 *
144 174 * @return bool
@@ -147,323 +177,772 @@
147 177 return true;
148 178 }
149 179
150 180 /**
151 - * Prepare and validate order data before creation
181 + * Create a new order with the provided data
152 182 *
153 - * @param array $data
183 + * @param WP_REST_Request $request
154 184 *
185 + * @return WP_Error|WP_HTTP_Response
186 + * @throws Exception
187 + */
188 + public function create_item( $request ) {
189 + $input_data = json_decode( $request->get_body(), true );
190 +
191 + // Single comprehensive validation and preparation
192 + $validation_result = $this->validate_and_prepare_order_data( $input_data );
193 + if ( is_wp_error( $validation_result ) ) {
194 + $validation_error_msg = $validation_result->get_error_message() ?? __( 'Order validation failed', 'booktics' );
195 +
196 + return $this->error(
197 + $validation_error_msg,
198 + 422,
199 + 'Validation'
200 + );
201 + }
202 +
203 + $db = new Booktics_Database();
204 +
205 + // Resolve customer ID
206 + $customer_id_result = $this->resolve_customer_id( $input_data );
207 + if ( is_wp_error( $customer_id_result ) ) {
208 + return $this->error( $customer_id_result->get_error_message() );
209 + }
210 + $input_data['customer_id'] = $customer_id_result;
211 +
212 + try {
213 + $db->begin_transaction();
214 + list( $order_model, $appointments ) = $this->create_order_and_appointments( $input_data, $db );
215 + $payment_intent = $this->handle_payment_if_needed( $input_data, $order_model, $db );
216 + $db->commit();
217 + } catch ( Exception $e ) {
218 + $db->roll_back();
219 + return $this->error( $e->getMessage() );
220 + }
221 +
222 + if ( empty( $input_data['pay_now'] ) ) {
223 + $this->send_notifications( $db, $order_model, $input_data['customer_id'], $appointments );
224 + }
225 + $response = $this->format_order_response( $db, $order_model, $input_data, $payment_intent );
226 + do_action('booktics_after_order_created', $order_model, $appointments);
227 +
228 + $response_message = 'Order created successfully';
229 + if ( isset( $input_data['pay_now']) && $input_data['pay_now'] ) {
230 + $response_message = 'Order pending, please proceed payment';
231 + }
232 +
233 + return $this->response(
234 + $response,
235 + sprintf(
236 + /* translators: %s: dynamic response message */
237 + __( 'Response %s', 'booktics' ),
238 + $response_message
239 + ),
240 + 201
241 + );
242 + }
243 +
244 + /**
245 + * Comprehensive validation and preparation of order data
246 + *
247 + * @param array $input_data
248 + *
155 249 * @return array|WP_Error
156 250 */
157 - private function prepare_item_for_order_create( array $data ) {
158 - if ( ! isset( $data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $data['timezone'] ) ) {
159 - $data['timezone'] = 'UTC';
251 + private function validate_and_prepare_order_data( &$input_data ) {
252 + // Step 1: Basic validation using Order_Validator
253 + $validator = new Order_Validator();
254 + $validation_result = $validator->validate( $input_data );
255 + if ( is_wp_error( $validation_result ) ) {
256 + return $validation_result;
160 257 }
161 258
162 - if ( isset( $data['pay_now'] ) && $data['pay_now'] ) {
259 + // Step 2: Preserve original timezone for appointment creation, then validate and setup working timezone
260 + $input_data['original_timezone'] = $input_data['timezone'] ?? wp_timezone_string();
261 + if ( ! isset( $input_data['timezone'] ) || ! Time_Zone_Handler::validate_time_zone( $input_data['timezone'] ) ) {
262 + $input_data['timezone'] = 'UTC';
263 + }
264 +
265 + // Step 3: Payment validation if pay_now is true
266 + if ( isset( $input_data['pay_now'] ) && $input_data['pay_now'] ) {
163 267 $validate = booktics_validate(
164 - $data, array(
165 - 'payment_method' => array( 'required' ),
268 + $input_data, array(
269 + 'payment_method' => array( 'required' ),
166 270 )
167 271 );
168 -
169 272 if ( is_wp_error( $validate ) ) {
170 273 return $validate;
171 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 + }
172 289 }
173 290
174 - $error = ( new \WP_Error() );
175 - if ( ! is_array( $data['items'] ) || count( $data['items'] ) == 0 ) {
176 - $error->add( 'items', __( 'Items are required', 'booktics' ) );
177 -
178 - return $error;
291 + // Step 4: Process and validate each item
292 + if ( ! is_array( $input_data['items'] ) || count( $input_data['items'] ) == 0 ) {
293 + return new WP_Error( 'items', __( 'Items are required', 'booktics' ) );
179 294 }
180 295
181 - foreach ( $data['items'] as $key => $item ) {
182 - $item_validate = booktics_validate(
183 - $item, array(
184 - 'date' => array( 'required' ),
185 - 'start_time' => array( 'required' ),
186 - 'end_time' => array( 'required' ),
187 - 'team_member_id' => array( 'required' ),
188 - 'service_id' => array( 'required' ),
189 - )
296 + foreach ( $input_data['items'] as $key => $item ) {
297 + // Validate required fields for each item
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' ),
190 304 );
191 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 +
192 322 if ( is_wp_error( $item_validate ) ) {
193 323 return $item_validate;
194 324 }
195 325
326 + // Convert times to UTC
196 327 $converted_start_time = Time_Zone_Handler::convert_to_utc(
197 328 $item['start_time'],
198 329 $item['date'],
199 - $data['timezone']
330 + $input_data['timezone']
200 331 );
201 - $converted_end_time = Time_Zone_Handler::convert_to_utc(
332 + $converted_end_time = Time_Zone_Handler::convert_to_utc(
202 333 $item['end_time'],
203 334 $item['date'],
204 - $data['timezone']
335 + $input_data['timezone']
205 336 );
206 - $item['start_time'] = $converted_start_time['time'];
207 - $item['end_time'] = $converted_end_time['time'];
208 337
209 - $start_time = strtotime( $item['start_time'] );
210 - $end_time = strtotime( $item['end_time'] );
338 + // Validate time range
339 + $start_time = strtotime( $converted_start_time['time'] );
340 + $end_time = strtotime( $converted_end_time['time'] );
211 341
212 342 if ( ! $start_time || ! $end_time || $start_time >= $end_time ) {
213 - $error->add( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
343 + return new WP_Error( 'time_invalid', __( 'Invalid time range provided', 'booktics' ) );
344 + }
214 345
215 - return $error;
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 );
350 +
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;
216 356 }
217 357
218 - $data['items'][ $key ]['start_time'] = $converted_start_time['time'];
219 - $data['items'][ $key ]['end_time'] = $converted_end_time['time'];
220 - $data['items'][ $key ]['date'] = gmdate( 'Y-m-d', strtotime( $converted_start_time['date'] ) );
221 - $data['items'][ $key ]['customer_id'] = absint( $item['customer_id'] );
222 - $data['items'][ $key ]['team_member_id'] = absint( $item['team_member_id'] );
223 - $data['items'][ $key ]['service_id'] = absint( $item['service_id'] );
224 - $data['items'][ $key ]['additional_duration_id'] = $item['additional_duration_id'];
225 - $data['items'][ $key ]['price'] = floatval( $item['price'] );
226 - $data['items'][ $key ]['subtotal'] = floatval( $item['subtotal'] );
227 - $data['items'][ $key ]['quantity'] = absint( $item['quantity'] ?? 1 );
228 - $data['items'][ $key ]['group_booking'] = $item['group_booking'] ?? [];
358 + // Sanitize and prepare item data
359 + $input_data['items'][ $key ] = array(
360 + 'start_time' => $converted_start_time['time'],
361 + 'end_time' => $converted_end_time['time'],
362 + 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
363 + 'customer_id' => isset( $item['customer_id'] ) ? absint( $item['customer_id'] ) : null,
364 + 'team_member_id' => absint( $item['team_member_id'] ),
365 + 'service_id' => absint( $item['service_id'] ),
366 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
367 + 'price' => $amount['price'],
368 + 'subtotal' => $amount['total'],
369 + 'total' => $amount['total'],
370 + 'quantity' => absint( $item['quantity'] ?? 1 ),
371 + 'total_attendees' => $attendees,
372 + );
373 + $input_data['items'][$key] = apply_filters('booktics_pro_appointment_validate', $input_data['items'][$key], $item);
229 374 }
230 375
231 - return $data;
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 +
390 + $input_data = apply_filters( 'booktics_pro_order_validate', $input_data );
391 +
392 + return $input_data;
232 393 }
233 394
395 + /**
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
406 + */
407 + private function resolve_item_attendees( $item, $input_data ) {
408 + $group_booking = is_array( $item['group_booking'] ?? null ) ? $item['group_booking'] : array();
234 409
410 + $attendees = $group_booking['attendee_count']
411 + ?? $item['total_attendees']
412 + ?? $input_data['total_attendees']
413 + ?? 1;
414 +
415 + return max( 1, absint( $attendees ) );
416 + }
417 +
235 418 /**
236 - * Create a new order with the provided data
419 + * Calculate what a booking line actually costs, from the service record.
237 420 *
238 - * @param WP_REST_Request $request
421 + * @param array $item Raw item from the request.
422 + * @param int $attendees Resolved attendee count for this line.
239 423 *
240 - * @return WP_Error|WP_HTTP_Response
241 - * @throws Exception
424 + * @return array|WP_Error array{price: float, total: float}
242 425 */
243 - public function create_item( $request ) {
244 - $input_data = json_decode( $request->get_body(), true );
245 - $validator = new Order_Validator();
246 - $validation_result = $validator->validate( $input_data );
426 + private function calculate_item_amount( $item, $attendees ) {
427 + $service_id = absint( $item['service_id'] ?? 0 );
428 + $service = Service_Model::find( $service_id );
247 429
248 - if ( is_wp_error( $validation_result ) ) {
249 - return $this->error(
250 - __( 'Order validation failed', 'booktics' ),
251 - 422,
252 - 'Validation',
253 - $validation_result->get_error_messages()
254 - );
430 + if ( ! $service ) {
431 + return new WP_Error( 'service_not_found', __( 'Service not found', 'booktics' ) );
255 432 }
256 433
257 - $db = new Booktics_Database();
258 - $validation = $this->prepare_item_for_order_create( $input_data );
434 + $durations = is_array( $service->additional_durations ) ? $service->additional_durations : array();
259 435
260 - if ( is_wp_error( $validation ) ) {
261 - return $this->error( $validation->get_error_message() );
436 + if ( empty( $durations ) ) {
437 + return new WP_Error( 'invalid_duration', __( 'This service has no bookable duration.', 'booktics' ) );
262 438 }
263 439
264 - if ( ! isset( $input_data['customer_id'] ) ) {
265 - $customer_id = $this->find_or_create_customer( $input_data );
266 - if ( is_wp_error( $customer_id ) ) {
267 - return $this->error( $customer_id->get_error_message() );
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;
268 447 }
269 - $input_data['customer_id'] = $customer_id;
270 448 }
271 - try {
272 - $db->begin_transaction();
273 - // Order creation logic
274 - $input_data['currency'] = $input_data['currency'] ?? 'usd';
275 - $order_model = new Order_Model( $db );
276 - $order_model = $order_model->create(
277 - array(
278 - 'customer_id' => $input_data['customer_id'],
279 - 'payment_status' => 'pending',
280 - 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
281 - 'tax_total' => $input_data['tax_total'] ?? 0,
282 - 'coupon_code' => $input_data['coupon_code'] ?? '',
283 - 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
284 - 'subtotal' => $input_data['subtotal'],
285 - 'timezone' => $input_data['timezone'] ?? 'UTC',
286 - 'total' => $input_data['total'],
287 - 'payment_method' => $input_data['payment_method'] ?? null,
288 - )
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' ) );
455 + }
456 + $selected = reset( $durations );
457 + }
458 +
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 + );
471 + }
472 +
473 + /**
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
483 + */
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;
489 + }
490 +
491 + if ( ! empty( $group_booking['charge_per_attendee'] ) ) {
492 + return 1;
493 + }
494 +
495 + return max( 1, (int) $attendees );
496 + }
497 +
498 + /**
499 + * Checks if group booking is enabled for a services group booking meta data
500 + *
501 + * @param array $group_booking_meta Group booking meta data
502 + *
503 + * @return bool True if group booking is enabled, false otherwise
504 + */
505 + private function is_group_booking_enabled( $group_booking_meta ) {
506 + return !empty( $group_booking_meta ) && is_array( $group_booking_meta ) && !empty( $group_booking_meta['enable_group_booking'] );
507 + }
508 +
509 + /**
510 + * Resolve the customer ID from input data, creating or finding the customer as needed
511 + *
512 + * @param array $input_data
513 + *
514 + * @return int|WP_Error
515 + */
516 + private function resolve_customer_id( &$input_data ) {
517 + if ( isset( $input_data['customer_id'] ) ) {
518 + return $input_data['customer_id'];
519 + }
520 + if ( ! isset( $input_data['customer'] ) ) {
521 + return new WP_Error( 'missing_customer', __( 'Customer information is required', 'booktics' ) );
522 + }
523 +
524 + return $this->find_or_create_customer( $input_data['customer'] );
525 + }
526 +
527 + /**
528 + * Create the order and its associated appointments
529 + *
530 + * @param array $input_data
531 + * @param Booktics_Database $db
532 + *
533 + * @return array [Order_Model $order_model, array $appointments]
534 + */
535 + private function create_order_and_appointments( $input_data, $db ) {
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();
539 + $order_model = new Order_Model( $db );
540 +
541 + $input_timezone_string = $input_data['timezone'] ?? 'UTC';
542 + $input_timezone = Time_Zone_Handler::get_timezone_from_string( $input_timezone_string );
543 + $original_timezone = $input_data['original_timezone'] ?? $input_timezone_string;
544 +
545 + $order_data = array(
546 + 'customer_id' => $input_data['customer_id'],
547 + 'payment_status' => Payment_Status::FAILED,
548 + 'price_breakdown' => $input_data['price_breakdown'] ?? array(),
549 + 'status' => 'open',
550 + 'tax_total' => $input_data['tax_total'] ?? 0,
551 + 'coupon_code' => $input_data['coupon_code'] ?? '',
552 + 'coupon_discount' => $input_data['coupon_discount'] ?? 0,
553 + 'subtotal' => $input_data['subtotal'],
554 + 'timezone' => $input_timezone,
555 + 'total' => $input_data['total'],
556 + 'currency' => $input_data['currency'],
557 + 'payment_method' => $input_data['payment_method'] ?? null,
558 + );
559 +
560 + // This is to only allow setting payment status when creating order from admin panel as admin
561 + if ( isset( $input_data['payment_status'] ) && current_user_can( 'manage_options' ) ) {
562 + $order_data['payment_status'] = $input_data['payment_status'];
563 + }
564 +
565 + $order_data = apply_filters( 'booktics_pro_order_columns', $order_data, $input_data );
566 + $order_model = $order_model->create( $order_data );
567 + $appointments = array();
568 + foreach ( $input_data['items'] as $item ) {
569 + if ( isset( $item['uuid'] ) ) {
570 + ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
571 + }
572 + $total_duration_min = $this->calculate_total_duration( $item );
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 +
583 + $appointment_data = array(
584 + 'start_time' => $start_local->format( $time_format ),
585 + 'end_time' => $end_local->format( $time_format ),
586 + 'date' => $date_local->format( 'Y-m-d' ),
587 + 'customer_id' => $input_data['customer_id'] ?? '',
588 + 'team_member_id' => $item['team_member_id'] ?? '',
589 + 'service_id' => $item['service_id'] ?? '',
590 + 'additional_duration_id' => booktics_sanitize_id_string( $item['additional_duration_id'] ?? '' ),
591 + 'duration' => $total_duration_min,
592 + 'order_id' => $order_model->id,
593 + 'quantity' => $item['quantity'] ?? 1,
594 + 'price' => $item['price'] ?? 0,
595 + 'subtotal' => $item['subtotal'] ?? 0,
596 + 'total' => $item['total'] ?? $item['subtotal'] ?? 0,
597 + 'status' => booktics_get_option( 'appointment_status', 'pending' ),
598 + 'total_attendees' => $item['total_attendees'] ?? $input_data['total_attendees'] ?? 1,
599 + 'timezone' => $original_timezone,
289 600 );
290 601
291 - foreach ( $input_data['items'] as $item ) {
292 - if ( isset( $item['uuid'] ) ) {
293 - ( new Cart_Manager() )->remove_cart_item( $item['uuid'] );
294 - }
295 - $total_duration_min = $this->calculate_total_duration( $item );
296 - $appointment_data = array(
297 - 'start_time' => $item['start_time'] ?? '',
298 - 'end_time' => $item['end_time'] ?? '',
299 - 'date' => gmdate( 'Y-m-d', strtotime( $item['date'] ) ),
300 - 'customer_id' => $input_data['customer_id'] ?? '',
301 - 'team_member_id' => $item['team_member_id'] ?? '',
302 - 'service_id' => $item['service_id'] ?? '',
303 - 'additional_duration_id' => $item['additional_duration_id'] ?? '',
304 - 'duration' => $total_duration_min,
305 - 'order_id' => $order_model->id,
306 - 'quantity' => $item['quantity'] ?? 1,
307 - 'price' => $item['price'] ?? 0,
308 - 'subtotal' => $item['subtotal'] ?? 0,
309 - 'total' => $item['total'] ?? 0,
310 - 'status' => 'pending',
311 - );
312 - $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
313 - Appointment_Model::create( $appointment_data );
602 +
603 +
604 + $appointment_data = apply_filters( 'booktics_appointment_pro_format', $appointment_data, $item );
605 +
606 + $conflict = Appointment_Validator::validate_slot_available( $appointment_data );
607 + if ( $conflict ) {
608 + throw new Exception( esc_html( $conflict->get_error_message() ) );
314 609 }
315 610
316 - if ( $input_data['pay_now'] ) {
317 - $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
318 - if ( $payment_method ) {
319 - $payment_intent = $payment_method->process_payment( $order_model );
320 - if ( $payment_intent['status'] === 'error' ) {
321 - return $this->error( $payment_intent['message'] );
322 - }
323 - if ( $payment_intent['status'] === 'success' ) {
324 - ( new Order_Model( $db ) )->update(
325 - $order_model->id, array(
326 - 'payment_intent_id' => $payment_intent['payment_intent'],
327 - )
328 - );
611 + $appointment = Appointment_Model::create( $appointment_data );
612 + if ( is_wp_error( $appointment ) ) {
613 + throw new Exception( esc_html( $appointment->get_error_message() ) );
614 + }
329 615
330 - ( new Payment_Model( $db ) )->create(
331 - array(
332 - 'order_id' => $order_model->id,
333 - 'customer_id' => $input_data['customer_id'],
334 - 'amount' => $input_data['total'] * 100,
335 - 'currency' => $input_data['currency'],
336 - 'intent_id' => $payment_intent['payment_intent'],
337 - 'transaction_id' => null,
338 - 'payment_method' => $input_data['payment_method'],
339 - 'status' => 'pending',
340 - 'date' => gmdate( 'Y-m-d' ),
341 - 'date_time' => gmdate( 'Y-m-d H:i:s' ),
342 - )
343 - );
344 - }
345 - } else {
346 - return $this->error( __( 'Payment method can not found!', 'booktics' ) );
347 - }
348 - }
616 + do_action( 'booktics_after_appointment_created', $appointment );
617 + $appointments[] = $appointment;
618 + }
349 619
350 - $db->commit();
351 - } catch ( Exception $e ) {
352 - $db->roll_back();
620 + return array( $order_model, $appointments );
621 + }
353 622
354 - return $this->error( $e->getMessage() );
623 +
624 + /**
625 + * Process payment for an order if immediate payment is requested
626 + *
627 + * @param array $input_data Order input data containing payment details
628 + * @param Order_Model $order_model Order model instance
629 + * @param Booktics_Database $db Database instance
630 + *
631 + * @return array
632 + * @throws Exception
633 + */
634 + private function handle_payment_if_needed( $input_data, $order_model, $db ) {
635 + if ( ! isset( $input_data['pay_now'] ) || ! $input_data['pay_now'] ) {
636 + return array();
355 637 }
356 - do_action( 'booktics_order_created', $order_model );
357 638
358 - $customer = ( new Customer_Model() )->find( $input_data['customer_id'] );
359 - $appointments = ( new Appointment_Model() )->where( 'order_id', $order_model->id );
639 + $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $input_data['payment_method'] );
640 + if ( ! $payment_method ) {
641 + throw new Exception( esc_html__( 'Payment method cannot be found!', 'booktics' ) );
642 + }
643 +
644 + $payment_intent = $payment_method->process_payment( $order_model );
645 +
646 + if ( $payment_intent['status'] === 'error' ) {
647 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is not directly output; escaping is handled at the display layer
648 + throw new Exception( $payment_intent['message'] );
649 + }
650 +
651 + if ( $payment_intent['status'] === 'success' ) {
652 + $this->update_payment_records( $db, $order_model, $input_data, $payment_intent );
653 + // Do NOT set fully_paid here — PaymentIntent created ≠ payment collected.
654 + // Status is updated after client-side confirmation via update_payment_status endpoint.
655 + }
656 +
657 + return $payment_intent;
658 + }
659 +
660 +
661 + /**
662 + * Update payment records after successful payment processing
663 + *
664 + * @param Booktics_Database $db Database instance
665 + * @param Order_Model $order_model Order model instance
666 + * @param array $input_data Order input data containing payment details
667 + * @param array $payment_intent Payment intent data from payment processor
668 + *
669 + * @return void
670 + */
671 + private function update_payment_records( $db, $order_model, $input_data, $payment_intent ) {
672 + ( new Order_Model( $db ) )->update(
673 + $order_model->id,
674 + array( 'payment_intent_id' => $payment_intent['payment_intent'] ?? null )
675 + );
676 +
677 + ( new Payment_Model( $db ) )->create(
678 + array(
679 + 'order_id' => $order_model->id,
680 + 'customer_id' => $input_data['customer_id'],
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,
684 + 'transaction_id' => null,
685 + 'payment_method' => $input_data['payment_method'],
686 + 'status' => $payment_intent['status'] == 'success' ? 'completed' : 'pending',
687 + 'date' => gmdate( 'Y-m-d' ),
688 + 'date_time' => gmdate( 'Y-m-d H:i:s' ),
689 + )
690 + );
691 + }
692 +
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 + /**
706 + * Helper: Format a single appointment for API output
707 + *
708 + * @param Appointment_Model|array $appointment
709 + *
710 + * @return array
711 + */
712 + private function format_appointment( $appointment ): array {
713 + return Order_Notifier::format_appointment( $appointment );
714 + }
715 +
716 + /**
717 + * Helper: Format order for API output
718 + *
719 + * @param Order_Model $order_model
720 + * @param array $input_data
721 + * @param array $payment_intent
722 + *
723 + * @return array
724 + */
725 + private function format_order_response( $db, $order_model, $input_data, $payment_intent ): array {
726 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $input_data['customer_id'] ) );
727 + // Ensure $order_model is an object before accessing properties
728 + $order_id = is_object( $order_model ) ? $order_model->id : ( is_array( $order_model ) ? $order_model['id'] : null );
729 + $appointments = ( new Appointment_Model() )->where( 'order_id', $order_id );
360 730 $appointment_details = array();
361 731 foreach ( $appointments as $appointment ) {
362 - $appointment = $appointment->to_array();
363 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
364 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
365 - $service = $service ? $service->to_array() : array();
366 - $additional_durations = $service['additional_durations'] ?? array();
367 - $additional_durations = ! empty( $additional_durations )
368 - ? array_filter(
369 - $additional_durations, function ( $duration ) use ( $appointment ) {
370 - return $duration['id'] === $appointment['additional_duration_id'];
371 - }
372 - ) : array();
373 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
374 - $formatted_appointment = array(
375 - 'id' => $appointment['id'],
376 - 'date' => $appointment['date'],
377 - 'start_time' => $appointment['start_time'],
378 - 'end_time' => $appointment['end_time'],
379 - 'additional_duration_id' => $appointment['additional_duration_id'],
380 - 'team' => array(
381 - 'id' => $team_member->id,
382 - 'display_name' => $team_member->display_name ?? '',
383 - 'image' => $team_member->image ?? '',
384 - ),
385 - 'service' => array(
386 - 'id' => $appointment['service_id'],
387 - 'title' => $service['title'] ?? '',
388 - 'additional_duration' => $additional_duration,
389 - ),
390 - 'duration' => $appointment['duration'],
391 - 'quantity' => $appointment['quantity'],
392 - 'price' => $appointment['price'],
393 - 'subtotal' => $appointment['subtotal'],
394 - 'total' => $appointment['total'],
395 - 'status' => $appointment['status'],
396 - );
397 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
398 - $appointment_details[] = $formatted_appointment;
732 + $appointment_details[] = $this->format_appointment( $appointment );
399 733 }
400 734
401 - do_action(
402 - 'global_notification_hook', 'order_created', array(
403 - 'order_no' => $order_model->order_no,
404 - 'order_date' => booktics_datetime( 'M j, Y, g:ia', time(), 'UTC' ),
405 - 'order_total' => $input_data['total'],
406 - 'user_email' => $customer->user_email,
407 - )
408 - );
735 + $order_no_for_key = is_object( $order_model ) ? $order_model->order_no : ( $order_model['order_no'] ?? '' );
409 736
410 - $response = array(
411 - 'id' => $order_model->id,
412 - 'order_no' => $order_model->order_no,
413 - 'customer_email' => $customer->user_email,
414 - 'subtotal' => $order_model->subtotal,
415 - 'total' => $order_model->total,
416 - 'payment_status' => $order_model->payment_status,
737 + $order_data = array(
738 + 'id' => $order_id,
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 ),
741 + 'customer' => array(
742 + 'id' => $customer->id ?? null,
743 + 'name' => $customer->name ?? '',
744 + 'phone' => $customer->phone ?? '',
745 + 'email' => $customer->email ?? '',
746 + ),
747 + 'status' => is_object( $order_model ) ? $order_model->status : ( $order_model['status'] ?? null ),
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 ),
755 + 'total' => is_object( $order_model ) ? $order_model->total : ( $order_model['total'] ?? null ),
756 + 'payment_status' => is_object( $order_model ) ? $order_model->payment_status : ( $order_model['payment_status'] ?? null ),
757 + 'payment_method' => is_object( $order_model ) ? $order_model->payment_method : $order_model['payment_method'],
417 758 'items' => $appointment_details,
418 759 'payment_intent_id' => $payment_intent['payment_intent'] ?? '',
419 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'] ?? '',
764 + 'total_services' => $input_data['total_services'] ?? 1,
420 765 );
421 766
422 - return $this->response( $response, __( 'Order created successfully', 'booktics' ), 201 );
767 + $order_data = apply_filters( 'booktics_pro_order_format', $order_data, $order_model );
768 +
769 + return $order_data;
423 770 }
424 771
425 772 /**
773 + * Helper: Send notifications for order and appointment creation
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
779 + * @param Order_Model $order_model
780 + * @param int $customer_id
781 + * @param array $appointments
782 + *
783 + * @return void
784 + */
785 + private function send_notifications( $db, $order_model, $customer_id, $appointments ): void {
786 + Order_Notifier::order_created( $db, $order_model, $customer_id, $appointments );
787 + }
788 +
789 + /**
426 790 * Find existing customer by email or create new customer
427 791 *
428 - * @param array $data
792 + * @param array $customer
429 793 *
430 794 * @return int|WP_Error
431 795 */
432 - private function find_or_create_customer( array $data ) {
433 - $customer = get_user_by( 'email', $data['customer_email'] );
796 + private function find_or_create_customer( array $customer ) {
797 + $db = new Booktics_Database();
798 + if ( ! isset( $customer['email'] ) ) {
799 + return new WP_Error( 'invalid_email', __( 'Customer email is required', 'booktics' ) );
800 + }
801 + $user_login = User_Model::generate_username( $customer['email'] );
802 + $allow_customer_as_wp_user = booktics_get_option( 'allow_customer_as_wp_user', false );
803 + $wp_user_id = null;
804 + if ( $allow_customer_as_wp_user ) {
805 + $wp_user_id = $this->get_or_create_wp_user( $customer, $user_login );
806 + }
807 + $guest = $this->find_and_update_guest( $db, $customer, $wp_user_id );
808 + if ( $guest ) {
809 + return $guest->id;
810 + }
811 + $guest = $this->create_guest( $db, $customer, $user_login, $wp_user_id );
434 812
435 - if ( $customer ) {
436 - return $customer->ID;
813 + return $guest->id;
814 + }
815 +
816 + /**
817 + * Get or create a WordPress user for the customer
818 + *
819 + * @param array $customer
820 + * @param string $user_login
821 + *
822 + * @return int WP user ID
823 + */
824 + private function get_or_create_wp_user( $customer, $user_login ) {
825 + $wp_user = get_user_by( 'email', $customer['email'] );
826 + if ( $wp_user ) {
827 + return $wp_user->ID;
437 828 }
829 + $userdata = array(
830 + 'user_login' => $user_login,
831 + 'user_email' => $customer['email'],
832 + 'first_name' => $customer['name'] ?? '',
833 + 'display_name' => $customer['name'] ?? '',
834 + 'phone' => $customer['phone'] ?? '',
835 + );
836 + $customer_model = new Customer_Model();
837 + $customer_id = $customer_model->save( $userdata );
838 + ( new Customer_Event_Handler() )->on_customer_create( $customer_id );
438 839
439 - $userdata = array(
440 - 'user_login' => User_Model::generate_username( $data['customer_email'] ),
441 - 'user_email' => $data['customer_email'],
442 - 'user_pass' => wp_generate_password(),
443 - 'first_name' => $data['customer_name'] ?? '',
444 - 'display_name' => $data['customer_name'] ?? '',
445 - 'phone' => $data['customer_phone'] ?? '',
446 - );
840 + return $customer_id;
841 + }
447 842
448 - $customer = new Customer_Model();
843 + /**
844 + * Find and update a guest record by email
845 + *
846 + * @param Booktics_Database $db
847 + * @param array $customer
848 + *
849 + * @return object|null Guest_Model instance or null
850 + */
851 + private function find_and_update_guest( $db, $customer, $wp_user_id = null ) {
852 + $guest = ( new Guest_Model( $db ) )->find( array( 'email' => $customer['email'] ) );
853 + if ( $guest && $this->caller_owns_customer_email( $guest, $customer['email'] ) ) {
854 + ( new Guest_Model( $db ) )->update(
855 + $guest->id, array(
856 + 'name' => $customer['name'] ?? '',
857 + 'phone' => $customer['phone'] ?? '',
858 + 'wp_user_id' => $wp_user_id,
859 + )
860 + );
861 + }
449 862
450 - return $customer->save( $userdata );
863 + return $guest;
451 864 }
452 865
453 866 /**
454 - * Check if user has permission to get orders
867 + * Determine whether the current request is authorized to overwrite an
868 + * existing customer's stored contact details (name, phone, wp_user_id).
455 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 + /**
899 + * Create a new guest record
900 + *
901 + * @param Booktics_Database $db
902 + * @param array $customer
903 + * @param string $user_login
904 + * @param int|null $wp_user_id
905 + *
906 + * @return object Guest_Model instance
907 + */
908 + private function create_guest( $db, $customer, $user_login, $wp_user_id ) {
909 + return ( new Guest_Model( $db ) )->create(
910 + array(
911 + 'wp_user_id' => $wp_user_id,
912 + 'email' => $customer['email'],
913 + 'name' => $customer['name'] ?? '',
914 + 'user_login' => $user_login,
915 + 'phone' => $customer['phone'] ?? '',
916 + )
917 + );
918 + }
919 +
920 + /**
921 + * Check if user has permission to list orders.
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 + *
456 927 * @param WP_REST_Request $request
457 928 *
458 - * @return bool
929 + * @return bool|WP_Error
459 930 */
460 931 public function get_items_permission( $request ) {
461 - return true;
932 + if ( is_user_logged_in() ) {
933 + return true;
934 + }
935 +
936 + return new WP_Error(
937 + 'booktics_forbidden',
938 + __( 'You are not allowed to view orders.', 'booktics' ),
939 + array( 'status' => 403 )
940 + );
462 941 }
463 942
464 943 /**
465 - * Get paginated list of orders
944 + * Get paginated list of orders.
466 945 *
467 946 * @param WP_REST_Request $request
468 947 *
469 948 * @return WP_Error|WP_HTTP_Response
@@ -468,20 +947,20 @@
468 947 *
469 948 * @return WP_Error|WP_HTTP_Response
470 949 */
471 950 public function get_items( $request ) {
472 - $page = (int) $request->get_param( 'paged' );
473 - $per_page = (int) $request->get_param( 'per_page' );
474 - $payment_status = $request->get_param( 'payment_status' );
475 - $payment_method = $request->get_param( 'payment_method' );
476 - $order_date = $request->get_param( 'order_date' );
477 - $team_member = (int) $request->get_param( 'team_member' );
478 - $service = (int) $request->get_param( 'service' );
479 - $search = $request->get_param( 'search' );
951 + $page = (int) $request->get_param( 'paged' ) === 0 ? 1 : absint( $request->get_param( 'paged' ) );
952 + $per_page = (int) $request->get_param( 'per_page' ) === 0 ? 10 : absint( $request->get_param( 'per_page' ) );
953 + $payment_status = sanitize_text_field( $request->get_param( 'payment_status' ) );
954 + $payment_method = sanitize_text_field( $request->get_param( 'payment_method' ) );
955 + $order_date = sanitize_text_field( $request->get_param( 'order_date' ) );
956 + $team_member = absint( $request->get_param( 'team_member' ) );
957 + $service = absint( $request->get_param( 'service' ) );
958 + $search = sanitize_text_field( $request->get_param( 'search' ) );
480 959
481 - $db = new Booktics_Database();
482 - $orderModel = new Order_Model( $db );
483 - $conditions = array();
960 + $db = new Booktics_Database();
961 + $order_model = new Order_Model( $db );
962 + $conditions = array();
484 963
485 964 if ( $payment_status ) {
486 965 $conditions['payment_status'] = $payment_status;
487 966 }
@@ -497,12 +976,40 @@
497 976 if ( $search ) {
498 977 $conditions['order_no'] = array( 'LIKE', '%' . $search . '%' );
499 978 }
500 979
501 - $orders = $orderModel->get(
980 + $conditions = apply_filters( 'booktics_order_filters', $conditions );
981 +
982 + // Scope to customer's own orders when not admin/team-member
983 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
984 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
985 + if ( ! $guest ) {
986 + return $this->response(
987 + array( 'items' => array(), 'total' => 0, 'total_pages' => 0 ),
988 + __( 'Orders fetched successfully', 'booktics' )
989 + );
990 + }
991 + $conditions['customer_id'] = $guest->id;
992 + }
993 +
994 + // Scope to team member's assigned orders - only return orders that contain
995 + // at least one appointment where team_member_id equals the current user's team member ID
996 + $is_team_member_scope = false;
997 + $team_member_filter_id = null;
998 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
999 + $current_user_id = get_current_user_id();
1000 + $team_member_model = new Team_Member_Model();
1001 + $team_member = $team_member_model->find( array( 'wp_user_id' => $current_user_id ) );
1002 + if ( $team_member ) {
1003 + $is_team_member_scope = true;
1004 + $team_member_filter_id = $team_member->id;
1005 + }
1006 + }
1007 +
1008 + $orders = $order_model->get(
502 1009 $conditions, $page, $per_page, array(
503 - 'created_at',
504 - 'DESC',
1010 + 'created_at',
1011 + 'DESC',
505 1012 )
506 1013 );
507 1014
508 1015 if ( $team_member || $service ) {
@@ -529,14 +1036,28 @@
529 1036 $orders['total_pages'] = ceil( $orders['total'] / $per_page );
530 1037 }
531 1038
532 1039 foreach ( $orders['items'] as &$order ) {
533 - $customer = ( new Customer_Model() )->find( $order->customer_id );
534 - $order->customer = array(
535 - 'display_name' => $customer ? $customer->display_name : '',
536 - 'email' => $customer ? $customer->user_email : '',
537 - );
1040 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1041 + $customer_data_for_order = [
1042 + 'id' => $customer ? $customer->id : null,
1043 + 'name' => $customer ? $customer->name : '',
1044 + 'email' => '',
1045 + 'phone' => '',
1046 + ];
1047 + // Admins and team members see email and phone
1048 + if ( booktics_is_admin_or_team_member() ) {
1049 + $customer_data_for_order['email'] = $customer ? $customer->email : '';
1050 + $customer_data_for_order['phone'] = $customer ? $customer->phone : '';
1051 + }
1052 + $order->customer = $customer_data_for_order;
538 1053 $order->total_services = ( new Appointment_Model() )->get_appointments_count_by_order_id( $order->id );
1054 +
1055 + // Hide payment intent fields from team members (not admins)
1056 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1057 + $order->payment_intent_id = '';
1058 + $order->client_secret = '';
1059 + }
539 1060 }
540 1061
541 1062 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
542 1063 }
@@ -541,9 +1062,9 @@
541 1062 return $this->response( $orders, __( 'Orders fetched successfully', 'booktics' ) );
542 1063 }
543 1064
544 1065 /**
545 - * Check if user has permission to update order
1066 + * Check if user has permission to update order.
546 1067 *
547 1068 * @param WP_REST_Request $request
548 1069 *
549 1070 * @return bool
@@ -548,13 +1069,13 @@
548 1069 *
549 1070 * @return bool
550 1071 */
551 1072 public function update_item_permission( $request ) {
552 - return true;
1073 + return current_user_can( 'manage_options' );
553 1074 }
554 1075
555 1076 /**
556 - * Update an existing order
1077 + * Update an existing order.
557 1078 *
558 1079 * @param WP_REST_Request $request
559 1080 *
560 1081 * @return WP_Error|WP_HTTP_Response
@@ -559,9 +1080,9 @@
559 1080 *
560 1081 * @return WP_Error|WP_HTTP_Response
561 1082 */
562 1083 public function update_item( $request ) {
563 - $order_id = (int) $request->get_param( 'id' );
1084 + $order_id = absint( $request->get_param( 'id' ) );
564 1085 $data = json_decode( $request->get_body(), true );
565 1086 $db = new Booktics_Database();
566 1087 $order_model = new Order_Model( $db );
567 1088 $order = $order_model->find( array( 'id' => $order_id ) );
@@ -569,31 +1090,122 @@
569 1090 if ( ! $order ) {
570 1091 return $this->error( __( 'Order not found', 'booktics' ) );
571 1092 }
572 1093
1094 + $status = isset( $data['status'] ) ? sanitize_text_field( $data['status'] ) : $order->status;
1095 + $payment_status = isset( $data['payment_status'] ) ? sanitize_text_field( $data['payment_status'] ) : $order->payment_status;
1096 +
573 1097 $order_model->update(
574 1098 $order_id, array(
575 - 'status' => $data['status'] ?? $order->status,
576 - 'payment_status' => $data['payment_status'] ?? $order->payment_status,
1099 + 'status' => $status,
1100 + 'payment_status' => $payment_status,
577 1101 )
578 1102 );
579 1103
580 - return $this->response( $order_model->find( array( 'id' => $order_id ) ), __( 'Order updated successfully', 'booktics' ) );
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 +
1109 + if ( isset( $data['customer'] ) && isset( $data['customer']['id'] ) ) {
1110 + $customer_id = absint( $data['customer']['id'] );
1111 + $customer_data = $data['customer'];
1112 + $customer_data['id'] = $customer_id;
1113 + if ( isset( $customer_data['name'] ) ) {
1114 + $customer_data['name'] = sanitize_text_field( $customer_data['name'] );
1115 + }
1116 + if ( isset( $customer_data['email'] ) ) {
1117 + $customer_data['email'] = sanitize_email( $customer_data['email'] );
1118 + }
1119 + if ( isset( $customer_data['phone'] ) ) {
1120 + $customer_data['phone'] = sanitize_text_field( $customer_data['phone'] );
1121 + }
1122 + ( new Guest_Model( $db ) )->update( $customer_id, $customer_data );
1123 + }
1124 +
1125 + $order_model = $order_model->find( array( 'id' => $order_id ) );
1126 + $data['customer_id'] = $order_model->customer_id;
1127 +
1128 + $response = $this->format_order_response($db, $order_model, $data, null);
1129 +
1130 + // Hide payment intent fields from team members (not admins)
1131 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1132 + $response['payment_intent_id'] = '';
1133 + $response['client_secret'] = '';
1134 + }
1135 +
1136 + return $this->response( $response, __( 'Order updated successfully', 'booktics' ) );
581 1137 }
582 1138
583 1139 /**
584 - * Check if user has permission to get order
1140 + * Check if user has permission to get order.
585 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 + *
586 1146 * @param WP_REST_Request $request
587 1147 *
1148 + * @return bool|WP_Error
1149 + */
1150 + public function get_item_permission( $request ) {
1151 + if ( booktics_is_admin_or_team_member() ) {
1152 + return true;
1153 + }
1154 +
1155 + $order_id = absint( $request->get_param( 'id' ) );
1156 + $db = new Booktics_Database();
1157 + $order = ( new Order_Model( $db ) )->find( array( 'id' => $order_id ) );
1158 +
1159 + if ( ! $order ) {
1160 + return true;
1161 + }
1162 +
1163 + if ( is_user_logged_in() ) {
1164 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $order->customer_id ) );
1165 +
1166 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1167 + return true;
1168 + }
1169 + }
1170 +
1171 + $order_key = sanitize_text_field( (string) $request->get_param( 'order_key' ) );
1172 + if ( $order_key && $this->verify_order_view_key( $order_id, (string) $order->order_no, $order_key ) ) {
1173 + return true;
1174 + }
1175 +
1176 + return new WP_Error(
1177 + 'booktics_forbidden',
1178 + __( 'You are not allowed to view this order.', 'booktics' ),
1179 + array( 'status' => 403 )
1180 + );
1181 + }
1182 +
1183 + /**
1184 + * Non-secret, unguessable key for viewing an order without logging in (returned on create and with GET responses).
1185 + *
1186 + * @param int $order_id
1187 + * @param string $order_no
1188 + *
1189 + * @return string
1190 + */
1191 + private function get_order_view_key( $order_id, $order_no ) {
1192 + return Order_Key::make( $order_id, $order_no );
1193 + }
1194 +
1195 + /**
1196 + * @param int $order_id
1197 + * @param string $order_no
1198 + * @param string $provided_key
1199 + *
588 1200 * @return bool
589 1201 */
590 - public function get_item_permission( $request ) {
591 - return true;
1202 + private function verify_order_view_key( $order_id, $order_no, $provided_key ) {
1203 + return Order_Key::verify( $order_id, $order_no, $provided_key );
592 1204 }
593 1205
594 1206 /**
595 - * Get single order
1207 + * Get single order.
596 1208 *
597 1209 * @param WP_REST_Request $request
598 1210 *
599 1211 * @return WP_Error|WP_HTTP_Response
@@ -598,9 +1210,9 @@
598 1210 *
599 1211 * @return WP_Error|WP_HTTP_Response
600 1212 */
601 1213 public function get_item( $request ) {
602 - $order_id = (int) $request->get_param( 'id' );
1214 + $order_id = absint( $request->get_param( 'id' ) );
603 1215 $db = new Booktics_Database();
604 1216 $order_model = new Order_Model( $db );
605 1217 $order = $order_model->find( array( 'id' => $order_id ) );
606 1218
@@ -607,91 +1219,77 @@
607 1219 if ( ! $order ) {
608 1220 return $this->error( __( 'Order not found', 'booktics' ) );
609 1221 }
610 1222
611 - $customer = ( new Customer_Model() )->find( $order->customer_id );
612 - $appointments = ( new Appointment_Model() )->where( 'order_id', $order->id );
613 - $appointment_details = array();
1223 + // Use the format_order_response helper for consistency
1224 + $input_data = array( 'customer_id' => $order->customer_id );
1225 + $response = $this->format_order_response( $db, $order, $input_data, array() );
1226 + // Add extra fields specific to GET if needed
1227 + $response['status'] = $order->status;
1228 + $response['created_at'] = $this->format_datetime_to_dayjs_format( $order->created_at );
1229 + $response['payment_gateway'] = $order->payment_method;
1230 + $response['payment_method'] = $order->payment_method;
614 1231
615 - foreach ( $appointments as $appointment ) {
616 - $appointment = $appointment->to_array();
617 - $team_member = ( new Team_Member_Model() )->find( $appointment['team_member_id'] );
618 - $service = ( new Service_Model() )->find( $appointment['service_id'] );
619 - $service = $service ? $service->to_array() : array();
620 - $additional_durations = $service['additional_durations'] ?? array();
621 - $additional_durations = ! empty( $additional_durations )
622 - ? array_filter(
623 - $additional_durations, function ( $duration ) use ( $appointment ) {
624 - return $duration['id'] === $appointment['additional_duration_id'];
625 - }
626 - ) : array();
627 - $additional_duration = ! empty( $additional_durations ) ? reset( $additional_durations ) : array();
628 - $formatted_appointment = array(
629 - 'id' => $appointment['id'],
630 - 'date' => $appointment['date'],
631 - 'start_time' => $appointment['start_time'],
632 - 'end_time' => $appointment['end_time'],
633 - 'team' => array(
634 - 'id' => $team_member->id,
635 - 'display_name' => $team_member->display_name ?? '',
636 - 'image' => $team_member->image ?? '',
637 - ),
638 - 'service' => array(
639 - 'id' => $appointment['service_id'],
640 - 'title' => $service['title'] ?? '',
641 - 'additional_duration' => $additional_duration,
642 - ),
643 - 'quantity' => $appointment['quantity'],
644 - 'price' => $appointment['price'],
645 - 'subtotal' => $appointment['subtotal'],
646 - 'total' => $appointment['total'],
647 - 'status' => $appointment['status'],
648 - );
649 - $formatted_appointment = apply_filters( 'booktics_appointment_pro_format', $formatted_appointment, $appointment );
650 - $appointment_details[] = $formatted_appointment;
1232 + // format_order_response() builds this from a freshly created payment
1233 + // intent, which a GET does not have, so read the stored one instead.
1234 + // The team-member guard below still strips it for non-admins.
1235 + $response['payment_intent_id'] = $order->payment_intent_id ?? '';
1236 +
1237 + // Hide payment intent fields from team members (not admins)
1238 + if ( booktics_is_team_member() && ! current_user_can( 'manage_options' ) ) {
1239 + $response['payment_intent_id'] = '';
1240 + $response['client_secret'] = '';
651 1241 }
652 1242
653 - $response = array(
654 - 'id' => $order->id,
655 - 'order_no' => $order->order_no,
656 - 'customer' => array(
657 - 'display_name' => $customer->display_name,
658 - 'email' => $customer->user_email,
659 - ),
660 - 'subtotal' => $order->subtotal,
661 - 'total' => $order->total,
662 - 'payment_status' => $order->payment_status,
663 - 'status' => $order->status,
664 - 'items' => $appointment_details,
665 - 'created_at' => $order->created_at,
666 - 'payment_gateway' => $order->payment_method,
667 - );
668 -
669 1243 return $this->response( $response, __( 'Order fetched successfully', 'booktics' ) );
670 1244 }
671 1245
672 1246 /**
673 - * Check if user has permission to update payment status
1247 + * Check if user has permission to update payment status.
674 1248 *
675 - * @param $request
1249 + * @param WP_REST_Request $request
676 1250 *
677 1251 * @return bool
678 1252 */
679 1253 public function update_payment_status_permission( $request ) {
680 - return true;
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 );
681 1274 }
682 1275
683 1276 /**
684 - * Update payment status for an order
1277 + * Update payment status for an order.
685 1278 *
686 - * @param $request
1279 + * @param WP_REST_Request $request
1280 + *
1281 + * @return WP_Error|WP_HTTP_Response
687 1282 */
688 1283 public function update_payment_status( $request ) {
689 - $data = json_decode( $request->get_body(), true );
690 - $order_id = ! empty( $data['order_id'] ) ? intval( $data['order_id'] ) : 0;
691 - $payment_status = ! empty( $data['payment_status'] ) ? $data['payment_status'] : 0;
692 - $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();
693 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 +
694 1292 $db = new Booktics_Database();
695 1293 $order_model = new Order_Model( $db );
696 1294 $order = $order_model->find( array( 'id' => $order_id ) );
697 1295
@@ -698,9 +1296,15 @@
698 1296 if ( ! $order ) {
699 1297 return $this->error( __( 'Order not found', 'booktics' ) );
700 1298 }
701 1299
702 - if ( ! in_array( $payment_method, array( 'stripe', 'paypal' ) ) ) {
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 +
1306 + if ( ! array_key_exists( $payment_method, ( new Payment_Method_Manager() )->get_payment_methods() ) ) {
703 1307 return $this->error( __( 'Unauthorized payment method', 'booktics' ) );
704 1308 }
705 1309
706 1310 try {
@@ -715,26 +1319,47 @@
715 1319 }
716 1320
717 1321 $order_model->update(
718 1322 $order_id, array(
719 - 'payment_status' => $status,
720 - 'transaction_id' => $data['transaction_id'] ?? null,
1323 + 'payment_status' => $status,
1324 + 'transaction_id' => isset( $data['transaction_id'] ) ? sanitize_text_field( (string) $data['transaction_id'] ) : null,
721 1325 )
722 1326 );
723 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 +
724 1347 // Payment model status and transaction id update
725 1348 return $this->response(
726 1349 array(
727 - 'payment_status' => $status,
1350 + 'payment_status' => $status,
728 1351 ), __( 'Payment status updated successfully', 'booktics' )
729 1352 );
730 1353 } catch ( \Exception $e ) {
731 - return $this->error( $e->getMessage() );
1354 +
1355 + booktics_log( 'An error occurred while updating payment status: ' . $e->getMessage() );
1356 + return $this->error( __( 'An error occurred while updating payment status.', 'booktics' ) );
732 1357 }
733 1358 }
734 1359
735 1360 /**
736 - * Check if user has permission to delete order
1361 + * Check if user has permission to delete order.
737 1362 *
738 1363 * @param WP_REST_Request $request
739 1364 *
740 1365 * @return bool
@@ -739,18 +1364,20 @@
739 1364 *
740 1365 * @return bool
741 1366 */
742 1367 public function delete_item_permission( $request ) {
743 - return true;
1368 + return current_user_can( 'manage_options' );
744 1369 }
745 1370
746 1371 /**
747 - * Delete an order
1372 + * Delete an order.
748 1373 *
749 - * @param $request
1374 + * @param WP_REST_Request $request
1375 + *
1376 + * @return WP_Error|WP_HTTP_Response
750 1377 */
751 1378 public function delete_item( $request ) {
752 - $order_id = (int) $request->get_param( 'id' );
1379 + $order_id = absint( $request->get_param( 'id' ) );
753 1380 $db = new Booktics_Database();
754 1381 $order_model = new Order_Model( $db );
755 1382 $order = $order_model->find( array( 'id' => $order_id ) );
756 1383
@@ -761,17 +1388,17 @@
761 1388 try {
762 1389 $db->begin_transaction();
763 1390
764 1391 // Delete related appointments
765 - $appointmentModel = new Appointment_Model();
766 - $appointments = $appointmentModel->where( 'order_id', $order_id );
1392 + $appointment_model = new Appointment_Model();
1393 + $appointments = $appointment_model->where( 'order_id', $order_id );
767 1394 foreach ( $appointments as $appointment ) {
768 1395 $appointment->delete();
769 1396 }
770 1397
771 1398 // Delete related payments
772 - $paymentModel = new Payment_Model( $db );
773 - $payments = $paymentModel->get( array( 'order_id' => $order_id ) );
1399 + $payment_model = new Payment_Model( $db );
1400 + $payments = $payment_model->get( array( 'order_id' => $order_id ) );
774 1401 foreach ( $payments['items'] as $payment ) {
775 1402 ( new Payment_Model( $db ) )->delete( $payment->id );
776 1403 }
777 1404 // Delete the order
@@ -787,9 +1414,9 @@
787 1414 }
788 1415 }
789 1416
790 1417 /**
791 - * Check if user has permission to bulk delete orders
1418 + * Check if user has permission to bulk delete orders.
792 1419 *
793 1420 * @param WP_REST_Request $request
794 1421 *
795 1422 * @return bool
@@ -794,13 +1421,13 @@
794 1421 *
795 1422 * @return bool
796 1423 */
797 1424 public function bulk_delete_items_permission( $request ) {
798 - return true;
1425 + return current_user_can( 'manage_options' );
799 1426 }
800 1427
801 1428 /**
802 - * Bulk delete orders
1429 + * Bulk delete orders.
803 1430 *
804 1431 * @param WP_REST_Request $request
805 1432 *
806 1433 * @return WP_Error|WP_HTTP_Response
@@ -805,9 +1432,10 @@
805 1432 *
806 1433 * @return WP_Error|WP_HTTP_Response
807 1434 */
808 1435 public function bulk_delete_items( $request ) {
809 - $order_ids = array_map( 'intval', $request->get_param( 'ids' ) );
1436 + $ids_param = $request->get_param( 'ids' );
1437 + $order_ids = is_array( $ids_param ) ? array_map( 'absint', $ids_param ) : array();
810 1438 $db = new Booktics_Database();
811 1439
812 1440 try {
813 1441 $db->begin_transaction();
@@ -820,17 +1448,17 @@
820 1448 continue;
821 1449 }
822 1450
823 1451 // Delete related appointments
824 - $appointmentModel = new Appointment_Model();
825 - $appointments = $appointmentModel->where( 'order_id', $order_id );
1452 + $appointment_model = new Appointment_Model();
1453 + $appointments = $appointment_model->where( 'order_id', $order_id );
826 1454 foreach ( $appointments as $appointment ) {
827 1455 $appointment->delete();
828 1456 }
829 1457
830 1458 // Delete related payments
831 - $paymentModel = new Payment_Model( $db );
832 - $payments = $paymentModel->get( array( 'order_id' => $order_id ) );
1459 + $payment_model = new Payment_Model( $db );
1460 + $payments = $payment_model->get( array( 'order_id' => $order_id ) );
833 1461 foreach ( $payments['items'] as $payment ) {
834 1462 ( new Payment_Model( $db ) )->delete( $payment->id );
835 1463 }
836 1464
@@ -848,30 +1476,60 @@
848 1476 }
849 1477 }
850 1478
851 1479 /**
852 - * Process payment refund
1480 + * Check if the current user may refund an order.
853 1481 *
854 - * @param $request
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
855 1490 */
1491 + public function refund_payment_permission( $request ) {
1492 + return current_user_can( 'manage_options' );
1493 + }
1494 +
1495 + /**
1496 + * Process payment refund for an order.
1497 + *
1498 + * @param WP_REST_Request $request
1499 + *
1500 + * @return WP_Error|WP_HTTP_Response
1501 + */
856 1502 public function refund_payment( $request ) {
857 - $order_id = (int) $request->get_param( 'id' );
1503 + $order_id = absint( $request->get_param( 'id' ) );
858 1504 $db = new Booktics_Database();
859 1505 $order_model = new Order_Model( $db );
860 1506 $order = $order_model->find( array( 'id' => $order_id ) );
861 1507
1508 + if ( ! $order || is_wp_error( $order ) ) {
1509 + return $this->error( __( 'Order not found', 'booktics' ), 404 );
1510 + }
1511 +
862 1512 $payment_method = ( new Payment_Method_Manager() )->get_payment_method( $order->payment_method );
863 1513 if ( ! $payment_method ) {
864 1514 return $this->error( __( 'Invalid payment method!', 'booktics' ) );
865 1515 }
866 1516 $response = $payment_method->process_refund( $order );
867 - if ( $response['status'] === 'error' ) {
868 - 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() );
869 1523 }
870 1524
1525 + if ( ! is_array( $response ) || ( $response['status'] ?? '' ) === 'error' ) {
1526 + return $this->error( $response['message'] ?? __( 'Refund failed', 'booktics' ) );
1527 + }
1528 +
871 1529 $order_model->update(
872 1530 $order_id, array(
873 - 'payment_status' => 'refunded',
1531 + 'payment_status' => Payment_Status::REFUNDED,
874 1532 )
875 1533 );
876 1534
877 1535 return $this->response(
@@ -880,9 +1538,9 @@
880 1538 );
881 1539 }
882 1540
883 1541 /**
884 - * Calculate total duration in minutes for a service appointment
1542 + * Calculate total duration in minutes for a service appointment.
885 1543 *
886 1544 * @param array $item
887 1545 *
888 1546 * @return int
@@ -907,7 +1565,22 @@
907 1565 if ( ! empty( $item['extra_services'] ) ) {
908 1566 $total_duration_min = apply_filters( 'booktics_extra_service_duration', $total_duration_min, $item['extra_services'] );
909 1567 }
910 1568
1569 + $total_duration_min = apply_filters( 'booktics_calculate_total_duration', $total_duration_min, $item );
1570 +
911 1571 return $total_duration_min;
1572 + }
1573 +
1574 + /**
1575 + * Converts a datetime string to ISO 8601 format with WordPress timezone for dayjs compatibility
1576 + *
1577 + * @param string $datetime Datetime string from database (e.g., "2024-01-15 14:00:00")
1578 + *
1579 + * @return string ISO 8601 formatted datetime with timezone offset (e.g., "2024-01-15T14:00:00-05:00")
1580 + */
1581 + private function format_datetime_to_dayjs_format( $datetime ) {
1582 + $dt = new DateTime( $datetime );
1583 + $dt->setTimezone( wp_timezone() );
1584 + return $dt->format( 'Y-m-d H:i:s' );
912 1585 }
913 1586 }