PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
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 trunk, at app/Services/BookingService.php

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