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
booktics / core / appointment / services / appointment-service.php

appointment-service.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/appointment/services/appointment-service.php

297 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Booktics\Appointment\Services;
3
4 use Booktics\Abstracts\Booktics_Database;
5 use WP_Error;
6 use Booktics\Utils\Singleton;
7 use Booktics\Models\Appointment_Model;
8 use Booktics\Models\Guest_Model;
9 use Booktics\Models\Order_Model;
10
11 class Appointment_Service {
12
13 use Singleton;
14
15 /**
16 * Create a new appointment.
17 * @param array $data
18 * @return mixed (Appointment object or WP_Error)
19 */
20 public static function create( array $data ) {
21 // Sanitize and prepare data as needed
22 $appointment = Appointment_Model::create( $data );
23 if ( $appointment && ! is_wp_error( $appointment ) ) {
24 // Ensure a security token is always present for new appointments
25 $appointment->add_security_token_if_not_exists();
26 }
27 return $appointment;
28 }
29
30 /**
31 * Update an existing appointment.
32 * @param int $id
33 * @param array $data
34 * @return mixed (Updated appointment object or WP_Error)
35 */
36 public static function update( int $id, array $data ) {
37 $appointment = Appointment_Model::find( $id );
38 if ( ! $appointment ) {
39 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
40 }
41
42 // Store original status to check if it's being changed
43 $original_status = $appointment->status;
44 $new_status = isset( $data['status'] ) ? $data['status'] : null;
45
46 // Optionally handle customer update
47 if ( ! empty( $data['customer'] ) ) {
48 $db = new Booktics_Database();
49 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
50 if ( $customer ) {
51 $updated_customer = ( new Guest_Model( $db ) )
52 ->update( $appointment->customer_id, $data['customer'] );
53 unset( $data['customer'] );
54 $appointment->customer = $updated_customer;
55 }
56 }
57
58 $updated_appointment = $appointment->update( $data );
59 if ( is_wp_error( $updated_appointment ) ) {
60 return $updated_appointment;
61 }
62
63 // Update associated order status if appointment status changed
64 if ( $new_status && $new_status !== $original_status ) {
65 self::sync_order_status( $appointment, $new_status );
66 }
67
68 return $updated_appointment;
69 }
70
71 /**
72 * Update associated order status when appointment status changes
73 *
74 * @param object $appointment The appointment object
75 * @param string $new_status The new status to set for the order
76 * @return void
77 */
78 private static function sync_order_status( $appointment, string $new_status ) {
79 // Get order_id using fillable property accessor with post meta fallback
80 $order_id = $appointment->order_id ?: get_post_meta( $appointment->id, 'order_id', true );
81
82 if ( empty( $order_id ) ) {
83 return;
84 }
85
86 $order_status = self::map_appointment_status_to_order_status( $new_status );
87
88 if ( null === $order_status ) {
89 return;
90 }
91
92 $db = new Booktics_Database();
93 $order_model = new Order_Model( $db );
94 $order = $order_model->find( array( 'id' => $order_id ) );
95
96 if ( ! $order || is_wp_error( $order ) ) {
97 return;
98 }
99
100 // One order can hold several appointments (cart / group bookings), so it is
101 // only canceled or completed once every appointment on it reached that state.
102 if ( in_array( $order_status, array( 'canceled', 'completed' ), true )
103 && ! self::all_order_appointments_have_status( $order_id, $order_status, $appointment->id ) ) {
104 return;
105 }
106
107 if ( $order->status === $order_status ) {
108 return;
109 }
110
111 // payment_status is intentionally not touched here: it is owned by the payment
112 // flow (gateway callbacks / manual payment update), not by the appointment status.
113 $order_model->update( $order_id, array( 'status' => $order_status ) );
114
115 /**
116 * Fires after an order's status changed.
117 *
118 * Lets an external system that mirrors the order (a WooCommerce order,
119 * for instance) follow the change without polling.
120 *
121 * @param int $order_id Order id.
122 * @param string $new_status Status just written.
123 * @param string $old_status Status it replaced.
124 */
125 do_action( 'booktics_order_status_updated', (int) $order_id, $order_status, $order->status );
126 }
127
128 /**
129 * Map an appointment status onto the order status domain.
130 *
131 * Appointment statuses (pending, approved, canceled, completed, ...) and order
132 * statuses (open, canceled, completed) are separate domains. Writing an appointment
133 * status straight into the order row leaves the order with a value its own UI
134 * cannot represent.
135 *
136 * @param string $appointment_status Appointment status.
137 *
138 * @return string|null Mapped order status, or null when the status has no mapping.
139 */
140 private static function map_appointment_status_to_order_status( string $appointment_status ) {
141 $map = array(
142 'pending' => 'open',
143 'active' => 'open',
144 'approved' => 'open',
145 'not_now' => 'open',
146 'canceled' => 'canceled',
147 'cancelled' => 'canceled',
148 'completed' => 'completed',
149 );
150
151 return $map[ $appointment_status ] ?? null;
152 }
153
154 /**
155 * Check whether every appointment on an order maps to the given order status.
156 *
157 * @param int $order_id Order ID.
158 * @param string $order_status Target order status.
159 * @param int $current_appointment_id Appointment being updated (already known to match).
160 *
161 * @return bool
162 */
163 private static function all_order_appointments_have_status( $order_id, string $order_status, $current_appointment_id ): bool {
164 $appointments = ( new Appointment_Model() )->get_appointments_by_order_id( $order_id );
165
166 foreach ( $appointments as $sibling ) {
167 if ( (int) $sibling->id === (int) $current_appointment_id ) {
168 continue;
169 }
170
171 if ( self::map_appointment_status_to_order_status( (string) $sibling->status ) !== $order_status ) {
172 return false;
173 }
174 }
175
176 return true;
177 }
178
179 /**
180 * Delete an appointment by ID.
181 * @param int $id
182 * @return mixed (Deleted appointment object or WP_Error)
183 */
184 public static function delete( int $id ) {
185 $appointment = Appointment_Model::find( $id );
186 if ( ! $appointment ) {
187 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
188 }
189 $deleted = $appointment->delete();
190 if ( is_wp_error( $deleted ) ) {
191 return $deleted;
192 }
193 return $appointment;
194 }
195
196 /**
197 * Delete multiple appointments by IDs.
198 * @param array $ids
199 * @return array|WP_Error
200 */
201 public static function delete_many( array $ids ) {
202 if ( ! is_array( $ids ) ) {
203 return new WP_Error( 'invalid_ids', __( 'Ids should be an array of items ids', 'booktics' ) );
204 }
205 $count = 0;
206 $total = count( $ids );
207 $deleted = array();
208 foreach ( $ids as $id ) {
209 $appointment = Appointment_Model::find( $id );
210 if ( $appointment ) {
211 $appointment->delete();
212 $deleted[] = $appointment;
213 ++$count;
214 }
215 }
216 if ( $count === 0 ) {
217 return new WP_Error( 'none_deleted', __( 'No item deleted.', 'booktics' ) );
218 }
219 /* translators: 1: deleted count, 2: total count */
220 $message = sprintf( __( 'Deleted %1$d items of %2$d', 'booktics' ), $count, $total );
221 return array(
222 'data' => $deleted,
223 'message' => $message,
224 );
225 }
226
227 /**
228 * Reschedule an appointment.
229 * @param int $id
230 * @param array $data
231 * @return mixed (Updated appointment object or WP_Error)
232 */
233 public static function reschedule( int $id, array $data ) {
234 $appointment = Appointment_Model::find( $id );
235 if ( ! $appointment ) {
236 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
237 }
238 $reschedule_data = array(
239 'start_time' => sanitize_text_field( $data['start_time'] ),
240 'end_time' => sanitize_text_field( $data['end_time'] ),
241 'date' => sanitize_text_field( $data['date'] ),
242 );
243 $customer_reschedule_status = booktics_get_option( 'customer_reschedule_status', array( 'enable' => false ) );
244 if ( $customer_reschedule_status['enable'] ) {
245 $reschedule_data['status'] = $customer_reschedule_status['status'];
246 }
247 $updated = $appointment->update( $reschedule_data );
248 if ( is_wp_error( $updated ) ) {
249 return new WP_Error( 'reschedule_error', __( 'Appointment reschedule error.', 'booktics' ) );
250 }
251 // Optionally fire hooks here
252 return $appointment;
253 }
254
255 /**
256 * Cancel an appointment.
257 * @param int $id
258 * @param array $data Additional data like cancel_note
259 * @return mixed (Updated appointment object or WP_Error)
260 */
261 public static function cancel( int $id, array $data = [] ) {
262 $appointment = Appointment_Model::find( $id );
263 if ( ! $appointment ) {
264 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
265 }
266
267 $update_data = [ 'status' => 'canceled' ];
268
269 if ( ! empty( $data['cancel_note'] ) ) {
270 $update_data['cancel_note'] = sanitize_text_field( $data['cancel_note'] );
271 }
272
273 $updated = $appointment->update( $update_data );
274
275 if ( is_wp_error( $updated ) ) {
276 return new WP_Error( 'cancel_error', __( 'Appointment cancellation error.', 'booktics' ) );
277 }
278
279 // Update associated order status to canceled
280 self::sync_order_status( $appointment, 'canceled' );
281
282 /**
283 * Fires after an appointment is canceled.
284 *
285 * Lets integrations (e.g. Google Calendar sync) react to a cancellation
286 * so external state is kept in sync. This is the single choke point for
287 * every cancel path — abandoned-booking cleanup, failed online payment,
288 * refunds and manual cancels all route through here.
289 *
290 * @param \Booktics\Models\Appointment_Model $appointment The canceled appointment.
291 */
292 do_action( 'booktics_after_appointment_cancelled', $appointment );
293
294 return $appointment;
295 }
296 }
297