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 +148 -30 1.5.20 → 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,12 +123,18 @@
90 123 $bookingData['end_time'] = $endTime;
91 124
92 125 $isConfRequired = $calendarSlot->isConfirmationRequired($startTime);
93 126 $bookingData['status'] = $isConfRequired ? 'pending' : $data['status'];
94 -
127 + $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
128 +
129 + if ($startTime == $lastBooking) {
130 + $createdBookingIds = $bookingIds;
131 + } else {
132 + $bookingData['parent_id'] = '';
133 + }
134 +
95 135 if (Arr::get($data, 'payment_method')) {
96 136 if ($startTime == $lastBooking) {
97 - $createdBookingIds = $bookingIds;
98 137 $bookingData['quantity'] = $totalBooking;
99 138 } else {
100 139 $bookingData['payment_status'] = '';
101 140 $bookingData['payment_method'] = '';
@@ -118,8 +157,12 @@
118 157 $lastBooking = end($data['email']);
119 158 $totalBooking = count($data['email']);
120 159 $guests = array_combine($data['email'], $data['first_name']);
121 160
161 + if ($guests === false) {
162 + throw new \InvalidArgumentException(esc_html__('Guest names and emails are invalid.', 'fluent-booking'));
163 + }
164 +
122 165 foreach ($guests as $email => $name) {
123 166 $bookingData = $data;
124 167
125 168 $bookingData['email'] = $email;
@@ -133,11 +176,14 @@
133 176 }
134 177
135 178 $bookingData['group_id'] = self::getGroupId($calendarSlot, $bookingData);
136 179
180 + if ($email == $lastBooking) {
181 + $createdBookingIds = $bookingIds;
182 + }
183 +
137 184 if (Arr::get($data, 'payment_method')) {
138 185 if ($email == $lastBooking) {
139 - $createdBookingIds = $bookingIds;
140 186 $bookingData['quantity'] = $totalBooking;
141 187 } else {
142 188 $bookingData['payment_status'] = '';
143 189 $bookingData['payment_method'] = '';
@@ -188,12 +234,14 @@
188 234 $guestEmails = array_map(function ($guest) {
189 235 return $guest['email'];
190 236 }, $additionalGuests);
191 237 $data['email'] = array_merge($guestEmails, (array) $data['email']);
238 +
192 239 $guestNames = array_map(function ($guest) {
193 240 return $guest['name'];
194 241 }, $additionalGuests);
195 - $data['first_name'] = array_merge($guestNames, (array) $data['first_name']);
242 + $data['first_name'] = array_merge($guestNames, (array) ($data['first_name'] . ' ' . $data['last_name']));
243 +
196 244 $data['additional_guests'] = [];
197 245 }
198 246 }
199 247
@@ -202,9 +250,9 @@
202 250
203 251 private static function attachHosts($booking, $calendarSlot)
204 252 {
205 253 $hosts = [$booking->host_user_id];
206 - if ($calendarSlot->isOneOffEvent()) {
254 + if ($calendarSlot->isMultiHostsEvent()) {
207 255 $hosts = $calendarSlot->getHostIds();
208 256 }
209 257
210 258 $hostData = [];
@@ -229,9 +277,9 @@
229 277
230 278 return $event ? $event->group_id : null;
231 279 }
232 280
233 - private static function updateMetas($booking, $bookingData, $guests, $customFieldsData)
281 + private static function updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot)
234 282 {
235 283 if ($customFieldsData) {
236 284 Helper::updateBookingMeta($booking->id, 'custom_fields_data', $customFieldsData);
237 285 }
@@ -242,8 +290,10 @@
242 290
243 291 if ($quantity = Arr::get($bookingData, 'quantity')) {
244 292 Helper::updateBookingMeta($booking->id, 'quantity', $quantity);
245 293 }
294 +
295 + do_action('fluent_booking/after_booking_meta_update', $booking, $bookingData, $customFieldsData, $calendarSlot);
246 296 }
247 297
248 298 private static function updateParentInfo($booking, $bookingIds)
249 299 {
@@ -324,12 +374,12 @@
324 374 'content' => wpautop($booking->message)
325 375 ];
326 376 }
327 377
328 - $customFieldsData = $booking->getCustomFormData(true);
378 + $customFieldsData = $booking->getCustomFormData(true, true);
329 379
330 380 foreach ($customFieldsData as $dataKey => $data) {
331 - if (!empty($data['value'])) {
381 + if (!empty($data['value'])) {
332 382 $sections[$dataKey] = [
333 383 'title' => $data['label'],
334 384 'content' => $data['value']
335 385 ];
@@ -342,10 +392,9 @@
342 392 if ($booking->status == 'scheduled') {
343 393 // translators: %s is the name of the person scheduled
344 394 $subHeading = sprintf(__('You are scheduled with %s', 'fluent-booking'), $author['name']);
345 395
346 - $requestType = sanitize_text_field(Arr::get($_REQUEST, 'type'));
347 - if ($requestType == 'confirmation' && $calendarSlot->allowMultiBooking()) {
396 + if ($actionType == 'confirmation' && $calendarSlot->allowMultiBooking()) {
348 397 $bookingTime = (array) $sections['when']['content'];
349 398 $sections['when']['content'] = array_merge($bookingTime, $booking->getOtherBookingTimes());
350 399 }
351 400 }
@@ -405,39 +454,108 @@
405 454 }
406 455
407 456 public static function generateBookingICS(Booking $booking)
408 457 {
409 - $author = $booking->getHostDetails(false);
410 -
411 458 // Initialize the ICS content
412 459 $icsContent = "BEGIN:VCALENDAR\r\n";
413 460 $icsContent .= "VERSION:2.0\r\n";
414 - $icsContent .= "PRODID:-//Google Inc//Fluent Booking//EN\r\n";
415 - $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";
416 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
417 519
418 - $icsContent .= "BEGIN:VEVENT\r\n";
419 - $icsContent .= "UID:" . md5($booking->hash) . "\r\n"; // Unique ID for the event
520 + $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
420 521
421 - // Event details
422 - $icsContent .= "SUMMARY:" . $booking->getBookingTitle() . "\r\n";
423 - $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";
424 525
425 526 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
426 527 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
427 528 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
428 529
429 - $icsContent .= "LOCATION:" . $booking->getLocationAsText() . "\r\n";
530 + $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
430 531
431 - $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";
432 534
433 - $icsContent .= "ATTENDEE;CN=\"" . $booking->email . "\";ROLE=REQ-PARTICIPANT;RSVP=TRUE;PARTSTAT=ACCEPTED:mailto:" . $booking->email . "\r\n";
535 + $icsContent .= "END:VEVENT\r\n";
434 536
435 - $icsContent .= "END:VEVENT\r\n";
537 + return $icsContent;
538 + }
436 539
437 - // Close the VCALENDAR component
438 - $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);
439 557
440 - return $icsContent;
558 + return $value;
441 559 }
442 560
443 561 }