PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.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 All 34 releases
fluent-booking / app / Services / BookingService.php

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

562 lines 20.9 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 = Helper::dbTransaction(function () use ($bookingData) {
60 return Booking::create($bookingData);
61 });
62
63 self::attachHosts($booking, $calendarSlot);
64 self::updateParentInfo($booking, $bookingIds);
65 self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
66
67 $booking->load('calendar');
68
69 $bookingStatus = $booking->status;
70 $paymentStatus = $booking->payment_status;
71
72 $bookingData = apply_filters('fluent_booking/after_booking_data', $bookingData, $booking, $calendarSlot, $customFieldsData);
73
74 // this pre hook is for early actions that require for remote calendars and locations
75 do_action('fluent_booking/pre_after_booking_' . $bookingStatus, $booking, $calendarSlot, $bookingData);
76
77 // We are just renewing this as this may have been changed by the pre hook
78 $booking = Booking::find($booking->id);
79
80 if (self::preHookHasDispatched($bookingStatus, $paymentStatus, $booking)) {
81 return $booking;
82 }
83
84 do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
85
86 return $booking;
87 }
88
89 /**
90 * Whether the pre hook already dispatched the lifecycle action, so
91 * dispatching again would notify twice. Status is not the only sign: a
92 * full-price coupon settles payment on a booking that stays pending for
93 * manual confirmation.
94 *
95 * Loose on purpose - payment_status is nullable with no default, and
96 * multi-time child rows are written as ''.
97 *
98 * @return bool
99 */
100 private static function preHookHasDispatched($bookingStatus, $paymentStatus, $booking)
101 {
102 return $bookingStatus != $booking->status
103 || $paymentStatus != $booking->payment_status;
104 }
105
106 public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
107 {
108 $booking = [];
109 $bookingIds = [];
110 $createdBookingIds = [];
111 $lastBooking = end($data['start_time']);
112 $totalBooking = count($data['start_time']);
113 $bookingTimes = array_combine($data['start_time'], $data['end_time']);
114
115 if ($bookingTimes === false) {
116 throw new \InvalidArgumentException(esc_html__('Booking start and end times are invalid.', 'fluent-booking'));
117 }
118
119 foreach ($bookingTimes as $startTime => $endTime) {
120 $bookingData = $data;
121
122 $bookingData['start_time'] = $startTime;
123 $bookingData['end_time'] = $endTime;
124
125 $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
126 $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
127 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
128
129 if ($startTime == $lastBooking) {
130 $createdBookingIds = $bookingIds;
131 } else {
132 $bookingData['parent_id'] = '';
133 }
134
135 if (Arr::get($data, 'payment_method')) {
136 if ($startTime == $lastBooking) {
137 $bookingData['quantity'] = $totalBooking;
138 } else {
139 $bookingData['payment_status'] = '';
140 $bookingData['payment_method'] = '';
141 }
142 }
143
144 $booking = self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, $guests, $createdBookingIds);
145
146 $bookingIds[] = $booking->id;
147 }
148
149 return $booking;
150 }
151
152 public static function createMultiGuestBooking($data, $calendarSlot, $customFieldsData)
153 {
154 $booking = [];
155 $bookingIds = [];
156 $createdBookingIds = [];
157 $lastBooking = end($data['email']);
158 $totalBooking = count($data['email']);
159 $guests = array_combine($data['email'], $data['first_name']);
160
161 if ($guests === false) {
162 throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
163 }
164
165 foreach ($guests as $email => $name) {
166 $bookingData = $data;
167
168 $bookingData['email'] = $email;
169
170 $nameArray = explode(' ', trim($name));
171 $bookingData['first_name'] = array_shift($nameArray);
172 $bookingData['last_name'] = implode(' ', $nameArray);
173
174 if ($user = get_user_by('email', $email)) {
175 $bookingData['person_user_id'] = $user->ID;
176 }
177
178 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
179
180 if ($email == $lastBooking) {
181 $createdBookingIds = $bookingIds;
182 }
183
184 if (Arr::get($data, 'payment_method')) {
185 if ($email == $lastBooking) {
186 $bookingData['quantity'] = $totalBooking;
187 } else {
188 $bookingData['payment_status'] = '';
189 $bookingData['payment_method'] = '';
190 }
191 }
192
193 $booking = self::createSingleOrMultiBooking($bookingData, $calendarSlot, $customFieldsData, [], $createdBookingIds);
194
195 $bookingIds[] = $booking->id;
196 }
197
198 return $booking;
199 }
200
201 private static function prepareBookingData($data, $calendarSlot)
202 {
203 if (empty($data['first_name']) && !empty($data['name'])) {
204 $nameArray = explode(' ', trim($data['name']));
205 $data['first_name'] = array_shift($nameArray);
206 $data['last_name'] = implode(' ', $nameArray);
207 }
208
209 if (empty($data['slot_minutes'])) {
210 $data['slot_minutes'] = $calendarSlot->duration;
211 }
212
213 if (empty($data['end_time'])) {
214 $data['end_time'] = gmdate('Y-m-d H:i:s', strtotime($data['start_time']) + ($data['slot_minutes'] * 60));
215 }
216
217 if (!isset($data['person_user_id'])) {
218 $user = get_user_by('email', $data['email']);
219 if ($user) {
220 $data['person_user_id'] = $user->ID;
221 if (empty($data['first_name'])) {
222 $data['first_name'] = $user->first_name;
223 $data['last_name'] = $user->last_name;
224 }
225 }
226 }
227
228 if (empty($data['location_details'])) {
229 $data['location_details'] = LocationService::getLocationDetails($calendarSlot, [], []);
230 }
231
232 if ($additionalGuests = Arr::get($data, 'additional_guests', [])) {
233 if ($calendarSlot->isMultiGuestEvent()) {
234 $guestEmails = array_map(function ($guest) {
235 return $guest['email'];
236 }, $additionalGuests);
237 $data['email'] = array_merge($guestEmails, (array) $data['email']);
238
239 $guestNames = array_map(function ($guest) {
240 return $guest['name'];
241 }, $additionalGuests);
242 $data['first_name'] = array_merge($guestNames, (array) ($data['first_name'] . ' ' . $data['last_name']));
243
244 $data['additional_guests'] = [];
245 }
246 }
247
248 return $data;
249 }
250
251 private static function attachHosts($booking, $calendarSlot)
252 {
253 $hosts = [$booking->host_user_id];
254 if ($calendarSlot->isMultiHostsEvent()) {
255 $hosts = $calendarSlot->getHostIds();
256 }
257
258 $hostData = [];
259 foreach ($hosts as $hostId) {
260 $hostData[$hostId] = ['status' => 'confirmed'];
261 }
262
263 $booking->hosts()->attach($hostData);
264 }
265
266 protected static function getGroupId($calendarSlot, $bookingData)
267 {
268 if (!$calendarSlot->isMultiGuestEvent()) {
269 return null;
270 }
271
272 $event = Booking::select('group_id')
273 ->where('event_id', $calendarSlot->id)
274 ->where('calendar_id', $calendarSlot->calendar_id)
275 ->where('start_time', $bookingData['start_time'])
276 ->first();
277
278 return $event ? $event->group_id : null;
279 }
280
281 private static function updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot)
282 {
283 if ($customFieldsData) {
284 Helper::updateBookingMeta($booking->id, 'custom_fields_data', $customFieldsData);
285 }
286
287 if ($guests) {
288 Helper::updateBookingMeta($booking->id, 'additional_guests', $guests);
289 }
290
291 if ($quantity = Arr::get($bookingData, 'quantity')) {
292 Helper::updateBookingMeta($booking->id, 'quantity', $quantity);
293 }
294
295 do_action('fluent_booking/after_booking_meta_update', $booking, $bookingData, $customFieldsData, $calendarSlot);
296 }
297
298 private static function updateParentInfo($booking, $bookingIds)
299 {
300 if (!$bookingIds) {
301 return;
302 }
303
304 Booking::whereIn('id', $bookingIds)->update(['parent_id' => $booking->id]);
305 }
306
307 public static function getBookingConfirmationHtml(Booking $booking, $actionType = 'confirmation')
308 {
309 $validActions = [
310 'confirmation',
311 'cancel',
312 'reschedule'
313 ];
314
315 if (!in_array($actionType, $validActions)) {
316 $actionType = 'confirmation';
317 }
318
319 $calendarSlot = $booking->calendar_event;
320
321 $author = $booking->getHostDetails(false);
322
323 $bookingTitle = $booking->getBookingTitle();
324
325 $sections = [
326 'what' => [
327 'title' => __('What', 'fluent-booking'),
328 'content' => $bookingTitle
329 ],
330 'when' => [
331 'title' => __('When', 'fluent-booking'),
332 'content' => $booking->getFullBookingDateTimeText($booking->person_time_zone, true) . ' (' . $booking->person_time_zone . ')'
333 ],
334 'who' => [
335 'title' => __('Who', 'fluent-booking'),
336 'content' => $booking->getHostAndGuestDetailsHtml()
337 ],
338 'where' => [
339 'title' => __('Where', 'fluent-booking'),
340 'content' => $booking->getLocationDetailsHtml()
341 ]
342 ];
343
344 if ($guests = $booking->getAdditionalGuests(true)) {
345 $sections['guests'] = [
346 'title' => __('Additional Guests', 'fluent-booking'),
347 'content' => $guests
348 ];
349 }
350
351 if ($booking->status == 'cancelled') {
352 // add cancellation reason at the beginning
353 $sections = array_merge([
354 'cancellation_reason' => [
355 'title' => __('Cancellation Reason', 'fluent-booking'),
356 'content' => $booking->getCancelReason(false, true)
357 ]
358 ], $sections);
359 }
360
361 if ($booking->status == 'rejected') {
362 // add rejection reason at the beginning
363 $sections = array_merge([
364 'cancellation_reason' => [
365 'title' => __('Rejection Reason', 'fluent-booking'),
366 'content' => $booking->getRejectReason(false, true)
367 ]
368 ], $sections);
369 }
370
371 if ($booking->message) {
372 $sections['note'] = [
373 'title' => __('Additional Note', 'fluent-booking'),
374 'content' => wpautop($booking->message)
375 ];
376 }
377
378 $customFieldsData = $booking->getCustomFormData(true, true);
379
380 foreach ($customFieldsData as $dataKey => $data) {
381 if (!empty($data['value'])) {
382 $sections[$dataKey] = [
383 'title' => $data['label'],
384 'content' => $data['value']
385 ];
386 }
387 }
388
389 $bookingStatus = $booking->getBookingStatus();
390
391 $subHeading = '';
392 if ($booking->status == 'scheduled') {
393 // translators: %s is the name of the person scheduled
394 $subHeading = sprintf(__('You are scheduled with %s', 'fluent-booking'), $author['name']);
395
396 if ($actionType == 'confirmation' && $calendarSlot->allowMultiBooking()) {
397 $bookingTime = (array) $sections['when']['content'];
398 $sections['when']['content'] = array_merge($bookingTime, $booking->getOtherBookingTimes());
399 }
400 }
401
402 // translators: %s is the status of the meeting
403 $title = sprintf(__('Your meeting has been %s', 'fluent-booking'), $bookingStatus);
404 if ($booking->status == 'pending' && $booking->payment_status != 'pending') {
405 $title = __('Your booking has been submitted', 'fluent-booking');
406 $subHeading = __('Please wait for the host to confirm your booking', 'fluent-booking');
407 }
408
409 $assetsUrl = App::getInstance('url.assets');
410
411 $confirmationData = [
412 'author' => $author,
413 'title' => $title,
414 'sub_heading' => $subHeading,
415 'sections' => $sections,
416 'slot' => $calendarSlot,
417 'booking' => $booking,
418 'message' => __('A confirmation has been sent to your email address along with meeting location details.', 'fluent-booking'),
419 'action_type' => $actionType,
420 'can_cancel' => $booking->canCancel(),
421 'bookmarks' => [],
422 'confirm_icon' => '',
423 'action_url' => '',
424 'extra_html' => ''
425 ];
426
427 if ($booking->payment_status) {
428 $confirmationData['extra_html'] = EditorShortCodeParser::parse('{{payment.receipt_html}}', $booking);
429 }
430
431 if ($actionType == 'cancel') {
432 $confirmationData['title'] = __('Booking Cancellation', 'fluent-booking');
433 $confirmationData['sub_heading'] = __('Confirm and cancel the scheduled booking', 'fluent-booking');
434 $confirmationData['cancel_field'] = BookingFieldService::getBookingFieldByName($calendarSlot, 'cancellation_reason');
435 $confirmationData['action_url'] = add_query_arg([
436 'action' => 'fcal_cancel_meeting',
437 'meeting_hash' => $booking->hash,
438 'scope' => Arr::get($_REQUEST, 'scope') // phpcs:ignore WordPress.Security.NonceVerification.Recommended
439 ], admin_url('admin-ajax.php'));
440 }
441
442 if ($booking->status == 'scheduled' && $actionType == 'confirmation') {
443 $confirmationData['confirm_icon'] = $assetsUrl . '/images/check-mark.png';
444 $confirmationData['bookmarks'] = $booking->getMeetingBookmarks($assetsUrl);
445 }
446
447 if ($booking->status == 'cancelled' || $booking->status == 'rejected') {
448 $confirmationData['confirm_icon'] = $assetsUrl . '/images/cancel-mark.png';
449 }
450
451 $confirmationData = apply_filters('fluent_booking/schedule_receipt_data', $confirmationData, $booking);
452
453 return (string)App::make('view')->make('public.booking_confirmation', $confirmationData);
454 }
455
456 public static function generateBookingICS(Booking $booking)
457 {
458 // Initialize the ICS content
459 $icsContent = "BEGIN:VCALENDAR\r\n";
460 $icsContent .= "VERSION:2.0\r\n";
461 $icsContent .= "PRODID:-//FluentBooking//Fluent Booking//EN\r\n";
462
463 // PUBLISH = plain "add to calendar" event. METHOD:REQUEST makes it an iTIP
464 // invitation bound to the ATTENDEE, which Google Calendar then rejects/mishandles.
465 $icsContent .= "METHOD:PUBLISH\r\n";
466
467 foreach (self::getIcsBookings($booking) as $icsBooking) {
468 $icsContent .= self::generateIcsEvent($icsBooking);
469 }
470
471 // Close the VCALENDAR component
472 $icsContent .= "END:VCALENDAR\r\n";
473
474 return $icsContent;
475 }
476
477 /**
478 * A recurring or multiple-time booking is stored as one row per time, with
479 * the last row as the parent the guest lands on. Its export carries every
480 * confirmed time in the set, one VEVENT each, so an occurrence that was
481 * cancelled or is still awaiting confirmation stays out of the calendar.
482 */
483 private static function getIcsBookings(Booking $booking)
484 {
485 if ($booking->parent_id) {
486 return [$booking];
487 }
488
489 // Additional guests on a group booking are linked the same way, each
490 // with their own email; their bookings are not this guest's to export.
491 $childBookings = Booking::with(['calendar', 'calendar_event', 'booking_meta'])
492 ->where('parent_id', $booking->id)
493 ->where('email', $booking->email)
494 ->whereIn('status', ['scheduled', 'completed'])
495 ->get()
496 ->all();
497
498 if (!$childBookings) {
499 return [$booking];
500 }
501
502 $bookings = array_merge($childBookings, [$booking]);
503
504 usort($bookings, function ($first, $second) {
505 return strtotime($first->start_time) - strtotime($second->start_time);
506 });
507
508 return $bookings;
509 }
510
511 private static function generateIcsEvent(Booking $booking)
512 {
513 $author = $booking->getHostDetails(false);
514
515 $icsContent = "BEGIN:VEVENT\r\n";
516 $icsContent .= "STATUS:CONFIRMED\r\n";
517 $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
518 $icsContent .= "DTSTAMP:" . gmdate('Ymd\THis\Z') . "\r\n"; // Required by RFC5545; Google rejects ICS without it
519
520 $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
521
522 // Escape per segment so the existing "\n" line-break escapes are not double-escaped.
523 $descriptionSegments = array_map([self::class, 'escapeIcsText'], explode('\n', $booking->getIcsBookingDescription()));
524 $icsContent .= "DESCRIPTION:" . implode('\n', $descriptionSegments) . "\r\n";
525
526 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
527 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
528 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
529
530 $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
531
532 $organizerEmail = sanitize_email($author['email']) ?: $author['email'];
533 $icsContent .= "ORGANIZER;CN=\"" . self::escapeIcsText($author['name']) . "\":mailto:" . $organizerEmail . "\r\n";
534
535 $icsContent .= "END:VEVENT\r\n";
536
537 return $icsContent;
538 }
539
540 /**
541 * Escape text for use in ICS (iCalendar) content per RFC5545.
542 * Escapes backslash, semicolon, comma and normalizes newlines to \\n.
543 *
544 * @param string $value Raw text value.
545 * @return string Escaped value safe for ICS properties.
546 */
547 public static function escapeIcsText($value)
548 {
549 if (empty($value)) {
550 return '';
551 }
552 $value = (string) $value;
553 // Escape backslash first, then semicolon and comma (RFC5545 special chars).
554 $value = str_replace(['\\', ';', ','], ['\\\\', '\\;', '\\,'], $value);
555 // Normalize line breaks to literal \n in output (ICS uses \\n for newline in text).
556 $value = str_replace(["\r\n", "\r", "\n"], "\\n", $value);
557
558 return $value;
559 }
560
561 }
562