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 +303 -6 2.2.5 → 2.5.0 View file →
@@ -4,15 +4,17 @@
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;
17 19 use FluentBooking\App\Vite;
18 20
@@ -18,8 +20,11 @@
18 20
19 21
20 22 class FluentFormInit
21 23 {
24 + // Fluent Forms keys its Offline Payment method 'test'.
25 + const FF_OFFLINE_METHOD = 'test';
26 +
22 27 protected $hostId;
23 28
24 29 public function init()
25 30 {
@@ -30,8 +35,9 @@
30 35 public function registerHooks()
31 36 {
32 37 add_action('fluentform/validate_input_item_fcal_booking', [$this, 'handleValidations'], 10, 3);
33 38 add_action('fluentform/notify_on_form_submit', [$this, 'handleFormSubmitted'], 10, 3);
39 + add_action('fluentform/after_payment_status_change', [$this, 'handlePaymentStatusChanged'], 10, 2);
34 40 add_action('fluentform/conversational_question', [$this, 'loadConversationalAsset'], 10, 3);
35 41
36 42 add_filter('fluentform/conversational_field_types', function ($fieldTypes) {
37 43 $fieldTypes['fcal_booking'] = 'FlowFormCustomType';
@@ -108,10 +114,12 @@
108 114 if (is_wp_error($timeSlotService)) {
109 115 return TimeSlotServiceHandler::sendError($timeSlotService, $calendarEvent, $timeZone);
110 116 }
111 117
112 - $availableSpot = $timeSlotService->isSpotAvailable($startDateTime, $endDateTime, $duration);
118 + $isSlotLocked = Helper::lockRoundRobinSlot($calendarEvent, $startDateTime, $endDateTime);
113 119
120 + $availableSpot = $isSlotLocked ? $timeSlotService->isSpotAvailable($startDateTime, $endDateTime, $duration) : false;
121 +
114 122 if (!$availableSpot) {
115 123 $message = __('This selected time slot is not available. Maybe someone booked the spot just a few seconds ago.', 'fluent-booking');
116 124 wp_send_json(['errors' => [$message]], 422);
117 125 }
@@ -194,8 +202,35 @@
194 202
195 203 return '';
196 204 }
197 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 +
198 233 public function handleFormSubmitted($entryId, $formDataX, $form)
199 234 {
200 235 $fields = FormFieldsParser::getInputs($form, ['rules', 'raw', 'name']);
201 236
@@ -206,9 +241,9 @@
206 241 if (!$bookingFields) {
207 242 return;
208 243 }
209 244
210 - if (\FluentForm\App\Helpers\Helper::getSubmissionMeta($entryId, 'fluent_booking_id')) {
245 + if (FluentFormHelper::getSubmissionMeta($entryId, 'fluent_booking_id')) {
211 246 return; // Already processed
212 247 }
213 248
214 249 $entry = wpFluent()->table('fluentform_submissions')
@@ -314,8 +349,10 @@
314 349 if ($event->isConfirmationRequired($startDateTime)) {
315 350 $bookingData['status'] = 'pending';
316 351 }
317 352
353 + $bookingData = $this->maybeAddPaymentData($bookingData, $entry);
354 +
318 355 if ($entry->user_id) {
319 356 $bookingData['person_user_id'] = $entry->user_id;
320 357 }
321 358
@@ -331,12 +368,15 @@
331 368 }
332 369 $bookingData['location_details'] = $selectedLocation;
333 370
334 371 try {
335 - $booking = BookingService::createBooking($bookingData, $event);
372 + $customFieldsData = $this->getMappedFieldsData($bookingField, $submittedData, $event);
336 373
337 - \FluentForm\App\Helpers\Helper::getSubmissionMeta($entry->id, 'fluent_booking_id', $booking->id);
374 + $booking = BookingService::createBooking($bookingData, $event, $customFieldsData);
338 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 +
339 379 $fieldData = $submittedData[$fieldName];
340 380 $fieldData['booking_id'] = $booking->id;
341 381
342 382 $submittedData[$fieldName] = (array)$fieldData;
@@ -371,8 +411,265 @@
371 411 }
372 412 }
373 413 }
374 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 +
375 672 public function loadConversationalAsset($question, $field, $form)
376 673 {
377 674 if ('fcal_booking' === $field['element']) {
378 675
@@ -508,9 +805,9 @@
508 805 */
509 806 protected function makeElementId($data, $form)
510 807 {
511 808 if (isset($data['attributes']['name'])) {
512 - $formInstance = \FluentForm\App\Helpers\Helper::$formInstance;
809 + $formInstance = FluentFormHelper::$formInstance;
513 810 if (!empty($data['attributes']['id'])) {
514 811 return $data['attributes']['id'];
515 812 }
516 813 $elementName = $data['attributes']['name'];