PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.2.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.2.0
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Services / BookingService.php

BookingService.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.2.0, at app/Services/BookingService.php

499 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Services;
4
5 use FluentBooking\App\App;
6 use FluentBooking\App\Models\Booking;
7 use FluentBooking\App\Models\CalendarSlot;
8 use FluentBooking\Framework\Support\Arr;
9
10 class BookingService
11 {
12 public static function createBooking($data = [], $calendarSlot = null, $customFieldsData = [])
13 {
14 if (empty($data['email']) || empty($data['start_time']) || empty($data['person_time_zone'])) {
15 throw new \Exception(esc_html__('Email, Start Time and timezone are required to create a booking', 'fluent-booking'), 422);
16 }
17
18 if (!$calendarSlot) {
19 $calendarSlot = CalendarSlot::findOrFail($data['event_id']);
20 }
21
22 $defaults = [
23 'event_id' => $calendarSlot->id,
24 'calendar_id' => $calendarSlot->calendar_id,
25 'host_user_id' => $calendarSlot->user_id
26 ];
27
28 $data = self::prepareBookingData($data, $calendarSlot);
29
30 $guests = Arr::get($data, 'additional_guests', []);
31
32 $bookingData = Arr::only(wp_parse_args($data, $defaults), (new Booking())->getFillable());
33
34 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
35
36 $bookingData['event_type'] = $calendarSlot->event_type;
37
38 $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData, $data);
39
40 if (is_wp_error($bookingData)) {
41 return $bookingData;
42 }
43
44 return self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests);
45 }
46
47 public static function createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests = [], $bookingIds = [])
48 {
49 if (is_array($bookingData['start_time'])) {
50 return self::createMultiTimeBooking($bookingData, $calendarSlot, $customFieldsData, $guests);
51 }
52
53 if (is_array($bookingData['email'])) {
54 return self::createMultiGuestBooking($bookingData, $calendarSlot, $customFieldsData);
55 }
56
57 do_action('fluent_booking/before_booking', $bookingData, $calendarSlot);
58
59 $booking = Booking::create($bookingData);
60
61 self::attachHosts($booking, $calendarSlot);
62 self::updateParentInfo($booking, $bookingIds);
63 self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
64
65 $booking->load('calendar');
66
67 $bookingStatus = $booking->status;
68
69 $bookingData = apply_filters('fluent_booking/after_booking_data', $bookingData, $booking, $calendarSlot, $customFieldsData);
70
71 // this pre hook is for early actions that require for remote calendars and locations
72 do_action('fluent_booking/pre_after_booking_' . $bookingStatus, $booking, $calendarSlot, $bookingData);
73
74 // We are just renewing this as this may have been changed by the pre hook
75 $booking = Booking::find($booking->id);
76
77 if ($bookingStatus != $booking->status) {
78 return $booking;
79 }
80
81 do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
82
83 return $booking;
84 }
85
86 public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
87 {
88 $booking = [];
89 $bookingIds = [];
90 $createdBookingIds = [];
91 $lastBooking = end($data['start_time']);
92 $totalBooking = count($data['start_time']);
93 $bookingTimes = array_combine($data['start_time'], $data['end_time']);
94
95 if ($bookingTimes === false) {
96 throw new \InvalidArgumentException(esc_html__('Booking start and end times are invalid.', 'fluent-booking'));
97 }
98
99 foreach ($bookingTimes as $startTime => $endTime) {
100 $bookingData = $data;
101
102 $bookingData['start_time'] = $startTime;
103 $bookingData['end_time'] = $endTime;
104
105 $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
106 $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
107 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
108
109 if ($startTime == $lastBooking) {
110 $createdBookingIds = $bookingIds;
111 } else {
112 $bookingData['parent_id'] = '';
113 }
114
115 if (Arr::get($data, 'payment_method')) {
116 if ($startTime == $lastBooking) {
117 $bookingData['quantity'] = $totalBooking;
118 } else {
119 $bookingData['payment_status'] = '';
120 $bookingData['payment_method'] = '';
121 }
122 }
123
124 $booking = self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests, $createdBookingIds);
125
126 $bookingIds[] = $booking->id;
127 }
128
129 return $booking;
130 }
131
132 public static function createMultiGuestBooking($data, $calendarSlot, $customFieldsData)
133 {
134 $booking = [];
135 $bookingIds = [];
136 $createdBookingIds = [];
137 $lastBooking = end($data['email']);
138 $totalBooking = count($data['email']);
139 $guests = array_combine($data['email'], $data['first_name']);
140
141 if ($guests === false) {
142 throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
143 }
144
145 foreach ($guests as $email => $name) {
146 $bookingData = $data;
147
148 $bookingData['email'] = $email;
149
150 $nameArray = explode(' ', trim($name));
151 $bookingData['first_name'] = array_shift($nameArray);
152 $bookingData['last_name'] = implode(' ', $nameArray);
153
154 if ($user = get_user_by('email', $email)) {
155 $bookingData['person_user_id'] = $user->ID;
156 }
157
158 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
159
160 if ($email == $lastBooking) {
161 $createdBookingIds = $bookingIds;
162 }
163
164 if (Arr::get($data, 'payment_method')) {
165 if ($email == $lastBooking) {
166 $bookingData['quantity'] = $totalBooking;
167 } else {
168 $bookingData['payment_status'] = '';
169 $bookingData['payment_method'] = '';
170 }
171 }
172
173 $booking = self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, [], $createdBookingIds);
174
175 $bookingIds[] = $booking->id;
176 }
177
178 return $booking;
179 }
180
181 private static function prepareBookingData($data, $calendarSlot)
182 {
183 if (empty($data['first_name']) && !empty($data['name'])) {
184 $nameArray = explode(' ', trim($data['name']));
185 $data['first_name'] = array_shift($nameArray);
186 $data['last_name'] = implode(' ', $nameArray);
187 }
188
189 if (empty($data['slot_minutes'])) {
190 $data['slot_minutes'] = $calendarSlot->duration;
191 }
192
193 if (empty($data['end_time'])) {
194 $data['end_time'] = gmdate('Y-m-d H:i:s', strtotime($data['start_time']) + ($data['slot_minutes'] * 60));
195 }
196
197 if (!isset($data['person_user_id'])) {
198 $user = get_user_by('email', $data['email']);
199 if ($user) {
200 $data['person_user_id'] = $user->ID;
201 if (empty($data['first_name'])) {
202 $data['first_name'] = $user->first_name;
203 $data['last_name'] = $user->last_name;
204 }
205 }
206 }
207
208 if (empty($data['location_details'])) {
209 $data['location_details'] = LocationService::getLocationDetails($calendarSlot, [], []);
210 }
211
212 if ($additionalGuests = Arr::get($data, 'additional_guests', [])) {
213 if ($calendarSlot->isMultiGuestEvent()) {
214 $guestEmails = array_map(function ($guest) {
215 return $guest['email'];
216 }, $additionalGuests);
217 $data['email'] = array_merge($guestEmails, (array) $data['email']);
218
219 $guestNames = array_map(function ($guest) {
220 return $guest['name'];
221 }, $additionalGuests);
222 $data['first_name'] = array_merge($guestNames, (array) ($data['first_name'] . ' ' . $data['last_name']));
223
224 $data['additional_guests'] = [];
225 }
226 }
227
228 return $data;
229 }
230
231 private static function attachHosts($booking, $calendarSlot)
232 {
233 $hosts = [$booking->host_user_id];
234 if ($calendarSlot->isMultiHostsEvent()) {
235 $hosts = $calendarSlot->getHostIds();
236 }
237
238 $hostData = [];
239 foreach ($hosts as $hostId) {
240 $hostData[$hostId] = ['status' => 'confirmed'];
241 }
242
243 $booking->hosts()->attach($hostData);
244 }
245
246 protected static function getGroupId($calendarSlot, $bookingData)
247 {
248 if (!$calendarSlot->isMultiGuestEvent()) {
249 return null;
250 }
251
252 $event = Booking::select('group_id')
253 ->where('event_id', $calendarSlot->id)
254 ->where('calendar_id', $calendarSlot->calendar_id)
255 ->where('start_time', $bookingData['start_time'])
256 ->first();
257
258 return $event ? $event->group_id : null;
259 }
260
261 private static function updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot)
262 {
263 if ($customFieldsData) {
264 Helper::updateBookingMeta($booking->id, 'custom_fields_data', $customFieldsData);
265 }
266
267 if ($guests) {
268 Helper::updateBookingMeta($booking->id, 'additional_guests', $guests);
269 }
270
271 if ($quantity = Arr::get($bookingData, 'quantity')) {
272 Helper::updateBookingMeta($booking->id, 'quantity', $quantity);
273 }
274
275 do_action('fluent_booking/after_booking_meta_update', $booking, $bookingData, $customFieldsData, $calendarSlot);
276 }
277
278 private static function updateParentInfo($booking, $bookingIds)
279 {
280 if (!$bookingIds) {
281 return;
282 }
283
284 Booking::whereIn('id', $bookingIds)->update(['parent_id' => $booking->id]);
285 }
286
287 public static function getBookingConfirmationHtml(Booking $booking, $actionType = 'confirmation')
288 {
289 $validActions = [
290 'confirmation',
291 'cancel',
292 'reschedule'
293 ];
294
295 if (!in_array($actionType, $validActions)) {
296 $actionType = 'confirmation';
297 }
298
299 $calendarSlot = $booking->calendar_event;
300
301 $author = $booking->getHostDetails(false);
302
303 $bookingTitle = $booking->getBookingTitle();
304
305 $sections = [
306 'what' => [
307 'title' => __('What', 'fluent-booking'),
308 'content' => $bookingTitle
309 ],
310 'when' => [
311 'title' => __('When', 'fluent-booking'),
312 'content' => $booking->getFullBookingDateTimeText($booking->person_time_zone, true) . ' (' . $booking->person_time_zone . ')'
313 ],
314 'who' => [
315 'title' => __('Who', 'fluent-booking'),
316 'content' => $booking->getHostAndGuestDetailsHtml()
317 ],
318 'where' => [
319 'title' => __('Where', 'fluent-booking'),
320 'content' => $booking->getLocationDetailsHtml()
321 ]
322 ];
323
324 if ($guests = $booking->getAdditionalGuests(true)) {
325 $sections['guests'] = [
326 'title' => __('Additional Guests', 'fluent-booking'),
327 'content' => $guests
328 ];
329 }
330
331 if ($booking->status == 'cancelled') {
332 // add cancellation reason at the beginning
333 $sections = array_merge([
334 'cancellation_reason' => [
335 'title' => __('Cancellation Reason', 'fluent-booking'),
336 'content' => $booking->getCancelReason(false, true)
337 ]
338 ], $sections);
339 }
340
341 if ($booking->status == 'rejected') {
342 // add rejection reason at the beginning
343 $sections = array_merge([
344 'cancellation_reason' => [
345 'title' => __('Rejection Reason', 'fluent-booking'),
346 'content' => $booking->getRejectReason(false, true)
347 ]
348 ], $sections);
349 }
350
351 if ($booking->message) {
352 $sections['note'] = [
353 'title' => __('Additional Note', 'fluent-booking'),
354 'content' => wpautop($booking->message)
355 ];
356 }
357
358 $customFieldsData = $booking->getCustomFormData(true, true);
359
360 foreach ($customFieldsData as $dataKey => $data) {
361 if (!empty($data['value'])) {
362 $sections[$dataKey] = [
363 'title' => $data['label'],
364 'content' => $data['value']
365 ];
366 }
367 }
368
369 $bookingStatus = $booking->getBookingStatus();
370
371 $subHeading = '';
372 if ($booking->status == 'scheduled') {
373 // translators: %s is the name of the person scheduled
374 $subHeading = sprintf(__('You are scheduled with %s', 'fluent-booking'), $author['name']);
375
376 if ($actionType == 'confirmation' && $calendarSlot->allowMultiBooking()) {
377 $bookingTime = (array) $sections['when']['content'];
378 $sections['when']['content'] = array_merge($bookingTime, $booking->getOtherBookingTimes());
379 }
380 }
381
382 // translators: %s is the status of the meeting
383 $title = sprintf(__('Your meeting has been %s', 'fluent-booking'), $bookingStatus);
384 if ($booking->status == 'pending' && $booking->payment_status != 'pending') {
385 $title = __('Your booking has been submitted', 'fluent-booking');
386 $subHeading = __('Please wait for the host to confirm your booking', 'fluent-booking');
387 }
388
389 $assetsUrl = App::getInstance('url.assets');
390
391 $confirmationData = [
392 'author' => $author,
393 'title' => $title,
394 'sub_heading' => $subHeading,
395 'sections' => $sections,
396 'slot' => $calendarSlot,
397 'booking' => $booking,
398 'message' => __('A confirmation has been sent to your email address along with meeting location details.', 'fluent-booking'),
399 'action_type' => $actionType,
400 'can_cancel' => $booking->canCancel(),
401 'bookmarks' => [],
402 'confirm_icon' => '',
403 'action_url' => '',
404 'extra_html' => ''
405 ];
406
407 if ($booking->payment_status) {
408 $confirmationData['extra_html'] = EditorShortCodeParser::parse('{{payment.receipt_html}}', $booking);
409 }
410
411 if ($actionType == 'cancel') {
412 $confirmationData['title'] = __('Booking Cancellation', 'fluent-booking');
413 $confirmationData['sub_heading'] = __('Confirm and cancel the scheduled booking', 'fluent-booking');
414 $confirmationData['cancel_field'] = BookingFieldService::getBookingFieldByName($calendarSlot, 'cancellation_reason');
415 $confirmationData['action_url'] = add_query_arg([
416 'action' => 'fcal_cancel_meeting',
417 'meeting_hash' => $booking->hash,
418 'scope' => Arr::get($_REQUEST, 'scope') // phpcs:ignore WordPress.Security.NonceVerification.Recommended
419 ], admin_url('admin-ajax.php'));
420 }
421
422 if ($booking->status == 'scheduled' && $actionType == 'confirmation') {
423 $confirmationData['confirm_icon'] = $assetsUrl . '/images/check-mark.png';
424 $confirmationData['bookmarks'] = $booking->getMeetingBookmarks($assetsUrl);
425 }
426
427 if ($booking->status == 'cancelled' || $booking->status == 'rejected') {
428 $confirmationData['confirm_icon'] = $assetsUrl . '/images/cancel-mark.png';
429 }
430
431 $confirmationData = apply_filters('fluent_booking/schedule_receipt_data', $confirmationData, $booking);
432
433 return (string)App::make('view')->make('public.booking_confirmation', $confirmationData);
434 }
435
436 public static function generateBookingICS(Booking $booking)
437 {
438 $author = $booking->getHostDetails(false);
439
440 // Initialize the ICS content
441 $icsContent = "BEGIN:VCALENDAR\r\n";
442 $icsContent .= "VERSION:2.0\r\n";
443 $icsContent .= "PRODID:-//FluentBooking//Fluent Booking//EN\r\n";
444
445 // PUBLISH = plain "add to calendar" event. METHOD:REQUEST makes it an iTIP
446 // invitation bound to the ATTENDEE, which Google Calendar then rejects/mishandles.
447 $icsContent .= "METHOD:PUBLISH\r\n";
448
449 $icsContent .= "BEGIN:VEVENT\r\n";
450 $icsContent .= "STATUS:CONFIRMED\r\n";
451 $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
452 $icsContent .= "DTSTAMP:" . gmdate('Ymd\THis\Z') . "\r\n"; // Required by RFC5545; Google rejects ICS without it
453
454 $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
455
456 // Escape per segment so the existing "\n" line-break escapes are not double-escaped.
457 $descriptionSegments = array_map([self::class, 'escapeIcsText'], explode('\n', $booking->getIcsBookingDescription()));
458 $icsContent .= "DESCRIPTION:" . implode('\n', $descriptionSegments) . "\r\n";
459
460 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
461 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
462 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
463
464 $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
465
466 $organizerEmail = sanitize_email($author['email']) ?: $author['email'];
467 $icsContent .= "ORGANIZER;CN=\"" . self::escapeIcsText($author['name']) . "\":mailto:" . $organizerEmail . "\r\n";
468
469 $icsContent .= "END:VEVENT\r\n";
470
471 // Close the VCALENDAR component
472 $icsContent .= "END:VCALENDAR\r\n";
473
474 return $icsContent;
475 }
476
477 /**
478 * Escape text for use in ICS (iCalendar) content per RFC5545.
479 * Escapes backslash, semicolon, comma and normalizes newlines to \\n.
480 *
481 * @param string $value Raw text value.
482 * @return string Escaped value safe for ICS properties.
483 */
484 public static function escapeIcsText($value)
485 {
486 if (empty($value)) {
487 return '';
488 }
489 $value = (string) $value;
490 // Escape backslash first, then semicolon and comma (RFC5545 special chars).
491 $value = str_replace(['\\', ';', ','], ['\\\\', '\\;', '\\,'], $value);
492 // Normalize line breaks to literal \n in output (ICS uses \\n for newline in text).
493 $value = str_replace(["\r\n", "\r", "\n"], "\\n", $value);
494
495 return $value;
496 }
497
498 }
499