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/Integrations/FluentForms/FluentFormInit.php +309 -17 1.5.01 → 2.5.0 View file →
@@ -4,21 +4,27 @@
4 4
5 5 use FluentBooking\App\Services\BookingFieldService;
6 6 use FluentBooking\App\Services\LocationService;
7 7 use FluentBooking\Framework\Support\Arr;
8 +use FluentBooking\App\Models\Booking;
8 9 use FluentBooking\App\Models\CalendarSlot;
10 +use FluentBooking\App\Services\Helper;
9 11 use FluentBooking\App\Services\DateTimeHelper;
10 12 use FluentBooking\App\Services\BookingService;
11 -use FluentBooking\App\Services\TimeSlotService;
12 13 use FluentBooking\App\Hooks\Handlers\TimeSlotServiceHandler;
13 14 use FluentBooking\App\Hooks\Handlers\FrontEndHandler;
14 15 use FluentForm\App\Models\Submission;
16 +use FluentForm\App\Helpers\Helper as FluentFormHelper;
15 17 use FluentForm\App\Modules\Form\FormFieldsParser;
16 18 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
19 +use FluentBooking\App\Vite;
17 20
18 21
19 22 class FluentFormInit
20 23 {
24 + // Fluent Forms keys its Offline Payment method 'test'.
25 + const FF_OFFLINE_METHOD = 'test';
26 +
21 27 protected $hostId;
22 28
23 29 public function init()
24 30 {
@@ -29,8 +35,9 @@
29 35 public function registerHooks()
30 36 {
31 37 add_action('fluentform/validate_input_item_fcal_booking', [$this, 'handleValidations'], 10, 3);
32 38 add_action('fluentform/notify_on_form_submit', [$this, 'handleFormSubmitted'], 10, 3);
39 + add_action('fluentform/after_payment_status_change', [$this, 'handlePaymentStatusChanged'], 10, 2);
33 40 add_action('fluentform/conversational_question', [$this, 'loadConversationalAsset'], 10, 3);
34 41
35 42 add_filter('fluentform/conversational_field_types', function ($fieldTypes) {
36 43 $fieldTypes['fcal_booking'] = 'FlowFormCustomType';
@@ -104,19 +111,21 @@
104 111
105 112 $timeSlotService = TimeSlotServiceHandler::initService($calendarEvent->calendar, $calendarEvent);
106 113
107 114 if (is_wp_error($timeSlotService)) {
108 - return TimeSlotServiceHandler::sendError($timeSlotService, $calendarEvent, $timezone);
115 + return TimeSlotServiceHandler::sendError($timeSlotService, $calendarEvent, $timeZone);
109 116 }
110 117
111 - $isSpotAvailable = $timeSlotService->isSpotAvailable($startDateTime, $endDateTime, $duration);
118 + $isSlotLocked = Helper::lockRoundRobinSlot($calendarEvent, $startDateTime, $endDateTime);
112 119
113 - if (!$isSpotAvailable) {
120 + $availableSpot = $isSlotLocked ? $timeSlotService->isSpotAvailable($startDateTime, $endDateTime, $duration) : false;
121 +
122 + if (!$availableSpot) {
114 123 $message = __('This selected time slot is not available. Maybe someone booked the spot just a few seconds ago.', 'fluent-booking');
115 124 wp_send_json(['errors' => [$message]], 422);
116 125 }
117 126
118 - if ($calendarEvent->isTeamEvent()) {
127 + if ($calendarEvent->isRoundRobin()) {
119 128 $this->hostId = $timeSlotService->hostUserId;
120 129 }
121 130
122 131 if (!is_user_logged_in()) {
@@ -193,8 +202,35 @@
193 202
194 203 return '';
195 204 }
196 205
206 + /**
207 + * Read the Fluent Forms values mapped onto the event's custom booking fields.
208 + * The form's own field rules decide what is required; values are only sanitized here.
209 + *
210 + * @param array $field parsed fcal_booking field
211 + * @param array $formData submitted form values keyed by field name
212 + * @param CalendarSlot $event
213 + *
214 + * @return array
215 + */
216 + private function getMappedFieldsData($field, $formData, CalendarSlot $event)
217 + {
218 + $fieldMap = array_filter((array) Arr::get($field, 'raw.settings.cal_guest_fields.field_map', []));
219 +
220 + if (!$fieldMap) {
221 + return [];
222 + }
223 +
224 + // Form values arrive unslashed; getCustomFieldsData() unslashes, so slash to keep backslashes.
225 + $postedData = [];
226 + foreach ($fieldMap as $bookingFieldKey => $formFieldName) {
227 + $postedData[$bookingFieldKey] = wp_slash(Arr::get($formData, $formFieldName));
228 + }
229 +
230 + return BookingFieldService::getCustomFieldsData($postedData, $event, array_keys($fieldMap));
231 + }
232 +
197 233 public function handleFormSubmitted($entryId, $formDataX, $form)
198 234 {
199 235 $fields = FormFieldsParser::getInputs($form, ['rules', 'raw', 'name']);
200 236
@@ -205,9 +241,9 @@
205 241 if (!$bookingFields) {
206 242 return;
207 243 }
208 244
209 - if (\FluentForm\App\Helpers\Helper::getSubmissionMeta($entryId, 'fluent_booking_id')) {
245 + if (FluentFormHelper::getSubmissionMeta($entryId, 'fluent_booking_id')) {
210 246 return; // Already processed
211 247 }
212 248
213 249 $entry = wpFluent()->table('fluentform_submissions')
@@ -313,8 +349,10 @@
313 349 if ($event->isConfirmationRequired($startDateTime)) {
314 350 $bookingData['status'] = 'pending';
315 351 }
316 352
353 + $bookingData = $this->maybeAddPaymentData($bookingData, $entry);
354 +
317 355 if ($entry->user_id) {
318 356 $bookingData['person_user_id'] = $entry->user_id;
319 357 }
320 358
@@ -330,12 +368,15 @@
330 368 }
331 369 $bookingData['location_details'] = $selectedLocation;
332 370
333 371 try {
334 - $booking = BookingService::createBooking($bookingData, $event);
372 + $customFieldsData = $this->getMappedFieldsData($bookingField, $submittedData, $event);
335 373
336 - \FluentForm\App\Helpers\Helper::getSubmissionMeta($entry->id, 'fluent_booking_id', $booking->id);
374 + $booking = BookingService::createBooking($bookingData, $event, $customFieldsData);
337 375
376 + // Must persist, or the guard above misses a payment retry on the same submission.
377 + FluentFormHelper::setSubmissionMeta($entry->id, 'fluent_booking_id', $booking->id, $form->id);
378 +
338 379 $fieldData = $submittedData[$fieldName];
339 380 $fieldData['booking_id'] = $booking->id;
340 381
341 382 $submittedData[$fieldName] = (array)$fieldData;
@@ -370,8 +411,265 @@
370 411 }
371 412 }
372 413 }
373 414
415 + /**
416 + * Fluent Forms fires notify_on_form_submit before the gateway runs, so the row is
417 + * written to hold the slot but waits for the payment, as the cart integration does.
418 + * Offline is honoured up front, like the native offline method.
419 + *
420 + * @param array $bookingData
421 + * @param object $entry fluentform_submissions row
422 + *
423 + * @return array
424 + */
425 + private function maybeAddPaymentData($bookingData, $entry)
426 + {
427 + $submissionStatus = isset($entry->payment_status) ? $entry->payment_status : '';
428 +
429 + if (!$submissionStatus) {
430 + return $bookingData; // Not a payment submission
431 + }
432 +
433 + $paymentStatus = $this->mapPaymentStatus($submissionStatus);
434 + $isOffline = isset($entry->payment_method) && $entry->payment_method === self::FF_OFFLINE_METHOD;
435 +
436 + // 'offline' is the only method FiveMinuteScheduler::maybeAutoCancelBooking exempts.
437 + $bookingData['payment_method'] = $isOffline ? 'offline' : 'fluentform';
438 + $bookingData['payment_status'] = $paymentStatus;
439 +
440 + if ($paymentStatus != 'paid' && !$isOffline) {
441 + $bookingData['status'] = 'pending';
442 + }
443 +
444 + return $bookingData;
445 + }
446 +
447 + /**
448 + * @param string $submissionStatus Fluent Forms payment status
449 + *
450 + * @return string FluentBooking payment status
451 + */
452 + private function mapPaymentStatus($submissionStatus)
453 + {
454 + $statusMap = [
455 + 'paid' => 'paid',
456 + 'refunded' => 'refunded',
457 + 'partially-refunded' => 'partially-refunded',
458 + 'failed' => 'failed',
459 + 'cancelled' => 'failed'
460 + ];
461 +
462 + // pending / processing / requires_review all mean "not settled yet"
463 + return Arr::get($statusMap, $submissionStatus, 'pending');
464 + }
465 +
466 + /**
467 + * @param string $newStatus Fluent Forms payment status
468 + * @param object $submission Submission model or fluentform_submissions row
469 + */
470 + public function handlePaymentStatusChanged($newStatus, $submission)
471 + {
472 + $submissionId = $this->readSubmissionId($submission);
473 +
474 + if (!$submissionId) {
475 + return;
476 + }
477 +
478 + // Fires for every payment on the site; Fluent Forms' indexed meta is the cheap check.
479 + if (!FluentFormHelper::getSubmissionMeta($submissionId, 'fluent_booking_id')) {
480 + return;
481 + }
482 +
483 + // A form can carry several booking fields, so a submission can own several bookings.
484 + $bookings = Booking::where('source', 'fluentform')
485 + ->where('source_id', $submissionId)
486 + ->get();
487 +
488 + if ($bookings->isEmpty()) {
489 + return;
490 + }
491 +
492 + $paymentStatus = $this->mapPaymentStatus($newStatus);
493 +
494 + foreach ($bookings as $booking) {
495 + if ($paymentStatus == 'paid') {
496 + $this->confirmPaidBooking($booking);
497 + continue;
498 + }
499 +
500 + if ($booking->payment_status == $paymentStatus) {
501 + continue;
502 + }
503 +
504 + if ($this->undoesSettledOutcome($paymentStatus, $booking->payment_status)) {
505 + continue;
506 + }
507 +
508 + // Failure and refund leave the booking status alone, as the native gateways do.
509 + $this->settlePaymentStatus($booking, $paymentStatus);
510 + }
511 + }
512 +
513 + /**
514 + * One payment module passes a Submission model, whose columns live behind __get,
515 + * the other a plain row. Property access reads both; an array cast reads only the row.
516 + *
517 + * @param object $submission
518 + *
519 + * @return int
520 + */
521 + private function readSubmissionId($submission)
522 + {
523 + if (!is_object($submission)) {
524 + return 0;
525 + }
526 +
527 + return isset($submission->id) ? (int)$submission->id : 0;
528 + }
529 +
530 + /**
531 + * Statuses that already record an outcome. An unsettled event landing on one of
532 + * these is stale delivery, not a state change.
533 + */
534 + const SETTLED_PAYMENT_STATUSES = ['paid', 'refunded', 'partially-refunded'];
535 +
536 + /**
537 + * @param string $paymentStatus
538 + *
539 + * @return bool
540 + */
541 + private function isSettled($paymentStatus)
542 + {
543 + return in_array($paymentStatus, self::SETTLED_PAYMENT_STATUSES, true);
544 + }
545 +
546 + /**
547 + * Redelivered events arrive out of order, and one carrying no outcome must not
548 + * overwrite a recorded one - flattening a refund would hide it from the paid path.
549 + *
550 + * @param string $paymentStatus incoming
551 + * @param string $currentPaymentStatus stored on the booking
552 + *
553 + * @return bool
554 + */
555 + private function undoesSettledOutcome($paymentStatus, $currentPaymentStatus)
556 + {
557 + return !$this->isSettled($paymentStatus) && $this->isSettled($currentPaymentStatus);
558 + }
559 +
560 + /**
561 + * @param string $paymentStatus
562 + *
563 + * @return bool
564 + */
565 + private function isRefunded($paymentStatus)
566 + {
567 + return in_array($paymentStatus, ['refunded', 'partially-refunded'], true);
568 + }
569 +
570 + /**
571 + * The precedence between two events landing together is settled in SQL, not against
572 + * the row as it was read: a refund is the final outcome and lands whatever arrived
573 + * first, anything else only fills in a booking with no outcome recorded yet.
574 + *
575 + * @param \FluentBooking\App\Models\Booking $booking
576 + * @param string $paymentStatus
577 + */
578 + private function settlePaymentStatus($booking, $paymentStatus)
579 + {
580 + $query = Booking::where('id', $booking->id);
581 +
582 + if (!$this->isRefunded($paymentStatus)) {
583 + $query->whereNotIn('payment_status', self::SETTLED_PAYMENT_STATUSES);
584 + }
585 +
586 + $query->update([
587 + 'payment_status' => $paymentStatus,
588 + 'updated_at' => gmdate('Y-m-d H:i:s') // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
589 + ]);
590 + }
591 +
592 + /**
593 + * @param \FluentBooking\App\Models\Booking $booking
594 + */
595 + private function confirmPaidBooking($booking)
596 + {
597 + if ($booking->payment_status == 'paid') {
598 + return;
599 + }
600 +
601 + $calendarEvent = $booking->calendar_event;
602 +
603 + if (!$calendarEvent) {
604 + return;
605 + }
606 +
607 + // Webhooks arrive late and out of order, and a refund is the last word on an
608 + // order - a stale 'paid' must not settle it again, let alone put it back on
609 + // the calendar. Logged rather than dropped, so the mismatch is visible.
610 + if ($this->isRefunded($booking->payment_status)) {
611 + do_action('fluent_booking/log_booking_activity', [
612 + 'booking_id' => $booking->id,
613 + 'status' => 'closed',
614 + 'type' => 'error',
615 + 'title' => __('Fluent Forms: Payment status could not be changed', 'fluent-booking'),
616 + /* translators: %s is the current payment status of the booking */
617 + 'description' => sprintf(__('A paid notification arrived after the order was %s, so the booking was left unchanged.', 'fluent-booking'), $booking->getPaymentStatus())
618 + ]);
619 + return;
620 + }
621 +
622 + if ($booking->status != 'pending') {
623 + // Honoured up front, or already moved on - only settle the payment.
624 + $this->settlePaymentStatus($booking, 'paid');
625 + return;
626 + }
627 +
628 + $isRequireConfirmation = $calendarEvent->isConfirmationRequired($booking->start_time, $booking->created_at);
629 +
630 + // Awaiting approval stays pending; paying only releases the held-back notifications.
631 + $newStatus = $isRequireConfirmation ? 'pending' : 'scheduled';
632 +
633 + // Gateways redeliver, so only the request that settles the row dispatches the hooks.
634 + $flagged = Booking::where('id', $booking->id)
635 + ->where('status', 'pending')
636 + ->whereNotIn('payment_status', self::SETTLED_PAYMENT_STATUSES)
637 + ->update([
638 + 'status' => $newStatus,
639 + 'payment_status' => 'paid',
640 + 'updated_at' => gmdate('Y-m-d H:i:s') // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
641 + ]);
642 +
643 + if (!$flagged) {
644 + return;
645 + }
646 +
647 + $booking = Booking::with(['calendar_event', 'calendar'])->find($booking->id);
648 +
649 + do_action('fluent_booking/log_booking_activity', [
650 + 'booking_id' => $booking->id,
651 + 'status' => 'closed',
652 + 'type' => 'success',
653 + 'title' => __('Payment completed on Fluent Forms', 'fluent-booking'),
654 + /* translators: %s is the booking status after the payment has been completed */
655 + 'description' => sprintf(__('The form payment has been paid and the appointment is now in %s status.', 'fluent-booking'), $booking->getBookingStatus())
656 + ]);
657 +
658 + $bookingData = [
659 + 'name' => $booking->first_name . ' ' . $booking->last_name,
660 + 'email' => $booking->email,
661 + 'phone' => $booking->phone
662 + ];
663 +
664 + // this pre hook is for early actions that require for remote calendars and locations
665 + do_action('fluent_booking/pre_after_booking_' . $newStatus, $booking, $calendarEvent, $bookingData);
666 +
667 + $booking = Booking::with(['calendar_event', 'calendar'])->find($booking->id);
668 +
669 + do_action('fluent_booking/after_booking_' . $newStatus, $booking, $calendarEvent, $bookingData);
670 + }
671 +
374 672 public function loadConversationalAsset($question, $field, $form)
375 673 {
376 674 if ('fcal_booking' === $field['element']) {
377 675
@@ -383,18 +681,12 @@
383 681 }
384 682
385 683 [$localizeData, $elementId] = $this->getLocalizedData($calendarEvent, $field, $form);
386 684
387 - wp_enqueue_script(
388 - 'fluent_booking',
389 - FLUENT_BOOKING_URL . 'assets/public/js/fluentform-conversational.js',
390 - [],
391 - FLUENT_BOOKING_ASSETS_VERSION,
392 - true
393 - );
685 + Vite::enqueueScript('fluent_booking', 'ff_conversational', [], FLUENT_BOOKING_ASSETS_VERSION);
394 686
395 687 if (BookingFieldService::hasPhoneNumberField($localizeData['form_fields'])) {
396 - wp_enqueue_script('fluent-booking-phone-field', FLUENT_BOOKING_URL . 'assets/public/js/phone-field.js', [], FLUENT_BOOKING_ASSETS_VERSION, true);
688 + Vite::enqueueScript('fluent-booking-phone-field', 'phone_field', [], FLUENT_BOOKING_ASSETS_VERSION);
397 689 $inlineStyle = '.fcal_phone_wrapper .flag { background: url(' . esc_url(FLUENT_BOOKING_URL . 'assets/images/flags_responsive.png') . ') no-repeat;background-size: 100%;}';
398 690 wp_add_inline_style('fluent-booking-phone-field', $inlineStyle);
399 691 }
400 692
@@ -513,9 +805,9 @@
513 805 */
514 806 protected function makeElementId($data, $form)
515 807 {
516 808 if (isset($data['attributes']['name'])) {
517 - $formInstance = \FluentForm\App\Helpers\Helper::$formInstance;
809 + $formInstance = FluentFormHelper::$formInstance;
518 810 if (!empty($data['attributes']['id'])) {
519 811 return $data['attributes']['id'];
520 812 }
521 813 $elementName = $data['attributes']['name'];