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 +81 -13 2.1.1 → 2.5.0 View file →
@@ -55,9 +55,11 @@
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 65 self::updateMetas($booking, $bookingData, $guests, $customFieldsData, $calendarSlot);
@@ -64,8 +66,9 @@
64 66
65 67 $booking->load('calendar');
66 68
67 69 $bookingStatus = $booking->status;
70 + $paymentStatus = $booking->payment_status;
68 71
69 72 $bookingData = apply_filters('fluent_booking/after_booking_data', $bookingData, $booking, $calendarSlot, $customFieldsData);
70 73
71 74 // this pre hook is for early actions that require for remote calendars and locations
@@ -73,9 +76,9 @@
73 76
74 77 // We are just renewing this as this may have been changed by the pre hook
75 78 $booking = Booking::find($booking->id);
76 79
77 - if ($bookingStatus != $booking->status) {
80 + if (self::preHookHasDispatched($bookingStatus, $paymentStatus, $booking)) {
78 81 return $booking;
79 82 }
80 83
81 84 do_action('fluent_booking/after_booking_' . $booking->status, $booking, $calendarSlot, $bookingData);
@@ -82,8 +85,25 @@
82 85
83 86 return $booking;
84 87 }
85 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 +
86 106 public static function createMultiTimeBooking($data, $calendarSlot, $customFieldsData, $guests)
87 107 {
88 108 $booking = [];
89 109 $bookingIds = [];
@@ -434,23 +454,76 @@
434 454 }
435 455
436 456 public static function generateBookingICS(Booking $booking)
437 457 {
438 - $author = $booking->getHostDetails(false);
439 -
440 458 // Initialize the ICS content
441 459 $icsContent = "BEGIN:VCALENDAR\r\n";
442 460 $icsContent .= "VERSION:2.0\r\n";
443 - $icsContent .= "PRODID:-//Google Inc//Fluent Booking//EN\r\n";
444 - $icsContent .= "METHOD:REQUEST\r\n";
461 + $icsContent .= "PRODID:-//FluentBooking//Fluent Booking//EN\r\n";
445 462
446 - $icsContent .= "BEGIN:VEVENT\r\n";
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";
447 516 $icsContent .= "STATUS:CONFIRMED\r\n";
448 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
449 519
450 520 $icsContent .= "SUMMARY:" . self::escapeIcsText($booking->getBookingTitle()) . "\r\n";
451 - $icsContent .= "DESCRIPTION:" . self::escapeIcsText($booking->getIcsBookingDescription()) . "\r\n";
452 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 +
453 526 // Date and time formatting (assuming eventStart and eventEnd are DateTime objects)
454 527 $icsContent .= "DTSTART:" . gmdate('Ymd\THis\Z', strtotime($booking->start_time)) . "\r\n";
455 528 $icsContent .= "DTEND:" . gmdate('Ymd\THis\Z', strtotime($booking->end_time)) . "\r\n";
456 529
@@ -456,16 +529,11 @@
456 529
457 530 $icsContent .= "LOCATION:" . self::escapeIcsText($booking->getLocationAsText()) . "\r\n";
458 531
459 532 $organizerEmail = sanitize_email($author['email']) ?: $author['email'];
460 - $attendeeEmail = sanitize_email($booking->email) ?: $booking->email;
461 533 $icsContent .= "ORGANIZER;CN=\"" . self::escapeIcsText($author['name']) . "\":mailto:" . $organizerEmail . "\r\n";
462 - $icsContent .= "ATTENDEE;CN=\"" . $attendeeEmail . "\";ROLE=REQ-PARTICIPANT;RSVP=TRUE;PARTSTAT=ACCEPTED:mailto:" . $attendeeEmail . "\r\n";
463 534
464 535 $icsContent .= "END:VEVENT\r\n";
465 -
466 - // Close the VCALENDAR component
467 - $icsContent .= "END:VCALENDAR\r\n";
468 536
469 537 return $icsContent;
470 538 }
471 539