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
← All changes | app/Services/BookingService.php +133 -22 1.7.1 → 2.5.0 View file →
@@ -34,9 +34,9 @@
34 34 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
35 35
36 36 $bookingData['event_type'] = $calendarSlot->event_type;
37 37
38 - $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData);
38 + $bookingData = apply_filters('fluent_booking/booking_data', $bookingData, $calendarSlot, $customFieldsData, $data);
39 39
40 40 if (is_wp_error($bookingData)) {
41 41 return $bookingData;
42 42 }
@@ -55,26 +55,55 @@
55 55 }
56 56
57 57 do_action('fluent_booking/before_booking', $bookingData, $calendarSlot);
58 58
59 - $booking = Booking::create($bookingData);
59 + $booking = Helper::dbTransaction(function () use ($bookingData) {
60 + return Booking::create($bookingData);
61 + });
60 62
61 63 self::attachHosts($booking, $calendarSlot);
62 64 self::updateParentInfo($booking, $bookingIds);
63 - self::updateMetas($booking, $bookingData, $guests, $customFieldsData);
65 + self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
64 66
65 67 $booking->load('calendar');
66 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 +
67 74 // this pre hook is for early actions that require for remote calendars and locations
68 - do_action('fluent_booking/pre_after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
75 + do_action('fluent_booking/pre_after_booking_' . $bookingStatus, $booking, $calendarSlot, $bookingData);
69 76
70 77 // We are just renewing this as this may have been changed by the pre hook
71 78 $booking = Booking::find($booking->id);
79 +
80 + if (self::preHookHasDispatched($bookingStatus, $paymentStatus, $booking)) {
81 + return $booking;
82 + }
83 +
72 84 do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
73 85
74 86 return $booking;
75 87 }
76 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 +
77 106 public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
78 107 {
79 108 $booking = [];
80 109 $bookingIds = [];
@@ -82,8 +111,12 @@
82 111 $lastBooking = end($data['start_time']);
83 112 $totalBooking = count($data['start_time']);
84 113 $bookingTimes = array_combine($data['start_time'], $data['end_time']);
85 114
115 + if ($bookingTimes === false) {
116 + throw new \InvalidArgumentException(esc_html__('Booking start and end times are invalid.', 'fluent-booking'));
117 + }
118 +
86 119 foreach ($bookingTimes as $startTime => $endTime) {
87 120 $bookingData = $data;
88 121
89 122 $bookingData['start_time'] = $startTime;
@@ -90,11 +123,14 @@
90 123 $bookingData['end_time'] = $endTime;
91 124
92 125 $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
93 126 $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
127 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
94 128
95 129 if ($startTime == $lastBooking) {
96 130 $createdBookingIds = $bookingIds;
131 + } else {
132 + $bookingData['parent_id'] = '';
97 133 }
98 134
99 135 if (Arr::get($data, 'payment_method')) {
100 136 if ($startTime == $lastBooking) {
@@ -121,8 +157,12 @@
121 157 $lastBooking = end($data['email']);
122 158 $totalBooking = count($data['email']);
123 159 $guests = array_combine($data['email'], $data['first_name']);
124 160
161 + if ($guests === false) {
162 + throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
163 + }
164 +
125 165 foreach ($guests as $email => $name) {
126 166 $bookingData = $data;
127 167
128 168 $bookingData['email'] = $email;
@@ -198,9 +238,9 @@
198 238
199 239 $guestNames = array_map(function ($guest) {
200 240 return $guest['name'];
201 241 }, $additionalGuests);
202 - $data['first_name'] = array_merge($guestNames, (array) $data['first_name']);
242 + $data['first_name'] = array_merge($guestNames, (array) ($data['first_name'] . ' ' . $data['last_name']));
203 243
204 244 $data['additional_guests'] = [];
205 245 }
206 246 }
@@ -237,9 +277,9 @@
237 277
238 278 return $event ? $event->group_id : null;
239 279 }
240 280
241 - private static function updateMetas($booking, $bookingData, $guests, $customFieldsData)
281 + private static function updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot)
242 282 {
243 283 if ($customFieldsData) {
244 284 Helper::updateBookingMeta($booking->id, 'custom_fields_data', $customFieldsData);
245 285 }
@@ -250,8 +290,10 @@
250 290
251 291 if ($quantity = Arr::get($bookingData, 'quantity')) {
252 292 Helper::updateBookingMeta($booking->id, 'quantity', $quantity);
253 293 }
294 +
295 + do_action('fluent_booking/after_booking_meta_update', $booking, $bookingData, $customFieldsData, $calendarSlot);
254 296 }
255 297
256 298 private static function updateParentInfo($booking, $bookingIds)
257 299 {
@@ -412,39 +454,108 @@
412 454 }
413 455
414 456 public static function generateBookingICS(Booking $booking)
415 457 {
416 - $author = $booking->getHostDetails(false);
417 -
418 458 // Initialize the ICS content
419 459 $icsContent = "BEGIN:VCALENDAR\r\n";
420 460 $icsContent .= "VERSION:2.0\r\n";
421 - $icsContent .= "PRODID:-//Google Inc//Fluent Booking//EN\r\n";
422 - $icsContent .= "METHOD:REQUEST\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";
423 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
424 519
425 - $icsContent .= "BEGIN:VEVENT\r\n";
426 - $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
520 + $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
427 521
428 - // Event details
429 - $icsContent .= "SUMMARY:" . $booking->getBookingTitle() . "\r\n";
430 - $icsContent .= "DESCRIPTION:" . $booking->getIcsBookingDescription() . "\r\n";
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";
431 525
432 526 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
433 527 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
434 528 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
435 529
436 - $icsContent .= "LOCATION:" . $booking->getLocationAsText() . "\r\n";
530 + $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
437 531
438 - $icsContent .= "ORGANIZER;CN=\"" . $author['name'] . "\":mailto:" . $author['email'] . "\r\n";
532 + $organizerEmail = sanitize_email($author['email']) ?: $author['email'];
533 + $icsContent .= "ORGANIZER;CN=\"" . self::escapeIcsText($author['name']) . "\":mailto:" . $organizerEmail . "\r\n";
439 534
440 - $icsContent .= "ATTENDEE;CN=\"" . $booking->email . "\";ROLE=REQ-PARTICIPANT;RSVP=TRUE;PARTSTAT=ACCEPTED:mailto:" . $booking->email . "\r\n";
535 + $icsContent .= "END:VEVENT\r\n";
441 536
442 - $icsContent .= "END:VEVENT\r\n";
537 + return $icsContent;
538 + }
443 539
444 - // Close the VCALENDAR component
445 - $icsContent .= "END:VCALENDAR\r\n";
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);
446 557
447 - return $icsContent;
558 + return $value;
448 559 }
449 560
450 561 }