PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.23
Booktics – Appointment Booking Calendar for Service Businesses v1.0.23
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.23, at core/appointment/services/appointment-service.php

285 lines 10.1 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 /**
117 * Map an appointment status onto the order status domain.
118 *
119 * Appointment statuses (pending, approved, canceled, completed, ...) and order
120 * statuses (open, canceled, completed) are separate domains. Writing an appointment
121 * status straight into the order row leaves the order with a value its own UI
122 * cannot represent.
123 *
124 * @param string $appointment_status Appointment status.
125 *
126 * @return string|null Mapped order status, or null when the status has no mapping.
127 */
128 private static function map_appointment_status_to_order_status( string $appointment_status ) {
129 $map = array(
130 'pending' => 'open',
131 'active' => 'open',
132 'approved' => 'open',
133 'not_now' => 'open',
134 'canceled' => 'canceled',
135 'cancelled' => 'canceled',
136 'completed' => 'completed',
137 );
138
139 return $map[ $appointment_status ] ?? null;
140 }
141
142 /**
143 * Check whether every appointment on an order maps to the given order status.
144 *
145 * @param int $order_id Order ID.
146 * @param string $order_status Target order status.
147 * @param int $current_appointment_id Appointment being updated (already known to match).
148 *
149 * @return bool
150 */
151 private static function all_order_appointments_have_status( $order_id, string $order_status, $current_appointment_id ): bool {
152 $appointments = ( new Appointment_Model() )->get_appointments_by_order_id( $order_id );
153
154 foreach ( $appointments as $sibling ) {
155 if ( (int) $sibling->id === (int) $current_appointment_id ) {
156 continue;
157 }
158
159 if ( self::map_appointment_status_to_order_status( (string) $sibling->status ) !== $order_status ) {
160 return false;
161 }
162 }
163
164 return true;
165 }
166
167 /**
168 * Delete an appointment by ID.
169 * @param int $id
170 * @return mixed (Deleted appointment object or WP_Error)
171 */
172 public static function delete( int $id ) {
173 $appointment = Appointment_Model::find( $id );
174 if ( ! $appointment ) {
175 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
176 }
177 $deleted = $appointment->delete();
178 if ( is_wp_error( $deleted ) ) {
179 return $deleted;
180 }
181 return $appointment;
182 }
183
184 /**
185 * Delete multiple appointments by IDs.
186 * @param array $ids
187 * @return array|WP_Error
188 */
189 public static function delete_many( array $ids ) {
190 if ( ! is_array( $ids ) ) {
191 return new WP_Error( 'invalid_ids', __( 'Ids should be an array of items ids', 'booktics' ) );
192 }
193 $count = 0;
194 $total = count( $ids );
195 $deleted = array();
196 foreach ( $ids as $id ) {
197 $appointment = Appointment_Model::find( $id );
198 if ( $appointment ) {
199 $appointment->delete();
200 $deleted[] = $appointment;
201 ++$count;
202 }
203 }
204 if ( $count === 0 ) {
205 return new WP_Error( 'none_deleted', __( 'No item deleted.', 'booktics' ) );
206 }
207 /* translators: 1: deleted count, 2: total count */
208 $message = sprintf( __( 'Deleted %1$d items of %2$d', 'booktics' ), $count, $total );
209 return array(
210 'data' => $deleted,
211 'message' => $message,
212 );
213 }
214
215 /**
216 * Reschedule an appointment.
217 * @param int $id
218 * @param array $data
219 * @return mixed (Updated appointment object or WP_Error)
220 */
221 public static function reschedule( int $id, array $data ) {
222 $appointment = Appointment_Model::find( $id );
223 if ( ! $appointment ) {
224 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
225 }
226 $reschedule_data = array(
227 'start_time' => sanitize_text_field( $data['start_time'] ),
228 'end_time' => sanitize_text_field( $data['end_time'] ),
229 'date' => sanitize_text_field( $data['date'] ),
230 );
231 $customer_reschedule_status = booktics_get_option( 'customer_reschedule_status', array( 'enable' => false ) );
232 if ( $customer_reschedule_status['enable'] ) {
233 $reschedule_data['status'] = $customer_reschedule_status['status'];
234 }
235 $updated = $appointment->update( $reschedule_data );
236 if ( is_wp_error( $updated ) ) {
237 return new WP_Error( 'reschedule_error', __( 'Appointment reschedule error.', 'booktics' ) );
238 }
239 // Optionally fire hooks here
240 return $appointment;
241 }
242
243 /**
244 * Cancel an appointment.
245 * @param int $id
246 * @param array $data Additional data like cancel_note
247 * @return mixed (Updated appointment object or WP_Error)
248 */
249 public static function cancel( int $id, array $data = [] ) {
250 $appointment = Appointment_Model::find( $id );
251 if ( ! $appointment ) {
252 return new WP_Error( 'not_found', __( 'Appointment not found', 'booktics' ) );
253 }
254
255 $update_data = [ 'status' => 'canceled' ];
256
257 if ( ! empty( $data['cancel_note'] ) ) {
258 $update_data['cancel_note'] = sanitize_text_field( $data['cancel_note'] );
259 }
260
261 $updated = $appointment->update( $update_data );
262
263 if ( is_wp_error( $updated ) ) {
264 return new WP_Error( 'cancel_error', __( 'Appointment cancellation error.', 'booktics' ) );
265 }
266
267 // Update associated order status to canceled
268 self::sync_order_status( $appointment, 'canceled' );
269
270 /**
271 * Fires after an appointment is canceled.
272 *
273 * Lets integrations (e.g. Google Calendar sync) react to a cancellation
274 * so external state is kept in sync. This is the single choke point for
275 * every cancel path — abandoned-booking cleanup, failed online payment,
276 * refunds and manual cancels all route through here.
277 *
278 * @param \Booktics\Models\Appointment_Model $appointment The canceled appointment.
279 */
280 do_action( 'booktics_after_appointment_cancelled', $appointment );
281
282 return $appointment;
283 }
284 }
285